- XBX-1: normalizer 불릿 depth 보존 (D1/D2 마커) + 조립 로직 계층 반영 - XBX-3: 하단 구조 개선 — 하나의 큰 박스 안에 중제목 헤더 + 세로 구분선 2분할 - XBX-5: before→filled→after 파이프라인 연결 확인 (filled 2.2MB, 측정/재배분 정상) - XBX-6: Type B에서 Sonnet 재구성 + renderer 스킵 — code_assembled 직접 사용 - final.html: 4,934 bytes → 2.2MB (Type B 정상 출력) - Type A 코드 한 글자도 안 건드림 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""Phase T-1: MDX 4-Layer 파서.
|
|
|
|
Stage 0에서 호출. 원본 MDX를 정규화하여 이후 모든 Stage에 깨끗한 입력 제공.
|
|
|
|
Layer 1: python-frontmatter — YAML frontmatter 분리, title 추출
|
|
Layer 2: regex — 코드블록 보호 + MDX 전용 패턴 (details, :::, JSX, import)
|
|
Layer 3: markdown-it-py — AST 파싱 → 이미지/표/헤딩 구조 추출
|
|
Layer 4: regex — 텍스트 정리, 빈 줄 정리, clean_text
|
|
|
|
조사 결과 (T-1):
|
|
- python-frontmatter: parse() → (dict, str). frontmatter 없으면 안전하게 {}
|
|
- markdown-it-py: js-default 프리셋에 table 기본 포함. 한국어 정상
|
|
- 코드블록 보호: backtick 10→3 순서 매칭. 중첩/inline 검증됨
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import logging
|
|
from typing import Any
|
|
|
|
import frontmatter
|
|
from markdown_it import MarkdownIt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# 코드블록 보호 (Layer 2 선행)
|
|
# ══════════════════════════════════════
|
|
|
|
class _CodeBlockProtector:
|
|
"""코드블록을 placeholder로 보호하고 복원.
|
|
|
|
backtick 개수가 많은 순서(10→3)로 매칭하여 중첩 코드블록 안전 처리.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._store: dict[str, str] = {}
|
|
self._counter = 0
|
|
|
|
def _make_key(self) -> str:
|
|
self._counter += 1
|
|
return f"__CODEBLOCK_{self._counter}__"
|
|
|
|
def protect(self, text: str) -> str:
|
|
# fenced code blocks (큰 backtick부터)
|
|
for n in range(10, 2, -1):
|
|
pattern = rf"^(`{{{n}}})([^\n]*)\n(.*?)\n\1\s*$"
|
|
|
|
def _replacer(m, _n=n):
|
|
key = self._make_key()
|
|
self._store[key] = m.group(0)
|
|
return key
|
|
|
|
text = re.sub(pattern, _replacer, text, flags=re.MULTILINE | re.DOTALL)
|
|
|
|
# inline code
|
|
def _inline_replacer(m):
|
|
key = self._make_key()
|
|
self._store[key] = m.group(0)
|
|
return key
|
|
|
|
text = re.sub(r"`[^`\n]+`", _inline_replacer, text)
|
|
return text
|
|
|
|
def restore(self, text: str) -> str:
|
|
for key, original in self._store.items():
|
|
text = text.replace(key, original)
|
|
return text
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# Layer 2: MDX 전용 패턴 처리
|
|
# ══════════════════════════════════════
|
|
|
|
def _convert_md_list_to_html(text: str) -> str:
|
|
"""마크다운 리스트(* item, - item)를 HTML <ul><li>로 변환.
|
|
|
|
들여쓰기 수준(2-4칸)을 감지하여 중첩 <ul>을 생성한다.
|
|
"""
|
|
lines = text.split("\n")
|
|
result = []
|
|
list_stack: list[int] = [] # 현재 열린 리스트의 들여쓰기 레벨들
|
|
|
|
for line in lines:
|
|
m = re.match(r"^(\s*)[*\-]\s+(.+)$", line)
|
|
if m:
|
|
indent = len(m.group(1))
|
|
content = m.group(2)
|
|
if not list_stack:
|
|
result.append("<ul>")
|
|
list_stack.append(indent)
|
|
elif indent > list_stack[-1]:
|
|
result.append("<ul>")
|
|
list_stack.append(indent)
|
|
else:
|
|
while len(list_stack) > 1 and indent < list_stack[-1]:
|
|
result.append("</ul></li>")
|
|
list_stack.pop()
|
|
result.append(f"<li>{content}")
|
|
else:
|
|
while list_stack:
|
|
result.append("</li></ul>")
|
|
list_stack.pop()
|
|
result.append(line)
|
|
|
|
while list_stack:
|
|
result.append("</li></ul>")
|
|
list_stack.pop()
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
def _convert_md_table_to_html(text: str) -> str:
|
|
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
|
|
|
|
어떤 마크다운 테이블이든 동작. 하드코딩 없음.
|
|
"""
|
|
lines = text.split("\n")
|
|
result = []
|
|
table_lines = []
|
|
in_table = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("|") and stripped.endswith("|"):
|
|
table_lines.append(stripped)
|
|
in_table = True
|
|
else:
|
|
if in_table and table_lines:
|
|
result.append(_render_md_table(table_lines))
|
|
table_lines = []
|
|
in_table = False
|
|
result.append(line)
|
|
|
|
if table_lines:
|
|
result.append(_render_md_table(table_lines))
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
def _render_md_table(table_lines: list[str]) -> str:
|
|
"""마크다운 테이블 라인들을 HTML 테이블로."""
|
|
if len(table_lines) < 2:
|
|
return "\n".join(table_lines)
|
|
|
|
def _parse_row(line):
|
|
cells = [c.strip() for c in line.split("|")]
|
|
# 앞뒤 빈 셀 제거 (| col1 | col2 | → ['', 'col1', 'col2', ''])
|
|
return [c for c in cells if c or c == ""].__getitem__(slice(1, -1)) if cells[0] == "" else cells
|
|
|
|
headers = _parse_row(table_lines[0])
|
|
|
|
# 구분선(|---|---|) 스킵
|
|
data_start = 1
|
|
if len(table_lines) > 1 and all(c.strip().replace("-", "").replace(":", "") == "" for c in table_lines[1].split("|") if c.strip()):
|
|
data_start = 2
|
|
|
|
rows = [_parse_row(line) for line in table_lines[data_start:]]
|
|
|
|
# HTML 생성 — 셀 내 <br/> → <br> 유지 (줄바꿈 역할)
|
|
header_html = "".join(f"<th>{h}</th>" for h in headers)
|
|
rows_html = ""
|
|
for row in rows:
|
|
cells = ""
|
|
for c in row:
|
|
c = re.sub(r"<br\s*/?>", "<br>", c)
|
|
cells += f"<td>{c}</td>"
|
|
rows_html += f"<tr>{cells}</tr>\n"
|
|
|
|
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
|
|
|
|
|
|
def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
|
|
"""MDX 전용 패턴 처리. popups를 추출하고 텍스트에서 마커로 교체.
|
|
|
|
Returns:
|
|
(처리된 텍스트, popups 리스트)
|
|
"""
|
|
popups = []
|
|
|
|
# <details><summary>제목</summary>내용</details> → 팝업 추출
|
|
def _extract_popup(m):
|
|
title = m.group(1).strip()
|
|
content = m.group(2).strip()
|
|
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
|
|
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
|
|
content = content.replace("</div>", "")
|
|
# 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내 <br/>로 행이 쪼개지는 것 방지)
|
|
content = _convert_md_table_to_html(content)
|
|
# 테이블 밖 <br/> → \n (테이블 안은 이미 <br>로 변환 완료)
|
|
content = re.sub(r"<br\s*/>", "\n", content)
|
|
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
|
|
# 마크다운 리스트(* item) → HTML <ul><li>
|
|
content = _convert_md_list_to_html(content)
|
|
popups.append({"title": title, "content": content})
|
|
return f"[팝업: {title}]"
|
|
|
|
text = re.sub(
|
|
r"<details>\s*<summary[^>]*>(.+?)</summary>(.*?)</details>",
|
|
_extract_popup,
|
|
text,
|
|
flags=re.DOTALL,
|
|
)
|
|
|
|
# import 문 제거
|
|
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
|
|
text = re.sub(r"^export\s+.+$", "", text, flags=re.MULTILINE)
|
|
|
|
# <br/> 제거
|
|
text = re.sub(r"<br\s*/?>", "", text)
|
|
|
|
# JSX div style → 태그만 제거 (내용 유지)
|
|
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", text)
|
|
text = text.replace("</div>", "")
|
|
|
|
# 커스텀 컴포넌트 (<Component />, <Component>...</Component>)
|
|
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
|
|
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
|
|
|
|
# :::directive[제목] → ## 승격 + 핵심요약 마킹
|
|
def _process_directive(m):
|
|
directive = m.group(1)
|
|
title = m.group(2)
|
|
if directive in ("note", "tip", "caution", "danger"):
|
|
return f"[핵심요약: {title}]"
|
|
return f"## {title}"
|
|
|
|
text = re.sub(r":::(\w+)\[(.+?)\]", _process_directive, text)
|
|
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
|
|
|
|
# ## N. 제목 → ## 제목 (번호 제거, 공백 1개 이상 필수 — T-1 조사 버그 수정)
|
|
text = re.sub(r"^## \d+\.\s+", "## ", text, flags=re.MULTILINE)
|
|
|
|
# ### N.N 제목 → ### 제목
|
|
text = re.sub(r"^### \d+\.\d+\s+", "### ", text, flags=re.MULTILINE)
|
|
|
|
# * **제목** → ## 승격 (## 전 도입부에서만)
|
|
first_hash = text.find("\n## ")
|
|
if first_hash == -1:
|
|
first_hash = len(text)
|
|
intro = text[:first_hash]
|
|
rest = text[first_hash:]
|
|
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
|
|
text = intro + rest
|
|
|
|
# 이탤릭 출처 (단독 줄)
|
|
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
|
|
|
|
# 장식용 --- 제거
|
|
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
|
|
|
|
return text, popups
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# Layer 3: AST 파싱
|
|
# ══════════════════════════════════════
|
|
|
|
def _extract_structure(text: str) -> dict[str, Any]:
|
|
"""markdown-it-py AST 파싱으로 구조 추출.
|
|
|
|
Returns:
|
|
{"images": [...], "tables": [...], "sections": [...]}
|
|
"""
|
|
md = MarkdownIt("js-default")
|
|
tokens = md.parse(text)
|
|
|
|
images = []
|
|
tables = []
|
|
sections = []
|
|
|
|
current_section_title = ""
|
|
current_section_lines = []
|
|
|
|
current_section_level = 2
|
|
bullet_depth = 0 # 불릿 중첩 깊이 추적 (bullet_list_open/close)
|
|
|
|
def _flush_section():
|
|
nonlocal current_section_title, current_section_lines, current_section_level, bullet_depth
|
|
if current_section_title:
|
|
sections.append({
|
|
"level": current_section_level,
|
|
"title": current_section_title,
|
|
"content": "\n".join(current_section_lines).strip(),
|
|
})
|
|
current_section_lines = []
|
|
current_section_level = 2
|
|
bullet_depth = 0
|
|
|
|
for i, token in enumerate(tokens):
|
|
# 이미지 추출 (inline children)
|
|
if token.type == "inline" and token.children:
|
|
for child in token.children:
|
|
if child.type == "image":
|
|
attrs = child.attrs or {}
|
|
images.append({
|
|
"alt": child.content or attrs.get("alt", ""),
|
|
"path": attrs.get("src", ""),
|
|
})
|
|
|
|
# 표 추출
|
|
if token.type == "table_open":
|
|
table = {"headers": [], "rows": []}
|
|
# 이후 토큰에서 thead/tbody 파싱
|
|
j = i + 1
|
|
in_thead = False
|
|
in_tbody = False
|
|
current_row = []
|
|
while j < len(tokens) and tokens[j].type != "table_close":
|
|
t = tokens[j]
|
|
if t.type == "thead_open":
|
|
in_thead = True
|
|
elif t.type == "thead_close":
|
|
in_thead = False
|
|
if current_row:
|
|
table["headers"] = current_row
|
|
current_row = []
|
|
elif t.type == "tbody_open":
|
|
in_tbody = True
|
|
elif t.type == "tbody_close":
|
|
in_tbody = False
|
|
elif t.type == "tr_close":
|
|
if in_tbody and current_row:
|
|
table["rows"].append(current_row)
|
|
elif in_thead and current_row:
|
|
table["headers"] = current_row
|
|
current_row = []
|
|
elif t.type == "inline" and (in_thead or in_tbody):
|
|
current_row.append(t.content)
|
|
j += 1
|
|
if table["headers"] or table["rows"]:
|
|
tables.append(table)
|
|
|
|
# 불릿 depth 추적 (섹션 내용 수집 시 계층 보존)
|
|
if current_section_title:
|
|
if token.type == "bullet_list_open":
|
|
bullet_depth += 1
|
|
elif token.type == "bullet_list_close":
|
|
bullet_depth = max(0, bullet_depth - 1)
|
|
|
|
# 섹션 추출 (## 및 ### 기준 — 대목차/소목차 모두)
|
|
if token.type == "heading_open" and token.tag in ("h2", "h3"):
|
|
# 다음 토큰이 inline (제목 텍스트) — 무의미한 제목(<br/> 등)은 건너뜀
|
|
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
|
heading_text = tokens[i + 1].content.strip()
|
|
# <br/>, 빈 문자열, 숫자만 등은 section 제목으로 부적합
|
|
clean_heading = re.sub(r'<br\s*/?>', '', heading_text).strip()
|
|
if clean_heading and len(clean_heading) > 1:
|
|
_flush_section()
|
|
current_section_title = clean_heading
|
|
current_section_level = 2 if token.tag == "h2" else 3
|
|
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
|
|
"ordered_list_open", "fence"):
|
|
# 섹션 내용 수집 — inline 토큰의 content만
|
|
pass
|
|
if current_section_title and token.type == "inline" and token.tag == "":
|
|
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
|
|
parent_type = tokens[i - 1].type if i > 0 else ""
|
|
if parent_type != "heading_open":
|
|
# depth prefix 추가: D1=1단 불릿, D2=2단 불릿, D3=3단 불릿
|
|
depth = max(1, bullet_depth) if bullet_depth > 0 else 0
|
|
if depth > 0:
|
|
current_section_lines.append(f"D{depth}: {token.content}")
|
|
else:
|
|
current_section_lines.append(token.content)
|
|
|
|
_flush_section()
|
|
|
|
return {"images": images, "tables": tables, "sections": sections}
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# Layer 4: 텍스트 정리
|
|
# ══════════════════════════════════════
|
|
|
|
def _clean_text(text: str) -> str:
|
|
"""최종 텍스트 정리: 남은 HTML 태그 제거, 빈 줄 정리."""
|
|
# 이미지 참조 보존 (markdown 형식 → 마커)
|
|
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1]", text)
|
|
|
|
# 남은 HTML 태그 제거 (self-closing)
|
|
text = re.sub(r"<[^>]+/?>", "", text)
|
|
|
|
# 연속 빈 줄 정리
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
|
|
return text.strip()
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# 메인 함수
|
|
# ══════════════════════════════════════
|
|
|
|
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
|
"""MDX 원본을 4-Layer 파서로 정규화.
|
|
|
|
Stage 0에서 호출. 결과는 PipelineContext.normalized에 저장.
|
|
|
|
Returns:
|
|
{
|
|
"clean_text": str,
|
|
"title": str,
|
|
"images": [{"alt": str, "path": str}],
|
|
"popups": [{"title": str, "content": str}],
|
|
"tables": [{"headers": list, "rows": list}],
|
|
"sections": [{"level": int, "title": str, "content": str}],
|
|
}
|
|
"""
|
|
# ── Layer 1: frontmatter 분리 ──
|
|
metadata, body = frontmatter.parse(raw_mdx)
|
|
title = metadata.get("title", "")
|
|
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
|
|
|
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
|
protector = _CodeBlockProtector()
|
|
protected = protector.protect(body)
|
|
processed, popups = _process_mdx_patterns(protected)
|
|
restored = protector.restore(processed)
|
|
logger.info(f"[Layer 2] popups={len(popups)}개, 코드블록={protector._counter}개 보호/복원")
|
|
|
|
# ── Layer 3: AST 파싱 → 구조 추출 ──
|
|
structure = _extract_structure(restored)
|
|
images = structure["images"]
|
|
tables = structure["tables"]
|
|
sections = structure["sections"]
|
|
logger.info(f"[Layer 3] images={len(images)}, tables={len(tables)}, sections={len(sections)}")
|
|
|
|
# ── Layer 4: 텍스트 정리 ──
|
|
clean_text = _clean_text(restored)
|
|
logger.info(f"[Layer 4] clean_text={len(clean_text)}자")
|
|
|
|
return {
|
|
"clean_text": clean_text,
|
|
"title": title,
|
|
"images": images,
|
|
"popups": popups,
|
|
"tables": tables,
|
|
"sections": sections,
|
|
}
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# Stage 0 검증
|
|
# ══════════════════════════════════════
|
|
|
|
def validate_stage0(result: dict, raw_mdx: str) -> list[dict]:
|
|
"""Stage 0 출력 검증.
|
|
|
|
Returns:
|
|
에러 리스트 (빈 리스트 = 통과)
|
|
"""
|
|
errors = []
|
|
|
|
clean_text = result.get("clean_text", "")
|
|
if not clean_text.strip():
|
|
errors.append({
|
|
"severity": "FATAL",
|
|
"field": "clean_text",
|
|
"localization": "clean_text가 비어있음",
|
|
"instruction": "원본 MDX를 확인하세요",
|
|
})
|
|
return errors
|
|
|
|
# 원본 대비 텍스트 보존율 (30% 이상)
|
|
raw_text_len = len(re.sub(r"<[^>]+>|\{[^}]+\}|---\n.*?\n---", "", raw_mdx, flags=re.DOTALL).strip())
|
|
if raw_text_len > 0:
|
|
preservation = len(clean_text) / raw_text_len
|
|
if preservation < 0.3:
|
|
errors.append({
|
|
"severity": "FATAL",
|
|
"field": "clean_text",
|
|
"localization": f"텍스트 보존율 {preservation:.0%} < 30%",
|
|
"evidence": f"원본 {raw_text_len}자 → clean {len(clean_text)}자",
|
|
"instruction": "파서가 너무 많은 텍스트를 제거함",
|
|
})
|
|
|
|
# 이미지 수 대조
|
|
raw_img_count = len(re.findall(r"!\[", raw_mdx))
|
|
result_img_count = len(result.get("images", []))
|
|
if raw_img_count > 0 and result_img_count == 0:
|
|
errors.append({
|
|
"severity": "ADJUSTABLE",
|
|
"field": "images",
|
|
"localization": f"원본 이미지 {raw_img_count}개, 추출 0개",
|
|
"instruction": "이미지 추출 패턴 확인",
|
|
})
|
|
|
|
# 팝업 수 대조
|
|
raw_details_count = raw_mdx.count("<details>")
|
|
result_popup_count = len(result.get("popups", []))
|
|
if raw_details_count > 0 and result_popup_count == 0:
|
|
errors.append({
|
|
"severity": "ADJUSTABLE",
|
|
"field": "popups",
|
|
"localization": f"원본 details {raw_details_count}개, 추출 0개",
|
|
"instruction": "details 추출 패턴 확인",
|
|
})
|
|
|
|
return errors
|