197 lines
8.3 KiB
Python
197 lines
8.3 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
class LLMStructuredCandidatesMixin:
|
||
def _iter_structured_candidates(
|
||
self,
|
||
*,
|
||
message: Any,
|
||
choice: Any,
|
||
response: Any,
|
||
include_reasoning: bool = True,
|
||
) -> 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 include_reasoning:
|
||
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
|
||
|
||
@staticmethod
|
||
def _coerce_number(value: Any) -> Optional[float]:
|
||
if isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return float(text)
|
||
except Exception:
|
||
return None
|
||
|
||
@staticmethod
|
||
def _coerce_int(value: Any) -> Optional[int]:
|
||
number = LLMStructuredCandidatesMixin._coerce_number(value)
|
||
if number is None:
|
||
return None
|
||
try:
|
||
return int(number)
|
||
except Exception:
|
||
return None
|
||
|
||
@staticmethod
|
||
def _coerce_bool(value: Any) -> Optional[bool]:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
return bool(int(value))
|
||
text = str(value or "").strip().lower()
|
||
if not text:
|
||
return None
|
||
if text in BOOL_TRUE_VALUES:
|
||
return True
|
||
if text in {"0", "false", "no", "off", "n"}:
|
||
return False
|
||
return None
|
||
|
||
@classmethod
|
||
def _coerce_intent_structured_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
|
||
normalized = dict(payload)
|
||
confidence = cls._coerce_number(normalized.get("confidence"))
|
||
if confidence is None:
|
||
for alias in ("score", "probability", "confidence_score"):
|
||
confidence = cls._coerce_number(normalized.get(alias))
|
||
if confidence is not None:
|
||
break
|
||
if confidence is not None:
|
||
normalized["confidence"] = confidence
|
||
|
||
reasoning_value = normalized.get("reasoning")
|
||
if isinstance(reasoning_value, list):
|
||
text_items = [str(item).strip() for item in reasoning_value if str(item).strip()]
|
||
normalized["reasoning"] = ";".join(text_items)
|
||
elif isinstance(reasoning_value, dict):
|
||
normalized["reasoning"] = json.dumps(reasoning_value, ensure_ascii=False)
|
||
else:
|
||
reasoning_text = str(reasoning_value or "").strip()
|
||
if reasoning_text:
|
||
normalized["reasoning"] = reasoning_text
|
||
else:
|
||
for alias in ("reason", "rationale", "analysis", "explanation"):
|
||
alias_text = str(normalized.get(alias) or "").strip()
|
||
if alias_text:
|
||
normalized["reasoning"] = alias_text
|
||
break
|
||
|
||
if "requires_confirmation" in normalized:
|
||
bool_value = cls._coerce_bool(normalized.get("requires_confirmation"))
|
||
if bool_value is not None:
|
||
normalized["requires_confirmation"] = bool_value
|
||
|
||
for field in ("project_id", "requested_count"):
|
||
if field in normalized:
|
||
number = cls._coerce_int(normalized.get(field))
|
||
normalized[field] = number if number is not None else None
|
||
|
||
route_hint = str(
|
||
normalized.get("workflow_route") or normalized.get("route") or ""
|
||
).strip().lower()
|
||
if route_hint and "." in route_hint:
|
||
family, action = route_hint.split(".", 1)
|
||
if not str(normalized.get("family") or "").strip():
|
||
normalized["family"] = family
|
||
if not str(normalized.get("action") or "").strip():
|
||
normalized["action"] = action
|
||
return normalized
|
||
|
||
@classmethod
|
||
def _coerce_structured_payload(cls, *, function_name: str, payload: Any) -> Any:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not isinstance(payload, dict):
|
||
return payload
|
||
if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}:
|
||
return cls._coerce_intent_structured_payload(payload)
|
||
return payload
|
||
|
||
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)
|
||
payload = self._coerce_structured_payload(function_name=fn, payload=payload)
|
||
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
|