""" Semantic matcher for intent recognition. [AC-AISVC-113, AC-AISVC-114] Vector-based semantic matching. """ import asyncio import logging import time from typing import TYPE_CHECKING import numpy as np from app.services.intent.models import ( FusionConfig, SemanticCandidate, SemanticMatchResult, ) if TYPE_CHECKING: from app.models.entities import IntentRule from app.services.embedding.base import EmbeddingProvider logger = logging.getLogger(__name__) class SemanticMatcher: """ [AC-AISVC-113] Semantic matcher using vector similarity. Supports two matching modes: - Mode A: Use pre-computed intent_vector for direct similarity calculation - Mode B: Use semantic_examples for dynamic vector computation """ def __init__( self, embedding_provider: "EmbeddingProvider", config: FusionConfig, ): """ Initialize semantic matcher. Args: embedding_provider: Provider for generating embeddings config: Fusion configuration """ self._embedding_provider = embedding_provider self._config = config async def match( self, message: str, rules: list["IntentRule"], tenant_id: str, top_k: int | None = None, ) -> SemanticMatchResult: """ [AC-AISVC-113] Perform vector semantic matching. Args: message: User message rules: List of intent rules tenant_id: Tenant ID for isolation top_k: Number of top candidates to return Returns: SemanticMatchResult with candidates and scores """ start_time = time.time() effective_top_k = top_k or self._config.semantic_top_k if not self._config.semantic_matcher_enabled: return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=0, skipped=True, skip_reason="disabled", ) rules_with_semantic = [r for r in rules if self._has_semantic_config(r)] if not rules_with_semantic: duration_ms = int((time.time() - start_time) * 1000) logger.debug( f"[AC-AISVC-113] No rules with semantic config for tenant={tenant_id}" ) return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=duration_ms, skipped=True, skip_reason="no_semantic_config", ) try: message_vector = await asyncio.wait_for( self._embedding_provider.embed(message), timeout=self._config.semantic_matcher_timeout_ms / 1000, ) except asyncio.TimeoutError: duration_ms = int((time.time() - start_time) * 1000) logger.warning( f"[AC-AISVC-113] Embedding timeout for tenant={tenant_id}, " f"timeout={self._config.semantic_matcher_timeout_ms}ms" ) return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=duration_ms, skipped=True, skip_reason="embedding_timeout", ) except Exception as e: duration_ms = int((time.time() - start_time) * 1000) logger.error( f"[AC-AISVC-113] Embedding error for tenant={tenant_id}: {e}" ) return SemanticMatchResult( candidates=[], top_score=0.0, duration_ms=duration_ms, skipped=True, skip_reason=f"embedding_error: {str(e)}", ) candidates = [] for rule in rules_with_semantic: try: score = await self._calculate_similarity(message_vector, rule) if score > 0: candidates.append(SemanticCandidate(rule=rule, score=score)) except Exception as e: logger.warning( f"[AC-AISVC-114] Similarity calculation failed for rule={rule.id}: {e}" ) continue candidates.sort(key=lambda x: x.score, reverse=True) candidates = candidates[:effective_top_k] duration_ms = int((time.time() - start_time) * 1000) logger.info( f"[AC-AISVC-113] Semantic match completed for tenant={tenant_id}, " f"candidates={len(candidates)}, top_score={candidates[0].score if candidates else 0:.3f}, " f"duration={duration_ms}ms" ) return SemanticMatchResult( candidates=candidates, top_score=candidates[0].score if candidates else 0.0, duration_ms=duration_ms, skipped=False, skip_reason=None, ) def _has_semantic_config(self, rule: "IntentRule") -> bool: """ Check if rule has semantic configuration. Args: rule: Intent rule to check Returns: True if rule has intent_vector or semantic_examples """ return bool(rule.intent_vector) or bool(rule.semantic_examples) async def _calculate_similarity( self, message_vector: list[float], rule: "IntentRule", ) -> float: """ [AC-AISVC-114] Calculate similarity between message and rule. Mode A: Use pre-computed intent_vector Mode B: Use semantic_examples for dynamic computation Args: message_vector: Message embedding vector rule: Intent rule with semantic config Returns: Similarity score (0.0 ~ 1.0) """ if rule.intent_vector: return self._cosine_similarity(message_vector, rule.intent_vector) elif rule.semantic_examples: try: example_vectors = await self._embedding_provider.embed_batch( rule.semantic_examples ) similarities = [ self._cosine_similarity(message_vector, v) for v in example_vectors ] return max(similarities) if similarities else 0.0 except Exception as e: logger.warning( f"[AC-AISVC-114] Failed to compute example vectors for rule={rule.id}: {e}" ) return 0.0 return 0.0 def _cosine_similarity( self, v1: list[float], v2: list[float], ) -> float: """ Calculate cosine similarity between two vectors. Args: v1: First vector v2: Second vector Returns: Cosine similarity (0.0 ~ 1.0) """ if not v1 or not v2: return 0.0 v1_arr = np.array(v1) v2_arr = np.array(v2) norm1 = np.linalg.norm(v1_arr) norm2 = np.linalg.norm(v2_arr) if norm1 == 0 or norm2 == 0: return 0.0 similarity = float(np.dot(v1_arr, v2_arr) / (norm1 * norm2)) return max(0.0, min(1.0, similarity))