fix(study): Phase 4-A parse_fail 디버깅 — 파서 fallback + raw 저장
운영 데이터에서 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) <noreply@anthropic.com>
This commit is contained in:
+29
-6
@@ -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"
|
||||
|
||||
@@ -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. 환각 가드 — 정답 번호 일치
|
||||
|
||||
Reference in New Issue
Block a user