5 changed files with 260 additions and 54 deletions
-
14CLAUDE.md
-
25frontend/src/views/PipelineFlow.vue
-
48src/main/java/com/wok/supportbot/app/ChatPipeline.java
-
2src/main/java/com/wok/supportbot/rag/RagPipeline.java
-
221src/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资料:退货说明"); |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue