From b3dbf1a11e68bc0cbe12dbbee417a21b78548090 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 2 May 2026 08:00:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai):=20parse=5Fjson=5Fresponse=20=E2=80=94?= =?UTF-8?q?=20string=20literal=20=EC=95=88=EB=A7=8C=20fix=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20stateful=20walker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직전 fallback 의 무차별 newline replace 가 string 외부 (object 구조) 의 raw newline 까지 escape 해서 JSON 거부. 또 LaTeX 수식 (\circ, \text, \, etc) 의 invalid backslash 는 newline 이슈와 별개라 별도 fix 필요. state machine: in_string 토글 (`\"` 만남). string literal 안에서만: - raw LF/CR/TAB → \\n/\\r/\\t 로 변환 - backslash 다음에 valid escape char (\"\\/bfnrtu) 면 그대로 - backslash 다음에 invalid (\\c, \\,) 면 backslash 자체를 \\\\ 로 escape - string 외부 raw newline 은 JSON whitespace 라 보존 운영 데이터 id=243 의 raw 940자에 \\circ \\text \\, \\approx \\times 등 다수 LaTeX + markdown 줄바꿈 → 새 walker 가 두 케이스 모두 fix. 다른 worker (classify/triage/ study_explanation/evidence/study_session_analysis) 자동 혜택. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/ai/client.py | 71 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/app/ai/client.py b/app/ai/client.py index 282a7a8..8765cae 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -62,17 +62,78 @@ def parse_json_response(raw: str) -> dict | None: return obj if isinstance(obj, dict) else None except json.JSONDecodeError: pass - # 5. (Phase 4-A 후속) Markdown 줄바꿈이 JSON string literal 안에 raw 로 들어간 - # 케이스 방어. JSON 표준은 string 안 raw newline 금지. raw \n / \r 을 \\n/\\r 로 - # escape 후 재시도. 이미 escape 된 ``\\n`` (Python str 로는 backslash+n 두 글자) - # 는 raw newline 아니라 영향 없음. - escaped = candidate.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\r") + # 5. (Phase 4-A 후속) Markdown 줄바꿈 + LaTeX 수식이 JSON string literal 안에 + # raw 로 들어간 케이스 방어. 두 가지 invalid: + # - raw newline (LF/CR/TAB) — JSON 표준 string 안 control char 금지 + # - invalid backslash — `\circ`, `\text`, `\,` 같은 LaTeX. JSON valid escape + # 은 `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX` 만. + # stateful walker — string literal 안에서만 fix. 외부 (object 구조) 의 newline + # 은 valid whitespace 라 보존. + escaped = _fix_json_string_escapes(candidate) try: obj = json.loads(escaped) return obj if isinstance(obj, dict) else None except json.JSONDecodeError: return None + +_VALID_JSON_ESCAPES = set('"\\/bfnrtu') + + +def _fix_json_string_escapes(s: str) -> str: + """JSON string literal 안의 raw newline + invalid backslash 만 escape. + + state machine: in_string 토글 (`"` 마주침). string 안에서만: + - raw LF/CR/TAB → ``\\n``/``\\r``/``\\t`` 로 변환 + - 백슬래시 다음에 valid escape char (`"\\/bfnrtu`) 면 그대로 + - 백슬래시 다음에 invalid char (`\\c`, `\\,`) 면 백슬래시 자체를 ``\\\\`` 로 escape + string 외부 (`{` `,` `:` 사이) 의 raw newline 등은 JSON whitespace 라 보존. + """ + out: list[str] = [] + i = 0 + n = len(s) + in_string = False + while i < n: + ch = s[i] + if not in_string: + if ch == '"': + in_string = True + out.append(ch) + i += 1 + continue + # in_string + if ch == "\\": + nxt = s[i + 1] if i + 1 < n else "" + if nxt in _VALID_JSON_ESCAPES: + out.append(ch) + out.append(nxt) + i += 2 + continue + # invalid escape — backslash 자체를 escape + out.append("\\\\") + i += 1 + continue + if ch == '"': + in_string = False + out.append(ch) + i += 1 + continue + if ch == "\n": + out.append("\\n") + i += 1 + continue + if ch == "\r": + out.append("\\r") + i += 1 + continue + if ch == "\t": + out.append("\\t") + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + # 프롬프트 로딩 PROMPTS_DIR = Path(__file__).parent.parent / "prompts"