225 lines
8.7 KiB
Python
225 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
|
|
from .shared import *
|
|
|
|
class LLMStructuredExtractObjectMixin:
|
|
@staticmethod
|
|
def _structured_candidate_preference(field: str) -> int:
|
|
normalized = str(field or "").strip().lower()
|
|
if normalized == "message.content":
|
|
return 4
|
|
if normalized == "response.output_text":
|
|
return 3
|
|
if normalized == "choice.text":
|
|
return 2
|
|
if normalized == "tool_calls.function.arguments":
|
|
return 1
|
|
return 0
|
|
|
|
@classmethod
|
|
def _structured_payload_richness(cls, payload: Any) -> int:
|
|
if isinstance(payload, dict):
|
|
score = 0
|
|
for key, value in payload.items():
|
|
score += 1 if str(key or "").strip() else 0
|
|
score += cls._structured_payload_richness(value)
|
|
return score
|
|
if isinstance(payload, list):
|
|
return len(payload) + sum(cls._structured_payload_richness(item) for item in payload)
|
|
if isinstance(payload, str):
|
|
return len(str(payload).strip())
|
|
if isinstance(payload, (int, float)) and not isinstance(payload, bool):
|
|
return 1
|
|
if isinstance(payload, bool):
|
|
return 1
|
|
return 0
|
|
|
|
def _extract_structured_object_from_response(
|
|
self,
|
|
*,
|
|
function_name: str,
|
|
response: Any,
|
|
fallback_text: str,
|
|
provider: str,
|
|
model: str,
|
|
) -> StructuredExtractionResult:
|
|
message = {}
|
|
choice: dict[str, Any] = {}
|
|
if isinstance(response, dict):
|
|
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
|
|
choice = choices[0] if choices and isinstance(choices[0], dict) else {}
|
|
message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
|
|
|
|
last_nonempty_field = ""
|
|
last_nonempty_text = ""
|
|
last_invalid_result: Optional[StructuredExtractionResult] = None
|
|
valid_results: list[StructuredExtractionResult] = []
|
|
for field, raw_value in self._iter_structured_candidates(
|
|
message=message,
|
|
choice=choice,
|
|
response=response,
|
|
include_reasoning=False,
|
|
):
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value))
|
|
normalized = self._strip_code_fence(text)
|
|
if normalized:
|
|
last_nonempty_field = field
|
|
last_nonempty_text = normalized[:4000]
|
|
if not self._looks_like_json_container(normalized):
|
|
continue
|
|
candidate = self._extract_json_candidate(normalized)
|
|
if not candidate:
|
|
continue
|
|
try:
|
|
parsed = json.loads(candidate)
|
|
except Exception:
|
|
parsed = self._parse_loose_structured_candidate(candidate)
|
|
if parsed is None:
|
|
last_invalid_result = StructuredExtractionResult(
|
|
payload=None,
|
|
source_field=field,
|
|
provider=provider,
|
|
model=model,
|
|
error_code="structured_json_invalid",
|
|
raw_text=candidate[:4000],
|
|
validation="invalid",
|
|
)
|
|
continue
|
|
parsed = self._unwrap_structured_payload_once(parsed)
|
|
parsed = self._coerce_structured_payload(
|
|
function_name=function_name,
|
|
payload=parsed,
|
|
)
|
|
validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed)
|
|
if validation_error is not None:
|
|
last_invalid_result = StructuredExtractionResult(
|
|
payload=None,
|
|
source_field=field,
|
|
provider=provider,
|
|
model=model,
|
|
error_code=validation_error,
|
|
raw_text=candidate[:4000],
|
|
validation="invalid",
|
|
)
|
|
continue
|
|
valid_results.append(
|
|
StructuredExtractionResult(
|
|
payload=parsed if isinstance(parsed, dict) else None,
|
|
source_field=field,
|
|
provider=provider,
|
|
model=model,
|
|
raw_text=candidate[:4000],
|
|
validation="ok",
|
|
)
|
|
)
|
|
|
|
fallback_normalized = self._strip_code_fence(fallback_text)
|
|
fallback_candidate = (
|
|
self._extract_json_candidate(fallback_normalized)
|
|
if self._looks_like_json_container(fallback_normalized)
|
|
else ""
|
|
)
|
|
if fallback_candidate:
|
|
parsed: Any = None
|
|
try:
|
|
parsed = json.loads(fallback_candidate)
|
|
except Exception:
|
|
parsed = self._parse_loose_structured_candidate(fallback_candidate)
|
|
if parsed is not None:
|
|
parsed = self._unwrap_structured_payload_once(parsed)
|
|
parsed = self._coerce_structured_payload(
|
|
function_name=function_name,
|
|
payload=parsed,
|
|
)
|
|
validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed)
|
|
if validation_error is None and isinstance(parsed, dict):
|
|
valid_results.append(
|
|
StructuredExtractionResult(
|
|
payload=parsed,
|
|
source_field="generated_text",
|
|
provider=provider,
|
|
model=model,
|
|
raw_text=fallback_candidate[:4000],
|
|
validation="ok",
|
|
)
|
|
)
|
|
elif validation_error is not None:
|
|
last_invalid_result = StructuredExtractionResult(
|
|
payload=None,
|
|
source_field="generated_text",
|
|
provider=provider,
|
|
model=model,
|
|
error_code=validation_error or "structured_schema_invalid",
|
|
raw_text=fallback_candidate[:4000],
|
|
validation="invalid",
|
|
)
|
|
else:
|
|
last_invalid_result = StructuredExtractionResult(
|
|
payload=None,
|
|
source_field="generated_text",
|
|
provider=provider,
|
|
model=model,
|
|
error_code="structured_json_invalid",
|
|
raw_text=fallback_candidate[:4000],
|
|
validation="invalid",
|
|
)
|
|
|
|
if valid_results:
|
|
return max(
|
|
valid_results,
|
|
key=lambda item: (
|
|
self._structured_payload_richness(item.payload),
|
|
self._structured_candidate_preference(item.source_field or ""),
|
|
),
|
|
)
|
|
|
|
if last_invalid_result is not None:
|
|
return last_invalid_result
|
|
|
|
if not last_nonempty_field and str(fallback_text or "").strip():
|
|
last_nonempty_field = "generated_text"
|
|
last_nonempty_text = str(fallback_text or "").strip()[:4000]
|
|
error_code = "structured_json_not_found" if last_nonempty_field else "structured_empty"
|
|
return StructuredExtractionResult(
|
|
payload=None,
|
|
source_field=last_nonempty_field,
|
|
provider=provider,
|
|
model=model,
|
|
error_code=error_code,
|
|
raw_text=last_nonempty_text,
|
|
validation="invalid",
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_loose_structured_candidate(candidate: str) -> Any:
|
|
text = str(candidate or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
parsed = ast.literal_eval(text)
|
|
except Exception:
|
|
return None
|
|
if isinstance(parsed, str):
|
|
try:
|
|
return json.loads(parsed)
|
|
except Exception:
|
|
return None
|
|
if isinstance(parsed, (dict, list)):
|
|
return parsed
|
|
return None
|
|
|
|
@staticmethod
|
|
def _classify_structured_exception(exc: Exception) -> str:
|
|
if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)):
|
|
return "structured_timeout"
|
|
if isinstance(exc, (httpx.HTTPError, httpx.RequestError)):
|
|
return "structured_http_error"
|
|
lowered = str(exc or "").strip().lower()
|
|
if "timeout" in lowered:
|
|
return "structured_timeout"
|
|
if "status" in lowered or "http" in lowered:
|
|
return "structured_http_error"
|
|
return "structured_empty"
|