|
@@ -15,7 +15,7 @@ import org.springframework.http.codec.ServerSentEvent;
|
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
import reactor.core.publisher.Flux;
|
|
import reactor.core.publisher.Flux;
|
|
|
-import reactor.core.publisher.Sinks;
|
|
|
|
|
|
|
+import reactor.core.publisher.Mono;
|
|
|
|
|
|
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
@@ -159,61 +159,69 @@ public class ChatController {
|
|
|
final long t0 = System.currentTimeMillis();
|
|
final long t0 = System.currentTimeMillis();
|
|
|
log.info("[chatStream] intent={}, query={}", intent, query.substring(0, Math.min(50, query.length())));
|
|
log.info("[chatStream] intent={}, query={}", intent, query.substring(0, Math.min(50, query.length())));
|
|
|
|
|
|
|
|
- return retrieverService.searchReactive(query, intent, 20)
|
|
|
|
|
|
|
+ // 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
|
|
|
|
|
+ // 纯 Reactor 管道(零裸 subscribe),连接断开时整条链路自动取消到百炼。
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("intent").data(intent).build(),
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status").data("Retrieving...").build()
|
|
|
|
|
+ ).concatWith(retrieverService.searchReactive(query, intent, 20)
|
|
|
.map(docs -> rerankerService.rerank(docs, query, 5))
|
|
.map(docs -> rerankerService.rerank(docs, query, 5))
|
|
|
.flatMapMany(docs -> {
|
|
.flatMapMany(docs -> {
|
|
|
long t2 = System.currentTimeMillis();
|
|
long t2 = System.currentTimeMillis();
|
|
|
log.info("[chatStream] search+rerank done, docs={}, elapsed={}ms", docs.size(), t2 - t0);
|
|
log.info("[chatStream] search+rerank done, docs={}, elapsed={}ms", docs.size(), t2 - t0);
|
|
|
- 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);
|
|
final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
- StringBuilder fullAnswer = new StringBuilder();
|
|
|
|
|
final long t3 = System.currentTimeMillis();
|
|
final long t3 = System.currentTimeMillis();
|
|
|
log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
|
|
log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
|
|
|
t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
|
|
t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
|
|
|
|
|
|
|
|
- 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());
|
|
|
|
|
- long t4 = System.currentTimeMillis();
|
|
|
|
|
- log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
|
|
|
|
|
- persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
|
|
|
|
|
- persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
|
|
+ // 用 StringBuilder 攒完整答案(Flux 内部同步操作,无线程安全问题)
|
|
|
|
|
+ var fullAnswerBuf = new StringBuilder();
|
|
|
|
|
+
|
|
|
|
|
+ // 纯管道拼接,无裸 subscribe:
|
|
|
|
|
+ // [status] → [token1, token2, ...] → [meta] → complete
|
|
|
|
|
+ Flux<ServerSentEvent<String>> contentFlux = llmService.chatStream(messages)
|
|
|
|
|
+ .map(token -> {
|
|
|
|
|
+ fullAnswerBuf.append(token);
|
|
|
|
|
+ return ServerSentEvent.<String>builder().data(token).build();
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // 写入全局缓存 + 清除 PENDING 标记
|
|
|
|
|
- qaCache.put(normalized, finalAnswer, intent, sources);
|
|
|
|
|
|
|
+ Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
|
|
|
|
|
+ String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
|
|
|
|
|
+ final List<Map<String, Object>> sources = buildSources(docs);
|
|
|
|
|
+ String meta;
|
|
|
|
|
+ try {
|
|
|
|
|
+ meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
|
|
+ "intent", intent,
|
|
|
|
|
+ "sources", sources,
|
|
|
|
|
+ "conversation_id", cid
|
|
|
|
|
+ ));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ meta = "{}";
|
|
|
|
|
+ }
|
|
|
|
|
+ long t4 = System.currentTimeMillis();
|
|
|
|
|
+ log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
+ qaCache.put(normalized, finalAnswer, intent, sources);
|
|
|
|
|
+
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("meta").data(meta).build()
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> headFlux = Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
+ .data("Matched " + docs.size() + " records, generating...").build()
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- sink.tryEmitComplete();
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ return Flux.concat(headFlux, contentFlux, tailFlux)
|
|
|
.doOnError(e -> {
|
|
.doOnError(e -> {
|
|
|
long t4 = System.currentTimeMillis();
|
|
long t4 = System.currentTimeMillis();
|
|
|
- log.error("[chatStream] LLM error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
|
|
|
|
|
- // 异常时清除 PENDING 标记,避免后续同问题请求被锁死
|
|
|
|
|
|
|
+ log.error("[chatStream] error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
|
|
|
qaCache.removePending(normalized);
|
|
qaCache.removePending(normalized);
|
|
|
- sink.tryEmitError(e);
|
|
|
|
|
- })
|
|
|
|
|
- .subscribe();
|
|
|
|
|
-
|
|
|
|
|
- return sink.asFlux();
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ });
|
|
|
|
|
+ }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 将缓存命中结果以流式 SSE 形式返回 */
|
|
/** 将缓存命中结果以流式 SSE 形式返回 */
|
|
@@ -299,80 +307,78 @@ public class ChatController {
|
|
|
? request.getConversationId()
|
|
? request.getConversationId()
|
|
|
: UUID.randomUUID().toString();
|
|
: UUID.randomUUID().toString();
|
|
|
|
|
|
|
|
- Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
|
|
|
|
|
-
|
|
|
|
|
// 校验图片数据
|
|
// 校验图片数据
|
|
|
if (request.getImageBase64() == null || request.getImageBase64().isBlank()) {
|
|
if (request.getImageBase64() == null || request.getImageBase64().isBlank()) {
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("图片数据为空,请重新上传").build());
|
|
|
|
|
- sink.tryEmitComplete();
|
|
|
|
|
- return sink.asFlux();
|
|
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status").data("图片数据为空,请重新上传").build()
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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.getFirst().put("content", messages.getFirst().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(userKey, cid, "user",
|
|
|
|
|
- request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
|
|
|
|
|
- intent, null);
|
|
|
|
|
- persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
- sink.tryEmitComplete();
|
|
|
|
|
- })
|
|
|
|
|
- .doOnError(sink::tryEmitError)
|
|
|
|
|
- .subscribe();
|
|
|
|
|
-
|
|
|
|
|
- return docs; // dummy return for map
|
|
|
|
|
- });
|
|
|
|
|
- })
|
|
|
|
|
- .doOnError(sink::tryEmitError)
|
|
|
|
|
- .subscribe();
|
|
|
|
|
-
|
|
|
|
|
- return sink.asFlux();
|
|
|
|
|
|
|
+ // 先发状态,不等图片分析完成
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status").data("正在分析图片(OCR 文字识别)...").build()
|
|
|
|
|
+ ).concatWith(
|
|
|
|
|
+ llmService.analyzeImageStream(request.getImageBase64(), request.getMimeType(),
|
|
|
|
|
+ "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。")
|
|
|
|
|
+ .collectList()
|
|
|
|
|
+ .flatMapMany(tokens -> {
|
|
|
|
|
+ String ocrText = String.join("", tokens);
|
|
|
|
|
+
|
|
|
|
|
+ String query = (!request.getMessage().isBlank())
|
|
|
|
|
+ ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
|
|
|
|
|
+ : ocrText;
|
|
|
|
|
+ final String intent = retrieverService.classifyIntent(query);
|
|
|
|
|
+
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status").data("图片分析完成,正在检索药典知识库...").build(),
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("intent").data(intent).build()
|
|
|
|
|
+ ).concatWith(retrieverService.searchReactive(query, intent, 20)
|
|
|
|
|
+ .map(docs -> rerankerService.rerank(docs, query, 5))
|
|
|
|
|
+ .flatMapMany(docs -> {
|
|
|
|
|
+ final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
|
|
+ String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
|
|
|
|
|
+ messages.getFirst().put("content", messages.getFirst().get("content") + imageContext);
|
|
|
|
|
+
|
|
|
|
|
+ var fullAnswerBuf = new StringBuilder();
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> contentFlux = llmService.chatStream(messages, true)
|
|
|
|
|
+ .map(token -> {
|
|
|
|
|
+ fullAnswerBuf.append(token);
|
|
|
|
|
+ return ServerSentEvent.<String>builder().data(token).build();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
|
|
|
|
|
+ String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
|
|
|
|
|
+ final List<Map<String, Object>> sources = buildSources(docs);
|
|
|
|
|
+ String meta;
|
|
|
|
|
+ try {
|
|
|
|
|
+ meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
|
|
+ "intent", intent,
|
|
|
|
|
+ "sources", sources,
|
|
|
|
|
+ "conversation_id", cid,
|
|
|
|
|
+ "ocr_text", ocrText
|
|
|
|
|
+ ));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ meta = "{}";
|
|
|
|
|
+ }
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "user",
|
|
|
|
|
+ request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
|
|
|
|
|
+ intent, null);
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("meta").data(meta).build()
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> headFlux = Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
+ .data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build()
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return Flux.concat(headFlux, contentFlux, tailFlux);
|
|
|
|
|
+ }));
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ============================================================
|
|
// ============================================================
|
|
@@ -446,37 +452,111 @@ public class ChatController {
|
|
|
&& request.getMediaType() != null && !request.getMediaType().isBlank();
|
|
&& request.getMediaType() != null && !request.getMediaType().isBlank();
|
|
|
final String mediaLabel = hasMedia && "video".equals(request.getMediaType()) ? "视频" : "图片";
|
|
final String mediaLabel = hasMedia && "video".equals(request.getMediaType()) ? "视频" : "图片";
|
|
|
|
|
|
|
|
- Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
|
|
|
|
|
|
|
+ if (hasMedia) {
|
|
|
|
|
+ // 先发 OCR section header
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
+ .data("🔍 正在分析" + mediaLabel + "...").build(),
|
|
|
|
|
+ ServerSentEvent.<String>builder().data("【📷 " + mediaLabel + "分析】\n\n").build()
|
|
|
|
|
+ ).concatWith(
|
|
|
|
|
+ // 用 collectList 收集流式结果,同时攒 OCR 文本,全程在管道内无阻塞
|
|
|
|
|
+ llmService.analyzeMediaStream(request.getMediaBase64(), request.getMediaType(),
|
|
|
|
|
+ request.getMediaMime(), "")
|
|
|
|
|
+ .flatMap(token -> Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().data(token).build() // 前端实时看到
|
|
|
|
|
+ ))
|
|
|
|
|
+ .concatWith(Flux.just(ServerSentEvent.<String>builder().data("\n\n").build()))
|
|
|
|
|
+ // collectList 后再 flatMapMany 接 RAG——Reactor 取消时自动 abort
|
|
|
|
|
+ .collectList()
|
|
|
|
|
+ .flatMapMany(events -> {
|
|
|
|
|
+ // 从已发送的事件中拼回 OCR 文本(不额外调 API)
|
|
|
|
|
+ StringBuilder ocrBuilder = new StringBuilder();
|
|
|
|
|
+ for (ServerSentEvent<String> e : events) {
|
|
|
|
|
+ String d = e.data();
|
|
|
|
|
+ if (d != null && !d.equals("\n\n")) ocrBuilder.append(d);
|
|
|
|
|
+ }
|
|
|
|
|
+ String mediaOcr = ocrBuilder.toString().trim();
|
|
|
|
|
+ // 不重复发送 OCR 事件(已经通过 analyzeMediaStream 实时发送过了)
|
|
|
|
|
+ return buildRagPipeline(cid,
|
|
|
|
|
+ request.getMessage(), mediaOcr, userKey, mediaLabel);
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return buildRagPipeline(cid, request.getMessage(), "", userKey, "");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- if (hasMedia) {
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
- .data("🔍 正在分析" + mediaLabel + "...").build());
|
|
|
|
|
- // 先发 OCR section header
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder()
|
|
|
|
|
- .data("【📷 " + mediaLabel + "分析】\n\n").build());
|
|
|
|
|
-
|
|
|
|
|
- StringBuilder ocrBuilder = new StringBuilder();
|
|
|
|
|
- llmService.analyzeMediaStream(request.getMediaBase64(), request.getMediaType(),
|
|
|
|
|
- request.getMediaMime(), "")
|
|
|
|
|
- .doOnNext(token -> {
|
|
|
|
|
- ocrBuilder.append(token);
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
|
|
|
|
|
- })
|
|
|
|
|
- .doOnComplete(() -> {
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().data("\n\n").build());
|
|
|
|
|
- doStreamAnswer(sink, cid, request, ocrBuilder.toString(), mediaLabel, userKey);
|
|
|
|
|
- })
|
|
|
|
|
- .doOnError(sink::tryEmitError)
|
|
|
|
|
- .subscribe();
|
|
|
|
|
- } else {
|
|
|
|
|
- doStreamAnswer(sink, cid, request, "", "", userKey);
|
|
|
|
|
- }
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- sink.tryEmitError(e);
|
|
|
|
|
|
|
+ /** 构建 RAG → 百炼流式管道(纯 Reactor,零裸 subscribe) */
|
|
|
|
|
+ private Flux<ServerSentEvent<String>> buildRagPipeline(String cid, String rawMsg,
|
|
|
|
|
+ String ocrText, String userKey,
|
|
|
|
|
+ String mediaLabel) {
|
|
|
|
|
+ String rq = rawMsg != null ? rawMsg.trim() : "";
|
|
|
|
|
+ if (!rq.isEmpty() && ocrText != null && !ocrText.isEmpty()) {
|
|
|
|
|
+ rq = rq + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
|
|
|
|
|
+ } else if (ocrText != null && !ocrText.isEmpty()) {
|
|
|
|
|
+ rq = ocrText;
|
|
|
|
|
+ } else if (rq.isEmpty()) {
|
|
|
|
|
+ rq = "请介绍一下自己";
|
|
|
}
|
|
}
|
|
|
|
|
+ final String query = rq;
|
|
|
|
|
+ final String intent = retrieverService.classifyIntent(query);
|
|
|
|
|
+ final boolean enableSearch = (ocrText != null && !ocrText.isEmpty()) || props.isEnableWebSearch();
|
|
|
|
|
+
|
|
|
|
|
+ // 如果等待检索,先发状态
|
|
|
|
|
+ Flux<ServerSentEvent<String>> prefixFlux = (ocrText != null && !ocrText.isEmpty())
|
|
|
|
|
+ ? Flux.just(ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
+ .data("📚 检索药典知识库...").build())
|
|
|
|
|
+ : Flux.just(ServerSentEvent.<String>builder().event("intent").data(intent).build());
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> intentFlux = (ocrText != null && !ocrText.isEmpty())
|
|
|
|
|
+ ? Flux.just(ServerSentEvent.<String>builder().event("intent").data(intent).build())
|
|
|
|
|
+ : Flux.empty();
|
|
|
|
|
+
|
|
|
|
|
+ return prefixFlux.concatWith(intentFlux)
|
|
|
|
|
+ .concatWith(retrieverService.searchReactive(query, intent, 20)
|
|
|
|
|
+ .map(docs -> rerankerService.rerank(docs, query, 5))
|
|
|
|
|
+ .flatMapMany(docs -> {
|
|
|
|
|
+ final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
|
|
|
|
|
+ if (ocrText != null && !ocrText.isEmpty()) {
|
|
|
|
|
+ messages.getFirst().put("content",
|
|
|
|
|
+ messages.getFirst().get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var fullAnswerBuf = new StringBuilder();
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> contentFlux = llmService.chatStream(messages, enableSearch)
|
|
|
|
|
+ .map(token -> {
|
|
|
|
|
+ fullAnswerBuf.append(token);
|
|
|
|
|
+ return ServerSentEvent.<String>builder().data(token).build();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
|
|
|
|
|
+ final List<Map<String, Object>> sources = buildSources(docs);
|
|
|
|
|
+ String meta;
|
|
|
|
|
+ try {
|
|
|
|
|
+ meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
|
|
+ "intent", intent, "sources", sources, "conversation_id", cid,
|
|
|
|
|
+ "ocr_text", ocrText != null ? ocrText : ""));
|
|
|
|
|
+ } catch (Exception e) { meta = "{}"; }
|
|
|
|
|
+ String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
|
|
|
|
|
+ String userMsg = (rawMsg != null && !rawMsg.isBlank()) ? rawMsg
|
|
|
|
|
+ : (ocrText != null && !ocrText.isEmpty()) ? "[" + mediaLabel + "]" : "";
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "user", userMsg, intent, null);
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
+ return Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("meta").data(meta).build()
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Flux<ServerSentEvent<String>> headFlux = Flux.just(
|
|
|
|
|
+ ServerSentEvent.<String>builder().data("\n【📚 药典参考回答】\n\n").build(),
|
|
|
|
|
+ ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
+ .data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
|
|
|
|
|
+ + (enableSearch ? "(已启用联网搜索)" : "") + "...").build()
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- return sink.asFlux();
|
|
|
|
|
|
|
+ return Flux.concat(headFlux, contentFlux, tailFlux);
|
|
|
|
|
+ }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ============================================================
|
|
// ============================================================
|
|
@@ -565,72 +645,6 @@ public class ChatController {
|
|
|
return chatAskMultimodal(req);
|
|
return chatAskMultimodal(req);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 流式多模态:OCR 完成后,走 RAG + 生成 */
|
|
|
|
|
- private void doStreamAnswer(Sinks.Many<ServerSentEvent<String>> sink, String cid,
|
|
|
|
|
- MultimodalChatRequest request, String ocrText, String mediaLabel,
|
|
|
|
|
- String userKey) {
|
|
|
|
|
- if (!ocrText.isEmpty()) {
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
|
|
|
|
|
- .data("📚 检索药典知识库...").build());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- String rawQuery = request.getMessage() != null ? request.getMessage().trim() : "";
|
|
|
|
|
- if (!rawQuery.isEmpty() && !ocrText.isEmpty()) {
|
|
|
|
|
- rawQuery = rawQuery + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
|
|
|
|
|
- } else if (!ocrText.isEmpty()) {
|
|
|
|
|
- rawQuery = ocrText;
|
|
|
|
|
- } else if (rawQuery.isEmpty()) {
|
|
|
|
|
- rawQuery = "请介绍一下自己";
|
|
|
|
|
- }
|
|
|
|
|
- final String query = rawQuery;
|
|
|
|
|
-
|
|
|
|
|
- final String intent = retrieverService.classifyIntent(query);
|
|
|
|
|
- sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
|
|
|
|
|
-
|
|
|
|
|
- final boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
|
|
|
|
|
-
|
|
|
|
|
- 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.getFirst().put("content",
|
|
|
|
|
- messages.getFirst().get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 发送回答 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(userKey, cid, "user", userMsg, intent, null);
|
|
|
|
|
- persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources);
|
|
|
|
|
- sink.tryEmitComplete();
|
|
|
|
|
- })
|
|
|
|
|
- .doOnError(sink::tryEmitError)
|
|
|
|
|
- .subscribe();
|
|
|
|
|
- }, sink::tryEmitError);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
@GetMapping("/history")
|
|
@GetMapping("/history")
|
|
|
public ResponseEntity<Map<String, Object>> getHistory(
|
|
public ResponseEntity<Map<String, Object>> getHistory(
|
|
|
@RequestParam(defaultValue = "1") int page,
|
|
@RequestParam(defaultValue = "1") int page,
|