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:
@@ -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 도메인 한정) 만 통과시킨다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user