diff --git a/app/ai/client.py b/app/ai/client.py index 282a7a8..8765cae 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -62,17 +62,78 @@ def parse_json_response(raw: str) -> dict | None: return obj if isinstance(obj, dict) else None except json.JSONDecodeError: pass - # 5. (Phase 4-A 후속) Markdown 줄바꿈이 JSON string literal 안에 raw 로 들어간 - # 케이스 방어. JSON 표준은 string 안 raw newline 금지. raw \n / \r 을 \\n/\\r 로 - # escape 후 재시도. 이미 escape 된 ``\\n`` (Python str 로는 backslash+n 두 글자) - # 는 raw newline 아니라 영향 없음. - escaped = candidate.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\r") + # 5. (Phase 4-A 후속) Markdown 줄바꿈 + LaTeX 수식이 JSON string literal 안에 + # raw 로 들어간 케이스 방어. 두 가지 invalid: + # - raw newline (LF/CR/TAB) — JSON 표준 string 안 control char 금지 + # - invalid backslash — `\circ`, `\text`, `\,` 같은 LaTeX. JSON valid escape + # 은 `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX` 만. + # stateful walker — string literal 안에서만 fix. 외부 (object 구조) 의 newline + # 은 valid whitespace 라 보존. + escaped = _fix_json_string_escapes(candidate) try: obj = json.loads(escaped) return obj if isinstance(obj, dict) else None except json.JSONDecodeError: return None + +_VALID_JSON_ESCAPES = set('"\\/bfnrtu') + + +def _fix_json_string_escapes(s: str) -> str: + """JSON string literal 안의 raw newline + invalid backslash 만 escape. + + state machine: in_string 토글 (`"` 마주침). string 안에서만: + - raw LF/CR/TAB → ``\\n``/``\\r``/``\\t`` 로 변환 + - 백슬래시 다음에 valid escape char (`"\\/bfnrtu`) 면 그대로 + - 백슬래시 다음에 invalid char (`\\c`, `\\,`) 면 백슬래시 자체를 ``\\\\`` 로 escape + string 외부 (`{` `,` `:` 사이) 의 raw newline 등은 JSON whitespace 라 보존. + """ + out: list[str] = [] + i = 0 + n = len(s) + in_string = False + while i < n: + ch = s[i] + if not in_string: + if ch == '"': + in_string = True + out.append(ch) + i += 1 + continue + # in_string + if ch == "\\": + nxt = s[i + 1] if i + 1 < n else "" + if nxt in _VALID_JSON_ESCAPES: + out.append(ch) + out.append(nxt) + i += 2 + continue + # invalid escape — backslash 자체를 escape + out.append("\\\\") + i += 1 + continue + if ch == '"': + in_string = False + out.append(ch) + i += 1 + continue + if ch == "\n": + out.append("\\n") + i += 1 + continue + if ch == "\r": + out.append("\\r") + i += 1 + continue + if ch == "\t": + out.append("\\t") + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + # 프롬프트 로딩 PROMPTS_DIR = Path(__file__).parent.parent / "prompts"