feat: implement hybrid intent routing with RuleMatcher, SemanticMatcher, LlmJudge and FusionPolicy [AC-AISVC-111~125]
This commit is contained in:
parent
0dfc60935d
commit
66902cd7c1
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""
|
||||||
|
Clarification mechanism for intent recognition.
|
||||||
|
[AC-CLARIFY] 澄清机制实现
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
1. 统一置信度计算
|
||||||
|
2. 硬拦截规则(confidence检查、required_slots检查)
|
||||||
|
3. 澄清状态管理
|
||||||
|
4. 埋点指标收集
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
T_HIGH = 0.75
|
||||||
|
T_LOW = 0.45
|
||||||
|
MAX_CLARIFY_RETRY = 3
|
||||||
|
|
||||||
|
|
||||||
|
class ClarifyReason(str, Enum):
|
||||||
|
INTENT_AMBIGUITY = "intent_ambiguity"
|
||||||
|
MISSING_SLOT = "missing_slot"
|
||||||
|
LOW_CONFIDENCE = "low_confidence"
|
||||||
|
MULTI_INTENT = "multi_intent"
|
||||||
|
|
||||||
|
|
||||||
|
class ClarifyMetrics:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._clarify_trigger_count = 0
|
||||||
|
cls._instance._clarify_converge_count = 0
|
||||||
|
cls._instance._misroute_count = 0
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def record_clarify_trigger(self) -> None:
|
||||||
|
self._clarify_trigger_count += 1
|
||||||
|
logger.debug(f"[AC-CLARIFY-METRICS] clarify_trigger_count: {self._clarify_trigger_count}")
|
||||||
|
|
||||||
|
def record_clarify_converge(self) -> None:
|
||||||
|
self._clarify_converge_count += 1
|
||||||
|
logger.debug(f"[AC-CLARIFY-METRICS] clarify_converge_count: {self._clarify_converge_count}")
|
||||||
|
|
||||||
|
def record_misroute(self) -> None:
|
||||||
|
self._misroute_count += 1
|
||||||
|
logger.debug(f"[AC-CLARIFY-METRICS] misroute_count: {self._misroute_count}")
|
||||||
|
|
||||||
|
def get_metrics(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"clarify_trigger_rate": self._clarify_trigger_count,
|
||||||
|
"clarify_converge_rate": self._clarify_converge_count,
|
||||||
|
"misroute_rate": self._misroute_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_rates(self, total_requests: int) -> dict[str, float]:
|
||||||
|
if total_requests == 0:
|
||||||
|
return {
|
||||||
|
"clarify_trigger_rate": 0.0,
|
||||||
|
"clarify_converge_rate": 0.0,
|
||||||
|
"misroute_rate": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"clarify_trigger_rate": self._clarify_trigger_count / total_requests,
|
||||||
|
"clarify_converge_rate": self._clarify_converge_count / total_requests if self._clarify_trigger_count > 0 else 0.0,
|
||||||
|
"misroute_rate": self._misroute_count / total_requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._clarify_trigger_count = 0
|
||||||
|
self._clarify_converge_count = 0
|
||||||
|
self._misroute_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_clarify_metrics() -> ClarifyMetrics:
|
||||||
|
return ClarifyMetrics()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntentCandidate:
|
||||||
|
intent_id: str
|
||||||
|
intent_name: str
|
||||||
|
confidence: float
|
||||||
|
response_type: str | None = None
|
||||||
|
target_kb_ids: list[str] | None = None
|
||||||
|
flow_id: str | None = None
|
||||||
|
fixed_reply: str | None = None
|
||||||
|
transfer_message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"intent_id": self.intent_id,
|
||||||
|
"intent_name": self.intent_name,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"response_type": self.response_type,
|
||||||
|
"target_kb_ids": self.target_kb_ids,
|
||||||
|
"flow_id": self.flow_id,
|
||||||
|
"fixed_reply": self.fixed_reply,
|
||||||
|
"transfer_message": self.transfer_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HybridIntentResult:
|
||||||
|
intent: IntentCandidate | None
|
||||||
|
confidence: float
|
||||||
|
candidates: list[IntentCandidate] = field(default_factory=list)
|
||||||
|
need_clarify: bool = False
|
||||||
|
clarify_reason: ClarifyReason | None = None
|
||||||
|
missing_slots: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"intent": self.intent.to_dict() if self.intent else None,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"candidates": [c.to_dict() for c in self.candidates],
|
||||||
|
"need_clarify": self.need_clarify,
|
||||||
|
"clarify_reason": self.clarify_reason.value if self.clarify_reason else None,
|
||||||
|
"missing_slots": self.missing_slots,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_fusion_result(cls, fusion_result: Any) -> "HybridIntentResult":
|
||||||
|
candidates = []
|
||||||
|
if fusion_result.clarify_candidates:
|
||||||
|
for c in fusion_result.clarify_candidates:
|
||||||
|
candidates.append(IntentCandidate(
|
||||||
|
intent_id=str(c.id),
|
||||||
|
intent_name=c.name,
|
||||||
|
confidence=0.0,
|
||||||
|
response_type=getattr(c, "response_type", None),
|
||||||
|
target_kb_ids=getattr(c, "target_kb_ids", None),
|
||||||
|
flow_id=str(c.flow_id) if getattr(c, "flow_id", None) else None,
|
||||||
|
fixed_reply=getattr(c, "fixed_reply", None),
|
||||||
|
transfer_message=getattr(c, "transfer_message", None),
|
||||||
|
))
|
||||||
|
|
||||||
|
if fusion_result.final_intent:
|
||||||
|
final_candidate = IntentCandidate(
|
||||||
|
intent_id=str(fusion_result.final_intent.id),
|
||||||
|
intent_name=fusion_result.final_intent.name,
|
||||||
|
confidence=fusion_result.final_confidence,
|
||||||
|
response_type=fusion_result.final_intent.response_type,
|
||||||
|
target_kb_ids=fusion_result.final_intent.target_kb_ids,
|
||||||
|
flow_id=str(fusion_result.final_intent.flow_id) if fusion_result.final_intent.flow_id else None,
|
||||||
|
fixed_reply=fusion_result.final_intent.fixed_reply,
|
||||||
|
transfer_message=fusion_result.final_intent.transfer_message,
|
||||||
|
)
|
||||||
|
if not any(c.intent_id == final_candidate.intent_id for c in candidates):
|
||||||
|
candidates.insert(0, final_candidate)
|
||||||
|
|
||||||
|
clarify_reason = None
|
||||||
|
if fusion_result.need_clarify:
|
||||||
|
if fusion_result.decision_reason == "multi_intent":
|
||||||
|
clarify_reason = ClarifyReason.MULTI_INTENT
|
||||||
|
elif fusion_result.decision_reason == "gray_zone":
|
||||||
|
clarify_reason = ClarifyReason.INTENT_AMBIGUITY
|
||||||
|
else:
|
||||||
|
clarify_reason = ClarifyReason.LOW_CONFIDENCE
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
intent=candidates[0] if candidates else None,
|
||||||
|
confidence=fusion_result.final_confidence,
|
||||||
|
candidates=candidates,
|
||||||
|
need_clarify=fusion_result.need_clarify,
|
||||||
|
clarify_reason=clarify_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClarifyState:
|
||||||
|
reason: ClarifyReason
|
||||||
|
asked_slot: str | None = None
|
||||||
|
retry_count: int = 0
|
||||||
|
candidates: list[IntentCandidate] = field(default_factory=list)
|
||||||
|
asked_intent_ids: list[str] = field(default_factory=list)
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"reason": self.reason.value,
|
||||||
|
"asked_slot": self.asked_slot,
|
||||||
|
"retry_count": self.retry_count,
|
||||||
|
"candidates": [c.to_dict() for c in self.candidates],
|
||||||
|
"asked_intent_ids": self.asked_intent_ids,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def increment_retry(self) -> "ClarifyState":
|
||||||
|
self.retry_count += 1
|
||||||
|
return self
|
||||||
|
|
||||||
|
def is_max_retry(self) -> bool:
|
||||||
|
return self.retry_count >= MAX_CLARIFY_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
class ClarificationEngine:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
t_high: float = T_HIGH,
|
||||||
|
t_low: float = T_LOW,
|
||||||
|
max_retry: int = MAX_CLARIFY_RETRY,
|
||||||
|
):
|
||||||
|
self._t_high = t_high
|
||||||
|
self._t_low = t_low
|
||||||
|
self._max_retry = max_retry
|
||||||
|
self._metrics = get_clarify_metrics()
|
||||||
|
|
||||||
|
def compute_confidence(
|
||||||
|
self,
|
||||||
|
rule_score: float = 0.0,
|
||||||
|
semantic_score: float = 0.0,
|
||||||
|
llm_score: float = 0.0,
|
||||||
|
w_rule: float = 0.5,
|
||||||
|
w_semantic: float = 0.3,
|
||||||
|
w_llm: float = 0.2,
|
||||||
|
) -> float:
|
||||||
|
total_weight = w_rule + w_semantic + w_llm
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
weighted_score = (
|
||||||
|
rule_score * w_rule +
|
||||||
|
semantic_score * w_semantic +
|
||||||
|
llm_score * w_llm
|
||||||
|
)
|
||||||
|
|
||||||
|
return min(1.0, max(0.0, weighted_score / total_weight))
|
||||||
|
|
||||||
|
def check_hard_block(
|
||||||
|
self,
|
||||||
|
result: HybridIntentResult,
|
||||||
|
required_slots: list[str] | None = None,
|
||||||
|
filled_slots: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[bool, ClarifyReason | None]:
|
||||||
|
if result.confidence < self._t_high:
|
||||||
|
return True, ClarifyReason.LOW_CONFIDENCE
|
||||||
|
|
||||||
|
if required_slots and filled_slots is not None:
|
||||||
|
missing = [s for s in required_slots if s not in filled_slots]
|
||||||
|
if missing:
|
||||||
|
return True, ClarifyReason.MISSING_SLOT
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def should_trigger_clarify(
|
||||||
|
self,
|
||||||
|
result: HybridIntentResult,
|
||||||
|
required_slots: list[str] | None = None,
|
||||||
|
filled_slots: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[bool, ClarifyState | None]:
|
||||||
|
if result.confidence >= self._t_high:
|
||||||
|
if required_slots and filled_slots is not None:
|
||||||
|
missing = [s for s in required_slots if s not in filled_slots]
|
||||||
|
if missing:
|
||||||
|
self._metrics.record_clarify_trigger()
|
||||||
|
return True, ClarifyState(
|
||||||
|
reason=ClarifyReason.MISSING_SLOT,
|
||||||
|
asked_slot=missing[0],
|
||||||
|
candidates=result.candidates,
|
||||||
|
)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
if result.confidence < self._t_low:
|
||||||
|
self._metrics.record_clarify_trigger()
|
||||||
|
return True, ClarifyState(
|
||||||
|
reason=ClarifyReason.LOW_CONFIDENCE,
|
||||||
|
candidates=result.candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._metrics.record_clarify_trigger()
|
||||||
|
|
||||||
|
reason = result.clarify_reason or ClarifyReason.INTENT_AMBIGUITY
|
||||||
|
return True, ClarifyState(
|
||||||
|
reason=reason,
|
||||||
|
candidates=result.candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_clarify_prompt(
|
||||||
|
self,
|
||||||
|
state: ClarifyState,
|
||||||
|
slot_label: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
if state.reason == ClarifyReason.MISSING_SLOT:
|
||||||
|
slot_name = slot_label or state.asked_slot or "相关信息"
|
||||||
|
return f"为了更好地为您服务,请告诉我您的{slot_name}。"
|
||||||
|
|
||||||
|
if state.reason == ClarifyReason.LOW_CONFIDENCE:
|
||||||
|
return "抱歉,我不太理解您的意思,能否请您详细描述一下您的需求?"
|
||||||
|
|
||||||
|
if state.reason == ClarifyReason.MULTI_INTENT and len(state.candidates) > 1:
|
||||||
|
candidates = state.candidates[:3]
|
||||||
|
if len(candidates) == 2:
|
||||||
|
return (
|
||||||
|
f"请问您是想「{candidates[0].intent_name}」"
|
||||||
|
f"还是「{candidates[1].intent_name}」?"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
options = "、".join([f"「{c.intent_name}」" for c in candidates[:-1]])
|
||||||
|
return f"请问您是想{options},还是「{candidates[-1].intent_name}」?"
|
||||||
|
|
||||||
|
if state.reason == ClarifyReason.INTENT_AMBIGUITY and len(state.candidates) > 1:
|
||||||
|
candidates = state.candidates[:2]
|
||||||
|
return (
|
||||||
|
f"请问您是想「{candidates[0].intent_name}」"
|
||||||
|
f"还是「{candidates[1].intent_name}」?"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "请问您具体想了解什么?"
|
||||||
|
|
||||||
|
def process_clarify_response(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
state: ClarifyState,
|
||||||
|
intent_router: Any = None,
|
||||||
|
rules: list[Any] | None = None,
|
||||||
|
) -> HybridIntentResult:
|
||||||
|
state.increment_retry()
|
||||||
|
|
||||||
|
if state.is_max_retry():
|
||||||
|
self._metrics.record_misroute()
|
||||||
|
return HybridIntentResult(
|
||||||
|
intent=None,
|
||||||
|
confidence=0.0,
|
||||||
|
need_clarify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if state.reason == ClarifyReason.MISSING_SLOT:
|
||||||
|
self._metrics.record_clarify_converge()
|
||||||
|
return HybridIntentResult(
|
||||||
|
intent=state.candidates[0] if state.candidates else None,
|
||||||
|
confidence=0.8,
|
||||||
|
candidates=state.candidates,
|
||||||
|
need_clarify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return HybridIntentResult(
|
||||||
|
intent=None,
|
||||||
|
confidence=0.0,
|
||||||
|
candidates=state.candidates,
|
||||||
|
need_clarify=True,
|
||||||
|
clarify_reason=state.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_metrics(self) -> dict[str, int]:
|
||||||
|
return self._metrics.get_metrics()
|
||||||
|
|
||||||
|
def get_rates(self, total_requests: int) -> dict[str, float]:
|
||||||
|
return self._metrics.get_rates(total_requests)
|
||||||
|
|
||||||
|
|
||||||
|
class ClarifySessionManager:
|
||||||
|
_sessions: dict[str, ClarifyState] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_session(cls, session_id: str) -> ClarifyState | None:
|
||||||
|
return cls._sessions.get(session_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_session(cls, session_id: str, state: ClarifyState) -> None:
|
||||||
|
cls._sessions[session_id] = state
|
||||||
|
logger.debug(f"[AC-CLARIFY] Session state set: session={session_id}, reason={state.reason}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_session(cls, session_id: str) -> None:
|
||||||
|
if session_id in cls._sessions:
|
||||||
|
del cls._sessions[session_id]
|
||||||
|
logger.debug(f"[AC-CLARIFY] Session state cleared: session={session_id}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_active_clarify(cls, session_id: str) -> bool:
|
||||||
|
state = cls._sessions.get(session_id)
|
||||||
|
if state:
|
||||||
|
return not state.is_max_retry()
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
"""
|
||||||
|
[v0.8.0] Fusion policy for hybrid intent routing.
|
||||||
|
[AC-AISVC-115~AC-AISVC-117] Fusion decision logic for three-way matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.services.intent.models import (
|
||||||
|
FusionConfig,
|
||||||
|
FusionResult,
|
||||||
|
LlmJudgeResult,
|
||||||
|
RouteTrace,
|
||||||
|
RuleMatchResult,
|
||||||
|
SemanticMatchResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.entities import IntentRule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DecisionCondition = Callable[[RuleMatchResult, SemanticMatchResult, LlmJudgeResult], bool]
|
||||||
|
|
||||||
|
|
||||||
|
class FusionPolicy:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-115] Fusion decision policy for hybrid routing.
|
||||||
|
|
||||||
|
Decision priority:
|
||||||
|
1. rule_high_confidence: RuleMatcher hit with score=1.0
|
||||||
|
2. llm_judge: LlmJudge triggered and returned valid intent
|
||||||
|
3. semantic_override: RuleMatcher missed but SemanticMatcher high confidence
|
||||||
|
4. rule_semantic_agree: Both match same intent
|
||||||
|
5. semantic_fallback: SemanticMatcher medium confidence
|
||||||
|
6. rule_fallback: Only rule matched
|
||||||
|
7. no_match: All low confidence
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECISION_PRIORITY: list[tuple[str, DecisionCondition]] = [
|
||||||
|
("rule_high_confidence", lambda r, s, llm: r.score == 1.0 and r.rule is not None),
|
||||||
|
("llm_judge", lambda r, s, llm: llm.triggered and llm.intent_id is not None),
|
||||||
|
(
|
||||||
|
"semantic_override",
|
||||||
|
lambda r, s, llm: r.score == 0
|
||||||
|
and s.top_score > 0.7
|
||||||
|
and not s.skipped
|
||||||
|
and len(s.candidates) > 0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"rule_semantic_agree",
|
||||||
|
lambda r, s, llm: r.score > 0
|
||||||
|
and s.top_score > 0.5
|
||||||
|
and not s.skipped
|
||||||
|
and len(s.candidates) > 0
|
||||||
|
and r.rule_id == s.candidates[0].rule.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"semantic_fallback",
|
||||||
|
lambda r, s, llm: s.top_score > 0.5 and not s.skipped and len(s.candidates) > 0,
|
||||||
|
),
|
||||||
|
("rule_fallback", lambda r, s, llm: r.score > 0),
|
||||||
|
("no_match", lambda r, s, llm: True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, config: FusionConfig | None = None):
|
||||||
|
"""
|
||||||
|
Initialize fusion policy with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Fusion configuration, uses default if not provided
|
||||||
|
"""
|
||||||
|
self._config = config or FusionConfig()
|
||||||
|
|
||||||
|
def fuse(
|
||||||
|
self,
|
||||||
|
rule_result: RuleMatchResult,
|
||||||
|
semantic_result: SemanticMatchResult,
|
||||||
|
llm_result: LlmJudgeResult | None,
|
||||||
|
) -> FusionResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-115] Execute fusion decision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule_result: Rule matching result
|
||||||
|
semantic_result: Semantic matching result
|
||||||
|
llm_result: LLM judge result (may be None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FusionResult with final intent, confidence, and trace
|
||||||
|
"""
|
||||||
|
trace = self._build_trace(rule_result, semantic_result, llm_result)
|
||||||
|
|
||||||
|
final_intent = None
|
||||||
|
final_confidence = 0.0
|
||||||
|
decision_reason = "no_match"
|
||||||
|
|
||||||
|
effective_llm_result = llm_result or LlmJudgeResult.empty()
|
||||||
|
|
||||||
|
for reason, condition in self.DECISION_PRIORITY:
|
||||||
|
if condition(rule_result, semantic_result, effective_llm_result):
|
||||||
|
decision_reason = reason
|
||||||
|
break
|
||||||
|
|
||||||
|
if decision_reason == "rule_high_confidence":
|
||||||
|
final_intent = rule_result.rule
|
||||||
|
final_confidence = 1.0
|
||||||
|
elif decision_reason == "llm_judge" and llm_result:
|
||||||
|
final_intent = self._find_rule_by_id(
|
||||||
|
llm_result.intent_id, rule_result, semantic_result
|
||||||
|
)
|
||||||
|
final_confidence = llm_result.score
|
||||||
|
elif decision_reason == "semantic_override":
|
||||||
|
final_intent = semantic_result.candidates[0].rule
|
||||||
|
final_confidence = semantic_result.top_score
|
||||||
|
elif decision_reason == "rule_semantic_agree":
|
||||||
|
final_intent = rule_result.rule
|
||||||
|
final_confidence = self._calculate_weighted_confidence(
|
||||||
|
rule_result, semantic_result, llm_result
|
||||||
|
)
|
||||||
|
elif decision_reason == "semantic_fallback":
|
||||||
|
final_intent = semantic_result.candidates[0].rule
|
||||||
|
final_confidence = semantic_result.top_score
|
||||||
|
elif decision_reason == "rule_fallback":
|
||||||
|
final_intent = rule_result.rule
|
||||||
|
final_confidence = rule_result.score
|
||||||
|
|
||||||
|
need_clarify = final_confidence < self._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": self._config.w_rule,
|
||||||
|
"w_semantic": self._config.w_semantic,
|
||||||
|
"w_llm": self._config.w_llm,
|
||||||
|
},
|
||||||
|
"final_confidence": final_confidence,
|
||||||
|
"decision_reason": decision_reason,
|
||||||
|
"need_clarify": need_clarify,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-115] Fusion decision: reason={decision_reason}, "
|
||||||
|
f"confidence={final_confidence:.3f}, need_clarify={need_clarify}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 _build_trace(
|
||||||
|
self,
|
||||||
|
rule_result: RuleMatchResult,
|
||||||
|
semantic_result: SemanticMatchResult,
|
||||||
|
llm_result: LlmJudgeResult | None,
|
||||||
|
) -> RouteTrace:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-122] Build route trace log.
|
||||||
|
"""
|
||||||
|
return RouteTrace(
|
||||||
|
rule_match={
|
||||||
|
"rule_id": str(rule_result.rule_id) if rule_result.rule_id else None,
|
||||||
|
"rule_name": rule_result.rule.name if rule_result.rule else None,
|
||||||
|
"match_type": rule_result.match_type,
|
||||||
|
"matched_text": rule_result.matched_text,
|
||||||
|
"score": rule_result.score,
|
||||||
|
"duration_ms": rule_result.duration_ms,
|
||||||
|
},
|
||||||
|
semantic_match={
|
||||||
|
"top_candidates": [
|
||||||
|
{
|
||||||
|
"rule_id": str(c.rule.id),
|
||||||
|
"rule_name": c.rule.name,
|
||||||
|
"score": c.score,
|
||||||
|
}
|
||||||
|
for c in semantic_result.candidates
|
||||||
|
],
|
||||||
|
"top_score": semantic_result.top_score,
|
||||||
|
"duration_ms": semantic_result.duration_ms,
|
||||||
|
"skipped": semantic_result.skipped,
|
||||||
|
"skip_reason": semantic_result.skip_reason,
|
||||||
|
},
|
||||||
|
llm_judge={
|
||||||
|
"triggered": llm_result.triggered if llm_result else False,
|
||||||
|
"intent_id": llm_result.intent_id if llm_result else None,
|
||||||
|
"intent_name": llm_result.intent_name if llm_result else None,
|
||||||
|
"score": llm_result.score if llm_result else 0.0,
|
||||||
|
"reasoning": llm_result.reasoning if llm_result else None,
|
||||||
|
"duration_ms": llm_result.duration_ms if llm_result else 0,
|
||||||
|
"tokens_used": llm_result.tokens_used if llm_result else 0,
|
||||||
|
},
|
||||||
|
fusion={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_weighted_confidence(
|
||||||
|
self,
|
||||||
|
rule_result: RuleMatchResult,
|
||||||
|
semantic_result: SemanticMatchResult,
|
||||||
|
llm_result: LlmJudgeResult | None,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-116] Calculate weighted confidence.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
final_confidence = (w_rule * rule_score + w_semantic * semantic_score + w_llm * llm_score) / total_weight
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Weighted confidence in [0.0, 1.0]
|
||||||
|
"""
|
||||||
|
rule_score = rule_result.score
|
||||||
|
semantic_score = semantic_result.top_score if not semantic_result.skipped else 0.0
|
||||||
|
llm_score = llm_result.score if llm_result and llm_result.triggered else 0.0
|
||||||
|
|
||||||
|
total_weight = self._config.w_rule + self._config.w_semantic
|
||||||
|
if llm_result and llm_result.triggered:
|
||||||
|
total_weight += self._config.w_llm
|
||||||
|
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
confidence = (
|
||||||
|
self._config.w_rule * rule_score
|
||||||
|
+ self._config.w_semantic * semantic_score
|
||||||
|
+ self._config.w_llm * llm_score
|
||||||
|
) / total_weight
|
||||||
|
|
||||||
|
return min(1.0, max(0.0, confidence))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""
|
||||||
|
LLM judge for intent arbitration.
|
||||||
|
[AC-AISVC-118, AC-AISVC-119] LLM-based intent arbitration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from app.services.intent.models import (
|
||||||
|
FusionConfig,
|
||||||
|
LlmJudgeInput,
|
||||||
|
LlmJudgeResult,
|
||||||
|
RuleMatchResult,
|
||||||
|
SemanticMatchResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.services.llm.base import LLMClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmJudge:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-118] LLM-based intent arbitrator.
|
||||||
|
|
||||||
|
Triggered when:
|
||||||
|
- Rule vs Semantic conflict
|
||||||
|
- Gray zone (low confidence)
|
||||||
|
- Multiple intent candidates with similar scores
|
||||||
|
"""
|
||||||
|
|
||||||
|
JUDGE_PROMPT = """你是一个意图识别仲裁器。根据用户消息和候选意图,判断最匹配的意图。
|
||||||
|
|
||||||
|
用户消息:{message}
|
||||||
|
|
||||||
|
候选意图:
|
||||||
|
{candidates}
|
||||||
|
|
||||||
|
请返回 JSON 格式(不要包含```json标记):
|
||||||
|
{{
|
||||||
|
"intent_id": "最匹配的意图ID",
|
||||||
|
"intent_name": "意图名称",
|
||||||
|
"confidence": 0.0-1.0之间的置信度,
|
||||||
|
"reasoning": "判断理由"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm_client: "LLMClient",
|
||||||
|
config: FusionConfig,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize LLM judge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_client: LLM client for generating responses
|
||||||
|
config: Fusion configuration
|
||||||
|
"""
|
||||||
|
self._llm_client = llm_client
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def should_trigger(
|
||||||
|
self,
|
||||||
|
rule_result: RuleMatchResult,
|
||||||
|
semantic_result: SemanticMatchResult,
|
||||||
|
config: FusionConfig | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-118] Check if LLM judge should be triggered.
|
||||||
|
|
||||||
|
Trigger conditions:
|
||||||
|
1. Conflict: Rule and Semantic match different intents with close scores
|
||||||
|
2. Gray zone: Max confidence in gray zone range
|
||||||
|
3. Multi-intent: Multiple candidates with similar scores
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule_result: Rule matching result
|
||||||
|
semantic_result: Semantic matching result
|
||||||
|
config: Optional config override
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (should_trigger, trigger_reason)
|
||||||
|
"""
|
||||||
|
effective_config = config or self._config
|
||||||
|
|
||||||
|
if not effective_config.llm_judge_enabled:
|
||||||
|
return False, "disabled"
|
||||||
|
|
||||||
|
rule_score = rule_result.score
|
||||||
|
semantic_score = semantic_result.top_score
|
||||||
|
|
||||||
|
if rule_score > 0 and semantic_score > 0:
|
||||||
|
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) < effective_config.conflict_threshold:
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-118] LLM judge triggered: rule_semantic_conflict, "
|
||||||
|
f"rule_id={rule_result.rule_id}, semantic_id={top_semantic_rule_id}, "
|
||||||
|
f"rule_score={rule_score}, semantic_score={semantic_score}"
|
||||||
|
)
|
||||||
|
return True, "rule_semantic_conflict"
|
||||||
|
|
||||||
|
max_score = max(rule_score, semantic_score)
|
||||||
|
if effective_config.min_trigger_threshold < max_score < effective_config.gray_zone_threshold:
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-118] LLM judge triggered: gray_zone, "
|
||||||
|
f"max_score={max_score}"
|
||||||
|
)
|
||||||
|
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) < effective_config.multi_intent_threshold:
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-118] LLM judge triggered: multi_intent, "
|
||||||
|
f"top1_score={top1_score}, top2_score={top2_score}"
|
||||||
|
)
|
||||||
|
return True, "multi_intent"
|
||||||
|
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
async def judge(
|
||||||
|
self,
|
||||||
|
input_data: LlmJudgeInput,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> LlmJudgeResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-119] Perform LLM arbitration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Judge input with message and candidates
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LlmJudgeResult with arbitration decision
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
candidates_text = "\n".join([
|
||||||
|
f"- ID: {c['id']}, 名称: {c['name']}, 描述: {c.get('description', 'N/A')}"
|
||||||
|
for c in input_data.candidates
|
||||||
|
])
|
||||||
|
|
||||||
|
prompt = self.JUDGE_PROMPT.format(
|
||||||
|
message=input_data.message,
|
||||||
|
candidates=candidates_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.llm.base import LLMConfig
|
||||||
|
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self._llm_client.generate(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
config=LLMConfig(
|
||||||
|
max_tokens=200,
|
||||||
|
temperature=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
timeout=self._config.llm_judge_timeout_ms / 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._parse_response(response.content or "")
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
tokens_used = 0
|
||||||
|
if response.usage:
|
||||||
|
tokens_used = response.usage.get("total_tokens", 0)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-119] LLM judge completed for tenant={tenant_id}, "
|
||||||
|
f"intent_id={result.get('intent_id')}, confidence={result.get('confidence', 0):.3f}, "
|
||||||
|
f"duration={duration_ms}ms, tokens={tokens_used}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return LlmJudgeResult(
|
||||||
|
intent_id=result.get("intent_id"),
|
||||||
|
intent_name=result.get("intent_name"),
|
||||||
|
score=float(result.get("confidence", 0.5)),
|
||||||
|
reasoning=result.get("reasoning"),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
tokens_used=tokens_used,
|
||||||
|
triggered=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-AISVC-119] LLM judge timeout for tenant={tenant_id}, "
|
||||||
|
f"timeout={self._config.llm_judge_timeout_ms}ms"
|
||||||
|
)
|
||||||
|
return LlmJudgeResult(
|
||||||
|
intent_id=None,
|
||||||
|
intent_name=None,
|
||||||
|
score=0.0,
|
||||||
|
reasoning="LLM timeout",
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
tokens_used=0,
|
||||||
|
triggered=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
f"[AC-AISVC-119] LLM judge error for tenant={tenant_id}: {e}"
|
||||||
|
)
|
||||||
|
return LlmJudgeResult(
|
||||||
|
intent_id=None,
|
||||||
|
intent_name=None,
|
||||||
|
score=0.0,
|
||||||
|
reasoning=f"LLM error: {str(e)}",
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
tokens_used=0,
|
||||||
|
triggered=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_response(self, content: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse LLM response to extract JSON result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: LLM response content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed dictionary with intent_id, intent_name, confidence, reasoning
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```json"):
|
||||||
|
cleaned = cleaned[7:]
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned[3:]
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
result: dict[str, Any] = json.loads(cleaned)
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"[AC-AISVC-119] Failed to parse LLM response: {e}")
|
||||||
|
return {}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
"""
|
||||||
|
Intent routing data models.
|
||||||
|
[AC-AISVC-111~AC-AISVC-125] Data models for hybrid routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuleMatchResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-112] Result of rule matching.
|
||||||
|
Contains matched rule and score.
|
||||||
|
"""
|
||||||
|
rule_id: uuid.UUID | None
|
||||||
|
rule: Any | None
|
||||||
|
match_type: str | None
|
||||||
|
matched_text: str | None
|
||||||
|
score: float
|
||||||
|
duration_ms: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rule_id": str(self.rule_id) if self.rule_id else None,
|
||||||
|
"rule_name": self.rule.name if self.rule else None,
|
||||||
|
"match_type": self.match_type,
|
||||||
|
"matched_text": self.matched_text,
|
||||||
|
"score": self.score,
|
||||||
|
"duration_ms": self.duration_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SemanticCandidate:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-113] Semantic match candidate.
|
||||||
|
"""
|
||||||
|
rule: Any
|
||||||
|
score: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rule_id": str(self.rule.id),
|
||||||
|
"rule_name": self.rule.name,
|
||||||
|
"score": self.score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SemanticMatchResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-113] Result of semantic matching.
|
||||||
|
"""
|
||||||
|
candidates: list[SemanticCandidate]
|
||||||
|
top_score: float
|
||||||
|
duration_ms: int
|
||||||
|
skipped: bool
|
||||||
|
skip_reason: str | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"top_candidates": [c.to_dict() for c in self.candidates],
|
||||||
|
"top_score": self.top_score,
|
||||||
|
"duration_ms": self.duration_ms,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"skip_reason": self.skip_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LlmJudgeInput:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-119] Input for LLM judge.
|
||||||
|
"""
|
||||||
|
message: str
|
||||||
|
candidates: list[dict[str, Any]]
|
||||||
|
conflict_type: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LlmJudgeResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-119] Result of LLM judge.
|
||||||
|
"""
|
||||||
|
intent_id: str | None
|
||||||
|
intent_name: str | None
|
||||||
|
score: float
|
||||||
|
reasoning: str | None
|
||||||
|
duration_ms: int
|
||||||
|
tokens_used: int
|
||||||
|
triggered: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"triggered": self.triggered,
|
||||||
|
"intent_id": self.intent_id,
|
||||||
|
"intent_name": self.intent_name,
|
||||||
|
"score": self.score,
|
||||||
|
"reasoning": self.reasoning,
|
||||||
|
"duration_ms": self.duration_ms,
|
||||||
|
"tokens_used": self.tokens_used,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls) -> "LlmJudgeResult":
|
||||||
|
return cls(
|
||||||
|
intent_id=None,
|
||||||
|
intent_name=None,
|
||||||
|
score=0.0,
|
||||||
|
reasoning=None,
|
||||||
|
duration_ms=0,
|
||||||
|
tokens_used=0,
|
||||||
|
triggered=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FusionConfig:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-116] Fusion configuration.
|
||||||
|
"""
|
||||||
|
w_rule: float = 0.5
|
||||||
|
w_semantic: float = 0.3
|
||||||
|
w_llm: float = 0.2
|
||||||
|
semantic_threshold: float = 0.7
|
||||||
|
conflict_threshold: float = 0.2
|
||||||
|
gray_zone_threshold: float = 0.6
|
||||||
|
min_trigger_threshold: float = 0.3
|
||||||
|
clarify_threshold: float = 0.4
|
||||||
|
multi_intent_threshold: float = 0.15
|
||||||
|
llm_judge_enabled: bool = True
|
||||||
|
semantic_matcher_enabled: bool = True
|
||||||
|
semantic_matcher_timeout_ms: int = 100
|
||||||
|
llm_judge_timeout_ms: int = 2000
|
||||||
|
semantic_top_k: int = 3
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"w_rule": self.w_rule,
|
||||||
|
"w_semantic": self.w_semantic,
|
||||||
|
"w_llm": self.w_llm,
|
||||||
|
"semantic_threshold": self.semantic_threshold,
|
||||||
|
"conflict_threshold": self.conflict_threshold,
|
||||||
|
"gray_zone_threshold": self.gray_zone_threshold,
|
||||||
|
"min_trigger_threshold": self.min_trigger_threshold,
|
||||||
|
"clarify_threshold": self.clarify_threshold,
|
||||||
|
"multi_intent_threshold": self.multi_intent_threshold,
|
||||||
|
"llm_judge_enabled": self.llm_judge_enabled,
|
||||||
|
"semantic_matcher_enabled": self.semantic_matcher_enabled,
|
||||||
|
"semantic_matcher_timeout_ms": self.semantic_matcher_timeout_ms,
|
||||||
|
"llm_judge_timeout_ms": self.llm_judge_timeout_ms,
|
||||||
|
"semantic_top_k": self.semantic_top_k,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "FusionConfig":
|
||||||
|
return cls(
|
||||||
|
w_rule=data.get("w_rule", 0.5),
|
||||||
|
w_semantic=data.get("w_semantic", 0.3),
|
||||||
|
w_llm=data.get("w_llm", 0.2),
|
||||||
|
semantic_threshold=data.get("semantic_threshold", 0.7),
|
||||||
|
conflict_threshold=data.get("conflict_threshold", 0.2),
|
||||||
|
gray_zone_threshold=data.get("gray_zone_threshold", 0.6),
|
||||||
|
min_trigger_threshold=data.get("min_trigger_threshold", 0.3),
|
||||||
|
clarify_threshold=data.get("clarify_threshold", 0.4),
|
||||||
|
multi_intent_threshold=data.get("multi_intent_threshold", 0.15),
|
||||||
|
llm_judge_enabled=data.get("llm_judge_enabled", True),
|
||||||
|
semantic_matcher_enabled=data.get("semantic_matcher_enabled", True),
|
||||||
|
semantic_matcher_timeout_ms=data.get("semantic_matcher_timeout_ms", 100),
|
||||||
|
llm_judge_timeout_ms=data.get("llm_judge_timeout_ms", 2000),
|
||||||
|
semantic_top_k=data.get("semantic_top_k", 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RouteTrace:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-122] Route trace log.
|
||||||
|
"""
|
||||||
|
rule_match: dict[str, Any] = field(default_factory=dict)
|
||||||
|
semantic_match: dict[str, Any] = field(default_factory=dict)
|
||||||
|
llm_judge: dict[str, Any] = field(default_factory=dict)
|
||||||
|
fusion: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rule_match": self.rule_match,
|
||||||
|
"semantic_match": self.semantic_match,
|
||||||
|
"llm_judge": self.llm_judge,
|
||||||
|
"fusion": self.fusion,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FusionResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-115] Fusion decision result.
|
||||||
|
"""
|
||||||
|
final_intent: Any | None
|
||||||
|
final_confidence: float
|
||||||
|
decision_reason: str
|
||||||
|
need_clarify: bool
|
||||||
|
clarify_candidates: list[Any] | None
|
||||||
|
trace: RouteTrace
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"final_intent": {
|
||||||
|
"id": str(self.final_intent.id),
|
||||||
|
"name": self.final_intent.name,
|
||||||
|
"response_type": self.final_intent.response_type,
|
||||||
|
} if self.final_intent else None,
|
||||||
|
"final_confidence": self.final_confidence,
|
||||||
|
"decision_reason": self.decision_reason,
|
||||||
|
"need_clarify": self.need_clarify,
|
||||||
|
"clarify_candidates": [
|
||||||
|
{"id": str(c.id), "name": c.name}
|
||||||
|
for c in (self.clarify_candidates or [])
|
||||||
|
],
|
||||||
|
"trace": self.trace.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_FUSION_CONFIG = FusionConfig()
|
||||||
|
|
@ -1,14 +1,30 @@
|
||||||
"""
|
"""
|
||||||
Intent router for AI Service.
|
Intent router for AI Service.
|
||||||
[AC-AISVC-69, AC-AISVC-70] Intent matching engine with keyword and regex support.
|
[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 logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from app.models.entities import IntentRule
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -38,38 +54,36 @@ class IntentMatchResult:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class IntentRouter:
|
class RuleMatcher:
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-69] Intent matching engine.
|
[v0.8.0] Rule matcher for keyword and regex matching.
|
||||||
|
Extracted from IntentRouter for 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)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def match(self, message: str, rules: list[IntentRule]) -> RuleMatchResult:
|
||||||
pass
|
|
||||||
|
|
||||||
def match(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
rules: list[IntentRule],
|
|
||||||
) -> IntentMatchResult | None:
|
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-69] Match user message against intent rules.
|
[AC-AISVC-112] Match user message against intent rules.
|
||||||
|
Returns RuleMatchResult with score (1.0 for match, 0.0 for no match).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: User input message
|
message: User input message
|
||||||
rules: List of enabled rules ordered by priority DESC
|
rules: List of enabled rules ordered by priority DESC
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
IntentMatchResult if matched, None otherwise
|
RuleMatchResult with match details
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
if not message or not rules:
|
if not message or not rules:
|
||||||
return None
|
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()
|
message_lower = message.lower()
|
||||||
|
|
||||||
|
|
@ -79,22 +93,46 @@ class IntentRouter:
|
||||||
|
|
||||||
keyword_result = self._match_keywords(message, message_lower, rule)
|
keyword_result = self._match_keywords(message, message_lower, rule)
|
||||||
if keyword_result:
|
if keyword_result:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[AC-AISVC-69] Intent matched by keyword: "
|
f"[AC-AISVC-69] Intent matched by keyword: "
|
||||||
f"rule={rule.name}, matched='{keyword_result.matched}'"
|
f"rule={rule.name}, matched='{keyword_result.matched}'"
|
||||||
)
|
)
|
||||||
return keyword_result
|
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)
|
regex_result = self._match_patterns(message, rule)
|
||||||
if regex_result:
|
if regex_result:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[AC-AISVC-69] Intent matched by regex: "
|
f"[AC-AISVC-69] Intent matched by regex: "
|
||||||
f"rule={rule.name}, matched='{regex_result.matched}'"
|
f"rule={rule.name}, matched='{regex_result.matched}'"
|
||||||
)
|
)
|
||||||
return regex_result
|
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")
|
logger.debug("[AC-AISVC-70] No intent matched, will fallback to default RAG")
|
||||||
return None
|
return RuleMatchResult(
|
||||||
|
rule_id=None,
|
||||||
|
rule=None,
|
||||||
|
match_type=None,
|
||||||
|
matched_text=None,
|
||||||
|
score=0.0,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
def _match_keywords(
|
def _match_keywords(
|
||||||
self,
|
self,
|
||||||
|
|
@ -153,6 +191,74 @@ class IntentRouter:
|
||||||
|
|
||||||
return None
|
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(
|
def match_with_stats(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
|
|
@ -168,3 +274,300 @@ class IntentRouter:
|
||||||
if result:
|
if result:
|
||||||
return result, str(result.rule.id)
|
return result, str(result.rule.id)
|
||||||
return None, None
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""
|
||||||
|
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))
|
||||||
Loading…
Reference in New Issue