26-7-31-1

This commit is contained in:
shuai
2026-07-31 12:27:41 +08:00
commit 9b8b8b57b3
142 changed files with 22408 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
"""影像报告与临床决策建议生成。"""
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"(?<![.\d])\s*([((]?\d+[)).、])\s*", r"\n\1", s)
# 中文段落分隔:句号/分号后跟新意时换行(保留较短从句)
s = re.sub(r"([。;])\s*", r"\1\n", s)
lines = [ln.strip() for ln in s.splitlines() if ln.strip()]
# 合并过碎的短行(如单独标点)
merged: list[str] = []
for ln in lines:
if merged and len(ln) <= 2 and not re.match(r"^[((]?\d+", ln):
merged[-1] = merged[-1] + ln
else:
merged.append(ln)
return "\n".join(merged)
def _normalize_recommendations(text: str) -> 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",
)