diff --git a/summarize/llm_client.py b/summarize/llm_client.py index b164f56..d1aba9f 100644 --- a/summarize/llm_client.py +++ b/summarize/llm_client.py @@ -109,6 +109,29 @@ def _repair_json(text: str) -> str: return "".join(out) +def _first_object(content: str) -> dict | None: + """`{` 가 나오는 자리마다 객체 하나를 읽어 본다 — 앞에 군더더기 `{` 나 서문이 붙은 경우. + + 고객사 실측: 모델이 `{` 한 줄을 먼저 쓰고 그 다음 줄에 진짜 JSON 을 냈다. + """ + dec = json.JSONDecoder() + first = content.find("{") + i = first + while i >= 0: + # 첫 `{` 이후로는 그 사이가 공백뿐인 자리(군더더기 `{` 바로 다음)만 본다 — + # 값 안에 중첩된 `{"kind": ...}` 를 최상위 객체로 오인하지 않게 + if i != first and content[first + 1 : i].strip(" \t\r\n{"): + break + try: + data, _ = dec.raw_decode(content, i) + if isinstance(data, dict) and data: + return data + except json.JSONDecodeError: + pass + i = content.find("{", i + 1) + return None + + def _extract_json(content: str) -> dict: """모델이 코드펜스/서문을 붙이거나 문법이 약간 틀린 JSON 을 내는 경우까지 감안해 뽑아낸다. @@ -117,6 +140,9 @@ def _extract_json(content: str) -> dict: try: return json.loads(content) except json.JSONDecodeError as first: + found = _first_object(content) + if found is not None: + return found s, e = content.find("{"), content.rfind("}") candidate = content[s : e + 1] if (s >= 0 and e > s) else content[s:] if s >= 0 else content for attempt in (candidate, _repair_json(candidate)): @@ -126,6 +152,15 @@ def _extract_json(content: str) -> dict: return data except json.JSONDecodeError: continue + # 군더더기 `{` 뒤에 오는 객체가 문법까지 틀린 경우: 두 번째 `{` 부터 복구 + s2 = content.find("{", s + 1) if s >= 0 else -1 + if s2 > 0: + try: + data = json.loads(_repair_json(content[s2 : e + 1] if e > s2 else content[s2:])) + if isinstance(data, dict): + return data + except json.JSONDecodeError: + pass try: d = settings.data_llm_jobs / "_badjson" d.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index b086206..120f257 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -303,3 +303,16 @@ def test_extract_json_dumps_unreadable_content(tmp_path, monkeypatch): _extract_json("안녕하세요! 반갑습니다.") dumped = list((tmp_path / "_badjson").glob("*.txt")) assert len(dumped) == 1 and "안녕하세요" in dumped[0].read_text(encoding="utf-8") + + +def test_extract_json_skips_stray_opening_brace(tmp_path, monkeypatch): + """고객사 실측: `{` 한 줄 뒤에 진짜 JSON (Expecting property name enclosed in double quotes: line 2 column 1).""" + from config.settings import settings + monkeypatch.setattr(settings, "data_llm_jobs", tmp_path) + assert _extract_json('{\n{"program": "ZRMEM2016", "business_purpose_ko": "요약"}') == { + "program": "ZRMEM2016", "business_purpose_ko": "요약"} + # 군더더기 { + 값 안 따옴표까지 같이 틀린 경우 + assert _extract_json('{\n{"program": "Z1", "x": "a "b" c"}')["x"] == 'a "b" c' + # 서문 + 객체 두 개면 첫 번째 완전한 객체 + assert _extract_json('결과:\n{"a": 1}\n{"b": 2}') == {"a": 1} + assert not list((tmp_path / "_badjson").glob("*")) if (tmp_path / "_badjson").exists() else True