Przeglądaj źródła

缓存数据调整

liuchengsen 1 miesiąc temu
rodzic
commit
3e91a0a13f

+ 90 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -30,17 +30,20 @@ public class ChatController {
     private final PromptService promptService;
     private final ChatPersistenceService persistenceService;
     private final RerankerService rerankerService;
+    private final QACacheService qaCache;
     private final JdbcTemplate jdbc;
     private final QwenProperties props;
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
                           ChatPersistenceService cps, RerankerService rrs,
+                          QACacheService qaCache,
                           JdbcTemplate jdbc, QwenProperties props) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
         this.persistenceService = cps;
         this.rerankerService = rrs;
+        this.qaCache = qaCache;
         this.jdbc = jdbc;
         this.props = props;
     }
@@ -48,10 +51,46 @@ public class ChatController {
     @PostMapping("/ask")
     public ResponseEntity<Map<String, Object>> chatAsk(@RequestBody ChatRequest request) {
         String query = request.getMessage();
+        String normalized = qaCache.normalize(query);
         String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
                 ? request.getConversationId()
                 : UUID.randomUUID().toString();
 
+        // 检查全局缓存(24 小时有效,不区分用户)
+        Map<String, Object> cached = qaCache.get(normalized);
+        if (cached != null) {
+            String answer = (String) cached.get("answer");
+            String intent = (String) cached.getOrDefault("intent", "");
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
+            persistenceService.saveMessage(cid, "user", query, intent, null);
+            persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
+            return ResponseEntity.ok(Map.of(
+                    "answer", answer,
+                    "sources", sources,
+                    "intent", intent,
+                    "conversation_id", cid,
+                    "cached", true
+            ));
+        }
+
+        // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
+        if (!qaCache.tryMarkPending(normalized)) {
+            Map<String, Object> waited = qaCache.waitForCache(normalized);
+            if (waited != null) {
+                String answer = (String) waited.get("answer");
+                String intent = (String) waited.getOrDefault("intent", "");
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> sources = (List<Map<String, Object>>) waited.getOrDefault("sources", List.of());
+                persistenceService.saveMessage(cid, "user", query, intent, null);
+                persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
+                return ResponseEntity.ok(Map.of(
+                        "answer", answer, "sources", sources, "intent", intent,
+                        "conversation_id", cid, "cached", true
+                ));
+            }
+        }
+
         String intent = retrieverService.classifyIntent(query);
         List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
         docs = rerankerService.rerank(docs, query, 5);
@@ -65,6 +104,9 @@ public class ChatController {
         persistenceService.saveMessage(cid, "user", query, intent, null);
         persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
 
+        // 写入全局缓存
+        qaCache.put(normalized, answer, intent, sources);
+
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
@@ -76,10 +118,25 @@ public class ChatController {
     @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
     public Flux<ServerSentEvent<String>> chatStream(@RequestBody ChatRequest request) {
         String query = request.getMessage();
+        String normalized = qaCache.normalize(query);
         final String cid = request.getConversationId() != null && !request.getConversationId().isBlank()
                 ? request.getConversationId()
                 : UUID.randomUUID().toString();
 
+        // 检查全局缓存
+        Map<String, Object> cached = qaCache.get(normalized);
+        if (cached != null) {
+            return streamCached(cached, cid, query, normalized);
+        }
+
+        // 未命中缓存:尝试抢占处理权,避免并发重复调 LLM
+        if (!qaCache.tryMarkPending(normalized)) {
+            Map<String, Object> waited = qaCache.waitForCache(normalized);
+            if (waited != null) {
+                return streamCached(waited, cid, query, normalized);
+            }
+        }
+
         final String intent = retrieverService.classifyIntent(query);
 
         return retrieverService.searchReactive(query, intent, 20)
@@ -113,6 +170,10 @@ public class ChatController {
                                 String finalAnswer = cleanAnswer(fullAnswer.toString());
                                 persistenceService.saveMessage(cid, "user", query, intent, null);
                                 persistenceService.saveMessage(cid, "assistant", finalAnswer, intent, sources);
+
+                                // 写入全局缓存
+                                qaCache.put(normalized, finalAnswer, intent, sources);
+
                                 sink.tryEmitComplete();
                             })
                             .doOnError(sink::tryEmitError)
@@ -122,6 +183,35 @@ public class ChatController {
                 });
     }
 
+    /** 将缓存命中结果以流式 SSE 形式返回 */
+    private Flux<ServerSentEvent<String>> streamCached(Map<String, Object> cached, String cid,
+                                                        String query, String normalized) {
+        String answer = (String) cached.get("answer");
+        String intent = (String) cached.getOrDefault("intent", "");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> sources = (List<Map<String, Object>>) cached.getOrDefault("sources", List.of());
+
+        persistenceService.saveMessage(cid, "user", query, intent, null);
+        persistenceService.saveMessage(cid, "assistant", answer, intent, sources);
+
+        return Flux.create(sink -> {
+            sink.next(ServerSentEvent.<String>builder().event("intent").data(intent).build());
+            sink.next(ServerSentEvent.<String>builder().event("status").data("命中缓存,直接返回...").build());
+            // 将缓存答案按段落拆分发送,模拟流式体验
+            String[] chunks = answer.split("(?<=\\n)");
+            for (String chunk : chunks) {
+                sink.next(ServerSentEvent.<String>builder().data(chunk).build());
+            }
+            try {
+                String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
+                        "intent", intent, "sources", sources, "conversation_id", cid, "cached", true
+                ));
+                sink.next(ServerSentEvent.<String>builder().event("meta").data(meta).build());
+            } catch (Exception ignored) {}
+            sink.complete();
+        });
+    }
+
     // ============================================================
     // 图片对话 API(Qwen VL 分析 + OCR → RAG 检索 → 联网搜索)
     // ============================================================

+ 161 - 0
backend-java/src/main/java/com/pharmacopoeia/service/QACacheService.java

@@ -0,0 +1,161 @@
+package com.pharmacopoeia.service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 全局 QA 缓存(不区分用户,24 小时过期)。
+ * 同一个问题在 24 小时内只调用一次 LLM,后续直接返回缓存结果。
+ * 并发控制:同一问题同时只有第一个请求调 LLM,其余等待缓存写入后直接返回。
+ */
+@Service
+public class QACacheService {
+
+    private static final Logger log = LoggerFactory.getLogger(QACacheService.class);
+
+    private static final String CACHE_PREFIX = "aiyaodian:qa:";
+    private static final String PENDING_PREFIX = "aiyaodian:qa:pending:";
+    private static final Duration TTL = Duration.ofHours(24);
+    /** 处理中标记超时:防止死锁,30 秒后自动释放 */
+    private static final Duration PENDING_TTL = Duration.ofSeconds(30);
+    /** 等待缓存写入的最大重试次数 */
+    private static final int MAX_RETRIES = 30;
+    /** 每次重试间隔 */
+    private static final long RETRY_INTERVAL_MS = 500;
+
+    private final StringRedisTemplate redis;
+    private final boolean available;
+    private final ObjectMapper objectMapper;
+
+    public QACacheService(StringRedisTemplate redis, ObjectMapper objectMapper) {
+        this.redis = redis;
+        this.objectMapper = objectMapper;
+        this.available = pingRedis();
+    }
+
+    private boolean pingRedis() {
+        try {
+            return "PONG".equals(redis.getConnectionFactory().getConnection().ping());
+        } catch (Exception e) {
+            log.warn("Redis 不可用,QA 缓存降级为无缓存模式");
+            return false;
+        }
+    }
+
+    /** 规范化问题文本,用于生成缓存 key */
+    public String normalize(String query) {
+        if (query == null) return "";
+        return query.trim().replaceAll("\\s+", " ");
+    }
+
+    /** 根据问题文本生成缓存 key */
+    public String cacheKey(String normalizedQuery) {
+        return CACHE_PREFIX + sha256(normalizedQuery);
+    }
+
+    private String pendingKey(String normalizedQuery) {
+        return PENDING_PREFIX + sha256(normalizedQuery);
+    }
+
+    /**
+     * 查询缓存,命中返回 {answer, sources, intent},未命中返回 null。
+     */
+    public Map<String, Object> get(String normalizedQuery) {
+        if (!available || normalizedQuery == null || normalizedQuery.isBlank()) {
+            return null;
+        }
+        try {
+            String json = redis.opsForValue().get(cacheKey(normalizedQuery));
+            if (json == null || json.isEmpty()) return null;
+            return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 写入缓存,同时移除处理中标记。
+     */
+    public void put(String normalizedQuery, String answer, String intent,
+                    List<Map<String, Object>> sources) {
+        if (!available || normalizedQuery == null || normalizedQuery.isBlank()) {
+            return;
+        }
+        try {
+            Map<String, Object> entry = Map.of(
+                    "answer", answer,
+                    "intent", intent != null ? intent : "",
+                    "sources", sources != null ? sources : List.of()
+            );
+            String json = objectMapper.writeValueAsString(entry);
+            String key = cacheKey(normalizedQuery);
+            redis.opsForValue().set(key, json, TTL);
+            // 移除处理中标记,唤醒等待者
+            redis.delete(pendingKey(normalizedQuery));
+        } catch (Exception e) {
+            log.warn("QA 缓存写入失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 尝试标记该问题为"处理中"。
+     * @return true 表示成功抢占(可以调 LLM),false 表示已有其他请求在处理
+     */
+    public boolean tryMarkPending(String normalizedQuery) {
+        if (!available || normalizedQuery == null || normalizedQuery.isBlank()) {
+            return true; // Redis 不可用时放行,避免阻塞
+        }
+        try {
+            Boolean ok = redis.opsForValue()
+                    .setIfAbsent(pendingKey(normalizedQuery), "1", PENDING_TTL);
+            return Boolean.TRUE.equals(ok);
+        } catch (Exception e) {
+            return true; // 异常时放行
+        }
+    }
+
+    /**
+     * 等待缓存写入(另一个请求正在调 LLM),轮询直到缓存命中或超时。
+     * @return 缓存结果,超时返回 null(调用方应自行调 LLM)
+     */
+    public Map<String, Object> waitForCache(String normalizedQuery) {
+        for (int i = 0; i < MAX_RETRIES; i++) {
+            try {
+                Thread.sleep(RETRY_INTERVAL_MS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return null;
+            }
+            Map<String, Object> cached = get(normalizedQuery);
+            if (cached != null) {
+                return cached;
+            }
+        }
+        return null; // 超时,调用方自行处理
+    }
+
+    private static String sha256(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            return Integer.toHexString(input.hashCode());
+        }
+    }
+}

+ 82 - 1
static/index.html

@@ -97,11 +97,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
 .pager button:disabled{opacity:.35;cursor:default}
 .pager .page-info{font-size:12px;color:#888;margin:0 4px;white-space:nowrap}
 .pager select{padding:6px 8px;appearance:auto}
+.clear-chat-btn{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.15);border:1px solid rgba(255,255,255,.3);color:#fff;padding:4px 10px;border-radius:14px;font-size:11px;cursor:pointer;z-index:2;transition:all .15s}
+.clear-chat-btn:hover{background:rgba(255,255,255,.25)}
 </style>
 </head>
 <body>
 <div class="header">
   <h1>AI 药典助手</h1>
+  <button class="clear-chat-btn" onclick="clearChat()" title="清除本地对话记录">🗑 清空对话</button>
   <div class="sub">基于《中华人民共和国药典》& 通义千问</div>
 </div>
 <div class="nav">
@@ -109,7 +112,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei
   <button class="nav-btn" data-tab="drugs" onclick="switchTab('drugs')">药品库</button>
 </div>
 <div id="tabChat">
-  <div class="chat" id="chat"><div class="welcome"><div class="icon">📷💊🎬</div><p>输入药品名称或问题,AI 从药典知识库中检索回答<br><small style="color:#aaa">支持图片/视频上传识别 · 2025版药典为主 · 2020版补充</small></p></div></div>
+  <div class="chat" id="chat"><div class="welcome"><div class="icon">📷💊🎬</div><p>输入药品名称或问题,AI 从药典知识库中检索回答<br><small style="color:#aaa">支持图片/视频上传识别 · 2025版药典</small></p></div></div>
   <div class="tags"><span class="tag-btn" onclick="quickAsk('布洛芬')">布洛芬</span><span class="tag-btn" onclick="quickAsk('甲硝唑')">甲硝唑</span><span class="tag-btn" onclick="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</span><span class="tag-btn" onclick="quickAsk('布洛芬用法用量')">布洛芬用法用量</span><span class="tag-btn" onclick="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</span><span class="tag-btn" onclick="quickAsk('青霉素过敏处理')">青霉素过敏处理</span></div>
   <div id="previewArea" style="display:none;padding:8px 14px;background:#fffef5;border-top:1px solid #f0e68c;align-items:center;gap:10px;flex-wrap:wrap">
     <span id="previewLabel" style="font-size:13px;color:#666;font-weight:500"></span>
@@ -206,6 +209,84 @@ psRe.lastIndex=0;
 var STREAMING=false,streamAbort=null,currentIntent='';
 var pendingMedia=null;
 
+// ============================================================
+// 本地对话持久化 (IndexedDB, 上限 256MB)
+// ============================================================
+var chatDB=null,chatDBTotalSize=0,chatDBMaxSize=256*1024*1024;
+function openChatDB(){
+  return new Promise(function(resolve,reject){
+    var req=indexedDB.open('PharmaChat',1);
+    req.onupgradeneeded=function(e){
+      var db=e.target.result;
+      if(!db.objectStoreNames.contains('messages')){db.createObjectStore('messages',{keyPath:'id',autoIncrement:true})}
+    };
+    req.onsuccess=function(e){chatDB=e.target.result;resolve()};
+    req.onerror=function(e){reject(e.target.error)};
+  });
+}
+function saveMsgToDB(msg){
+  if(!chatDB)return;
+  var tx=chatDB.transaction('messages','readwrite');
+  var store=tx.objectStore('messages');
+  store.add(msg);
+  chatDBTotalSize+=new Blob([JSON.stringify(msg)]).size;
+  pruneIfNeeded();
+}
+function pruneIfNeeded(){
+  if(chatDBTotalSize<=chatDBMaxSize)return;
+  var tx=chatDB.transaction('messages','readwrite');
+  var store=tx.objectStore('messages');
+  var req=store.openCursor();
+  req.onsuccess=function(e){
+    var cursor=e.target.result;
+    if(cursor&&chatDBTotalSize>chatDBMaxSize*0.8){
+      chatDBTotalSize-=new Blob([JSON.stringify(cursor.value)]).size;
+      cursor.delete();
+      cursor.continue();
+    }
+  };
+}
+function loadChatFromDB(){
+  return new Promise(function(resolve){
+    if(!chatDB){resolve([]);return}
+    var tx=chatDB.transaction('messages','readonly');
+    var store=tx.objectStore('messages');
+    var req=store.getAll();
+    req.onsuccess=function(){
+      var msgs=req.result||[];
+      chatDBTotalSize=0;
+      for(var i=0;i<msgs.length;i++){chatDBTotalSize+=new Blob([JSON.stringify(msgs[i])]).size}
+      resolve(msgs);
+    };
+    req.onerror=function(){resolve([])};
+  });
+}
+window.clearChat=function(){
+  if(!chatDB)return;
+  if(!confirm('确定要清空所有本地对话记录吗?此操作不可撤销。'))return;
+  var tx=chatDB.transaction('messages','readwrite');
+  tx.objectStore('messages').clear();
+  chatDBTotalSize=0;
+  document.getElementById('chat').innerHTML='<div class="welcome"><div class="icon">📷💊🎬</div><p>输入药品名称或问题,AI 从药典知识库中检索回答<br><small style="color:#aaa">支持图片/视频上传识别 · 2025版药典</small></p></div>';
+};
+async function restoreChat(){
+  var msgs=await loadChatFromDB();
+  if(!msgs.length)return;
+  var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();
+  for(var i=0;i<msgs.length;i++){
+    var m=msgs[i],div=document.createElement('div');
+    div.className='msg '+m.role;
+    if(m.role==='user'){div.textContent=m.content}
+    else{
+      var tag=m.intent||'';
+      var tagHtml=tag?'<div class="tag '+tagClass(tag)+'">'+intentLabel(tag)+'</div>':'<div class="tag tag-fallback">AI 药典助手</div>';
+      div.innerHTML=tagHtml+'<div class="content">'+md2html(m.content)+'</div>';
+    }
+    document.getElementById('chat').appendChild(div);
+  }
+  document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
+}
+
 function onFilePicked(type){
   var input=document.getElementById(type==='video'?'videoInput':'imageInput');
   var file=input.files[0];if(!file)return;