""" Integration tests for IntentRouter.match_hybrid(). [AC-AISVC-111] Tests for hybrid routing integration. """ import pytest from unittest.mock import AsyncMock, MagicMock, patch import uuid import asyncio from app.services.intent.models import ( FusionConfig, FusionResult, LlmJudgeInput, LlmJudgeResult, RuleMatchResult, SemanticCandidate, SemanticMatchResult, RouteTrace, ) @pytest.fixture def mock_embedding_provider(): """Create a mock embedding provider.""" provider = AsyncMock() provider.embed = AsyncMock(return_value=[0.1] * 768) provider.embed_batch = AsyncMock(return_value=[[0.1] * 768]) return provider @pytest.fixture def mock_llm_client(): """Create a mock LLM client.""" client = AsyncMock() return client @pytest.fixture def config(): """Create a fusion config.""" return FusionConfig() @pytest.fixture def mock_rule(): """Create a mock intent rule.""" rule = MagicMock() rule.id = uuid.uuid4() rule.name = "Return Intent" rule.response_type = "rag" rule.keywords = ["退货", "退款"] rule.patterns = [] rule.intent_vector = [0.1] * 768 rule.semantic_examples = None rule.is_enabled = True rule.priority = 10 return rule @pytest.fixture def mock_rules(mock_rule): """Create a list of mock intent rules.""" other_rule = MagicMock() other_rule.id = uuid.uuid4() other_rule.name = "Order Query" other_rule.response_type = "rag" other_rule.keywords = ["订单", "查询"] other_rule.patterns = [] other_rule.intent_vector = [0.5] * 768 other_rule.semantic_examples = None other_rule.is_enabled = True other_rule.priority = 5 return [mock_rule, other_rule] class MockRuleMatcher: """Mock RuleMatcher for testing.""" def match(self, message: str, rules: list) -> RuleMatchResult: import time start_time = time.time() message_lower = message.lower() for rule in rules: if not rule.is_enabled: continue for keyword in (rule.keywords or []): if keyword.lower() in message_lower: return RuleMatchResult( rule_id=rule.id, rule=rule, match_type="keyword", matched_text=keyword, score=1.0, duration_ms=int((time.time() - start_time) * 1000), ) return RuleMatchResult( rule_id=None, rule=None, match_type=None, matched_text=None, score=0.0, duration_ms=int((time.time() - start_time) * 1000), ) class MockSemanticMatcher: """Mock SemanticMatcher for testing.""" def __init__(self, config): self._config = config async def match(self, message: str, rules: list, tenant_id: str, top_k: int = 3) -> SemanticMatchResult: import time start_time = time.time() if not self._config.semantic_matcher_enabled: return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=0, skipped=True, skip_reason="disabled", ) candidates = [] for rule in rules: if rule.intent_vector: candidates.append(SemanticCandidate(rule=rule, score=0.85)) break return SemanticMatchResult( candidates=candidates[:top_k], top_score=candidates[0].score if candidates else 0.0, duration_ms=int((time.time() - start_time) * 1000), skipped=False, skip_reason=None, ) class MockLlmJudge: """Mock LlmJudge for testing.""" def __init__(self, config): self._config = config def should_trigger(self, rule_result, semantic_result, config=None) -> tuple: effective_config = config or self._config if not effective_config.llm_judge_enabled: return False, "disabled" if rule_result.score > 0 and semantic_result.top_score > 0: if semantic_result.candidates: if rule_result.rule_id != semantic_result.candidates[0].rule.id: if abs(rule_result.score - semantic_result.top_score) < effective_config.conflict_threshold: return True, "rule_semantic_conflict" max_score = max(rule_result.score, semantic_result.top_score) if effective_config.min_trigger_threshold < max_score < effective_config.gray_zone_threshold: return True, "gray_zone" return False, "" async def judge(self, input_data: LlmJudgeInput, tenant_id: str) -> LlmJudgeResult: return LlmJudgeResult( intent_id=input_data.candidates[0]["id"] if input_data.candidates else None, intent_name=input_data.candidates[0]["name"] if input_data.candidates else None, score=0.9, reasoning="Test arbitration", duration_ms=500, tokens_used=100, triggered=True, ) class MockFusionPolicy: """Mock FusionPolicy for testing.""" DECISION_PRIORITY = [ ("rule_high_confidence", lambda r, s, l: r.score == 1.0 and r.rule is not None), ("llm_judge", lambda r, s, l: l.triggered and l.intent_id is not None), ("semantic_override", lambda r, s, l: r.score == 0 and s.top_score > 0.7), ("no_match", lambda r, s, l: True), ] def __init__(self, config): self._config = config def fuse(self, rule_result, semantic_result, llm_result) -> FusionResult: decision_reason = "no_match" for reason, condition in self.DECISION_PRIORITY: if condition(rule_result, semantic_result, llm_result or LlmJudgeResult.empty()): decision_reason = reason break final_intent = None final_confidence = 0.0 if decision_reason == "rule_high_confidence": final_intent = rule_result.rule final_confidence = 1.0 elif decision_reason == "llm_judge" and llm_result: final_intent = self._find_rule_by_id(llm_result.intent_id, rule_result, semantic_result) final_confidence = llm_result.score elif decision_reason == "semantic_override": final_intent = semantic_result.candidates[0].rule final_confidence = semantic_result.top_score return FusionResult( final_intent=final_intent, final_confidence=final_confidence, decision_reason=decision_reason, need_clarify=final_confidence < 0.4, clarify_candidates=None, trace=RouteTrace(), ) def _find_rule_by_id(self, intent_id, rule_result, semantic_result): if not intent_id: return None if rule_result.rule_id and str(rule_result.rule_id) == intent_id: return rule_result.rule for c in semantic_result.candidates: if str(c.rule.id) == intent_id: return c.rule return None class MockIntentRouter: """Mock IntentRouter for testing match_hybrid.""" def __init__(self, rule_matcher, semantic_matcher, llm_judge, fusion_policy, config=None): self._rule_matcher = rule_matcher self._semantic_matcher = semantic_matcher self._llm_judge = llm_judge self._fusion_policy = fusion_policy self._config = config or FusionConfig() async def match_hybrid( self, message: str, rules: list, tenant_id: str, config: FusionConfig | None = None, ) -> FusionResult: effective_config = config or self._config rule_result, semantic_result = await asyncio.gather( asyncio.to_thread(self._rule_matcher.match, message, rules), self._semantic_matcher.match(message, rules, tenant_id), ) llm_result = None should_trigger, trigger_reason = self._llm_judge.should_trigger( rule_result, semantic_result, effective_config ) if should_trigger: candidates = self._build_llm_candidates(rule_result, semantic_result) llm_result = await self._llm_judge.judge( LlmJudgeInput( message=message, candidates=candidates, conflict_type=trigger_reason, ), tenant_id, ) fusion_result = self._fusion_policy.fuse( rule_result, semantic_result, llm_result ) return fusion_result def _build_llm_candidates(self, rule_result, semantic_result) -> list: candidates = [] if rule_result.rule: candidates.append({ "id": str(rule_result.rule_id), "name": rule_result.rule.name, "description": f"匹配方式: {rule_result.match_type}", }) for candidate in semantic_result.candidates[:3]: if not any(c["id"] == str(candidate.rule.id) for c in candidates): candidates.append({ "id": str(candidate.rule.id), "name": candidate.rule.name, "description": f"语义相似度: {candidate.score:.2f}", }) return candidates class TestIntentRouterHybrid: """Tests for IntentRouter.match_hybrid() integration.""" @pytest.mark.asyncio async def test_match_hybrid_rule_match(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test hybrid routing with rule match.""" rule_matcher = MockRuleMatcher() semantic_matcher = MockSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") assert result.decision_reason == "rule_high_confidence" assert result.final_intent == mock_rules[0] assert result.final_confidence == 1.0 @pytest.mark.asyncio async def test_match_hybrid_semantic_match(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test hybrid routing with semantic match only.""" rule_matcher = MockRuleMatcher() semantic_matcher = MockSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("商品有问题", mock_rules, "tenant-1") assert result.decision_reason == "semantic_override" assert result.final_intent is not None assert result.final_confidence > 0.7 @pytest.mark.asyncio async def test_match_hybrid_parallel_execution(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test that rule and semantic matching run in parallel.""" import time class SlowSemanticMatcher(MockSemanticMatcher): async def match(self, message, rules, tenant_id, top_k=3): await asyncio.sleep(0.1) return await super().match(message, rules, tenant_id, top_k) rule_matcher = MockRuleMatcher() semantic_matcher = SlowSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) start_time = time.time() result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") elapsed = time.time() - start_time assert elapsed < 0.2 assert result is not None @pytest.mark.asyncio async def test_match_hybrid_llm_judge_triggered(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test hybrid routing with LLM judge triggered.""" config = FusionConfig(conflict_threshold=0.3) class ConflictSemanticMatcher(MockSemanticMatcher): async def match(self, message, rules, tenant_id, top_k=3): result = await super().match(message, rules, tenant_id, top_k) if result.candidates: result.candidates[0] = SemanticCandidate(rule=rules[1], score=0.9) result.top_score = 0.9 return result rule_matcher = MockRuleMatcher() semantic_matcher = ConflictSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") assert result.decision_reason in ["rule_high_confidence", "llm_judge"] @pytest.mark.asyncio async def test_match_hybrid_no_match(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test hybrid routing with no match.""" class NoMatchSemanticMatcher(MockSemanticMatcher): async def match(self, message, rules, tenant_id, top_k=3): return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=10, skipped=True, skip_reason="no_semantic_config", ) rule_matcher = MockRuleMatcher() semantic_matcher = NoMatchSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("随便说说", mock_rules, "tenant-1") assert result.decision_reason == "no_match" assert result.final_intent is None assert result.final_confidence == 0.0 @pytest.mark.asyncio async def test_match_hybrid_semantic_disabled(self, mock_embedding_provider, mock_llm_client, mock_rules): """Test hybrid routing with semantic matcher disabled.""" config = FusionConfig(semantic_matcher_enabled=False) rule_matcher = MockRuleMatcher() semantic_matcher = MockSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") assert result.decision_reason == "rule_high_confidence" assert result.final_intent == mock_rules[0] @pytest.mark.asyncio async def test_match_hybrid_llm_disabled(self, mock_embedding_provider, mock_llm_client, mock_rules): """Test hybrid routing with LLM judge disabled.""" config = FusionConfig(llm_judge_enabled=False) rule_matcher = MockRuleMatcher() semantic_matcher = MockSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") assert result.decision_reason == "rule_high_confidence" @pytest.mark.asyncio async def test_match_hybrid_trace_generated(self, mock_embedding_provider, mock_llm_client, config, mock_rules): """Test that route trace is generated.""" rule_matcher = MockRuleMatcher() semantic_matcher = MockSemanticMatcher(config) llm_judge = MockLlmJudge(config) fusion_policy = MockFusionPolicy(config) router = MockIntentRouter( rule_matcher, semantic_matcher, llm_judge, fusion_policy, config ) result = await router.match_hybrid("我想退货", mock_rules, "tenant-1") assert result.trace is not None