ai-robot-core/ai-service/app/services/mid/policy_router.py

412 lines
16 KiB
Python

"""
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'}",
)