187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import *
|
|
|
|
class LLMStructuredParseMixin:
|
|
@staticmethod
|
|
def _extract_json_candidate(text: str) -> str:
|
|
raw = str(text or "").strip()
|
|
if not raw:
|
|
return ""
|
|
if raw.startswith("```"):
|
|
chunks = raw.split("```")
|
|
if len(chunks) >= 2:
|
|
raw = chunks[1].strip()
|
|
if raw.startswith("json"):
|
|
raw = raw[4:].strip()
|
|
if raw.startswith("{") or raw.startswith("["):
|
|
return raw
|
|
|
|
object_candidate = LLMStructuredParseMixin._extract_balanced_json_segment(raw, "{", "}")
|
|
array_candidate = LLMStructuredParseMixin._extract_balanced_json_segment(raw, "[", "]")
|
|
if object_candidate and array_candidate:
|
|
return object_candidate if raw.find("{") <= raw.find("[") else array_candidate
|
|
if object_candidate:
|
|
return object_candidate
|
|
if array_candidate:
|
|
return array_candidate
|
|
return raw
|
|
|
|
@staticmethod
|
|
def _looks_like_json_container(text: str) -> bool:
|
|
raw = str(text or "").strip()
|
|
if not raw:
|
|
return False
|
|
if raw.startswith("{") or raw.startswith("["):
|
|
return True
|
|
return "{" in raw or "[" in raw
|
|
|
|
@staticmethod
|
|
def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str:
|
|
raw = str(text or "")
|
|
start = raw.find(open_char)
|
|
if start < 0:
|
|
return ""
|
|
depth = 0
|
|
in_str = False
|
|
escaped = False
|
|
for idx in range(start, len(raw)):
|
|
ch = raw[idx]
|
|
if in_str:
|
|
if escaped:
|
|
escaped = False
|
|
elif ch == "\\":
|
|
escaped = True
|
|
elif ch == '"':
|
|
in_str = False
|
|
continue
|
|
if ch == '"':
|
|
in_str = True
|
|
continue
|
|
if ch == open_char:
|
|
depth += 1
|
|
elif ch == close_char:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return raw[start : idx + 1].strip()
|
|
return ""
|
|
|
|
def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool:
|
|
if not self._structured_output_required(function_name):
|
|
return False
|
|
candidate = self._extract_json_candidate(response_text)
|
|
if not candidate:
|
|
return True
|
|
try:
|
|
parsed = json.loads(candidate)
|
|
except Exception:
|
|
return True
|
|
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:
|
|
return True
|
|
return not isinstance(parsed, (dict, list))
|
|
|
|
@staticmethod
|
|
def _strip_code_fence(text: str) -> str:
|
|
raw = str(text or "").strip()
|
|
if not raw.startswith("```"):
|
|
return raw
|
|
chunks = raw.split("```")
|
|
if len(chunks) >= 2:
|
|
raw = chunks[1].strip()
|
|
if raw.lower().startswith("json"):
|
|
raw = raw[4:].strip()
|
|
return raw.strip()
|
|
|
|
def _extract_text_from_chat_response(
|
|
self,
|
|
*,
|
|
message: Any,
|
|
choice: Any,
|
|
response: Any,
|
|
structured_output: bool,
|
|
) -> str:
|
|
if structured_output:
|
|
for _, 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 not self._looks_like_json_container(normalized):
|
|
continue
|
|
candidate = self._extract_json_candidate(normalized)
|
|
if candidate:
|
|
return candidate
|
|
return ""
|
|
if isinstance(message, dict):
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(message.get("content")))
|
|
if text:
|
|
return text
|
|
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 {}
|
|
if isinstance(fn, dict):
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(fn.get("arguments")))
|
|
if text:
|
|
return text
|
|
if isinstance(choice, dict):
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(choice.get("text")))
|
|
if text:
|
|
return text
|
|
if isinstance(response, dict):
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(response.get("output_text")))
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
def _extract_reasoning_fallback_text(
|
|
self,
|
|
*,
|
|
message: Any,
|
|
choice: Any,
|
|
response: Any,
|
|
) -> str:
|
|
candidates: list[Any] = []
|
|
if isinstance(message, dict):
|
|
candidates.append(message.get("reasoning_content"))
|
|
candidates.append(message.get("reasoning"))
|
|
if isinstance(choice, dict):
|
|
candidates.append(choice.get("reasoning_content"))
|
|
candidates.append(choice.get("reasoning"))
|
|
if isinstance(response, dict):
|
|
candidates.append(response.get("reasoning_content"))
|
|
candidates.append(response.get("reasoning"))
|
|
for raw in candidates:
|
|
text = self._sanitize_visible_answer(self._extract_text_from_value(raw))
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _reasoning_content_fallback_enabled(*, function_name: str) -> bool:
|
|
fn = str(function_name or "").strip().lower()
|
|
# P0 guardrail: user-facing answer/reply/qa must never expose reasoning_content.
|
|
if fn and any(token in fn for token in ("answer", "reply", "qa")):
|
|
return False
|
|
if fn and "title_refine" in fn:
|
|
return True
|
|
raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower()
|
|
if raw not in {"1", "true", "yes", "on"}:
|
|
return False
|
|
if not fn:
|
|
return True
|
|
if fn.startswith("generate_structured") or fn.startswith("llm_generate_structured"):
|
|
return False
|
|
if "signal_batch_analysis" in fn:
|
|
return False
|
|
return False
|