PMFlow/backend/app/services/llm_service/mixin_guardrails_prompt.py

263 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from .shared import *
class LLMGuardrailsPromptMixin:
@staticmethod
def _requires_ddl_api_guardrail(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
return fn in DDL_API_GUARDRAIL_FUNCTION_EXACT
@classmethod
def _inject_function_guardrails(cls, *, system_prompt: str, function_name: str) -> str:
base_prompt = str(system_prompt or "").strip()
if not cls._requires_ddl_api_guardrail(function_name):
return base_prompt
extra = str(DDL_API_GUARDRAIL_SYSTEM_PROMPT or "").strip()
if not extra:
return base_prompt
if extra in base_prompt:
return base_prompt
if not base_prompt:
return extra
return f"{base_prompt}\n\n{extra}"
@classmethod
def _merge_prompt_with_profile(
cls,
*,
prompt: str,
prompt_profile: dict[str, str],
function_name: str = "",
) -> str:
system_prompt = cls._inject_function_guardrails(
system_prompt=str(prompt_profile.get("system_prompt") or "").strip(),
function_name=function_name,
)
if not system_prompt:
return prompt
role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown"
return f"[system_role:{role_name}]\n{system_prompt}\n\n{prompt}"
def _direct_emergency_enabled(self) -> bool:
return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False)
@staticmethod
def _should_raise_model_error(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
if fn in STRICT_MODEL_ERROR_FUNCTION_EXACT:
return True
return any(prefix and fn.startswith(prefix) for prefix in STRICT_MODEL_ERROR_FUNCTION_PREFIXES)
@staticmethod
def _build_model_error_message(
*,
function_name: str,
routing_meta: dict[str, Any] | None = None,
error: Exception,
) -> str:
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
if len(clean_error) > 160:
clean_error = f"{clean_error[:160]}..."
fn_text = str(function_name or "llm_generate").strip() or "llm_generate"
meta = _normalize_routing_meta(routing_meta)
if not clean_error:
clean_error = "upstream_empty_or_invalid_response"
route_bits = _routing_meta_log_text(meta)
if route_bits:
return f"模型调用失败:function={fn_text}, {route_bits}, reason={clean_error}"
return f"模型调用失败:function={fn_text}, reason={clean_error}"
@staticmethod
def _structured_cache_enabled(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return fn in {
"signal_source_onboarding_plan",
}
def _request_timeout_for_function(
self,
function_name: str,
*,
response_mode: str = "complete",
routing_meta: dict[str, Any] | None = None,
) -> float:
meta = _normalize_routing_meta(routing_meta)
explicit_timeout = meta.get("provider_timeout_seconds")
if explicit_timeout is not None:
try:
configured_timeout = float(explicit_timeout)
except (TypeError, ValueError):
configured_timeout = 0.0
if configured_timeout > 0:
return min(max(configured_timeout, 1.0), 600.0)
if meta.get("fallback_policy") != "fast_local":
return self.request_timeout
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE"
default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS
if meta.get("execution_mode") == "local_planner" and str(response_mode or "").strip().lower() == "fast":
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST"
default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS
try:
configured = float(os.getenv(env_key, str(default_timeout)))
except Exception:
configured = default_timeout
if configured <= 0:
return self.request_timeout
return min(self.request_timeout, configured)
@staticmethod
def _is_control_plane_gateway_request(*, provider_name: str, base_url: str) -> bool:
if str(provider_name or "").strip().lower() != "openai":
return False
normalized_base_url = str(base_url or "").strip().lower()
if not normalized_base_url:
return False
return "control-plane" in normalized_base_url or "control_plane" in normalized_base_url
@staticmethod
def _control_plane_agents_timeout_seconds() -> int:
raw = os.getenv(
"CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS",
str(CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS),
)
try:
configured = int(str(raw).strip())
except Exception:
configured = CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS
if configured <= 0:
configured = CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS
return min(configured, CONTROL_PLANE_AGENTS_TIMEOUT_SECONDS_MAX)
@staticmethod
def _http_status_retry_budget_for_function(function_name: str) -> int:
fn = str(function_name or "").strip().lower()
is_intent_function = fn in {
"pmflow_semantic_context_decision",
"intent_structured_runtime_failover",
}
is_deep_query_function = fn == "adaptive_rag_answer_deep"
if is_intent_function:
env_key = "LLM_INTENT_HTTP_RETRY_BUDGET"
default_budget = "3"
elif is_deep_query_function:
env_key = "LLM_DEEP_QUERY_HTTP_RETRY_BUDGET"
default_budget = "3"
else:
env_key = "LLM_HTTP_STATUS_RETRY_BUDGET"
default_budget = "2"
raw = str(os.getenv(env_key, default_budget) or default_budget).strip()
try:
parsed = int(raw)
except Exception:
parsed = int(default_budget)
return max(0, min(parsed, 3))
@staticmethod
def _max_retries_from_routing_meta(routing_meta: dict[str, Any] | None) -> int | None:
meta = _normalize_routing_meta(routing_meta)
raw = meta.get("max_retries")
if raw is None:
return None
try:
parsed = int(raw)
except Exception:
return None
return max(0, min(parsed, 3))
@staticmethod
def _empty_chat_retry_budget(*, function_name: str, current_max_tokens: int) -> int:
fn = str(function_name or "").strip().lower()
current = max(int(current_max_tokens or 0), 1)
if current >= 2800:
return 0
if fn == "pmflow_semantic_context_decision":
raw_budget = str(os.getenv("LLM_INTENT_EMPTY_RETRY_BUDGET", "2") or "2").strip()
try:
parsed_budget = int(raw_budget)
except Exception:
parsed_budget = 2
return max(0, min(parsed_budget, 3))
if fn.startswith("adaptive_rag_answer_"):
# RAG 问答在 GLM 系列上经常先返回 reasoning_content,需至少升档两次才能拿到最终 content。
return 2
if any(token in fn for token in ("qa", "query", "reply")):
return 1
return 1
@staticmethod
def _should_retry_empty_chat_response(
*,
structured_output: bool,
message: Any,
choice: Any,
current_max_tokens: int,
) -> bool:
if int(current_max_tokens or 0) >= 2800:
return False
finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else ""
if finish_reason not in {"length", "max_tokens"}:
return False
reasoning_text = ""
if isinstance(message, dict):
reasoning_text = str(message.get("reasoning_content") or message.get("reasoning") or "").strip()
if not reasoning_text and isinstance(choice, dict):
reasoning_text = str(choice.get("reasoning_content") or choice.get("reasoning") or "").strip()
return bool(reasoning_text)
def _should_retry_truncated_structured_response(
self,
*,
function_name: str,
response_text: str,
routing_meta: dict[str, Any] | None,
choice: Any,
current_max_tokens: int,
) -> bool:
if int(current_max_tokens or 0) >= 3000:
return False
if not str(response_text or "").strip():
return False
finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else ""
if finish_reason not in {"length", "max_tokens"}:
return False
return self._call_is_invalid_structured_output(
function_name=function_name,
response_text=response_text,
routing_meta=routing_meta,
)
@staticmethod
def _next_retry_max_tokens_for_empty_chat(current_max_tokens: int) -> int:
current = max(int(current_max_tokens or 0), 1)
grown = max(current + 512, int(current * 1.8))
return min(grown, 3000)
def _use_provider_circuit_breaker(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> bool:
fn = str(function_name or "").strip().lower()
if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES):
return False
meta = _normalize_routing_meta(routing_meta)
if bool(meta.get("direct_operation")) and str(meta.get("scene_key") or "") == "platform_ai_ops":
return False
return meta.get("fallback_policy") != "fast_local"
def _structured_output_required(
self,
function_name: str,
routing_meta: dict[str, Any] | None = None,
) -> bool:
meta = _normalize_routing_meta(routing_meta)
if meta.get("response_contract") == "structured":
return True
if meta.get("response_contract") == "freeform":
return False
fn = str(function_name or "").strip().lower()
if not fn:
return False
return fn in STRUCTURED_OBJECT_SCHEMAS