|
|
@@ -14,27 +14,49 @@ import reactor.core.publisher.Flux;
|
|
|
import reactor.core.publisher.Sinks;
|
|
|
|
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
|
|
|
import java.util.*;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
@RestController
|
|
|
@RequestMapping("/api/v1/chat")
|
|
|
public class ChatController {
|
|
|
|
|
|
+ private static final Map<String, String> SECTION_DISPLAY = Map.ofEntries(
|
|
|
+ Map.entry("处方", "处方"), Map.entry("制法", "制法"), Map.entry("性状", "性状"),
|
|
|
+ Map.entry("鉴别", "鉴别"), Map.entry("检查", "检查"), Map.entry("浸出物", "浸出物"),
|
|
|
+ Map.entry("含量测定", "含量测定"), Map.entry("功能主治", "功能与主治"),
|
|
|
+ Map.entry("用法用量", "用法与用量"), Map.entry("注意事项", "注意事项"),
|
|
|
+ Map.entry("规格", "规格"), Map.entry("贮藏", "贮藏"), Map.entry("类别", "类别"),
|
|
|
+ Map.entry("制剂", "制剂"), Map.entry("附注", "附注"), Map.entry("包装", "包装"),
|
|
|
+ Map.entry("有效期", "有效期"), Map.entry("核准日期", "核准日期"),
|
|
|
+ Map.entry("修订日期", "修订日期"), Map.entry("执行标准", "执行标准"),
|
|
|
+ Map.entry("批准文号", "批准文号"), Map.entry("禁忌", "禁忌"),
|
|
|
+ Map.entry("不良反应", "不良反应"), Map.entry("正文", "正文"),
|
|
|
+ Map.entry("处方", "处方"), Map.entry("功能", "功能与主治"), Map.entry("主治", "功能与主治"),
|
|
|
+ Map.entry("用法", "用法与用量"), Map.entry("用量", "用法与用量"),
|
|
|
+ Map.entry("注意", "注意事项"), Map.entry("含量", "含量测定")
|
|
|
+ );
|
|
|
+
|
|
|
private final RetrieverService retrieverService;
|
|
|
private final LLMService llmService;
|
|
|
private final PromptService promptService;
|
|
|
private final ChatPersistenceService persistenceService;
|
|
|
+ private final RerankerService rerankerService;
|
|
|
private final JdbcTemplate jdbc;
|
|
|
private final QwenProperties props;
|
|
|
|
|
|
public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
|
|
|
- ChatPersistenceService cps, JdbcTemplate jdbc, QwenProperties props) {
|
|
|
+ ChatPersistenceService cps, RerankerService rrs,
|
|
|
+ JdbcTemplate jdbc, QwenProperties props) {
|
|
|
this.retrieverService = rs;
|
|
|
this.llmService = ls;
|
|
|
this.promptService = ps;
|
|
|
this.persistenceService = cps;
|
|
|
+ this.rerankerService = rrs;
|
|
|
this.jdbc = jdbc;
|
|
|
this.props = props;
|
|
|
}
|
|
|
@@ -48,7 +70,7 @@ public class ChatController {
|
|
|
|
|
|
String intent = retrieverService.classifyIntent(query);
|
|
|
List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
|
|
|
- docs = rerank(docs, query, 5);
|
|
|
+ docs = rerankerService.rerank(docs, query, 5);
|
|
|
|
|
|
// 统一:LLM 回答 + 原文对照
|
|
|
List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
@@ -63,7 +85,8 @@ public class ChatController {
|
|
|
return ResponseEntity.ok(Map.of(
|
|
|
"answer", answer,
|
|
|
"sources", sources,
|
|
|
- "intent", intent
|
|
|
+ "intent", intent,
|
|
|
+ "conversation_id", cid
|
|
|
));
|
|
|
}
|
|
|
|
|
|
@@ -75,7 +98,7 @@ public class ChatController {
|
|
|
: UUID.randomUUID().toString();
|
|
|
|
|
|
final String intent = retrieverService.classifyIntent(query);
|
|
|
- final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
+ final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
|
|
|
Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
|
|
|
|
|
|
@@ -101,7 +124,8 @@ public class ChatController {
|
|
|
try {
|
|
|
String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
"intent", intent,
|
|
|
- "sources", sources
|
|
|
+ "sources", sources,
|
|
|
+ "cid", cid
|
|
|
));
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
|
|
|
} catch (Exception ignored) {}
|
|
|
@@ -142,7 +166,7 @@ public class ChatController {
|
|
|
|
|
|
String intent = retrieverService.classifyIntent(query);
|
|
|
List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
|
|
|
- docs = rerank(docs, query, 5);
|
|
|
+ docs = rerankerService.rerank(docs, query, 5);
|
|
|
|
|
|
// Step 3: 构建 Prompt(含图片分析上下文)+ 联网搜索
|
|
|
List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
@@ -162,7 +186,8 @@ public class ChatController {
|
|
|
return ResponseEntity.ok(Map.of(
|
|
|
"answer", answer,
|
|
|
"sources", sources,
|
|
|
- "intent", intent
|
|
|
+ "intent", intent,
|
|
|
+ "conversation_id", cid
|
|
|
));
|
|
|
}
|
|
|
|
|
|
@@ -193,7 +218,7 @@ public class ChatController {
|
|
|
final String intent = retrieverService.classifyIntent(query);
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
|
|
|
|
|
|
- final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
+ final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
|
|
|
.data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build());
|
|
|
|
|
|
@@ -217,6 +242,7 @@ public class ChatController {
|
|
|
String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
"intent", intent,
|
|
|
"sources", sources,
|
|
|
+ "cid", cid,
|
|
|
"ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText
|
|
|
));
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
|
|
|
@@ -275,7 +301,7 @@ public class ChatController {
|
|
|
// Step 3: RAG 检索
|
|
|
String intent = retrieverService.classifyIntent(query);
|
|
|
List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
|
|
|
- docs = rerank(docs, query, 5);
|
|
|
+ docs = rerankerService.rerank(docs, query, 5);
|
|
|
|
|
|
// Step 4: 构建 Prompt + 联网搜索
|
|
|
List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
@@ -298,7 +324,8 @@ public class ChatController {
|
|
|
return ResponseEntity.ok(Map.of(
|
|
|
"answer", answer,
|
|
|
"sources", sources,
|
|
|
- "intent", intent
|
|
|
+ "intent", intent,
|
|
|
+ "conversation_id", cid
|
|
|
));
|
|
|
}
|
|
|
|
|
|
@@ -344,6 +371,92 @@ public class ChatController {
|
|
|
return sink.asFlux();
|
|
|
}
|
|
|
|
|
|
+ // ============================================================
|
|
|
+ // 文件上传 API(multipart → base64 → 复用已有对话管线)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @PostMapping("/upload-image")
|
|
|
+ public ResponseEntity<Map<String, Object>> uploadImage(
|
|
|
+ @RequestParam("file") MultipartFile file,
|
|
|
+ @RequestParam(defaultValue = "") String message,
|
|
|
+ @RequestParam(defaultValue = "") String conversationId) {
|
|
|
+
|
|
|
+ // 校验 MIME 类型
|
|
|
+ Set<String> allowed = Set.of("image/jpeg", "image/png", "image/webp", "image/bmp");
|
|
|
+ String contentType = file.getContentType();
|
|
|
+ if (contentType == null || !allowed.contains(contentType)) {
|
|
|
+ throw new IllegalArgumentException(
|
|
|
+ "不支持的图片格式: " + contentType + ",支持 jpg/png/webp/bmp");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 校验大小 ≤ 10MB
|
|
|
+ if (file.getSize() > 10 * 1024 * 1024) {
|
|
|
+ throw new IllegalArgumentException("图片大小不能超过 10MB");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 转 base64 → 委托给 ask-image
|
|
|
+ String base64;
|
|
|
+ try {
|
|
|
+ base64 = Base64.getEncoder().encodeToString(file.getBytes());
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException("读取上传文件失败", e);
|
|
|
+ }
|
|
|
+
|
|
|
+ ImageChatRequest req = new ImageChatRequest();
|
|
|
+ req.setImageBase64(base64);
|
|
|
+ req.setMimeType(contentType);
|
|
|
+ req.setMessage(message);
|
|
|
+ req.setConversationId(
|
|
|
+ conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
|
|
|
+ return chatAskImage(req);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/upload-media")
|
|
|
+ public ResponseEntity<Map<String, Object>> uploadMedia(
|
|
|
+ @RequestParam("file") MultipartFile file,
|
|
|
+ @RequestParam(defaultValue = "") String message,
|
|
|
+ @RequestParam(defaultValue = "") String conversationId) {
|
|
|
+
|
|
|
+ String contentType = file.getContentType();
|
|
|
+ if (contentType == null) {
|
|
|
+ throw new IllegalArgumentException("无法识别的媒体类型");
|
|
|
+ }
|
|
|
+
|
|
|
+ String mediaType;
|
|
|
+ long maxSize;
|
|
|
+ if (contentType.startsWith("image/")) {
|
|
|
+ mediaType = "image";
|
|
|
+ maxSize = 10 * 1024 * 1024; // 10MB
|
|
|
+ } else if (contentType.startsWith("video/")) {
|
|
|
+ mediaType = "video";
|
|
|
+ maxSize = 50 * 1024 * 1024; // 50MB
|
|
|
+ } else {
|
|
|
+ throw new IllegalArgumentException(
|
|
|
+ "不支持的媒体格式: " + contentType + ",支持 jpg/png/webp/bmp/mp4/mov/avi/webm");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.getSize() > maxSize) {
|
|
|
+ throw new IllegalArgumentException(
|
|
|
+ "文件大小不能超过 " + (maxSize / 1024 / 1024) + "MB");
|
|
|
+ }
|
|
|
+
|
|
|
+ String base64;
|
|
|
+ try {
|
|
|
+ base64 = Base64.getEncoder().encodeToString(file.getBytes());
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new RuntimeException("读取上传文件失败", e);
|
|
|
+ }
|
|
|
+
|
|
|
+ MultimodalChatRequest req = new MultimodalChatRequest();
|
|
|
+ req.setMessage(message);
|
|
|
+ req.setMediaType(mediaType);
|
|
|
+ req.setMediaBase64(base64);
|
|
|
+ req.setMediaMime(contentType);
|
|
|
+ req.setConversationId(
|
|
|
+ conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
|
|
|
+ return chatAskMultimodal(req);
|
|
|
+ }
|
|
|
+
|
|
|
/** 流式多模态:OCR 完成后,走 RAG + 生成 */
|
|
|
private void doStreamAnswer(Sinks.Many<ServerSentEvent<String>> sink, String cid,
|
|
|
MultimodalChatRequest request, String ocrText, String mediaLabel) {
|
|
|
@@ -364,7 +477,7 @@ public class ChatController {
|
|
|
final String intent = retrieverService.classifyIntent(query);
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
|
|
|
|
|
|
- final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
+ final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
|
|
|
final boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
|
|
|
.data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
|
|
|
@@ -391,7 +504,7 @@ public class ChatController {
|
|
|
final List<Map<String, Object>> sources = buildSources(docs);
|
|
|
try {
|
|
|
String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
- "intent", intent, "sources", sources,
|
|
|
+ "intent", intent, "sources", sources, "cid", cid,
|
|
|
"ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText));
|
|
|
sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
|
|
|
} catch (Exception ignored) {}
|
|
|
@@ -470,16 +583,43 @@ public class ChatController {
|
|
|
.limit(3) // 最多 3 条
|
|
|
.map(d -> {
|
|
|
String content = (String) d.getOrDefault("content", "");
|
|
|
+ String drugName = (String) d.getOrDefault("name", "");
|
|
|
String storedSection = (String) d.getOrDefault("section", "");
|
|
|
- String section = realSection(content, storedSection);
|
|
|
- String drugName = extractDrugName(content);
|
|
|
+ String sourceVersion = (String) d.getOrDefault("source_version", "");
|
|
|
+ String sourceVolume = (String) d.getOrDefault("source_volume", "");
|
|
|
+ String category = (String) d.getOrDefault("category", "");
|
|
|
+
|
|
|
+ // 优先用 DB 元数据,回退到内容解析
|
|
|
+ if (drugName == null || drugName.isEmpty()) {
|
|
|
+ drugName = extractDrugName(content);
|
|
|
+ }
|
|
|
+ String sectionDisplay = SECTION_DISPLAY.getOrDefault(storedSection, storedSection);
|
|
|
+ if (sectionDisplay == null || sectionDisplay.isEmpty()) {
|
|
|
+ sectionDisplay = realSection(content, storedSection);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建完整来源引用
|
|
|
+ StringBuilder sourceBuilder = new StringBuilder();
|
|
|
+ if (!sourceVersion.isEmpty()) sourceBuilder.append(sourceVersion);
|
|
|
+ if (!sourceVolume.isEmpty()) {
|
|
|
+ if (!sourceBuilder.isEmpty()) sourceBuilder.append(" ");
|
|
|
+ sourceBuilder.append(sourceVolume);
|
|
|
+ }
|
|
|
+ String src = (String) d.getOrDefault("source", "");
|
|
|
+ if (!src.isEmpty()) {
|
|
|
+ if (!sourceBuilder.isEmpty()) sourceBuilder.append(" ");
|
|
|
+ sourceBuilder.append(src);
|
|
|
+ }
|
|
|
+ String fullSource = sourceBuilder.toString();
|
|
|
+
|
|
|
content = content.replaceAll("\\s*来源:.*$", "");
|
|
|
content = content.replaceAll("[\\r\\n]+", " ").trim();
|
|
|
String excerpt = content.length() > 500 ? content.substring(0, 500) + "…" : content;
|
|
|
return Map.<String, Object>of(
|
|
|
"name", drugName,
|
|
|
- "section", section,
|
|
|
- "source", d.getOrDefault("source", ""),
|
|
|
+ "section", sectionDisplay,
|
|
|
+ "category", category != null ? category : "",
|
|
|
+ "source", fullSource,
|
|
|
"excerpt", excerpt
|
|
|
);
|
|
|
})
|
|
|
@@ -619,21 +759,6 @@ public class ChatController {
|
|
|
return storedSection;
|
|
|
}
|
|
|
|
|
|
- private List<Map<String, Object>> rerank(List<Map<String, Object>> docs, String query, int topK) {
|
|
|
- if (docs.size() <= topK) return docs;
|
|
|
- docs.sort((a, b) -> {
|
|
|
- double sa = toDouble(a.get("similarity"));
|
|
|
- double sb = toDouble(b.get("similarity"));
|
|
|
- return Double.compare(sb, sa);
|
|
|
- });
|
|
|
- return docs.subList(0, Math.min(topK, docs.size()));
|
|
|
- }
|
|
|
-
|
|
|
- private double toDouble(Object o) {
|
|
|
- if (o instanceof Number n) return n.doubleValue();
|
|
|
- return 0;
|
|
|
- }
|
|
|
-
|
|
|
private String cleanAnswer(String text) {
|
|
|
if (text == null) return "";
|
|
|
// 去除多余空白行(保留单个换行),修复 Qwen 常见格式问题
|