PMFlow/backend/app/services/llm_service/mixin_tier_engine_selection.py

380 lines
14 KiB
Python

from __future__ import annotations
from .shared import *
class LLMTierEngineSelectionMixin:
_PROTECTED_STRUCTURED_FUNCTIONS = frozenset(
{
"pmflow_launch_draft_v1",
"pmflow_requirement_plan_draft",
"pmflow_requirement_plan_round",
"project_requirement_extraction",
"generate_task_breakdown",
"decompose_tasks_from_charter",
"sstc_ai_breakdown",
"pmflow_os_changeset_extraction",
"pmflow_change_set_extraction",
"project_changeset_extraction",
}
)
@staticmethod
def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool:
return (
str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower()
and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower()
and str(a.get("base_url") or "").strip().rstrip("/").lower()
== str(b.get("base_url") or "").strip().rstrip("/").lower()
)
async def _resolve_high_tier_engine_for_escalation(
self,
db: AsyncSession,
*,
tenant_id: int,
primary_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
active_engine = self._resolve_active_engine_from_bundle(bundle)
engines: list[dict[str, Any]] = [
e for e in bundle.get("engines", []) if isinstance(e, dict) and self._engine_enabled(e)
]
routed = self._resolve_tier_engine(
tier="high",
engines=engines,
active_engine=active_engine,
slot_bundle=await self._load_capability_slot_bundle(db),
)
if not routed:
return None
if self._is_same_runtime_engine(routed, primary_engine):
return None
provider = str(routed.get("provider") or "").strip().lower()
api_key = str(routed.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
return None
return routed
def _pick_tier_fallback_engine(
self,
*,
tier: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
for item in engines:
candidate = self._normalize_engine_runtime(item)
provider = str(candidate.get("provider") or "")
api_key = str(candidate.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
continue
if tier == "high" and provider == "ollama":
continue
if str(candidate.get("id") or "") == str(active_engine.get("id") or ""):
continue
_runtime_log(
"LLM路由",
(
"分层路由回退引擎生效:"
f"tier={tier}, decision=tier_fallback_engine"
),
)
return candidate
return None
def _resolve_tier_engine(
self,
*,
tier: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
slot_bundle: dict[str, Any] | None = None,
) -> Optional[dict[str, Any]]:
tier = str(tier or "").strip().lower()
if tier not in {"low", "high"}:
return None
slot_routed = self._resolve_engine_from_slot_binding(
tier=tier,
engines=engines,
slot_bundle=slot_bundle,
)
if slot_routed is not None:
return self._ensure_control_plane_engine(
_merge_engine_routing_meta(
slot_routed,
tier_alias=tier,
planned_tier=tier,
executed_tier=tier,
route_source="slot_binding",
routing_decision="capability_slot",
)
)
tier_defaults = dict(TIER_DEFAULTS.get(tier) or {})
provider_env = str(tier_defaults.get("provider") or "dashscope").strip()
routed: Optional[dict[str, Any]] = None
if routed is None and provider_env:
try:
provider = _normalize_provider(provider_env)
except Exception:
provider = ""
if provider:
for item in engines:
if str(item.get("provider") or "").strip().lower() == provider:
routed = self._normalize_engine_runtime(item)
break
if routed is None:
return None
provider = str(routed.get("provider") or "").strip().lower()
if tier_defaults.get("model"):
routed["model"] = str(tier_defaults.get("model") or routed.get("model") or "").strip()
if tier_defaults.get("base_url"):
routed["base_url"] = str(tier_defaults.get("base_url") or routed.get("base_url") or "").strip().rstrip("/")
routed = _merge_engine_routing_meta(
routed,
tier_alias=tier,
planned_tier=tier,
executed_tier=tier,
route_source="default",
routing_decision="tier_default",
)
if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
_runtime_log(
"LLM路由",
(
"混合路由已命中:"
f"tier={tier}, decision=tier_default"
),
)
return self._ensure_control_plane_engine(routed)
def _resolve_capability_slot_engine(
self,
*,
slot_key: str,
engines: list[dict[str, Any]],
slot_bundle: dict[str, Any] | None,
) -> Optional[dict[str, Any]]:
slot_key = str(slot_key or "").strip().lower()
if slot_key not in CAPABILITY_SLOT_ALIASES:
return None
routed = self._resolve_engine_from_slot_binding(
tier=slot_key,
engines=engines,
slot_bundle=slot_bundle,
)
if routed is None:
return None
return self._ensure_control_plane_engine(
_merge_engine_routing_meta(
routed,
tier_alias=slot_key,
planned_tier=slot_key,
executed_tier=slot_key,
route_source="slot_binding",
routing_decision="capability_slot",
slot_key=slot_key,
slot_version=self._slot_bundle_active_version(slot_bundle),
slot_runtime_source="active_slot",
slot_fallback_index=routed.get("_slot_fallback_index", 0),
slot_degraded=False,
)
)
def _resolve_structured_slot_engine(
self,
*,
routing_meta: dict[str, Any] | None,
engines: list[dict[str, Any]],
slot_bundle: dict[str, Any] | None,
) -> Optional[dict[str, Any]]:
meta = _normalize_routing_meta(routing_meta)
if str(meta.get("response_contract") or "").strip().lower() != "structured":
return None
return self._resolve_capability_slot_engine(
slot_key="structured",
engines=engines,
slot_bundle=slot_bundle,
)
@staticmethod
def _requested_runtime_capability_slot(routing_meta: dict[str, Any] | None) -> str:
meta = _normalize_routing_meta(routing_meta)
if str(meta.get("response_contract") or "").strip().lower() == "structured":
return "structured"
explicit_slot = _optional_tier_alias(
meta.get("slot_key")
or meta.get("planned_tier")
or meta.get("tier_alias")
or meta.get("executed_tier")
)
if explicit_slot in {"long_context", "tool"}:
return explicit_slot
execution_mode = str(meta.get("execution_mode") or "").strip().lower()
if execution_mode in {"local_planner", "local_worker"}:
return "tool"
return ""
def _resolve_requested_capability_slot_engine(
self,
*,
routing_meta: dict[str, Any] | None,
engines: list[dict[str, Any]],
slot_bundle: dict[str, Any] | None,
) -> Optional[dict[str, Any]]:
slot_key = self._requested_runtime_capability_slot(routing_meta)
if slot_key not in {"structured", "long_context", "tool"}:
return None
return self._resolve_capability_slot_engine(
slot_key=slot_key,
engines=engines,
slot_bundle=slot_bundle,
)
def _resolve_engine_from_slot_binding(
self,
*,
tier: str,
engines: list[dict[str, Any]],
slot_bundle: dict[str, Any] | None,
) -> Optional[dict[str, Any]]:
candidates = self._resolve_engines_from_slot_binding(
tier=tier,
engines=engines,
slot_bundle=slot_bundle,
)
return candidates[0] if candidates else None
@staticmethod
def _active_slot_map(slot_bundle: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(slot_bundle, dict):
return {}
active = slot_bundle.get("active") if isinstance(slot_bundle.get("active"), dict) else {}
if isinstance(active.get("slots"), dict):
return active["slots"]
if isinstance(slot_bundle.get("slots"), dict):
return slot_bundle["slots"]
return {}
@staticmethod
def _slot_bundle_active_version(slot_bundle: dict[str, Any] | None) -> str:
if not isinstance(slot_bundle, dict):
return ""
return str(slot_bundle.get("active_version") or slot_bundle.get("version") or "").strip()
def _slot_binding_configured(
self,
*,
slot_bundle: dict[str, Any] | None,
tier: str,
) -> bool:
slot = self._active_slot_map(slot_bundle).get(str(tier or "").strip().lower())
return isinstance(slot, dict) and bool(str(slot.get("primary_engine_id") or "").strip())
def _engine_satisfies_slot_runtime_requirements(
self,
*,
tier: str,
engine: dict[str, Any],
) -> bool:
normalized_tier = str(tier or "").strip().lower()
if normalized_tier not in {"structured", "long_context", "tool"}:
return True
capabilities = self._normalize_engine_capabilities(
engine.get("capabilities"),
provider=str(engine.get("provider") or ""),
model=str(engine.get("model") or ""),
)
if normalized_tier == "structured":
return bool(capabilities.get("structured_output")) and bool(capabilities.get("json_schema"))
if normalized_tier == "tool":
return bool(capabilities.get("tool_calling"))
context_tokens = capabilities.get("max_context_tokens")
try:
max_context_tokens = int(context_tokens) if context_tokens is not None else 0
except (TypeError, ValueError):
max_context_tokens = 0
return bool(capabilities.get("long_context")) and max_context_tokens >= 32768
def _resolve_engines_from_slot_binding(
self,
*,
tier: str,
engines: list[dict[str, Any]],
slot_bundle: dict[str, Any] | None,
) -> list[dict[str, Any]]:
tier = str(tier or "").strip().lower()
slots = self._active_slot_map(slot_bundle)
slot = slots.get(tier)
if not isinstance(slot, dict):
return []
ordered_ids = [str(slot.get("primary_engine_id") or "").strip()]
if isinstance(slot.get("fallback_engine_ids"), list):
ordered_ids.extend(str(item or "").strip() for item in slot.get("fallback_engine_ids", []))
seen: set[str] = set()
candidates: list[dict[str, Any]] = []
for fallback_index, engine_id in enumerate(ordered_ids):
if not engine_id or engine_id in seen:
continue
seen.add(engine_id)
for item in engines:
if str(item.get("id") or "").strip() != engine_id:
continue
candidate = self._normalize_engine_runtime(item)
provider = str(candidate.get("provider") or "").strip().lower()
if provider != "ollama" and not str(candidate.get("api_key") or "").strip():
continue
if not self._engine_satisfies_slot_runtime_requirements(tier=tier, engine=candidate):
required = {
"structured": "structured_output/json_schema",
"long_context": "long_context/max_context_tokens>=32768",
"tool": "tool_calling",
}.get(tier, "slot_capability")
_runtime_log(
"LLM路由",
(
"能力槽运行时因能力不足被跳过:"
f"slot={tier}, engine={engine_id}, required={required}"
),
)
continue
candidate["_slot_fallback_index"] = fallback_index
candidates.append(candidate)
break
return candidates
async def _resolve_engine_for_test(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: Optional[str],
) -> dict[str, Any]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
settings = self._normalize_engine_settings(bundle.get("settings"))
if not engine_id:
engine = self._resolve_active_engine_from_bundle(bundle)
return self._ensure_engine_allowed_for_test(
engine=engine,
settings=settings,
engine_label="当前激活引擎",
)
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
for item in engines:
if str(item.get("id") or "") == str(engine_id):
engine = self._normalize_engine_runtime(item)
return self._ensure_engine_allowed_for_test(
engine=engine,
settings=settings,
engine_label="目标引擎",
)
raise ValueError("engine 不存在")