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

214 lines
8.7 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 _prefer_fast_local_fallback(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS)
@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 _is_fast_local_planner_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS)
@staticmethod
def _is_fast_local_worker_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS)
@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") -> float:
if not self._prefer_fast_local_fallback(function_name):
return self.request_timeout
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE"
default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS
if self._is_fast_local_planner_function(function_name) 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_openclaw_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 "openclaw" in normalized_base_url
@staticmethod
def _openclaw_agents_timeout_seconds() -> int:
raw = os.getenv(
"OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS",
str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS),
)
try:
configured = int(str(raw).strip())
except Exception:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
if configured <= 0:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
return min(configured, OPENCLAW_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 {
"classify_intent_ai_pmo_kernel",
"classify_intent_complex",
"intent_structured_runtime_failover",
}
env_key = "LLM_INTENT_HTTP_RETRY_BUDGET" if is_intent_function else "LLM_HTTP_STATUS_RETRY_BUDGET"
default_budget = "0" if is_intent_function else "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 _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 in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}:
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)
@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) -> bool:
fn = str(function_name or "").strip().lower()
if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES):
return False
return not self._prefer_fast_local_fallback(function_name)
def _structured_output_required(self, function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", ""))
hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS
return any(hint and hint in fn for hint in hint_pool)