From 95b127fd8d8fe4f21c785e5d15c8e2cb4f7d8898 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 2 May 2026 07:56:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai):=20parse=5Fjson=5Fresponse=20=E2=80=94?= =?UTF-8?q?=20raw=20newline=20escape=20fallback=20(5=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4-A debug 결과 study_question_jobs.parse_fail 33건의 raw preview 분석: - 모델이 explanation_md 안에 raw newline (LF) 그대로 박음 ('### [풀이]\n\n**자료...') - JSON 표준상 string literal 안 raw control char 금지 → json.loads 거부 - 4단계 fallback (greedy slice) 도 이 때문에 실패 5단계 fallback 추가: candidate 의 \r\n/\n/\r 을 ``\\n``/``\\r`` escape 로 치환 후 재시도. 이미 escape 된 ``\\n`` (Python str = backslash+n 두 글자) 는 raw newline 아니라 영향 없음. 다른 worker (classify/triage/study_explanation/evidence/study_session_analysis) 모두 같은 파서를 공유하므로 자동으로 혜택. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/ai/client.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/ai/client.py b/app/ai/client.py index 6a126ab..282a7a8 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -54,14 +54,24 @@ def parse_json_response(raw: str) -> dict | None: # 4. greedy slice fallback — first '{' ~ last '}' 까지 first = cleaned.find("{") last = cleaned.rfind("}") - if first >= 0 and last > first: - candidate = cleaned[first : last + 1] - try: - obj = json.loads(candidate) - return obj if isinstance(obj, dict) else None - except json.JSONDecodeError: - return None - return None + if first < 0 or last <= first: + return None + candidate = cleaned[first : last + 1] + try: + obj = json.loads(candidate) + 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") + try: + obj = json.loads(escaped) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None # 프롬프트 로딩 PROMPTS_DIR = Path(__file__).parent.parent / "prompts"