llm_client: 문법이 약간 틀린 모델 JSON 복구 (값 안 따옴표·줄바꿈·꼬리 콤마·잘린 출력), 못 읽으면 원문 보존

고객사 사내 LLM 실측: "Expecting ',' delimiter" — 설명 문장 안의 따옴표를 이스케이프하지 않았다.
_repair_json 이 문자열 안의 따옴표를 닫는 따옴표(뒤에 , : } ])와 구분해 이스케이프하고,
끝내 못 읽으면 data/llm_jobs/_badjson/ 에 원문을 남긴다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 16:32:50 +09:00
co-authored by Claude Fable 5.1
parent 9cdefa1a49
commit d8718c858f
2 changed files with 117 additions and 5 deletions
+88 -5
View File
@@ -43,15 +43,98 @@ class PendingResponse(Exception):
self.response_path = response_path
def _repair_json(text: str) -> str:
"""모델이 흔히 내는 JSON 문법 오류를 고친다 (고객사 사내 LLM 실측: 문자열 안의 따옴표 미이스케이프).
- 문자열 안의 `"` 가 닫는 따옴표가 아니면(뒤에 , : } ] 가 안 오면) `\\"` 로 바꾼다
- 문자열 안의 실제 줄바꿈·탭은 `\\n` `\\t` 로
- 닫는 괄호 앞의 꼬리 콤마 제거
- 출력이 잘린 경우: 열린 문자열·괄호를 닫아 준다 (부분 결과라도 스키마 검증에 맡긴다)
"""
out: list[str] = []
stack: list[str] = []
in_str = False
i, n = 0, len(text)
while i < n:
ch = text[i]
if in_str:
if ch == "\\":
out.append(text[i : i + 2]); i += 2; continue
if ch == '"':
j = i + 1
while j < n and text[j] in " \t\r\n":
j += 1
if j >= n or text[j] in ",:}]":
in_str = False
out.append(ch)
else:
out.append('\\"') # 값 안의 따옴표
elif ch == "\n":
out.append("\\n")
elif ch == "\t":
out.append("\\t")
elif ch == "\r":
pass
else:
out.append(ch)
else:
if ch == '"':
in_str = True
out.append(ch)
elif ch in "{[":
stack.append("}" if ch == "{" else "]")
out.append(ch)
elif ch in "}]":
# 꼬리 콤마 제거
k = len(out) - 1
while k >= 0 and out[k].strip() == "":
k -= 1
if k >= 0 and out[k] == ",":
del out[k]
if stack:
stack.pop()
out.append(ch)
else:
out.append(ch)
i += 1
if in_str:
out.append('"')
while stack:
k = len(out) - 1
while k >= 0 and out[k].strip() == "":
k -= 1
if k >= 0 and out[k] == ",":
del out[k]
out.append(stack.pop())
return "".join(out)
def _extract_json(content: str) -> dict:
"""모델이 코드펜스/서문을 붙이는 경우까지 감안해 JSON 을 뽑아낸다."""
"""모델이 코드펜스/서문을 붙이거나 문법이 약간 틀린 JSON 을 내는 경우까지 감안해 뽑아낸다.
끝내 못 읽으면 원문을 data/llm_jobs/_badjson/ 에 남기고 JSONDecodeError 를 올린다.
"""
try:
return json.loads(content)
except json.JSONDecodeError:
except json.JSONDecodeError as first:
s, e = content.find("{"), content.rfind("}")
if s >= 0 and e > s:
return json.loads(content[s : e + 1])
raise
candidate = content[s : e + 1] if (s >= 0 and e > s) else content[s:] if s >= 0 else content
for attempt in (candidate, _repair_json(candidate)):
try:
data = json.loads(attempt)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
continue
try:
d = settings.data_llm_jobs / "_badjson"
d.mkdir(parents=True, exist_ok=True)
name = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
(d / f"{name}.txt").write_text(content, encoding="utf-8")
print(f"[BADJSON] 모델 응답을 JSON 으로 읽지 못함 — 원문: {d / (name + '.txt')}")
except OSError:
pass
raise first
class OpenAICompatClient(LLMClient):