PMFlow/backend/app/services/ai_scene_runtime_policy.py

168 lines
6.1 KiB
Python

"""Scene-level AI runtime policy.
This module keeps product SLA decisions out of the low-level transport layer.
The transport only consumes explicit routing metadata such as
``provider_timeout_seconds``; scene services decide those values from business
context and input scale.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
POLICY_VERSION = "scene-timeout-v1"
@dataclass(frozen=True)
class SceneRuntimePolicy:
scene_key: str
function_name: str
input_bucket: str
foreground_wait_seconds: int
provider_timeout_seconds: int
job_timeout_seconds: int
max_retries: int
fallback_policy: str
large_input_policy: str
source: str = "default_scene_policy"
version: str = POLICY_VERSION
def routing_meta(self, *, input_tokens_estimate: int, reason: str = "") -> dict[str, Any]:
return {
"scene_key": self.scene_key,
"scene_timeout_policy_id": f"{self.scene_key}:{self.function_name}:{self.input_bucket}",
"scene_timeout_policy_version": self.version,
"input_bucket": self.input_bucket,
"input_tokens_estimate": int(input_tokens_estimate),
"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,
"scene_fallback_policy": self.fallback_policy,
"large_input_policy": self.large_input_policy,
"timeout_policy_source": self.source,
"timeout_policy_reason": reason or self._default_reason(input_tokens_estimate),
}
def _default_reason(self, input_tokens_estimate: int) -> str:
return (
f"{self.scene_key}/{self.function_name} uses {self.input_bucket} policy "
f"for about {int(input_tokens_estimate)} input tokens"
)
class AISceneRuntimePolicyService:
"""Resolve timeout and job policies for AI business scenes."""
PROJECT_LAUNCH_FUNCTION = "pmflow_launch_draft_v1"
@classmethod
def estimate_tokens(cls, text: str) -> int:
value = str(text or "")
if not value:
return 0
# Conservative estimate used only for policy selection and audit.
return max(1, len(value) // 4)
@classmethod
def input_bucket_for_tokens(cls, tokens: int) -> str:
count = max(int(tokens or 0), 0)
if count < 1500:
return "small"
if count <= 8000:
return "normal"
return "large"
@classmethod
def resolve(
cls,
*,
scene_key: str,
function_name: str,
input_text: str,
attachment_count: int = 0,
) -> tuple[SceneRuntimePolicy, int]:
normalized_scene = str(scene_key or "").strip().lower()
normalized_function = str(function_name or "").strip().lower()
input_tokens = cls.estimate_tokens(input_text)
bucket = cls.input_bucket_for_tokens(input_tokens)
if int(attachment_count or 0) > 0 and bucket == "small":
bucket = "normal"
if normalized_scene == "project_launch" and normalized_function == cls.PROJECT_LAUNCH_FUNCTION:
return cls._project_launch_policy(function_name=normalized_function, bucket=bucket), input_tokens
return (
SceneRuntimePolicy(
scene_key=normalized_scene or "unknown",
function_name=normalized_function or "unknown",
input_bucket=bucket,
foreground_wait_seconds=10,
provider_timeout_seconds=60,
job_timeout_seconds=180,
max_retries=1,
fallback_policy="same_slot_fallback",
large_input_policy="preprocess_then_generate",
),
input_tokens,
)
@staticmethod
def _project_launch_policy(*, function_name: str, bucket: str) -> SceneRuntimePolicy:
specs = {
"small": {
"foreground": 8,
"provider": 60,
"job": 180,
"large_input_policy": "direct_generate",
},
"normal": {
"foreground": 10,
"provider": 90,
"job": 180,
"large_input_policy": "direct_generate",
},
"large": {
"foreground": 12,
"provider": 120,
"job": 300,
"large_input_policy": "preprocess_then_generate",
},
}
spec = specs.get(bucket, specs["normal"])
return SceneRuntimePolicy(
scene_key="project_launch",
function_name=function_name,
input_bucket=bucket,
foreground_wait_seconds=spec["foreground"],
provider_timeout_seconds=spec["provider"],
job_timeout_seconds=spec["job"],
max_retries=1,
fallback_policy="same_slot_fallback",
large_input_policy=spec["large_input_policy"],
)
@staticmethod
def timeout_recommendation(*, status: str, routing_meta: dict[str, Any]) -> dict[str, Any] | None:
text = str(status or "").strip().lower()
if text != "structured_timeout":
return None
current_timeout = routing_meta.get("provider_timeout_seconds")
bucket = str(routing_meta.get("input_bucket") or "unknown")
scene_key = str(routing_meta.get("scene_key") or "unknown")
return {
"code": "model_read_timeout",
"label": "模型读超时",
"summary": "上游模型在场景策略规定时间内没有返回可解析结构化结果。",
"recommended_actions": [
"检查同场景 p95/p99 延迟后调整 provider timeout",
"为 structured 槽配置同能力 fallback runtime",
"输入进入 large 桶时先做材料预处理或分段结构化",
],
"current_provider_timeout_seconds": current_timeout,
"input_bucket": bucket,
"scene_key": scene_key,
}