PMFlow/backend/app/services/ai_pmo_kernel_mixins_intent...

242 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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