fix(ai): B-1 deep_summary JSON parser 강건화 (최외곽 JSON 추출)

실측 버그 (doc 10573 산업안전보건법 deep 처리):
- 26B MLX 응답 길이 1131자 (8192 token 한도 미도달) 에서 응답이
  \`entities_confirmed\` 섹션 중간에 잘림.
- parse_json_response 의 regex \`{[^{}]*(?:{[^{}]*}[^{}]*)*}\` 가 1단계
  중첩까지만 매칭 + reversed 순회로 "가장 마지막 valid JSON" 우선 반환.
- 결과적으로 entities_confirmed 내부 객체 (\`{"people":[],"orgs":[],...}\`)
  가 파싱돼 detail/tldr/bullets 전부 손실 → ai_detail_summary 빈값.

수정: deep_summary_worker 에 \`_parse_outermost_json\` helper 추가.
brace balance + 문자열 리터럴 인식으로 첫 '{' 부터 최외곽 '}' 까지 추출.
응답이 잘려 closure 없으면 남은 depth 만큼 '}' 보강 후 재시도 (partial
응답도 최대한 복구). parse_json_response 는 fallback.

이 수정 후 doc 10573 재처리 smoke 필요. entities_confirmed 필드는 정보창
UI 에 안 쓰므로 응답에서 제거하는 프롬프트 조정은 다음 라운드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-24 11:25:01 +09:00
parent 165b00f917
commit 154cb1c8bd
+60 -2
View File
@@ -18,7 +18,9 @@ from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient, parse_json_response
import json
import re
from ai.client import AIClient, parse_json_response, strip_thinking
from ai.envelope import EscalationEnvelope
from core.config import settings
from core.utils import setup_logger
@@ -117,7 +119,9 @@ async def process(document_id: int, session: AsyncSession) -> None:
if raw:
try:
parsed = parse_json_response(raw) or {}
# parse_json_response 는 중첩 JSON (entities_confirmed) 을 최외곽으로 오인하는
# 케이스가 있어 — deep_summary 응답에서 자주 발생 — 최외곽 추출 전용 helper 사용.
parsed = _parse_outermost_json(raw) or parse_json_response(raw) or {}
deep_out = DeepSummaryOutput.model_validate(parsed)
except (ValidationError, ValueError, TypeError) as exc:
parse_error = f"parse:{type(exc).__name__}"
@@ -190,6 +194,60 @@ def _build_text_slices(text: str, pointers: dict) -> str:
return "\n\n".join(parts)
def _parse_outermost_json(raw: str) -> dict | None:
"""Response 의 첫 '{' 부터 brace balance 로 최외곽 JSON 추출.
parse_json_response 의 re.finditer 패턴이 1단계 중첩까지만 매치해서 deep_summary
응답처럼 `entities_confirmed: {...}` 2단계 중첩이 포함된 경우 최외곽 대신 내부
객체만 반환되는 문제를 우회. 또한 응답이 잘려 closure `}` 가 없으면 강제로
`}` 추가 시도하여 부분 파싱.
"""
cleaned = strip_thinking(raw)
code_match = re.search(r"```(?:json)?\s*(\{.*)", cleaned, re.DOTALL)
if code_match:
cleaned = code_match.group(1)
start = cleaned.find("{")
if start < 0:
return None
depth = 0
end = -1
in_str = False
esc = False
for i in range(start, len(cleaned)):
ch = cleaned[i]
if esc:
esc = False
continue
if ch == "\\":
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end > 0:
try:
return json.loads(cleaned[start:end])
except json.JSONDecodeError:
pass
# 응답 잘림 — 남은 depth 만큼 `}` 보강 후 재시도
if depth > 0:
candidate = cleaned[start:].rstrip().rstrip(",") + ("}" * depth)
try:
return json.loads(candidate)
except json.JSONDecodeError:
pass
return None
def _filter_inconsistencies(items: list) -> list[dict]:
"""허용 kind 목록 (safety/news 도메인 한정) 만 통과시킨다.