You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
781 lines
36 KiB
781 lines
36 KiB
package com.wok.supportbot.app;
|
|
|
|
import com.wok.supportbot.advisor.ContentSafetyAdvisor;
|
|
import com.wok.supportbot.advisor.MyLoggerAdvisor;
|
|
import com.wok.supportbot.advisor.ReReadingAdvisor;
|
|
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
|
|
import com.wok.supportbot.config.ChatModelFactory;
|
|
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
|
|
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
|
|
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
|
|
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
|
|
import com.wok.supportbot.service.FaqMatchEngine;
|
|
import com.wok.supportbot.mcp.McpToolCallback;
|
|
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
|
|
import jakarta.annotation.Resource;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.ai.chat.client.ChatClient;
|
|
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
|
import org.springframework.ai.chat.client.advisor.api.Advisor;
|
|
import org.springframework.ai.chat.memory.ChatMemory;
|
|
import org.springframework.ai.chat.messages.Message;
|
|
import org.springframework.ai.chat.model.ChatModel;
|
|
import org.springframework.ai.chat.model.ChatResponse;
|
|
import org.springframework.ai.document.Document;
|
|
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
|
|
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
|
|
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
|
|
import org.springframework.ai.tool.ToolCallback;
|
|
import org.springframework.ai.vectorstore.SearchRequest;
|
|
import org.springframework.ai.vectorstore.VectorStore;
|
|
import org.springframework.ai.vectorstore.filter.Filter;
|
|
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.util.StringUtils;
|
|
import reactor.core.publisher.Flux;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.stream.Collectors;
|
|
|
|
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
|
|
|
|
/**
|
|
* @Classname AssistantApp
|
|
* @Description
|
|
* @Version 1.0.0
|
|
* @Date 2025/06/27 14:11
|
|
* @Author lyx
|
|
*/
|
|
@Component
|
|
@Slf4j
|
|
public class AssistantApp {
|
|
|
|
@Resource
|
|
private VectorStore pgVectorVectorStore;
|
|
|
|
@Resource
|
|
private ContentSafetyAdvisor contentSafetyAdvisor;
|
|
|
|
@Resource
|
|
private FaqMatchEngine faqMatchEngine;
|
|
|
|
@Resource
|
|
private McpToolCallbackAdapter mcpToolCallbackAdapter;
|
|
|
|
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
|
|
@Value("${chat.mcp.enabled:true}")
|
|
private boolean enableMcpTools;
|
|
|
|
private final ChatModelFactory chatModelFactory;
|
|
|
|
private final DatabaseChatMemory chatMemory;
|
|
|
|
private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>();
|
|
|
|
private static final String SYSTEM_PROMPT = "";//""\u4f60\u662f\u83dc\u5927\u738b\u5185\u90e8\u77e5\u8bc6\u52a9\u624b\uff0c\u4e3b\u8981\u670d\u52a1\u5bf9\u8c61\u662f\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u7b49\u5185\u90e8\u4eba\u5458\uff0c\u4e0d\u662f\u76f4\u63a5\u9762\u5411\u7ec8\u7aef\u5ba2\u6237\u7684\u673a\u5668\u4eba\u3002\n\n\u9ed8\u8ba4\u4e1a\u52a1\u4e0a\u4e0b\u6587\uff1a\n- \u7528\u6237\u672a\u7279\u522b\u8bf4\u660e\u65f6\uff0c\u5e73\u53f0\u3001APP\u3001\u540e\u53f0\u3001\u8ba2\u5355\u3001\u5ba2\u6237\u3001\u4f9b\u5e94\u5546\u5747\u9ed8\u8ba4\u6307\u83dc\u5927\u738b\u76f8\u5173\u7cfb\u7edf\u548c\u4e1a\u52a1\u3002\n- \u4e0d\u8981\u53cd\u95ee\u201c\u54ea\u4e2a\u5e73\u53f0/\u7cfb\u7edf\u201d\uff0c\u9664\u975e\u7528\u6237\u660e\u786e\u63d0\u5230\u5916\u90e8\u5e73\u53f0\u3001\u591a\u4e1a\u52a1\u7ebf\u51b2\u7a81\uff0c\u6216\u95ee\u9898\u672c\u8eab\u5fc5\u987b\u533a\u5206\u7cfb\u7edf\u3002\n\n\u56de\u7b54\u65b9\u5f0f\uff1a\n- \u5185\u90e8\u4eba\u5458\u8be2\u95ee\u5ba2\u6237\u54a8\u8be2\u7c7b\u95ee\u9898\u65f6\uff0c\u5148\u7ed9\u201c\u53ef\u8f6c\u8ff0\u7ed9\u5ba2\u6237\u7684\u7b54\u590d\u53e3\u5f84\u201d\uff0c\u518d\u7ed9\u5185\u90e8\u5904\u7406\u8981\u70b9\u3002\n- \u5bf9\u6d41\u7a0b\u7c7b\u95ee\u9898\uff0c\u4f18\u5148\u76f4\u63a5\u7ed9\u5165\u53e3\u3001\u6b65\u9aa4\u3001\u6761\u4ef6\u548c\u5f02\u5e38\u5904\u7406\uff0c\u4e0d\u8981\u4e00\u5f00\u59cb\u53ea\u8ffd\u95ee\u4fe1\u606f\u3002\n- \u53ea\u6709\u5728\u7f3a\u5c11\u8ba2\u5355\u53f7\u3001\u8ba2\u5355\u72b6\u6001\u3001\u53d1\u7968\u7c7b\u578b\u3001\u8d26\u53f7\u6743\u9650\u7b49\u4f1a\u5f71\u54cd\u529e\u7406\u7ed3\u679c\u7684\u5173\u952e\u4fe1\u606f\u65f6\uff0c\u624d\u8865\u5145\u9700\u8981\u786e\u8ba4\u7684\u4e8b\u9879\u3002\n- \u6d89\u53ca\u8d22\u52a1\u3001\u884c\u653f\u5236\u5ea6\u6216\u654f\u611f\u5185\u90e8\u4fe1\u606f\u65f6\uff0c\u53ea\u8f93\u51fa\u5f53\u524d\u89d2\u8272\u548c\u77e5\u8bc6\u5e93\u5141\u8bb8\u4f7f\u7528\u7684\u5185\u5bb9\uff1b\u8d44\u6599\u4e0d\u8db3\u65f6\u5982\u5b9e\u8bf4\u660e\uff0c\u4e0d\u8981\u7f16\u9020\u3002\n\n\u4fdd\u6301\u4e13\u4e1a\u3001\u7b80\u6d01\u3001\u53ef\u6267\u884c\u3002";
|
|
|
|
/**
|
|
* 初始化 ChatClient
|
|
*
|
|
* @param dashscopeChatModel
|
|
*/
|
|
public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) {
|
|
this.chatModelFactory = chatModelFactory;
|
|
this.chatMemory = chatMemory;
|
|
}
|
|
|
|
private ChatClient getChatClient(String appType) {
|
|
return getChatClient(appType, enableMcpTools ? java.util.List.of("*") : null);
|
|
}
|
|
|
|
/**
|
|
* 获取 ChatClient(支持按角色过滤 MCP 工具)
|
|
* 缓存 key 包含工具权限信息,不同角色权限缓存不同的 ChatClient 实例。
|
|
*
|
|
* @param appType 应用类型(CHAT / RAG_REWRITE 等)
|
|
* @param allowedMcpTools 允许的 MCP 工具名列表;null 或空 = 允许所有(默认行为);["*"] = 全部
|
|
*/
|
|
private ChatClient getChatClient(String appType, List<String> allowedMcpTools) {
|
|
// 空列表降级为 ["*"](允许所有工具),与无参数版本行为一致
|
|
final List<String> effectiveTools;
|
|
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
|
|
effectiveTools = java.util.List.of("*");
|
|
} else {
|
|
effectiveTools = allowedMcpTools;
|
|
}
|
|
String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools));
|
|
return chatClientCache.computeIfAbsent(cacheKey, type -> {
|
|
ChatModel chatModel = chatModelFactory.getChatModel(appType);
|
|
var builder = ChatClient.builder(chatModel);
|
|
// 仅在系统提示词非空时设置,避免 Spring AI 的 Assert.hasText 校验抛异常
|
|
if (StringUtils.hasText(SYSTEM_PROMPT)) {
|
|
builder.defaultSystem(SYSTEM_PROMPT);
|
|
}
|
|
// 注册 MCP 工具(按角色权限过滤)
|
|
if (enableMcpTools && effectiveTools != null && !effectiveTools.isEmpty()) {
|
|
ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools);
|
|
if (mcpTools.length > 0) {
|
|
builder.defaultToolCallbacks(mcpTools);
|
|
log.info("✅ 已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
|
|
for (ToolCallback tc : mcpTools) {
|
|
log.info(" 🔧 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
|
|
}
|
|
} else {
|
|
log.warn("⚠️ MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
|
|
log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?");
|
|
log.warn(" 2. 配置是否已启用(is_active=true)?");
|
|
log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?");
|
|
log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?");
|
|
}
|
|
} else {
|
|
log.info("ℹ️ MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
|
|
}
|
|
return builder
|
|
.defaultAdvisors(
|
|
contentSafetyAdvisor,
|
|
MessageChatMemoryAdvisor.builder(chatMemory).build(),
|
|
new MyLoggerAdvisor()
|
|
)
|
|
.build();
|
|
});
|
|
}
|
|
|
|
public void clearCache() {
|
|
chatClientCache.clear();
|
|
log.info("AssistantApp ChatClient cache cleared");
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(支持多轮对话记忆)
|
|
*
|
|
* @param message
|
|
* @param chatId
|
|
* @return
|
|
*/
|
|
public String doChat(String message, String chatId) {
|
|
return doChat(message, chatId, null);
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(支持多轮对话记忆 + 角色系统提示词)
|
|
*
|
|
* @param message 用户消息(保持原样,不做包装,避免污染会话记忆)
|
|
* @param chatId 会话ID
|
|
* @param systemPrompt 角色人设/风格,作为系统提示词叠加在基础提示词之上;为空则仅用基础提示词
|
|
* @return AI 回答
|
|
*/
|
|
public String doChat(String message, String chatId, String systemPrompt) {
|
|
return doChat(message, chatId, systemPrompt, null);
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(支持多轮对话记忆 + 角色系统提示词 + MCP 工具权限)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param systemPrompt 角色人设/风格
|
|
* @param allowedMcpTools 允许的 MCP 工具列表;null=不注册工具
|
|
* @return AI 回答
|
|
*/
|
|
public String doChat(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.user(message)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
ChatResponse chatResponse = spec.call().chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
|
|
/**
|
|
* 组合系统提示词:基础客服提示词 + 角色人设。
|
|
* 角色人设作为附加段落叠加,既保留客服基线约束,又让角色风格生效。
|
|
*/
|
|
private String effectiveSystem(String rolePrompt) {
|
|
if (!StringUtils.hasText(rolePrompt)) {
|
|
return SYSTEM_PROMPT;
|
|
}
|
|
return SYSTEM_PROMPT + "\n\n【当前角色设定】\n" + rolePrompt;
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(支持多轮对话记忆,SSE 流式传输)
|
|
*
|
|
* @param message
|
|
* @param chatId
|
|
* @return
|
|
*/
|
|
public Flux<String> doChatByStream(String message, String chatId) {
|
|
return doChatByStream(message, chatId, null);
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(多轮记忆 + 角色系统提示词,SSE 流式传输)
|
|
*
|
|
* @param message 用户消息(保持原样)
|
|
* @param chatId 会话ID
|
|
* @param systemPrompt 角色人设/风格,作为系统提示词叠加;为空则仅用基础提示词
|
|
* @return 流式回答
|
|
*/
|
|
public Flux<String> doChatByStream(String message, String chatId, String systemPrompt) {
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT")
|
|
.prompt()
|
|
.user(message)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
return spec.stream().content();
|
|
}
|
|
|
|
/**
|
|
* AI 基础对话(多轮记忆 + 角色系统提示词 + MCP 工具权限,SSE 流式传输)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param systemPrompt 角色人设/风格
|
|
* @param allowedMcpTools 允许的 MCP 工具列表;null=不注册工具
|
|
* @return 流式回答
|
|
*/
|
|
public Flux<String> doChatByStream(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
|
|
// 重置 MCP 工具调用状态(事件收集器 + 轮次计数器)
|
|
McpToolCallback.resetEvents();
|
|
McpToolCallback.resetCallRounds();
|
|
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.user(message)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
return appendMcpToolEvents(spec.stream().content());
|
|
}
|
|
|
|
/**
|
|
* 在 SSE 文本流末尾追加 MCP 工具调用事件
|
|
* 前端通过解析 "event: tool_call_start" / "event: tool_call_result" 行来展示工具调用过程。
|
|
* 事件格式遵循 SSE 标准,与 readSSEStreamWithEvents 的解析逻辑匹配。
|
|
*/
|
|
private Flux<String> appendMcpToolEvents(Flux<String> contentFlux) {
|
|
return contentFlux.concatMap(chunk -> Flux.just(chunk))
|
|
.concatWith(Flux.defer(() -> {
|
|
java.util.List<McpToolCallback.ToolCallEvent> events = McpToolCallback.drainEvents();
|
|
if (events.isEmpty()) {
|
|
return Flux.empty();
|
|
}
|
|
log.info("本次对话触发了 {} 个 MCP 工具调用", events.size());
|
|
List<String> sseLines = new ArrayList<>();
|
|
for (McpToolCallback.ToolCallEvent event : events) {
|
|
sseLines.add("event:tool_call_result");
|
|
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
|
|
escapeSse(event.tool()),
|
|
escapeSse(event.result()),
|
|
event.latencyMs());
|
|
sseLines.add("data:" + json);
|
|
sseLines.add(""); // SSE 事件分隔空行
|
|
}
|
|
return Flux.fromIterable(sseLines);
|
|
}));
|
|
}
|
|
|
|
/** 转义 SSE 数据中的特殊字符 */
|
|
private static String escapeSse(String s) {
|
|
if (s == null) return "";
|
|
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
.replace("\n", "\\n").replace("\r", "\\r");
|
|
}
|
|
|
|
// ==================== FAQ 优先匹配 ====================
|
|
|
|
/**
|
|
* 尝试 FAQ 三级匹配(精确→关键词→语义),命中则返回标准答案。
|
|
* 在 RAG 对话入口处优先调用,避免不必要的知识库检索开销。
|
|
*
|
|
* @param message 用户消息
|
|
* @return FAQ 标准答案(未命中时返回 empty)
|
|
*/
|
|
private Optional<String> tryFaqMatch(String message) {
|
|
try {
|
|
return faqMatchEngine.match(message)
|
|
.map(result -> result.getFaq().getAnswer());
|
|
} catch (Exception e) {
|
|
log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage());
|
|
return Optional.empty();
|
|
}
|
|
}
|
|
|
|
// AI 恋爱知识库问答功能
|
|
@Resource
|
|
RewriteQueryRewriter rewriteQueryRewriter;
|
|
@Resource
|
|
CompressionQueryRewriter compressionQueryRewriter;
|
|
@Resource
|
|
MultiQueryExpanderRewriter multiQueryExpanderRewriter;
|
|
@Resource
|
|
TranslationQueryRewriter translationQueryRewriter;
|
|
|
|
|
|
/**
|
|
* 和 RAG 知识库进行对话
|
|
*
|
|
* @param message
|
|
* @param chatId
|
|
* @return
|
|
*/
|
|
public String doChatWithRag(String message, String chatId) {
|
|
// 在预检索阶段,系统接收用户的原始查询,通过查询转换和查询扩展等方法对其进行优化,输出增强的用户查询。
|
|
// String rewrittenMessage = translationQueryRewriter.doQueryRewrite(message);
|
|
String rewrittenMessage = rewriteQueryRewriter.doQueryRewrite(message);
|
|
|
|
ChatResponse chatResponse = getChatClient("CHAT")
|
|
.prompt()
|
|
.user(rewrittenMessage)
|
|
.advisors(spec -> spec.param(CONVERSATION_ID, chatId))
|
|
// 应用 RAG 知识库问答
|
|
.advisors(buildRetrievalAdvisor(4, Collections.emptyList()))
|
|
.call()
|
|
.chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
|
|
/**
|
|
* 和 RAG 知识库进行对话(支持动态选择查询重写策略)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param strategy 查询重写策略:NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY
|
|
* @return AI 回答
|
|
*/
|
|
public String doChatWithRagStrategy(String message, String chatId, String strategy) {
|
|
return doChatWithRagStrategy(message, chatId, strategy, Collections.emptyList());
|
|
}
|
|
|
|
public String doChatWithRagStrategy(String message, String chatId, String strategy, Long categoryId) {
|
|
List<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
|
|
return doChatWithRagStrategy(message, chatId, strategy, categoryIds);
|
|
}
|
|
|
|
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds) {
|
|
return doChatWithRagStrategy(message, chatId, strategy, categoryIds, null);
|
|
}
|
|
|
|
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds, String systemPrompt) {
|
|
// FAQ 优先匹配:三级匹配(精确→关键词→语义)命中则直接返回标准答案
|
|
Optional<String> faqAnswer = tryFaqMatch(message);
|
|
if (faqAnswer.isPresent()) {
|
|
log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId);
|
|
return faqAnswer.get();
|
|
}
|
|
|
|
// 对于 MULTI_QUERY 策略,需要使用特殊的处理方式
|
|
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
|
|
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt);
|
|
}
|
|
|
|
// 其他策略:单查询处理
|
|
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
|
|
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT")
|
|
.prompt()
|
|
.user(rewrittenMessage)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
// 应用 RAG 知识库问答
|
|
.advisors(buildRetrievalAdvisor(4, categoryIds));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
ChatResponse chatResponse = spec.call().chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
|
|
/**
|
|
* RAG 知识库对话(支持查询重写策略 + MCP 工具权限)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param strategy 查询重写策略
|
|
* @param categoryIds 知识库分类过滤
|
|
* @param systemPrompt 角色人设/风格
|
|
* @param allowedMcpTools 允许的 MCP 工具列表;null=不注册工具
|
|
* @return AI 回答
|
|
*/
|
|
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds,
|
|
String systemPrompt, List<String> allowedMcpTools) {
|
|
// FAQ 优先匹配
|
|
Optional<String> faqAnswer = tryFaqMatch(message);
|
|
if (faqAnswer.isPresent()) {
|
|
log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId);
|
|
return faqAnswer.get();
|
|
}
|
|
|
|
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
|
|
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
|
|
}
|
|
|
|
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
|
|
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.user(rewrittenMessage)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
.advisors(buildRetrievalAdvisor(4, categoryIds));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
ChatResponse chatResponse = spec.call().chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
|
|
/**
|
|
* 根据策略对查询做预检索改写(MULTI_QUERY 不走此方法)。
|
|
*/
|
|
private String rewriteQuery(String message, String chatId, String strategy) {
|
|
if (strategy == null || strategy.isEmpty()) {
|
|
return message;
|
|
}
|
|
try {
|
|
switch (strategy.toUpperCase()) {
|
|
case "REWRITE":
|
|
return rewriteQueryRewriter.doQueryRewrite(message);
|
|
case "TRANSLATION":
|
|
return translationQueryRewriter.doQueryRewrite(message);
|
|
case "COMPRESSION":
|
|
// 查询压缩需要对话历史,从会话记忆中取最近若干条传入,
|
|
// 利用多轮上下文把指代不清的追问补全为独立查询
|
|
List<Message> history = chatMemory.get(chatId, 10);
|
|
return compressionQueryRewriter.doQueryRewrite(message, history);
|
|
case "NONE":
|
|
default:
|
|
return message;
|
|
}
|
|
} catch (Exception e) {
|
|
// 查询重写失败时降级为原始查询,避免 RAG_REWRITE 配置异常(如 InvalidApiKey)导致整次对话不可用
|
|
log.warn("查询重写失败 [strategy={}, chatId={}],降级使用原始查询: {}", strategy, chatId, e.getMessage());
|
|
return message;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 和 RAG 知识库进行对话(支持查询重写策略,SSE 流式传输)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param strategy 查询重写策略:NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY
|
|
* @param categoryIds 知识库分类过滤
|
|
* @param systemPrompt 角色人设/风格
|
|
* @return 流式回答
|
|
*/
|
|
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
|
|
List<Long> categoryIds, String systemPrompt) {
|
|
// FAQ 优先匹配:命中则直接以流式形式返回标准答案
|
|
Optional<String> faqAnswer = tryFaqMatch(message);
|
|
if (faqAnswer.isPresent()) {
|
|
log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId);
|
|
return Flux.just(faqAnswer.get());
|
|
}
|
|
|
|
// 对于 MULTI_QUERY 策略,需要先手动检索合并再流式生成
|
|
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
|
|
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt);
|
|
}
|
|
|
|
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
|
|
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT")
|
|
.prompt()
|
|
.user(rewrittenMessage)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
.advisors(buildRetrievalAdvisor(4, categoryIds));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
return spec.stream().content();
|
|
}
|
|
|
|
/**
|
|
* 和 RAG 知识库进行对话(支持查询重写策略 + MCP 工具权限,SSE 流式传输)
|
|
*
|
|
* @param message 用户消息
|
|
* @param chatId 会话ID
|
|
* @param strategy 查询重写策略
|
|
* @param categoryIds 知识库分类过滤
|
|
* @param systemPrompt 角色人设/风格
|
|
* @param allowedMcpTools 允许的 MCP 工具列表;null=不注册工具
|
|
* @return 流式回答
|
|
*/
|
|
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
|
|
List<Long> categoryIds, String systemPrompt,
|
|
List<String> allowedMcpTools) {
|
|
// 重置 MCP 工具调用状态
|
|
McpToolCallback.resetEvents();
|
|
McpToolCallback.resetCallRounds();
|
|
|
|
// FAQ 优先匹配:命中则直接以流式形式返回标准答案
|
|
Optional<String> faqAnswer = tryFaqMatch(message);
|
|
if (faqAnswer.isPresent()) {
|
|
log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId);
|
|
return Flux.just(faqAnswer.get());
|
|
}
|
|
|
|
// 对于 MULTI_QUERY 策略,需要先手动检索合并再流式生成
|
|
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
|
|
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
|
|
}
|
|
|
|
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
|
|
|
|
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.user(rewrittenMessage)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
.advisors(buildRetrievalAdvisor(4, categoryIds));
|
|
if (StringUtils.hasText(systemPrompt)) {
|
|
spec = spec.system(effectiveSystem(systemPrompt));
|
|
}
|
|
return appendMcpToolEvents(spec.stream().content());
|
|
}
|
|
|
|
/**
|
|
* 使用多路查询扩展的 RAG 对话
|
|
* 将原始查询扩展为多个语义不同的查询,分别检索后按文档ID去重合并,
|
|
* 再把合并后的资料注入 system 提示词(不污染用户消息与会话记忆)。
|
|
*
|
|
* @param message 用户消息(保持原样)
|
|
* @param chatId 会话ID
|
|
* @param categoryIds 知识库分类过滤
|
|
* @param systemPrompt 角色人设/风格
|
|
* @return AI 回答
|
|
*/
|
|
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
|
|
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, null);
|
|
}
|
|
|
|
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds,
|
|
String systemPrompt, List<String> allowedMcpTools) {
|
|
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
|
|
ChatResponse chatResponse = getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.system(ragSystem)
|
|
.user(message)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
.call()
|
|
.chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
|
|
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
|
|
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, null);
|
|
}
|
|
|
|
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds,
|
|
String systemPrompt, List<String> allowedMcpTools) {
|
|
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
|
|
return appendMcpToolEvents(getChatClient("CHAT", allowedMcpTools)
|
|
.prompt()
|
|
.system(ragSystem)
|
|
.user(message)
|
|
.advisors(s -> s.param(CONVERSATION_ID, chatId))
|
|
.stream()
|
|
.content());
|
|
}
|
|
|
|
/**
|
|
* 多路查询扩展 + 检索合并,组合出注入了知识库资料的系统提示词。
|
|
* 将原始查询扩展为多个语义不同的查询,分别检索后按文档ID去重合并,
|
|
* 资料注入 system(不污染用户消息与会话记忆)。供同步与流式两条路径复用。
|
|
*/
|
|
private String buildMultiQueryRagSystem(String message, List<Long> categoryIds, String systemPrompt) {
|
|
List<Document> mergedDocs = retrieveMultiQueryDocs(message, categoryIds);
|
|
log.info("多路检索合并后文档数: {}", mergedDocs.size());
|
|
|
|
// 拼接资料为上下文,组合系统提示词
|
|
String context = mergedDocs.stream()
|
|
.map(Document::getText)
|
|
.filter(StringUtils::hasText)
|
|
.collect(Collectors.joining("\n\n---\n\n"));
|
|
return buildRagSystemPrompt(systemPrompt, context);
|
|
}
|
|
|
|
/**
|
|
* 多路查询扩展 + 检索去重合并,返回命中的文档(供 RAG 回答与"引用来源"复用)。
|
|
*/
|
|
private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) {
|
|
// 多路扩展依赖 RAG_REWRITE 模型;扩展失败时退回原问题检索,避免整次 RAG 不可用。
|
|
List<String> expandedQueries;
|
|
try {
|
|
expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
|
|
} catch (Exception e) {
|
|
log.warn("多路查询扩展失败,降级为原始问题检索: {}", e.getMessage());
|
|
expandedQueries = List.of(message);
|
|
}
|
|
if (expandedQueries == null || expandedQueries.isEmpty()) {
|
|
expandedQueries = List.of(message);
|
|
}
|
|
log.info("多路查询扩展结果: {}", expandedQueries);
|
|
|
|
// 对每个查询分别检索,按文档 ID 去重合并,封顶 maxDocs,避免上下文膨胀。
|
|
final int maxDocs = 8;
|
|
Map<String, Document> merged = new LinkedHashMap<>();
|
|
for (String query : expandedQueries) {
|
|
if (!StringUtils.hasText(query) || merged.size() >= maxDocs) {
|
|
continue;
|
|
}
|
|
List<Document> docs = pgVectorVectorStore.similaritySearch(buildRagSearchRequest(4, categoryIds, query));
|
|
if (docs == null) {
|
|
continue;
|
|
}
|
|
for (Document doc : docs) {
|
|
if (merged.size() >= maxDocs) {
|
|
break;
|
|
}
|
|
merged.putIfAbsent(doc.getId(), doc);
|
|
}
|
|
}
|
|
return new ArrayList<>(merged.values());
|
|
}
|
|
|
|
/**
|
|
* 检索本次问题命中的知识库片段(不生成回答),用于在回答下方展示"引用来源"。
|
|
* 复用与 RAG 回答完全相同的查询改写策略与分类范围,确保来源即答案所依据的片段。
|
|
*
|
|
* @param strategy 查询重写策略(与回答保持一致)
|
|
* @return 命中的文档片段(含 metadata:documentId/title/sourceName/chunkIndex/distance)
|
|
*/
|
|
public List<Document> retrieveRagSources(String message, String chatId, String strategy, List<Long> categoryIds) {
|
|
if (!StringUtils.hasText(message)) {
|
|
return Collections.emptyList();
|
|
}
|
|
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
|
|
return retrieveMultiQueryDocs(message, categoryIds);
|
|
}
|
|
String rewritten = rewriteQuery(message, chatId, strategy);
|
|
List<Document> docs = pgVectorVectorStore.similaritySearch(buildRagSearchRequest(4, categoryIds, rewritten));
|
|
return docs != null ? docs : Collections.emptyList();
|
|
}
|
|
|
|
/**
|
|
* 组合 RAG 场景的系统提示词:基础提示词 + 角色人设 + 检索到的资料。
|
|
* 资料为空时不声称“依据资料”,避免诱导模型编造。
|
|
*/
|
|
private String buildRagSystemPrompt(String rolePrompt, String context) {
|
|
String base = effectiveSystem(rolePrompt);
|
|
if (!StringUtils.hasText(context)) {
|
|
return base;
|
|
}
|
|
return base + "\n\n【RAG回答硬性规则】\n"
|
|
+ "- 只能依据下方【知识库资料】回答用户问题,不要调用模型自身记忆补充资料之外的内容。\n"
|
|
+ "- 回答诗词、制度、流程等具体内容时,必须从【知识库资料】中逐字引用或转述,不得使用未检索到的诗句、篇名、作者或步骤。\n"
|
|
+ "- 如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。\n"
|
|
+ "- 引用来源应能支撑回答内容;不要让回答内容和下方引用来源不一致。\n\n"
|
|
+ "【知识库资料】\n" + context;
|
|
}
|
|
|
|
private SearchRequest buildRagSearchRequest(int topK, Long categoryId) {
|
|
List<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
|
|
return buildRagSearchRequest(topK, categoryIds);
|
|
}
|
|
|
|
private SearchRequest buildRagSearchRequest(int topK, List<Long> categoryIds) {
|
|
return ragSearchRequestBuilder(topK, categoryIds).build();
|
|
}
|
|
|
|
/**
|
|
* 带查询文本的检索请求,供手动向量检索(多路查询)使用。
|
|
* QuestionAnswerAdvisor 会自行设置查询文本,故那条路径不需要此重载。
|
|
*/
|
|
private SearchRequest buildRagSearchRequest(int topK, List<Long> categoryIds, String query) {
|
|
SearchRequest.Builder builder = ragSearchRequestBuilder(topK, categoryIds);
|
|
if (StringUtils.hasText(query)) {
|
|
builder.query(query);
|
|
}
|
|
return builder.build();
|
|
}
|
|
|
|
private SearchRequest.Builder ragSearchRequestBuilder(int topK, List<Long> categoryIds) {
|
|
SearchRequest.Builder builder = SearchRequest.builder()
|
|
.similarityThreshold(0.0)
|
|
.topK(topK);
|
|
Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds);
|
|
if (filterExpression != null) {
|
|
builder.filterExpression(filterExpression);
|
|
}
|
|
return builder;
|
|
}
|
|
|
|
private Advisor buildRetrievalAdvisor(int topK, List<Long> categoryIds) {
|
|
Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds);
|
|
return RetrievalAugmentationAdvisor.builder()
|
|
.documentRetriever(new VectorStoreDocumentRetriever(
|
|
pgVectorVectorStore,
|
|
0.0,
|
|
topK,
|
|
() -> filterExpression))
|
|
.queryAugmenter(ContextualQueryAugmenter.builder()
|
|
.allowEmptyContext(false)
|
|
.build())
|
|
.build();
|
|
}
|
|
|
|
private Filter.Expression buildCategoryFilterExpression(List<Long> categoryIds) {
|
|
List<Object> values = normalizeCategoryIds(categoryIds).stream()
|
|
.map(value -> (Object) value)
|
|
.toList();
|
|
if (values.isEmpty()) {
|
|
return null;
|
|
}
|
|
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
|
|
return filterBuilder.in("categoryId", values).build();
|
|
}
|
|
|
|
private List<String> normalizeCategoryIds(List<Long> categoryIds) {
|
|
if (categoryIds == null || categoryIds.isEmpty()) {
|
|
return Collections.emptyList();
|
|
}
|
|
return categoryIds.stream()
|
|
.filter(java.util.Objects::nonNull)
|
|
.filter(id -> id > 0)
|
|
.map(String::valueOf)
|
|
.distinct()
|
|
.toList();
|
|
}
|
|
|
|
/**
|
|
* 和 RAG 知识库进行对话(另外一种使用方式)
|
|
*
|
|
* @param message
|
|
* @param chatId
|
|
* @return
|
|
*/
|
|
public String doChatWithRagEnhance(String message, String chatId) {
|
|
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
|
|
.documentRetriever(VectorStoreDocumentRetriever.builder()
|
|
.vectorStore(pgVectorVectorStore)
|
|
.similarityThreshold(0.5)
|
|
.topK(4)
|
|
.build())
|
|
.queryAugmenter(ContextualQueryAugmenter.builder()
|
|
.allowEmptyContext(false) // 不允许模型在没有找到相关文档的情况下也生成回答
|
|
.build())
|
|
.build();
|
|
|
|
ChatResponse chatResponse = getChatClient("CHAT")
|
|
.prompt()
|
|
.user(message)
|
|
.advisors(spec -> spec.param(CONVERSATION_ID, chatId))
|
|
// 应用 RAG 知识库问答
|
|
.advisors(retrievalAugmentationAdvisor)
|
|
.call()
|
|
.chatResponse();
|
|
return chatResponse.getResult().getOutput().getText();
|
|
}
|
|
}
|