| |
| """ |
| 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 |
|
|
| |
| _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). |
| """ |
| |
| 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) |
|
|
| |
| |
| text = re.sub(r'```(?:json)?\s*', '', llm_response_text) |
| text = text.replace('```', '') |
|
|
| |
| |
| |
| |
| |
| shielded = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', text) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| try: |
| parsed = json.loads(extracted) |
| logger.info(f"[SAFE_JSON:{caller}] ✅ Parsed successfully (direct).") |
| return _sanitize_math_newlines(parsed) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| 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}") |
|
|
| |
| |
| patterns = [ |
| r'(\{.*\}|\[.*\])', |
| r'(\{[^{}]*\}|\[[^\[\]]*\])' |
| ] |
|
|
| 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): |
| |
| |
| v = v.replace('\x0c', r'\f') |
| v = v.replace('\x08', r'\b') |
| v = v.replace('\t', r'\t') |
| v = v.replace('\r', r'\r') |
| |
| |
| import re |
| v = re.sub(r'\n(eq|u|abla|ormal| left| right)', r'\\n\1', v) |
|
|
| |
| 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 |
|
|