|
|
@@ -19,7 +19,6 @@ import org.springframework.beans.factory.annotation.Value;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
-import java.math.RoundingMode;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.HashMap;
|
|
|
@@ -27,12 +26,19 @@ import java.util.List;
|
|
|
import java.util.Map;
|
|
|
|
|
|
/**
|
|
|
- * 本地API舌诊诊断实现(骨架)
|
|
|
+ * 本地API舌诊诊断实现
|
|
|
*
|
|
|
- * <p>当前已按你提供的本地模型协议对齐:返回为 OpenAI ChatCompletions 结构,
|
|
|
- * 从 {@code choices[0].message.content} 取自然语言内容,再拆分为结构化字段。</p>
|
|
|
+ * <p>当前已按本地模型协议对齐:返回为 OpenAI ChatCompletions 结构,
|
|
|
+ * 从 {@code choices[0].message.content} 取结构化文本,再拆分为结构化字段。</p>
|
|
|
*
|
|
|
- * <p>额外保留一个兼容分支:如果未来本地接口直接返回结构化 JSON(包含 tongueGeneralDesc/keyFeatures 等字段),
|
|
|
+ * <p>模型 content 输出格式:
|
|
|
+ * <pre>
|
|
|
+ * 舌苔厚薄:薄苔;
|
|
|
+ * 舌苔颜色:黄;
|
|
|
+ * 舌质颜色:红舌
|
|
|
+ * </pre>
|
|
|
+ *
|
|
|
+ * <p>额外保留一个兼容分支:如果本地接口直接返回结构化 JSON(包含 tongueGeneralDesc/keyFeatures 等字段),
|
|
|
* 也能直接映射;否则最终兜底返回 mock。</p>
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@@ -85,32 +91,23 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 构建本地API请求体(按你的本地协议改这里)
|
|
|
- *
|
|
|
- * <p>当前默认发送:imageUrl + chiefComplaint</p>
|
|
|
- */
|
|
|
private Map<String, Object> buildLocalRequest(TongueDiagnosisDetailVo detail) {
|
|
|
Map<String, Object> requestBody = new HashMap<>();
|
|
|
|
|
|
- // 你提供的本地接口协议:{ model, messages: [...] }
|
|
|
requestBody.put("model", localModel);
|
|
|
|
|
|
List<Map<String, Object>> messages = new ArrayList<>();
|
|
|
|
|
|
- // system
|
|
|
Map<String, Object> systemMsg = new HashMap<>();
|
|
|
systemMsg.put("role", "system");
|
|
|
systemMsg.put("content", systemPrompt);
|
|
|
messages.add(systemMsg);
|
|
|
|
|
|
- // user (multimodal content)
|
|
|
Map<String, Object> userMsg = new HashMap<>();
|
|
|
userMsg.put("role", "user");
|
|
|
|
|
|
List<Map<String, Object>> content = new ArrayList<>();
|
|
|
|
|
|
- // 本地模型无法访问外网/MinIO URL 时,优先传 imagePath(服务器落盘路径);没有再回退到 imageUrl
|
|
|
String imageUrl = null;
|
|
|
if (detail.getTongueImage() != null) {
|
|
|
imageUrl = detail.getTongueImage().getPath();
|
|
|
@@ -139,6 +136,7 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
messages.add(userMsg);
|
|
|
|
|
|
requestBody.put("messages", messages);
|
|
|
+ requestBody.put("temperature", 0);
|
|
|
return requestBody;
|
|
|
}
|
|
|
|
|
|
@@ -152,11 +150,8 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
/**
|
|
|
* 解析本地API响应
|
|
|
*
|
|
|
- * <p>优先解析你当前本地模型的返回格式:OpenAI ChatCompletions 结构,
|
|
|
- * 取 {@code choices[0].message.content} 并拆分为 tongueManifestation/syndrome/prescription/keyFeatures。</p>
|
|
|
- *
|
|
|
- * <p>仅当本地接口未来改为直接返回结构化 JSON(包含 tongueGeneralDesc/keyFeatures 等字段)时,
|
|
|
- * 才会走结构化映射分支。</p>
|
|
|
+ * <p>优先走 OpenAI ChatCompletions 结构 → choices[0].message.content → 按行解析 kv。</p>
|
|
|
+ * <p>兼容分支:直接返回结构化 JSON(包含 tongueGeneralDesc/keyFeatures 等字段)时走 mapStructuredPayload。</p>
|
|
|
*/
|
|
|
private TongueDiagnosisInfo parseLocalResponse(String raw, TongueDiagnosisDetailVo detail) {
|
|
|
if (raw == null || raw.trim().isEmpty()) {
|
|
|
@@ -167,13 +162,11 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
try {
|
|
|
JsonNode root = objectMapper.readTree(raw);
|
|
|
|
|
|
- // 1) 如果本地接口返回也是 OpenAI 结构,则取 choices[0].message.content
|
|
|
String assistantContent = extractAssistantContent(root);
|
|
|
if (assistantContent != null && !assistantContent.trim().isEmpty()) {
|
|
|
- return parseAssistantNaturalLanguage(assistantContent);
|
|
|
+ return parseAssistantStructuredContent(assistantContent);
|
|
|
}
|
|
|
|
|
|
- // 2) 否则尝试兼容:直接返回结构化 JSON(保留之前的通用实现)
|
|
|
JsonNode payload = extractPayload(root);
|
|
|
if (payload != null && !payload.isMissingNode() && !payload.isNull()
|
|
|
&& (payload.has("tongueGeneralDesc") || payload.has("keyFeatures"))) {
|
|
|
@@ -207,7 +200,6 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
diagnosisInfo.setHealthAdvice(payload.get("healthAdvice").asText());
|
|
|
}
|
|
|
|
|
|
- // keyFeatures: [{feature, confidence}]
|
|
|
List<KeyFeatures> keyFeatures = new ArrayList<>();
|
|
|
String tongueColorResult = null;
|
|
|
String tongueCoatingResult = null;
|
|
|
@@ -259,151 +251,78 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 将本地模型返回的自然语言 content 拆分成:
|
|
|
- * - tongueManifestation(舌象)
|
|
|
- * - keyFeatures(从“现象为”里拆)
|
|
|
- * - syndrome(“诊断为”后的证型)
|
|
|
- * - prescription(“处方:”后的文本)
|
|
|
- * - diagnosis(按需求先空着)
|
|
|
+ * 解析本地舌象专精模型返回的结构化 content。
|
|
|
+ *
|
|
|
+ * <p>模型输出格式示例:
|
|
|
+ * <pre>
|
|
|
+ * 舌苔厚薄:薄苔;
|
|
|
+ * 舌苔颜色:黄;
|
|
|
+ * 舌质颜色:红舌
|
|
|
+ * </pre>
|
|
|
+ * 按"key:value"逐行解析,映射到 TongueDiagnosisInfo 对应字段。
|
|
|
*/
|
|
|
- private TongueDiagnosisInfo parseAssistantNaturalLanguage(String content) {
|
|
|
+ private TongueDiagnosisInfo parseAssistantStructuredContent(String content) {
|
|
|
String normalized = content.replace("\r\n", "\n").replace("\r", "\n").trim();
|
|
|
|
|
|
- String manifestation = extractBetween(normalized, "现象为", "诊断为");
|
|
|
- String syndrome = extractAfterLabel(normalized, "诊断为");
|
|
|
- syndrome = firstSentence(syndrome);
|
|
|
- String prescription = extractAfterLabel(normalized, "处方");
|
|
|
-
|
|
|
- TongueDiagnosisInfo info = new TongueDiagnosisInfo();
|
|
|
- info.setTongueManifestation(manifestation);
|
|
|
- // 兼容旧字段:让旧前端/旧落库字段还能看见
|
|
|
- info.setTongueGeneralDesc(manifestation);
|
|
|
- info.setSyndrome(syndrome);
|
|
|
- info.setDiagnosis(""); // 按需求先空着
|
|
|
- info.setPrescription(prescription);
|
|
|
-
|
|
|
- List<KeyFeatures> features = parseFeaturesFromManifestation(manifestation);
|
|
|
- info.setKeyFeatures(features);
|
|
|
-
|
|
|
- // 尝试从特征里填三个老字段(如果你后续不用也没关系)
|
|
|
- fillLegacyTongueFields(info, features);
|
|
|
-
|
|
|
- // overallConfidence 这里没给,先用平均固定值兜底
|
|
|
- if (features != null && !features.isEmpty()) {
|
|
|
- BigDecimal avg = BigDecimal.ZERO;
|
|
|
- for (KeyFeatures f : features) {
|
|
|
- if (f.getConfidence() != null) avg = avg.add(f.getConfidence());
|
|
|
+ Map<String, String> kvMap = new HashMap<>();
|
|
|
+ for (String line : normalized.split("\n")) {
|
|
|
+ String trimmed = line.trim();
|
|
|
+ if (trimmed.isEmpty()) continue;
|
|
|
+ if (trimmed.endsWith(";") || trimmed.endsWith(";")) {
|
|
|
+ trimmed = trimmed.substring(0, trimmed.length() - 1).trim();
|
|
|
+ }
|
|
|
+ int colonIdx = trimmed.indexOf(":");
|
|
|
+ if (colonIdx < 0) colonIdx = trimmed.indexOf(":");
|
|
|
+ if (colonIdx > 0 && colonIdx < trimmed.length() - 1) {
|
|
|
+ String key = trimmed.substring(0, colonIdx).trim();
|
|
|
+ String value = trimmed.substring(colonIdx + 1).trim();
|
|
|
+ kvMap.put(key, value);
|
|
|
}
|
|
|
- avg = avg.divide(BigDecimal.valueOf(features.size()), 2, RoundingMode.HALF_UP);
|
|
|
- info.setOverallConfidence(avg);
|
|
|
}
|
|
|
|
|
|
- return info;
|
|
|
- }
|
|
|
+ String coatingThickness = kvMap.get("舌苔厚薄");
|
|
|
+ String coatingColor = kvMap.get("舌苔颜色");
|
|
|
+ String tongueColor = kvMap.get("舌质颜色");
|
|
|
|
|
|
- private List<KeyFeatures> parseFeaturesFromManifestation(String manifestation) {
|
|
|
- if (manifestation == null) return new ArrayList<>();
|
|
|
-
|
|
|
- // 去掉前缀描述,只保留“厚苔, 润苔...”这段
|
|
|
- String s = manifestation;
|
|
|
- int idx = s.indexOf(":");
|
|
|
- if (idx >= 0) s = s.substring(idx + 1);
|
|
|
-
|
|
|
- s = s.replace("。", "").replace(";", ",").replace(",", ",").replace("、", ",");
|
|
|
- String[] parts = s.split(",");
|
|
|
-
|
|
|
- List<KeyFeatures> list = new ArrayList<>();
|
|
|
- // 给一个“固定但合理”的置信度梯度:0.92 -> 0.85
|
|
|
- BigDecimal[] confs = new BigDecimal[] {
|
|
|
- new BigDecimal("0.92"),
|
|
|
- new BigDecimal("0.89"),
|
|
|
- new BigDecimal("0.87"),
|
|
|
- new BigDecimal("0.86"),
|
|
|
- new BigDecimal("0.85"),
|
|
|
- new BigDecimal("0.85"),
|
|
|
- new BigDecimal("0.85"),
|
|
|
- new BigDecimal("0.85")
|
|
|
- };
|
|
|
-
|
|
|
- int i = 0;
|
|
|
- for (String p : parts) {
|
|
|
- String feature = p == null ? "" : p.trim();
|
|
|
- if (feature.isEmpty()) continue;
|
|
|
- KeyFeatures k = new KeyFeatures();
|
|
|
- k.setFeature(feature);
|
|
|
- k.setConfidence(confs[Math.min(i, confs.length - 1)]);
|
|
|
- list.add(k);
|
|
|
- i++;
|
|
|
- }
|
|
|
- return list;
|
|
|
- }
|
|
|
-
|
|
|
- private void fillLegacyTongueFields(TongueDiagnosisInfo info, List<KeyFeatures> features) {
|
|
|
- if (info == null || features == null) return;
|
|
|
+ TongueDiagnosisInfo info = new TongueDiagnosisInfo();
|
|
|
|
|
|
- for (KeyFeatures f : features) {
|
|
|
- if (f == null || f.getFeature() == null) continue;
|
|
|
- String t = f.getFeature().trim();
|
|
|
- // 很粗的启发式:命中几个常见词就填到老字段里
|
|
|
- if (info.getTongueShapeResult() == null && (t.contains("齿痕") || t.contains("裂纹") || t.contains("胖大") || t.contains("瘦薄"))) {
|
|
|
- info.setTongueShapeResult(t);
|
|
|
- }
|
|
|
- if (info.getTongueColorResult() == null && (t.contains("淡红") || t.contains("淡白") || t.contains("红") || t.contains("青") || t.contains("黯") || t.contains("紫"))) {
|
|
|
- info.setTongueColorResult(t);
|
|
|
- }
|
|
|
- if (info.getTongueCoatingResult() == null && (t.contains("苔") || t.contains("黄") || t.contains("白") || t.contains("灰") || t.contains("黑") || t.contains("腻") || t.contains("润") || t.contains("燥"))) {
|
|
|
- info.setTongueCoatingResult(t);
|
|
|
- }
|
|
|
+ if (tongueColor != null && !tongueColor.isEmpty()) {
|
|
|
+ info.setTongueColorResult(tongueColor);
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- private String extractBetween(String text, String leftLabel, String rightLabel) {
|
|
|
- if (text == null) return null;
|
|
|
- int l = text.indexOf(leftLabel);
|
|
|
- if (l < 0) return null;
|
|
|
- int start = text.indexOf(":", l);
|
|
|
- if (start < 0) start = l + leftLabel.length();
|
|
|
- else start = start + 1;
|
|
|
- int r = text.indexOf(rightLabel, start);
|
|
|
- String out = (r > start) ? text.substring(start, r) : text.substring(start);
|
|
|
- return (leftLabel.contains("现象") ? "病人舌苔呈现的现象为:" : "") + cleanup(out);
|
|
|
- }
|
|
|
+ StringBuilder coatingDesc = new StringBuilder();
|
|
|
+ if (coatingThickness != null && !coatingThickness.isEmpty()) {
|
|
|
+ coatingDesc.append(coatingThickness);
|
|
|
+ }
|
|
|
+ if (coatingColor != null && !coatingColor.isEmpty()) {
|
|
|
+ if (!coatingDesc.isEmpty()) coatingDesc.append(",");
|
|
|
+ coatingDesc.append(coatingColor);
|
|
|
+ }
|
|
|
+ if (!coatingDesc.isEmpty()) {
|
|
|
+ info.setTongueCoatingResult(coatingDesc.toString());
|
|
|
+ }
|
|
|
|
|
|
- private String extractAfterLabel(String text, String label) {
|
|
|
- if (text == null) return null;
|
|
|
- int l = text.indexOf(label);
|
|
|
- if (l < 0) return null;
|
|
|
- int start = text.indexOf(":", l);
|
|
|
- if (start < 0) start = l + label.length();
|
|
|
- else start = start + 1;
|
|
|
- return cleanup(text.substring(start));
|
|
|
- }
|
|
|
+ String generalDesc = normalized.replace("\n", ";").replace(";;", ";");
|
|
|
+ info.setTongueGeneralDesc(generalDesc);
|
|
|
+ info.setTongueManifestation(generalDesc);
|
|
|
+
|
|
|
+ List<KeyFeatures> features = new ArrayList<>();
|
|
|
+ BigDecimal fixedConfidence = new BigDecimal("0.90");
|
|
|
+ for (Map.Entry<String, String> entry : kvMap.entrySet()) {
|
|
|
+ KeyFeatures kf = new KeyFeatures();
|
|
|
+ kf.setFeature(entry.getKey() + ":" + entry.getValue());
|
|
|
+ kf.setConfidence(fixedConfidence);
|
|
|
+ features.add(kf);
|
|
|
+ }
|
|
|
+ info.setKeyFeatures(features);
|
|
|
|
|
|
- private String cleanup(String s) {
|
|
|
- if (s == null) return null;
|
|
|
- return s.replace("\n", " ").trim();
|
|
|
- }
|
|
|
+ if (!features.isEmpty()) {
|
|
|
+ info.setOverallConfidence(fixedConfidence);
|
|
|
+ }
|
|
|
|
|
|
- private String firstSentence(String s) {
|
|
|
- if (s == null) return null;
|
|
|
- int idx = s.indexOf("。");
|
|
|
- if (idx >= 0) return s.substring(0, idx + 1).trim();
|
|
|
- idx = s.indexOf("\n");
|
|
|
- if (idx >= 0) return s.substring(0, idx).trim();
|
|
|
- return s.trim();
|
|
|
+ return info;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 从不同形态的响应中提取真正的诊断payload(你本地接口若不同,改这里最方便)
|
|
|
- *
|
|
|
- * <p>支持:
|
|
|
- * <ul>
|
|
|
- * <li>直接返回 TongueDiagnosisInfo JSON</li>
|
|
|
- * <li>包一层 data: {...}</li>
|
|
|
- * <li>包一层 result: {...}</li>
|
|
|
- * </ul>
|
|
|
- * </p>
|
|
|
- */
|
|
|
private JsonNode extractPayload(JsonNode root) {
|
|
|
if (root == null) return null;
|
|
|
if (root.has("tongueGeneralDesc") || root.has("keyFeatures")) return root;
|
|
|
@@ -430,7 +349,6 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
}
|
|
|
|
|
|
String json = objectMapper.writeValueAsString(requestBody);
|
|
|
- // 记录喂给模型前的完整提示词/请求体(包含 system/user/messages 以及 image_path)
|
|
|
log.info("本地模型请求: url={}, body={}", url, json);
|
|
|
httpPost.setEntity(new StringEntity(json, StandardCharsets.UTF_8));
|
|
|
|
|
|
@@ -438,7 +356,6 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
HttpEntity entity = response.getEntity();
|
|
|
String content = entity != null ? EntityUtils.toString(entity, StandardCharsets.UTF_8) : null;
|
|
|
int status = response.getStatusLine() != null ? response.getStatusLine().getStatusCode() : -1;
|
|
|
- // 记录模型返回的原始结果(完整响应)
|
|
|
log.info("本地模型响应: url={}, status={}, body={}", url, status, content);
|
|
|
if (status < 200 || status >= 300) {
|
|
|
throw new RuntimeException("本地API HTTP状态码异常: " + status + ", body=" + content);
|
|
|
@@ -448,5 +365,3 @@ public class TongueAiLocalDiagnosisService implements TongueAiDiagnosisProvider
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
-
|