File size: 6,674 Bytes
9d29c62 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | # 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
|