PMFlow/backend/app/services/llm_service/mixin_prompt_profile_runtim...

327 lines
14 KiB
Python

from __future__ import annotations
from .shared import *
AI_CALL_LOG_RESPONSE_MAX_CHARS = 10_000
AI_CALL_LOG_RESPONSE_TAIL_CHARS = 2_000
AI_CALL_LOG_TRUNCATION_MARKER = "\n...[TRUNCATED]...\n"
class LLMPromptProfileRuntimeMixin:
async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]:
cached = self._prompt_profile_cache.get(int(tenant_id))
now = time.time()
if cached and cached[0] > now:
return dict(cached[1])
role_name = ""
system_prompt = ""
role_key = ""
try:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id),
Integration.type == "ai_prompt_profiles",
)
)
)
).scalar_one_or_none()
bundle = _safe_json(row.config if row else {})
active_key = str(bundle.get("active_role_key") or "").strip()
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
selected = None
for item in roles:
if not isinstance(item, dict):
continue
item_key = str(item.get("role_key") or "").strip()
if item_key == active_key:
selected = item
break
if selected is None:
for item in roles:
if isinstance(item, dict) and bool(item.get("enabled", True)):
selected = item
break
if isinstance(selected, dict):
role_name = str(selected.get("role_name") or "").strip()
system_prompt = str(selected.get("system_prompt") or "").strip()
role_key = str(selected.get("role_key") or "").strip()
except Exception:
role_name = ""
system_prompt = ""
role_key = ""
if len(system_prompt) < 20:
fallback = build_default_role_prompt_bundle()
fallback_key = str(fallback.get("active_role_key") or "").strip()
roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else []
role = None
for item in roles:
if not isinstance(item, dict):
continue
if str(item.get("role_key") or "").strip() == fallback_key:
role = item
break
if role is None and roles:
role = roles[0] if isinstance(roles[0], dict) else None
if isinstance(role, dict):
role_name = str(role.get("role_name") or role_name or "默认角色").strip()
system_prompt = str(role.get("system_prompt") or "").strip()
role_key = str(role.get("role_key") or role_key or fallback_key).strip()
result = {
"role_key": role_key,
"role_name": role_name,
"system_prompt": system_prompt,
}
self._prompt_profile_cache[int(tenant_id)] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result)
return dict(result)
def _build_fallback_engines(self, primary_engine: dict[str, Any]) -> list[dict[str, Any]]:
candidates: list[dict[str, Any]] = []
provider = _normalize_provider(primary_engine.get("provider"))
current_model = str(primary_engine.get("model") or "").strip()
default_model = str(PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip()
if default_model and default_model != current_model:
same_provider = dict(primary_engine)
same_provider["model"] = default_model
candidates.append(self._normalize_engine_runtime(same_provider))
return candidates
def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]:
provider = _normalize_provider(engine.get("provider"))
defaults = PROVIDER_DEFAULTS[provider]
runtime = dict(engine)
runtime["provider"] = provider
runtime["base_url"] = str(runtime.get("base_url") or defaults["base_url"]).strip().rstrip("/")
runtime["model"] = str(runtime.get("model") or defaults["model"]).strip()
runtime["api_key"] = str(runtime.get("api_key") or "").strip()
runtime["name"] = str(runtime.get("name") or defaults["name"]).strip()
runtime["enabled"] = bool(runtime.get("enabled", True))
if not runtime.get("id"):
runtime["id"] = str(uuid4())
return runtime
async def _log_ai_call(
self,
*,
tenant_id: int,
function_name: str,
prompt: str,
response: str,
status: str,
provider: str = "",
model: str = "",
routing_meta: dict[str, Any] | None = None,
user_id: int | None = None,
provider_request_payload: Any = None,
provider_response_payload: Any = None,
provider_attempts: Any = None,
) -> None:
provider_usage = self._extract_provider_usage(provider_response_payload)
tokens_used = int(provider_usage.get("total_tokens") or 0)
if tokens_used <= 0:
tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0)
cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used)
meta = _normalize_routing_meta(routing_meta)
if provider_usage:
meta["provider_usage"] = provider_usage
trace_id = str(meta.get("trace_id") or meta.get("run_id") or "").strip() or f"ai-{uuid4().hex}"
row_payload = {
"tenant_id": tenant_id,
"user_id": int(user_id) if user_id is not None else None,
"function_name": function_name,
"prompt": prompt[:8000],
"response": self._trim_ai_call_response(response),
"tokens_used": tokens_used,
"cost": cost,
"status": status,
"trace_id": trace_id,
"provider": str(provider or meta.get("provider") or "").strip() or None,
"model": str(model or meta.get("model") or "").strip() or None,
"latency_ms": self._safe_int(meta.get("latency_ms") or meta.get("duration_ms")),
"routing_meta": meta or None,
"prompt_key": str(meta.get("prompt_key") or "").strip() or None,
"prompt_release_id": self._safe_int(meta.get("prompt_release_id")),
"prompt_source": str(meta.get("prompt_source") or "").strip() or None,
"provider_request_payload": self._json_safe_payload(provider_request_payload),
"provider_response_payload": self._json_safe_payload(provider_response_payload),
"provider_attempts": self._json_safe_payload(provider_attempts),
"planned_tier": meta.get("planned_tier") or None,
"executed_tier": meta.get("executed_tier") or None,
"routing_decision": meta.get("routing_decision") or None,
"route_source": meta.get("route_source") or None,
"route_rule_name": meta.get("route_rule_name") or None,
"routing_config_version": meta.get("routing_config_version") or None,
"routing_config_updated_at": meta.get("routing_config_updated_at") or None,
}
self._last_ai_call_summary = {
"function_name": function_name,
"status": status,
"planned_tier": meta.get("planned_tier") or None,
"executed_tier": meta.get("executed_tier") or None,
"route_source": meta.get("route_source") or None,
"route_rule_name": meta.get("route_rule_name") or None,
"routing_decision": meta.get("routing_decision") or None,
"routing_config_version": meta.get("routing_config_version") or None,
"slot_key": meta.get("slot_key") or None,
"slot_version": meta.get("slot_version") or None,
"slot_runtime_source": meta.get("slot_runtime_source") or None,
"slot_fallback_index": meta.get("slot_fallback_index"),
"slot_degraded": bool(meta.get("slot_degraded", False)),
"prompt_key": meta.get("prompt_key") or None,
"prompt_release_id": meta.get("prompt_release_id"),
"prompt_source": meta.get("prompt_source") or None,
"prompt_format_error": bool(meta.get("prompt_format_error", False)),
"provider_usage": provider_usage,
}
async with AsyncSessionLocal() as session:
for attempt in range(2):
try:
session.add(AICallLog(**row_payload))
await session.commit()
return
except OperationalError as exc:
await session.rollback()
if "database is locked" not in str(exc).lower() or attempt >= 1:
return
await asyncio.sleep(0.05 * (attempt + 1))
except Exception:
await session.rollback()
return
@staticmethod
def _safe_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
@classmethod
def _json_safe_payload(cls, value: Any) -> Any:
if value is None:
return None
try:
json.dumps(value, ensure_ascii=False)
return value
except Exception:
try:
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
except Exception:
return {"repr": str(value)}
@staticmethod
def _trim_ai_call_response(response: str) -> str:
text = str(response or "")
if len(text) <= AI_CALL_LOG_RESPONSE_MAX_CHARS:
return text
tail_chars = min(AI_CALL_LOG_RESPONSE_TAIL_CHARS, AI_CALL_LOG_RESPONSE_MAX_CHARS // 3)
head_budget = AI_CALL_LOG_RESPONSE_MAX_CHARS - tail_chars - len(AI_CALL_LOG_TRUNCATION_MARKER)
if head_budget <= 0:
return text[:AI_CALL_LOG_RESPONSE_MAX_CHARS]
return f"{text[:head_budget]}{AI_CALL_LOG_TRUNCATION_MARKER}{text[-tail_chars:]}"
@staticmethod
def _estimate_cost(provider: str, model: str, tokens: int) -> float:
_ = model
per_1k = {
"deepseek": 0.0003,
"dashscope": 0.0006,
"openai": 0.0012,
"ollama": 0.0,
}.get(provider, 0.0005)
return round((tokens / 1000.0) * per_1k, 6)
@staticmethod
def _extract_provider_usage(payload: Any) -> dict[str, int]:
if not isinstance(payload, dict):
return {}
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
if not usage:
return {}
result: dict[str, int] = {}
for source_key, target_key in (
("prompt_tokens", "prompt_tokens"),
("completion_tokens", "completion_tokens"),
("total_tokens", "total_tokens"),
("input_tokens", "prompt_tokens"),
("output_tokens", "completion_tokens"),
):
if source_key not in usage:
continue
try:
value = int(usage.get(source_key) or 0)
except (TypeError, ValueError):
continue
if value > 0:
result[target_key] = value
if "total_tokens" not in result and (result.get("prompt_tokens") or result.get("completion_tokens")):
result["total_tokens"] = int(result.get("prompt_tokens") or 0) + int(result.get("completion_tokens") or 0)
return result
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
raw = (text or "").strip()
if raw.startswith("```"):
parts = raw.split("```")
if len(parts) >= 2:
raw = parts[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _parse_json_array(text: str) -> list[dict]:
raw = (text or "").strip()
if raw.startswith("```"):
parts = raw.split("```")
if len(parts) >= 2:
raw = parts[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [item for item in parsed if isinstance(item, dict)]
except Exception:
return []
return []
@staticmethod
def _extract_text_from_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
if isinstance(value, dict):
parts: list[str] = []
for key in ("text", "content", "value", "output_text", "input_text", "arguments", "json"):
if key in value:
parts.append(LLMService._extract_text_from_value(value.get(key)))
return "".join(parts)
if isinstance(value, list):
return "".join(LLMService._extract_text_from_value(item) for item in value)
return str(value)
@staticmethod
def _sanitize_visible_answer(text: str) -> str:
cleaned = str(text or "")
if not cleaned:
return ""
cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.IGNORECASE | re.DOTALL)
return cleaned.strip()