diff --git a/CLAUDE.md b/CLAUDE.md
index dd85b7b..e4de5a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,7 +27,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。
-**测试说明**: 所有测试均为集成测试(`@SpringBootTest`),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:`SupportBotApplicationTests`(对话/RAG)、`PgVectorVectorStoreConfigTest`(向量存储)、`QueryTransformerTests`(查询重写策略)。无单元测试。
+**测试说明**: `@SpringBootTest` 集成测试需要运行中的 PostgreSQL 和有效的 DashScope API Key;`ChatPipelineTests` 为隔离的 Mockito 编排测试,无需 DB 或 API Key。Surefire 默认跳过测试,执行时显式加 `-DskipTests=false`,例如 `./mvnw test -DskipTests=false -Dtest=ChatPipelineTests`。
**访问地址**: 前端管理页面 `http://localhost:9090/index.html`,API 文档 `http://localhost:9090/doc.html`(Knife4j)
@@ -52,19 +52,23 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
用户请求
→ 鉴权/角色解析(Controller)
→ ChatPipeline.buildRequest(ChatContext)
- → IntentRouter 意图路由(CHITCHAT/FAQ/RAG)
- → RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索)
+ → enableRag=false:普通对话(不调用 FAQ / 意图路由)
+ → enableRag=true:完整 FAQ 三级匹配(角色分类隔离,命中直接返回,跳过意图分类)
+ → 未命中:寒暄词快速路径 / IntentRouter 意图路由(CHITCHAT/FAQ/RAG)
+ → 高置信 CHITCHAT:纯对话
+ → 其余:RagPipeline.retrieve(FAQ 异常时重试 → 查询重写 → 统一检索)
→ 组装 finalMessage + finalSystemPrompt + 资料块
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
```
-- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象
+- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象。启用 RAG 时先做完整 FAQ 匹配,标准答案命中不调用意图 LLM;仅 `completedCleanly=true` 的未命中允许 `retrieve(ctx, true)` 跳过重复 FAQ,异常未命中仍保留 RAG 中的 FAQ 重试
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter
- **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除
-- **IntentRouter**: 已在 ChatPipeline 接入,`AiController.shouldBypassKnowledgeRetrieval` 已移除
+- **IntentRouter**: 已在 ChatPipeline 接入,`AiController.shouldBypassKnowledgeRetrieval` 已移除。`doubao-seed-2-0-*` 的意图分类请求单独设置 `reasoning_effort=minimal`(关闭深度思考)和 `max_tokens=128`,避免小型分类任务先等待长思维链;只覆盖当前分类请求,不修改缓存模型或主回答配置,其他模型保持原参数。请求序列化及降级由 `IntentRouterTests` 验证
- **分类过滤**: 统一由 `CategoryFilter` 工具类处理(`parse`/`normalize`/`buildExpression`)
- **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
+- **正文完成与引用加载解耦**: 管理聊天页及 SDK 测试页在正文完成后立即解除发送状态,引用来源后台补齐到原消息;请求参数固定为发送时的会话/角色/检索配置,清空或切换后的旧引用不会写入新对话
### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。
diff --git a/frontend/src/views/PipelineFlow.vue b/frontend/src/views/PipelineFlow.vue
index 15eace8..2469e6c 100644
--- a/frontend/src/views/PipelineFlow.vue
+++ b/frontend/src/views/PipelineFlow.vue
@@ -76,7 +76,7 @@ mermaid.initialize({
// Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
-// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
+// %%graph-meta: { updated: "2026-09-14", basedOn: "ChatPipeline v4, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
const GRAPH_DEFINITION = `
flowchart TD
A["用户请求
message + roleId + accountId + chatId"]
@@ -89,28 +89,27 @@ flowchart TD
D -- "❌ false" --> E["模式: 纯对话
systemPrompt(角色人设 + 全局配置)
不检索知识库"]
- D -- "✅ true" --> F["IntentRouter
🔹 寒暄词快速路径: 本地列表精确匹配(零 LLM)
🔹 未命中则 LLM 意图分类
FAQ / RAG / CHITCHAT"]
+ D -- "true" --> H["FAQ 优先匹配
FaqMatchEngine 完整三级匹配
精确 → 关键词 → 向量语义
沿用角色分类隔离"]
- F --> G{"意图分类结果"}
-
- G -- "FAQ
confidence ≧ 0.8" --> H["FaqMatchEngine
三级匹配策略
精确 → 关键词 → 向量语义"]
+ H -- "命中标准答案,跳过意图分类" --> T
+ H -- "未命中 / 异常" --> F["IntentRouter
寒暄词快速路径: 本地精确匹配(零 LLM)
未命中则 LLM 意图分类
FAQ / RAG / CHITCHAT"]
- G -- "CHITCHAT
confidence ≧ 0.6" --> CHK["闲聊前 FAQ 精准匹配
先试 FaqMatchEngine
命中则短路返回"]
+ F --> G{"意图分类结果"}
- G -- "RAG / 降级
其余情况" --> J["RagPipeline.retrieve
RAG 检索流水线入口"]
+ G -- "CHITCHAT
confidence ≧ 0.6" --> I["模式: 纯对话
跳过知识库检索
不注入资料块"]
- H -- "✅ 命中标准答案" --> T
- H -. "❌ 未命中 → 降级 RAG" .-> J
+ G -- "FAQ / RAG / 降级
其余情况" --> J["RagPipeline.retrieve
RAG 检索流水线入口"]
subgraph RAG["📚 RAG 检索流水线(当前: 纯向量检索)"]
- J --> K["1. FAQ 优先匹配(二次兜底)
FaqMatchEngine 三级匹配
命中则短路返回"]
- K --> L["2. 查询重写
REWRITE / TRANSLATION
COMPRESSION / MULTI_QUERY"]
+ J --> K{"前置 FAQ 匹配
completedCleanly ?"}
+ K -- "true: 跳过重复 FAQ" --> L["2. 查询重写
REWRITE / TRANSLATION
COMPRESSION / MULTI_QUERY"]
+ K -- "false: 异常后重试" --> KR["1. FAQ 匹配重试
FaqMatchEngine 完整三级匹配"]
+ KR -- "命中标准答案" --> T
+ KR -- "未命中 / 异常" --> L
L --> M["3. 向量检索
PGVector similaritySearch
topK=4 + 分类过滤"]
M --> S["4. 构建资料块
拼接检索文档
注入 system prompt 末尾"]
end
- CHK -. "❌ 未命中 → 纯对话" .-> I["模式: 纯对话
跳过知识库检索
不注入资料块"]
- CHK -- "✅ 命中标准答案" --> T
I --> T
S --> T
E --> T
diff --git a/src/main/java/com/wok/supportbot/app/ChatPipeline.java b/src/main/java/com/wok/supportbot/app/ChatPipeline.java
index 2e02a05..0f5b44c 100644
--- a/src/main/java/com/wok/supportbot/app/ChatPipeline.java
+++ b/src/main/java/com/wok/supportbot/app/ChatPipeline.java
@@ -20,7 +20,7 @@ import java.util.Optional;
/**
* 统一对话管道(编排层)。
*
- * 编排一次完整对话的决策流程:意图路由 → FAQ 优先 → RAG 检索 → 组装系统提示词与用户消息,
+ * 编排一次完整对话的决策流程:FAQ 优先 → 意图路由 → RAG 检索 → 组装系统提示词与用户消息,
* 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。
*
* 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在
@@ -31,7 +31,7 @@ import java.util.Optional;
* 寒暄词列表保留为快速路径与兜底,IntentRouter 负责细粒度意图分类,二者命中其一即跳过 KB 检索。
*
* {@code @pipeline} orchestration-layer order=0
- * {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装
+ * {@code @pipeline-step} buildRequest: FAQ优先 → 意图路由 → RAG检索 → 提示词组装
* {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG
* {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图
@@ -43,9 +43,6 @@ 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;
@@ -64,8 +61,8 @@ public class ChatPipeline {
* 决策分支:
*
* - 未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system
- * - 寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索
- * - FAQ 命中 → 直接返回标准答案,不调用 ChatClient
+ * - FAQ 命中 → 直接返回标准答案,不调用 IntentRouter 或 ChatClient
+ * - FAQ 未命中的寒暄/闲聊(IntentRouter 或寒暄词命中)→ 跳过 KB 检索
* - RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)
*
*
@@ -82,43 +79,28 @@ public class ChatPipeline {
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 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();
+ // 完整 FAQ 三级匹配前置:标准答案命中时省去 LLM 意图分类,并保留角色分类隔离。
+ RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ if (faqOutcome.result().isPresent()) {
+ FaqMatchResult faqMatch = faqOutcome.result().get();
+ log.info("FAQ 命中标准答案,跳过意图分类: chatId={}, matchType={}", ctx.chatId(), faqMatch.getMatchType());
+ return new ChatRequest(ctx, ctx.message(), baseSystem,
+ Optional.ofNullable(faqMatch.getFaq().getAnswer()),
+ globalPrompt, null, null, "FAQ", null, null, faqMatch);
}
+ // FAQ 未命中后再路由:寒暄词快速路径 → IntentRouter LLM 分类。
+ IntentRouter.IntentResult intent = routeIntent(ctx.message());
+
// 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
- // 闲聊前先尝试 FAQ 精准匹配,避免"你是谁"等被配置成 FAQ 后命中不了
- Optional 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);
+ // 仅干净完成的 FAQ 匹配可跳过;异常降级的 miss 仍由 RAG 重试,避免误跳。
+ RagContext rag = ragPipeline.retrieve(ctx, faqOutcome.completedCleanly());
// 记录 RAG 检索日志到 rag_hit_log 表(供知识库分析看板使用)
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {
diff --git a/src/main/java/com/wok/supportbot/rag/RagPipeline.java b/src/main/java/com/wok/supportbot/rag/RagPipeline.java
index bd663fa..bbb61ab 100644
--- a/src/main/java/com/wok/supportbot/rag/RagPipeline.java
+++ b/src/main/java/com/wok/supportbot/rag/RagPipeline.java
@@ -128,7 +128,7 @@ public class RagPipeline {
* 流程:FAQ 优先(未匹配过时)→ 查询重写/扩展 → 统一检索 → 拼接资料文本。
*
* @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds})
- * @param faqAlreadyMatched 编排层是否已在前序阶段(FAQ 高置信未命中降级)干净跑过完整 FAQ 三级匹配;
+ * @param faqAlreadyMatched 编排层是否已在前序阶段(意图路由之前)干净跑过完整 FAQ 三级匹配;
* true 时跳过 retrieve 内重复的 FAQ 匹配,避免同一请求重复做 FAQ 语义 embedding
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
*/
diff --git a/src/test/java/com/wok/supportbot/ChatPipelineTests.java b/src/test/java/com/wok/supportbot/ChatPipelineTests.java
new file mode 100644
index 0000000..9c4e1c2
--- /dev/null
+++ b/src/test/java/com/wok/supportbot/ChatPipelineTests.java
@@ -0,0 +1,221 @@
+package com.wok.supportbot;
+
+import com.wok.supportbot.app.ChatContext;
+import com.wok.supportbot.app.ChatPipeline;
+import com.wok.supportbot.app.ChatRequest;
+import com.wok.supportbot.entity.KnowledgeFaq;
+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.RagHitLogService;
+import com.wok.supportbot.service.SystemConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.ai.document.Document;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ChatPipelineTests {
+
+ @Mock
+ private IntentRouter intentRouter;
+ @Mock
+ private RagPipeline ragPipeline;
+ @Mock
+ private SystemConfigService systemConfigService;
+ @Mock
+ private RagHitLogService ragHitLogService;
+ @InjectMocks
+ private ChatPipeline pipeline;
+
+ @BeforeEach
+ void configureGlobalPrompt() {
+ when(systemConfigService.getValueByKey("ai_system_prompt")).thenReturn("全局提示词");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"EXACT", "KEYWORD", "SEMANTIC"})
+ void faqHitSkipsIntentRoutingAndRetrieval(String matchType) {
+ ChatContext ctx = context("退货流程是什么", true);
+ FaqMatchResult match = faqMatch("标准答案", matchType);
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.of(match), true));
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("FAQ", request.intent());
+ assertEquals(Optional.of("标准答案"), request.faqAnswer());
+ assertSame(match, request.faqMatchResult());
+ assertSame(ctx, request.ctx());
+ assertEquals(ctx.message(), request.finalMessage());
+ assertEquals("全局提示词\n\n【当前角色设定】\n售后客服", request.finalSystemPrompt());
+ verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ verifyNoMoreInteractions(ragPipeline);
+ verifyNoInteractions(intentRouter, ragHitLogService);
+ }
+
+ @Test
+ void ordinaryChatDoesNotMatchFaqOrRouteIntent() {
+ ChatContext ctx = context("退货流程是什么", false);
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("CHAT", request.intent());
+ assertFalse(request.faqHit());
+ assertSame(ctx, request.ctx());
+ assertEquals(ctx.message(), request.finalMessage());
+ assertNull(request.ragContextText());
+ verifyNoInteractions(ragPipeline, intentRouter, ragHitLogService);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"RAG, 0.9", "FAQ, 0.95", "FAQ, 0.5", "CHITCHAT, 0.59"})
+ void cleanFaqMissRoutesThenRetrievesWithoutRepeatingFaq(String intent, double confidence) {
+ ChatContext ctx = context("退货流程是什么", true);
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.empty(), true));
+ when(intentRouter.route(ctx.message())).thenReturn(new IntentRouter.IntentResult(intent, confidence));
+ stubRagRetrieval(ctx, true);
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("RAG", request.intent());
+ assertSame(ctx, request.ctx());
+ assertEquals(ctx.message(), request.finalMessage());
+ assertEquals("退货说明", request.ragContextText());
+ assertTrue(request.finalSystemPrompt().endsWith("\n资料:退货说明"));
+ assertEquals(1, request.hitCount());
+ var order = inOrder(ragPipeline, intentRouter);
+ order.verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ order.verify(intentRouter).route(ctx.message());
+ order.verify(ragPipeline).retrieve(ctx, true);
+ order.verify(ragPipeline).buildRagContextBlock("退货说明");
+ verifyNoMoreInteractions(ragPipeline, intentRouter);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void faqMissKeepsHighConfidenceChitchatPath(boolean completedCleanly) {
+ ChatContext ctx = context("和我聊聊天吧", true);
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.empty(), completedCleanly));
+ when(intentRouter.route(ctx.message())).thenReturn(new IntentRouter.IntentResult("CHITCHAT", 0.6));
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("CHITCHAT", request.intent());
+ assertFalse(request.faqHit());
+ assertNull(request.ragContextText());
+ var order = inOrder(ragPipeline, intentRouter);
+ order.verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ order.verify(intentRouter).route(ctx.message());
+ verifyNoMoreInteractions(ragPipeline, intentRouter);
+ verifyNoInteractions(ragHitLogService);
+ }
+
+ @Test
+ void localGreetingStillMatchesFaqOnceWithoutCallingIntentLlm() {
+ ChatContext ctx = context("你好", true);
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.empty(), true));
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("CHITCHAT", request.intent());
+ verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ verifyNoMoreInteractions(ragPipeline);
+ verifyNoInteractions(intentRouter, ragHitLogService);
+ }
+
+ @Test
+ void exceptionalFaqMissAllowsRagRetryToReturnFaq() {
+ ChatContext ctx = context("退货流程是什么", true);
+ FaqMatchResult retryMatch = faqMatch("重试命中的标准答案", "SEMANTIC");
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.empty(), false));
+ when(intentRouter.route(ctx.message())).thenReturn(new IntentRouter.IntentResult("FAQ", 0.95));
+ when(ragPipeline.retrieve(ctx, false)).thenReturn(new RagContext(
+ Optional.of("重试命中的标准答案"), List.of(), "", ctx.message(), "VECTOR", retryMatch));
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("FAQ", request.intent());
+ assertEquals(Optional.of("重试命中的标准答案"), request.faqAnswer());
+ assertSame(retryMatch, request.faqMatchResult());
+ verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ verify(ragPipeline).retrieve(ctx, false);
+ verifyNoMoreInteractions(ragPipeline);
+ verifyNoInteractions(ragHitLogService);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void intentFailureFallsBackToRagWithFaqCompletionFlag(boolean completedCleanly) {
+ ChatContext ctx = context("退货流程是什么", true);
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.empty(), completedCleanly));
+ when(intentRouter.route(ctx.message())).thenThrow(new IllegalStateException("意图服务不可用"));
+ stubRagRetrieval(ctx, completedCleanly);
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("RAG", request.intent());
+ verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ verify(ragPipeline).retrieve(ctx, completedCleanly);
+ verify(ragPipeline).buildRagContextBlock("退货说明");
+ verifyNoMoreInteractions(ragPipeline);
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void faqWithoutAnswerPreservesExistingOptionalSemantics(String answer) {
+ ChatContext ctx = context("退货流程是什么", true);
+ FaqMatchResult match = faqMatch(answer, "EXACT");
+ when(ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds()))
+ .thenReturn(new RagPipeline.FaqMatchOutcome(Optional.of(match), true));
+
+ ChatRequest request = pipeline.buildRequest(ctx);
+
+ assertEquals("FAQ", request.intent());
+ assertEquals(Optional.ofNullable(answer), request.faqAnswer());
+ assertEquals(answer != null, request.faqHit());
+ assertSame(match, request.faqMatchResult());
+ verify(ragPipeline).tryFaqMatchClean(ctx.message(), ctx.categoryIds());
+ verifyNoMoreInteractions(ragPipeline);
+ verifyNoInteractions(intentRouter, ragHitLogService);
+ }
+
+ private ChatContext context(String message, boolean enableRag) {
+ return ChatContext.of(message, "faq-fast-path")
+ .withSystemPrompt("售后客服")
+ .withCategoryIds(List.of(101L, 202L))
+ .withRewriteStrategy("MULTI_QUERY")
+ .withEnableRag(enableRag);
+ }
+
+ private FaqMatchResult faqMatch(String answer, String matchType) {
+ KnowledgeFaq faq = new KnowledgeFaq();
+ faq.setAnswer(answer);
+ return new FaqMatchResult(faq, matchType, 0.95);
+ }
+
+ private void stubRagRetrieval(ChatContext ctx, boolean faqAlreadyMatched) {
+ when(ragPipeline.retrieve(ctx, faqAlreadyMatched)).thenReturn(new RagContext(
+ Optional.empty(), List.of(new Document("退货说明")), "退货说明", "改写后的检索问题", "VECTOR", null));
+ when(ragPipeline.buildRagContextBlock("退货说明")).thenReturn("\n资料:退货说明");
+ }
+}