260 lines
12 KiB
Python
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
|
|
|