|
|
@@ -1,20 +1,31 @@
|
|
|
package com.emoon.tongue.service.impl;
|
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.emoon.tongue.domain.entity.MedicalData;
|
|
|
+import com.emoon.tongue.domain.entity.TongueSyndromeMapping;
|
|
|
import com.emoon.tongue.domain.vo.TongueDiagnosisDetailVo;
|
|
|
import com.emoon.tongue.domain.vo.TongueDiagnosisInfo;
|
|
|
+import com.emoon.tongue.mapper.MedicalDataMapper;
|
|
|
+import com.emoon.tongue.mapper.TongueSyndromeMappingMapper;
|
|
|
import com.emoon.tongue.service.ILLMService;
|
|
|
-import com.emoon.tongue.service.ai.TongueAiDiagnosisProvider;
|
|
|
import com.emoon.tongue.service.ITongueAiDiagnosisService;
|
|
|
import com.emoon.tongue.service.ITongueDiagnosisService;
|
|
|
+import com.emoon.tongue.service.ai.TongueAiDiagnosisProvider;
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import jakarta.annotation.PostConstruct;
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.core.io.ClassPathResource;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
+import java.io.BufferedReader;
|
|
|
+import java.io.InputStreamReader;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.List;
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
|
/**
|
|
|
* 舌诊AI诊断服务实现类
|
|
|
@@ -30,9 +41,14 @@ public class TongueAiDiagnosisServiceImpl implements ITongueAiDiagnosisService {
|
|
|
private final ITongueDiagnosisService tongueDiagnosisService;
|
|
|
private final List<TongueAiDiagnosisProvider> providers;
|
|
|
private final ILLMService llmService;
|
|
|
+ private final MedicalDataMapper medicalDataMapper;
|
|
|
+ private final TongueSyndromeMappingMapper tongueSyndromeMappingMapper;
|
|
|
|
|
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
|
|
+ /** 启动时加载的候选诊断列表(来自 diagnosis-list.csv) */
|
|
|
+ private List<String> diagnosisCandidates = new ArrayList<>();
|
|
|
+
|
|
|
@Value("${ai.mock-flag:true}")
|
|
|
private boolean mockFlag;
|
|
|
|
|
|
@@ -45,22 +61,44 @@ public class TongueAiDiagnosisServiceImpl implements ITongueAiDiagnosisService {
|
|
|
@Value("${ai.llm.model:}")
|
|
|
private String llmModel;
|
|
|
|
|
|
- /**
|
|
|
- * 二次诊断(病名推断)专用模型;未配置则回退到 ai.llm.model
|
|
|
- */
|
|
|
@Value("${ai.llm.diagnosis-model:${ai.llm.model:}}")
|
|
|
private String diagnosisModel;
|
|
|
|
|
|
@Value("${ai.llm.max-tokens:2048}")
|
|
|
private Integer llmMaxTokens;
|
|
|
|
|
|
+ // ==================== 资源加载 ====================
|
|
|
+
|
|
|
+ @PostConstruct
|
|
|
+ public void loadResources() {
|
|
|
+ loadDiagnosisCandidates();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void loadDiagnosisCandidates() {
|
|
|
+ try {
|
|
|
+ ClassPathResource resource = new ClassPathResource("data/diagnosis-list.csv");
|
|
|
+ try (BufferedReader reader = new BufferedReader(
|
|
|
+ new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
|
|
+ diagnosisCandidates = reader.lines()
|
|
|
+ .map(String::trim)
|
|
|
+ .filter(line -> !line.isEmpty() && !"诊断".equals(line))
|
|
|
+ .distinct()
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+ log.info("诊断候选列表加载完成,共 {} 条: {}", diagnosisCandidates.size(), diagnosisCandidates);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("加载 diagnosis-list.csv 失败,诊断推断步骤将退化为保留原值", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 核心诊断入口 ====================
|
|
|
+
|
|
|
@Override
|
|
|
public TongueDiagnosisInfo performDiagnosis(String patientId, String projectId) {
|
|
|
log.info("开始进行舌诊AI诊断,患者ID: {}, 医院编码: {}, Mock模式: {}, provider: {}",
|
|
|
patientId, projectId, mockFlag, providerName);
|
|
|
|
|
|
try {
|
|
|
- // 获取舌诊详情(包含图片URL和患者信息)
|
|
|
TongueDiagnosisDetailVo detail = tongueDiagnosisService.getDiagnosisDetail(patientId, projectId);
|
|
|
|
|
|
if (detail == null || detail.getTongueImage() == null) {
|
|
|
@@ -71,11 +109,9 @@ public class TongueAiDiagnosisServiceImpl implements ITongueAiDiagnosisService {
|
|
|
TongueDiagnosisInfo result;
|
|
|
|
|
|
if (mockFlag) {
|
|
|
- // Mock模式:使用模拟结果
|
|
|
log.info("使用Mock模式生成诊断结果");
|
|
|
result = TongueDiagnosisInfo.generateMockResult();
|
|
|
} else {
|
|
|
- // 真实AI模式:按 provider 路由
|
|
|
TongueAiDiagnosisProvider provider = selectProvider(providerName);
|
|
|
log.info("使用真实AI模式进行诊断,provider={}", provider.provider());
|
|
|
result = provider.diagnose(detail);
|
|
|
@@ -86,9 +122,15 @@ public class TongueAiDiagnosisServiceImpl implements ITongueAiDiagnosisService {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- // 二段式推理:舌象模型不负责 diagnosis,这里强制用“舌象结果 + 主诉”调用对话模型补齐 diagnosis
|
|
|
+ log.info("专精模型返回完毕 → 舌象: {}, 证候: {}, 诊断: {}, 处方: {}",
|
|
|
+ result.getTongueManifestation(),
|
|
|
+ result.getSyndrome(),
|
|
|
+ result.getDiagnosis(),
|
|
|
+ result.getPrescription() != null ? result.getPrescription().substring(0, Math.min(60, result.getPrescription().length())) + "..." : "null");
|
|
|
+
|
|
|
+ // 三步增强:用 235b 大模型重新推断 诊断/证候/处方 并替换
|
|
|
if (!mockFlag) {
|
|
|
- tryFillDiagnosisByDialogueModel(detail, result);
|
|
|
+ enhanceDiagnosisResults(detail, result);
|
|
|
}
|
|
|
|
|
|
log.info("舌诊AI诊断完成,置信度: {}", result.getOverallConfidence());
|
|
|
@@ -100,204 +142,343 @@ public class TongueAiDiagnosisServiceImpl implements ITongueAiDiagnosisService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private void tryFillDiagnosisByDialogueModel(TongueDiagnosisDetailVo detail, TongueDiagnosisInfo info) {
|
|
|
- // 已经有 diagnosis 则不重复生成
|
|
|
- if (info.getDiagnosis() != null && !info.getDiagnosis().trim().isEmpty()) {
|
|
|
+ // ==================== 三步增强逻辑 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 三步增强:依次用 235b 大模型重新推断 诊断、证候、处方 并替换原值。
|
|
|
+ * 每一步如果失败或返回为空,保留专精模型的原始值不覆盖。
|
|
|
+ */
|
|
|
+ private void enhanceDiagnosisResults(TongueDiagnosisDetailVo detail, TongueDiagnosisInfo info) {
|
|
|
+ if (llmUrl == null || llmUrl.trim().isEmpty()) {
|
|
|
+ log.warn("未配置 ai.llm.url,跳过三步增强");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- String complaint = "";
|
|
|
+ String chiefComplaint = "";
|
|
|
if (detail.getPatientInfo() != null && detail.getPatientInfo().getPatientChiefComplaint() != null) {
|
|
|
- complaint = detail.getPatientInfo().getPatientChiefComplaint();
|
|
|
+ chiefComplaint = detail.getPatientInfo().getPatientChiefComplaint().trim();
|
|
|
}
|
|
|
|
|
|
- String syndrome = info.getSyndrome();
|
|
|
- syndrome = syndrome == null ? "" : syndrome.trim();
|
|
|
-
|
|
|
- String prompt = buildDiagnosisPrompt(info, complaint);
|
|
|
- if (prompt == null || prompt.trim().isEmpty()) {
|
|
|
- return;
|
|
|
+ String tongueManifestation = info.getTongueManifestation();
|
|
|
+ if (tongueManifestation == null || tongueManifestation.trim().isEmpty()) {
|
|
|
+ tongueManifestation = info.getTongueGeneralDesc();
|
|
|
}
|
|
|
|
|
|
- if (llmUrl == null || llmUrl.trim().isEmpty()) {
|
|
|
- log.warn("diagnosis-llm已启用,但未配置 ai.llm.url,跳过生成 diagnosis");
|
|
|
- return;
|
|
|
+ String origDiagnosis = info.getDiagnosis();
|
|
|
+ String origSyndrome = info.getSyndrome();
|
|
|
+ String origPrescription = info.getPrescription();
|
|
|
+ String origManifestation = tongueManifestation;
|
|
|
+ log.info("========== 三步增强开始 ==========");
|
|
|
+ log.info("[专精模型原始值] 舌象(保留不动): {}", origManifestation);
|
|
|
+ log.info("[专精模型原始值] 诊断: {}", origDiagnosis);
|
|
|
+ log.info("[专精模型原始值] 证候: {}", origSyndrome);
|
|
|
+ log.info("[专精模型原始值] 处方: {}", origPrescription);
|
|
|
+ log.info("[增强输入] 主诉: {}", chiefComplaint);
|
|
|
+
|
|
|
+ long totalStart = System.currentTimeMillis();
|
|
|
+
|
|
|
+ // Step 1: 推断诊断
|
|
|
+ log.info("---------- Step1: 推断诊断 开始 ----------");
|
|
|
+ long step1Start = System.currentTimeMillis();
|
|
|
+ String newDiagnosis = inferDiagnosis(chiefComplaint);
|
|
|
+ long step1Cost = System.currentTimeMillis() - step1Start;
|
|
|
+ if (newDiagnosis != null && !newDiagnosis.isEmpty()) {
|
|
|
+ log.info("Step1 诊断替换: [{}] → [{}] (耗时 {}ms)", origDiagnosis, newDiagnosis, step1Cost);
|
|
|
+ info.setDiagnosis(newDiagnosis);
|
|
|
+ if (info.getConstitutionAnalysis() == null || info.getConstitutionAnalysis().trim().isEmpty()) {
|
|
|
+ info.setConstitutionAnalysis(newDiagnosis);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ log.warn("Step1 诊断推断为空,保留原值: {} (耗时 {}ms)", origDiagnosis, step1Cost);
|
|
|
}
|
|
|
|
|
|
- // 记录喂给模型的完整提示词(用于排查 prompt/输出)
|
|
|
- log.info("diagnosis-llm prompt: {}", prompt);
|
|
|
- String raw = llmService.callLLM(prompt, llmUrl, diagnosisModel, llmMaxTokens);
|
|
|
- // 记录模型返回的原始结果(完整响应)
|
|
|
- log.info("diagnosis-llm raw response: {}", raw);
|
|
|
- String diagnosis = extractAssistantContentFromChatCompletion(raw);
|
|
|
- if (diagnosis == null || diagnosis.trim().isEmpty()) {
|
|
|
- diagnosis = raw;
|
|
|
- }
|
|
|
- diagnosis = diagnosis == null ? "" : diagnosis.trim();
|
|
|
-
|
|
|
- // 这里的“诊断”仅需要一个病名/病情名称:压成一行并做轻量清洗,避免前缀/多余描述
|
|
|
- diagnosis = normalizeDiseaseNameOnly(diagnosis);
|
|
|
-
|
|
|
- // 如果模型“把证候抄过去”,做一次强约束重试
|
|
|
- if (isInvalidDiseaseName(diagnosis, syndrome)) {
|
|
|
- String retryPrompt = prompt
|
|
|
- + "\n【纠错要求】你刚才的输出不符合“病名”要求。"
|
|
|
- + "严禁输出证候/体质/分析结论,严禁复述输入字段值。"
|
|
|
- + (syndrome.isEmpty() ? "" : ("禁止输出:" + syndrome + "。"))
|
|
|
- + "请重新只输出一个单一病名(例如:消渴、肛门湿疹、胃炎、胃溃疡),不要任何额外字符。";
|
|
|
- log.info("diagnosis-llm retry prompt: {}", retryPrompt);
|
|
|
- String raw2 = llmService.callLLM(retryPrompt, llmUrl, diagnosisModel, llmMaxTokens);
|
|
|
- log.info("diagnosis-llm retry raw response: {}", raw2);
|
|
|
- String diagnosis2 = extractAssistantContentFromChatCompletion(raw2);
|
|
|
- if (diagnosis2 == null || diagnosis2.trim().isEmpty()) {
|
|
|
- diagnosis2 = raw2;
|
|
|
- }
|
|
|
- diagnosis2 = normalizeDiseaseNameOnly(diagnosis2 == null ? "" : diagnosis2.trim());
|
|
|
- if (!isInvalidDiseaseName(diagnosis2, syndrome)) {
|
|
|
- diagnosis = diagnosis2;
|
|
|
- } else {
|
|
|
- log.warn("diagnosis-llm retry仍返回疑似非病名结果,将保留首次结果: diagnosis={}, syndrome={}", diagnosis, syndrome);
|
|
|
- }
|
|
|
+ // Step 2: 推断证候(使用 Step1 得出的诊断 + 舌象,查库筛候选后喂模型)
|
|
|
+ String diagnosisForStep2 = info.getDiagnosis();
|
|
|
+ log.info("---------- Step2: 推断证候 开始 ----------");
|
|
|
+ log.info("Step2 查询条件: diagnosis=[{}], 舌象=[{}]", diagnosisForStep2, tongueManifestation);
|
|
|
+ long step2Start = System.currentTimeMillis();
|
|
|
+ String newSyndrome = inferSyndrome(diagnosisForStep2, tongueManifestation);
|
|
|
+ long step2Cost = System.currentTimeMillis() - step2Start;
|
|
|
+ if (newSyndrome != null && !newSyndrome.isEmpty()) {
|
|
|
+ log.info("Step2 证候替换: [{}] → [{}] (耗时 {}ms)", origSyndrome, newSyndrome, step2Cost);
|
|
|
+ info.setSyndrome(newSyndrome);
|
|
|
+ } else {
|
|
|
+ log.warn("Step2 证候推断为空,保留原值: {} (耗时 {}ms)", origSyndrome, step2Cost);
|
|
|
}
|
|
|
|
|
|
- info.setDiagnosis(diagnosis);
|
|
|
- // 兼容旧字段:如果 constitutionAnalysis 为空,同步填一下(方便旧前端/旧展示)
|
|
|
- if (info.getConstitutionAnalysis() == null || info.getConstitutionAnalysis().trim().isEmpty()) {
|
|
|
- info.setConstitutionAnalysis(diagnosis);
|
|
|
+ // Step 3: 推断处方(依赖 Step1 + Step2 的结果)
|
|
|
+ String finalDiagnosis = info.getDiagnosis();
|
|
|
+ String finalSyndrome = info.getSyndrome();
|
|
|
+ log.info("---------- Step3: 推断处方 开始 ----------");
|
|
|
+ log.info("Step3 查询条件: disease=[{}], syndrome=[{}]", finalDiagnosis, finalSyndrome);
|
|
|
+ long step3Start = System.currentTimeMillis();
|
|
|
+ String newPrescription = inferPrescription(finalDiagnosis, finalSyndrome);
|
|
|
+ long step3Cost = System.currentTimeMillis() - step3Start;
|
|
|
+ if (newPrescription != null && !newPrescription.isEmpty()) {
|
|
|
+ log.info("Step3 处方替换: [{}] → [{}] (耗时 {}ms)",
|
|
|
+ origPrescription != null ? origPrescription.substring(0, Math.min(80, origPrescription.length())) + "..." : "null",
|
|
|
+ newPrescription.substring(0, Math.min(80, newPrescription.length())) + "...",
|
|
|
+ step3Cost);
|
|
|
+ info.setPrescription(newPrescription);
|
|
|
+ } else {
|
|
|
+ log.warn("Step3 处方推断为空,保留原值 (耗时 {}ms)", step3Cost);
|
|
|
}
|
|
|
+
|
|
|
+ long totalCost = System.currentTimeMillis() - totalStart;
|
|
|
+ log.info("========== 三步增强完成 (总耗时 {}ms, Step1={}ms, Step2={}ms, Step3={}ms) ==========",
|
|
|
+ totalCost, step1Cost, step2Cost, step3Cost);
|
|
|
+ log.info("[最终结果] 舌象: {}", info.getTongueManifestation());
|
|
|
+ log.info("[最终结果] 诊断: {} {}", info.getDiagnosis(),
|
|
|
+ !String.valueOf(info.getDiagnosis()).equals(String.valueOf(origDiagnosis)) ? "(已替换)" : "(未变)");
|
|
|
+ log.info("[最终结果] 证候: {} {}", info.getSyndrome(),
|
|
|
+ !String.valueOf(info.getSyndrome()).equals(String.valueOf(origSyndrome)) ? "(已替换)" : "(未变)");
|
|
|
+ log.info("[最终结果] 处方: {} {}", info.getPrescription() != null ? info.getPrescription().substring(0, Math.min(80, info.getPrescription().length())) + "..." : "null",
|
|
|
+ !String.valueOf(info.getPrescription()).equals(String.valueOf(origPrescription)) ? "(已替换)" : "(未变)");
|
|
|
}
|
|
|
|
|
|
- private String buildDiagnosisPrompt(TongueDiagnosisInfo info, String chiefComplaint) {
|
|
|
- String manifestation = info.getTongueManifestation();
|
|
|
- if (manifestation == null || manifestation.trim().isEmpty()) {
|
|
|
- manifestation = info.getTongueGeneralDesc();
|
|
|
- }
|
|
|
+ // ==================== Step 1: 推断诊断 ====================
|
|
|
|
|
|
- String syndrome = info.getSyndrome();
|
|
|
- if (syndrome == null || syndrome.trim().isEmpty()) {
|
|
|
- syndrome = "";
|
|
|
+ private String inferDiagnosis(String chiefComplaint) {
|
|
|
+ if (chiefComplaint == null || chiefComplaint.trim().isEmpty()) {
|
|
|
+ log.warn("Step1: 主诉为空,无法推断诊断");
|
|
|
+ return null;
|
|
|
}
|
|
|
-
|
|
|
- List<String> features = new ArrayList<>();
|
|
|
- if (info.getKeyFeatures() != null) {
|
|
|
- info.getKeyFeatures().forEach(k -> {
|
|
|
- if (k != null && k.getFeature() != null && !k.getFeature().trim().isEmpty()) {
|
|
|
- features.add(k.getFeature().trim());
|
|
|
- }
|
|
|
- });
|
|
|
+ if (diagnosisCandidates.isEmpty()) {
|
|
|
+ log.warn("Step1: 诊断候选列表为空,无法推断诊断");
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- StringBuilder sb = new StringBuilder();
|
|
|
- // 完全按你给的模板组织(尽量保持格式一致)
|
|
|
- sb.append("已知患者核心信息:");
|
|
|
-
|
|
|
- String complaintPart = chiefComplaint == null ? "" : chiefComplaint.trim();
|
|
|
- String manifestationPart = manifestation == null ? "" : manifestation.trim();
|
|
|
- String syndromePart = syndrome == null ? "" : syndrome.trim();
|
|
|
- String prescriptionPart = info.getPrescription() == null ? "" : info.getPrescription().trim();
|
|
|
-
|
|
|
- // 压掉多余换行,避免提示词在日志/模型侧断行混乱
|
|
|
- complaintPart = complaintPart.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
- manifestationPart = manifestationPart.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
- syndromePart = syndromePart.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
- prescriptionPart = prescriptionPart.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
-
|
|
|
- sb.append("1.主诉:").append(complaintPart.isEmpty() ? "无" : complaintPart).append(";");
|
|
|
- sb.append("2.舌象:").append(manifestationPart.isEmpty() ? "无" : manifestationPart).append(";");
|
|
|
- sb.append("3.症候:").append(syndromePart.isEmpty() ? "无" : syndromePart).append(";");
|
|
|
- sb.append("4.处方:").append(prescriptionPart.isEmpty() ? "无" : prescriptionPart).append("。");
|
|
|
-
|
|
|
-// // 你希望的结尾指令(保持原句式)
|
|
|
-// sb.append("请结合中医舌诊、主诉、症候及处方用药配伍逻辑,精准推断患者具体病情名称,仅返回病情名称(格式参考:胃炎、胃溃疡类单一病症名),无任何额外表述。");
|
|
|
-//
|
|
|
-// // 仍然给模型一点额外的“防抄”约束(不改变你模板外观,放到最后)
|
|
|
-// sb.append("(严禁复述输入字段值,尤其严禁输出症候原文;只输出病名)");
|
|
|
- sb.append("【请结合中医舌诊、主诉、症候及处方用药配伍逻辑,精准推断患者具体病情名称】"
|
|
|
- + ",【仅返回病情名称】"
|
|
|
- + "(格式参考:胃炎、胃溃疡、偏头痛等单一病症名),【无任何额外表述】。");
|
|
|
-
|
|
|
- // 防抄 & 防复述硬约束
|
|
|
- sb.append("【重要约束❗❗】(严禁复述输入字段值,尤其严禁输出症候原文;【只输出病名】)");
|
|
|
-
|
|
|
- return sb.toString();
|
|
|
+ String candidateList = String.join("、", diagnosisCandidates);
|
|
|
+ String prompt = "你是一名资深中医诊断专家。\n"
|
|
|
+ + "患者主诉: " + chiefComplaint.trim() + "\n"
|
|
|
+ + "以下是中医诊断候选列表: [" + candidateList + "]\n"
|
|
|
+ + "请根据患者主诉,从上述候选列表中选择最匹配的一个诊断。\n"
|
|
|
+ + "【仅返回诊断名称,不要任何解释、标点或额外内容】";
|
|
|
+
|
|
|
+ try {
|
|
|
+ log.info("Step1 diagnosis prompt: {}", prompt);
|
|
|
+ String raw = llmService.callLLM(prompt, llmUrl, diagnosisModel, llmMaxTokens);
|
|
|
+ log.info("Step1 diagnosis raw response: {}", raw);
|
|
|
+ String content = extractContent(raw);
|
|
|
+ log.info("Step1 extractContent: [{}]", content);
|
|
|
+ String cleaned = cleanSingleValue(content);
|
|
|
+ log.info("Step1 cleanSingleValue: [{}]", cleaned);
|
|
|
+ return cleaned;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("Step1 推断诊断异常", e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
+ // ==================== Step 2: 推断证候 ====================
|
|
|
+
|
|
|
/**
|
|
|
- * 将对话模型输出规范化为“仅病名/病情名称”
|
|
|
+ * 根据诊断从 tongue_syndrome_mapping 表查出候选证候行,
|
|
|
+ * 再连同患者舌象一起喂给大模型,让模型从候选中选出最匹配的证候。
|
|
|
+ *
|
|
|
+ * @param diagnosis Step1 推断出的诊断(如"失眠")
|
|
|
+ * @param tongueManifestation 专精模型输出的舌象描述
|
|
|
*/
|
|
|
- private String normalizeDiseaseNameOnly(String s) {
|
|
|
- if (s == null) return "";
|
|
|
- String x = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
- if (x.isEmpty()) return "";
|
|
|
+ private String inferSyndrome(String diagnosis, String tongueManifestation) {
|
|
|
+ if (tongueManifestation == null || tongueManifestation.trim().isEmpty()) {
|
|
|
+ log.warn("Step2: 舌象为空,无法推断证候");
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (diagnosis == null || diagnosis.trim().isEmpty()) {
|
|
|
+ log.warn("Step2: 诊断为空,无法查询候选证候");
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- // 去掉常见前缀
|
|
|
- String[] prefixes = {"诊断:", "诊断:", "病名:", "病名:", "疾病:", "疾病:", "病情:", "病情:", "结论:", "结论:"};
|
|
|
- for (String p : prefixes) {
|
|
|
- if (x.startsWith(p)) {
|
|
|
- x = x.substring(p.length()).trim();
|
|
|
- break;
|
|
|
- }
|
|
|
+ List<TongueSyndromeMapping> candidates = querySyndromeCandidates(diagnosis.trim());
|
|
|
+ if (candidates == null || candidates.isEmpty()) {
|
|
|
+ log.warn("Step2: 数据库未查到诊断 [{}] 的候选证候,跳过证候推断", diagnosis);
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- // 去掉包裹引号
|
|
|
- if ((x.startsWith("\"") && x.endsWith("\"")) || (x.startsWith("“") && x.endsWith("”"))) {
|
|
|
- x = x.substring(1, x.length() - 1).trim();
|
|
|
+ log.info("Step2: 数据库查到 {} 条候选证候 (diagnosis=[{}])", candidates.size(), diagnosis);
|
|
|
+
|
|
|
+ if (candidates.size() == 1) {
|
|
|
+ String onlySyndrome = candidates.get(0).getSyndrome();
|
|
|
+ log.info("Step2: 仅查到 1 条候选证候 [{}],直接使用,跳过模型选择", onlySyndrome);
|
|
|
+ return onlySyndrome;
|
|
|
}
|
|
|
|
|
|
- // 如果模型仍输出多段,取第一段(按常见分隔符)
|
|
|
- int cut = x.length();
|
|
|
- for (String sep : new String[]{" ", ";", ";", "。", ".", ",", ",", " "}) {
|
|
|
- int i = x.indexOf(sep);
|
|
|
- if (i >= 0 && i < cut) cut = i;
|
|
|
+ // 构建候选表格
|
|
|
+ StringBuilder table = new StringBuilder();
|
|
|
+ table.append("| 证候 | 舌象主要特征 | 互斥舌象 |\n");
|
|
|
+ table.append("| --- | --- | --- |\n");
|
|
|
+ for (TongueSyndromeMapping row : candidates) {
|
|
|
+ table.append("| ").append(row.getSyndrome())
|
|
|
+ .append(" | ").append(row.getTongueFeatures() != null ? row.getTongueFeatures() : "")
|
|
|
+ .append(" | ").append(row.getExclusiveFeatures() != null ? row.getExclusiveFeatures() : "")
|
|
|
+ .append(" |\n");
|
|
|
}
|
|
|
- if (cut != x.length()) {
|
|
|
- x = x.substring(0, cut).trim();
|
|
|
+
|
|
|
+ String prompt = "你的任务是根据患者舌象,严格按照下面的关联表做关键词匹配,选出最匹配的证候。\n"
|
|
|
+ + "【禁止使用你自己的医学知识,必须且只能依据表格内容做匹配】\n\n"
|
|
|
+ + "## 患者舌象特征\n"
|
|
|
+ + tongueManifestation.trim() + "\n\n"
|
|
|
+ + "## 该诊断下的候选证候\n"
|
|
|
+ + table + "\n"
|
|
|
+ + "## 匹配规则\n"
|
|
|
+ + "1. 先排除:如果患者舌象命中了某行\"互斥舌象\"中的任何一个关键词,该行直接排除\n"
|
|
|
+ + "2. 再计数:对未排除的行,统计患者舌象命中\"舌象主要特征\"中的关键词数量\n"
|
|
|
+ + "3. 取最优:选命中数最高的那行对应的证候\n\n"
|
|
|
+ + "## 输出格式\n"
|
|
|
+ + "仅返回证候名称(如\"脾胃不和证\"),不要解释,不要其他任何内容。";
|
|
|
+
|
|
|
+ try {
|
|
|
+ log.info("Step2 syndrome prompt length: {}, 候选数: {}, 舌象输入: {}", prompt.length(), candidates.size(), tongueManifestation.trim());
|
|
|
+ String raw = llmService.callLLM(prompt, llmUrl, diagnosisModel, llmMaxTokens);
|
|
|
+ log.info("Step2 syndrome raw response: {}", raw);
|
|
|
+ String content = extractContent(raw);
|
|
|
+ log.info("Step2 extractContent: [{}]", content);
|
|
|
+ String cleaned = cleanSingleValue(content);
|
|
|
+ log.info("Step2 cleanSingleValue: [{}]", cleaned);
|
|
|
+ return cleaned;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("Step2 推断证候异常", e);
|
|
|
+ return null;
|
|
|
}
|
|
|
- return x;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 判断模型输出是否不像“病名”(更像证候/体质/解释,或直接复述输入的证候)
|
|
|
- */
|
|
|
- private boolean isInvalidDiseaseName(String diagnosis, String syndrome) {
|
|
|
- if (diagnosis == null) return true;
|
|
|
- String d = diagnosis.trim();
|
|
|
- if (d.isEmpty()) return true;
|
|
|
-
|
|
|
- // 明确:如果直接等于/包含输入证候,基本就是抄了
|
|
|
- if (syndrome != null && !syndrome.trim().isEmpty()) {
|
|
|
- String s = syndrome.trim();
|
|
|
- if (d.equals(s) || d.contains(s)) return true;
|
|
|
+ private List<TongueSyndromeMapping> querySyndromeCandidates(String diagnosis) {
|
|
|
+ try {
|
|
|
+ LambdaQueryWrapper<TongueSyndromeMapping> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(TongueSyndromeMapping::getDiagnosis, diagnosis);
|
|
|
+ return tongueSyndromeMappingMapper.selectList(wrapper);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("查询 tongue_syndrome_mapping 异常,diagnosis={}", diagnosis, e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Step 3: 推断处方 ====================
|
|
|
+
|
|
|
+ private String inferPrescription(String diagnosis, String syndrome) {
|
|
|
+ if (diagnosis == null || diagnosis.trim().isEmpty()
|
|
|
+ || syndrome == null || syndrome.trim().isEmpty()) {
|
|
|
+ log.warn("Step3: 诊断或证候为空,无法查询处方。diagnosis={}, syndrome={}", diagnosis, syndrome);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<MedicalData> candidates = queryPrescriptionCandidates(diagnosis.trim(), syndrome.trim());
|
|
|
+ if (candidates == null || candidates.isEmpty()) {
|
|
|
+ log.warn("Step3: 数据库未查到匹配处方。disease=[{}], syndrome=[{}]", diagnosis, syndrome);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("Step3: 数据库查到 {} 条候选处方 (disease=[{}], syndrome=[{}])", candidates.size(), diagnosis, syndrome);
|
|
|
+ for (int i = 0; i < candidates.size(); i++) {
|
|
|
+ MedicalData md = candidates.get(i);
|
|
|
+ log.info("Step3 候选处方[{}]: id={}, treatment={}", i + 1, md.getId(),
|
|
|
+ md.getTreatment() != null ? md.getTreatment().substring(0, Math.min(80, md.getTreatment().length())) + "..." : "null");
|
|
|
}
|
|
|
|
|
|
- // 常见“不是病名”的提示词
|
|
|
- String[] badTokens = {"体质", "属于", "分析", "提示", "考虑", "证候", "证型", "辨证", "治法"};
|
|
|
- for (String t : badTokens) {
|
|
|
- if (d.contains(t)) return true;
|
|
|
+ if (candidates.size() == 1) {
|
|
|
+ log.info("Step3: 仅查到 1 条处方,直接使用,跳过模型选择");
|
|
|
+ return candidates.get(0).getTreatment();
|
|
|
}
|
|
|
|
|
|
- // 证候通常以“证”结尾,病名一般不要求以“证”结尾
|
|
|
- if (d.endsWith("证")) return true;
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("你是一名中医处方专家,请从以下候选处方中选择最贴合患者病情的一条。\n\n");
|
|
|
+ sb.append("患者诊断: ").append(diagnosis.trim()).append("\n");
|
|
|
+ sb.append("患者证候: ").append(syndrome.trim()).append("\n\n");
|
|
|
+ sb.append("候选处方:\n");
|
|
|
+ for (int i = 0; i < candidates.size(); i++) {
|
|
|
+ sb.append("编号").append(i + 1).append(": ").append(candidates.get(i).getTreatment()).append("\n");
|
|
|
+ }
|
|
|
+ sb.append("\n仅返回你选择的编号数字(如\"1\"或\"2\"或\"3\"),不要返回处方内容,不要解释,不要任何其他内容。");
|
|
|
+
|
|
|
+ try {
|
|
|
+ String prompt = sb.toString();
|
|
|
+ log.info("Step3 prescription prompt length: {}, 候选数: {}", prompt.length(), candidates.size());
|
|
|
+ String raw = llmService.callLLM(prompt, llmUrl, diagnosisModel, llmMaxTokens);
|
|
|
+ log.info("Step3 prescription raw response: {}", raw);
|
|
|
+ String content = extractContent(raw);
|
|
|
+ log.info("Step3 extractContent: [{}]", content);
|
|
|
+ String cleaned = cleanSingleValue(content);
|
|
|
+ log.info("Step3 cleanSingleValue: [{}]", cleaned);
|
|
|
+
|
|
|
+ if (cleaned != null) {
|
|
|
+ String numberStr = cleaned.replaceAll("[^0-9]", "");
|
|
|
+ if (!numberStr.isEmpty()) {
|
|
|
+ int idx = Integer.parseInt(numberStr) - 1;
|
|
|
+ if (idx >= 0 && idx < candidates.size()) {
|
|
|
+ String treatment = candidates.get(idx).getTreatment();
|
|
|
+ log.info("Step3 模型选择编号 {} → 处方id={}, treatment={}",
|
|
|
+ numberStr, candidates.get(idx).getId(),
|
|
|
+ treatment != null ? treatment.substring(0, Math.min(80, treatment.length())) + "..." : "null");
|
|
|
+ return treatment;
|
|
|
+ }
|
|
|
+ log.warn("Step3 编号 {} 超出候选范围 (共{}条),兜底取第一条", numberStr, candidates.size());
|
|
|
+ } else {
|
|
|
+ log.warn("Step3 模型返回无法解析为编号: [{}],兜底取第一条", cleaned);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ log.warn("Step3 模型返回为空,兜底取第一条");
|
|
|
+ }
|
|
|
+ return candidates.get(0).getTreatment();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("Step3 推断处方异常,兜底取第一条", e);
|
|
|
+ return candidates.get(0).getTreatment();
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // 太长也很像解释
|
|
|
- return d.length() > 12;
|
|
|
+ private List<MedicalData> queryPrescriptionCandidates(String disease, String syndrome) {
|
|
|
+ try {
|
|
|
+ LambdaQueryWrapper<MedicalData> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(MedicalData::getDisease, disease);
|
|
|
+ wrapper.eq(MedicalData::getSyndrome, syndrome);
|
|
|
+ wrapper.orderByDesc(MedicalData::getId);
|
|
|
+ wrapper.last("LIMIT 3");
|
|
|
+ return medicalDataMapper.selectList(wrapper);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("查询 medical_data 异常,disease={}, syndrome={}", disease, syndrome, e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- private String extractAssistantContentFromChatCompletion(String raw) {
|
|
|
+ // ==================== 公共工具方法 ====================
|
|
|
+
|
|
|
+ private String extractContent(String raw) {
|
|
|
if (raw == null || raw.trim().isEmpty()) return null;
|
|
|
try {
|
|
|
JsonNode root = objectMapper.readTree(raw);
|
|
|
JsonNode choices = root.get("choices");
|
|
|
- if (choices != null && choices.isArray() && choices.size() > 0) {
|
|
|
+ if (choices != null && choices.isArray() && !choices.isEmpty()) {
|
|
|
JsonNode message = choices.get(0).get("message");
|
|
|
if (message != null && message.has("content")) {
|
|
|
return message.get("content").asText();
|
|
|
}
|
|
|
}
|
|
|
} catch (Exception ignored) {
|
|
|
- // ignore
|
|
|
}
|
|
|
- return null;
|
|
|
+ return raw;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String cleanSingleValue(String s) {
|
|
|
+ if (s == null) return null;
|
|
|
+ String x = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").trim();
|
|
|
+ if (x.isEmpty()) return null;
|
|
|
+
|
|
|
+ if (x.startsWith("```")) {
|
|
|
+ x = x.replaceAll("```[a-z]*\\s*", "").replace("```", "").trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((x.startsWith("\"") && x.endsWith("\"")) || (x.startsWith("\u201c") && x.endsWith("\u201d"))) {
|
|
|
+ x = x.substring(1, x.length() - 1).trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ String[] prefixes = {"诊断:", "诊断:", "病名:", "病名:", "证候:", "证候:", "处方:", "处方:"};
|
|
|
+ for (String p : prefixes) {
|
|
|
+ if (x.startsWith(p)) {
|
|
|
+ x = x.substring(p.length()).trim();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return x.isEmpty() ? null : x;
|
|
|
}
|
|
|
|
|
|
private TongueAiDiagnosisProvider selectProvider(String name) {
|