264 lines
8.8 KiB
Python
264 lines
8.8 KiB
Python
"""Shared decision kernel for dual-agent routing and guardrails."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Iterable, Sequence
|
||
|
||
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
|
||
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
|
||
INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
|
||
|
||
BACKGROUND_MARKERS = ("因为", "所以", "用来", "为了", "方便", "希望", "背景", "场景")
|
||
MULTI_SEGMENT_MARKERS = (",", ",", "、", ";", ";", "并且", "同时", "然后")
|
||
INTENT_GROUP_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||
"project": ("项目", "立项", "创建项目", "新增项目", "新建项目"),
|
||
"signal": ("信号", "信号源", "接入", "onboard", "source"),
|
||
"task": ("任务", "分配", "指派", "执行", "部署"),
|
||
"change": ("修改", "更新", "改为", "改成", "撤销", "回滚", "归档"),
|
||
}
|
||
|
||
CORRECTION_ACTION_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("undo_last", ("撤销", "撤回", "undo", "作废上一步", "取消上一步")),
|
||
("rollback", ("回滚", "恢复到", "还原", "回退")),
|
||
("replace_target", ("改为", "改成", "替换为", "不是", "不对", "纠正")),
|
||
("reroute", ("重新识别", "重新判断", "重新路由", "再判断一次")),
|
||
)
|
||
|
||
|
||
def _env_float(name: str, default: float) -> float:
|
||
raw = os.getenv(name)
|
||
if raw is None:
|
||
return float(default)
|
||
try:
|
||
value = float(str(raw).strip())
|
||
except Exception:
|
||
return float(default)
|
||
return float(default) if value < 0 else value
|
||
|
||
|
||
def _env_bool(name: str, default: bool = False) -> bool:
|
||
raw = os.getenv(name)
|
||
if raw is None:
|
||
return default
|
||
return str(raw).strip().lower() in BOOL_TRUE_VALUES
|
||
|
||
|
||
def _normalize_text(text: str) -> str:
|
||
return re.sub(r"\s+", "", str(text or "")).lower()
|
||
|
||
|
||
def _normalize_name(name: str) -> str:
|
||
return re.sub(r"[\s_\-]+", "", str(name or "")).lower()
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class IntentRouteDecision:
|
||
function_name: str
|
||
tier: str
|
||
is_complex: bool
|
||
reasons: list[str]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class GateDecision:
|
||
action: str
|
||
gate_code: str
|
||
reason: str
|
||
auto_execute: bool
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class CorrectionDecision:
|
||
action: str
|
||
confidence: float
|
||
matched_keywords: list[str]
|
||
|
||
|
||
def _count_intent_groups(text: str) -> int:
|
||
normalized = _normalize_text(text)
|
||
hits = 0
|
||
for tokens in INTENT_GROUP_KEYWORDS.values():
|
||
if any(_normalize_name(token) in normalized for token in tokens):
|
||
hits += 1
|
||
return hits
|
||
|
||
|
||
def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]:
|
||
normalized = _normalize_text(text)
|
||
if not normalized:
|
||
return False, []
|
||
|
||
reasons: list[str] = []
|
||
length_threshold = int(_env_float("AGENT_COMPLEX_LENGTH_THRESHOLD", 20))
|
||
if len(normalized) > length_threshold:
|
||
reasons.append("length_gt_threshold")
|
||
|
||
has_multi_segment = any(marker in text for marker in MULTI_SEGMENT_MARKERS)
|
||
if has_multi_segment:
|
||
reasons.append("multi_segment")
|
||
|
||
has_background = any(marker in text for marker in BACKGROUND_MARKERS)
|
||
if has_background:
|
||
reasons.append("has_background")
|
||
|
||
intent_group_hits = _count_intent_groups(text)
|
||
if intent_group_hits >= 3 or (
|
||
intent_group_hits >= 2
|
||
and (
|
||
len(normalized) > max(14, length_threshold)
|
||
or has_multi_segment
|
||
or has_background
|
||
)
|
||
):
|
||
reasons.append("multi_intent_groups")
|
||
|
||
for name in project_names or []:
|
||
candidate = _normalize_name(name)
|
||
if len(candidate) >= 2 and candidate in normalized:
|
||
reasons.append("contains_project_name")
|
||
break
|
||
|
||
return bool(reasons), reasons
|
||
|
||
|
||
def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision:
|
||
is_complex, reasons = is_complex_instruction(text, project_names=project_names)
|
||
if is_complex:
|
||
return IntentRouteDecision(
|
||
function_name=INTENT_FUNCTION_COMPLEX,
|
||
tier="high",
|
||
is_complex=True,
|
||
reasons=reasons,
|
||
)
|
||
return IntentRouteDecision(
|
||
function_name=INTENT_FUNCTION_SIMPLE,
|
||
tier="low",
|
||
is_complex=False,
|
||
reasons=["default_simple"],
|
||
)
|
||
|
||
|
||
def resolve_correction(text: str) -> CorrectionDecision:
|
||
normalized = _normalize_text(text)
|
||
if not normalized:
|
||
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
|
||
|
||
for action, tokens in CORRECTION_ACTION_PATTERNS:
|
||
matched = [token for token in tokens if _normalize_name(token) in normalized]
|
||
if matched:
|
||
confidence = 0.9 if len(matched) >= 2 else 0.78
|
||
return CorrectionDecision(
|
||
action=action,
|
||
confidence=float(min(1.0, confidence)),
|
||
matched_keywords=matched,
|
||
)
|
||
return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[])
|
||
|
||
|
||
def gate_execution(
|
||
*,
|
||
confidence: float,
|
||
risk_level: str = "medium",
|
||
correction_action: str = "none",
|
||
force_manual: bool = False,
|
||
force_manual_code: str = "manual_review_required",
|
||
force_manual_reason: str = "",
|
||
) -> GateDecision:
|
||
auto_threshold = _env_float("AGENT_GATE_AUTO_THRESHOLD", 0.82)
|
||
review_threshold = _env_float("AGENT_GATE_REVIEW_THRESHOLD", 0.60)
|
||
strict_high_risk = _env_bool("AGENT_GATE_STRICT_HIGH_RISK", True)
|
||
require_review_below_auto = _env_bool("AGENT_GATE_REVIEW_BELOW_AUTO", False)
|
||
normalized_risk = str(risk_level or "medium").strip().lower()
|
||
safe_confidence = max(0.0, min(1.0, float(confidence or 0.0)))
|
||
|
||
if force_manual:
|
||
return GateDecision(
|
||
action="pending_manual",
|
||
gate_code=str(force_manual_code or "manual_review_required").strip() or "manual_review_required",
|
||
reason=str(force_manual_reason or "命中人工复核策略,需人工确认后执行。").strip(),
|
||
auto_execute=False,
|
||
)
|
||
|
||
if correction_action in {"undo_last", "rollback", "replace_target", "reroute"}:
|
||
return GateDecision(
|
||
action="pending_manual",
|
||
gate_code="correction_review",
|
||
reason="检测到修正/回滚语义,需人工确认后执行。",
|
||
auto_execute=False,
|
||
)
|
||
|
||
if safe_confidence < review_threshold:
|
||
return GateDecision(
|
||
action="reject",
|
||
gate_code="confidence_too_low",
|
||
reason="置信度低于安全阈值,已阻断自动执行。",
|
||
auto_execute=False,
|
||
)
|
||
|
||
if normalized_risk in {"high", "critical"} and strict_high_risk and safe_confidence < max(auto_threshold, 0.9):
|
||
return GateDecision(
|
||
action="pending_manual",
|
||
gate_code="high_risk_manual_review",
|
||
reason="高风险写操作需要更高置信度,当前进入待人工确认。",
|
||
auto_execute=False,
|
||
)
|
||
|
||
if safe_confidence < auto_threshold and require_review_below_auto:
|
||
return GateDecision(
|
||
action="pending_manual",
|
||
gate_code="confidence_review_required",
|
||
reason="置信度未达自动执行阈值,进入待人工确认。",
|
||
auto_execute=False,
|
||
)
|
||
|
||
return GateDecision(
|
||
action="auto_execute",
|
||
gate_code="auto_execute_allowed",
|
||
reason="置信度达标,允许自动执行。",
|
||
auto_execute=True,
|
||
)
|
||
|
||
|
||
def build_decision_trace(
|
||
*,
|
||
intent: str,
|
||
confidence: float,
|
||
route_tier: str,
|
||
route_function: str,
|
||
gate_action: str,
|
||
gate_code: str,
|
||
correction_action: str = "none",
|
||
correction_keywords: Iterable[str] | None = None,
|
||
complexity_reasons: Iterable[str] | None = None,
|
||
) -> dict[str, object]:
|
||
return {
|
||
"intent": str(intent or "").strip(),
|
||
"confidence": round(max(0.0, min(1.0, float(confidence or 0.0))), 4),
|
||
"route_tier": str(route_tier or "").strip(),
|
||
"route_function": str(route_function or "").strip(),
|
||
"gate_action": str(gate_action or "").strip(),
|
||
"gate_code": str(gate_code or "").strip(),
|
||
"correction_action": str(correction_action or "none").strip(),
|
||
"correction_keywords": [str(token) for token in (correction_keywords or []) if str(token).strip()],
|
||
"complexity_reasons": [str(token) for token in (complexity_reasons or []) if str(token).strip()],
|
||
}
|
||
|
||
|
||
def compute_capability_score(
|
||
*,
|
||
intent_accuracy: float,
|
||
gate_precision: float,
|
||
exec_success: float,
|
||
correction_success: float,
|
||
) -> float:
|
||
score = (
|
||
0.30 * max(0.0, min(1.0, float(intent_accuracy or 0.0)))
|
||
+ 0.30 * max(0.0, min(1.0, float(gate_precision or 0.0)))
|
||
+ 0.25 * max(0.0, min(1.0, float(exec_success or 0.0)))
|
||
+ 0.15 * max(0.0, min(1.0, float(correction_success or 0.0)))
|
||
)
|
||
return round(score, 4)
|