412 lines
16 KiB
Python
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'}",
|
|||
|
|
)
|