5 changed files with 260 additions and 54 deletions
-
14CLAUDE.md
-
25frontend/src/views/PipelineFlow.vue
-
52src/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