"""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"]