Coverage for src/local_deep_research/utilities/json_utils.py: 97%
93 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 (when their ``type`` attribute, if any, is ``"text"``).
64 Returns ``""`` for an empty/textless list.
65 """
66 parts: list[str] = []
67 for block in blocks:
68 if isinstance(block, str):
69 parts.append(block)
70 elif isinstance(block, dict):
71 if block.get("type", "text") == "text" and isinstance(
72 block.get("text"), str
73 ):
74 parts.append(block["text"])
75 else:
76 # A tool_result or thinking block object can expose .text too.
77 # As for dicts, only an untyped or "text"-typed block counts.
78 if getattr(block, "type", "text") != "text":
79 continue
80 text_attr = getattr(block, "text", None)
81 if isinstance(text_attr, str): 81 ↛ 67line 81 didn't jump to line 67 because the condition on line 81 was always true
82 parts.append(text_attr)
83 return "".join(parts)
86def extract_json(
87 text: str,
88 expected_type: Optional[Type] = None,
89) -> Optional[Union[dict, list]]:
90 """Extract and parse JSON from LLM-generated text.
92 Applies a cleaning pipeline to handle common LLM output patterns:
93 code fences, think tags, prose surrounding JSON, and minor artifacts.
95 Args:
96 text: Raw text potentially containing JSON.
97 expected_type: Expected JSON type (dict or list). If specified,
98 bracket extraction is ordered to prefer the matching type.
99 None accepts either type.
101 Returns:
102 Parsed dict or list, or None if no valid JSON found.
103 """
104 if not text or not text.strip():
105 return None
107 text = text.strip()
108 text = _strip_code_fences(text)
109 text = _remove_think_tags(text)
111 # Step 1: Try direct parse
112 try:
113 result = json.loads(text)
114 if isinstance(result, (dict, list)):
115 if expected_type is None or isinstance(result, expected_type):
116 return result
117 # Type mismatch — fall through to bracket extraction
118 except (json.JSONDecodeError, ValueError):
119 pass
121 # Step 2: Bracket extraction ordered by expected_type
122 if expected_type is list:
123 bracket_pairs = [("[", "]"), ("{", "}")]
124 elif expected_type is dict:
125 bracket_pairs = [("{", "}"), ("[", "]")]
126 else:
127 bracket_pairs = [("{", "}"), ("[", "]")]
129 for open_char, close_char in bracket_pairs:
130 extracted = _extract_by_brackets(text, open_char, close_char)
131 if extracted is None:
132 continue
134 # Try parsing the extracted substring
135 try:
136 result = json.loads(extracted)
137 if isinstance(result, (dict, list)): 137 ↛ 144line 137 didn't jump to line 144 because the condition on line 137 was always true
138 if expected_type is None or isinstance(result, expected_type):
139 return result
140 except (json.JSONDecodeError, ValueError):
141 pass
143 # Try cleaning LLM artifacts and retrying
144 cleaned = _clean_llm_json_artifacts(extracted)
145 if cleaned != extracted:
146 try:
147 result = json.loads(cleaned)
148 if isinstance(result, (dict, list)): 148 ↛ 129line 148 didn't jump to line 129 because the condition on line 148 was always true
149 if expected_type is None or isinstance( 149 ↛ 129line 149 didn't jump to line 129 because the condition on line 149 was always true
150 result, expected_type
151 ):
152 return result
153 except (json.JSONDecodeError, ValueError):
154 pass
156 logger.debug("No valid JSON found in text")
157 return None
160# ---------------------------------------------------------------------------
161# Private helpers
162# ---------------------------------------------------------------------------
165def _strip_code_fences(text: str) -> str:
166 """Remove markdown code fences from text.
168 Uses split-based extraction (not startswith) to handle fences
169 appearing mid-text with surrounding prose.
170 """
171 if "```json" in text:
172 parts = text.split("```json")
173 if len(parts) > 1: 173 ↛ 179line 173 didn't jump to line 179 because the condition on line 173 was always true
174 return parts[1].split("```")[0].strip()
175 elif "```" in text:
176 parts = text.split("```")
177 if len(parts) >= 3:
178 return parts[1].strip()
179 return text
182def _extract_by_brackets(
183 text: str, open_char: str, close_char: str
184) -> Optional[str]:
185 """Extract substring between outermost matching brackets.
187 Uses find()/rfind() which is equivalent to re.search with re.DOTALL
188 for bracket matching.
189 """
190 start = text.find(open_char)
191 end = text.rfind(close_char)
192 if start >= 0 and end > start:
193 return text[start : end + 1]
194 return None
197def _clean_llm_json_artifacts(text: str) -> str:
198 """Clean common LLM JSON artifacts from malformed JSON text.
200 Only called after json.loads has already failed, so the text is
201 already malformed. These regexes cannot corrupt valid JSON.
203 Handles:
204 - Trailing commas before ] or }
205 - Inline // comments
206 - Ellipsis entries (... or "...")
207 """
208 # Remove trailing commas
209 text = re.sub(r",\s*([}\]])", r"\1", text)
210 # Remove // line comments
211 text = re.sub(r"//[^\n]*", "", text)
212 # Remove ellipsis entries, preserving comma separator when between items
213 text = re.sub(
214 r',\s*"?\.\.\.+"?\s*,', ",", text
215 ) # between items: keep one comma
216 return re.sub(r',?\s*"?\.\.\.+"?\s*', "", text) # trailing/leading ellipsis