更新bug

This commit is contained in:
Your Name
2026-08-17 09:06:49 +08:00
parent 2fc8048844
commit c1eee2d28a
48 changed files with 3646 additions and 1016 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 188 KiB

@@ -43,7 +43,7 @@ from .repository import (
ItemT = TypeVar("ItemT")
DEMO_PERMISSIONS: tuple[str, ...] = (
DEMO_PERMISSIONS: tuple[str, ...] = (
"doctor.appointment/lists",
"doctor.appointment/reception",
"doctor.appointment/notifyAssistant",
@@ -59,12 +59,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/delete",
"tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/aiReports",
"tcm.diagnosis/generateAiReports",
"tcm.diagnosis/editAiReport",
"tcm.diagnosis/aiAnalysis",
"tcm.diagnosis/aiAssistant",
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
"tcm.diagnosis/generateAiReports",
"tcm.diagnosis/editAiReport",
"tcm.diagnosis/aiAnalysis",
"tcm.diagnosis/aiAssistant",
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
"tcm.diagnosis/dailyRecord",
"tcm.diagnosis/chufang",
"tcm.diagnosis/huifang",
@@ -120,12 +120,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/guahao",
"tcm.diagnosis/fillIdCard",
"doctor.medicine/lists",
)
PATIENT_AI_MEDICAL_DISCLAIMER = (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
)
PATIENT_AI_MEDICAL_DISCLAIMER = (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
class DemoDoctorRepository:
@@ -155,12 +155,12 @@ class DemoDoctorRepository:
self._template_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_template_ai_reports()
)
self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_diagnosis_ai_reports()
)
self._patient_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_patient_ai_reports()
)
self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_diagnosis_ai_reports()
)
self._patient_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_patient_ai_reports()
)
self._prescriptions = self._build_prescriptions()
self._medicines = self._build_medicines()
self._patient_orders = self._build_patient_orders()
@@ -987,7 +987,7 @@ class DemoDoctorRepository:
consultation, generated=True, status="success"
)
def edit_diagnosis_ai_report(
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
@@ -1027,155 +1027,155 @@ class DemoDoctorRepository:
target["edited_by"] = 1001
target["edited_time"] = int(now.timestamp())
target["edited_at"] = now.isoformat(sep=" ")
return {
"diagnosis_id": consultation.id,
"report": deepcopy(target),
"can_edit": True,
"can_refresh": True,
}
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Return a deterministic offline answer matching the server DTO."""
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
if task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
with self._lock:
consultation = self._find_consultation(diagnosis_id)
model_key = (
"openai"
if task in {"exam_review", "complication_risk", "guideline_review"}
else "qwen"
)
return {
"diagnosis_id": consultation.id,
"answer": (
f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。"
"请由医生结合四诊与最新检验结果复核。"
),
"model_key": model_key,
"model_label": "千问" if model_key == "qwen" else "OpenAI",
"task": task,
}
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Return one model's structured offline analysis for the demo case."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
with self._lock:
consultation = self._find_consultation(diagnosis_id)
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip()
complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip()
try:
glucose = float(raw.get("fasting_blood_sugar"))
except (TypeError, ValueError):
glucose = None
risk_assessment: list[dict[str, str]] = []
if glucose is not None and glucose >= 7.0:
risk_assessment.append({"label": "高血糖风险", "level": "high"})
elif glucose is not None and glucose >= 6.1:
risk_assessment.append({"label": "血糖波动风险", "level": "medium"})
if "" in complaint or "睡眠" in str(raw.get("remark") or ""):
risk_assessment.append({"label": "睡眠质量风险", "level": "low"})
if not risk_assessment:
risk_assessment.append({"label": "需持续随访", "level": "low"})
treatment = str(
raw.get("treatment_advice")
or raw.get("treatment_principle")
or raw.get("prescription_advice")
or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。"
).strip()
diagnosis_advice = f"{diagnosis};重点复核:{complaint}"
if clean_model == "openai":
diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}"
treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。"
return {
"diagnosis_advice": diagnosis_advice,
"risk_assessment": risk_assessment,
"treatment_advice": treatment,
"model_key": clean_model,
"model_label": "千问" if clean_model == "qwen" else "OpenAI",
"model_name": (
"qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo"
),
"generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "),
}
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every saved patient snapshot without generating a new one."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
with self._lock:
consultation = self._find_patient_consultation(patient_id)
return self._patient_ai_reports_payload(patient_id, consultation)
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one deterministic demo snapshot while retaining every version."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
with self._lock:
consultation = self._find_patient_consultation(patient_id)
current = self._patient_ai_reports.setdefault(patient_id, [])
version = 1 + sum(
str(row.get("model_key") or "").strip().lower() == clean_model
for row in current
)
generated = self._make_patient_ai_report(
patient_id,
consultation,
model=clean_model,
generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "),
version=version,
)
current.append(generated)
return {
"patient_id": patient_id,
"generated_report": deepcopy(generated),
"report": deepcopy(generated),
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": deepcopy(generated.get("source_summary", {})),
}
def list_medicines(
return {
"diagnosis_id": consultation.id,
"report": deepcopy(target),
"can_edit": True,
"can_refresh": True,
}
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Return a deterministic offline answer matching the server DTO."""
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
if task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
with self._lock:
consultation = self._find_consultation(diagnosis_id)
model_key = (
"openai"
if task in {"exam_review", "complication_risk", "guideline_review"}
else "qwen"
)
return {
"diagnosis_id": consultation.id,
"answer": (
f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。"
"请由医生结合四诊与最新检验结果复核。"
),
"model_key": model_key,
"model_label": "千问" if model_key == "qwen" else "OpenAI",
"task": task,
}
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Return one model's structured offline analysis for the demo case."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
with self._lock:
consultation = self._find_consultation(diagnosis_id)
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip()
complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip()
try:
glucose = float(raw.get("fasting_blood_sugar"))
except (TypeError, ValueError):
glucose = None
risk_assessment: list[dict[str, str]] = []
if glucose is not None and glucose >= 7.0:
risk_assessment.append({"label": "高血糖风险", "level": "high"})
elif glucose is not None and glucose >= 6.1:
risk_assessment.append({"label": "血糖波动风险", "level": "medium"})
if "" in complaint or "睡眠" in str(raw.get("remark") or ""):
risk_assessment.append({"label": "睡眠质量风险", "level": "low"})
if not risk_assessment:
risk_assessment.append({"label": "需持续随访", "level": "low"})
treatment = str(
raw.get("treatment_advice")
or raw.get("treatment_principle")
or raw.get("prescription_advice")
or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。"
).strip()
diagnosis_advice = f"{diagnosis};重点复核:{complaint}"
if clean_model == "openai":
diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}"
treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。"
return {
"diagnosis_advice": diagnosis_advice,
"risk_assessment": risk_assessment,
"treatment_advice": treatment,
"model_key": clean_model,
"model_label": "千问" if clean_model == "qwen" else "OpenAI",
"model_name": (
"qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo"
),
"generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "),
}
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every saved patient snapshot without generating a new one."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
with self._lock:
consultation = self._find_patient_consultation(patient_id)
return self._patient_ai_reports_payload(patient_id, consultation)
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one deterministic demo snapshot while retaining every version."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
with self._lock:
consultation = self._find_patient_consultation(patient_id)
current = self._patient_ai_reports.setdefault(patient_id, [])
version = 1 + sum(
str(row.get("model_key") or "").strip().lower() == clean_model
for row in current
)
generated = self._make_patient_ai_report(
patient_id,
consultation,
model=clean_model,
generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "),
version=version,
)
current.append(generated)
return {
"patient_id": patient_id,
"generated_report": deepcopy(generated),
"report": deepcopy(generated),
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": deepcopy(generated.get("source_summary", {})),
}
def list_medicines(
self,
*,
name: str = "",
@@ -3138,23 +3138,23 @@ class DemoDoctorRepository:
return patient
raise RepositoryNotFoundError(f"patient diagnosis {diagnosis_id} not found")
def _find_consultation(self, diagnosis_id: int) -> Consultation:
for consultation in self._consultations:
if consultation.id == diagnosis_id:
return consultation
raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found")
def _find_patient_consultation(self, patient_id: int) -> Consultation:
"""Return the most recent demo consultation for a source patient id."""
matches = [
consultation
for consultation in self._consultations
if consultation.patient_id == patient_id
]
if matches:
return max(matches, key=lambda consultation: consultation.id)
raise RepositoryNotFoundError(f"patient {patient_id} not found")
def _find_consultation(self, diagnosis_id: int) -> Consultation:
for consultation in self._consultations:
if consultation.id == diagnosis_id:
return consultation
raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found")
def _find_patient_consultation(self, patient_id: int) -> Consultation:
"""Return the most recent demo consultation for a source patient id."""
matches = [
consultation
for consultation in self._consultations
if consultation.patient_id == patient_id
]
if matches:
return max(matches, key=lambda consultation: consultation.id)
raise RepositoryNotFoundError(f"patient {patient_id} not found")
def _find_order(self, order_id: int) -> dict[str, Any]:
for order in self._patient_orders:
@@ -3296,6 +3296,9 @@ class DemoDoctorRepository:
"period": "上午",
"status": 1,
"status_desc": "待接诊",
"clinical_diagnosis": "肝郁脾虚证",
"disease_course_text": "病程 3 年",
"fasting_blood_sugar": 6.8,
"diagnosis_confirmed": 1,
"has_prescription": 0,
"remark": "复诊,关注睡眠与口干。",
@@ -3320,6 +3323,9 @@ class DemoDoctorRepository:
"period": "上午",
"status": 4,
"status_desc": "已过号",
"clinical_diagnosis": "痰湿中阻证",
"disease_course_text": "病程 8 年",
"fasting_blood_sugar": 8.6,
"diagnosis_confirmed": 0,
"has_prescription": 1,
"prescription_audit_status": 1,
@@ -3370,6 +3376,9 @@ class DemoDoctorRepository:
"period": "上午",
"status": 3,
"status_desc": "已完成",
"clinical_diagnosis": "气阴两虚证",
"disease_course_text": "病程 5 年",
"fasting_blood_sugar": 7.2,
"diagnosis_confirmed": 1,
"has_prescription": 1,
"prescription_audit_status": 1,
@@ -3872,10 +3881,10 @@ class DemoDoctorRepository:
"is_edited": False,
}
def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next((row for row in self._consultations if row.id == 501), None)
if consultation is None:
return {}
def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next((row for row in self._consultations if row.id == 501), None)
if consultation is None:
return {}
fingerprint = _consultation_fingerprint(consultation)
return {
501: [
@@ -3886,158 +3895,158 @@ class DemoDoctorRepository:
name="qwen3.6-35b",
fingerprint=fingerprint,
generated_at="2026-08-12 09:18:00",
)
]
}
def _patient_ai_reports_payload(
self,
patient_id: int,
consultation: Consultation,
) -> dict[str, Any]:
reports = sorted(
(deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])),
key=lambda row: (
str(row.get("generated_at") or row.get("created_at") or ""),
int(row.get("id") or 0),
),
reverse=True,
)
latest_by_model: dict[str, dict[str, Any] | None] = {
"qwen": None,
"openai": None,
}
for row in reports:
model = str(row.get("model_key") or "").strip().lower()
if model in latest_by_model and latest_by_model[model] is None:
latest_by_model[model] = deepcopy(row)
source_summary = (
deepcopy(reports[0].get("source_summary"))
if reports and isinstance(reports[0].get("source_summary"), Mapping)
else {}
)
return {
"patient_id": patient_id,
"reports": reports,
"latest_by_model": latest_by_model,
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": source_summary,
}
def _make_patient_ai_report(
self,
patient_id: int,
consultation: Consultation,
*,
model: str,
generated_at: str,
version: int,
) -> dict[str, Any]:
report_id = self._next_ai_report_id
self._next_ai_report_id += 1
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
base_diagnosis = str(
raw.get("clinical_diagnosis")
or consultation.clinical_diagnosis
or "待进一步辨证"
).strip()
complaint = str(
raw.get("chief_complaint")
or consultation.chief_complaint
or "主诉信息待完善"
).strip()
is_openai = model == "openai"
diagnosis = (
f"{base_diagnosis};建议结合客观检查复核:{complaint}"
if is_openai
else f"{base_diagnosis};重点结合四诊复核:{complaint}"
)
risks = [
{"label": "血糖波动风险", "level": "medium"},
{"label": "睡眠质量风险", "level": "low"},
]
treatment = (
"复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。"
if is_openai
else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。"
)
disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER
source_summary = {
"diagnosis_count": 1,
"doctor_note_count": 0,
"tracking_note_count": 0,
"blood_record_count": 0,
"diet_record_count": 0,
"exercise_record_count": 0,
"im_message_count": 0,
"wechat_message_count": 0,
"call_record_count": 0,
"transcript_segment_count": 0,
"recording_asset_count": 0,
"source_record_count": 1,
"snapshot_complete": True,
"may_be_truncated": False,
"analysis_chunk_count": 1,
"analysis_reduction_rounds": 0,
"analyzed_source_bytes": 0,
"analysis_complete": True,
}
structured = {
"diagnosis": diagnosis,
"risk_assessment": risks,
"treatment_advice": treatment,
"disclaimer": disclaimer,
}
return {
"id": report_id,
"report_id": report_id,
"patient_id": patient_id,
"diagnosis_id": consultation.id,
"model_key": model,
"model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo",
"model_label": "OpenAI" if is_openai else "千问",
"version": version,
"report": structured,
"content": json.dumps(structured, ensure_ascii=False),
"diagnosis": diagnosis,
"diagnosis_advice": diagnosis,
"risk_assessment": deepcopy(risks),
"treatment_advice": treatment,
"disclaimer": disclaimer,
"source_hash": _consultation_fingerprint(consultation),
"source_summary": source_summary,
"generated_at": generated_at,
"created_at": generated_at,
"admin_id": 1001,
"department_id": 10,
"department_name": "演示中医院",
}
def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next(
(row for row in self._consultations if row.patient_id == 301),
None,
)
if consultation is None:
return {}
reports: list[dict[str, Any]] = []
for version, generated_at in (
(1, "2026-08-12 09:18:00"),
(2, "2026-08-13 15:42:00"),
):
for model in ("qwen", "openai"):
reports.append(
self._make_patient_ai_report(
301,
consultation,
model=model,
generated_at=generated_at,
version=version,
)
)
return {301: reports}
def _build_templates(self) -> list[PrescriptionTemplate]:
)
]
}
def _patient_ai_reports_payload(
self,
patient_id: int,
consultation: Consultation,
) -> dict[str, Any]:
reports = sorted(
(deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])),
key=lambda row: (
str(row.get("generated_at") or row.get("created_at") or ""),
int(row.get("id") or 0),
),
reverse=True,
)
latest_by_model: dict[str, dict[str, Any] | None] = {
"qwen": None,
"openai": None,
}
for row in reports:
model = str(row.get("model_key") or "").strip().lower()
if model in latest_by_model and latest_by_model[model] is None:
latest_by_model[model] = deepcopy(row)
source_summary = (
deepcopy(reports[0].get("source_summary"))
if reports and isinstance(reports[0].get("source_summary"), Mapping)
else {}
)
return {
"patient_id": patient_id,
"reports": reports,
"latest_by_model": latest_by_model,
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": source_summary,
}
def _make_patient_ai_report(
self,
patient_id: int,
consultation: Consultation,
*,
model: str,
generated_at: str,
version: int,
) -> dict[str, Any]:
report_id = self._next_ai_report_id
self._next_ai_report_id += 1
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
base_diagnosis = str(
raw.get("clinical_diagnosis")
or consultation.clinical_diagnosis
or "待进一步辨证"
).strip()
complaint = str(
raw.get("chief_complaint")
or consultation.chief_complaint
or "主诉信息待完善"
).strip()
is_openai = model == "openai"
diagnosis = (
f"{base_diagnosis};建议结合客观检查复核:{complaint}"
if is_openai
else f"{base_diagnosis};重点结合四诊复核:{complaint}"
)
risks = [
{"label": "血糖波动风险", "level": "medium"},
{"label": "睡眠质量风险", "level": "low"},
]
treatment = (
"复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。"
if is_openai
else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。"
)
disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER
source_summary = {
"diagnosis_count": 1,
"doctor_note_count": 0,
"tracking_note_count": 0,
"blood_record_count": 0,
"diet_record_count": 0,
"exercise_record_count": 0,
"im_message_count": 0,
"wechat_message_count": 0,
"call_record_count": 0,
"transcript_segment_count": 0,
"recording_asset_count": 0,
"source_record_count": 1,
"snapshot_complete": True,
"may_be_truncated": False,
"analysis_chunk_count": 1,
"analysis_reduction_rounds": 0,
"analyzed_source_bytes": 0,
"analysis_complete": True,
}
structured = {
"diagnosis": diagnosis,
"risk_assessment": risks,
"treatment_advice": treatment,
"disclaimer": disclaimer,
}
return {
"id": report_id,
"report_id": report_id,
"patient_id": patient_id,
"diagnosis_id": consultation.id,
"model_key": model,
"model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo",
"model_label": "OpenAI" if is_openai else "千问",
"version": version,
"report": structured,
"content": json.dumps(structured, ensure_ascii=False),
"diagnosis": diagnosis,
"diagnosis_advice": diagnosis,
"risk_assessment": deepcopy(risks),
"treatment_advice": treatment,
"disclaimer": disclaimer,
"source_hash": _consultation_fingerprint(consultation),
"source_summary": source_summary,
"generated_at": generated_at,
"created_at": generated_at,
"admin_id": 1001,
"department_id": 10,
"department_name": "演示中医院",
}
def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next(
(row for row in self._consultations if row.patient_id == 301),
None,
)
if consultation is None:
return {}
reports: list[dict[str, Any]] = []
for version, generated_at in (
(1, "2026-08-12 09:18:00"),
(2, "2026-08-13 15:42:00"),
):
for model in ("qwen", "openai"):
reports.append(
self._make_patient_ai_report(
301,
consultation,
model=model,
generated_at=generated_at,
version=version,
)
)
return {301: reports}
def _build_templates(self) -> list[PrescriptionTemplate]:
return [
PrescriptionTemplate.from_dict(
{
@@ -648,7 +648,7 @@ class AppointmentsPage(QWidget):
root = QVBoxLayout(self)
root.setContentsMargins(22, 7, 6, 8)
root.setSpacing(4)
header = PageHeader("患者列表")
header = PageHeader("问诊列表")
header.title_label.hide()
header.subtitle_label.hide()
root.addWidget(header)
File diff suppressed because it is too large Load Diff
+22 -30
View File
@@ -86,7 +86,7 @@ class NavigationItem:
NAVIGATION = (
NavigationItem(
"appointments",
"接诊台",
"问诊列表",
"",
AppointmentsPage,
("doctor.appointment/lists",),
@@ -301,8 +301,8 @@ def _resolve_navigation(
continue
if not _canonical_allowed(permissions, item.permissions[0]):
continue
if item.key in {"appointments", "patients"}:
# Keep the two product-facing navigation titles stable even when
if item.key in {"appointments", "reception", "patients"}:
# Keep the product-facing navigation titles stable even when
# the server still carries an older menu label.
title = item.title
else:
@@ -549,7 +549,7 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
class _ShellBrandMark(QWidget):
"""Paint the supplied indigo pulse mark without a font glyph dependency."""
"""Paint the supplied indigo M mark without a font-glyph asset."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
@@ -562,26 +562,12 @@ class _ShellBrandMark(QWidget):
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
painter.setBrush(gradient)
painter.drawRoundedRect(rect, 10, 10)
pen = QPen(QColor("#FFFFFF"), 1.8)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
y = self.height() / 2
painter.drawPolyline(
QPolygonF(
[
QPointF(8, y + 2),
QPointF(11, y + 2),
QPointF(14, y - 6),
QPointF(18, y + 7),
QPointF(22, y - 4),
QPointF(25, y + 2),
QPointF(self.width() - 8, y + 2),
]
)
)
painter.setPen(QColor("#FFFFFF"))
font = QFont(painter.font())
font.setPixelSize(18)
font.setWeight(QFont.Weight.Bold)
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
class _AssistantRobot(QWidget):
@@ -837,7 +823,7 @@ class ShellWindow(QMainWindow):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("甄养堂 · AI 问诊助手")
self.setWindowTitle("甄养堂 · 问诊中心")
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
self.repository = repository
@@ -1032,7 +1018,7 @@ class ShellWindow(QMainWindow):
brand_copy_layout = QVBoxLayout(self.brand_copy)
brand_copy_layout.setContentsMargins(0, 0, 0, 0)
brand_copy_layout.setSpacing(1)
self.brand_name = QLabel("AI问诊助手", self.brand_copy)
self.brand_name = QLabel("问诊中心", self.brand_copy)
self.brand_name.setObjectName("ShellBrandName")
brand_copy_layout.addWidget(self.brand_name)
self.brand_subtitle = QLabel("糖尿病专科版", self.brand_copy)
@@ -1083,7 +1069,7 @@ class ShellWindow(QMainWindow):
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.model_label = QLabel("模型:服务端自动匹配 ", sidebar)
self.model_label = QLabel("模型:GPT-4o 医疗版 ", sidebar)
self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76)
@@ -1130,11 +1116,15 @@ class ShellWindow(QMainWindow):
font-size: 10px;
}
QPushButton#ShellAiEntry {
min-height: 34px;
padding: 0 10px;
min-width: 38px;
max-width: 38px;
min-height: 38px;
max-height: 38px;
padding: 0;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 9px;
font-weight: 700;
}
QPushButton#ShellAiEntry:hover { background-color: #EEF1FF; }
@@ -1220,11 +1210,13 @@ class ShellWindow(QMainWindow):
self.context_label.hide()
layout.addStretch(1)
self.ai_top_button = QPushButton("AI 助手", topbar)
self.ai_top_button = QPushButton("", topbar)
self.ai_top_button.setObjectName("ShellAiEntry")
self.ai_top_button.setIcon(_painted_shell_icon("ai", 18))
self.ai_top_button.setIconSize(QSize(18, 18))
self.ai_top_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_top_button.setToolTip("AI 助手")
self.ai_top_button.setAccessibleName("AI 助手")
self.ai_top_button.clicked.connect(self._open_ai_assistant)
layout.addWidget(self.ai_top_button)
@@ -191,6 +191,7 @@ def test_saved_history_is_rendered_without_automatic_generation(
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
assert "第 2 版" in page.ai_analysis_snapshot_meta.text()
assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER
assert not page.ai_analysis_disclaimer.isVisibleTo(page)
assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton"
assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton"
+195 -6
View File
@@ -10,7 +10,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
@@ -129,7 +129,7 @@ def test_queue_status_badge_is_not_clipped_in_narrow_panel(
{
"patient_name": "张蒙",
"status": 1,
"status_desc": "待接诊",
"status_desc": "问诊中",
"appointment_time": "12:45:00",
"gender": 1,
"age": 36,
@@ -296,6 +296,33 @@ def test_queue_uses_admin_same_day_contract(
application.processEvents()
def test_queue_date_picker_filters_the_selected_day(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.show()
application.processEvents()
page.queue_date_button.click()
calendar = page.queue_date_button.calendarWidget()
assert calendar.isVisible()
page.queue_date_button._pick_date(QDate(2026, 8, 1))
assert not calendar.isVisible()
assert page.queue_date_button.text() == "2026-08-01"
assert page._queue_date == "2026-08-01"
assert calls[-1]["start_date"] == "2026-08-01"
assert calls[-1]["end_date"] == "2026-08-01"
page.close()
application.processEvents()
def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
application: QApplication,
immediate_async: None,
@@ -630,14 +657,164 @@ def test_queue_load_more_accumulates_to_total_boundary(
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_list.count() == 15
assert page.load_more_button.isVisibleTo(page)
assert page._queue_has_more()
assert not hasattr(page, "load_more_button")
assert not hasattr(page, "add_patient_button")
page._load_more()
assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
assert page.load_more_button.isHidden()
assert not page._queue_has_more()
page.close()
application.processEvents()
def test_silent_poll_keeps_already_loaded_queue_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 28)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(dict(kwargs))
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 27
assert page._queue_page == 2
calls.clear()
page.refresh(silent=True)
assert page.queue_list.count() == 27
assert page._queue_page == 2
assert calls[-1]["page_no"] == 1
assert calls[-1]["page_size"] >= 27
page.close()
application.processEvents()
def test_queue_shows_loading_indicator_while_page_request_is_open(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
class Repository:
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{"id": 1, "patient_name": "加载患者", "status": 1},
],
"count": 16,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 1001,
"status": 1,
},
"diagnosis": {"id": 2001, "patient_id": 1001},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_loading_indicator.isVisibleTo(page)
assert page.queue_loading_indicator.label.text() == "正在加载…"
assert page.queue_loading_indicator.spinner._timer.isActive()
queued_async[0]["on_success"](
{
"lists": [{"id": 1, "patient_name": "加载患者", "status": 1}],
"count": 16,
}
)
queued_async[0]["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
assert not page.queue_loading_indicator.spinner._timer.isActive()
page._load_more()
assert page.queue_loading_indicator.isVisibleTo(page)
load_more_job = queued_async[-1]
load_more_job["on_success"](
{
"lists": [{"id": 2, "patient_name": "第二页患者", "status": 1}],
"count": 16,
}
)
load_more_job["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
page.close()
application.processEvents()
def test_queue_scrolls_to_bottom_loads_next_page(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.resize(1280, 640)
page.show()
application.processEvents()
assert page.queue_list.count() == 15
scrollbar = page.queue_list.verticalScrollBar()
assert scrollbar.maximum() > 0
scrollbar.setValue(scrollbar.maximum())
application.processEvents()
assert [call["page_no"] for call in calls] == [1, 2]
assert page.queue_list.count() == 22
page.close()
application.processEvents()
@@ -871,6 +1048,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
] == [("千问", "qwen"), ("OpenAI", "openai")]
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_state == "success"
assert page.ai_analysis_stack.currentWidget() is page.ai_analysis_content_page
assert page.ai_summary_label.isVisible()
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert page.ai_treatment_label.text().startswith("建议调整降糖方案")
assert page.ai_summary_label.textFormat() == Qt.TextFormat.PlainText
@@ -887,7 +1066,7 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
assert [button.text() for button in page.ai_prompt_buttons] == [
"基于当前病史,下一步检查建议?",
"该患者的用药调整建议?",
"糖尿病教育要点?",
"糖尿病教育要点有哪些",
"并发症筛查建议?",
]
assert page.ai_send_button.text() == ""
@@ -915,10 +1094,12 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry()
assert 235 <= left.height() <= 260
assert 470 <= left.height() <= 520
assert left.height() == right.height()
assert 0 <= right.left() - left.right() - 1 <= 2
assert abs(left.width() * 5 - right.width() * 4) <= 10
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48
calls_before_switch = list(analysis_calls)
page.ai_analysis_model_selector.setCurrentIndex(
@@ -931,6 +1112,14 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
)
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert analysis_calls == calls_before_switch
assert page.detail_scroll.objectName() == "ReceptionDetailScroll"
assert (
page.detail_scroll.verticalScrollBarPolicy()
== Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
page.resize(1494, 620)
application.processEvents()
assert page.detail_scroll.verticalScrollBar().maximum() > 0
page.close()
application.processEvents()
+3 -3
View File
@@ -73,7 +73,7 @@ def test_appointments_navigation_is_named_reception_and_always_first() -> None:
)
assert [(item.key, title) for item, title in resolved] == [
("appointments", "接诊台"),
("appointments", "问诊列表"),
("patients", "我的患者"),
]
@@ -86,7 +86,7 @@ def shell_window(
navigation = [
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
for key, title, glyph, permission in (
("appointments", "接诊台", "", "doctor.appointment/lists"),
("appointments", "问诊列表", "", "doctor.appointment/lists"),
("reception", "接诊台", "", "doctor.appointment/lists"),
(
"prescription_library",
@@ -164,7 +164,7 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "服务端自动匹配" in shell_window.model_label.text()
assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""