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