26-7-31-1
This commit is contained in:
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user