nz-nz commited on
Commit
1d0b820
·
verified ·
1 Parent(s): dc47444

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. llm.py +36 -2
  2. test_extract_json.py +24 -0
llm.py CHANGED
@@ -307,10 +307,31 @@ def _loads(s: str):
307
  return None
308
 
309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  def extract_json(text: str):
311
  """
312
- Pull the first JSON object/array out of a model reply. Returns the parsed
313
- object or None. Callers should handle None (skip card / use fallback grade).
 
314
  """
315
  text = _strip_think(text.strip())
316
  # strip ```json fences if present
@@ -318,6 +339,19 @@ def extract_json(text: str):
318
  data = _loads(text)
319
  if data is not None:
320
  return data
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  match = re.search(r"(\[.*\]|\{.*\})", text, re.DOTALL)
322
  if match:
323
  return _loads(match.group(1))
 
307
  return None
308
 
309
 
310
+ def _scan_json_values(text: str) -> list:
311
+ """Walk the string and collect every top-level JSON value. Handles models
312
+ that emit several values with no array wrapper — e.g. MiniCPM-V on image
313
+ input returns `{...} {...} {...}` (space-separated objects, no brackets) —
314
+ and ignores junk between/around them (stray quotes, prose, trailing `"}`)."""
315
+ dec = json.JSONDecoder()
316
+ out, i, n = [], 0, len(text)
317
+ while i < n:
318
+ if text[i] in "{[":
319
+ try:
320
+ val, end = dec.raw_decode(text, i)
321
+ out.append(val)
322
+ i = end
323
+ continue
324
+ except ValueError:
325
+ pass
326
+ i += 1
327
+ return out
328
+
329
+
330
  def extract_json(text: str):
331
  """
332
+ Pull JSON out of a model reply. Returns the parsed object/array, a list when
333
+ the model emitted several values without an array wrapper, or None. Callers
334
+ must handle None (skip card / use fallback grade).
335
  """
336
  text = _strip_think(text.strip())
337
  # strip ```json fences if present
 
339
  data = _loads(text)
340
  if data is not None:
341
  return data
342
+ # The whole text didn't parse — commonly because the model concatenated
343
+ # multiple JSON values (objects and/or arrays). Collect them all and flatten
344
+ # to a single list so callers expecting an array still work.
345
+ values = _scan_json_values(text)
346
+ if len(values) == 1:
347
+ return values[0]
348
+ if values:
349
+ flat: list = []
350
+ for v in values:
351
+ flat.extend(v) if isinstance(v, list) else flat.append(v)
352
+ return flat
353
+ # Last resort: a single object/array embedded in prose and/or over-escaped
354
+ # (\" / \n) — the plain scan above can't read that, but _loads can.
355
  match = re.search(r"(\[.*\]|\{.*\})", text, re.DOTALL)
356
  if match:
357
  return _loads(match.group(1))
test_extract_json.py CHANGED
@@ -61,6 +61,28 @@ def test_overescaped_embedded_in_prose():
61
  print("ok over-escaped object found inside prose")
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def test_valid_json_unaffected_regression():
65
  # Already-valid JSON (incl. a legitimately escaped quote inside a string)
66
  # parses on the first try and never hits the un-escape fallback.
@@ -76,5 +98,7 @@ if __name__ == "__main__":
76
  test_fully_escaped_with_newlines()
77
  test_overescaped_with_think_and_fence()
78
  test_overescaped_embedded_in_prose()
 
 
79
  test_valid_json_unaffected_regression()
80
  print("\nAll NAH-50 extract_json robustness tests passed.")
 
61
  print("ok over-escaped object found inside prose")
62
 
63
 
64
+ def test_concatenated_objects_no_array_wrapper():
65
+ # MiniCPM-V on image input returns several objects space-separated with no
66
+ # [ ] wrapper (+ a stray trailing quote) — verbatim shape from the OCR path.
67
+ raw = ('{"question":"Where do the light reactions occur?","answer":"Thylakoid '
68
+ 'membranes.","topic":"Photosynthesis","difficulty":2} '
69
+ '{"question":"Where does the Calvin cycle take place?","answer":"The '
70
+ 'stroma.","topic":"Photosynthesis","difficulty":1}"}')
71
+ data = llm.extract_json(raw)
72
+ assert isinstance(data, list) and len(data) == 2, data
73
+ assert data[0]["answer"] == "Thylakoid membranes."
74
+ assert data[1]["topic"] == "Photosynthesis"
75
+ print("ok concatenated objects (no array wrapper) -> list")
76
+
77
+
78
+ def test_concatenated_arrays_flattened():
79
+ raw = '[{"question":"A","answer":"a","topic":"T"}] [{"question":"B","answer":"b","topic":"T"}]'
80
+ data = llm.extract_json(raw)
81
+ assert isinstance(data, list) and len(data) == 2, data
82
+ assert [d["question"] for d in data] == ["A", "B"]
83
+ print("ok concatenated arrays flattened -> single list")
84
+
85
+
86
  def test_valid_json_unaffected_regression():
87
  # Already-valid JSON (incl. a legitimately escaped quote inside a string)
88
  # parses on the first try and never hits the un-escape fallback.
 
98
  test_fully_escaped_with_newlines()
99
  test_overescaped_with_think_and_fence()
100
  test_overescaped_embedded_in_prose()
101
+ test_concatenated_objects_no_array_wrapper()
102
+ test_concatenated_arrays_flattened()
103
  test_valid_json_unaffected_regression()
104
  print("\nAll NAH-50 extract_json robustness tests passed.")