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

97 lines
4.3 KiB
Python

from __future__ import annotations
from .shared import *
class LLMStructuredCandidatesMixin:
def _iter_structured_candidates(
self,
*,
message: Any,
choice: Any,
response: Any,
) -> list[tuple[str, Any]]:
candidates: list[tuple[str, Any]] = []
if isinstance(message, dict):
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
first = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
fn = first.get("function") if isinstance(first, dict) else {}
candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None))
candidates.append(("message.content", message.get("content")))
if isinstance(choice, dict):
candidates.append(("choice.text", choice.get("text")))
if isinstance(response, dict):
candidates.append(("response.output_text", response.get("output_text")))
if isinstance(message, dict):
candidates.append(("message.reasoning_content", message.get("reasoning_content")))
candidates.append(("message.reasoning_content", message.get("reasoning")))
if isinstance(choice, dict):
candidates.append(("choice.reasoning_content", choice.get("reasoning_content")))
candidates.append(("choice.reasoning_content", choice.get("reasoning")))
if isinstance(response, dict):
candidates.append(("response.reasoning_content", response.get("reasoning_content")))
candidates.append(("response.reasoning_content", response.get("reasoning")))
return candidates
@classmethod
def _unwrap_structured_payload_once(cls, payload: Any) -> Any:
if not isinstance(payload, dict):
return payload
for key in ("json", "data", "result", "arguments"):
if key not in payload:
continue
inner = payload.get(key)
if isinstance(inner, str):
candidate = cls._extract_json_candidate(cls._strip_code_fence(inner))
if not candidate:
continue
try:
return json.loads(candidate)
except Exception:
return inner
if isinstance(inner, (dict, list)):
return inner
return payload
@staticmethod
def _matches_expected_type(value: Any, expected: str) -> bool:
if expected == "bool":
return isinstance(value, bool)
if expected == "str":
return isinstance(value, str)
if expected == "list":
return isinstance(value, list)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "number_or_none":
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
if expected == "str_or_none":
return value is None or isinstance(value, str)
if expected == "dict_or_none":
return value is None or isinstance(value, dict)
return True
def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]:
fn = str(function_name or "").strip().lower()
schema = STRUCTURED_OBJECT_SCHEMAS.get(fn)
if schema is None:
return None if isinstance(payload, (dict, list)) else "structured_json_invalid"
if not isinstance(payload, dict):
return "structured_schema_invalid"
required_keys = schema.get("required_keys") or ()
for key in required_keys:
if key not in payload:
return "structured_schema_invalid"
type_checks = schema.get("type_checks") or {}
for key, expected in type_checks.items():
if not self._matches_expected_type(payload.get(key), str(expected)):
return "structured_schema_invalid"
optional_type_checks = schema.get("optional_type_checks") or {}
for key, expected_values in optional_type_checks.items():
if key not in payload:
continue
if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values):
return "structured_schema_invalid"
return None