ChatController.java 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. package com.pharmacopoeia.controller;
  2. import com.pharmacopoeia.config.QwenProperties;
  3. import com.pharmacopoeia.dto.ChatRequest;
  4. import com.pharmacopoeia.dto.FeedbackRequest;
  5. import com.pharmacopoeia.dto.ImageChatRequest;
  6. import com.pharmacopoeia.dto.MultimodalChatRequest;
  7. import com.pharmacopoeia.service.*;
  8. import com.pharmacopoeia.util.IpUtils;
  9. import jakarta.servlet.http.HttpServletRequest;
  10. import lombok.extern.slf4j.Slf4j;
  11. import org.jetbrains.annotations.NotNull;
  12. import org.springframework.http.MediaType;
  13. import org.springframework.http.ResponseEntity;
  14. import org.springframework.http.codec.ServerSentEvent;
  15. import org.springframework.security.core.context.SecurityContextHolder;
  16. import org.springframework.web.bind.annotation.*;
  17. import reactor.core.publisher.Flux;
  18. import reactor.core.publisher.Mono;
  19. import reactor.core.publisher.SignalType;
  20. import reactor.core.publisher.Sinks;
  21. import reactor.core.scheduler.Schedulers;
  22. import org.springframework.jdbc.core.JdbcTemplate;
  23. import org.springframework.web.multipart.MultipartFile;
  24. import java.util.*;
  25. import java.util.stream.Collectors;
  26. @RestController
  27. @RequestMapping("/api/v1/chat")
  28. @Slf4j
  29. public class ChatController {
  30. // 复用 PromptService.SECTION_DISPLAY 统一权威映射,避免两处重复定义导致不一致
  31. private final RetrieverService retrieverService;
  32. private final LLMService llmService;
  33. private final PromptService promptService;
  34. private final ChatPersistenceService persistenceService;
  35. private final RerankerService rerankerService;
  36. private final QACacheService qaCache;
  37. private final BrandRecommendService brandRecommendService;
  38. private final JdbcTemplate jdbc;
  39. private final QwenProperties props;
  40. private final HttpServletRequest request;
  41. private final AnalyticsService analyticsService;
  42. private final DrugService drugService;
  43. public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
  44. ChatPersistenceService cps, RerankerService rrs,
  45. QACacheService qaCache,
  46. BrandRecommendService brandRecommendService,
  47. JdbcTemplate jdbc, QwenProperties props,
  48. HttpServletRequest request,
  49. AnalyticsService analyticsService,
  50. DrugService drugService) {
  51. this.retrieverService = rs;
  52. this.llmService = ls;
  53. this.promptService = ps;
  54. this.persistenceService = cps;
  55. this.rerankerService = rrs;
  56. this.qaCache = qaCache;
  57. this.brandRecommendService = brandRecommendService;
  58. this.jdbc = jdbc;
  59. this.props = props;
  60. this.request = request;
  61. this.analyticsService = analyticsService;
  62. this.drugService = drugService;
  63. }
  64. @PostMapping("/ask")
  65. public ResponseEntity<Map<String, Object>> chatAsk(@RequestBody ChatRequest request) {
  66. final String userKey = getCurrentUserKey();
  67. String query = request.getMessage();
  68. String normalized = qaCache.normalize(query);
  69. String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  70. ? request.getConversationId()
  71. : UUID.randomUUID().toString();
  72. // 检查全局缓存(24 小时有效,不区分用户)
  73. Map<String, Object> cached = qaCache.get(normalized);
  74. if (cached != null) {
  75. String answer = (String) cached.get("answer");
  76. String intent = (String) cached.getOrDefault("intent", "");
  77. @SuppressWarnings("unchecked")
  78. List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
  79. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  80. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  81. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  82. return ResponseEntity.ok(Map.of(
  83. "answer", answer,
  84. "sources", sources,
  85. "brand_recommendations", brandRecs,
  86. "intent", intent,
  87. "conversation_id", cid,
  88. "cached", true
  89. ));
  90. }
  91. // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
  92. if (qaCache.tryMarkPending(normalized)) {
  93. Map<String, Object> waited = qaCache.waitForCache(normalized);
  94. if (waited != null) {
  95. String answer = (String) waited.get("answer");
  96. String intent = (String) waited.getOrDefault("intent", "");
  97. @SuppressWarnings("unchecked")
  98. List<Map<String, Object>> sources = (List<Map<String, Object>>) waited.getOrDefault("sources", List.of());
  99. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  100. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  101. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  102. return ResponseEntity.ok(Map.of(
  103. "answer", answer, "sources", sources,
  104. "brand_recommendations", brandRecs,
  105. "intent", intent,
  106. "conversation_id", cid, "cached", true
  107. ));
  108. }
  109. }
  110. String intent = retrieverService.classifyIntent(query);
  111. List<Map<String, Object>> docs;
  112. String llmAnswer;
  113. List<Map<String, Object>> sources;
  114. try {
  115. docs = retrieverService.search(query, intent, 20);
  116. docs = rerankerService.rerank(docs, query, 5);
  117. // 统一:LLM 回答 + 原文对照
  118. List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  119. llmAnswer = cleanAnswer(llmService.chat(messages));
  120. sources = buildSources(docs);
  121. } catch (Exception e) {
  122. // 异常时清除 PENDING 标记,避免后续同问题请求被锁死
  123. qaCache.removePending(normalized);
  124. throw e;
  125. }
  126. String answer = llmAnswer;
  127. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  128. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  129. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  130. // 写入全局缓存
  131. qaCache.put(normalized, answer, intent, sources);
  132. return ResponseEntity.ok(Map.of(
  133. "answer", answer,
  134. "sources", sources,
  135. "brand_recommendations", brandRecs,
  136. "intent", intent,
  137. "conversation_id", cid
  138. ));
  139. }
  140. @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  141. public Flux<ServerSentEvent<String>> chatStream(@RequestBody ChatRequest request, HttpServletRequest servletRequest) {
  142. final String userKey = getCurrentUserKey();
  143. String query = request.getMessage();
  144. String normalized = qaCache.normalize(query);
  145. final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  146. ? request.getConversationId()
  147. : UUID.randomUUID().toString();
  148. // 检查全局缓存
  149. Map<String, Object> cached = qaCache.get(normalized);
  150. if (cached != null) {
  151. return streamCached(cached, cid, query, userKey);
  152. }
  153. // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
  154. if (qaCache.tryMarkPending(normalized)) {
  155. Map<String, Object> waited = qaCache.waitForCache(normalized);
  156. if (waited != null) {
  157. return streamCached(waited, cid, query, userKey);
  158. }
  159. }
  160. final String intent = retrieverService.classifyIntent(query);
  161. final long t0 = System.currentTimeMillis();
  162. // request_id:请求体优先(DTO 已带 requestId 字段),header 兜底
  163. final String chatRequestId = request.getRequestId() != null && !request.getRequestId().isBlank()
  164. ? request.getRequestId() : servletRequest.getHeader("X-Request-Id");
  165. final String chatIp = IpUtils.getClientIp(servletRequest);
  166. final String chatUa = servletRequest.getHeader("User-Agent");
  167. final java.util.Map<String, Long> timings = new java.util.concurrent.ConcurrentHashMap<>();
  168. log.info("[chatStream] intent={}, query={}", intent, query.substring(0, Math.min(50, query.length())));
  169. // L1 知识库直取:药名命中 → 取该药栏目原文 + 一句结论(思考关),跳过全量 RAG 与思考
  170. if ("drug_query".equals(intent)) {
  171. Flux<ServerSentEvent<String>> kbFlux = tryKbDirect(query, intent, userKey, cid,
  172. chatRequestId, chatIp, chatUa, t0);
  173. if (kbFlux != null) return kbFlux;
  174. }
  175. // L2:后台独立订阅 + Sinks 桥接。前端断开后后台继续把 LLM 跑完并存库,
  176. // 用户下次进来 /recent-messages 仍能拿到。计时移到 pipeline 的 doFinally(真实完成)。
  177. final java.util.concurrent.atomic.AtomicBoolean clientGone = new java.util.concurrent.atomic.AtomicBoolean(false);
  178. final Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().multicast().onBackpressureBuffer();
  179. sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
  180. sink.tryEmitNext(ServerSentEvent.<String>builder().event("status").data("Retrieving...").build());
  181. Mono<Void> pipeline = retrieverService.searchReactive(query, intent, 20)
  182. .map(docs -> rerankerService.rerank(docs, query, 5))
  183. .flatMapMany(docs -> {
  184. long t2 = System.currentTimeMillis();
  185. log.info("[chatStream] search+rerank done, docs={}, elapsed={}ms", docs.size(), t2 - t0);
  186. timings.put("search_rerank_ms", t2 - t0);
  187. final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  188. final long t3 = System.currentTimeMillis();
  189. log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
  190. t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
  191. timings.put("prompt_ms", t3 - t0);
  192. sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
  193. .data("Matched " + docs.size() + " records, generating...").build());
  194. var fullAnswerBuf = new StringBuilder();
  195. return llmService.chatStream(messages)
  196. .doOnNext(token -> {
  197. fullAnswerBuf.append(token);
  198. if (!clientGone.get()) {
  199. sink.tryEmitNext(ServerSentEvent.<String>builder().data(token).build());
  200. }
  201. })
  202. .doOnComplete(() -> {
  203. String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
  204. final List<Map<String, Object>> sources = buildSources(docs);
  205. final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
  206. long t4 = System.currentTimeMillis();
  207. log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
  208. timings.put("llm_ms", t4 - t3);
  209. timings.put("total_internal_ms", t4 - t0);
  210. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  211. persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
  212. qaCache.put(normalized, finalAnswer, intent, sources);
  213. if (!clientGone.get()) {
  214. sink.tryEmitNext(buildBrandRecommendEvent(brandRecs));
  215. String meta;
  216. try {
  217. meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
  218. "intent", intent, "sources", sources, "conversation_id", cid
  219. ));
  220. } catch (Exception e) {
  221. meta = "{}";
  222. }
  223. sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
  224. sink.tryEmitComplete();
  225. }
  226. });
  227. })
  228. .then()
  229. .doOnError(e -> {
  230. long t4 = System.currentTimeMillis();
  231. log.error("[chatStream] error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
  232. qaCache.removePending(normalized);
  233. if (!clientGone.get()) {
  234. sink.tryEmitError(e);
  235. }
  236. })
  237. .doFinally(sig -> recordChatTiming(userKey, chatRequestId, chatIp, chatUa, "stream", t0, sig, timings));
  238. // 后台跑完(不绑 SSE 连接),subscribeOn boundedElastic 不占 servlet/Netty 线程
  239. pipeline.subscribeOn(Schedulers.boundedElastic())
  240. .subscribe(v -> {}, e -> log.error("[chatStream] 后台 pipeline 出错: cid={}, error={}", cid, e.getMessage()));
  241. return sink.asFlux().doFinally(sig -> {
  242. if (sig == SignalType.CANCEL) {
  243. clientGone.set(true);
  244. log.info("[chatStream] 客户端断开,后台继续生成并存库: cid={}, request_id={}", cid, chatRequestId);
  245. }
  246. });
  247. }
  248. /** 将缓存命中结果以流式 SSE 形式返回 */
  249. private Flux<ServerSentEvent<String>> streamCached(Map<String, Object> cached, String cid,
  250. String query,
  251. String userKey) {
  252. String answer = (String) cached.get("answer");
  253. String intent = (String) cached.getOrDefault("intent", "");
  254. @SuppressWarnings("unchecked")
  255. List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
  256. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  257. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  258. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  259. return Flux.create(sink -> {
  260. sink.next(ServerSentEvent.<String>builder().event("intent").data(intent).build());
  261. sink.next(ServerSentEvent.<String>builder().event("status").data("命中缓存,直接返回...").build());
  262. // 将缓存答案按段落拆分发送,模拟流式体验
  263. String[] chunks = answer.split("(?<=\\n)");
  264. for (String chunk : chunks) {
  265. sink.next(ServerSentEvent.<String>builder().data(chunk).build());
  266. }
  267. sink.next(buildBrandRecommendEvent(brandRecs));
  268. try {
  269. String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
  270. "intent", intent, "sources", sources, "conversation_id", cid, "cached", true
  271. ));
  272. sink.next(ServerSentEvent.<String>builder().event("meta").data(meta).build());
  273. } catch (Exception ignored) {}
  274. sink.complete();
  275. });
  276. }
  277. // ============================================================
  278. // 图片对话 API(Qwen VL 分析 + OCR → RAG 检索 → 联网搜索)
  279. // ============================================================
  280. @PostMapping("/ask-image")
  281. public ResponseEntity<Map<String, Object>> chatAskImage(@RequestBody ImageChatRequest request) {
  282. final String userKey = getCurrentUserKey();
  283. String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  284. ? request.getConversationId()
  285. : UUID.randomUUID().toString();
  286. // Step 1: Qwen VL 分析图片 + OCR 提取文字
  287. String ocrText = llmService.analyzeImage(
  288. request.getImageBase64(), request.getMimeType(),
  289. "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等关键药学信息。简要输出即可。");
  290. // Step 2: 拼接查询 → RAG 检索
  291. String query = (!request.getMessage().isBlank())
  292. ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
  293. : ocrText;
  294. String intent = retrieverService.classifyIntent(query);
  295. List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
  296. docs = rerankerService.rerank(docs, query, 5);
  297. // Step 3: 构建 Prompt(含图片分析上下文)+ 联网搜索
  298. List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  299. String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
  300. messages.getFirst().put("content", messages.getFirst().get("content") + imageContext);
  301. String answer = cleanAnswer(llmService.chat(messages, true));
  302. List<Map<String, Object>> sources = buildSources(docs);
  303. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  304. persistenceService.saveMessage(userKey, cid, "user",
  305. request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
  306. intent, null);
  307. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  308. return ResponseEntity.ok(Map.of(
  309. "answer", answer,
  310. "sources", sources,
  311. "brand_recommendations", brandRecs,
  312. "intent", intent,
  313. "conversation_id", cid
  314. ));
  315. }
  316. @PostMapping(value = "/stream-image", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  317. public Flux<ServerSentEvent<String>> chatStreamImage(@RequestBody ImageChatRequest request) {
  318. final String userKey = getCurrentUserKey();
  319. final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  320. ? request.getConversationId()
  321. : UUID.randomUUID().toString();
  322. // 校验图片数据
  323. if (request.getImageBase64() == null || request.getImageBase64().isBlank()) {
  324. return Flux.just(
  325. ServerSentEvent.<String>builder().event("status").data("图片数据为空,请重新上传").build()
  326. );
  327. }
  328. // 先发状态,不等图片分析完成
  329. return Flux.just(
  330. ServerSentEvent.<String>builder().event("status").data("正在分析图片(OCR 文字识别)...").build()
  331. ).concatWith(
  332. llmService.analyzeImageStream(request.getImageBase64(), request.getMimeType(),
  333. "请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。")
  334. .collectList()
  335. .flatMapMany(tokens -> {
  336. String ocrText = String.join("", tokens);
  337. String query = (!request.getMessage().isBlank())
  338. ? request.getMessage() + "\n\n(图片OCR提取内容:" + ocrText + ")"
  339. : ocrText;
  340. final String intent = retrieverService.classifyIntent(query);
  341. return Flux.just(
  342. ServerSentEvent.<String>builder().event("status").data("图片分析完成,正在检索药典知识库...").build(),
  343. ServerSentEvent.<String>builder().event("intent").data(intent).build()
  344. ).concatWith(retrieverService.searchReactive(query, intent, 20)
  345. .map(docs -> rerankerService.rerank(docs, query, 5))
  346. .flatMapMany(docs -> {
  347. final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  348. String imageContext = "\n\n【图片分析结果】\n" + ocrText + "\n";
  349. messages.getFirst().put("content", messages.getFirst().get("content") + imageContext);
  350. var fullAnswerBuf = new StringBuilder();
  351. Flux<ServerSentEvent<String>> contentFlux = llmService.chatStream(messages, true)
  352. .map(token -> {
  353. fullAnswerBuf.append(token);
  354. return ServerSentEvent.<String>builder().data(token).build();
  355. });
  356. Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
  357. String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
  358. final List<Map<String, Object>> sources = buildSources(docs);
  359. final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
  360. String meta;
  361. try {
  362. meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
  363. "intent", intent,
  364. "sources", sources,
  365. "conversation_id", cid,
  366. "ocr_text", ocrText
  367. ));
  368. } catch (Exception e) {
  369. meta = "{}";
  370. }
  371. persistenceService.saveMessage(userKey, cid, "user",
  372. request.getMessage().isBlank() ? "[图片]" : "[图片] " + request.getMessage(),
  373. intent, null);
  374. persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
  375. return Flux.just(
  376. buildBrandRecommendEvent(brandRecs),
  377. ServerSentEvent.<String>builder().event("meta").data(meta).build()
  378. );
  379. });
  380. Flux<ServerSentEvent<String>> headFlux = Flux.just(
  381. ServerSentEvent.<String>builder().event("status")
  382. .data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build()
  383. );
  384. return Flux.concat(headFlux, contentFlux, tailFlux);
  385. }));
  386. })
  387. );
  388. }
  389. // ============================================================
  390. // 统一多模态对话 API(文本 + 图片 + 视频)
  391. // ============================================================
  392. @PostMapping("/ask-multimodal")
  393. public ResponseEntity<Map<String, Object>> chatAskMultimodal(@RequestBody MultimodalChatRequest request) {
  394. final String userKey = getCurrentUserKey();
  395. String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  396. ? request.getConversationId()
  397. : UUID.randomUUID().toString();
  398. // Step 1: 媒体分析(如果有附件)
  399. String ocrText = "";
  400. String mediaLabel = "";
  401. if (request.getMediaBase64() != null && !request.getMediaBase64().isBlank()
  402. && request.getMediaType() != null && !request.getMediaType().isBlank()) {
  403. mediaLabel = "video".equals(request.getMediaType()) ? "视频" : "图片";
  404. ocrText = llmService.analyzeMedia(
  405. request.getMediaBase64(), request.getMediaType(),
  406. request.getMediaMime(), "");
  407. }
  408. // Step 2: 拼接查询
  409. String query = request.getMessage() != null ? request.getMessage().trim() : "";
  410. if (!query.isEmpty() && !ocrText.isEmpty()) {
  411. query = query + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
  412. } else if (!ocrText.isEmpty()) {
  413. query = ocrText;
  414. } else if (query.isEmpty()) {
  415. query = "请介绍一下自己";
  416. }
  417. // Step 3: RAG 检索
  418. String intent = retrieverService.classifyIntent(query);
  419. List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
  420. docs = rerankerService.rerank(docs, query, 5);
  421. // Step 4: 构建 Prompt + 联网搜索
  422. List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  423. if (!ocrText.isEmpty()) {
  424. messages.getFirst().put("content",
  425. messages.getFirst().get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
  426. }
  427. boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
  428. String answer = cleanAnswer(llmService.chat(messages, enableSearch));
  429. List<Map<String, Object>> sources = buildSources(docs);
  430. List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, answer);
  431. String userMsg = !request.getMessage().isBlank() ? request.getMessage()
  432. : !ocrText.isEmpty() ? "[" + mediaLabel + "]" : request.getMessage();
  433. persistenceService.saveMessage(userKey, cid, "user", userMsg, intent, null);
  434. persistenceService.saveMessage(userKey, cid, "assistant", answer, intent, sources, brandRecs);
  435. return ResponseEntity.ok(Map.of(
  436. "answer", answer,
  437. "sources", sources,
  438. "brand_recommendations", brandRecs,
  439. "intent", intent,
  440. "conversation_id", cid
  441. ));
  442. }
  443. @PostMapping(value = "/stream-multimodal", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  444. public Flux<ServerSentEvent<String>> chatStreamMultimodal(@RequestBody MultimodalChatRequest request) {
  445. final String userKey = getCurrentUserKey();
  446. final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
  447. ? request.getConversationId()
  448. : UUID.randomUUID().toString();
  449. final boolean hasMedia = request.getMediaBase64() != null && !request.getMediaBase64().isBlank()
  450. && request.getMediaType() != null && !request.getMediaType().isBlank();
  451. final String mediaLabel = hasMedia && "video".equals(request.getMediaType()) ? "视频" : "图片";
  452. if (hasMedia) {
  453. // 先发 OCR section header
  454. return Flux.just(
  455. ServerSentEvent.<String>builder().event("status")
  456. .data("🔍 正在分析" + mediaLabel + "...").build(),
  457. ServerSentEvent.<String>builder().data("【📷 " + mediaLabel + "分析】\n\n").build()
  458. ).concatWith(
  459. // 用 collectList 收集流式结果,同时攒 OCR 文本,全程在管道内无阻塞
  460. llmService.analyzeMediaStream(request.getMediaBase64(), request.getMediaType(),
  461. request.getMediaMime(), "")
  462. .flatMap(token -> Flux.just(
  463. ServerSentEvent.<String>builder().data(token).build() // 前端实时看到
  464. ))
  465. .concatWith(Flux.just(ServerSentEvent.<String>builder().data("\n\n").build()))
  466. // collectList 后再 flatMapMany 接 RAG——Reactor 取消时自动 abort
  467. .collectList()
  468. .flatMapMany(events -> {
  469. // 从已发送的事件中拼回 OCR 文本(不额外调 API)
  470. StringBuilder ocrBuilder = new StringBuilder();
  471. for (ServerSentEvent<String> e : events) {
  472. String d = e.data();
  473. if (d != null && !"\n\n".equals(d)) {
  474. ocrBuilder.append(d);
  475. }
  476. }
  477. String mediaOcr = ocrBuilder.toString().trim();
  478. // 不重复发送 OCR 事件(已经通过 analyzeMediaStream 实时发送过了)
  479. return buildRagPipeline(cid,
  480. request.getMessage(), mediaOcr, userKey, mediaLabel);
  481. })
  482. );
  483. } else {
  484. return buildRagPipeline(cid, request.getMessage(), "", userKey, "");
  485. }
  486. }
  487. /** 构建 RAG → 百炼流式管道(纯 Reactor,零裸 subscribe) */
  488. private Flux<ServerSentEvent<String>> buildRagPipeline(String cid, String rawMsg,
  489. String ocrText, String userKey,
  490. String mediaLabel) {
  491. String rq = rawMsg != null ? rawMsg.trim() : "";
  492. if (!rq.isEmpty() && ocrText != null && !ocrText.isEmpty()) {
  493. rq = rq + "\n\n(" + mediaLabel + "OCR提取内容:" + ocrText + ")";
  494. } else if (ocrText != null && !ocrText.isEmpty()) {
  495. rq = ocrText;
  496. } else if (rq.isEmpty()) {
  497. rq = "请介绍一下自己";
  498. }
  499. final String query = rq;
  500. final String intent = retrieverService.classifyIntent(query);
  501. final boolean enableSearch = (ocrText != null && !ocrText.isEmpty()) || props.isEnableWebSearch();
  502. // 如果等待检索,先发状态
  503. Flux<ServerSentEvent<String>> prefixFlux = (ocrText != null && !ocrText.isEmpty())
  504. ? Flux.just(ServerSentEvent.<String>builder().event("status")
  505. .data("📚 检索药典知识库...").build())
  506. : Flux.just(ServerSentEvent.<String>builder().event("intent").data(intent).build());
  507. Flux<ServerSentEvent<String>> intentFlux = (ocrText != null && !ocrText.isEmpty())
  508. ? Flux.just(ServerSentEvent.<String>builder().event("intent").data(intent).build())
  509. : Flux.empty();
  510. return prefixFlux.concatWith(intentFlux)
  511. .concatWith(retrieverService.searchReactive(query, intent, 20)
  512. .map(docs -> rerankerService.rerank(docs, query, 5))
  513. .flatMapMany(docs -> {
  514. final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
  515. if (ocrText != null && !ocrText.isEmpty()) {
  516. messages.getFirst().put("content",
  517. messages.getFirst().get("content") + "\n\n【" + mediaLabel + "分析结果】\n" + ocrText + "\n");
  518. }
  519. var fullAnswerBuf = new StringBuilder();
  520. Flux<ServerSentEvent<String>> contentFlux = llmService.chatStream(messages, enableSearch)
  521. .map(token -> {
  522. fullAnswerBuf.append(token);
  523. return ServerSentEvent.<String>builder().data(token).build();
  524. });
  525. Flux<ServerSentEvent<String>> tailFlux = Flux.defer(() -> {
  526. final List<Map<String, Object>> sources = buildSources(docs);
  527. String finalAnswer = cleanAnswer(fullAnswerBuf.toString());
  528. final List<Map<String, Object>> brandRecs = brandRecommendService.match(sources, finalAnswer);
  529. String meta;
  530. try {
  531. meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
  532. "intent", intent, "sources", sources, "conversation_id", cid,
  533. "ocr_text", ocrText != null ? ocrText : ""));
  534. } catch (Exception e) { meta = "{}"; }
  535. String userMsg = (rawMsg != null && !rawMsg.isBlank()) ? rawMsg
  536. : (ocrText != null && !ocrText.isEmpty()) ? "[" + mediaLabel + "]" : "";
  537. persistenceService.saveMessage(userKey, cid, "user", userMsg, intent, null);
  538. persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
  539. return Flux.just(
  540. buildBrandRecommendEvent(brandRecs),
  541. ServerSentEvent.<String>builder().event("meta").data(meta).build()
  542. );
  543. });
  544. Flux<ServerSentEvent<String>> headFlux = Flux.just(
  545. ServerSentEvent.<String>builder().data("\n【📚 药典参考回答】\n\n").build(),
  546. ServerSentEvent.<String>builder().event("status")
  547. .data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
  548. + (enableSearch ? "(已启用联网搜索)" : "") + "...").build()
  549. );
  550. return Flux.concat(headFlux, contentFlux, tailFlux);
  551. }));
  552. }
  553. // ============================================================
  554. // 文件上传 API(multipart → base64 → 复用已有对话管线)
  555. // ============================================================
  556. @PostMapping("/upload-image")
  557. public ResponseEntity<Map<String, Object>> uploadImage(
  558. @RequestParam("file") MultipartFile file,
  559. @RequestParam(defaultValue = "") String message,
  560. @RequestParam(defaultValue = "") String conversationId) {
  561. // 校验 MIME 类型
  562. Set<String> allowed = Set.of("image/jpeg", "image/png", "image/webp", "image/bmp");
  563. String contentType = file.getContentType();
  564. if (contentType == null || !allowed.contains(contentType)) {
  565. throw new IllegalArgumentException(
  566. "不支持的图片格式: " + contentType + ",支持 jpg/png/webp/bmp");
  567. }
  568. // 校验大小 ≤ 10MB
  569. if (file.getSize() > 10 * 1024 * 1024) {
  570. throw new IllegalArgumentException("图片大小不能超过 10MB");
  571. }
  572. // 转 base64 → 委托给 ask-image
  573. String base64;
  574. try {
  575. base64 = Base64.getEncoder().encodeToString(file.getBytes());
  576. } catch (Exception e) {
  577. throw new RuntimeException("读取上传文件失败", e);
  578. }
  579. ImageChatRequest req = new ImageChatRequest();
  580. req.setImageBase64(base64);
  581. req.setMimeType(contentType);
  582. req.setMessage(message);
  583. req.setConversationId(
  584. conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
  585. return chatAskImage(req);
  586. }
  587. @PostMapping("/upload-media")
  588. public ResponseEntity<Map<String, Object>> uploadMedia(
  589. @RequestParam("file") MultipartFile file,
  590. @RequestParam(defaultValue = "") String message,
  591. @RequestParam(defaultValue = "") String conversationId) {
  592. String contentType = file.getContentType();
  593. if (contentType == null) {
  594. throw new IllegalArgumentException("无法识别的媒体类型");
  595. }
  596. String mediaType;
  597. long maxSize;
  598. if (contentType.startsWith("image/")) {
  599. mediaType = "image";
  600. maxSize = 10 * 1024 * 1024; // 10MB
  601. } else if (contentType.startsWith("video/")) {
  602. mediaType = "video";
  603. maxSize = 50 * 1024 * 1024; // 50MB
  604. } else {
  605. throw new IllegalArgumentException(
  606. "不支持的媒体格式: " + contentType + ",支持 jpg/png/webp/bmp/mp4/mov/avi/webm");
  607. }
  608. if (file.getSize() > maxSize) {
  609. throw new IllegalArgumentException(
  610. "文件大小不能超过 " + (maxSize / 1024 / 1024) + "MB");
  611. }
  612. String base64;
  613. try {
  614. base64 = Base64.getEncoder().encodeToString(file.getBytes());
  615. } catch (Exception e) {
  616. throw new RuntimeException("读取上传文件失败", e);
  617. }
  618. MultimodalChatRequest req = new MultimodalChatRequest();
  619. req.setMessage(message);
  620. req.setMediaType(mediaType);
  621. req.setMediaBase64(base64);
  622. req.setMediaMime(contentType);
  623. req.setConversationId(
  624. conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
  625. return chatAskMultimodal(req);
  626. }
  627. @GetMapping("/history")
  628. public ResponseEntity<Map<String, Object>> getHistory(
  629. @RequestParam(defaultValue = "1") int page,
  630. @RequestParam(defaultValue = "20") int pageSize) {
  631. final String userKey = getCurrentUserKey();
  632. // getHistory 现在直接返回包含 items/page/page_size/total/total_pages 的 Map
  633. var result = persistenceService.getHistory(userKey, page, pageSize);
  634. return ResponseEntity.ok(result);
  635. }
  636. @GetMapping("/history/{cid}")
  637. public ResponseEntity<Map<String, Object>> getConversationDetail(@PathVariable String cid) {
  638. var msgs = persistenceService.getConversationDetail(cid);
  639. return ResponseEntity.ok(Map.of("conversation_id", cid, "messages", msgs));
  640. }
  641. /** 返回最近 N 条消息,供前端恢复对话(微信 WebView 等 IndexedDB 不可用场景) */
  642. @GetMapping("/recent-messages")
  643. public ResponseEntity<Map<String, Object>> getRecentMessages(
  644. @RequestParam(defaultValue = "50") int limit) {
  645. final String userKey = getCurrentUserKey();
  646. var msgs = persistenceService.getRecentMessages(userKey, Math.min(limit, 200));
  647. return ResponseEntity.ok(Map.of("messages", msgs));
  648. }
  649. @PostMapping("/feedback")
  650. public ResponseEntity<Map<String, Object>> submitFeedback(@RequestBody FeedbackRequest request) {
  651. persistenceService.updateFeedback(request.getMessageId(), request.getFeedback());
  652. return ResponseEntity.ok(Map.of("status", "ok"));
  653. }
  654. @GetMapping("/admin/conversations")
  655. public ResponseEntity<Map<String, Object>> adminListConversations(
  656. @RequestParam(defaultValue = "1") int page,
  657. @RequestParam(defaultValue = "20") int pageSize,
  658. @RequestParam(required = false) String keyword) {
  659. int offset = (page - 1) * pageSize;
  660. StringBuilder sql = new StringBuilder("""
  661. SELECT DISTINCT ON (c.conversation_id)
  662. c.conversation_id, c.title, c.created_at,
  663. m.content AS last_msg, m.role
  664. FROM conversations c
  665. JOIN messages m ON m.conversation_id = c.conversation_id
  666. """);
  667. List<Object> params = new ArrayList<>();
  668. if (keyword != null && !keyword.isBlank()) {
  669. sql.append("WHERE m.content ILIKE ? ");
  670. params.add("%" + keyword + "%");
  671. }
  672. sql.append("""
  673. ORDER BY c.conversation_id, m.created_at DESC
  674. LIMIT ? OFFSET ?
  675. """);
  676. params.add(pageSize);
  677. params.add(offset);
  678. List<Map<String, Object>> items = jdbc.queryForList(
  679. sql.toString(), params.toArray());
  680. return ResponseEntity.ok(Map.of(
  681. "items", items,
  682. "page", page,
  683. "page_size", pageSize
  684. ));
  685. }
  686. private ServerSentEvent<String> buildBrandRecommendEvent(List<Map<String, Object>> recs) {
  687. try {
  688. String data = new com.fasterxml.jackson.databind.ObjectMapper()
  689. .writeValueAsString(Map.of("recommendations", recs != null ? recs : List.of()));
  690. return ServerSentEvent.<String>builder().event("brand_recommend").data(data).build();
  691. } catch (Exception e) {
  692. return ServerSentEvent.<String>builder().event("brand_recommend")
  693. .data("{\"recommendations\":[]}").build();
  694. }
  695. }
  696. private List<Map<String, Object>> buildSources(List<Map<String, Object>> docs) {
  697. Set<String> rawSeen = new HashSet<>();
  698. Set<String> seen = new HashSet<>();
  699. return docs.stream()
  700. // 第一层:按原始 name|section 去重,消除同一栏目的多个分块
  701. .filter(d -> {
  702. String key = d.getOrDefault("name", "") + "|" + d.getOrDefault("section", "");
  703. return rawSeen.add(key);
  704. })
  705. .map(d -> {
  706. String content = (String) d.getOrDefault("content", "");
  707. String drugName = (String) d.getOrDefault("name", "");
  708. String storedSection = (String) d.getOrDefault("section", "");
  709. String sourceVersion = (String) d.getOrDefault("source_version", "");
  710. String sourceVolume = (String) d.getOrDefault("source_volume", "");
  711. String category = (String) d.getOrDefault("category", "");
  712. // 优先用 DB 元数据,回退到内容解析
  713. if (drugName == null || drugName.isEmpty()) {
  714. drugName = extractDrugName(content);
  715. }
  716. String sectionDisplay = PromptService.SECTION_DISPLAY.getOrDefault(storedSection, storedSection);
  717. if (sectionDisplay == null || sectionDisplay.isEmpty()) {
  718. sectionDisplay = realSection(content, storedSection);
  719. }
  720. // 把"正文"替换为更友好的显示名
  721. if ("正文".equals(sectionDisplay)) {
  722. sectionDisplay = "药典正文";
  723. }
  724. // 构建完整来源引用
  725. String fullSource = getFullSource(sourceVersion, sourceVolume);
  726. content = content.replaceAll("\\s*来源:.*$", "");
  727. content = content.replaceAll("[\\r\\n]+", " ").trim();
  728. String excerpt = content.length() > 500 ? content.substring(0, 500) + "…" : content;
  729. return Map.of(
  730. "drug_id", d.getOrDefault("drug_id", ""),
  731. "name", drugName,
  732. "section", sectionDisplay,
  733. "category", category != null ? category : "",
  734. "source", fullSource,
  735. "excerpt", excerpt
  736. );
  737. })
  738. .filter(Objects::nonNull)
  739. // 第二层:按显示名去重,避免别名映射(如"功能""主治"→"功能与主治")导致重复
  740. .filter(m -> {
  741. String key = m.get("name") + "|" + m.get("section");
  742. return seen.add(key);
  743. })
  744. // 每种药最多 5 个栏目,总数最多 8 条
  745. .collect(Collectors.groupingBy(m -> (String) m.get("name"), LinkedHashMap::new, Collectors.toList()))
  746. .values().stream()
  747. .flatMap(list -> list.stream().limit(5))
  748. .limit(8)
  749. .collect(Collectors.toList());
  750. }
  751. @NotNull
  752. private static String getFullSource(String sourceVersion, String sourceVolume) {
  753. StringBuilder sourceBuilder = new StringBuilder();
  754. if (!sourceVersion.isEmpty()) {
  755. sourceBuilder.append(sourceVersion);
  756. }
  757. if (!sourceVolume.isEmpty()) {
  758. if (!sourceBuilder.isEmpty()) {
  759. sourceBuilder.append(" ");
  760. }
  761. sourceBuilder.append(sourceVolume);
  762. }
  763. return sourceBuilder.toString();
  764. }
  765. private String extractDrugName(String content) {
  766. if (content == null) {
  767. return "";
  768. }
  769. int start = content.indexOf("【");
  770. int end = content.indexOf(" - ");
  771. if (start >= 0 && end > start) {
  772. return content.substring(start + 1, end);
  773. }
  774. return content.length() > 20 ? content.substring(0, 20) : content;
  775. }
  776. /** 从 content 文本中提取真实 section(兜底"正文") */
  777. private String realSection(String content, String storedSection) {
  778. if (!"正文".equals(storedSection) || content == null) {
  779. return storedSection;
  780. }
  781. int sep = content.indexOf(" - ");
  782. if (sep < 0) {
  783. return storedSection;
  784. }
  785. int end = content.indexOf("】", sep);
  786. if (end > sep) {
  787. return content.substring(sep + 3, end).trim();
  788. }
  789. return storedSection;
  790. }
  791. /** 从 SecurityContext 获取当前用户标识(JWT subject),未登录则用 IP 隔离 */
  792. private String getCurrentUserKey() {
  793. var auth = SecurityContextHolder.getContext().getAuthentication();
  794. if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
  795. return auth.getName();
  796. }
  797. // 未登录用 IP 隔离,避免不同手机会话串了
  798. String ip = IpUtils.getClientIp(request);
  799. return "ip:" + ip;
  800. }
  801. private String cleanAnswer(String text) {
  802. if (text == null) {
  803. return "";
  804. }
  805. // 去除多余空白行(保留单个换行),修复 Qwen 常见格式问题
  806. return text
  807. .replace("\r\n", "\n")
  808. .replaceAll("\\n{3,}", "\n\n")
  809. .trim();
  810. }
  811. /**
  812. * L1 知识库直取:drug_query 且识别到药名时,取该药栏目原文 + 一句结论(思考关)流式返回。
  813. * 命中返回 SSE Flux;未命中(识别不到药名/库里无该药/无栏目)返回 null,由调用方走 L2 兜底。
  814. */
  815. private Flux<ServerSentEvent<String>> tryKbDirect(String query, String intent, String userKey, String cid,
  816. String requestId, String ip, String ua, long t0) {
  817. String drugName = retrieverService.extractDrugName(query);
  818. if (drugName == null || drugName.isBlank()) return null;
  819. var drug = drugService.findDrugByName(drugName);
  820. if (drug == null) return null;
  821. var sectionsRaw = drug.getSections();
  822. if (sectionsRaw == null || sectionsRaw.isEmpty()) return null;
  823. // 归一化栏目:把 sectionsRaw 的 key 经 SECTION_DISPLAY 映射到标准显示名
  824. java.util.Map<String, String> displayToContent = new java.util.LinkedHashMap<>();
  825. for (var e : sectionsRaw.entrySet()) {
  826. if (e.getValue() == null) continue;
  827. String disp = PromptService.SECTION_DISPLAY.getOrDefault(e.getKey(), e.getKey());
  828. String text = String.valueOf(e.getValue());
  829. if (!text.isBlank()) displayToContent.put(disp, text);
  830. }
  831. if (displayToContent.isEmpty()) return null;
  832. // 栏目选择:query 命中某栏目 → 单栏;否则默认集
  833. java.util.List<String> order = new java.util.ArrayList<>();
  834. String hit = detectSection(query, displayToContent.keySet());
  835. if (hit != null) {
  836. order.add(hit);
  837. } else {
  838. for (String d : new String[]{"正文", "性状", "类别", "制剂", "贮藏",
  839. "功能与主治", "用法与用量", "不良反应", "禁忌", "注意事项"}) {
  840. if (displayToContent.containsKey(d)) order.add(d);
  841. }
  842. }
  843. if (order.isEmpty()) return null;
  844. final String sourceLabel = getFullSource(
  845. drug.getSourceVersion() == null ? "" : drug.getSourceVersion(),
  846. drug.getSourceVolume() == null ? "" : drug.getSourceVolume());
  847. final java.util.List<String> sectionOrder = java.util.Collections.unmodifiableList(order);
  848. final java.util.Map<String, String> sections = java.util.Collections.unmodifiableMap(displayToContent);
  849. // 拼 sources + 各栏目原文(供结论 prompt 与最终答案)
  850. StringBuilder sectionsText = new StringBuilder();
  851. java.util.List<Map<String, Object>> sources = new java.util.ArrayList<>();
  852. for (String sec : sectionOrder) {
  853. String content = sections.get(sec);
  854. sectionsText.append("【").append(sec).append("】\n").append(content).append("\n");
  855. Map<String, Object> s = new java.util.LinkedHashMap<>();
  856. s.put("drug_id", drug.getDrugId() == null ? "" : drug.getDrugId());
  857. s.put("name", drug.getName() == null ? "" : drug.getName());
  858. s.put("section", sec);
  859. s.put("source", sourceLabel);
  860. s.put("excerpt", content.length() > 400 ? content.substring(0, 400) + "…" : content);
  861. sources.add(s);
  862. }
  863. // 结论 prompt:小 LLM 调用,思考关
  864. List<Map<String, String>> conclusionMessages = new java.util.ArrayList<>();
  865. Map<String, String> sysMsg = new java.util.HashMap<>();
  866. sysMsg.put("role", "system");
  867. sysMsg.put("content", "你是一名药师。根据给定药典栏目原文,用一句话(不超过80字)概括该药关键信息作为【结论】,只输出结论文本,不得编造栏目外的信息。");
  868. Map<String, String> userMsg = new java.util.HashMap<>();
  869. userMsg.put("role", "user");
  870. userMsg.put("content", "药品:" + drug.getName() + "\n" + sectionsText);
  871. conclusionMessages.add(sysMsg);
  872. conclusionMessages.add(userMsg);
  873. final java.util.List<Map<String, Object>> srcList = java.util.Collections.unmodifiableList(sources);
  874. final String drugNameResolved = drug.getName();
  875. return Flux.<ServerSentEvent<String>>create(sink -> {
  876. sink.next(ServerSentEvent.<String>builder().event("intent").data(intent).build());
  877. sink.next(ServerSentEvent.<String>builder().event("status").data("已命中药典知识库,直取中...").build());
  878. // 结论(思考关,~1-2s)
  879. String conclusion;
  880. try {
  881. conclusion = llmService.chat(conclusionMessages, false, false);
  882. } catch (Exception ex) {
  883. log.warn("[kbDirect] 结论生成失败,跳过结论: {}", ex.getMessage());
  884. conclusion = "";
  885. }
  886. StringBuilder answer = new StringBuilder();
  887. if (conclusion != null && !conclusion.isBlank()) {
  888. answer.append("【结论】\n").append(conclusion.trim()).append("\n\n");
  889. }
  890. for (String sec : sectionOrder) {
  891. answer.append("【").append(sec).append("】\n")
  892. .append(sections.get(sec)).append("\n")
  893. .append("(来源:").append(sourceLabel).append(")\n\n");
  894. }
  895. answer.append("【来源明细】\n").append(sourceLabel);
  896. String finalAnswer = answer.toString();
  897. // 按段落流式发送
  898. String[] chunks = finalAnswer.split("(?<=\\n)");
  899. for (String chunk : chunks) {
  900. sink.next(ServerSentEvent.<String>builder().data(chunk).build());
  901. }
  902. // 品牌推荐
  903. List<Map<String, Object>> brandRecs = brandRecommendService.match(srcList, finalAnswer);
  904. sink.next(buildBrandRecommendEvent(brandRecs));
  905. // meta
  906. String meta;
  907. try {
  908. meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
  909. "intent", intent, "sources", srcList, "conversation_id", cid, "source", "kb_direct"
  910. ));
  911. } catch (Exception e) {
  912. meta = "{}";
  913. }
  914. sink.next(ServerSentEvent.<String>builder().event("meta").data(meta).build());
  915. // 持久化
  916. persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
  917. persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, srcList, brandRecs);
  918. sink.complete();
  919. }).doFinally(sig -> recordChatTiming(userKey, requestId, ip, ua, "kb_direct", t0, sig, null));
  920. }
  921. /** 扫描 query 中是否出现某栏目别名(SECTION_DISPLAY 的 key),返回其标准显示名(且需在可用栏目中) */
  922. private String detectSection(String query, java.util.Collection<String> available) {
  923. if (query == null) return null;
  924. for (var e : PromptService.SECTION_DISPLAY.entrySet()) {
  925. if (query.contains(e.getKey()) && available.contains(e.getValue())) {
  926. return e.getValue();
  927. }
  928. }
  929. return null;
  930. }
  931. /** 流式问答结束时上报一次"入参→出参"耗时(含分段),用 request_id 与前端 chat_complete 关联 */
  932. private void recordChatTiming(String userKey, String requestId, String ip, String ua,
  933. String endpoint, long t0, SignalType sig,
  934. java.util.Map<String, Long> timings) {
  935. try {
  936. long total = System.currentTimeMillis() - t0;
  937. String status;
  938. if (sig == null) {
  939. status = "unknown";
  940. } else {
  941. status = switch (sig) {
  942. case ON_COMPLETE -> "ok";
  943. case ON_ERROR -> "error";
  944. case CANCEL -> "cancelled";
  945. default -> sig.name().toLowerCase();
  946. };
  947. }
  948. java.util.Map<String, Object> ed = new java.util.LinkedHashMap<>();
  949. ed.put("endpoint", endpoint);
  950. ed.put("request_id", requestId != null ? requestId : "");
  951. ed.put("total_ms", total);
  952. ed.put("status", status);
  953. if (timings != null && !timings.isEmpty()) {
  954. ed.putAll(timings);
  955. }
  956. analyticsService.saveEvent(userKey, "chat_stream_server", ed, "", "", ip, ua, "AI药典");
  957. } catch (Exception ignored) {
  958. // 埋点失败不影响主流程
  959. }
  960. }
  961. }