From 373dd059b776a5b4d90decef42f2e0509198bff9 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 29 Apr 2026 13:55:18 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20outer=20fenced=20code=20block=20a?= =?UTF-8?q?uto-unwrap=20(renderMathMarkdown=20+=20DB=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 응답이 마크다운 자체를 \`\`\` 으로 감싸서 오는 패턴 (시작만 있고 닫음 누락 포함) 때문에 explanation/AI 해설 영역이 raw 코드블록으로 보이는 회귀. - frontend/lib/utils/mathMarkdown.ts: stripOuterFence helper. - terminated wrap 처리 (inner 에 \`\`\` 추가 있으면 보존) - unterminated 처리 (백틱 그룹 == 1 인 경우만 안전하게 unwrap) - 본문 중간 정상 코드블록은 보존 - scripts/strip_outer_fences.py: dry-run + --apply 양 모드. - 5개 필드 (question_text, choice_1~4, explanation, ai_explanation, content) 검사. - 운영 결과 explanation 34건 unwrap 적용 완료, recount 0 검증. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/utils/mathMarkdown.ts | 45 +++++++++- scripts/strip_outer_fences.py | 112 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 scripts/strip_outer_fences.py diff --git a/frontend/src/lib/utils/mathMarkdown.ts b/frontend/src/lib/utils/mathMarkdown.ts index 006627d..8955c01 100644 --- a/frontend/src/lib/utils/mathMarkdown.ts +++ b/frontend/src/lib/utils/mathMarkdown.ts @@ -46,10 +46,53 @@ const SANITIZE_OPTS = { ALLOW_UNKNOWN_PROTOCOLS: false, } as const; +/** + * 텍스트 전체가 단일 fenced code block 으로 감싸진 경우만 unwrap. + * + * 두 케이스 처리: + * (1) terminated: 시작 + 끝 모두 ``` — 백틱 그룹 정확히 2번 + * (2) unterminated: 시작 ``` 만 있고 닫음 누락 — 백틱 그룹 정확히 1번 + * + * 보존 케이스 (그대로): + * 설명 문장 + * ```python + * print("hi") + * ``` + * 추가 설명 + * → 백틱 그룹이 2 이상이지만 ``` 가 본문 중간에 있어 wrap 패턴 매칭 안 됨. + * + * 안전 조건: + * - terminated: inner 에 ``` 가 또 있으면 보존 (사용자가 진짜 코드블록 의도). + * - unterminated: 본문에 ``` 가 그 시작 한 번만 있어야 함 (백틱 그룹 = 1). + * + * AI 응답이 마크다운 자체를 ``` 으로 감싸서 오는 패턴을 자동 처리. + */ +function stripOuterFence(text: string): string { + const trimmed = text.trim(); + + // (1) terminated wrap: ```...\n본문\n``` 끝. + const term = trimmed.match(/^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*?)\n```$/); + if (term) { + const inner = term[1]; + if (!inner.includes('```')) return inner; + return text; + } + + // (2) unterminated: 시작 ``` 만, 본문 중에 ``` 가 더 없음. + const backtickGroups = (trimmed.match(/```/g) || []).length; + if (backtickGroups === 1) { + const unterm = trimmed.match(/^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*)$/); + if (unterm) return unterm[1]; + } + + return text; +} + export function renderMathMarkdown(text: string | null | undefined): string { if (!text) return ''; try { - const html = mathMarked.parse(text) as string; + const normalized = stripOuterFence(text); + const html = mathMarked.parse(normalized) as string; return DOMPurify.sanitize(html, SANITIZE_OPTS); } catch { return DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); diff --git a/scripts/strip_outer_fences.py b/scripts/strip_outer_fences.py new file mode 100644 index 0000000..9d3da1a --- /dev/null +++ b/scripts/strip_outer_fences.py @@ -0,0 +1,112 @@ +"""DB 일괄 — 5개 텍스트 필드의 outer fenced code block unwrap. + +처리 대상: + - study_questions.question_text / choice_1~4 / explanation / ai_explanation + - study_topic_subject_notes.content + +두 케이스: + (1) terminated: ```...\n본문\n``` 으로 wrap. inner 에 ``` 추가 없으면 unwrap. + (2) unterminated: 시작 ``` 만 있고 닫음 누락. 본문 중 ``` 추가 없으면 (백틱 그룹 = 1) unwrap. + +정상 중간 코드블록 (예: ```python ... ```) 가 본문 중에 있는 경우는 보존. + +dry-run 먼저 출력 (각 필드 N건). 그 다음 --apply 옵션으로 UPDATE. +""" + +from __future__ import annotations + +import asyncio +import re +import sys + +import asyncpg + + +TARGETS: list[tuple[str, str, str]] = [ + # (table, field, where_extra) + ("study_questions", "question_text", "deleted_at IS NULL"), + ("study_questions", "choice_1", "deleted_at IS NULL"), + ("study_questions", "choice_2", "deleted_at IS NULL"), + ("study_questions", "choice_3", "deleted_at IS NULL"), + ("study_questions", "choice_4", "deleted_at IS NULL"), + ("study_questions", "explanation", "deleted_at IS NULL AND explanation IS NOT NULL"), + ("study_questions", "ai_explanation", "deleted_at IS NULL AND ai_explanation IS NOT NULL"), + ("study_topic_subject_notes", "content", "content IS NOT NULL"), +] + + +TERM_RE = re.compile(r"^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*?)\n```$") +UNTERM_RE = re.compile(r"^```[A-Za-z0-9_-]*[ \t]*\n([\s\S]*)$") + + +def strip_outer_fence(text: str) -> str | None: + """unwrap 가능하면 새 텍스트 반환, 아니면 None (변경 없음).""" + if not text: + return None + trimmed = text.strip() + # (1) terminated + m = TERM_RE.match(trimmed) + if m: + inner = m.group(1) + if "```" not in inner: + return inner + return None + # (2) unterminated + backtick_groups = trimmed.count("```") + if backtick_groups == 1: + m2 = UNTERM_RE.match(trimmed) + if m2: + return m2.group(1) + return None + + +async def scan_and_apply(conn: asyncpg.Connection, apply: bool) -> None: + total_to_change = 0 + for table, field, where in TARGETS: + rows = await conn.fetch( + f"SELECT id, {field} AS val FROM {table} WHERE {where}" + ) + candidates: list[tuple[int, str, str]] = [] + for r in rows: + val = r["val"] + if val is None: + continue + new = strip_outer_fence(val) + if new is not None and new != val: + candidates.append((r["id"], val, new)) + n = len(candidates) + total_to_change += n + print(f" {table}.{field}: {n}건") + if n and not apply: + sample_id, sample_val, _ = candidates[0] + head = sample_val[:80].replace("\n", "\\n") + print(f" 샘플 id={sample_id} head={head!r}") + if apply and n: + for qid, _old, new in candidates: + await conn.execute( + f"UPDATE {table} SET {field} = $1 WHERE id = $2", new, qid + ) + print(f" → UPDATE {n}건 적용 완료") + print(f"\n총 변경 대상: {total_to_change}건") + + +async def main() -> None: + apply = "--apply" in sys.argv + mode = "APPLY (UPDATE)" if apply else "DRY-RUN" + print(f"[{mode}] outer-fence unwrap 검사 시작\n") + + conn = await asyncpg.connect( + host="postgres", + port=5432, + user="pkm", + password="uW38friypljVS0X2ULoMnw", + database="pkm", + ) + try: + await scan_and_apply(conn, apply=apply) + finally: + await conn.close() + + +if __name__ == "__main__": + asyncio.run(main())