Sync from GitHub via hub-sync
Browse files- llm.py +36 -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
|
| 313 |
-
|
|
|
|
| 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.")
|