580 lines
28 KiB
Python
580 lines
28 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")
|
||
DEFAULT_SLOT_PROFILE_BUDGETS: dict[str, dict[str, Any]] = {
|
||
"low": {
|
||
"max_tokens": 2600,
|
||
"max_tokens_min": 300,
|
||
"max_tokens_max": 8000,
|
||
"recommended_min_tokens": 1200,
|
||
"repair_max_tokens": 600,
|
||
"temperature": 0.1,
|
||
"foreground_wait_seconds": 6,
|
||
"provider_timeout_seconds": 60,
|
||
"job_timeout_seconds": 90,
|
||
"max_retries": 0,
|
||
"large_input_policy": "direct_generate",
|
||
"response_mode": "fast",
|
||
"response_format": "text",
|
||
},
|
||
"high": {
|
||
"max_tokens": 6000,
|
||
"max_tokens_min": 800,
|
||
"max_tokens_max": 12000,
|
||
"recommended_min_tokens": 2400,
|
||
"repair_max_tokens": 1200,
|
||
"temperature": 0.1,
|
||
"foreground_wait_seconds": 8,
|
||
"provider_timeout_seconds": 120,
|
||
"job_timeout_seconds": 240,
|
||
"max_retries": 1,
|
||
"large_input_policy": "preprocess_then_generate",
|
||
"response_mode": "complete",
|
||
"response_format": "text",
|
||
},
|
||
"structured": {
|
||
"max_tokens": 8000,
|
||
"max_tokens_min": 700,
|
||
"max_tokens_max": 12000,
|
||
"recommended_min_tokens": 3000,
|
||
"repair_max_tokens": 1800,
|
||
"temperature": 0.0,
|
||
"foreground_wait_seconds": 8,
|
||
"provider_timeout_seconds": 90,
|
||
"job_timeout_seconds": 180,
|
||
"max_retries": 1,
|
||
"large_input_policy": "preprocess_then_generate",
|
||
"response_mode": "fast",
|
||
"response_format": "json_object",
|
||
},
|
||
"long_context": {
|
||
"max_tokens": 8000,
|
||
"max_tokens_min": 700,
|
||
"max_tokens_max": 16000,
|
||
"recommended_min_tokens": 3000,
|
||
"repair_max_tokens": 1800,
|
||
"temperature": 0.1,
|
||
"foreground_wait_seconds": 10,
|
||
"provider_timeout_seconds": 180,
|
||
"job_timeout_seconds": 300,
|
||
"max_retries": 1,
|
||
"large_input_policy": "preprocess_then_generate",
|
||
"response_mode": "complete",
|
||
"response_format": "text",
|
||
},
|
||
"tool": {
|
||
"max_tokens": 3000,
|
||
"max_tokens_min": 300,
|
||
"max_tokens_max": 8000,
|
||
"recommended_min_tokens": 1200,
|
||
"repair_max_tokens": 600,
|
||
"temperature": 0.0,
|
||
"foreground_wait_seconds": 8,
|
||
"provider_timeout_seconds": 90,
|
||
"job_timeout_seconds": 180,
|
||
"max_retries": 0,
|
||
"large_input_policy": "direct_generate",
|
||
"response_mode": "tool",
|
||
"response_format": "json_object",
|
||
},
|
||
"external_query": {
|
||
"max_tokens": 1000,
|
||
"max_tokens_min": 1,
|
||
"max_tokens_max": 4000,
|
||
"recommended_min_tokens": 1,
|
||
"repair_max_tokens": 180,
|
||
"temperature": 0.0,
|
||
"foreground_wait_seconds": 8,
|
||
"provider_timeout_seconds": 120,
|
||
"job_timeout_seconds": 180,
|
||
"max_retries": 0,
|
||
"large_input_policy": "direct_generate",
|
||
"response_mode": "tool",
|
||
"response_format": "external_evidence",
|
||
},
|
||
}
|
||
|
||
|
||
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')}"
|
||
|
||
|
||
def _default_profile_for_scene_function(*, scene_key: str, scene: dict[str, Any], function_name: str) -> dict[str, Any]:
|
||
required_slots = [str(item or "").strip() for item in list(scene.get("required_slots") or []) if str(item or "").strip()]
|
||
default_slot = str(scene.get("default_slot") or scene.get("expected_slot_key") or (required_slots[0] if required_slots else "low")).strip()
|
||
if default_slot == "mixed":
|
||
default_slot = required_slots[0] if required_slots else "low"
|
||
slot_budget = dict(DEFAULT_SLOT_PROFILE_BUDGETS.get(default_slot) or DEFAULT_SLOT_PROFILE_BUDGETS["low"])
|
||
response_contract = "structured" if default_slot == "structured" else str(slot_budget.get("response_format") or "text")
|
||
if str(scene.get("output_contract") or "").lower().find("json") >= 0:
|
||
response_contract = "json"
|
||
return {
|
||
"function_name": function_name,
|
||
"label": function_name,
|
||
"scene_key": scene_key,
|
||
"prompt_key": "",
|
||
"required_slot": default_slot,
|
||
"response_contract": response_contract,
|
||
**slot_budget,
|
||
"retry_policy": {"max_attempts": max(1, int(slot_budget.get("max_retries") or 0) + 1)},
|
||
"health_policy": {"window_hours": 24, "consecutive_failure_threshold": 3, "smoke_required_for_publish": False},
|
||
"cost_policy": {"budget_class": default_slot, "estimated_cost_ceiling_cny": 0.02, "show_cost_warning_above_tokens": int(slot_budget.get("recommended_min_tokens") or 1000) * 2},
|
||
}
|
||
|
||
|
||
@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
|
||
runtime_mode: str
|
||
quality_gate_mode: 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,
|
||
"runtime_mode": self.runtime_mode,
|
||
"quality_gate_mode": self.quality_gate_mode,
|
||
"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."""
|
||
|
||
CONTROLLED_SMOKE_FUNCTIONS = frozenset(
|
||
{
|
||
"pmflow_launch_draft_v1",
|
||
"pmflow_change_review_extraction_v1",
|
||
}
|
||
)
|
||
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
@classmethod
|
||
def smoke_supported(cls, function_name: str) -> bool:
|
||
return str(function_name or "").strip() in cls.CONTROLLED_SMOKE_FUNCTIONS
|
||
|
||
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,
|
||
runtime_mode=normalized.runtime_mode,
|
||
quality_gate_mode=normalized.quality_gate_mode,
|
||
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", "runtime_mode", "quality_gate_mode"):
|
||
if key in payload:
|
||
next_profile[key] = str(payload.get(key) or "").strip() or current.get(key)
|
||
if "required_slot" in payload:
|
||
next_profile["required_slot"] = str(payload.get("required_slot") or "").strip() or current.get("required_slot")
|
||
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(f"函数运行合同草稿存在阻塞项,不能发布:{self._format_blocking_items(blocking)}")
|
||
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(self._default_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。"))
|
||
critical_count = sum(1 for item in items if item.get("severity") == "critical")
|
||
warning_count = sum(1 for item in items if item.get("severity") == "warning")
|
||
severity = "critical" if critical_count else ("warning" if warning_count else "ok")
|
||
return {
|
||
"stage": stage,
|
||
"status": "blocked" if severity == "critical" else ("warning" if severity == "warning" else "passed"),
|
||
"severity": severity,
|
||
"can_publish": critical_count == 0,
|
||
"summary": {
|
||
"critical": critical_count,
|
||
"warning": warning_count,
|
||
"ok": max(0, len(profiles) - critical_count - warning_count),
|
||
},
|
||
"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
|
||
|
||
@classmethod
|
||
def _default_profiles(cls) -> dict[str, dict[str, Any]]:
|
||
profiles = _json_clone(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES)
|
||
for scene_key, scene in AI_SCENE_REGISTRY.items():
|
||
if not isinstance(scene, dict):
|
||
continue
|
||
for function_name in list(scene.get("function_names") or []):
|
||
name = str(function_name or "").strip()
|
||
if not name or name in profiles:
|
||
continue
|
||
profiles[name] = _default_profile_for_scene_function(scene_key=str(scene_key), scene=scene, function_name=name)
|
||
return profiles
|
||
|
||
@classmethod
|
||
def _normalize_bundle(cls, 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"))
|
||
default_profiles = cls._default_profiles()
|
||
active_profiles = _safe_dict(active.get("profiles")) or _safe_dict(bundle.get("profiles")) or default_profiles
|
||
draft_profiles = _safe_dict(draft.get("profiles")) or active_profiles
|
||
allowed_functions = set(default_profiles)
|
||
active_profiles = {
|
||
str(function_name): _safe_dict(profile)
|
||
for function_name, profile in active_profiles.items()
|
||
if str(function_name) in allowed_functions
|
||
}
|
||
draft_profiles = {
|
||
str(function_name): _safe_dict(profile)
|
||
for function_name, profile in draft_profiles.items()
|
||
if str(function_name) in allowed_functions
|
||
}
|
||
for function_name, profile in default_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 [],
|
||
}
|
||
|
||
@classmethod
|
||
def _normalize_profile(cls, *, function_name: str, raw: dict[str, Any]) -> AIFunctionRuntimeProfile:
|
||
profile = {**_safe_dict(cls._default_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 > 300:
|
||
raise ValueError("foreground_wait_seconds 必须在 3-300 秒之间")
|
||
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")
|
||
runtime_mode = str(profile.get("runtime_mode") or "active").strip()
|
||
quality_gate_mode = str(profile.get("quality_gate_mode") or "strict").strip()
|
||
if runtime_mode not in {"active", "shadow", "bypass", "disabled"}:
|
||
raise ValueError("runtime_mode 仅支持 active / shadow / bypass / disabled")
|
||
if quality_gate_mode not in {"strict", "relaxed", "shadow", "off"}:
|
||
raise ValueError("quality_gate_mode 仅支持 strict / relaxed / shadow / off")
|
||
scene_key = str(profile.get("scene_key") or "").strip()
|
||
scene = _safe_dict(AI_SCENE_REGISTRY.get(scene_key))
|
||
required_slot = str(profile.get("required_slot") or "").strip()
|
||
allowed_slots = {str(item or "").strip() for item in list(scene.get("allowed_slots") or scene.get("required_slots") or []) if str(item or "").strip()}
|
||
if allowed_slots and required_slot not in allowed_slots:
|
||
raise ValueError("required_slot 不满足业务场景允许的能力槽")
|
||
return AIFunctionRuntimeProfile(
|
||
function_name=function_name,
|
||
scene_key=scene_key,
|
||
prompt_key=str(profile.get("prompt_key") or "").strip(),
|
||
required_slot=required_slot,
|
||
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(),
|
||
runtime_mode=runtime_mode,
|
||
quality_gate_mode=quality_gate_mode,
|
||
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,
|
||
"title": function_name,
|
||
"message": message,
|
||
}
|
||
|
||
@staticmethod
|
||
def _format_blocking_items(items: list[dict[str, Any]]) -> str:
|
||
if not items:
|
||
return "无明细"
|
||
chunks: list[str] = []
|
||
for item in items[:5]:
|
||
function_name = str(item.get("function_name") or item.get("title") or "unknown_function")
|
||
code = str(item.get("code") or "unknown")
|
||
message = str(item.get("message") or "存在阻塞项")
|
||
chunks.append(f"{function_name} [{code}] {message}")
|
||
if len(items) > 5:
|
||
chunks.append(f"另有 {len(items) - 5} 项")
|
||
return ";".join(chunks)
|
||
|
||
|
||
__all__ = ["AIFunctionRuntimeProfile", "AIFunctionRuntimeProfileService"]
|