Bladeren bron

ai药典优化

liuchengsen 3 weken geleden
bovenliggende
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 multimodalUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
     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 HttpServletRequest request;
     private final AnalyticsService analyticsService;
+    private final DrugService drugService;
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
                           ChatPersistenceService cps, RerankerService rrs,
@@ -49,7 +50,8 @@ public class ChatController {
                           BrandRecommendService brandRecommendService,
                           JdbcTemplate jdbc, QwenProperties props,
                           HttpServletRequest request,
-                          AnalyticsService analyticsService) {
+                          AnalyticsService analyticsService,
+                          DrugService drugService) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
@@ -61,6 +63,7 @@ public class ChatController {
         this.props = props;
         this.request = request;
         this.analyticsService = analyticsService;
+        this.drugService = drugService;
     }
 
     @PostMapping("/ask")
@@ -179,6 +182,13 @@ public class ChatController {
         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())));
 
+        // 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 接回管道保持取消链完整。
         // 纯 Reactor 管道(零裸 subscribe),连接断开时整条链路自动取消到百炼。
         return Flux.just(
@@ -889,6 +899,141 @@ public class ChatController {
                 .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 关联 */
     private void recordChatTiming(String userKey, String requestId, String ip, String ua,
                                   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> {
     Optional<Drug> findByDrugId(String drugId);
 
+    Optional<Drug> findFirstByNameAndIsActiveTrue(String name);
+
     long countByIsActiveTrue();
 
     @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;
 
 import com.pharmacopoeia.dto.DrugDetailResponse;
+import com.pharmacopoeia.entity.Drug;
 import com.pharmacopoeia.repository.DrugRepository;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.stereotype.Service;
@@ -18,6 +19,12 @@ public class DrugService {
         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) {
         return drugRepository.findByDrugId(drugId)
                 .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) {
+        return chatStream(messages, enableSearch, props.isEnableThinking());
+    }
+
+    public Flux<String> chatStream(List<Map<String, String>> messages, boolean enableSearch, boolean enableThinking) {
         checkRateLimit();
         long t0 = System.currentTimeMillis();
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
@@ -74,6 +78,8 @@ public class LLMService {
         if (enableSearch) {
             body.put("enable_search", true);
         }
+        // qwen3 默认开启思考(reasoning),首字延迟可达 10-20s;按调用控制开关
+        body.put("enable_thinking", enableThinking);
         log.info("[chatStream] POST /chat/completions, model={}, msgs={}, bodyBuilt={}ms",
                 props.getModel(), messages.size(), System.currentTimeMillis() - t0);
 
@@ -116,6 +122,10 @@ public class LLMService {
     }
 
     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();
         long t0 = System.currentTimeMillis();
         Map<String, Object> body = new java.util.HashMap<>(Map.of(
@@ -127,6 +137,7 @@ public class LLMService {
         if (enableSearch) {
             body.put("enable_search", true);
         }
+        body.put("enable_thinking", enableThinking);
         long t1 = System.currentTimeMillis();
         String response = chatClient.post()
                 .uri("/chat/completions")

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

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

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

@@ -83,6 +83,8 @@ qwen:
   vl-model: qwen3.7-plus
   multimodal-url: https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
   enable-web-search: true
+  # qwen3 思考(reasoning)模式:默认开(兜底路径用);L1 药名直取不经过 LLM,结论小调用单独关思考
+  enable-thinking: true
   # 嵌入模型
   embedding-model: text-embedding-v3
   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声明|来源说明)';
     h=h.replace(new RegExp('(?<![【])】\\s*('+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(/\n\n+/g,'</p><p>');h=h.replace(/\n/g,'<br>');
     // LLM prompts 产生的栏目标题(大标题样式)