balloonmann commited on
Commit
fd84f8b
·
1 Parent(s): 607d281

Harden reward parsing for truncated JSON

Browse files
Files changed (2) hide show
  1. tests/test_training_pipeline.py +17 -0
  2. training/reward.py +60 -15
tests/test_training_pipeline.py CHANGED
@@ -17,6 +17,23 @@ def test_parse_findings_from_json_array():
17
  assert parsed[0]["confidence"] == 0.88
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def test_parse_findings_from_free_text_block():
21
  text = (
22
  "document_id: INV-101\n"
 
17
  assert parsed[0]["confidence"] == 0.88
18
 
19
 
20
+ def test_parse_findings_from_truncated_json_array_salvages_complete_objects():
21
+ text = (
22
+ '[{"document_id": "INV-001", "error_type": "price_mismatch", '
23
+ '"description": "Unit price mismatch", "confidence": 1.0}, '
24
+ '{"document_id": "INV-002", "error_type": "quantity_mismatch", '
25
+ '"description": "Quantity mismatch", "confidence": 0.8}, '
26
+ '{"document_id": "INV-003", "error_type": "cascading_total", '
27
+ '"description": "Total mismatch"'
28
+ )
29
+
30
+ parsed = parse_findings_from_text(text)
31
+
32
+ assert len(parsed) == 2
33
+ assert parsed[0]["document_id"] == "INV-001"
34
+ assert parsed[1]["document_id"] == "INV-002"
35
+
36
+
37
  def test_parse_findings_from_free_text_block():
38
  text = (
39
  "document_id: INV-101\n"
training/reward.py CHANGED
@@ -14,6 +14,9 @@ from .evaluator import InProcessEvaluator
14
  _evaluator = InProcessEvaluator()
15
 
16
 
 
 
 
17
  def parse_findings_from_text(text: str) -> List[Dict[str, Any]]:
18
  """
19
  Parse model-generated findings from free text.
@@ -28,27 +31,69 @@ def parse_findings_from_text(text: str) -> List[Dict[str, Any]]:
28
  if not text or not isinstance(text, str):
29
  return []
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # Try JSON first — look for array in the text
32
  try:
33
- json_match = re.search(r"\[.*\]", text, re.DOTALL)
34
  if json_match:
35
- parsed = json.loads(json_match.group())
36
- if isinstance(parsed, list) and len(parsed) > 0:
37
- # Validate minimum fields
38
- valid = []
39
- for item in parsed:
40
- if isinstance(item, dict) and "document_id" in item and "error_type" in item:
41
- valid.append({
42
- "document_id": str(item["document_id"]).strip(),
43
- "error_type": str(item["error_type"]).strip().lower(),
44
- "description": str(item.get("description", "Finding")).strip(),
45
- "confidence": float(item["confidence"]) if "confidence" in item else None,
46
- })
47
- if valid:
48
- return valid
49
  except (json.JSONDecodeError, TypeError, ValueError):
50
  pass
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # Fallback: regex-based parsing for free-text output
53
  findings = []
54
  # Split on double newlines or lines starting with - or *
 
14
  _evaluator = InProcessEvaluator()
15
 
16
 
17
+ _OBJECT_RE = re.compile(r"\{[^{}]*\}")
18
+
19
+
20
  def parse_findings_from_text(text: str) -> List[Dict[str, Any]]:
21
  """
22
  Parse model-generated findings from free text.
 
31
  if not text or not isinstance(text, str):
32
  return []
33
 
34
+ def _coerce_findings(parsed: Any) -> List[Dict[str, Any]]:
35
+ if not isinstance(parsed, list) or len(parsed) == 0:
36
+ return []
37
+ valid: List[Dict[str, Any]] = []
38
+ for item in parsed:
39
+ if isinstance(item, dict) and "document_id" in item and "error_type" in item:
40
+ confidence = item.get("confidence")
41
+ try:
42
+ confidence_value = float(confidence) if confidence is not None else None
43
+ except (TypeError, ValueError):
44
+ confidence_value = None
45
+ valid.append({
46
+ "document_id": str(item["document_id"]).strip(),
47
+ "error_type": str(item["error_type"]).strip().lower(),
48
+ "description": str(item.get("description", "Finding")).strip(),
49
+ "confidence": confidence_value,
50
+ })
51
+ return valid
52
+
53
+ def _try_parse_json_fragment(fragment: str) -> List[Dict[str, Any]]:
54
+ fragment = fragment.strip()
55
+ if not fragment:
56
+ return []
57
+ try:
58
+ parsed = json.loads(fragment)
59
+ except (json.JSONDecodeError, TypeError, ValueError):
60
+ return []
61
+ return _coerce_findings(parsed)
62
+
63
  # Try JSON first — look for array in the text
64
  try:
65
+ json_match = re.search(r"\[.*?\]", text, re.DOTALL)
66
  if json_match:
67
+ valid = _try_parse_json_fragment(json_match.group())
68
+ if valid:
69
+ return valid
 
 
 
 
 
 
 
 
 
 
 
70
  except (json.JSONDecodeError, TypeError, ValueError):
71
  pass
72
 
73
+ # Fallback for truncated arrays or slightly malformed JSON:
74
+ # salvage any complete object blocks we can recover from the text.
75
+ salvaged: List[Dict[str, Any]] = []
76
+ seen = set()
77
+ for obj_text in _OBJECT_RE.findall(text):
78
+ # Accept either a full object or a fragment that becomes valid once wrapped.
79
+ parsed_candidates = [
80
+ _try_parse_json_fragment(obj_text),
81
+ _try_parse_json_fragment(f"[{obj_text}]"),
82
+ ]
83
+ for candidate in parsed_candidates:
84
+ for item in candidate:
85
+ key = (
86
+ item.get("document_id"),
87
+ item.get("error_type"),
88
+ item.get("description"),
89
+ )
90
+ if key not in seen:
91
+ seen.add(key)
92
+ salvaged.append(item)
93
+
94
+ if salvaged:
95
+ return salvaged
96
+
97
  # Fallback: regex-based parsing for free-text output
98
  findings = []
99
  # Split on double newlines or lines starting with - or *