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

233 lines
9.0 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._contains_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._contains_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 lowered in {
"structured_json_invalid",
"structured_invalid",
"structured_schema_invalid",
"structured_json_not_found",
"structured_empty",
}:
return lowered
if "timeout" in lowered:
return "structured_timeout"
if "status" in lowered or "http" in lowered:
return "structured_http_error"
return "structured_empty"