Files
Shixun/ai-service/app/services/report_generator.py
T
2026-07-31 12:27:41 +08:00

418 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""影像报告与临床决策建议生成。"""
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",
)