242 lines
9.7 KiB
Python
242 lines
9.7 KiB
Python
"""AI PMO kernel intent rules mixin."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from . import ai_pmo_kernel_common as _common
|
||
|
||
for _name, _value in vars(_common).items():
|
||
if not _name.startswith("__"):
|
||
globals()[_name] = _value
|
||
|
||
|
||
class KernelIntentRulesMixin:
|
||
@staticmethod
|
||
def _intent_llm_enabled() -> bool:
|
||
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
|
||
|
||
def _should_invoke_llm_last_resort(self, *, rule_intent: CanonicalIntent) -> bool:
|
||
"""
|
||
LLM 仅作为末位兜底:
|
||
- 规则默认查询兜底(default_query_fallback)可升级到 LLM
|
||
- 规则低置信度可升级到 LLM
|
||
- 其余高置信规则结果直接执行,不再触发 LLM
|
||
"""
|
||
if self._is_default_rule_query(rule_intent):
|
||
return True
|
||
if rule_intent.name == INTENT_IGNORE:
|
||
return False
|
||
confidence = float(rule_intent.confidence or 0.0)
|
||
return confidence < _intent_llm_stage2_confidence_threshold()
|
||
|
||
@staticmethod
|
||
def _intent_arbitration_enabled() -> bool:
|
||
return _env_bool("AI_PMO_INTENT_ARBITRATION_ENABLED", True)
|
||
|
||
@staticmethod
|
||
def _intent_conflict_policy() -> str:
|
||
raw = str(os.getenv("AI_PMO_INTENT_CONFLICT_POLICY", "pending_manual") or "").strip().lower()
|
||
return raw if raw in INTENT_CONFLICT_POLICIES else "pending_manual"
|
||
|
||
@staticmethod
|
||
def _copy_intent_with_metadata(intent: CanonicalIntent, **metadata_updates: Any) -> CanonicalIntent:
|
||
metadata = dict(intent.metadata or {})
|
||
metadata.update(metadata_updates)
|
||
return CanonicalIntent(
|
||
intent.name,
|
||
float(intent.confidence or 0.0),
|
||
intent.project_id,
|
||
intent.signal_title,
|
||
intent.signal_summary,
|
||
metadata,
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_default_rule_query(intent: CanonicalIntent) -> bool:
|
||
if intent.name != INTENT_QUERY:
|
||
return False
|
||
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
|
||
return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback"
|
||
|
||
@staticmethod
|
||
def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]:
|
||
if intent.name != INTENT_PROJECT_CREATE:
|
||
return True, {}
|
||
extracted_title = AIStewardService._extract_project_title_from_instruction(message_text)
|
||
has_title = bool(str(extracted_title or "").strip())
|
||
return has_title, {
|
||
"slot_check": "project_title",
|
||
"slot_ok": has_title,
|
||
"extracted_project_title": extracted_title[:50] if extracted_title else "",
|
||
}
|
||
|
||
def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent:
|
||
slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text)
|
||
if slot_ok:
|
||
if slot_meta:
|
||
return self._copy_intent_with_metadata(intent, **slot_meta)
|
||
return intent
|
||
return self._copy_intent_with_metadata(
|
||
intent,
|
||
**slot_meta,
|
||
requires_manual_review=True,
|
||
manual_review_code="slot_incomplete",
|
||
manual_review_reason="关键信息不完整,需人工确认后执行。",
|
||
)
|
||
|
||
def _arbitrate_intent(
|
||
self,
|
||
*,
|
||
rule_intent: CanonicalIntent,
|
||
llm_intent: CanonicalIntent,
|
||
message_text: str,
|
||
) -> tuple[CanonicalIntent, str]:
|
||
if rule_intent.name == llm_intent.name:
|
||
return self._copy_intent_with_metadata(
|
||
llm_intent,
|
||
arbitration="consensus",
|
||
arbitration_rule_intent=rule_intent.name,
|
||
arbitration_llm_intent=llm_intent.name,
|
||
), "llm"
|
||
|
||
policy = self._intent_conflict_policy()
|
||
rule_is_write = rule_intent.name in WRITE_INTENTS
|
||
llm_is_write = llm_intent.name in WRITE_INTENTS
|
||
has_write_conflict = rule_is_write or llm_is_write
|
||
prefer_rule_by_default = (
|
||
not self._is_default_rule_query(rule_intent)
|
||
and (
|
||
rule_intent.confidence >= llm_intent.confidence
|
||
or self._is_default_rule_query(llm_intent)
|
||
)
|
||
)
|
||
|
||
if policy == "prefer_rule":
|
||
chosen = rule_intent
|
||
source = "rule_fallback"
|
||
elif policy == "prefer_llm":
|
||
chosen = llm_intent
|
||
source = "llm"
|
||
else:
|
||
chosen = rule_intent if prefer_rule_by_default else llm_intent
|
||
source = "rule_fallback" if chosen is rule_intent else "llm"
|
||
|
||
if has_write_conflict and policy == "pending_manual":
|
||
chosen = self._copy_intent_with_metadata(
|
||
chosen,
|
||
arbitration="conflict_manual_review",
|
||
arbitration_rule_intent=rule_intent.name,
|
||
arbitration_llm_intent=llm_intent.name,
|
||
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
|
||
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
|
||
requires_manual_review=True,
|
||
manual_review_code="intent_conflict_write",
|
||
manual_review_reason="规则与LLM意图冲突,且涉及写操作,需人工确认后执行。",
|
||
)
|
||
return chosen, source
|
||
|
||
return self._copy_intent_with_metadata(
|
||
chosen,
|
||
arbitration="prefer_rule" if source == "rule_fallback" else "prefer_llm",
|
||
arbitration_rule_intent=rule_intent.name,
|
||
arbitration_llm_intent=llm_intent.name,
|
||
arbitration_rule_confidence=float(rule_intent.confidence or 0.0),
|
||
arbitration_llm_confidence=float(llm_intent.confidence or 0.0),
|
||
), source
|
||
|
||
@classmethod
|
||
def _is_short_confirmation_message(
|
||
cls,
|
||
*,
|
||
message_text: str,
|
||
session: Optional[ConversationSession],
|
||
) -> bool:
|
||
if session is None:
|
||
return False
|
||
if not cls._within_recent_window(
|
||
getattr(session, "last_active_at", None),
|
||
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||
):
|
||
return False
|
||
last_intent = str(getattr(session, "last_intent", "") or "").strip()
|
||
if not last_intent:
|
||
return False
|
||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH:
|
||
return False
|
||
return any(token in normalized for token in SHORT_CONFIRM_HINTS)
|
||
|
||
@staticmethod
|
||
def _build_canonical_intent(
|
||
*,
|
||
intent_name: str,
|
||
message_text: str,
|
||
project_id: Optional[int],
|
||
confidence: float,
|
||
metadata: Optional[dict[str, Any]] = None,
|
||
) -> CanonicalIntent:
|
||
pretty = re.sub(r"\s+", " ", str(message_text or "")).strip()
|
||
extras = dict(metadata or {})
|
||
safe_confidence = max(0.0, min(1.0, float(confidence or 0.0)))
|
||
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||
extras.setdefault("source_request", True)
|
||
return CanonicalIntent(
|
||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||
safe_confidence,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
|
||
pretty[:300],
|
||
extras,
|
||
)
|
||
if intent_name == INTENT_SIGNAL_CREATE:
|
||
return CanonicalIntent(
|
||
INTENT_SIGNAL_CREATE,
|
||
safe_confidence,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新信号"),
|
||
pretty[:300],
|
||
extras,
|
||
)
|
||
if intent_name == INTENT_PROJECT_CREATE:
|
||
return CanonicalIntent(
|
||
INTENT_PROJECT_CREATE,
|
||
safe_confidence,
|
||
project_id,
|
||
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
|
||
pretty[:300],
|
||
extras,
|
||
)
|
||
if intent_name == INTENT_QUERY:
|
||
return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras)
|
||
if intent_name == INTENT_FOLLOWUP_CONFIRM:
|
||
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, safe_confidence, project_id, "", "", extras)
|
||
if intent_name == INTENT_FOLLOWUP_CONTINUE:
|
||
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, safe_confidence, project_id, "", "", extras)
|
||
return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"})
|
||
|
||
def _build_short_confirm_intent(
|
||
self,
|
||
*,
|
||
message_text: str,
|
||
session: ConversationSession,
|
||
world_model: dict[str, Any],
|
||
) -> CanonicalIntent:
|
||
inherited_name = str(getattr(session, "last_intent", "") or "").strip()
|
||
if inherited_name not in INTENT_CANDIDATES:
|
||
inherited_name = INTENT_QUERY
|
||
inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id"))
|
||
if inherited_project_id is not None and int(inherited_project_id) <= 0:
|
||
inherited_project_id = None
|
||
inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text
|
||
return self._build_canonical_intent(
|
||
intent_name=inherited_name,
|
||
message_text=inherited_text,
|
||
project_id=inherited_project_id,
|
||
confidence=0.86,
|
||
metadata={
|
||
"context_source": "session_inherited",
|
||
"inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(),
|
||
},
|
||
)
|