"""影像报告与临床决策建议生成。""" from __future__ import annotations import logging import re from typing import Any from app.schemas.models import ( DecisionRequest, DecisionResponse, Detection, ImagingReportRequest, ImagingReportResponse, RiskItem, SourceRef, ) from app.services.llm_client import get_llm from app.services.rag_pipeline import get_rag logger = logging.getLogger(__name__) STUDY_LABEL = { "X_RAY": "X 光", "CT": "CT", "MRI": "MRI", "ULTRASOUND": "超声", } def build_imaging_texts( study_type: str, body_part: str, detections: list[Detection], mode: str, ) -> tuple[str, str, str, float]: """返回 findings, diagnosis, recommendations, confidence。""" st = STUDY_LABEL.get(study_type.upper(), study_type) part = body_part or "相关部位" if not detections: findings = f"{st}检查({part}):影像质量可评估,未见明确异常密度/信号灶。" diagnosis = f"{part}{st}未见明显异常" rec = "建议结合临床,必要时复查或进一步检查。" return findings, diagnosis, rec, 0.82 lines = [] for d in detections: name = d.label_zh or d.label lines.append( f"- 可见{name}样改变,框选区域约 ({int(d.bbox[0])},{int(d.bbox[1])})-" f"({int(d.bbox[2])},{int(d.bbox[3])}),模型置信度 {d.confidence:.0%}" ) findings = f"{st}检查({part})AI 辅助读片所见:\n" + "\n".join(lines) top = max(detections, key=lambda x: x.confidence) diagnosis = f"{part}可疑{top.label_zh or top.label},建议专科医师复核" rec = _rec_for_label(top.label) conf = sum(d.confidence for d in detections) / len(detections) if mode == "demo": findings += "\n(演示模式:检测框由 YOLO 演示引擎生成,非临床验证模型输出)" return findings, diagnosis, rec, round(min(0.98, conf), 4) def _rec_for_label(label: str) -> str: mapping = { "opacity": "建议结合血常规/炎症指标,必要时抗感染治疗并短期复查胸片。", "nodule": "建议按结节指南分层管理,3 个月后复查 CT,必要时多学科会诊。", "fracture": "建议骨科评估,必要时制动/固定,复查局部 X 光。", "effusion": "建议评估积液性质,必要时穿刺或超声随访。", "lesion": "建议结合临床与实验室检查,必要时增强扫描或专科转诊。", "mass": "建议进一步定性检查,排除占位性病变,及时专科就诊。", "calcification": "多为良性钙化可能,建议定期随访观察。", } return mapping.get(label, "建议专科医师综合临床资料判读,制定个体化方案。") def _normalize_multiline(text: str) -> str: """把挤成一段的长文尽量拆成可读多行(句号/分号后换行,编号建议分行)。""" if not text: return "" s = str(text).strip() # 已有明显换行则只做空白整理 if "\n" in s and s.count("\n") >= 2: return "\n".join(line.strip() for line in s.splitlines() if line.strip()) # 编号建议:1. / 1、 / (1) 前换行 s = re.sub(r"(? str: """建议统一为多行编号列表。""" if not text: return "" s = str(text).strip() # 已是多行编号 if re.search(r"(?m)^\s*[((]?\d+[\.、))]", s): return "\n".join(ln.strip() for ln in s.splitlines() if ln.strip()) # 行内编号:1. / 1、 / (1) items = re.findall( r"[((]?([1-9]\d?)[\.、))]\s*([^((]*?)(?=(?:[((]?[1-9]\d?[\.、))])|$)", s, ) cleaned = [(idx, t.strip(" ;;。 \t")) for idx, t in items if t.strip(" ;;。 \t")] if len(cleaned) >= 2: return "\n".join(f"{i}. {t}" for i, (_, t) in enumerate(cleaned, 1)) # 按分号切成条目 chunks = [c.strip(" ;;。") for c in re.split(r"[;;]", s) if c.strip(" ;;。")] if len(chunks) >= 2: return "\n".join(f"{i}. {c}" for i, c in enumerate(chunks, 1)) return s def make_full_report( study_type: str, body_part: str, findings: str, impression: str, recommendations: str, patient_summary: str = "", ) -> str: """结构化完整报告:固定四段,便于前端分段渲染。""" st = STUDY_LABEL.get(study_type.upper(), study_type) findings_n = _normalize_multiline(findings) impression_n = _normalize_multiline(impression) or impression rec_n = _normalize_recommendations(recommendations) or recommendations header = [ "【影像诊断报告(AI 辅助)】", f"检查类型:{st}", f"检查部位:{body_part or '—'}", ] if patient_summary: header.append(f"临床摘要:{patient_summary}") sections = [ "\n".join(header), "一、影像所见\n" + (findings_n or "—"), "二、诊断印象\n" + (impression_n or "—"), "三、建议\n" + (rec_n or "—"), "四、声明\n本报告由 AI 辅助生成,仅供临床参考,需执业医师审核,不能替代正式报告。", ] return "\n\n".join(sections) def generate_imaging_report(req: ImagingReportRequest) -> ImagingReportResponse: llm = get_llm() if llm.enabled: try: det_lines = [] for d in req.detections: name = d.label_zh or d.label box = ",".join(str(int(x)) for x in d.bbox[:4]) if d.bbox else "-" det_lines.append(f"{name} conf={d.confidence:.0%} box=[{box}]") data = llm.chat_json( [ { "role": "system", "content": ( "你是三甲医院影像科辅助报告生成器。" "必须只输出一个 JSON 对象(不要 markdown 代码块),字段:" "findings(影像所见:多段文字,用换行分隔;先写检查方法与部位," "再写病灶描述,再写其余部位阴性所见,勿写成一整段)、" "impression(诊断印象:1~3 句,可换行)、" "recommendations(建议:必须用换行的编号列表,如 " "'1. ...\\n2. ...\\n3. ...',含进一步检查/随访/会诊)、" "full_report 不要输出(由系统按分段模板拼接)。" "依据 YOLO 检测结果撰写,专业简洁;" "明确写明需执业医师审核,不能替代正式报告。" "禁止编造未提供的患者检验结果。" ), }, { "role": "user", "content": ( f"检查类型={req.study_type}\n" f"检查部位={req.body_part or '未注明'}\n" f"患者摘要={req.patient_summary or '无'}\n" f"规则初诊={req.preliminary_diagnosis}\n" f"规则所见={req.findings}\n" f"检测列表:\n" + ("\n".join(det_lines) if det_lines else "(无检出)") ), }, ] ) findings = _normalize_multiline(str(data.get("findings") or req.findings).strip()) impression = _normalize_multiline( str( data.get("impression") or data.get("preliminary_diagnosis") or req.preliminary_diagnosis ).strip() ) rec = _normalize_recommendations( str(data.get("recommendations") or "建议专科医师复核。").strip() ) # 始终用分段模板拼完整报告,避免 LLM 输出一整段墙文本 full = make_full_report( req.study_type, req.body_part, findings, impression, rec, req.patient_summary ) model_name = llm.info().get("model") or "llm" logger.info("影像报告已由 LLM 生成 model=%s", model_name) return ImagingReportResponse( findings=findings, impression=impression, recommendations=rec, full_report=full, model_version=f"report-llm:{model_name}", ) except Exception as e: logger.warning("影像报告 LLM 失败,回退模板: %s", e) logger.info("影像报告使用模板模式(LLM 未启用或调用失败) llm_enabled=%s", llm.enabled) # recommendations 留空,由 imaging API 回退到 build_imaging_texts 的按病灶建议 full = make_full_report( req.study_type, req.body_part, req.findings, req.preliminary_diagnosis, "建议结合临床,由影像科/临床医师最终签发。", req.patient_summary, ) return ImagingReportResponse( findings=req.findings, impression=req.preliminary_diagnosis, recommendations="", full_report=full, model_version="report-template", ) def generate_decision(req: DecisionRequest) -> DecisionResponse: rag = get_rag() query = " ".join( x for x in [req.diagnosis, req.chief_complaint, req.history, req.imaging_summary] if x ).strip() or "常见病辅助决策" sources = rag.retrieve(query, top_k=4) context = rag.build_context(sources) patient = req.patient patient_desc = "" if patient: patient_desc = f"年龄={patient.age} 性别={patient.gender} 姓名={patient.name or ''}" llm = get_llm() if llm.enabled: try: data = llm.chat_json( [ { "role": "system", "content": ( "你是临床辅助决策系统。输出严格 JSON,字段:" "treatment_suggestions(数组,元素含 title,description,confidence)," "medication_suggestions(数组,元素含 name,dosage,category,confidence)," "nursing_advice(字符串数组)," "follow_up_plan(字符串数组)," "risks(数组,元素含 type,description,level,confidence)," "conflicts(字符串数组)," "full_text(字符串)。" "必须提醒需医师审核;勿编造不存在的检查结果。" ), }, { "role": "user", "content": ( f"患者:{patient_desc}\n" f"主诉:{req.chief_complaint}\n" f"病史:{req.history}\n" f"查体:{req.exam_findings}\n" f"诊断:{req.diagnosis}\n" f"用药:{req.medications}\n" f"影像摘要:{req.imaging_summary}\n" f"知识库:\n{context or '无'}" ), }, ] ) return _map_decision(data, sources, engine="fastapi-rag+llm") except Exception as e: logger.warning("决策 LLM 失败: %s", e) return _template_decision(req, sources) def _map_decision(data: dict[str, Any], sources: list[SourceRef], engine: str) -> DecisionResponse: risks = [] for r in data.get("risks") or []: if isinstance(r, dict): risks.append( RiskItem( type=str(r.get("type") or "风险"), description=str(r.get("description") or ""), level=str(r.get("level") or "中"), confidence=float(r.get("confidence") or 0.8), ) ) return DecisionResponse( treatment_suggestions=list(data.get("treatment_suggestions") or []), medication_suggestions=list(data.get("medication_suggestions") or []), nursing_advice=[str(x) for x in (data.get("nursing_advice") or [])], follow_up_plan=[str(x) for x in (data.get("follow_up_plan") or [])], risks=risks, conflicts=[str(x) for x in (data.get("conflicts") or [])], sources=sources, full_text=str(data.get("full_text") or ""), engine=engine, ) def _template_decision(req: DecisionRequest, sources: list[SourceRef]) -> DecisionResponse: dx = req.diagnosis or "" treatments: list[dict[str, Any]] = [] meds: list[dict[str, Any]] = [] nursing: list[str] = [] follow: list[str] = [] risks: list[RiskItem] = [] if "高血压" in dx: treatments = [ {"title": "生活方式干预", "description": "低盐饮食,适量有氧运动,控制体重,戒烟限酒", "confidence": 0.95}, {"title": "药物治疗", "description": "可考虑 ACEI/ARB 或 CCB 作为一线方案(需医师确认)", "confidence": 0.9}, ] meds = [ {"name": "氨氯地平", "dosage": "5mg qd", "category": "钙通道阻滞剂", "confidence": 0.9}, {"name": "缬沙坦", "dosage": "80mg qd", "category": "ARB", "confidence": 0.88}, ] nursing = ["监测血压并记录", "宣教服药依从性", "观察头晕、乏力等低血压症状"] follow = ["1–2 周门诊复查血压", "评估靶器官损害相关检查"] elif "糖尿病" in dx: treatments = [ {"title": "饮食运动", "description": "控制总热量与碳水,规律运动", "confidence": 0.95}, {"title": "降糖治疗", "description": "二甲双胍等一线方案需结合肾功能与禁忌", "confidence": 0.9}, ] meds = [{"name": "二甲双胍", "dosage": "0.5g tid", "category": "双胍类", "confidence": 0.92}] nursing = ["血糖监测指导", "足部护理宣教", "低血糖识别与处理"] follow = ["2–4 周复诊评估血糖", "定期查 HbA1c"] elif "肺炎" in dx or "阴影" in dx: treatments = [ {"title": "抗感染", "description": "根据社区/医院获得性肺炎指南选择抗生素", "confidence": 0.88}, {"title": "支持治疗", "description": "休息、补液、必要时氧疗", "confidence": 0.92}, ] meds = [{"name": "阿莫西林", "dosage": "0.5g tid", "category": "青霉素类", "confidence": 0.85}] nursing = ["监测体温与呼吸", "叩背排痰指导", "隔离防护宣教(如需要)"] follow = ["3–5 天评估疗效", "必要时复查胸片"] elif "结节" in dx: treatments = [ {"title": "分层随访", "description": "按结节大小与特征选择随访或进一步检查", "confidence": 0.9}, ] nursing = ["戒烟宣教", "避免焦虑,说明随访意义"] follow = ["3 个月复查 CT", "出现咯血/胸痛及时就诊"] else: treatments = [ {"title": "进一步评估", "description": "完善相关检查以明确诊断", "confidence": 0.85}, {"title": "对症处理", "description": "根据症状给予相应支持治疗", "confidence": 0.88}, ] nursing = ["观察病情变化", "用药与生活方式宣教"] follow = ["按病情 1–2 周复诊", "出现加重症状及时急诊"] if req.patient and req.patient.age and req.patient.age >= 65: risks.append( RiskItem( type="高龄风险", description="高龄患者需注意剂量调整、跌倒与多药联用风险", level="高", confidence=0.85, ) ) if "高血压" in dx and req.patient and req.patient.age and req.patient.age > 60: risks.append( RiskItem( type="心血管风险", description="高血压合并高龄,心血管事件风险增加", level="中", confidence=0.8, ) ) conflicts: list[str] = [] meds_text = req.medications or "" if "华法林" in meds_text and "阿司匹林" in meds_text: conflicts.append("警告:华法林与阿司匹林联合使用可能增加出血风险") if "ACEI" in meds_text and "保钾" in meds_text: conflicts.append("注意:ACEI 与保钾利尿剂联用可能致高钾血症") src_hint = "" if sources: src_hint = "\n知识库参考:" + ";".join(s.title for s in sources[:3]) full = ( f"诊断相关辅助建议(规则+RAG):{dx or '未明确'}\n" f"治疗:{'; '.join(t['title'] for t in treatments)}\n" f"护理:{';'.join(nursing)}\n" f"随访:{';'.join(follow)}" f"{src_hint}\n" "(模板模式,可配置 LLM_API_KEY 启用大模型增强)" ) return DecisionResponse( treatment_suggestions=treatments, medication_suggestions=meds, nursing_advice=nursing, follow_up_plan=follow, risks=risks, conflicts=conflicts, sources=sources, full_text=full, engine="fastapi-rag-template", )