"""函数级模型路由配置服务。""" from __future__ import annotations import json import os from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ....models import AICallLog, Integration from ..model_config import ( VALID_TIER_ALIASES, find_matching_rule, load_model_config, ) from ....utils.timezone import now_cn_naive ALLOWED_TIERS = set(VALID_TIER_ALIASES) PROTECTED_HIGH_FUNCTIONS = { "adaptive_rag_answer_deep", "pmflow_requirement_plan_draft", "pmflow_requirement_plan_round", "project_requirement_extraction", "recommend_assignees_for_task", } DEFAULT_FUNCTIONS = ( "adaptive_rag_answer_deep", "adaptive_rag_answer_normal", "ai_pmo_signal_batch_analysis", "ai_pmo_signal_title_refine", "ai_service_stream_chat", "draft_generator_v1", "feishu_humanized_reply", "pmflow_semantic_context_decision", "rag_db_project_semantic_retrieval", "rag_intent_classification", "signal_planner_analyze", "signal_source_ai_worker_execution", "signal_source_onboarding_plan", ) INTEGRATION_TYPE = "model_routing" PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1") class ModelRoutingService: @staticmethod def _safe_dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): try: # 深拷贝,避免浅拷贝后嵌套 dict 原地修改导致 SQLAlchemy JSON 变更丢失。 return json.loads(json.dumps(value)) except Exception: return dict(value) if isinstance(value, str): try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except Exception: return {} return {} @staticmethod def _normalize_function_name(function_name: str) -> str: fn = str(function_name or "").strip().lower() if not fn: raise ValueError("function_name 不能为空") return fn @staticmethod def _normalize_tier_alias(tier: str) -> str: t = str(tier or "").strip().lower() or "high" if t not in ALLOWED_TIERS: raise ValueError("tier_alias 仅支持 low/high") return t @classmethod def _normalize_override(cls, override: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(override, dict): return None raw_tier = override.get("tier_alias") if raw_tier in (None, ""): raw_tier = override.get("tier") try: tier_alias = cls._normalize_tier_alias(str(raw_tier or "").strip().lower() or "high") except ValueError: return None return { "tier_alias": tier_alias, "enabled": bool(override.get("enabled", True)), "updated_at": str(override.get("updated_at") or ""), "updated_by": override.get("updated_by"), } @classmethod async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: _ = tenant_id runtime_tenant_id = PLATFORM_RUNTIME_TENANT_ID row = ( await db.execute( select(Integration).where( and_( Integration.tenant_id == runtime_tenant_id, Integration.type == INTEGRATION_TYPE, ) ) ) ).scalar_one_or_none() if row is None: row = Integration( tenant_id=runtime_tenant_id, type=INTEGRATION_TYPE, is_active=True, config={"mappings": {}}, ) db.add(row) await db.flush() bundle = cls._safe_dict(row.config) if not isinstance(bundle.get("mappings"), dict): bundle["mappings"] = {} return row, bundle @classmethod def _candidate_functions( cls, *, routing_bundle: dict[str, Any], mappings: dict[str, Any], ) -> list[str]: keys = {cls._normalize_function_name(k) for k in mappings.keys() if str(k).strip()} keys.update(DEFAULT_FUNCTIONS) rules = routing_bundle.get("rules") if isinstance(routing_bundle.get("rules"), list) else [] for row in rules: if not isinstance(row, dict): continue exact = str(row.get("function_exact") or "").strip().lower() if exact: keys.add(exact) prefix = str(row.get("function_prefix") or "").strip().lower() if prefix and prefix not in {"*", "all"}: keys.add(prefix) match_any = row.get("match_any") if isinstance(row.get("match_any"), list) else [] for item in match_any: token = str(item or "").strip().lower() if token: keys.add(token) return sorted(keys) @classmethod async def _load_last_execution( cls, db: AsyncSession, *, tenant_id: int, function_name: str, ) -> dict[str, Any] | None: row = ( await db.execute( select(AICallLog) .where( and_( AICallLog.tenant_id == int(tenant_id), AICallLog.function_name == cls._normalize_function_name(function_name), ) ) .order_by(AICallLog.created_at.desc(), AICallLog.id.desc()) .limit(1) ) ).scalar_one_or_none() if row is None: return None return { "planned_tier": str(getattr(row, "planned_tier", "") or "").strip().lower() or None, "executed_tier": str(getattr(row, "executed_tier", "") or "").strip().lower() or None, "routing_decision": str(getattr(row, "routing_decision", "") or "").strip() or None, "created_at": row.created_at.isoformat() if row.created_at else None, } @classmethod async def _compose_effective_routing( cls, db: AsyncSession, *, tenant_id: int, function_name: str, override: dict[str, Any] | None, routing_bundle: dict[str, Any], ) -> dict[str, Any]: fn = cls._normalize_function_name(function_name) normalized_override = cls._normalize_override(override) source = "override" if normalized_override and normalized_override.get("enabled", True) else "default" tier_alias = ( cls._normalize_tier_alias(str(normalized_override.get("tier_alias") or "")) if normalized_override and normalized_override.get("enabled", True) else "" ) tier_filter = {tier_alias} if tier_alias else None rule = find_matching_rule(fn, tier_filter=tier_filter, bundle=routing_bundle) resolved_tier = cls._normalize_tier_alias( tier_alias or (str(rule.get("tier") or "low").strip().lower() if isinstance(rule, dict) else "low") or "low" ) last_execution = await cls._load_last_execution( db, tenant_id=int(tenant_id), function_name=fn, ) return { "function_name": fn, "tier_alias": resolved_tier, "source": source, "rule_name": (str(rule.get("name") or "").strip() or None) if isinstance(rule, dict) else None, "rule_priority": int(rule.get("priority") or 0) if isinstance(rule, dict) else None, "last_execution": last_execution, "updated_at": str(normalized_override.get("updated_at") or "") if normalized_override else "", "updated_by": normalized_override.get("updated_by") if normalized_override else None, } @classmethod async def get_effective_routing( cls, db: AsyncSession, *, tenant_id: int, function_name: str, ) -> dict[str, Any]: fn = cls._normalize_function_name(function_name) _, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None routing_bundle = load_model_config() return await cls._compose_effective_routing( db, tenant_id=int(tenant_id), function_name=fn, override=override, routing_bundle=routing_bundle, ) @classmethod async def list_effective_routing( cls, db: AsyncSession, *, tenant_id: int, ) -> list[dict[str, Any]]: _, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} routing_bundle = load_model_config() keys = cls._candidate_functions(routing_bundle=routing_bundle, mappings=mappings) items: list[dict[str, Any]] = [] for fn in keys: override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None items.append( await cls._compose_effective_routing( db, tenant_id=int(tenant_id), function_name=fn, override=override, routing_bundle=routing_bundle, ) ) return items @classmethod async def upsert_override( cls, db: AsyncSession, *, tenant_id: int, function_name: str, tier_alias: str, updated_by: int, ) -> dict[str, Any]: fn = cls._normalize_function_name(function_name) normalized_tier = cls._normalize_tier_alias(tier_alias) if fn in PROTECTED_HIGH_FUNCTIONS and normalized_tier != "high": raise ValueError("保护场景不能降级到不满足能力要求的槽位") row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id)) mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} mappings[fn] = { "tier_alias": normalized_tier, "enabled": True, "updated_at": now_cn_naive().isoformat(), "updated_by": int(updated_by), } bundle["mappings"] = mappings row.config = bundle await db.flush() routing_bundle = load_model_config() return await cls._compose_effective_routing( db, tenant_id=int(tenant_id), function_name=fn, override=mappings[fn], routing_bundle=routing_bundle, )