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.
249 lines
12 KiB
249 lines
12 KiB
package com.wok.supportbot.app;
|
|
|
|
import com.wok.supportbot.rag.RagContext;
|
|
import com.wok.supportbot.rag.RagPipeline;
|
|
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
|
|
import com.wok.supportbot.service.IntentRouter;
|
|
import com.wok.supportbot.service.SystemConfigService;
|
|
import com.wok.supportbot.service.RagHitLogService;
|
|
import jakarta.annotation.Resource;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.ai.document.Document;
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.util.StringUtils;
|
|
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Optional;
|
|
|
|
/**
|
|
* 统一对话管道(编排层)。
|
|
* <p>
|
|
* 编排一次完整对话的决策流程:意图路由 → FAQ 优先 → RAG 检索 → 组装系统提示词与用户消息,
|
|
* 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。
|
|
* <p>
|
|
* 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在
|
|
* {@code AssistantApp}),因此 {@code call} / {@code stream} 由 {@code AssistantApp} 承担,
|
|
* 避免 {@code ChatPipeline} ↔ {@code AssistantApp} 循环依赖。
|
|
* <p>
|
|
* 接入 {@link IntentRouter} 替代原 {@code AiController.shouldBypassKnowledgeRetrieval} 的硬编码寒暄词判断:
|
|
* 寒暄词列表保留为快速路径与兜底,IntentRouter 负责细粒度意图分类,二者命中其一即跳过 KB 检索。
|
|
* <p>
|
|
* {@code @pipeline} orchestration-layer order=0<br>
|
|
* {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装<br>
|
|
* {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG<br>
|
|
* {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合<br>
|
|
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图
|
|
*/
|
|
@Component
|
|
@Slf4j
|
|
public class ChatPipeline {
|
|
|
|
/** IntentRouter 判为 CHITCHAT 的置信度阈值,低于此值视为不确定,继续走 RAG */
|
|
private static final double CHITCHAT_CONFIDENCE_THRESHOLD = 0.6;
|
|
|
|
/** FAQ 意图高置信度阈值:IntentRouter 返回 FAQ 且高于此值时,仅走 FAQ 匹配,不降级 RAG */
|
|
private static final double FAQ_HIGH_CONFIDENCE_THRESHOLD = 0.8;
|
|
|
|
@Resource
|
|
private IntentRouter intentRouter;
|
|
|
|
@Resource
|
|
private RagPipeline ragPipeline;
|
|
|
|
@Resource
|
|
private SystemConfigService systemConfigService;
|
|
|
|
@Resource
|
|
private RagHitLogService ragHitLogService;
|
|
|
|
/**
|
|
* 编排一次对话请求,产出执行决策。
|
|
* <p>
|
|
* 决策分支:
|
|
* <ul>
|
|
* <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li>
|
|
* <li>寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索</li>
|
|
* <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li>
|
|
* <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li>
|
|
* </ul>
|
|
*
|
|
* @param ctx 对话上下文
|
|
* @return 执行决策
|
|
*/
|
|
public ChatRequest buildRequest(ChatContext ctx) {
|
|
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
|
|
String baseSystem = effectiveSystem(ctx.systemPrompt(), globalPrompt);
|
|
|
|
// 普通对话:enableRag=false(含 Controller 层 isKbDenied 强制置 false 的情况)
|
|
if (!ctx.enableRag()) {
|
|
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
|
|
globalPrompt, null, null, "CHAT", null, null, null);
|
|
}
|
|
|
|
// 意图路由:先用 IntentRouter 做细粒度分类
|
|
IntentRouter.IntentResult intent = routeIntent(ctx.message());
|
|
|
|
// FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示。
|
|
// 若此处已「干净跑完」完整 FAQ 三级匹配仍未命中,进入 RAG 检索时跳过重复的 FAQ 匹配,避免同一请求两次 FAQ 语义 embedding。
|
|
boolean faqSkippableInRetrieve = false;
|
|
if (intent != null && "FAQ".equals(intent.getIntent())
|
|
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
|
|
RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
|
|
if (faqOutcome.result().isPresent()) {
|
|
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
|
|
Optional<String> faqAnswer = Optional.ofNullable(faqOutcome.result().get().getFaq().getAnswer());
|
|
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
|
|
globalPrompt, null, null, "FAQ", null, null, faqOutcome.result().get());
|
|
}
|
|
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
|
|
// 仅当第一次 FAQ 匹配「干净完成」才允许后续检索跳过第二次 FAQ(异常降级的 miss 不跳过,避免误跳)
|
|
faqSkippableInRetrieve = faqOutcome.completedCleanly();
|
|
}
|
|
|
|
// 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索
|
|
if (intent != null && "CHITCHAT".equals(intent.getIntent())
|
|
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
|
|
// 闲聊前先尝试 FAQ 精准匹配,避免"你是谁"等被配置成 FAQ 后命中不了
|
|
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
|
|
if (faqMatch.isPresent()) {
|
|
log.info("闲聊意图但 FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
|
|
return new ChatRequest(ctx, ctx.message(), baseSystem,
|
|
Optional.ofNullable(faqMatch.get().getFaq().getAnswer()),
|
|
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
|
|
}
|
|
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
|
|
globalPrompt, null, null, "CHITCHAT", null, null, null);
|
|
}
|
|
|
|
// RAG 检索(含 FAQ 优先匹配;FAQ 高置信已完整匹配过则跳过二次 FAQ)
|
|
RagContext rag = ragPipeline.retrieve(ctx, faqSkippableInRetrieve);
|
|
|
|
// 记录 RAG 检索日志到 rag_hit_log 表(供知识库分析看板使用)
|
|
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {
|
|
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
|
|
for (Document doc : rag.documents()) {
|
|
String docIdStr = String.valueOf(doc.getMetadata().getOrDefault("documentId", ""));
|
|
Long documentId = null;
|
|
try { if (!docIdStr.isEmpty()) documentId = Long.parseLong(docIdStr); } catch (NumberFormatException ignored) { }
|
|
String title = String.valueOf(doc.getMetadata().getOrDefault("title", ""));
|
|
String score = String.valueOf(doc.getMetadata().getOrDefault("score", ""));
|
|
ragHitLogService.recordHit(ctx.chatId(), ctx.message(), documentId, title, score, searchMode);
|
|
}
|
|
} else if (!rag.faqHit()) {
|
|
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
|
|
ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode);
|
|
}
|
|
if (rag.faqHit()) {
|
|
return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(),
|
|
globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult());
|
|
}
|
|
|
|
// RAG 生成:资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)
|
|
String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText());
|
|
return new ChatRequest(ctx, ctx.message(), finalSystem, Optional.empty(),
|
|
globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG",
|
|
rag.searchMode(), rag.documents(), null);
|
|
}
|
|
|
|
/**
|
|
* 意图路由:先用寒暄词列表做快速路径,未命中再调 IntentRouter 做 LLM 分类。
|
|
* 异常时返回 null(调用方默认走 RAG 检索)。
|
|
*/
|
|
private IntentRouter.IntentResult routeIntent(String message) {
|
|
// 寒暄词快速路径(零 LLM 开销)
|
|
if (isChitchat(message)) {
|
|
return new IntentRouter.IntentResult("CHITCHAT", 1.0);
|
|
}
|
|
try {
|
|
return intentRouter.route(message);
|
|
} catch (Exception e) {
|
|
log.debug("意图路由异常,沿用 RAG 检索: {}", e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 判断是否跳过 KB 检索(保留旧方法签名供 retrieveSources 等使用)。
|
|
*/
|
|
private boolean shouldBypassRag(String message) {
|
|
if (isChitchat(message)) {
|
|
return true;
|
|
}
|
|
try {
|
|
IntentRouter.IntentResult intent = intentRouter.route(message);
|
|
return "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD;
|
|
} catch (Exception e) {
|
|
log.debug("意图路由异常,沿用 RAG: {}", e.getMessage());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 寒暄词快速判断:问候/感谢/告别等短消息无知识库检索意图。
|
|
* 与原 {@code AiController.shouldBypassKnowledgeRetrieval} 逻辑一致,作为 IntentRouter 的快速路径与兜底。
|
|
* <p>
|
|
* 供 {@code buildRequest} 与"引用来源"等不需 LLM 意图分类的场景共用。
|
|
*/
|
|
public boolean isChitchat(String message) {
|
|
if (!StringUtils.hasText(message)) {
|
|
return true;
|
|
}
|
|
String normalized = message.trim()
|
|
.toLowerCase(Locale.ROOT)
|
|
.replaceAll("[\\s,。!?!?,.;;::、~~…]+", "");
|
|
if (normalized.isEmpty()) {
|
|
return true;
|
|
}
|
|
if (normalized.length() > 12) {
|
|
return false;
|
|
}
|
|
return List.of(
|
|
"你好", "您好", "hello", "hi", "哈喽", "嗨", "在吗",
|
|
"早上好", "上午好", "中午好", "下午好", "晚上好",
|
|
"谢谢", "感谢", "多谢", "好的", "好", "嗯", "嗯嗯",
|
|
"再见", "拜拜", "辛苦了"
|
|
).contains(normalized);
|
|
}
|
|
|
|
/**
|
|
* 组合系统提示词:全局系统提示词(从 system_config 动态读取) + 角色人设。
|
|
* 修改 system_config 中对应配置后即时生效,无需重启。
|
|
* <p>
|
|
* 注意:推荐问题(suggest-message-list)已不再由主回复生成,
|
|
* 改由 {@link SuggestionGenerator} 在 AI 回复结束后按需异步生成。
|
|
*/
|
|
private String effectiveSystem(String rolePrompt, String globalPrompt) {
|
|
StringBuilder sb = new StringBuilder();
|
|
|
|
// 全局系统提示词(由 buildRequest 读取后传入,避免重复查询)
|
|
if (StringUtils.hasText(globalPrompt)) {
|
|
sb.append(globalPrompt);
|
|
}
|
|
|
|
// 角色人设
|
|
if (StringUtils.hasText(rolePrompt)) {
|
|
if (sb.length() > 0) {
|
|
sb.append("\n\n");
|
|
}
|
|
sb.append("【当前角色设定】\n").append(rolePrompt);
|
|
}
|
|
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* 统一检索引用来源(不含 FAQ 匹配与意图路由)。
|
|
* 寒暄词无 KB 检索意图时直接返回空列表,避免向量库召回"最相似但实际无关"的片段。
|
|
*
|
|
* @param ctx 对话上下文(使用 message / rewriteStrategy / categoryIds)
|
|
* @return 命中的知识库片段,寒暄词或无命中时返回空列表
|
|
*/
|
|
public List<Document> retrieveSources(ChatContext ctx) {
|
|
if (!StringUtils.hasText(ctx.message()) || isChitchat(ctx.message())) {
|
|
return Collections.emptyList();
|
|
}
|
|
return ragPipeline.retrieveDocuments(ctx);
|
|
}
|
|
}
|