Coverage for src/local_deep_research/utilities/json_utils.py: 92%
91 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Centralized utilities for extracting and parsing JSON from LLM responses.
4Provides two public functions:
5- get_llm_response_text: Extract text from LLM response objects
6- extract_json: Parse JSON from LLM-generated text with robust cleaning
7"""
9import json
10import re
11from typing import Optional, Type, Union
13from loguru import logger
15from .search_utilities import remove_think_tags as _remove_think_tags
18def get_llm_response_text(response) -> str:
19 """Extract text content from an LLM response object.
21 Handles LangChain AIMessage (.content), plain text responses (.text),
22 list-type content blocks (Anthropic extended-thinking / tool-use, where
23 ``.content`` is a list of blocks), and arbitrary objects (via str()).
24 Removes <think> tags from the output.
26 Args:
27 response: LLM response object, string, or None.
29 Returns:
30 Extracted text with think tags removed. Empty string for None input.
31 """
32 if response is None:
33 return ""
34 if hasattr(response, "content") and response.content is not None:
35 raw = response.content
36 elif hasattr(response, "text") and response.text is not None:
37 raw = response.text
38 else:
39 raw = str(response)
40 if isinstance(raw, list):
41 # Anthropic-style content blocks, e.g.
42 # [{"type": "text", "text": "..."}, {"type": "tool_use", ...}].
43 # Extract and join the text so downstream string ops get clean text
44 # rather than the list's Python repr (the #4615 data-corruption bug).
45 raw = _coerce_content_blocks(raw)
46 if not isinstance(raw, str):
47 raw = str(raw)
48 return _remove_think_tags(raw)
51def _coerce_content_blocks(blocks: list) -> str:
52 """Join the text from a list of LLM content blocks into a plain string.
54 Some providers (notably Anthropic, and some LangChain paths) return
55 ``message.content`` as a list of blocks rather than a string — for
56 example ``[{"type": "text", "text": "Paris"}, {"type": "tool_use", ...}]``.
57 Keep only the textual parts; skip non-text blocks (tool_use, thinking,
58 etc.) so callers that then ``.strip()`` / ``.split()`` operate on real
59 text instead of the list's ``repr()``.
61 Accepts blocks that are plain strings, dicts with a ``"text"`` key (when
62 ``type`` is ``"text"`` or unset), or objects exposing a ``.text`` str
63 attribute. Returns ``""`` for an empty/textless list.
64 """
65 parts: list[str] = []
66 for block in blocks:
67 if isinstance(block, str): 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true
68 parts.append(block)
69 elif isinstance(block, dict): 69 ↛ 75line 69 didn't jump to line 75 because the condition on line 69 was always true
70 if block.get("type", "text") == "text" and isinstance(
71 block.get("text"), str
72 ):
73 parts.append(block["text"])
74 else:
75 text_attr = getattr(block, "text", None)
76 if isinstance(text_attr, str):
77 parts.append(text_attr)
78 return "".join(parts)
81def extract_json(
82 text: str,
83 expected_type: Optional[Type] = None,
84) -> Optional[Union[dict, list]]:
85 """Extract and parse JSON from LLM-generated text.
87 Applies a cleaning pipeline to handle common LLM output patterns:
88 code fences, think tags, prose surrounding JSON, and minor artifacts.
90 Args:
91 text: Raw text potentially containing JSON.
92 expected_type: Expected JSON type (dict or list). If specified,
93 bracket extraction is ordered to prefer the matching type.
94 None accepts either type.
96 Returns:
97 Parsed dict or list, or None if no valid JSON found.
98 """
99 if not text or not text.strip():
100 return None
102 text = text.strip()
103 text = _strip_code_fences(text)
104 text = _remove_think_tags(text)
106 # Step 1: Try direct parse
107 try:
108 result = json.loads(text)
109 if isinstance(result, (dict, list)):
110 if expected_type is None or isinstance(result, expected_type):
111 return result
112 # Type mismatch — fall through to bracket extraction
113 except (json.JSONDecodeError, ValueError):
114 pass
116 # Step 2: Bracket extraction ordered by expected_type
117 if expected_type is list:
118 bracket_pairs = [("[", "]"), ("{", "}")]
119 elif expected_type is dict:
120 bracket_pairs = [("{", "}"), ("[", "]")]
121 else:
122 bracket_pairs = [("{", "}"), ("[", "]")]
124 for open_char, close_char in bracket_pairs:
125 extracted = _extract_by_brackets(text, open_char, close_char)
126 if extracted is None:
127 continue
129 # Try parsing the extracted substring
130 try:
131 result = json.loads(extracted)
132 if isinstance(result, (dict, list)): 132 ↛ 139line 132 didn't jump to line 139 because the condition on line 132 was always true
133 if expected_type is None or isinstance(result, expected_type):
134 return result
135 except (json.JSONDecodeError, ValueError):
136 pass
138 # Try cleaning LLM artifacts and retrying
139 cleaned = _clean_llm_json_artifacts(extracted)
140 if cleaned != extracted:
141 try:
142 result = json.loads(cleaned)
143 if isinstance(result, (dict, list)): 143 ↛ 124line 143 didn't jump to line 124 because the condition on line 143 was always true
144 if expected_type is None or isinstance( 144 ↛ 124line 144 didn't jump to line 124 because the condition on line 144 was always true
145 result, expected_type
146 ):
147 return result
148 except (json.JSONDecodeError, ValueError):
149 pass
151 logger.debug("No valid JSON found in text")
152 return None
155# ---------------------------------------------------------------------------
156# Private helpers
157# ---------------------------------------------------------------------------
160def _strip_code_fences(text: str) -> str:
161 """Remove markdown code fences from text.
163 Uses split-based extraction (not startswith) to handle fences
164 appearing mid-text with surrounding prose.
165 """
166 if "```json" in text:
167 parts = text.split("```json")
168 if len(parts) > 1: 168 ↛ 174line 168 didn't jump to line 174 because the condition on line 168 was always true
169 return parts[1].split("```")[0].strip()
170 elif "```" in text:
171 parts = text.split("```")
172 if len(parts) >= 3:
173 return parts[1].strip()
174 return text
177def _extract_by_brackets(
178 text: str, open_char: str, close_char: str
179) -> Optional[str]:
180 """Extract substring between outermost matching brackets.
182 Uses find()/rfind() which is equivalent to re.search with re.DOTALL
183 for bracket matching.
184 """
185 start = text.find(open_char)
186 end = text.rfind(close_char)
187 if start >= 0 and end > start:
188 return text[start : end + 1]
189 return None
192def _clean_llm_json_artifacts(text: str) -> str:
193 """Clean common LLM JSON artifacts from malformed JSON text.
195 Only called after json.loads has already failed, so the text is
196 already malformed. These regexes cannot corrupt valid JSON.
198 Handles:
199 - Trailing commas before ] or }
200 - Inline // comments
201 - Ellipsis entries (... or "...")
202 """
203 # Remove trailing commas
204 text = re.sub(r",\s*([}\]])", r"\1", text)
205 # Remove // line comments
206 text = re.sub(r"//[^\n]*", "", text)
207 # Remove ellipsis entries, preserving comma separator when between items
208 text = re.sub(
209 r',\s*"?\.\.\.+"?\s*,', ",", text
210 ) # between items: keep one comma
211 return re.sub(r',?\s*"?\.\.\.+"?\s*', "", text) # trailing/leading ellipsis