ai-robot-core/ai-service/app/services/intent/semantic_matcher.py

234 lines
7.1 KiB
Python

"""
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))