BuddyMath / utils /safe_json.py
dotandru's picture
Fix: Clean production deployment with sse-starlette
9d29c62
Raw
History Blame Contribute Delete
6.67 kB
# utils/safe_json.py — V1.0 (CANONICAL JSON EXTRACTOR)
"""
CTO Directive: One canonical JSON extractor for the entire server.
Replaces 3 ad-hoc implementations in orchestrator.py, strategy_manager.py,
problem_understanding.py, and ocr_strip_engine.py.
Rules:
1. LOG the RAW LLM string BEFORE any processing (critical for debugging hallucinations).
2. LaTeX backslash shield applied BEFORE extraction (prevents json.loads crashes).
3. json_repair applied before json.loads (handles LLM-malformed JSON).
4. Fail-CLOSED: any failure returns a safe error dict, never raises.
5. Supports both {} (dict) and [] (array) top-level JSON blocks.
"""
import re
import json
import logging
from typing import Union
logger = logging.getLogger(__name__)
try:
from json_repair import repair_json
except ImportError:
logger.warning("[SAFE_JSON] json_repair not available. Using raw json.loads fallback.")
repair_json = lambda x: x # noqa: E731
# Sentinel returned on any unrecoverable failure
_PARSE_FAILURE = {
"logic_error": True,
"error_type": "PARSING_FAILURE",
"final_answer": "מצטערת, התשובה שיצרתי התבלגנה קצת בדרך. נוכל לנסות שוב? 🔄"
}
def safe_extract_json(
llm_response_text: str,
caller: str = "UNKNOWN",
allow_array: bool = True
) -> Union[dict, list]:
"""
Generic, robust JSON extraction from any LLM response string.
Args:
llm_response_text: Raw text from LLM (may include markdown, prose, LaTeX).
caller: Identifier for the calling module (used in log tags).
allow_array: If True, accepts top-level JSON arrays [...] as well as objects {...}.
Returns:
Parsed dict or list on success.
_PARSE_FAILURE dict on any failure (never raises).
"""
# ── Step 1: LOG RAW INPUT (CTO requirement) ────────────────────────────────
logger.info(
f"[SAFE_JSON:{caller}] RAW LLM STRING ({len(llm_response_text)} chars): "
f"{llm_response_text[:600]!r}"
)
if not llm_response_text or not llm_response_text.strip():
logger.error(f"[SAFE_JSON:{caller}] Empty LLM response.")
return dict(_PARSE_FAILURE)
# ── Step 2: Strip markdown fencing (```json ... ```) ──────────────────────
# This is the most common wrapper the LLM adds despite instructions
text = re.sub(r'```(?:json)?\s*', '', llm_response_text)
text = text.replace('```', '')
# ── Step 3: LaTeX Backslash Shield (CRITICAL FOR MATH) ─────────────────────
# LLMs frequently output raw backslashes for LaTeX (\frac, \ln) which are
# invalid in JSON strings. We escape them here unless already escaped.
# Note: We only escape backslashes that are NOT followed by a valid JSON escape char
# or another backslash.
shielded = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', text)
# ── Step 4: Locate the outermost JSON block (REGEX MANDATE) ────────────────
# V286.2: We use a robust re.DOTALL search to find the outermost {} or [] block.
# This ignores any conversational preamble or postamble added by the LLM.
pattern = r'(\{.*\}|\[.*\])'
match = re.search(pattern, shielded, re.DOTALL)
if not match:
logger.error(
f"[SAFE_JSON:{caller}] No JSON block found via Regex. "
f"First 200 chars: {llm_response_text[:200]!r}"
)
return dict(_PARSE_FAILURE)
extracted = match.group(0)
# ── Step 5: Try raw json.loads first ─────────────────────────────────────
try:
parsed = json.loads(extracted)
logger.info(f"[SAFE_JSON:{caller}] ✅ Parsed successfully (direct).")
return _sanitize_math_newlines(parsed)
except json.JSONDecodeError:
pass # Fall through to json_repair
# ── Step 5b: json_repair + json.loads (for malformed LLM output) ─────────
try:
repaired = repair_json(extracted)
parsed = json.loads(repaired)
logger.info(f"[SAFE_JSON:{caller}] ✅ Parsed successfully (repair).")
return _sanitize_math_newlines(parsed)
except Exception as e:
logger.error(f"[SAFE_JSON:{caller}] json_repair fail: {type(e).__name__} - {e}. Extracted: {extracted[:200]!r}")
# ── Step 6: Last resort — try raw extraction without LaTeX shield ─────────
# We look for anything that looks like a JSON block
patterns = [
r'(\{.*\}|\[.*\])', # Greedy match for outermost block
r'(\{[^{}]*\}|\[[^\[\]]*\])' # Non-nested match as fallback
]
try:
for pattern in patterns:
match = re.search(pattern, llm_response_text, re.DOTALL)
if match:
parsed = json.loads(repair_json(match.group(0)))
logger.warning(f"[SAFE_JSON:{caller}] ✅ Recovered via raw extraction (no LaTeX shield).")
return _sanitize_math_newlines(parsed)
except Exception:
pass
logger.error(f"[SAFE_JSON:{caller}] 🚨 All extraction strategies failed. Returning PARSE_FAILURE.")
return dict(_PARSE_FAILURE)
def _sanitize_math_newlines(data: Union[dict, list, str]) -> Union[dict, list, str]:
"""Recursively cleans up newline characters and restores swallowed LaTeX backslashes."""
if isinstance(data, list):
return [_sanitize_math_newlines(i) for i in data]
elif isinstance(data, dict):
new_dict = {}
for k, v in data.items():
if isinstance(v, str):
# V286.1: Fix swallowed backslashes!
# When LLM outputs "\frac", JSON parses \f as form feed (\x0c). \beta as backspace (\x08).
v = v.replace('\x0c', r'\f')
v = v.replace('\x08', r'\b')
v = v.replace('\t', r'\t')
v = v.replace('\r', r'\r')
# \n might be intended for \neq, \nu, \nabla, \normal, etc.
import re
v = re.sub(r'\n(eq|u|abla|ormal| left| right)', r'\\n\1', v)
# Special case for math-heavy keys: ensure no \n breaks rendering
if k in ['block_math', 'math_block', 'latex', 'equation', 'content'] or '$' in v:
v = v.replace('\n', ' ')
new_dict[k] = v
else:
new_dict[k] = _sanitize_math_newlines(v)
return new_dict
return data