574 lines
18 KiB
Python
574 lines
18 KiB
Python
"""
|
|
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
|