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

574 lines
18 KiB
Python
Raw Normal View History

"""
Intent router for AI Service.
[AC-AISVC-69, AC-AISVC-70] Intent matching engine with keyword and regex support.
[v0.8.0] Upgraded to hybrid routing with RuleMatcher + SemanticMatcher + LlmJudge + FusionPolicy.
"""
import logging
import re
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from app.models.entities import IntentRule
from app.services.intent.models import (
FusionConfig,
FusionResult,
LlmJudgeInput,
LlmJudgeResult,
RouteTrace,
RuleMatchResult,
SemanticMatchResult,
)
if TYPE_CHECKING:
from app.services.intent.fusion_policy import FusionPolicy
from app.services.intent.llm_judge import LlmJudge
from app.services.intent.semantic_matcher import SemanticMatcher
logger = logging.getLogger(__name__)
@dataclass
class IntentMatchResult:
"""
[AC-AISVC-69] Result of intent matching.
Contains the matched rule and match details.
"""
rule: IntentRule
match_type: str
matched: str
def to_dict(self) -> dict[str, Any]:
return {
"rule_id": str(self.rule.id),
"rule_name": self.rule.name,
"match_type": self.match_type,
"matched": self.matched,
"response_type": self.rule.response_type,
"target_kb_ids": self.rule.target_kb_ids or [],
"flow_id": str(self.rule.flow_id) if self.rule.flow_id else None,
"fixed_reply": self.rule.fixed_reply,
"transfer_message": self.rule.transfer_message,
}
class RuleMatcher:
"""
[v0.8.0] Rule matcher for keyword and regex matching.
Extracted from IntentRouter for hybrid routing.
"""
def match(self, message: str, rules: list[IntentRule]) -> RuleMatchResult:
"""
[AC-AISVC-112] Match user message against intent rules.
Returns RuleMatchResult with score (1.0 for match, 0.0 for no match).
Args:
message: User input message
rules: List of enabled rules ordered by priority DESC
Returns:
RuleMatchResult with match details
"""
start_time = time.time()
if not message or not rules:
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=duration_ms,
)
message_lower = message.lower()
for rule in rules:
if not rule.is_enabled:
continue
keyword_result = self._match_keywords(message, message_lower, rule)
if keyword_result:
duration_ms = int((time.time() - start_time) * 1000)
logger.info(
f"[AC-AISVC-69] Intent matched by keyword: "
f"rule={rule.name}, matched='{keyword_result.matched}'"
)
return RuleMatchResult(
rule_id=rule.id,
rule=rule,
match_type="keyword",
matched_text=keyword_result.matched,
score=1.0,
duration_ms=duration_ms,
)
regex_result = self._match_patterns(message, rule)
if regex_result:
duration_ms = int((time.time() - start_time) * 1000)
logger.info(
f"[AC-AISVC-69] Intent matched by regex: "
f"rule={rule.name}, matched='{regex_result.matched}'"
)
return RuleMatchResult(
rule_id=rule.id,
rule=rule,
match_type="regex",
matched_text=regex_result.matched,
score=1.0,
duration_ms=duration_ms,
)
duration_ms = int((time.time() - start_time) * 1000)
logger.debug("[AC-AISVC-70] No intent matched, will fallback to default RAG")
return RuleMatchResult(
rule_id=None,
rule=None,
match_type=None,
matched_text=None,
score=0.0,
duration_ms=duration_ms,
)
def _match_keywords(
self,
message: str,
message_lower: str,
rule: IntentRule,
) -> IntentMatchResult | None:
"""
Match message against rule keywords.
Any keyword match returns a result.
"""
keywords = rule.keywords or []
if not keywords:
return None
for keyword in keywords:
if not keyword:
continue
if keyword.lower() in message_lower:
return IntentMatchResult(
rule=rule,
match_type="keyword",
matched=keyword,
)
return None
def _match_patterns(
self,
message: str,
rule: IntentRule,
) -> IntentMatchResult | None:
"""
Match message against rule regex patterns.
Any pattern match returns a result.
"""
patterns = rule.patterns or []
if not patterns:
return None
for pattern in patterns:
if not pattern:
continue
try:
if re.search(pattern, message, re.IGNORECASE):
return IntentMatchResult(
rule=rule,
match_type="regex",
matched=pattern,
)
except re.error as e:
logger.warning(
f"Invalid regex pattern in rule {rule.id}: {pattern}, error: {e}"
)
continue
return None
class IntentRouter:
"""
[AC-AISVC-69] Intent matching engine.
[v0.8.0] Upgraded to support hybrid routing.
Matching algorithm:
1. Load rules ordered by priority DESC
2. For each rule, try keyword matching first
3. If no keyword match, try regex pattern matching
4. Return first match (highest priority)
5. If no match, return None (fallback to default RAG)
Hybrid routing (match_hybrid):
1. Parallel execute RuleMatcher + SemanticMatcher
2. Conditionally trigger LlmJudge
3. Execute FusionPolicy for final decision
"""
def __init__(
self,
rule_matcher: RuleMatcher | None = None,
semantic_matcher: "SemanticMatcher | None" = None,
llm_judge: "LlmJudge | None" = None,
fusion_policy: "FusionPolicy | None" = None,
config: FusionConfig | None = None,
):
"""
[v0.8.0] Initialize with optional dependencies for DI.
Args:
rule_matcher: Rule matcher for keyword/regex matching
semantic_matcher: Semantic matcher for vector similarity
llm_judge: LLM judge for arbitration
fusion_policy: Fusion policy for decision making
config: Fusion configuration
"""
self._rule_matcher = rule_matcher or RuleMatcher()
self._semantic_matcher = semantic_matcher
self._llm_judge = llm_judge
self._fusion_policy = fusion_policy
self._config = config or FusionConfig()
def match(
self,
message: str,
rules: list[IntentRule],
) -> IntentMatchResult | None:
"""
[AC-AISVC-69] Match user message against intent rules.
Preserved for backward compatibility.
Args:
message: User input message
rules: List of enabled rules ordered by priority DESC
Returns:
IntentMatchResult if matched, None otherwise
"""
result = self._rule_matcher.match(message, rules)
if result.rule:
return IntentMatchResult(
rule=result.rule,
match_type=result.match_type or "keyword",
matched=result.matched_text or "",
)
return None
def match_with_stats(
self,
message: str,
rules: list[IntentRule],
) -> tuple[IntentMatchResult | None, str | None]:
"""
[AC-AISVC-69] Match and return rule_id for statistics update.
Returns:
Tuple of (match_result, rule_id_for_stats)
"""
result = self.match(message, rules)
if result:
return result, str(result.rule.id)
return None, None
async def match_hybrid(
self,
message: str,
rules: list[IntentRule],
tenant_id: str,
config: FusionConfig | None = None,
) -> FusionResult:
"""
[AC-AISVC-111] Hybrid routing entry point.
Flow:
1. Parallel execute RuleMatcher + SemanticMatcher
2. Check if LlmJudge should trigger
3. Execute FusionPolicy for final decision
Args:
message: User input message
rules: List of enabled rules ordered by priority DESC
tenant_id: Tenant ID for isolation
config: Optional fusion config override
Returns:
FusionResult with final intent, confidence, and trace
"""
effective_config = config or self._config
start_time = time.time()
rule_result = self._rule_matcher.match(message, rules)
semantic_result = await self._execute_semantic_matcher(
message, rules, tenant_id, effective_config
)
llm_result = await self._conditionally_execute_llm_judge(
message, rule_result, semantic_result, tenant_id, effective_config
)
if self._fusion_policy:
fusion_result = self._fusion_policy.fuse(
rule_result, semantic_result, llm_result
)
else:
fusion_result = self._default_fusion(
rule_result, semantic_result, llm_result, effective_config
)
total_duration_ms = int((time.time() - start_time) * 1000)
fusion_result.trace.fusion["total_duration_ms"] = total_duration_ms
logger.info(
f"[AC-AISVC-111] Hybrid routing completed: "
f"decision={fusion_result.decision_reason}, "
f"confidence={fusion_result.final_confidence:.3f}, "
f"duration={total_duration_ms}ms"
)
return fusion_result
async def _execute_semantic_matcher(
self,
message: str,
rules: list[IntentRule],
tenant_id: str,
config: FusionConfig,
) -> SemanticMatchResult:
"""Execute semantic matcher if available and enabled."""
if not self._semantic_matcher:
return SemanticMatchResult(
candidates=[],
top_score=0.0,
duration_ms=0,
skipped=True,
skip_reason="not_configured",
)
if not config.semantic_matcher_enabled:
return SemanticMatchResult(
candidates=[],
top_score=0.0,
duration_ms=0,
skipped=True,
skip_reason="disabled",
)
try:
return await self._semantic_matcher.match(
message=message,
rules=rules,
tenant_id=tenant_id,
top_k=config.semantic_top_k,
)
except Exception as e:
logger.warning(f"[AC-AISVC-113] Semantic matcher failed: {e}")
return SemanticMatchResult(
candidates=[],
top_score=0.0,
duration_ms=0,
skipped=True,
skip_reason=f"error: {str(e)}",
)
async def _conditionally_execute_llm_judge(
self,
message: str,
rule_result: RuleMatchResult,
semantic_result: SemanticMatchResult,
tenant_id: str,
config: FusionConfig,
) -> LlmJudgeResult | None:
"""Conditionally execute LLM judge based on trigger conditions."""
if not self._llm_judge:
return None
if not config.llm_judge_enabled:
return None
should_trigger, trigger_reason = self._check_llm_trigger(
rule_result, semantic_result, config
)
if not should_trigger:
return None
logger.info(f"[AC-AISVC-118] LLM judge triggered: reason={trigger_reason}")
candidates = self._build_llm_candidates(rule_result, semantic_result)
if not candidates:
return None
try:
return await self._llm_judge.judge(
LlmJudgeInput(
message=message,
candidates=candidates,
conflict_type=trigger_reason,
),
tenant_id,
)
except Exception as e:
logger.warning(f"[AC-AISVC-119] LLM judge failed: {e}")
return LlmJudgeResult(
intent_id=None,
intent_name=None,
score=0.0,
reasoning=f"LLM error: {str(e)}",
duration_ms=0,
tokens_used=0,
triggered=True,
)
def _check_llm_trigger(
self,
rule_result: RuleMatchResult,
semantic_result: SemanticMatchResult,
config: FusionConfig,
) -> tuple[bool, str]:
"""
[AC-AISVC-118] Check if LLM judge should trigger.
Trigger conditions:
1. Conflict: RuleMatcher and SemanticMatcher match different intents
2. Gray zone: Max confidence in gray zone range
3. Multi-intent: Multiple candidates with close scores
Returns:
(should_trigger, trigger_reason)
"""
rule_score = rule_result.score
semantic_score = semantic_result.top_score
if rule_score > 0 and semantic_score > 0 and not semantic_result.skipped:
if semantic_result.candidates:
top_semantic_rule_id = semantic_result.candidates[0].rule.id
if rule_result.rule_id != top_semantic_rule_id:
if abs(rule_score - semantic_score) < config.conflict_threshold:
return True, "rule_semantic_conflict"
max_score = max(rule_score, semantic_score)
if config.min_trigger_threshold < max_score < config.gray_zone_threshold:
return True, "gray_zone"
if len(semantic_result.candidates) >= 2:
top1_score = semantic_result.candidates[0].score
top2_score = semantic_result.candidates[1].score
if abs(top1_score - top2_score) < config.multi_intent_threshold:
return True, "multi_intent"
return False, ""
def _build_llm_candidates(
self,
rule_result: RuleMatchResult,
semantic_result: SemanticMatchResult,
) -> list[dict[str, Any]]:
"""Build candidate list for LLM judge."""
candidates = []
if rule_result.rule:
candidates.append({
"id": str(rule_result.rule_id),
"name": rule_result.rule.name,
"description": f"匹配方式: {rule_result.match_type}, 匹配内容: {rule_result.matched_text}",
})
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
def _default_fusion(
self,
rule_result: RuleMatchResult,
semantic_result: SemanticMatchResult,
llm_result: LlmJudgeResult | None,
config: FusionConfig,
) -> FusionResult:
"""Default fusion logic when FusionPolicy is not available."""
trace = RouteTrace(
rule_match=rule_result.to_dict(),
semantic_match=semantic_result.to_dict(),
llm_judge=llm_result.to_dict() if llm_result else {},
fusion={},
)
final_intent = None
final_confidence = 0.0
decision_reason = "no_match"
if rule_result.score == 1.0 and rule_result.rule:
final_intent = rule_result.rule
final_confidence = 1.0
decision_reason = "rule_high_confidence"
elif llm_result and llm_result.triggered and llm_result.intent_id:
final_intent = self._find_rule_by_id(
llm_result.intent_id, rule_result, semantic_result
)
final_confidence = llm_result.score
decision_reason = "llm_judge"
elif rule_result.score == 0 and semantic_result.top_score > config.semantic_threshold:
if semantic_result.candidates:
final_intent = semantic_result.candidates[0].rule
final_confidence = semantic_result.top_score
decision_reason = "semantic_override"
elif semantic_result.top_score > 0.5:
if semantic_result.candidates:
final_intent = semantic_result.candidates[0].rule
final_confidence = semantic_result.top_score
decision_reason = "semantic_fallback"
need_clarify = final_confidence < config.clarify_threshold
clarify_candidates = None
if need_clarify and len(semantic_result.candidates) > 1:
clarify_candidates = [c.rule for c in semantic_result.candidates[:3]]
trace.fusion = {
"weights": {
"w_rule": config.w_rule,
"w_semantic": config.w_semantic,
"w_llm": config.w_llm,
},
"final_confidence": final_confidence,
"decision_reason": decision_reason,
}
return FusionResult(
final_intent=final_intent,
final_confidence=final_confidence,
decision_reason=decision_reason,
need_clarify=need_clarify,
clarify_candidates=clarify_candidates,
trace=trace,
)
def _find_rule_by_id(
self,
intent_id: str | None,
rule_result: RuleMatchResult,
semantic_result: SemanticMatchResult,
) -> IntentRule | None:
"""Find rule by ID from rule or semantic results."""
if not intent_id:
return None
if rule_result.rule_id and str(rule_result.rule_id) == intent_id:
return rule_result.rule
for candidate in semantic_result.candidates:
if str(candidate.rule.id) == intent_id:
return candidate.rule
return None