From ff41feb3e3c44cf57010084d9383c59bc8b089e7 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 2 May 2026 07:48:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20Phase=204-A=20parse=5Ffail=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20=E2=80=94=20=ED=8C=8C=EC=84=9C=20?= =?UTF-8?q?fallback=20+=20raw=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 데이터에서 4-A study_question_jobs 의 33/114 가 'envelope JSON parse failed' 로 종결. parse_json_response 의 balanced 정규식이 못 잡는 케이스 다수 추정. 원인 분류 위해: 1. 파서 보강 (app/ai/client.py) - 기존 4단계 파싱 (fenced / balanced finditer / 전체 cleaned) 보존 - 5단계 fallback 추가: first '{' ~ last '}' greedy slice → json.loads - envelope JSON 안에 내부 따옴표/뉴라인/escape 때문에 balanced 가 못 잡는 케이스 방어. 모델이 JSON 앞뒤 자유 텍스트 섞어도 본체만 추출. - 회귀 위험 낮은 추가만 (앞 단계 성공 시 즉시 반환) 2. parse_fail 시 raw preview 저장 (study_explanation_worker) - 3개 inline parse_fail 분기 (not_dict / invalid_answer_choice / empty_explanation_md) 모두 _save_raw_preview() 헬퍼 호출 - job.payload.debug_raw_preview = raw_text[:1000] - job.payload.parse_fail_reason = 분류 키 - 향후 parse_fail row 의 payload 분석으로 원인 정확히 분류 가능 다음 단계: 배포 후 재발생 추이 + raw preview 분석 → prompt 추가 강화 또는 parser 추가 보강. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/ai/client.py | 35 ++++++++++++++++++++----- app/workers/study_explanation_worker.py | 14 +++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/app/ai/client.py b/app/ai/client.py index 9ec3159..6a126ab 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -21,24 +21,47 @@ def strip_thinking(text: str) -> str: def parse_json_response(raw: str) -> dict | None: - """AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거)""" + """AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거). + + 파싱 시도 순서 (앞 단계가 성공하면 즉시 반환): + 1. ``` json fenced 블록 안의 첫 ``{...}`` (DOTALL) + 2. balanced 정규식 finditer 의 마지막 매치 + 3. 전체 cleaned 그대로 json.loads + 4. (Phase 4-A 후속) "first ``{`` ~ last ``}``" greedy slice — envelope JSON 안에 + 내부 따옴표/백틱/뉴라인 때문에 balanced 정규식이 못 잡는 케이스 방어. + raw text 의 첫 ``{`` 부터 마지막 ``}`` 까지 잘라 json.loads. 모델이 JSON 앞뒤 + 자유 텍스트 섞어도 본체만 추출. + """ cleaned = strip_thinking(raw) - # 코드블록 내부 JSON 추출 + # 1. 코드블록 내부 JSON 추출 code_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, re.DOTALL) if code_match: cleaned = code_match.group(1) - # 마지막 유효 JSON 객체 찾기 + # 2. 마지막 유효 JSON 객체 찾기 (balanced 1단계) matches = list(re.finditer(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", cleaned, re.DOTALL)) for m in reversed(matches): try: return json.loads(m.group()) except json.JSONDecodeError: continue - # 최후 시도: 전체 텍스트를 JSON으로 + # 3. 전체 cleaned try: - return json.loads(cleaned) + result = json.loads(cleaned) + if isinstance(result, dict): + return result except json.JSONDecodeError: - return None + pass + # 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 # 프롬프트 로딩 PROMPTS_DIR = Path(__file__).parent.parent / "prompts" diff --git a/app/workers/study_explanation_worker.py b/app/workers/study_explanation_worker.py index a8f20e7..04d8b7e 100644 --- a/app/workers/study_explanation_worker.py +++ b/app/workers/study_explanation_worker.py @@ -132,11 +132,21 @@ async def run_explanation_job(session: AsyncSession, job: StudyQuestionJob) -> N job.error_message = "empty response from primary" return - # 3. envelope 파싱 + # 3. envelope 파싱. + # parse_fail 시 raw 응답 첫 1000자를 payload.debug_raw_preview 에 저장 — 운영 분석. + # parse_json_response 가 None 또는 dict 아닌 경우 모두 분류. + def _save_raw_preview(reason: str) -> None: + preview = (raw_text or "")[:1000] + existing = dict(job.payload or {}) + existing["debug_raw_preview"] = preview + existing["parse_fail_reason"] = reason + job.payload = existing + envelope = parse_json_response(raw_text) if envelope is None or not isinstance(envelope, dict): job.error_code = "parse_fail" job.error_message = "envelope JSON parse failed" + _save_raw_preview("not_dict") return answer_choice = envelope.get("answer_choice") @@ -146,10 +156,12 @@ async def run_explanation_job(session: AsyncSession, job: StudyQuestionJob) -> N if not isinstance(answer_choice, int) or answer_choice not in (1, 2, 3, 4): job.error_code = "parse_fail" job.error_message = f"invalid answer_choice: {answer_choice!r}" + _save_raw_preview("invalid_answer_choice") return if not explanation_md.strip(): job.error_code = "parse_fail" job.error_message = "empty explanation_md" + _save_raw_preview("empty_explanation_md") return # 4. 환각 가드 — 정답 번호 일치