|
@@ -42,6 +42,7 @@ public class ChatController {
|
|
|
private final QwenProperties props;
|
|
private final QwenProperties props;
|
|
|
private final HttpServletRequest request;
|
|
private final HttpServletRequest request;
|
|
|
private final AnalyticsService analyticsService;
|
|
private final AnalyticsService analyticsService;
|
|
|
|
|
+ private final DrugService drugService;
|
|
|
|
|
|
|
|
public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
|
|
public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
|
|
|
ChatPersistenceService cps, RerankerService rrs,
|
|
ChatPersistenceService cps, RerankerService rrs,
|
|
@@ -49,7 +50,8 @@ public class ChatController {
|
|
|
BrandRecommendService brandRecommendService,
|
|
BrandRecommendService brandRecommendService,
|
|
|
JdbcTemplate jdbc, QwenProperties props,
|
|
JdbcTemplate jdbc, QwenProperties props,
|
|
|
HttpServletRequest request,
|
|
HttpServletRequest request,
|
|
|
- AnalyticsService analyticsService) {
|
|
|
|
|
|
|
+ AnalyticsService analyticsService,
|
|
|
|
|
+ DrugService drugService) {
|
|
|
this.retrieverService = rs;
|
|
this.retrieverService = rs;
|
|
|
this.llmService = ls;
|
|
this.llmService = ls;
|
|
|
this.promptService = ps;
|
|
this.promptService = ps;
|
|
@@ -61,6 +63,7 @@ public class ChatController {
|
|
|
this.props = props;
|
|
this.props = props;
|
|
|
this.request = request;
|
|
this.request = request;
|
|
|
this.analyticsService = analyticsService;
|
|
this.analyticsService = analyticsService;
|
|
|
|
|
+ this.drugService = drugService;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@PostMapping("/ask")
|
|
@PostMapping("/ask")
|
|
@@ -179,6 +182,13 @@ public class ChatController {
|
|
|
final java.util.Map<String, Long> timings = new java.util.concurrent.ConcurrentHashMap<>();
|
|
final java.util.Map<String, Long> timings = new java.util.concurrent.ConcurrentHashMap<>();
|
|
|
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())));
|
|
|
|
|
|
|
|
|
|
+ // L1 知识库直取:药名命中 → 取该药栏目原文 + 一句结论(思考关),跳过全量 RAG 与思考
|
|
|
|
|
+ if ("drug_query".equals(intent)) {
|
|
|
|
|
+ Flux<ServerSentEvent<String>> kbFlux = tryKbDirect(query, intent, userKey, cid,
|
|
|
|
|
+ chatRequestId, chatIp, chatUa, t0);
|
|
|
|
|
+ if (kbFlux != null) return kbFlux;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
|
|
// 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
|
|
|
// 纯 Reactor 管道(零裸 subscribe),连接断开时整条链路自动取消到百炼。
|
|
// 纯 Reactor 管道(零裸 subscribe),连接断开时整条链路自动取消到百炼。
|
|
|
return Flux.just(
|
|
return Flux.just(
|
|
@@ -889,6 +899,141 @@ public class ChatController {
|
|
|
.trim();
|
|
.trim();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * L1 知识库直取:drug_query 且识别到药名时,取该药栏目原文 + 一句结论(思考关)流式返回。
|
|
|
|
|
+ * 命中返回 SSE Flux;未命中(识别不到药名/库里无该药/无栏目)返回 null,由调用方走 L2 兜底。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Flux<ServerSentEvent<String>> tryKbDirect(String query, String intent, String userKey, String cid,
|
|
|
|
|
+ String requestId, String ip, String ua, long t0) {
|
|
|
|
|
+ String drugName = retrieverService.extractDrugName(query);
|
|
|
|
|
+ if (drugName == null || drugName.isBlank()) return null;
|
|
|
|
|
+ var drug = drugService.findDrugByName(drugName);
|
|
|
|
|
+ if (drug == null) return null;
|
|
|
|
|
+ var sectionsRaw = drug.getSections();
|
|
|
|
|
+ if (sectionsRaw == null || sectionsRaw.isEmpty()) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // 归一化栏目:把 sectionsRaw 的 key 经 SECTION_DISPLAY 映射到标准显示名
|
|
|
|
|
+ java.util.Map<String, String> displayToContent = new java.util.LinkedHashMap<>();
|
|
|
|
|
+ for (var e : sectionsRaw.entrySet()) {
|
|
|
|
|
+ if (e.getValue() == null) continue;
|
|
|
|
|
+ String disp = PromptService.SECTION_DISPLAY.getOrDefault(e.getKey(), e.getKey());
|
|
|
|
|
+ String text = String.valueOf(e.getValue());
|
|
|
|
|
+ if (!text.isBlank()) displayToContent.put(disp, text);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (displayToContent.isEmpty()) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // 栏目选择:query 命中某栏目 → 单栏;否则默认集
|
|
|
|
|
+ java.util.List<String> order = new java.util.ArrayList<>();
|
|
|
|
|
+ String hit = detectSection(query, displayToContent.keySet());
|
|
|
|
|
+ if (hit != null) {
|
|
|
|
|
+ order.add(hit);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ for (String d : new String[]{"正文", "性状", "类别", "制剂", "贮藏",
|
|
|
|
|
+ "功能与主治", "用法与用量", "不良反应", "禁忌", "注意事项"}) {
|
|
|
|
|
+ if (displayToContent.containsKey(d)) order.add(d);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (order.isEmpty()) return null;
|
|
|
|
|
+
|
|
|
|
|
+ final String sourceLabel = getFullSource(
|
|
|
|
|
+ drug.getSourceVersion() == null ? "" : drug.getSourceVersion(),
|
|
|
|
|
+ drug.getSourceVolume() == null ? "" : drug.getSourceVolume());
|
|
|
|
|
+ final java.util.List<String> sectionOrder = java.util.Collections.unmodifiableList(order);
|
|
|
|
|
+ final java.util.Map<String, String> sections = java.util.Collections.unmodifiableMap(displayToContent);
|
|
|
|
|
+
|
|
|
|
|
+ // 拼 sources + 各栏目原文(供结论 prompt 与最终答案)
|
|
|
|
|
+ StringBuilder sectionsText = new StringBuilder();
|
|
|
|
|
+ java.util.List<Map<String, Object>> sources = new java.util.ArrayList<>();
|
|
|
|
|
+ for (String sec : sectionOrder) {
|
|
|
|
|
+ String content = sections.get(sec);
|
|
|
|
|
+ sectionsText.append("【").append(sec).append("】\n").append(content).append("\n");
|
|
|
|
|
+ Map<String, Object> s = new java.util.LinkedHashMap<>();
|
|
|
|
|
+ s.put("drug_id", drug.getDrugId() == null ? "" : drug.getDrugId());
|
|
|
|
|
+ s.put("name", drug.getName() == null ? "" : drug.getName());
|
|
|
|
|
+ s.put("section", sec);
|
|
|
|
|
+ s.put("source", sourceLabel);
|
|
|
|
|
+ s.put("excerpt", content.length() > 400 ? content.substring(0, 400) + "…" : content);
|
|
|
|
|
+ sources.add(s);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 结论 prompt:小 LLM 调用,思考关
|
|
|
|
|
+ List<Map<String, String>> conclusionMessages = new java.util.ArrayList<>();
|
|
|
|
|
+ Map<String, String> sysMsg = new java.util.HashMap<>();
|
|
|
|
|
+ sysMsg.put("role", "system");
|
|
|
|
|
+ sysMsg.put("content", "你是一名药师。根据给定药典栏目原文,用一句话(不超过80字)概括该药关键信息作为【结论】,只输出结论文本,不得编造栏目外的信息。");
|
|
|
|
|
+ Map<String, String> userMsg = new java.util.HashMap<>();
|
|
|
|
|
+ userMsg.put("role", "user");
|
|
|
|
|
+ userMsg.put("content", "药品:" + drug.getName() + "\n" + sectionsText);
|
|
|
|
|
+ conclusionMessages.add(sysMsg);
|
|
|
|
|
+ conclusionMessages.add(userMsg);
|
|
|
|
|
+
|
|
|
|
|
+ final java.util.List<Map<String, Object>> srcList = java.util.Collections.unmodifiableList(sources);
|
|
|
|
|
+ final String drugNameResolved = drug.getName();
|
|
|
|
|
+
|
|
|
|
|
+ return Flux.<ServerSentEvent<String>>create(sink -> {
|
|
|
|
|
+ sink.next(ServerSentEvent.<String>builder().event("intent").data(intent).build());
|
|
|
|
|
+ sink.next(ServerSentEvent.<String>builder().event("status").data("已命中药典知识库,直取中...").build());
|
|
|
|
|
+
|
|
|
|
|
+ // 结论(思考关,~1-2s)
|
|
|
|
|
+ String conclusion;
|
|
|
|
|
+ try {
|
|
|
|
|
+ conclusion = llmService.chat(conclusionMessages, false, false);
|
|
|
|
|
+ } catch (Exception ex) {
|
|
|
|
|
+ log.warn("[kbDirect] 结论生成失败,跳过结论: {}", ex.getMessage());
|
|
|
|
|
+ conclusion = "";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ StringBuilder answer = new StringBuilder();
|
|
|
|
|
+ if (conclusion != null && !conclusion.isBlank()) {
|
|
|
|
|
+ answer.append("【结论】\n").append(conclusion.trim()).append("\n\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String sec : sectionOrder) {
|
|
|
|
|
+ answer.append("【").append(sec).append("】\n")
|
|
|
|
|
+ .append(sections.get(sec)).append("\n")
|
|
|
|
|
+ .append("(来源:").append(sourceLabel).append(")\n\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ answer.append("【来源明细】\n").append(sourceLabel);
|
|
|
|
|
+ String finalAnswer = answer.toString();
|
|
|
|
|
+
|
|
|
|
|
+ // 按段落流式发送
|
|
|
|
|
+ String[] chunks = finalAnswer.split("(?<=\\n)");
|
|
|
|
|
+ for (String chunk : chunks) {
|
|
|
|
|
+ sink.next(ServerSentEvent.<String>builder().data(chunk).build());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 品牌推荐
|
|
|
|
|
+ List<Map<String, Object>> brandRecs = brandRecommendService.match(srcList, finalAnswer);
|
|
|
|
|
+ sink.next(buildBrandRecommendEvent(brandRecs));
|
|
|
|
|
+
|
|
|
|
|
+ // meta
|
|
|
|
|
+ String meta;
|
|
|
|
|
+ try {
|
|
|
|
|
+ meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
|
|
|
|
+ "intent", intent, "sources", srcList, "conversation_id", cid, "source", "kb_direct"
|
|
|
|
|
+ ));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ meta = "{}";
|
|
|
|
|
+ }
|
|
|
|
|
+ sink.next(ServerSentEvent.<String>builder().event("meta").data(meta).build());
|
|
|
|
|
+
|
|
|
|
|
+ // 持久化
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
|
|
|
|
|
+ persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, srcList, brandRecs);
|
|
|
|
|
+ sink.complete();
|
|
|
|
|
+ }).doFinally(sig -> recordChatTiming(userKey, requestId, ip, ua, "kb_direct", t0, sig, null));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 扫描 query 中是否出现某栏目别名(SECTION_DISPLAY 的 key),返回其标准显示名(且需在可用栏目中) */
|
|
|
|
|
+ private String detectSection(String query, java.util.Collection<String> available) {
|
|
|
|
|
+ if (query == null) return null;
|
|
|
|
|
+ for (var e : PromptService.SECTION_DISPLAY.entrySet()) {
|
|
|
|
|
+ if (query.contains(e.getKey()) && available.contains(e.getValue())) {
|
|
|
|
|
+ return e.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 流式问答结束时上报一次"入参→出参"耗时(含分段),用 request_id 与前端 chat_complete 关联 */
|
|
/** 流式问答结束时上报一次"入参→出参"耗时(含分段),用 request_id 与前端 chat_complete 关联 */
|
|
|
private void recordChatTiming(String userKey, String requestId, String ip, String ua,
|
|
private void recordChatTiming(String userKey, String requestId, String ip, String ua,
|
|
|
String endpoint, long t0, SignalType sig,
|
|
String endpoint, long t0, SignalType sig,
|