""" Policy Router for Mid Platform. [AC-IDMP-02, AC-IDMP-05, AC-IDMP-16, AC-IDMP-20] Routes to agent/micro_flow/fixed/transfer based on policy. Decision Matrix: 1. High-risk scenario (refund/complaint/privacy/transfer) -> micro_flow or transfer 2. Low confidence or missing key info -> micro_flow or fixed 3. Tool unavailable -> fixed 4. Human mode active -> transfer 5. Normal case -> agent Intent Hint Integration: - intent_hint provides soft signals (suggested_mode, confidence, high_risk_detected) - policy_router consumes hints but retains final decision authority - When hint suggests high_risk, policy_router validates and may override High Risk Check Integration: - high_risk_check provides structured risk detection result - High-risk check takes priority over normal intent routing - When high_risk_check matched, skip normal intent matching """ import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any from app.models.mid.schemas import ( ExecutionMode, FeatureFlags, HighRiskScenario, PolicyRouterResult, ) if TYPE_CHECKING: from app.models.mid.schemas import HighRiskCheckResult, IntentHintOutput logger = logging.getLogger(__name__) DEFAULT_HIGH_RISK_SCENARIOS: list[HighRiskScenario] = [ HighRiskScenario.REFUND, HighRiskScenario.COMPLAINT_ESCALATION, HighRiskScenario.PRIVACY_SENSITIVE_PROMISE, HighRiskScenario.TRANSFER, ] HIGH_RISK_KEYWORDS: dict[HighRiskScenario, list[str]] = { HighRiskScenario.REFUND: ["退款", "退货", "退钱", "退费", "还钱", "退款申请"], HighRiskScenario.COMPLAINT_ESCALATION: ["投诉", "升级投诉", "举报", "12315", "消费者协会"], HighRiskScenario.PRIVACY_SENSITIVE_PROMISE: ["承诺", "保证", "一定", "肯定能", "绝对", "担保"], HighRiskScenario.TRANSFER: ["转人工", "人工客服", "人工服务", "真人", "人工"], } LOW_CONFIDENCE_THRESHOLD = 0.3 @dataclass class IntentMatch: """Intent match result.""" intent_id: str intent_name: str confidence: float response_type: str target_kb_ids: list[str] | None = None flow_id: str | None = None fixed_reply: str | None = None transfer_message: str | None = None class PolicyRouter: """ [AC-IDMP-02, AC-IDMP-05, AC-IDMP-16, AC-IDMP-20] Policy router for execution mode decision. Decision Flow: 1. Check feature flags (rollback_to_legacy -> fixed) 2. Check session mode (HUMAN_ACTIVE -> transfer) 3. Check high-risk scenarios -> micro_flow or transfer 4. Check intent match confidence -> fallback if low 5. Default -> agent """ def __init__( self, high_risk_scenarios: list[HighRiskScenario] | None = None, low_confidence_threshold: float = LOW_CONFIDENCE_THRESHOLD, ): self._high_risk_scenarios = high_risk_scenarios or DEFAULT_HIGH_RISK_SCENARIOS self._low_confidence_threshold = low_confidence_threshold def route( self, user_message: str, session_mode: str = "BOT_ACTIVE", feature_flags: FeatureFlags | None = None, intent_match: IntentMatch | None = None, intent_hint: "IntentHintOutput | None" = None, context: dict[str, Any] | None = None, ) -> PolicyRouterResult: """ [AC-IDMP-02] Route to appropriate execution mode. Args: user_message: User input message session_mode: Current session mode (BOT_ACTIVE/HUMAN_ACTIVE) feature_flags: Feature flags for grayscale control intent_match: Intent match result if available intent_hint: Soft signal from intent_hint tool (optional) context: Additional context for decision Returns: PolicyRouterResult with decided mode and metadata """ logger.info( f"[AC-IDMP-02] PolicyRouter routing: session_mode={session_mode}, " f"feature_flags={feature_flags}, intent_match={intent_match}, " f"intent_hint_mode={intent_hint.suggested_mode if intent_hint else None}" ) if feature_flags and feature_flags.rollback_to_legacy: logger.info("[AC-IDMP-17] Rollback to legacy requested, using fixed mode") return PolicyRouterResult( mode=ExecutionMode.FIXED, fallback_reason_code="rollback_to_legacy", ) if session_mode == "HUMAN_ACTIVE": logger.info("[AC-IDMP-09] Session in HUMAN_ACTIVE mode, routing to transfer") return PolicyRouterResult( mode=ExecutionMode.TRANSFER, transfer_message="正在为您转接人工客服...", ) if intent_hint and intent_hint.high_risk_detected: logger.info( f"[AC-IDMP-05, AC-IDMP-20] High-risk from hint: {intent_hint.fallback_reason_code}" ) return self._handle_high_risk_from_hint(intent_hint, intent_match) high_risk_scenario = self._check_high_risk(user_message) if high_risk_scenario: logger.info(f"[AC-IDMP-05, AC-IDMP-20] High-risk scenario detected: {high_risk_scenario}") return self._handle_high_risk(high_risk_scenario, intent_match) if intent_hint and intent_hint.confidence < self._low_confidence_threshold: logger.info( f"[AC-IDMP-16] Low confidence from hint ({intent_hint.confidence}), " f"considering fallback" ) if intent_hint.suggested_mode in (ExecutionMode.FIXED, ExecutionMode.MICRO_FLOW): return PolicyRouterResult( mode=intent_hint.suggested_mode, intent=intent_hint.intent, confidence=intent_hint.confidence, fallback_reason_code=intent_hint.fallback_reason_code or "low_confidence_hint", target_flow_id=intent_hint.target_flow_id, ) if intent_match: if intent_match.confidence < self._low_confidence_threshold: logger.info( f"[AC-IDMP-16] Low confidence ({intent_match.confidence}), " f"falling back from agent mode" ) return self._handle_low_confidence(intent_match) if intent_match.response_type == "fixed": return PolicyRouterResult( mode=ExecutionMode.FIXED, intent=intent_match.intent_name, confidence=intent_match.confidence, fixed_reply=intent_match.fixed_reply, ) if intent_match.response_type == "transfer": return PolicyRouterResult( mode=ExecutionMode.TRANSFER, intent=intent_match.intent_name, confidence=intent_match.confidence, transfer_message=intent_match.transfer_message or "正在为您转接人工客服...", ) if intent_match.response_type == "flow": return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_match.intent_name, confidence=intent_match.confidence, target_flow_id=intent_match.flow_id, ) if feature_flags and not feature_flags.agent_enabled: logger.info("[AC-IDMP-17] Agent disabled by feature flag, using fixed mode") return PolicyRouterResult( mode=ExecutionMode.FIXED, fallback_reason_code="agent_disabled", ) logger.info("[AC-IDMP-02] Default routing to agent mode") return PolicyRouterResult( mode=ExecutionMode.AGENT, intent=intent_match.intent_name if intent_match else None, confidence=intent_match.confidence if intent_match else None, ) def _check_high_risk(self, message: str) -> HighRiskScenario | None: """ [AC-IDMP-05, AC-IDMP-20] Check if message matches high-risk scenarios. Returns the first matched high-risk scenario or None. """ message_lower = message.lower() for scenario in self._high_risk_scenarios: keywords = HIGH_RISK_KEYWORDS.get(scenario, []) for keyword in keywords: if keyword.lower() in message_lower: return scenario return None def _handle_high_risk( self, scenario: HighRiskScenario, intent_match: IntentMatch | None, ) -> PolicyRouterResult: """ [AC-IDMP-05] Handle high-risk scenario by routing to micro_flow or transfer. """ if scenario == HighRiskScenario.TRANSFER: return PolicyRouterResult( mode=ExecutionMode.TRANSFER, high_risk_triggered=True, transfer_message="正在为您转接人工客服...", ) if intent_match and intent_match.flow_id: return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_match.intent_name, confidence=intent_match.confidence, high_risk_triggered=True, target_flow_id=intent_match.flow_id, ) return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, high_risk_triggered=True, fallback_reason_code=f"high_risk_{scenario.value}", ) def _handle_high_risk_from_hint( self, intent_hint: "IntentHintOutput", intent_match: IntentMatch | None, ) -> PolicyRouterResult: """ [AC-IDMP-05, AC-IDMP-20] Handle high-risk from intent_hint. Policy_router validates hint suggestion but may override. """ if intent_hint.suggested_mode == ExecutionMode.TRANSFER: return PolicyRouterResult( mode=ExecutionMode.TRANSFER, high_risk_triggered=True, transfer_message="正在为您转接人工客服...", ) if intent_match and intent_match.flow_id: return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_match.intent_name, confidence=intent_match.confidence, high_risk_triggered=True, target_flow_id=intent_match.flow_id, ) if intent_hint.target_flow_id: return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_hint.intent, confidence=intent_hint.confidence, high_risk_triggered=True, target_flow_id=intent_hint.target_flow_id, ) return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, high_risk_triggered=True, fallback_reason_code=intent_hint.fallback_reason_code or "high_risk_hint", ) def _handle_low_confidence(self, intent_match: IntentMatch) -> PolicyRouterResult: """ [AC-IDMP-16] Handle low confidence by falling back to micro_flow or fixed. """ if intent_match.flow_id: return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_match.intent_name, confidence=intent_match.confidence, fallback_reason_code="low_confidence", target_flow_id=intent_match.flow_id, ) if intent_match.fixed_reply: return PolicyRouterResult( mode=ExecutionMode.FIXED, intent=intent_match.intent_name, confidence=intent_match.confidence, fallback_reason_code="low_confidence", fixed_reply=intent_match.fixed_reply, ) return PolicyRouterResult( mode=ExecutionMode.FIXED, fallback_reason_code="low_confidence_no_flow", ) def get_active_high_risk_set(self) -> list[HighRiskScenario]: """[AC-IDMP-20] Get active high-risk scenario set.""" return self._high_risk_scenarios def route_with_high_risk_check( self, user_message: str, high_risk_check_result: "HighRiskCheckResult | None", session_mode: str = "BOT_ACTIVE", feature_flags: FeatureFlags | None = None, intent_match: IntentMatch | None = None, intent_hint: "IntentHintOutput | None" = None, context: dict[str, Any] | None = None, ) -> PolicyRouterResult: """ [AC-IDMP-05, AC-IDMP-20] Route with high_risk_check result. 高风险优先于普通意图路由: 1. 如果 high_risk_check 匹配,直接返回高风险路由结果 2. 否则继续正常的路由决策 Args: user_message: User input message high_risk_check_result: Result from high_risk_check tool session_mode: Current session mode (BOT_ACTIVE/HUMAN_ACTIVE) feature_flags: Feature flags for grayscale control intent_match: Intent match result if available intent_hint: Soft signal from intent_hint tool (optional) context: Additional context for decision Returns: PolicyRouterResult with decided mode and metadata """ if high_risk_check_result and high_risk_check_result.matched: logger.info( f"[AC-IDMP-05, AC-IDMP-20] High-risk check matched: " f"scenario={high_risk_check_result.risk_scenario}, " f"rule_id={high_risk_check_result.rule_id}" ) return self._handle_high_risk_check_result( high_risk_check_result, intent_match ) return self.route( user_message=user_message, session_mode=session_mode, feature_flags=feature_flags, intent_match=intent_match, intent_hint=intent_hint, context=context, ) def _handle_high_risk_check_result( self, high_risk_result: "HighRiskCheckResult", intent_match: IntentMatch | None, ) -> PolicyRouterResult: """ [AC-IDMP-05] Handle high_risk_check result. 高风险检测结果优先于普通意图路由。 """ recommended_mode = high_risk_result.recommended_mode or ExecutionMode.MICRO_FLOW risk_scenario = high_risk_result.risk_scenario if recommended_mode == ExecutionMode.TRANSFER: transfer_msg = "正在为您转接人工客服..." if risk_scenario: if risk_scenario == HighRiskScenario.COMPLAINT_ESCALATION: transfer_msg = "检测到您可能需要投诉处理,正在为您转接人工客服..." elif risk_scenario == HighRiskScenario.REFUND: transfer_msg = "您的退款请求需要人工处理,正在为您转接..." return PolicyRouterResult( mode=ExecutionMode.TRANSFER, high_risk_triggered=True, transfer_message=transfer_msg, fallback_reason_code=high_risk_result.rule_id, ) if intent_match and intent_match.flow_id: return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, intent=intent_match.intent_name, confidence=intent_match.confidence, high_risk_triggered=True, target_flow_id=intent_match.flow_id, ) return PolicyRouterResult( mode=ExecutionMode.MICRO_FLOW, high_risk_triggered=True, fallback_reason_code=high_risk_result.rule_id or f"high_risk_{risk_scenario.value if risk_scenario else 'unknown'}", )