234 lines
7.1 KiB
Python
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))
|