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

260 lines
12 KiB
Python

"""AI PMO kernel intent llm-arbitration 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 KernelIntentLLMMixin:
async def _resolve_intent_with_llm(
self,
*,
message_text: str,
session: Optional[ConversationSession],
world_model: dict[str, Any],
rule_intent: Optional[CanonicalIntent] = None,
) -> Optional[CanonicalIntent]:
project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {}
project_names = [
str(project_snapshot.get("title") or "").strip(),
str(project_snapshot.get("name") or "").strip(),
]
route_decision = select_intent_route(message_text, project_names=project_names)
correction_decision = kernel_resolve_correction(message_text)
forced_low_tier = _should_force_low_tier_intent_route(
message_text=message_text,
correction_action=correction_decision.action,
)
prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
message_text=str(message_text or "")[:1200],
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None,
world_model_project_id=_safe_int(world_model.get("resolved_project_id")),
candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
candidate_routes_json=json.dumps(
[
ROUTE_QUERY_ANSWER,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_SIGNAL_SOURCE_ONBOARD,
],
ensure_ascii=False,
),
).strip()
total_budget_seconds = _intent_llm_total_budget_seconds()
stage_started_at = time.perf_counter()
async def _invoke_intent_stage(
*,
function_name: str,
route_tier: str,
route_reasons: list[str],
stage_label: str,
timeout_cap_seconds: Optional[float] = None,
) -> Optional[CanonicalIntent]:
llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name)
if timeout_cap_seconds is not None:
llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0))
if llm_timeout_seconds <= 0.0:
return None
logger.info(
"ai_pmo_intent_llm_route",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
route_tier=route_tier,
timeout_seconds=llm_timeout_seconds,
forced_low_tier=forced_low_tier,
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
route_reasons=route_reasons,
total_budget_seconds=total_budget_seconds,
)
try:
extraction = await asyncio.wait_for(
llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=260,
response_mode="fast",
),
timeout=llm_timeout_seconds,
)
except TimeoutError:
logger.warning(
"ai_pmo_intent_llm_timeout",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
timeout_seconds=llm_timeout_seconds,
)
return None
except Exception as exc:
logger.warning(
"ai_pmo_intent_llm_failed",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
error=str(exc),
)
return None
payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None
if payload is None:
logger.warning(
"ai_pmo_intent_llm_empty_payload",
tenant_id=self.tenant_id,
stage=stage_label,
function_name=function_name,
error_code=str(getattr(extraction, "error_code", "") or ""),
provider=str(getattr(extraction, "provider", "") or ""),
model=str(getattr(extraction, "model", "") or ""),
)
return None
structured_route = RouteResolver.from_structured_payload(
payload=payload,
message_text=message_text,
fallback_project_id=_safe_int(world_model.get("resolved_project_id")),
)
intent_name = str(payload.get("intent") or "").strip()
if structured_route is not None:
intent_name = RouteResolver.canonical_intent_for_route(structured_route.key)
if intent_name not in INTENT_CANDIDATES:
return None
confidence = float(payload.get("confidence") or 0.0)
project_id = _safe_int(payload.get("project_id"))
if project_id is not None and int(project_id) <= 0:
project_id = None
if project_id is None:
project_id = _safe_int(world_model.get("resolved_project_id"))
if structured_route is not None and structured_route.project_id is not None:
project_id = structured_route.project_id
reasoning = str(payload.get("reasoning") or "").strip()
decision_trace = build_decision_trace(
intent=intent_name,
confidence=confidence,
route_tier=route_tier,
route_function=function_name,
gate_action="unresolved",
gate_code="intent_only",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=route_reasons,
)
return self._build_canonical_intent(
intent_name=intent_name,
message_text=message_text,
project_id=project_id,
confidence=confidence,
metadata={
"context_source": "llm",
"stage": stage_label,
"reasoning": reasoning[:300],
"llm_function": function_name,
"route_tier": route_tier,
"route_complex": route_tier == "high",
"route_reasons": route_reasons,
"forced_low_tier": forced_low_tier,
"timeout_seconds": llm_timeout_seconds,
"total_budget_seconds": total_budget_seconds,
"correction_action": correction_decision.action,
"correction_keywords": correction_decision.matched_keywords,
"decision_trace": decision_trace,
"workflow_route": structured_route.key if structured_route is not None else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER),
"workflow_family": structured_route.family if structured_route is not None else RouteResolver._split_route_key(
LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
)[0],
"workflow_action": structured_route.action if structured_route is not None else RouteResolver._split_route_key(
LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
)[1],
"requested_count": (
structured_route.requested_count
if structured_route is not None
else RouteResolver.extract_requested_count(message_text=message_text, metadata=payload)
),
"requires_confirmation": (
structured_route.requires_confirmation
if structured_route is not None
else bool(payload.get("requires_confirmation"))
),
},
)
low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])]
if forced_low_tier:
low_stage_reasons.append("correction_forced_low_tier")
low_intent = await _invoke_intent_stage(
function_name=INTENT_LLM_FUNCTION_NAME,
route_tier="low",
route_reasons=low_stage_reasons,
stage_label="stage1_low",
)
if forced_low_tier:
return low_intent
should_escalate, escalate_reasons = _should_escalate_to_high_intent_route(
route_is_complex=bool(route_decision.is_complex),
low_intent=low_intent,
rule_intent=rule_intent,
)
if not should_escalate:
return low_intent
elapsed_seconds = time.perf_counter() - stage_started_at
remaining_budget_seconds = total_budget_seconds - elapsed_seconds
stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds()
if remaining_budget_seconds < stage2_min_remaining_seconds:
logger.info(
"ai_pmo_intent_stage2_skipped",
tenant_id=self.tenant_id,
reason="budget_insufficient",
remaining_budget_seconds=max(0.0, remaining_budget_seconds),
min_remaining_seconds=stage2_min_remaining_seconds,
escalate_reasons=escalate_reasons,
)
if low_intent is not None:
return self._copy_intent_with_metadata(
low_intent,
stage2_skipped=True,
stage2_skip_reason="budget_insufficient",
stage2_reasons=escalate_reasons,
)
return None
high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"]
high_intent = await _invoke_intent_stage(
function_name="classify_intent_complex",
route_tier="high",
route_reasons=high_stage_reasons,
stage_label="stage2_high",
timeout_cap_seconds=remaining_budget_seconds,
)
if high_intent is not None:
if low_intent is not None:
return self._copy_intent_with_metadata(
high_intent,
stage2_promoted=True,
stage1_intent=low_intent.name,
stage1_confidence=float(low_intent.confidence or 0.0),
)
return high_intent
if low_intent is not None:
return self._copy_intent_with_metadata(
low_intent,
stage2_attempted=True,
stage2_failed=True,
stage2_reasons=escalate_reasons,
)
return None