From 256362e34a1441a580f0076851ba6dcc9978062d Mon Sep 17 00:00:00 2001 From: wei-py Date: Mon, 14 Sep 2026 11:42:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(chat):=20=E5=89=8D=E7=BD=AE=20FAQ=20?= =?UTF-8?q?=E4=B8=89=E7=BA=A7=E5=8C=B9=E9=85=8D=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 14 +- frontend/src/views/PipelineFlow.vue | 25 +- .../com/wok/supportbot/app/ChatPipeline.java | 52 ++--- .../com/wok/supportbot/rag/RagPipeline.java | 2 +- .../com/wok/supportbot/ChatPipelineTests.java | 221 ++++++++++++++++++ 5 files changed, 260 insertions(+), 54 deletions(-) create mode 100644 src/test/java/com/wok/supportbot/ChatPipelineTests.java 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 { * 决策分支: *

* @@ -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资料:退货说明"); + } +}