Ver código fonte

调整多模态支持

liuchengsen 1 mês atrás
pai
commit
9b49b20a5e

+ 141 - 134
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -83,46 +83,45 @@ public class ChatController {
                 : UUID.randomUUID().toString();
 
         final String intent = retrieverService.classifyIntent(query);
-        final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
 
-        Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
-
-        try {
-            sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
-            sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Retrieving...").build());
-            sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Matched " + docs.size() + " records, generating...").build());
-
-            final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
-            StringBuilder fullAnswer = new StringBuilder();
-
-            llmService.chatStream(messages)
-                    .doOnNext(token -> {
-                        fullAnswer.append(token);
-                        sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
-                    })
-                    .doOnComplete(() -> {
-                        final List<Map<String, Object>> sources = buildSources(docs);
-                        try {
-                            String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
-                                    "intent", intent,
-                                    "sources", sources,
-                                    "conversation_id", cid
-                            ));
-                            sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
-                        } catch (Exception ignored) {}
-
-                        String finalAnswer = cleanAnswer(fullAnswer.toString());
-                        persistenceService.saveMessage(cid, "user", query, intent, null);
-                        persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
-                        sink.tryEmitComplete();
-                    })
-                    .doOnError(e -> sink.tryEmitError(e))
-                    .subscribe();
-        } catch (Exception e) {
-            sink.tryEmitError(e);
-        }
-
-        return sink.asFlux();
+        return retrieverService.searchReactive(query, intent, 20)
+                .map(docs -> rerankerService.rerank(docs, query, 5))
+                .flatMapMany(docs -> {
+                    Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
+
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Retrieving...").build());
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Matched " + docs.size() + " records, generating...").build());
+
+                    final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+                    StringBuilder fullAnswer = new StringBuilder();
+
+                    llmService.chatStream(messages)
+                            .doOnNext(token -> {
+                                fullAnswer.append(token);
+                                sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
+                            })
+                            .doOnComplete(() -> {
+                                final List<Map<String, Object>> sources = buildSources(docs);
+                                try {
+                                    String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
+                                            "intent", intent,
+                                            "sources", sources,
+                                            "conversation_id", cid
+                                    ));
+                                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+                                } catch (Exception ignored) {}
+
+                                String finalAnswer = cleanAnswer(fullAnswer.toString());
+                                persistenceService.saveMessage(cid, "user", query, intent, null);
+                                persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+                                sink.tryEmitComplete();
+                            })
+                            .doOnError(sink::tryEmitError)
+                            .subscribe();
+
+                    return sink.asFlux();
+                });
     }
 
     // ============================================================
@@ -186,68 +185,69 @@ public class ChatController {
             return sink.asFlux();
         }
 
-        try {
-            sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("正在分析图片(OCR 文字识别)...").build());
-
-            // Step 1: Qwen VL 分析图片
-            StringBuilder ocrBuilder = new StringBuilder();
-            llmService.analyzeImageStream(request.getImageBase64(), request.getMimeType(),
-                    "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。")
-                    .doOnNext(ocrBuilder::append)
-                    .doOnComplete(() -> {
-                        String ocrText = ocrBuilder.toString();
-                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("图片分析完成,正在检索药典知识库...").build());
-
-                        // Step 2: 拼接查询 → RAG
-                        String query = (!request.getMessage().isBlank())
-                                ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
-                                : ocrText;
-                        final String intent = retrieverService.classifyIntent(query);
-                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
-
-                        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());
-
-                        // Step 3: 构建 Prompt + 联网搜索流式生成
-                        final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
-                        String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
-                        messages.get(0).put("content", messages.get(0).get("content") + imageContext);
-
-                        StringBuilder fullAnswer = new StringBuilder();
-                        llmService.chatStream(messages, true)
-                                .doOnNext(token -> {
-                                    fullAnswer.append(token);
-                                    sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
-                                })
-                                .doOnComplete(() -> {
-                                    
-                                    final List<Map<String, Object>> sources = buildSources(docs);
-                                    try {
-                                        String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
-                                                "intent", intent,
-                                                "sources", sources,
-                                                "conversation_id", cid,
-                                                "ocr_text", ocrText
-                                        ));
-                                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
-                                    } catch (Exception ignored) {}
-
-                                    String finalAnswer = cleanAnswer(fullAnswer.toString());
-                                    persistenceService.saveMessage(cid, "user",
-                                            request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
-                                            intent, null);
-                                    persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
-                                    sink.tryEmitComplete();
-                                })
-                                .doOnError(sink::tryEmitError)
-                                .subscribe();
-                    })
-                    .doOnError(sink::tryEmitError)
-                    .subscribe();
-        } catch (Exception e) {
-            sink.tryEmitError(e);
-        }
+        sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("正在分析图片(OCR 文字识别)...").build());
+
+        // Step 1: Qwen VL 分析图片(流式),收集完整结果
+        StringBuilder ocrBuilder = new StringBuilder();
+        llmService.analyzeImageStream(request.getImageBase64(), request.getMimeType(),
+                "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。")
+                .doOnNext(ocrBuilder::append)
+                .collectList()
+                .flatMap(tokens -> {
+                    String ocrText = ocrBuilder.toString();
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("图片分析完成,正在检索药典知识库...").build());
+
+                    // Step 2: 拼接查询 → RAG(响应式)
+                    String query = (!request.getMessage().isBlank())
+                            ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
+                            : ocrText;
+                    final String intent = retrieverService.classifyIntent(query);
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
+
+                    return retrieverService.searchReactive(query, intent, 20)
+                            .map(docs -> rerankerService.rerank(docs, query, 5))
+                            .map(docs -> {
+                                sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                                        .data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build());
+
+                                // Step 3: 构建 Prompt + 联网搜索流式生成
+                                final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+                                String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
+                                messages.get(0).put("content", messages.get(0).get("content") + imageContext);
+
+                                StringBuilder fullAnswer = new StringBuilder();
+                                llmService.chatStream(messages, true)
+                                        .doOnNext(token -> {
+                                            fullAnswer.append(token);
+                                            sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
+                                        })
+                                        .doOnComplete(() -> {
+                                            final List<Map<String, Object>> sources = buildSources(docs);
+                                            try {
+                                                String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
+                                                        "intent", intent,
+                                                        "sources", sources,
+                                                        "conversation_id", cid,
+                                                        "ocr_text", ocrText
+                                                ));
+                                                sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+                                            } catch (Exception ignored) {}
+
+                                            String finalAnswer = cleanAnswer(fullAnswer.toString());
+                                            persistenceService.saveMessage(cid, "user",
+                                                    request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
+                                                    intent, null);
+                                            persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+                                            sink.tryEmitComplete();
+                                        })
+                                        .doOnError(sink::tryEmitError)
+                                        .subscribe();
+
+                                return docs; // dummy return for map
+                            });
+                })
+                .doOnError(sink::tryEmitError)
+                .subscribe();
 
         return sink.asFlux();
     }
@@ -461,42 +461,49 @@ 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 = 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() + " 条药典资料,生成回答中"
-                        + (enableSearch ? "(已启用联网搜索)" : "") + "...").build());
-
-        final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
-        if (!ocrText.isEmpty()) {
-            messages.get(0).put("content",
-                    messages.get(0).get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
-        }
 
-        // 发送回答 section header
-        sink.tryEmitNext(ServerSentEvent.<String>builder().data("\n【📚 药典参考回答】\n\n").build());
+        retrieverService.searchReactive(query, intent, 20)
+                .map(docs -> rerankerService.rerank(docs, query, 5))
+                .subscribe(docs -> {
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
+                            .data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
+                                    + (enableSearch ? "(已启用联网搜索)" : "") + "...").build());
+
+                    final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
+                    if (!ocrText.isEmpty()) {
+                        messages.get(0).put("content",
+                                messages.get(0).get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
+                    }
 
-        StringBuilder fullAnswer = new StringBuilder();
-        llmService.chatStream(messages, enableSearch)
-                .doOnNext(token -> {
-                    fullAnswer.append(token);
-                    sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
-                })
-                .doOnComplete(() -> {
-                    final List<Map<String, Object>> sources = buildSources(docs);
-                    try {
-                        String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
-                                "intent", intent, "sources", sources, "conversation_id", cid,
-                                "ocr_text", ocrText));
-                        sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
-                    } catch (Exception ignored) {}
-                    String finalAnswer = cleanAnswer(fullAnswer.toString());
-                    String userMsg = !request.getMessage().isBlank() ? request.getMessage()
-                            : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : "";
-                    persistenceService.saveMessage(cid, "user", userMsg, intent, null);
-                    persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
-                    sink.tryEmitComplete();
-                })
+                    // 发送回答 section header
+                    sink.tryEmitNext(ServerSentEvent.<String>builder().data("\n【📚 药典参考回答】\n\n").build());
+
+                    StringBuilder fullAnswer = new StringBuilder();
+                    llmService.chatStream(messages, enableSearch)
+                            .doOnNext(token -> {
+                                fullAnswer.append(token);
+                                sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
+                            })
+                            .doOnComplete(() -> {
+                                final List<Map<String, Object>> sources = buildSources(docs);
+                                try {
+                                    String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
+                                            "intent", intent, "sources", sources, "conversation_id", cid,
+                                            "ocr_text", ocrText));
+                                    sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+                                } catch (Exception ignored) {}
+                                String finalAnswer = cleanAnswer(fullAnswer.toString());
+                                String userMsg = !request.getMessage().isBlank() ? request.getMessage()
+                                        : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : "";
+                                persistenceService.saveMessage(cid, "user", userMsg, intent, null);
+                                persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+                                sink.tryEmitComplete();
+                            })
+                            .doOnError(sink::tryEmitError)
+                            .subscribe();
+                }, sink::tryEmitError);
+    }
                 .doOnError(sink::tryEmitError)
                 .subscribe();
     }

+ 55 - 2
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -282,14 +282,20 @@ public class LLMService {
     }
 
     // ============================================================
-    // Embedding(含重试:最多 3 次,间隔 1s/2s
+    // Embedding(同步版:供非 reactor 线程调用
     // ============================================================
 
     public List<Float> embed(String text) {
-        return embed(List.of(text)).get(0);
+        List<List<Float>> result = embedBatch(List.of(text));
+        if (result.isEmpty()) return List.of();
+        return result.get(0);
     }
 
     public List<List<Float>> embed(List<String> texts) {
+        return embedBatch(texts);
+    }
+
+    private List<List<Float>> embedBatch(List<String> texts) {
         int maxRetries = 3;
         for (int attempt = 1; attempt <= maxRetries; attempt++) {
             try {
@@ -328,6 +334,53 @@ public class LLMService {
         return List.of();
     }
 
+    // ============================================================
+    // Embedding(响应式版:供 reactor 线程 / SSE 端点调用)
+    // ============================================================
+
+    public Mono<List<Float>> embedReactive(String text) {
+        return embedReactiveBatch(List.of(text))
+                .map(result -> result.isEmpty() ? List.<Float>of() : result.get(0));
+    }
+
+    private Mono<List<List<Float>>> embedReactiveBatch(List<String> texts) {
+        return embedClient.post()
+                .uri(props.getEmbeddingUrl())
+                .contentType(MediaType.APPLICATION_JSON)
+                .bodyValue(Map.of(
+                        "model", props.getEmbeddingModel(),
+                        "input", Map.of("texts", texts),
+                        "parameters", Map.of("text_type", "query")
+                ))
+                .retrieve()
+                .bodyToMono(String.class)
+                .flatMap(response -> {
+                    try {
+                        JsonNode node = mapper.readTree(response);
+                        var embeddings = node.get("output").get("embeddings");
+                        List<List<Float>> result = new ArrayList<>();
+                        for (JsonNode e : embeddings) {
+                            List<Float> vec = new ArrayList<>();
+                            for (JsonNode v : e.get("embedding")) {
+                                vec.add(v.asDouble().floatValue());
+                            }
+                            result.add(vec);
+                        }
+                        return Mono.just(result);
+                    } catch (Exception ex) {
+                        return Mono.error(ex);
+                    }
+                })
+                .retryWhen(reactor.util.retry.Retry.backoff(3, java.time.Duration.ofSeconds(1))
+                        .maxBackoff(java.time.Duration.ofSeconds(4))
+                        .doBeforeRetry(rs -> log.warn("Embedding API 失败,第 {} 次重试...", rs.totalRetries() + 1))
+                        .onRetryExhaustedThrow((rb, rs) -> {
+                            log.error("Embedding API 重试 3 次后仍失败,降级返回空");
+                            return new RuntimeException("Embedding exhausted");
+                        }))
+                .onErrorReturn(List.of());
+    }
+
     // ============================================================
     // Helpers
     // ============================================================

+ 14 - 0
backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java

@@ -203,6 +203,7 @@ public class RetrieverService {
     /** 检索:仅 2025 年版药典,AI 兜底补充 */
     public List<Map<String, Object>> search(String query, String intent, int topK) {
         List<Float> vec = embedWithCache(query);
+        if (vec.isEmpty()) return List.of();
         String vecStr = vec.stream()
                 .map(String::valueOf)
                 .collect(Collectors.joining(",", "[", "]"));
@@ -211,6 +212,19 @@ public class RetrieverService {
         return searchWithVersion(vecStr, drugName, "2025年版", topK);
     }
 
+    /** 响应式检索:供 SSE 端点使用,避免 reactor 线程中 block() */
+    public reactor.core.publisher.Mono<List<Map<String, Object>>> searchReactive(String query, String intent, int topK) {
+        return llmService.embedReactive(query)
+                .map(vec -> {
+                    if (vec.isEmpty()) return List.<Map<String, Object>>of();
+                    String vecStr = vec.stream()
+                            .map(String::valueOf)
+                            .collect(Collectors.joining(",", "[", "]"));
+                    String drugName = extractDrugName(query);
+                    return searchWithVersion(vecStr, drugName, "2025年版", topK);
+                });
+    }
+
     /** 带版本过滤的向量检索。version 为 null 时不限制版本。 */
     private List<Map<String, Object>> searchWithVersion(
             String vecStr, String drugName, String version, int topK) {

+ 7 - 6
static/index.html

@@ -7,16 +7,17 @@
 <style>
 :root{--bk:#002FA7;--bk-l:#E8EDF8;--rd:#C41E3A;--rd-l:#FDEAEE;--bg:#F5F6FA;--card:#fff;--text:#1a1a2e;--sub:#888;--radius:12px}
 *{margin:0;padding:0;box-sizing:border-box}
-body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);height:100dvh;display:flex;flex-direction:column}
-.header{background:linear-gradient(135deg,var(--bk),#1a3fbf);color:#fff;padding:12px 14px;text-align:center;flex-shrink:0;position:relative;overflow:hidden}
+body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);height:100dvh;display:flex;flex-direction:column;overflow:hidden}
+.header{background:linear-gradient(135deg,var(--bk),#1a3fbf);color:#fff;padding:12px 14px;text-align:center;flex-shrink:0;position:relative;overflow:hidden;z-index:10}
 .header::after{content:'';position:absolute;top:-40px;right:-30px;width:120px;height:120px;background:var(--rd);border-radius:50%;opacity:.15}
 .header h1{font-size:17px;font-weight:700;position:relative;z-index:1}
 .header .sub{font-size:11px;opacity:.85;margin-top:2px;position:relative;z-index:1}
 .header .badge{display:inline-block;font-size:10px;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;margin-top:4px;position:relative;z-index:1}
-.nav{display:flex;background:#fff;border-bottom:1px solid #e0e0e0;flex-shrink:0}
+.nav{display:flex;background:#fff;border-bottom:1px solid #e0e0e0;flex-shrink:0;z-index:10}
 .nav-btn{flex:1;padding:12px;text-align:center;font-size:13px;color:var(--sub);cursor:pointer;border:none;background:none;border-bottom:2px solid transparent;transition:all .2s;min-height:44px}
 .nav-btn.active{color:var(--bk);border-bottom-color:var(--bk);font-weight:600}
-.chat{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:12px}
+.chat{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:12px;-webkit-overflow-scrolling:touch}
+	#tabChat,#tabDrugs{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
 .msg{max-width:85%;padding:10px 14px;border-radius:var(--radius);font-size:14px;line-height:1.7;word-break:break-word;animation:fadeIn .3s}
 .msg.user{align-self:flex-end;background:var(--bk);color:#fff;border-radius:var(--radius) var(--radius) 4px var(--radius)}
 .msg.ai{align-self:flex-start;background:var(--card);color:var(--text);border-radius:var(--radius) var(--radius) var(--radius) 4px;box-shadow:0 1px 4px rgba(0,0,0,.06);border-left:3px solid var(--bk)}
@@ -55,7 +56,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 .ext-link{color:#002FA7;text-decoration:underline;word-break:break-all}
 .ext-link:hover{color:#C41E3A}
 .ext-link:visited{color:#6a1b9a}
-.input-area{display:flex;padding:10px 14px;background:#fff;border-top:1px solid #e0e0e0;gap:8px;flex-shrink:0}
+.input-area{display:flex;padding:10px 14px;background:#fff;border-top:1px solid #e0e0e0;gap:8px;flex-shrink:0;z-index:10}
+	#previewArea{flex-shrink:0}
 .input-area input{flex:1;padding:10px 14px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none;min-height:44px}
 .input-area input:focus{border-color:var(--bk);box-shadow:0 0 0 2px rgba(0,47,167,.12)}
 .btn{padding:8px 18px;border:none;border-radius:24px;font-size:14px;font-weight:700;cursor:pointer;min-height:44px;white-space:nowrap}
@@ -100,7 +102,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 <div class="header">
   <h1>AI 药典助手</h1>
   <div class="sub">基于《中华人民共和国药典》& 通义千问</div>
-  <div class="badge">连接知识库</div>
 </div>
 <div class="nav">
   <button class="nav-btn active" data-tab="chat" onclick="switchTab('chat')">AI 对话</button>