|
2 | 2 |
|
3 | 3 | import json |
4 | 4 | import os |
| 5 | +import re |
5 | 6 | import sys |
6 | 7 | import time |
7 | 8 | from typing import Any, Dict |
@@ -339,6 +340,10 @@ def _call_openai(self, system_prompt: str, user_prompt: str, json_mode: bool = F |
339 | 340 | # ── JSON parsing ─────────────────────────────────────────────── |
340 | 341 |
|
341 | 342 | def _parse_json(self, text: str) -> Dict[str, Any]: |
| 343 | + # Strip thinking blocks which often contain invalid JSON or brackets |
| 344 | + pattern = r"(?is)<(?:think|thinking|thought|reasoning)>.*?</(?:think|thinking|thought|reasoning)>" |
| 345 | + text = re.sub(pattern, "", text).strip() |
| 346 | + |
342 | 347 | # Handle markdown fences that might not be at the very start/end |
343 | 348 | text = text.strip() |
344 | 349 |
|
@@ -374,44 +379,53 @@ def _parse_json(self, text: str) -> Dict[str, Any]: |
374 | 379 | return {"error": "LLM returned non-object JSON", "raw_value": parsed} |
375 | 380 |
|
376 | 381 | def _salvage_json(self, text: str) -> Dict[str, Any]: |
| 382 | + pattern = r"(?is)<(?:think|thinking|thought|reasoning)>.*?</(?:think|thinking|thought|reasoning)>" |
| 383 | + text = re.sub(pattern, "", text).strip() |
377 | 384 | text = text.strip() |
378 | | - start = text.find("{") |
379 | | - if start == -1: |
380 | | - return {"error": "Failed to parse LLM response (no start brace)", "raw_response": text[:1000]} |
381 | 385 |
|
382 | | - end = text.rfind("}") |
| 386 | + brace_starts = [m.start() for m in re.finditer(r"\{", text)] |
| 387 | + if not brace_starts: |
| 388 | + return {"error": "Failed to parse LLM response (no start brace)", "raw_response": text[:1000]} |
383 | 389 |
|
384 | | - # Strategy 1: Classic substring or take all if no end brace |
385 | | - if end != -1 and end > start: |
386 | | - candidates = [text[start : end + 1], text[start:]] |
387 | | - else: |
388 | | - candidates = [text[start:]] |
| 390 | + candidates_parsed = [] |
389 | 391 |
|
390 | | - for candidate in candidates: |
| 392 | + # Parse starting from every brace. Save valid outputs along with string block length. |
| 393 | + for start_idx in brace_starts: |
| 394 | + candidate = text[start_idx:] |
391 | 395 | try: |
392 | 396 | parsed = json.loads(candidate) |
393 | 397 | if isinstance(parsed, dict): |
394 | | - return parsed |
| 398 | + candidates_parsed.append((len(candidate), parsed)) |
| 399 | + continue |
395 | 400 | except json.JSONDecodeError as e: |
396 | 401 | # Strategy 2: Handle extra data after valid object |
397 | 402 | if "Extra data" in str(e): |
398 | 403 | try: |
399 | | - parsed = json.loads(candidate[: e.pos].strip()) |
| 404 | + valid_str = candidate[: e.pos].strip() |
| 405 | + parsed = json.loads(valid_str) |
400 | 406 | if isinstance(parsed, dict): |
401 | | - return parsed |
| 407 | + candidates_parsed.append((len(valid_str), parsed)) |
| 408 | + continue |
402 | 409 | except Exception: |
403 | 410 | pass |
404 | 411 |
|
405 | 412 | # Strategy 3: Truncated JSON repair |
406 | 413 | for suffix in ["}", '"', '"}', '"}]}', '"}}', "}}", "]}", "]}"]: |
407 | 414 | try: |
408 | | - parsed = json.loads(candidate + suffix) |
| 415 | + valid_str = candidate + suffix |
| 416 | + parsed = json.loads(valid_str) |
409 | 417 | if isinstance(parsed, dict): |
410 | 418 | parsed["_repaired"] = True |
411 | | - return parsed |
| 419 | + candidates_parsed.append((len(valid_str), parsed)) |
| 420 | + break |
412 | 421 | except Exception: |
413 | 422 | continue |
414 | 423 |
|
| 424 | + if candidates_parsed: |
| 425 | + # Sort by the length of the matching JSON string to prefer the largest top-level object |
| 426 | + candidates_parsed.sort(key=lambda x: x[0], reverse=True) |
| 427 | + return candidates_parsed[0][1] |
| 428 | + |
415 | 429 | return {"error": "Failed to parse LLM response", "raw_response": text[:1000]} |
416 | 430 |
|
417 | 431 | def get_usage_stats(self) -> Dict[str, int]: |
|
0 commit comments