"""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, }