Browse Source

refactor(chat): 前置 FAQ 三级匹配并同步流程文档

feature/test
wei-py 3 weeks ago
parent
commit
256362e34a
  1. 14
      CLAUDE.md
  2. 25
      frontend/src/views/PipelineFlow.vue
  3. 52
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  4. 2
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  5. 221
      src/test/java/com/wok/supportbot/ChatPipelineTests.java

14
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` 表。

25
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["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
@ -89,28 +89,27 @@ flowchart TD
D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设 + 全局配置)<br/>不检索知识库"]
D -- "✅ true" --> F["<b>IntentRouter</b><br/>🔹 寒暄词快速路径: 本地列表精确匹配(零 LLM)<br/>🔹 未命中则 LLM 意图分类<br/>FAQ / RAG / CHITCHAT"]
D -- "true" --> H["<b>FAQ 优先匹配</b><br/>FaqMatchEngine 完整三级匹配<br/>精确 → 关键词 → 向量语义<br/>沿用角色分类隔离"]
F --> G{"意图分类结果"}
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
H -- "命中标准答案,跳过意图分类" --> T
H -- "未命中 / 异常" --> F["<b>IntentRouter</b><br/>寒暄词快速路径: 本地精确匹配(零 LLM)<br/>未命中则 LLM 意图分类<br/>FAQ / RAG / CHITCHAT"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
F --> G{"意图分类结果"}
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
H -- "✅ 命中标准答案" --> T
H -. "❌ 未命中 → 降级 RAG" .-> J
G -- "FAQ / RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
subgraph RAG["📚 RAG 检索流水线(当前: 纯向量检索)"]
J --> K["<b>1. FAQ 优先匹配(二次兜底)</b><br/>FaqMatchEngine 三级匹配<br/>命中则短路返回"]
K --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
J --> K{"前置 FAQ 匹配<br/>completedCleanly ?"}
K -- "true: 跳过重复 FAQ" --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
K -- "false: 异常后重试" --> KR["<b>1. FAQ 匹配重试</b><br/>FaqMatchEngine 完整三级匹配"]
KR -- "命中标准答案" --> T
KR -- "未命中 / 异常" --> L
L --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T
S --> T
E --> T

52
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -20,7 +20,7 @@ import java.util.Optional;
/**
* 统一对话管道(编排层)。
* <p>
* 编排一次完整对话的决策流程:意图路由 → FAQ 优先 → RAG 检索 → 组装系统提示词与用户消息,
* 编排一次完整对话的决策流程:FAQ 优先 → 意图路由 → RAG 检索 → 组装系统提示词与用户消息,
* 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。
* <p>
* 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在
@ -31,7 +31,7 @@ import java.util.Optional;
* 寒暄词列表保留为快速路径与兜底,IntentRouter 负责细粒度意图分类,二者命中其一即跳过 KB 检索。
* <p>
* {@code @pipeline} orchestration-layer order=0<br>
* {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装<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管道图
@ -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 {
* 决策分支:
* <ul>
* <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li>
* <li>寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索</li>
* <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li>
* <li>FAQ 命中 → 直接返回标准答案,不调用 IntentRouter 或 ChatClient</li>
* <li>FAQ 未命中的寒暄/闲聊(IntentRouter 或寒暄词命中)→ 跳过 KB 检索</li>
* <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li>
* </ul>
*
@ -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<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();
// 完整 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<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);
// 仅干净完成的 FAQ 匹配可跳过;异常降级的 miss 仍由 RAG 重试,避免误跳。
RagContext rag = ragPipeline.retrieve(ctx, faqOutcome.completedCleanly());
// 记录 RAG 检索日志到 rag_hit_log 表(供知识库分析看板使用)
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {

2
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
*/

221
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资料:退货说明");
}
}
Loading…
Cancel
Save