2025-07-09 00:53:17 +08:00

218 lines
8.9 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, Object> params) throws Exception {
SseEmitter emitter = new SseEmitter(300 * 1000L); // 超时时间设为5分钟
List<WorkspaceAttachment> 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<WorkspaceAttachment> 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<WorkspaceAttachment> getWorkspaceAttachments(String llmToken, String question) {
List<WorkspaceAttachment> finalPinnedDocs;
if(BooleanUtil.toBoolean(dynamicConfig.getEnableAutoPin())) {
List<String> keywords = extractKeywords(question);
log.info("正在回答问题: question: {}, keywords: {}", question, JSONUtil.toJsonStr(keywords));
List<WorkspaceAttachment> 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<WorkspaceAttachment> pinDocsByKeywords(String llmToken, String workspaceSlug, List<String> keywords, String question) {
if(CollectionUtil.isEmpty(keywords)) {
return Collections.emptyList();
}
if(PROCESSING) {
log.info("有问题正在处理请稍等question: ", PROCESSING_QUESTION);
return Collections.emptyList();
}
List<FileEsModel> fileEsModels = esGateway.searchByKeywords(keywords);
Set<String> 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<WorkspaceAttachment> attachments = anythingLLMService.fetchAttachments(llmToken, workspaceSlug);
if (attachments == null || attachments.isEmpty()) {
log.info("[pinDocsByKeywords] 当前工作区无已嵌入附件。");
return Collections.emptyList();
}
// 3. 找到需要pin的docPath集合
List<WorkspaceAttachment> 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<String> extractKeywords(String text) {
List<String> 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;
}
}