383 lines
19 KiB
Python
383 lines
19 KiB
Python
"""Function-level runtime profiles for protected AI calls."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from sqlalchemy import and_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_function_runtime_profiles import (
|
||
AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
|
||
DEFAULT_AI_FUNCTION_RUNTIME_PROFILES,
|
||
default_runtime_profile_bundle,
|
||
)
|
||
from ..models import Integration
|
||
from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY
|
||
from ..utils.timezone import now_cn_naive
|
||
|
||
PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||
|
||
|
||
def _json_clone(value: Any) -> Any:
|
||
return json.loads(json.dumps(value, ensure_ascii=False))
|
||
|
||
|
||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _release_version() -> str:
|
||
return f"profile-v{now_cn_naive().strftime('%Y%m%d%H%M%S')}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AIFunctionRuntimeProfile:
|
||
function_name: str
|
||
scene_key: str
|
||
prompt_key: str
|
||
required_slot: str
|
||
response_contract: str
|
||
max_tokens: int
|
||
repair_max_tokens: int
|
||
temperature: float
|
||
foreground_wait_seconds: int
|
||
provider_timeout_seconds: int
|
||
job_timeout_seconds: int
|
||
max_retries: int
|
||
large_input_policy: str
|
||
response_mode: str
|
||
response_format: str
|
||
profile_version: str
|
||
profile_source: str
|
||
|
||
def routing_meta(self) -> dict[str, Any]:
|
||
return {
|
||
"function_runtime_profile_version": self.profile_version,
|
||
"function_runtime_profile_source": self.profile_source,
|
||
"function_runtime_max_tokens": self.max_tokens,
|
||
"function_runtime_repair_max_tokens": self.repair_max_tokens,
|
||
"foreground_wait_seconds": self.foreground_wait_seconds,
|
||
"provider_timeout_seconds": self.provider_timeout_seconds,
|
||
"job_timeout_seconds": self.job_timeout_seconds,
|
||
"max_retries": self.max_retries,
|
||
"large_input_policy": self.large_input_policy,
|
||
"timeout_policy_source": "function_runtime_profile",
|
||
"timeout_policy_reason": f"{self.function_name} function runtime profile controls provider/job timeout",
|
||
}
|
||
|
||
|
||
class AIFunctionRuntimeProfileService:
|
||
"""Load, validate, draft and publish function runtime profiles."""
|
||
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
async def resolve_profile(
|
||
self,
|
||
*,
|
||
function_name: str,
|
||
scene_key: str | None = None,
|
||
prompt_key: str | None = None,
|
||
stage: str = "active",
|
||
) -> AIFunctionRuntimeProfile:
|
||
release = await self.get_profiles()
|
||
normalized_stage = "draft" if str(stage or "").strip().lower() == "draft" else "active"
|
||
profiles = release.get(f"{normalized_stage}_profiles") if isinstance(release.get(f"{normalized_stage}_profiles"), dict) else {}
|
||
profile = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name))
|
||
if not profile:
|
||
raise ValueError(f"function runtime profile not found: {function_name}")
|
||
normalized = self._normalize_profile(function_name=function_name, raw=profile)
|
||
if scene_key and normalized.scene_key != str(scene_key).strip():
|
||
raise ValueError(f"function runtime profile scene mismatch: {function_name}")
|
||
if prompt_key and normalized.prompt_key != str(prompt_key).strip():
|
||
raise ValueError(f"function runtime profile prompt mismatch: {function_name}")
|
||
return AIFunctionRuntimeProfile(
|
||
function_name=normalized.function_name,
|
||
scene_key=normalized.scene_key,
|
||
prompt_key=normalized.prompt_key,
|
||
required_slot=normalized.required_slot,
|
||
response_contract=normalized.response_contract,
|
||
max_tokens=normalized.max_tokens,
|
||
repair_max_tokens=normalized.repair_max_tokens,
|
||
temperature=normalized.temperature,
|
||
foreground_wait_seconds=normalized.foreground_wait_seconds,
|
||
provider_timeout_seconds=normalized.provider_timeout_seconds,
|
||
job_timeout_seconds=normalized.job_timeout_seconds,
|
||
max_retries=normalized.max_retries,
|
||
large_input_policy=normalized.large_input_policy,
|
||
response_mode=normalized.response_mode,
|
||
response_format=normalized.response_format,
|
||
profile_version=str(release.get(f"{normalized_stage}_version") or release.get("active_version") or "profile-default-v1"),
|
||
profile_source=normalized_stage,
|
||
)
|
||
|
||
async def get_profiles(self) -> dict[str, Any]:
|
||
bundle = self._normalize_bundle(await self._load_config())
|
||
active_profiles = _safe_dict(_safe_dict(bundle.get("active")).get("profiles"))
|
||
draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
|
||
validation = self.validate_bundle(bundle=bundle, stage="draft")
|
||
return {
|
||
"version": str(bundle.get("active_version") or "profile-default-v1"),
|
||
"active_version": str(bundle.get("active_version") or "profile-default-v1"),
|
||
"draft_version": str(bundle.get("draft_version") or "draft"),
|
||
"published_at": _safe_dict(bundle.get("active")).get("published_at"),
|
||
"published_by": _safe_dict(bundle.get("active")).get("published_by"),
|
||
"updated_at": _safe_dict(bundle.get("draft")).get("updated_at"),
|
||
"updated_by": _safe_dict(bundle.get("draft")).get("updated_by"),
|
||
"dirty": self._bundle_dirty(bundle),
|
||
"validation": validation,
|
||
"history": bundle.get("history") if isinstance(bundle.get("history"), list) else [],
|
||
"draft_last_smoke": _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke")),
|
||
"active_profiles": active_profiles,
|
||
"draft_profiles": draft_profiles,
|
||
"profiles": draft_profiles,
|
||
}
|
||
|
||
async def update_profile(self, *, function_name: str, payload: dict[str, Any], updated_by: int) -> dict[str, Any]:
|
||
integration, bundle = await self._get_or_init_bundle()
|
||
draft = _safe_dict(bundle.get("draft"))
|
||
profiles = _safe_dict(draft.get("profiles"))
|
||
current = _safe_dict(profiles.get(function_name)) or _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name))
|
||
if not current:
|
||
raise ValueError("函数运行合同不存在")
|
||
next_profile = _json_clone(current)
|
||
for key in ("max_tokens", "repair_max_tokens"):
|
||
if key in payload:
|
||
next_profile[key] = int(payload.get(key) or 0)
|
||
for key in ("foreground_wait_seconds", "provider_timeout_seconds", "job_timeout_seconds", "max_retries"):
|
||
if key in payload:
|
||
next_profile[key] = int(payload.get(key) or 0)
|
||
for key in ("temperature",):
|
||
if key in payload:
|
||
next_profile[key] = float(payload.get(key) or 0.0)
|
||
for key in ("response_mode", "large_input_policy"):
|
||
if key in payload:
|
||
next_profile[key] = str(payload.get(key) or "").strip() or current.get(key)
|
||
self._normalize_profile(function_name=function_name, raw=next_profile)
|
||
profiles[function_name] = next_profile
|
||
draft["profiles"] = profiles
|
||
draft["updated_at"] = now_cn_naive().isoformat()
|
||
draft["updated_by"] = int(updated_by)
|
||
last_smoke = _safe_dict(draft.get("last_smoke"))
|
||
last_smoke.pop(function_name, None)
|
||
draft["last_smoke"] = last_smoke
|
||
bundle["draft"] = draft
|
||
bundle["draft_version"] = "draft"
|
||
integration.config = _json_clone(bundle)
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_profiles()
|
||
|
||
async def record_smoke(self, *, function_name: str, status: str, summary: str, checked_by: int | None = None) -> dict[str, Any]:
|
||
integration, bundle = await self._get_or_init_bundle()
|
||
draft = _json_clone(_safe_dict(bundle.get("draft")))
|
||
last_smoke = _json_clone(_safe_dict(draft.get("last_smoke")))
|
||
last_smoke[function_name] = {
|
||
"status": str(status or "unknown"),
|
||
"summary": str(summary or "")[:500],
|
||
"checked_at": now_cn_naive().isoformat(),
|
||
"checked_by": int(checked_by) if checked_by is not None else None,
|
||
}
|
||
draft["last_smoke"] = last_smoke
|
||
bundle["draft"] = draft
|
||
integration.config = _json_clone(bundle)
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_profiles()
|
||
|
||
async def publish_profiles(self, *, published_by: int, summary: str | None = None) -> dict[str, Any]:
|
||
integration, bundle = await self._get_or_init_bundle()
|
||
validation = self.validate_bundle(bundle=bundle, stage="draft")
|
||
blocking = [item for item in validation.get("items", []) if item.get("severity") == "critical"]
|
||
if blocking:
|
||
raise ValueError("函数运行合同草稿存在阻塞项,不能发布")
|
||
version = _release_version()
|
||
now = now_cn_naive().isoformat()
|
||
draft_profiles = _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
|
||
bundle["active"] = {
|
||
"profiles": _json_clone(draft_profiles),
|
||
"published_at": now,
|
||
"published_by": int(published_by),
|
||
}
|
||
bundle["active_version"] = version
|
||
bundle["version"] = version
|
||
history = bundle.get("history") if isinstance(bundle.get("history"), list) else []
|
||
history.insert(
|
||
0,
|
||
{
|
||
"version": version,
|
||
"published_at": now,
|
||
"published_by": int(published_by),
|
||
"summary": str(summary or "发布函数运行合同草稿").strip()[:200],
|
||
"profiles": _json_clone(draft_profiles),
|
||
},
|
||
)
|
||
bundle["history"] = history[:10]
|
||
integration.config = _json_clone(bundle)
|
||
integration.is_active = True
|
||
await self.db.flush()
|
||
return await self.get_profiles()
|
||
|
||
def validate_bundle(self, *, bundle: dict[str, Any], stage: str = "draft") -> dict[str, Any]:
|
||
profiles = _safe_dict(_safe_dict(bundle.get(stage)).get("profiles"))
|
||
last_smoke = _safe_dict(_safe_dict(bundle.get("draft")).get("last_smoke"))
|
||
items: list[dict[str, Any]] = []
|
||
for function_name, raw_profile in profiles.items():
|
||
try:
|
||
profile = self._normalize_profile(function_name=str(function_name), raw=_safe_dict(raw_profile))
|
||
except ValueError as exc:
|
||
items.append(self._validation_item(str(function_name), "critical", "profile_invalid", str(exc)))
|
||
continue
|
||
scene = _safe_dict(AI_SCENE_REGISTRY.get(profile.scene_key))
|
||
if profile.function_name not in set(scene.get("function_names") or []):
|
||
items.append(self._validation_item(function_name, "critical", "scene_function_mismatch", "函数未登记在对应业务场景合同中。"))
|
||
if profile.required_slot not in set(scene.get("required_slots") or []):
|
||
items.append(self._validation_item(function_name, "critical", "slot_mismatch", "函数 required_slot 与业务场景必需槽不匹配。"))
|
||
default_profile = _safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name))
|
||
recommended_min = int(default_profile.get("recommended_min_tokens") or default_profile.get("max_tokens_min") or 0)
|
||
if recommended_min and profile.max_tokens < recommended_min:
|
||
items.append(self._validation_item(function_name, "warning", "token_budget_below_recommendation", f"当前 max_tokens={profile.max_tokens},低于建议值 {recommended_min},长结构化草案可能被截断。"))
|
||
smoke_required = bool(_safe_dict(profile.__dict__.get("health_policy")).get("smoke_required_for_publish")) if isinstance(profile.__dict__.get("health_policy"), dict) else bool(_safe_dict(default_profile.get("health_policy")).get("smoke_required_for_publish"))
|
||
smoke = _safe_dict(last_smoke.get(function_name))
|
||
if stage == "draft" and smoke_required and str(smoke.get("status") or "").lower() != "success":
|
||
items.append(self._validation_item(function_name, "critical", "smoke_required", "发布前必须运行函数运行合同 smoke,并且结果为 success。"))
|
||
severity = "critical" if any(item["severity"] == "critical" for item in items) else ("warning" if items else "ok")
|
||
return {
|
||
"status": "blocked" if severity == "critical" else ("warning" if severity == "warning" else "passed"),
|
||
"severity": severity,
|
||
"items": items,
|
||
}
|
||
|
||
async def _load_config(self) -> dict[str, Any]:
|
||
row = (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
|
||
Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
return _safe_dict(row.config if row is not None else {})
|
||
|
||
async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]:
|
||
row = (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
|
||
Integration.type == AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
row = Integration(
|
||
tenant_id=PLATFORM_RUNTIME_TENANT_ID,
|
||
type=AI_FUNCTION_RUNTIME_PROFILE_INTEGRATION_TYPE,
|
||
config=default_runtime_profile_bundle(),
|
||
is_active=True,
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
bundle = self._normalize_bundle(_safe_dict(row.config))
|
||
return row, bundle
|
||
|
||
@staticmethod
|
||
def _normalize_bundle(raw: dict[str, Any]) -> dict[str, Any]:
|
||
default = default_runtime_profile_bundle()
|
||
bundle = _safe_dict(raw)
|
||
active = _safe_dict(bundle.get("active"))
|
||
draft = _safe_dict(bundle.get("draft"))
|
||
active_profiles = _safe_dict(active.get("profiles")) or _safe_dict(bundle.get("profiles")) or DEFAULT_AI_FUNCTION_RUNTIME_PROFILES
|
||
draft_profiles = _safe_dict(draft.get("profiles")) or active_profiles
|
||
for function_name, profile in DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.items():
|
||
default_profile = _json_clone(profile)
|
||
active_profiles[function_name] = {
|
||
**default_profile,
|
||
**_safe_dict(active_profiles.get(function_name)),
|
||
}
|
||
draft_profiles[function_name] = {
|
||
**default_profile,
|
||
**_safe_dict(active_profiles.get(function_name, profile)),
|
||
**_safe_dict(draft_profiles.get(function_name)),
|
||
}
|
||
return {
|
||
"version": str(bundle.get("active_version") or bundle.get("version") or default["version"]),
|
||
"active_version": str(bundle.get("active_version") or bundle.get("version") or default["active_version"]),
|
||
"draft_version": str(bundle.get("draft_version") or "draft"),
|
||
"active": {**active, "profiles": _json_clone(active_profiles)},
|
||
"draft": {**draft, "profiles": _json_clone(draft_profiles), "last_smoke": _safe_dict(draft.get("last_smoke"))},
|
||
"history": bundle.get("history") if isinstance(bundle.get("history"), list) else [],
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_profile(*, function_name: str, raw: dict[str, Any]) -> AIFunctionRuntimeProfile:
|
||
profile = {**_safe_dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES.get(function_name)), **_safe_dict(raw)}
|
||
min_tokens = int(profile.get("max_tokens_min") or 256)
|
||
max_tokens_limit = int(profile.get("max_tokens_max") or 12000)
|
||
max_tokens = int(profile.get("max_tokens") or 0)
|
||
repair_max_tokens = int(profile.get("repair_max_tokens") or 0)
|
||
if max_tokens < min_tokens or max_tokens > max_tokens_limit:
|
||
raise ValueError(f"max_tokens 必须在 {min_tokens}-{max_tokens_limit} 之间")
|
||
if repair_max_tokens < 180 or repair_max_tokens > max_tokens:
|
||
raise ValueError("repair_max_tokens 必须不小于 180 且不大于 max_tokens")
|
||
temperature = float(profile.get("temperature") or 0.0)
|
||
if temperature < 0 or temperature > 1:
|
||
raise ValueError("temperature 必须在 0-1 之间")
|
||
foreground_wait_seconds = int(profile.get("foreground_wait_seconds") or 0)
|
||
provider_timeout_seconds = int(profile.get("provider_timeout_seconds") or 0)
|
||
job_timeout_seconds = int(profile.get("job_timeout_seconds") or 0)
|
||
max_retries = int(profile.get("max_retries") or 0)
|
||
large_input_policy = str(profile.get("large_input_policy") or "preprocess_then_generate").strip()
|
||
if foreground_wait_seconds < 3 or foreground_wait_seconds > 30:
|
||
raise ValueError("foreground_wait_seconds 必须在 3-30 秒之间")
|
||
if provider_timeout_seconds < 10 or provider_timeout_seconds > 300:
|
||
raise ValueError("provider_timeout_seconds 必须在 10-300 秒之间")
|
||
if job_timeout_seconds < provider_timeout_seconds or job_timeout_seconds > 900:
|
||
raise ValueError("job_timeout_seconds 必须不小于 provider_timeout_seconds 且不大于 900 秒")
|
||
if max_retries < 0 or max_retries > 3:
|
||
raise ValueError("max_retries 必须在 0-3 之间")
|
||
if large_input_policy not in {"direct_generate", "preprocess_then_generate"}:
|
||
raise ValueError("large_input_policy 仅支持 direct_generate / preprocess_then_generate")
|
||
return AIFunctionRuntimeProfile(
|
||
function_name=function_name,
|
||
scene_key=str(profile.get("scene_key") or "").strip(),
|
||
prompt_key=str(profile.get("prompt_key") or "").strip(),
|
||
required_slot=str(profile.get("required_slot") or "").strip(),
|
||
response_contract=str(profile.get("response_contract") or "structured").strip(),
|
||
max_tokens=max_tokens,
|
||
repair_max_tokens=repair_max_tokens,
|
||
temperature=temperature,
|
||
foreground_wait_seconds=foreground_wait_seconds,
|
||
provider_timeout_seconds=provider_timeout_seconds,
|
||
job_timeout_seconds=job_timeout_seconds,
|
||
max_retries=max_retries,
|
||
large_input_policy=large_input_policy,
|
||
response_mode=str(profile.get("response_mode") or "fast").strip(),
|
||
response_format=str(profile.get("response_format") or "json_object").strip(),
|
||
profile_version="",
|
||
profile_source="",
|
||
)
|
||
|
||
@staticmethod
|
||
def _bundle_dirty(bundle: dict[str, Any]) -> bool:
|
||
return _safe_dict(_safe_dict(bundle.get("active")).get("profiles")) != _safe_dict(_safe_dict(bundle.get("draft")).get("profiles"))
|
||
|
||
@staticmethod
|
||
def _validation_item(function_name: str, severity: str, code: str, message: str) -> dict[str, Any]:
|
||
return {
|
||
"function_name": function_name,
|
||
"severity": severity,
|
||
"code": code,
|
||
"message": message,
|
||
}
|
||
|
||
|
||
__all__ = ["AIFunctionRuntimeProfile", "AIFunctionRuntimeProfileService"]
|