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