286 lines
10 KiB
Python
286 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",
|
|
"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"
|
|
|
|
|
|
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,
|
|
)
|