package com.knowledge.base.application.service; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.google.common.collect.Lists; import com.knowledge.base.domain.doc.model.FileEsModel; import com.knowledge.base.infrastructure.config.ConstantConfig; import com.knowledge.base.infrastructure.config.DynamicConfig; import com.knowledge.base.infrastructure.config.ThreadPoolConfig; import com.knowledge.base.infrastructure.north.dto.llm.WorkspaceAttachment; import com.knowledge.base.infrastructure.south.es.FileElasticsearchGateway; import com.knowledge.base.infrastructure.south.llm.AnythingLLMService; import com.knowledge.base.infrastructure.south.llm.LLMServiceFactory; import com.knowledge.base.infrastructure.util.RateLimiterManager; import com.knowledge.base.infrastructure.util.ThreadPoolUtil; import com.knowledge.base.infrastructure.util.http.FilteredSseOutputAdapter; import com.knowledge.base.infrastructure.util.http.WriterAdapter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Slf4j public class LLMAppServiceImpl implements LLMAppService { private final LLMServiceFactory llmServiceFactory; private final RateLimiterManager rateLimiterManager; private final AnythingLLMService anythingLLMService; private final FileElasticsearchGateway esGateway; private final DynamicConfig dynamicConfig; /** * pin和unpin的并发控制 */ private static boolean PROCESSING = false; private static String PROCESSING_QUESTION = StrUtil.EMPTY; @Override public String getToken(String password) throws Exception { return llmServiceFactory.current().fetchToken(password); } @Override public SseEmitter ask(String llmToken, String question, Map params) throws Exception { SseEmitter emitter = new SseEmitter(300 * 1000L); // 超时时间设为5分钟 List finalPinnedDocs = getWorkspaceAttachments(llmToken, question); ThreadPoolUtil.execute(() -> { try { rateLimiterManager.getRateLimiter(RateLimiterManager.RATE_LIMIT_SCENE_LLM_ASK).acquire(); WriterAdapter adapter = new FilteredSseOutputAdapter(emitter); llmServiceFactory.current().streamAnswer(llmToken, question, params, adapter); emitter.complete(); } catch (Exception e) { log.error("LLM调用异常", e); try { emitter.send(SseEmitter.event().data("{\"error\": \"LLM异常\"}")); emitter.completeWithError(e); } catch (IOException ioException) { log.warn("SSE发送错误信息失败", ioException); } } finally { unpinLlmAttachments(llmToken, question, finalPinnedDocs); } }, ThreadPoolConfig.SSE_POOL); return emitter; } private void unpinLlmAttachments(String llmToken, String question, List finalPinnedDocs) { if(BooleanUtil.toBoolean(dynamicConfig.getEnableAutoPin())) { // 问题结束后,需要unpin掉 log.info("问题已回答完成: question: {}, pinnedDocs: {}", question, JSONUtil.toJsonStr(finalPinnedDocs)); finalPinnedDocs.forEach(doc -> { try { anythingLLMService.updatePin(llmToken, ConstantConfig.DEFAULT_SLUG_ID, doc.getDocpath(), false); } catch (Exception e) { log.warn("unpin失败: {}", doc, e); } }); PROCESSING = false; }else { log.info("[unpinLlmAttachments] 自动pin文档功能未开启. {}", dynamicConfig.getEnableAutoPin()); } } private List getWorkspaceAttachments(String llmToken, String question) { List finalPinnedDocs; if(BooleanUtil.toBoolean(dynamicConfig.getEnableAutoPin())) { List keywords = extractKeywords(question); log.info("正在回答问题: question: {}, keywords: {}", question, JSONUtil.toJsonStr(keywords)); List pinnedDocs = pinDocsByKeywords(llmToken, ConstantConfig.DEFAULT_SLUG_ID, keywords, question); finalPinnedDocs = pinnedDocs; } else { finalPinnedDocs = Collections.emptyList(); log.info("[getWorkspaceAttachments] 自动pin文档功能未开启. {}", dynamicConfig.getEnableAutoPin()); } return finalPinnedDocs; } @Override public List pinDocsByKeywords(String llmToken, String workspaceSlug, List keywords, String question) { if(CollectionUtil.isEmpty(keywords)) { return Collections.emptyList(); } if(PROCESSING) { log.info("有问题正在处理,请稍等,question: {}", PROCESSING_QUESTION); return Collections.emptyList(); } List fileEsModels = esGateway.searchByKeywords(keywords); Set esFilepaths = fileEsModels.stream() .map(FileEsModel::getFilepath) .filter(Objects::nonNull) .collect(Collectors.toSet()); if (esFilepaths.isEmpty()) { log.info("[pinDocsByKeywords] 未查到匹配ES文档,跳过pin操作。"); return Lists.newArrayList(); } log.info("[pinDocsByKeywords] 已查询到关键词关联的文档。 {}", JSONUtil.toJsonStr(esFilepaths)); // 将当前问题标注为处理中 markQuestionProcessing(question); // 2. 获取当前工作区所有附件(docPath -> url) List attachments = anythingLLMService.fetchAttachments(llmToken, workspaceSlug); if (attachments == null || attachments.isEmpty()) { log.info("[pinDocsByKeywords] 当前工作区无已嵌入附件。"); return Collections.emptyList(); } // 3. 找到需要pin的docPath集合 List toPinDocs = attachments.stream().filter(att -> { String url = att.getUrl(); if (url == null) return false; // .md 文件 if (url.endsWith(".md")) { return esFilepaths.stream().anyMatch(f -> url.endsWith(f)); } // 其它文件(如 .pptx、.pdf、.docx等) int lastSlash = url.lastIndexOf('/'); String fileName = lastSlash != -1 ? url.substring(lastSlash + 1) : url; // esFilepaths 里也提取末尾文件名 return esFilepaths.stream().anyMatch(f -> { int esLastSlash = f.lastIndexOf('/'); String esName = esLastSlash != -1 ? f.substring(esLastSlash + 1) : f; return fileName.equals(esName); }); }).collect(Collectors.toList()); // 4. 只unpin之前已pin的文档 attachments.stream() .filter(WorkspaceAttachment::isPinned) .forEach(att -> { try { log.info("unpin doc {}", att.getUrl()); anythingLLMService.updatePin(llmToken, workspaceSlug, att.getDocpath(), false); } catch (Exception e) { log.warn("取消pin失败: {}", att.getDocpath(), e); } }); // 5. pin目标文档 toPinDocs.forEach(doc -> { try { anythingLLMService.updatePin(llmToken, workspaceSlug, doc.getDocpath(), true); doc.setPinned(true); } catch (Exception e) { log.warn("pin失败: {}", doc, e); } }); log.info("[pinDocsByKeywords] 共pin住文档{}条: {}", toPinDocs.size(), JSONUtil.toJsonStr(toPinDocs)); return toPinDocs; } /** * 提取所有被LEFT & RIGHT 包裹的子串,返回数组 */ public static List extractKeywords(String text) { List keywords = new ArrayList<>(); String left = ConstantConfig.KEYWORD_PATTERN_LEFT; String right = ConstantConfig.KEYWORD_PATTERN_RIGHT; Pattern pattern = Pattern.compile(Pattern.quote(left) + "(.*?)" + Pattern.quote(right)); Matcher matcher = pattern.matcher(text); while (matcher.find()) { keywords.add(matcher.group(1)); } return keywords; } public static void markQuestionProcessing(String question) { PROCESSING = true; PROCESSING_QUESTION = question; } }