liuchengsen 3 tygodni temu
rodzic
commit
7febca4720

+ 1 - 0
backend-java/src/main/java/com/pharmacopoeia/config/QwenProperties.java

@@ -19,4 +19,5 @@ public class QwenProperties {
     private String vlModel = "qwen3.7-plus";       // 多模态模型(图片/视频分析,原生 API)
     private String vlModel = "qwen3.7-plus";       // 多模态模型(图片/视频分析,原生 API)
     private String multimodalUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
     private String multimodalUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
     private boolean enableWebSearch = true;        // 是否启用联网搜索
     private boolean enableWebSearch = true;        // 是否启用联网搜索
+    private boolean enableThinking = true;        // 是否启用 qwen3 思考(reasoning)模式;默认开(兜底路径),L1 药名直出不经过 LLM
 }
 }

+ 146 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -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,

+ 2 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/DrugRepository.java

@@ -14,6 +14,8 @@ import java.util.Optional;
 public interface DrugRepository extends JpaRepository<Drug, Long> {
 public interface DrugRepository extends JpaRepository<Drug, Long> {
     Optional<Drug> findByDrugId(String drugId);
     Optional<Drug> findByDrugId(String drugId);
 
 
+    Optional<Drug> findFirstByNameAndIsActiveTrue(String name);
+
     long countByIsActiveTrue();
     long countByIsActiveTrue();
 
 
     @Query("SELECT COALESCE(MAX(d.updatedAt), NULL) FROM Drug d")
     @Query("SELECT COALESCE(MAX(d.updatedAt), NULL) FROM Drug d")

+ 7 - 0
backend-java/src/main/java/com/pharmacopoeia/service/DrugService.java

@@ -1,6 +1,7 @@
 package com.pharmacopoeia.service;
 package com.pharmacopoeia.service;
 
 
 import com.pharmacopoeia.dto.DrugDetailResponse;
 import com.pharmacopoeia.dto.DrugDetailResponse;
+import com.pharmacopoeia.entity.Drug;
 import com.pharmacopoeia.repository.DrugRepository;
 import com.pharmacopoeia.repository.DrugRepository;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
@@ -18,6 +19,12 @@ public class DrugService {
         this.drugRepository = drugRepository;
         this.drugRepository = drugRepository;
     }
     }
 
 
+    /** 按药品名精确查找(is_active),返回带 sections 的实体,供 L1 知识库直取使用 */
+    public Drug findDrugByName(String name) {
+        if (name == null || name.isBlank()) return null;
+        return drugRepository.findFirstByNameAndIsActiveTrue(name).orElse(null);
+    }
+
     public DrugDetailResponse getDrugDetail(String drugId) {
     public DrugDetailResponse getDrugDetail(String drugId) {
         return drugRepository.findByDrugId(drugId)
         return drugRepository.findByDrugId(drugId)
                 .map(d -> DrugDetailResponse.builder()
                 .map(d -> DrugDetailResponse.builder()

+ 11 - 0
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -62,6 +62,10 @@ public class LLMService {
     }
     }
 
 
     public Flux<String> chatStream(List<Map<String, String>> messages, boolean enableSearch) {
     public Flux<String> chatStream(List<Map<String, String>> messages, boolean enableSearch) {
+        return chatStream(messages, enableSearch, props.isEnableThinking());
+    }
+
+    public Flux<String> chatStream(List<Map<String, String>> messages, boolean enableSearch, boolean enableThinking) {
         checkRateLimit();
         checkRateLimit();
         long t0 = System.currentTimeMillis();
         long t0 = System.currentTimeMillis();
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
@@ -74,6 +78,8 @@ public class LLMService {
         if (enableSearch) {
         if (enableSearch) {
             body.put("enable_search", true);
             body.put("enable_search", true);
         }
         }
+        // qwen3 默认开启思考(reasoning),首字延迟可达 10-20s;按调用控制开关
+        body.put("enable_thinking", enableThinking);
         log.info("[chatStream] POST /chat/completions, model={}, msgs={}, bodyBuilt={}ms",
         log.info("[chatStream] POST /chat/completions, model={}, msgs={}, bodyBuilt={}ms",
                 props.getModel(), messages.size(), System.currentTimeMillis() - t0);
                 props.getModel(), messages.size(), System.currentTimeMillis() - t0);
 
 
@@ -116,6 +122,10 @@ public class LLMService {
     }
     }
 
 
     public String chat(List<Map<String, String>> messages, boolean enableSearch) {
     public String chat(List<Map<String, String>> messages, boolean enableSearch) {
+        return chat(messages, enableSearch, props.isEnableThinking());
+    }
+
+    public String chat(List<Map<String, String>> messages, boolean enableSearch, boolean enableThinking) {
         checkRateLimit();
         checkRateLimit();
         long t0 = System.currentTimeMillis();
         long t0 = System.currentTimeMillis();
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
@@ -127,6 +137,7 @@ public class LLMService {
         if (enableSearch) {
         if (enableSearch) {
             body.put("enable_search", true);
             body.put("enable_search", true);
         }
         }
+        body.put("enable_thinking", enableThinking);
         long t1 = System.currentTimeMillis();
         long t1 = System.currentTimeMillis();
         String response = chatClient.post()
         String response = chatClient.post()
                 .uri("/chat/completions")
                 .uri("/chat/completions")

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

@@ -300,7 +300,7 @@ public class RetrieverService {
     );
     );
 
 
     /** 从 query 中提取已知药品名:查 drugs 表,支持剂型后缀剥离和模糊匹配 */
     /** 从 query 中提取已知药品名:查 drugs 表,支持剂型后缀剥离和模糊匹配 */
-    private String extractDrugName(String query) {
+    public String extractDrugName(String query) {
         String cleaned = query.trim();
         String cleaned = query.trim();
 
 
         // 第一步:去掉尾部常见修饰词
         // 第一步:去掉尾部常见修饰词

+ 2 - 0
backend-java/src/main/resources/application.yml

@@ -83,6 +83,8 @@ qwen:
   vl-model: qwen3.7-plus
   vl-model: qwen3.7-plus
   multimodal-url: https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
   multimodal-url: https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
   enable-web-search: true
   enable-web-search: true
+  # qwen3 思考(reasoning)模式:默认开(兜底路径用);L1 药名直取不经过 LLM,结论小调用单独关思考
+  enable-thinking: true
   # 嵌入模型
   # 嵌入模型
   embedding-model: text-embedding-v3
   embedding-model: text-embedding-v3
   embedding-url: https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding
   embedding-url: https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding

+ 2 - 0
static/index.html

@@ -266,6 +266,8 @@
     var s='(?:结论|详细说明|注意事项|来源明细|适应症|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|病情评估|处理方案|免责声明|AI 声明|AI声明|来源说明)';
     var s='(?:结论|详细说明|注意事项|来源明细|适应症|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|病情评估|处理方案|免责声明|AI 声明|AI声明|来源说明)';
     h=h.replace(new RegExp('(?<![【])】\\s*('+s+')','g'),'【$1】');
     h=h.replace(new RegExp('(?<![【])】\\s*('+s+')','g'),'【$1】');
     h=h.replace(new RegExp('(?<![【])('+s+')】','g'),'【$1】');
     h=h.replace(new RegExp('(?<![【])('+s+')】','g'),'【$1】');
+    // 去掉"来源引用"括号内多余的【栏目】标记,避免被当成栏目标题拆行
+    for(var k=0;k<3;k++){var _p=h;h=h.replace(/((来源:[^)]*)【[^】]*?】([^)]*))/g,'$1$2');if(h===_p)break;}
     h=h.replace(/(?<![<br>\n])【(.+?)】/g,'<br>【$1】');
     h=h.replace(/(?<![<br>\n])【(.+?)】/g,'<br>【$1】');
     h=h.replace(/\n\n+/g,'</p><p>');h=h.replace(/\n/g,'<br>');
     h=h.replace(/\n\n+/g,'</p><p>');h=h.replace(/\n/g,'<br>');
     // LLM prompts 产生的栏目标题(大标题样式)
     // LLM prompts 产生的栏目标题(大标题样式)