PMFlow/backend/app/services/model/model_routing/service.py

287 lines
10 KiB
Python

"""函数级模型路由配置服务。"""
from __future__ import annotations
import json
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)
DEFAULT_FUNCTIONS = (
"adaptive_rag_answer_deep",
"adaptive_rag_answer_normal",
"ai_pmo_signal_batch_analysis",
"ai_pmo_signal_title_refine",
"ai_service_stream_chat",
"classify_intent_ai_pmo_kernel",
"classify_intent_complex",
"draft_generator_v1",
"feishu_humanized_reply",
"rag_db_project_semantic_retrieval",
"rag_intent_classification",
"signal_planner_analyze",
"signal_source_ai_worker_execution",
"signal_source_onboarding_plan",
)
INTEGRATION_TYPE = "model_routing"
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]]:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id),
Integration.type == INTEGRATION_TYPE,
)
)
)
).scalar_one_or_none()
if row is None:
row = Integration(
tenant_id=int(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)
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,
)