PMFlow/backend/app/services/llm_service/mixin_structured_extract_ob...

179 lines
7.0 KiB
Python

from __future__ import annotations
import ast
from .shared import *
class LLMStructuredExtractObjectMixin:
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
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
return 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):
return StructuredExtractionResult(
payload=parsed,
source_field="generated_text",
provider=provider,
model=model,
raw_text=fallback_candidate[:4000],
validation="ok",
)
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 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"