""" Unit tests for SemanticMatcher. [AC-AISVC-113, AC-AISVC-114] Tests for semantic matching. """ import pytest from unittest.mock import AsyncMock, MagicMock, patch import uuid from app.services.intent.semantic_matcher import SemanticMatcher from app.services.intent.models import ( FusionConfig, SemanticCandidate, SemanticMatchResult, ) @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, [0.2] * 768]) return provider @pytest.fixture def mock_rule(): """Create a mock intent rule with semantic config.""" rule = MagicMock() rule.id = uuid.uuid4() rule.name = "Test Intent" rule.intent_vector = [0.1] * 768 rule.semantic_examples = None rule.is_enabled = True return rule @pytest.fixture def mock_rule_with_examples(): """Create a mock intent rule with semantic examples.""" rule = MagicMock() rule.id = uuid.uuid4() rule.name = "Test Intent with Examples" rule.intent_vector = None rule.semantic_examples = ["我想退货", "如何退款"] rule.is_enabled = True return rule @pytest.fixture def config(): """Create a fusion config.""" return FusionConfig() class TestSemanticMatcher: """Tests for SemanticMatcher class.""" @pytest.mark.asyncio async def test_init(self, mock_embedding_provider, config): """Test SemanticMatcher initialization.""" matcher = SemanticMatcher(mock_embedding_provider, config) assert matcher._embedding_provider == mock_embedding_provider assert matcher._config == config @pytest.mark.asyncio async def test_match_disabled(self, mock_embedding_provider): """Test match when semantic matcher is disabled.""" config = FusionConfig(semantic_matcher_enabled=False) matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("test message", [], "tenant-1") assert result.skipped is True assert result.skip_reason == "disabled" assert result.candidates == [] @pytest.mark.asyncio async def test_match_no_semantic_config( self, mock_embedding_provider, config, mock_rule ): """Test match when no rules have semantic config.""" mock_rule.intent_vector = None mock_rule.semantic_examples = None matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("test message", [mock_rule], "tenant-1") assert result.skipped is True assert result.skip_reason == "no_semantic_config" @pytest.mark.asyncio async def test_match_mode_a_with_intent_vector( self, mock_embedding_provider, config, mock_rule ): """Test match with pre-computed intent vector (Mode A).""" mock_embedding_provider.embed.return_value = [0.1] * 768 matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("我想退货", [mock_rule], "tenant-1") assert result.skipped is False assert result.skip_reason is None assert len(result.candidates) == 1 assert result.top_score > 0.9 assert result.duration_ms >= 0 @pytest.mark.asyncio async def test_match_mode_b_with_examples( self, mock_embedding_provider, config, mock_rule_with_examples ): """Test match with semantic examples (Mode B).""" mock_embedding_provider.embed.return_value = [0.1] * 768 mock_embedding_provider.embed_batch.return_value = [[0.1] * 768, [0.1] * 768] matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("我想退货", [mock_rule_with_examples], "tenant-1") assert result.skipped is False assert len(result.candidates) == 1 assert result.top_score > 0.9 @pytest.mark.asyncio async def test_match_embedding_timeout(self, mock_embedding_provider, config, mock_rule): """Test match when embedding times out.""" import asyncio mock_embedding_provider.embed.side_effect = asyncio.TimeoutError() config = FusionConfig(semantic_matcher_timeout_ms=100) matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("test message", [mock_rule], "tenant-1") assert result.skipped is True assert "embedding_timeout" in result.skip_reason @pytest.mark.asyncio async def test_match_embedding_error(self, mock_embedding_provider, config, mock_rule): """Test match when embedding fails with error.""" mock_embedding_provider.embed.side_effect = Exception("Embedding failed") matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("test message", [mock_rule], "tenant-1") assert result.skipped is True assert "embedding_error" in result.skip_reason @pytest.mark.asyncio async def test_match_top_k_limit(self, mock_embedding_provider, config): """Test that match returns only top_k candidates.""" rules = [] for i in range(5): rule = MagicMock() rule.id = uuid.uuid4() rule.name = f"Intent {i}" rule.intent_vector = [0.1 + i * 0.01] * 768 rule.semantic_examples = None rule.is_enabled = True rules.append(rule) mock_embedding_provider.embed.return_value = [0.1] * 768 config = FusionConfig(semantic_top_k=3) matcher = SemanticMatcher(mock_embedding_provider, config) result = await matcher.match("test message", rules, "tenant-1") assert len(result.candidates) <= 3 def test_cosine_similarity(self, mock_embedding_provider, config): """Test cosine similarity calculation.""" matcher = SemanticMatcher(mock_embedding_provider, config) v1 = [1.0, 0.0, 0.0] v2 = [1.0, 0.0, 0.0] similarity = matcher._cosine_similarity(v1, v2) assert similarity == 1.0 v1 = [1.0, 0.0, 0.0] v2 = [0.0, 1.0, 0.0] similarity = matcher._cosine_similarity(v1, v2) assert similarity == 0.0 v1 = [1.0, 1.0, 0.0] v2 = [1.0, 0.0, 0.0] similarity = matcher._cosine_similarity(v1, v2) assert 0.0 < similarity < 1.0 def test_cosine_similarity_empty_vectors(self, mock_embedding_provider, config): """Test cosine similarity with empty vectors.""" matcher = SemanticMatcher(mock_embedding_provider, config) assert matcher._cosine_similarity([], [1.0]) == 0.0 assert matcher._cosine_similarity([1.0], []) == 0.0 assert matcher._cosine_similarity([], []) == 0.0 def test_has_semantic_config(self, mock_embedding_provider, config, mock_rule): """Test checking if rule has semantic config.""" matcher = SemanticMatcher(mock_embedding_provider, config) mock_rule.intent_vector = [0.1] * 768 mock_rule.semantic_examples = None assert matcher._has_semantic_config(mock_rule) is True mock_rule.intent_vector = None mock_rule.semantic_examples = ["example"] assert matcher._has_semantic_config(mock_rule) is True mock_rule.intent_vector = None mock_rule.semantic_examples = None assert matcher._has_semantic_config(mock_rule) is False