- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동 - docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체) - docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리 - docs/test-*.html 등 초기 테스트 파일 정리 - 참고 페이지/ 스크린샷 정리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
37 KiB
Design Agent Architecture — Phase T
MDX 원본 문서 → 고정 크기 HTML 슬라이드(1280×720px) 자동 생성 파이프라인 폰트 위계가 먼저, 컨테이너가 따라간다 — 텍스트 보존 · 폰트 위계 강제 · 디자인 요소 크기를 수학적으로 역산
1. 핵심 설계 원칙
1.1 AI vs 코드 역할 분리
| 역할 | 담당 | 해당 Stage |
|---|---|---|
| 콘텐츠 판단 · 분류 | AI (Kei Persona API / Opus) | 1A, 1B |
| 폰트 위계 확정 + 컨테이너 비율 역산 | 코드 (결정론적 수학) | 1.5a |
| 블록 선택 · 변형 결정 | 코드 (키워드 매칭 + 룩업 테이블) | 1.7 |
| 블록 schema 기반 디자인 예산 역산 | 코드 (결정론적 수학) | 1.5b |
| HTML 생성 | AI (Claude Sonnet 4) | 2 |
| 텍스트·구조 검증 (L1~L3) | 코드 (kiwipiepy + regex) | 2 직후 |
| 실측 렌더링 (L4) | Selenium (headless Chrome) | 3 직후 |
| 시각 품질 평가 (L5) | AI (Opus Vision) | 4 |
| HTML 조립 · 서빙 | 코드 | 3, 5 |
AI가 공간을 볼 수 없는 근본적 한계를 코드(수학적 예산 역산)로 보완하는 구조. LLM이 참고 HTML 구조를 70~90% 복사하는 경향을 장점으로 활용 — "디자인 레퍼런스" 프레이밍.
1.2 폰트 위계 (Phase T 핵심 — 이것이 모든 계산의 출발점)
Phase S에서 폰트 크기가 중요도와 완전히 역전됨 (sidebar 14px > key-msg 11px). Phase T는 위계를 먼저 확정하고, 컨테이너가 위계에 맞춰지는 방향으로 전환.
| 영역 | 중요도 | 폰트 범위 | 강제 규칙 |
|---|---|---|---|
| 핵심 (key-msg) | 1위 | 14px bold | 무조건 한 줄, 슬라이드에서 가장 큰 폰트 |
| 본문 (core) | 2위 | 12px | 본문 텍스트 기본 |
| 배경 (bg) | 3위 | 10-12px | 텍스트 양에 따라 범위 내 조정 |
| 첨부 (sidebar) | 4위 | 9-11px | 참고 자료, 가장 작아도 됨 |
검증 기준: font_size(핵심) > font_size(본문) ≥ font_size(배경) > font_size(첨부) — 위반 시 에러.
1.3 파이프라인 운영 패턴
누적 컨텍스트 객체 (Pydantic BaseModel)
각 Stage가 독립 JSON을 읽고 쓰는 대신, PipelineContext 하나가 파이프라인을 따라가며 점진적으로 확장. T-0 조사 결과 Pydantic BaseModel 채택 (dataclass 아님) — model_dump_json() 직렬화, validate_assignment=True 타입 검증.
context.normalized.clean_text # Stage 0
context.normalized.title # Stage 0
context.normalized.images # Stage 0
context.normalized.popups # Stage 0
context.normalized.tables # Stage 0
context.analysis.core_message # Stage 1A
context.analysis.topics[0].source_hint # Stage 1A
context.analysis.page_structure # Stage 1A
context.topics[0].relation_type # Stage 1B
context.topics[0].expression_hint # Stage 1B
context.topics[0].source_data # Stage 1B
context.font_hierarchy # Stage 1.5a
context.container_ratio # Stage 1.5a (동적 body:sidebar 비율)
context.containers["본심"].text_budget # Stage 1.5a
context.references["본심"].block_id # Stage 1.7
context.references["본심"].design_reference_html # Stage 1.7
context.containers["본심"].design_budget # Stage 1.5b (블록 선택 후 재계산)
context.generated_html # Stage 2
context.rendered_html # Stage 3
context.measurement # Stage 4
context.quality_score # Stage 4
각 Stage 공통 실행 패턴
async def run_stage(stage_fn, context, stage_name, max_retries=1):
for attempt in range(max_retries + 1):
result = await stage_fn(context)
errors = result.get("_errors", [])
if not errors:
# Pydantic: model_copy(update=...) 사용
context = context.model_copy(update=result)
context.save_snapshot(stage_name)
return context
context.errors.append({"stage": stage_name, "attempt": attempt, "errors": errors})
if attempt < max_retries:
context.retry_feedback = build_retry_feedback(stage_name, errors)
raise StageFailure(stage_name, errors)
에러 3등급 분류
| 등급 | 의미 | 대응 |
|---|---|---|
| FATAL | 복구 불가 (원본 문제, JSON 파싱 실패) | 파이프라인 중단 |
| RETRYABLE | AI 재시도로 해결 가능 (분류 오류, 누락) | Self-Refine 피드백 포함 재요청 (최대 2회) |
| ADJUSTABLE | 코드로 자동 조정 가능 (높이 부족, 비율 초과) | 자동 조정 후 경고 기록 |
스냅샷 저장
data/runs/{run_id}/step{N}_context.json — run_id는 YYYYMMDD_HHMMSS timestamp.
Pydantic model_dump_json()으로 직렬화. diff step1a_context.json step1b_context.json으로 추적.
2. 파이프라인 (11 Stage)
Stage 0: MDX 표준화
- 담당: 코드
- 신규 파일:
src/mdx_normalizer.py - 라이브러리:
python-frontmatter+markdown-it-py+mdit-py-plugins(총 ~1MB) - 입력: 원본 MDX 텍스트
- 처리 (4-Layer 파서):
- Layer 1:
python-frontmatter.parse()→(metadata_dict, body_str)분리. title 추출. - Layer 2: 코드블록 보호 (backtick 10→3 순서로 fenced block → placeholder) → MDX 전용 패턴 처리:
- Astro
:::directive→[핵심요약]...[/핵심요약]마커 <details><summary>제목</summary>내용</details>→ popups[] 추출- JSX
style={{}},import/export제거
- Astro
- Layer 3:
markdown-it-pyAST 파싱 (js-default프리셋, table 기본 포함):- heading 토큰 → 섹션 구조 추출 (tag, level, content, source line)
- image 토큰 → images[] 추출 (alt, src)
- table 토큰 → tables[] 추출 (header, rows)
- 코드블록 placeholder 복원
- Layer 4: 텍스트 정리 — 남은 HTML 태그 제거, 빈 줄 정리, 최종 clean_text
- Layer 1:
- 출력:
{ "clean_text": str, # 정규화된 순수 텍스트 "title": str, # frontmatter 제목 "images": [{"alt": str, "path": str}], "popups": [{"title": str, "content": str}], "tables": [{"header": list, "rows": list}], "sections": [{"level": int, "title": str, "content": str}] # ## 기준 섹션 분리 } - 검증:
- clean_text 비어있지 않음
##섹션 최소 1개- 원본 대비 30% 이상 텍스트 보존 (과도한 제거 방지)
- images[] 수 = 원본
![패턴 수 - popups[] 수 = 원본
<details>패턴 수
- 주의: 기존
normalize_mdx()의r"^## \d+\.\s*"→r"^## \d+\.\s+"수정 (공백 1개 이상 필수) - 저장:
context.normalized.*
Stage 1A: Kei 꼭지 추출
- 담당: AI (Kei Persona API, localhost:8000, Opus — SSE 스트리밍)
- 입력:
context.normalized.clean_text(Stage 0에서 정규화된 텍스트) - 처리: Kei가 콘텐츠를 읽고 꼭지 분류 + 스토리라인 설계
- 출력:
topics[]— id, title, purpose, role, layer, weight, source_hint (원본 MDX 섹션 참조)page_structure— { "본심": {topic_ids, weight}, "배경": {...}, "첨부": {...}, "결론": {...} }core_message— 슬라이드 핵심 메시지 한 줄
- 검증 (Pydantic + 코드 대조):
- 형식: weight 합 0.9~1.1 범위, 본심 weight ≥ 0.3, 필수 필드 존재, topics > 0
- 내용 대조: 원본
##섹션 수 vs topic 수 비교 — 차이가 크면 분류 오류 가능성 - 내용 대조: topic summary 키워드가 원본 해당 섹션에 실제 존재하는지 (kiwipiepy)
- 실패 시 RETRYABLE → Self-Refine 피드백 포함 재요청 (최대 2회)
- 저장:
context.analysis,context.topics,context.page_structure
Stage 1B: 컨셉 구체화
- 담당: AI (Kei Persona API, Opus — SSE 스트리밍)
- 입력:
context.normalized.clean_text+context.topics(Stage 1A 결과) - 처리: 각 꼭지에 관계 유형, 표현 힌트, 원본 텍스트 참조 부여
- 출력: topics에 아래 필드 병합
relation_type— 7개 enum: hierarchy / cause_effect / comparison / sequence / definition / inclusion / noneexpression_hint— 디자인 방향 힌트 (3문장 구조: 관계 선언 + 콘텐츠 설명 + 시각 지침)source_data— 원본 텍스트 참조
- 검증 (Pydantic + 코드 대조 + 모순 탐지):
-
형식: relation_type이 7개 enum 중 하나, expression_hint 비어있지 않음, source_data 비어있지 않음
-
모순 결정 테이블:
purpose 모순인 relation_type 이유 결론강조 comparison, sequence 결론은 비교나 순서가 아님 문제제기 sequence, definition 문제제기는 순서 나열이나 정의가 아님 용어정의 hierarchy, cause_effect 정의 나열은 상하위나 인과가 아님 구조시각화 none 시각화할 관계가 없으면 구조시각화가 아님 -
source_data 원본 대조: source_data 키워드가 원본 clean_text에 실제 존재하는지 (kiwipiepy). 없는 출처 감지 → 할루시네이션
-
relation_type 원본 대조: 한국어 관계 표현 패턴으로 검증
relation_type 원본에 있어야 하는 패턴 (일부) comparison vs, 반면, 차이점, 에 비해, 와 달리, 상이, 구분 sequence →, 이후, 단계, 먼저, 점진적, 과정, 를 거쳐 hierarchy 상위, 하위, 속하, 범주, 구성요소, 체계, 계층 inclusion 포함, 융합, 통합, 결합, 내포, 포괄, 연계 cause_effect 때문에, 따라서, 결과, 로 인해, 초래, 야기, 기인 definition 이란, 정의, 의미, 을 말한다, 라 함은, 용어 -
실패 시 RETRYABLE → 모순/불일치 topic만 피드백 포함 재요청 (최대 2회)
-
- 저장:
context.topics[].relation_type,.expression_hint,.source_data
Stage 1.5a: 폰트 위계 확정 + 컨테이너 비율 역산
- 담당: 코드 (AI 아님, 결정론적 수학)
- 입력: page_structure weight + 각 영역의 source_data 텍스트 양
- 핵심 원칙: 폰트가 먼저, 컨테이너가 따라간다
(1) 폰트 위계에서 필요 공간 계산
FONT_HIERARCHY = {
"핵심": {"min": 14, "max": 14, "weight": "bold"},
"본심": {"min": 12, "max": 12},
"배경": {"min": 10, "max": 12},
"첨부": {"min": 9, "max": 11},
}
def calculate_required_space(role, content, font_size):
"""이 폰트 크기로 이 텍스트를 넣으려면 몇 px 필요한가?"""
char_width_px = font_size * 0.947 # Pretendard 한글 실측 비율
line_height_px = font_size * 1.5 # 본문 기준
chars_per_line = available_width // char_width_px
total_lines = len(content) // chars_per_line
required_height = total_lines * line_height_px + padding
return required_height
(2) 동적 body:sidebar 비율 역산
고정 65:35가 아니라 텍스트 양에서 역산:
def calculate_container_ratio(roles_text_volume, font_hierarchy):
"""폰트 위계를 지키면서 모든 텍스트가 들어가는 비율을 역산"""
# 1. 각 역할의 위계 기준 폰트로 필요 공간 계산
sidebar_need = calculate_required_space("첨부", sidebar_text, font_hierarchy["첨부"]["max"])
body_need = sum(calculate_required_space(r, t, font_hierarchy[r]["max"])
for r, t in body_roles)
# 2. sidebar 충전율로 비율 결정
sidebar_capacity_at_35 = estimate_capacity(slide_width * 0.35, font_hierarchy["첨부"]["max"])
fill_rate = len(sidebar_text) / sidebar_capacity_at_35
if fill_rate < 0.5:
ratio = (72, 28) # sidebar 텍스트 적음 → body 확대
elif fill_rate < 0.8:
ratio = (68, 32) # 보통
else:
ratio = (65, 35) # 현재 유지
return ratio # (body_pct, sidebar_pct)
(3) 텍스트 예산 계산
비율 확정 후, 각 영역의 텍스트 예산:
def calculate_text_budget(container, content, font_size):
char_width_px = font_size * 0.947
line_height_px = font_size * 1.5
inner_width = container.width_px - padding * 2
inner_height = container.height_px - padding * 2
chars_per_line = int(inner_width / char_width_px)
max_lines = int(inner_height / line_height_px)
max_chars = chars_per_line * max_lines
source_chars = len(content)
needs_compression = source_chars > max_chars
return TextBudget(
font_size=font_size,
chars_per_line=chars_per_line,
max_lines=max_lines,
max_chars=max_chars,
source_chars=source_chars,
needs_compression=needs_compression,
)
(4) 다단 레이아웃 판단
위계 범위 내 최소 폰트로도 텍스트가 안 들어가면 구조 변경:
1. 위계 기준 폰트(max)로 수용량 계산
2. 텍스트 양 > 수용량 → 폰트 1px 축소 (위계 min까지)
3. 최소 폰트로도 불가 → 레이아웃 변경 (1단→2단)
4. 2단으로도 불가 → 비율 조정 (sidebar 축소 → body 확대)
5. 비율 조정으로도 불가 → 텍스트 편집 필요 경고 (context.warnings에 기록)
- 검증: height_px 합 ≤ 전체 높이, 폰트 위계 유지, 음수 없음
- 저장:
context.font_hierarchy,context.container_ratio,context.containers[].text_budget
Stage 1.7: 참고 블록 선택 + 변형 결정
- 담당: 코드 (키워드 매칭 + 룩업 테이블, AI 아님)
- 입력: 1B의 relation_type + expression_hint + 1.5a의 컨테이너 스펙 + catalog.yaml
- 처리 4단계:
(1) relation_type → 블록 후보 (1차 필터)
catalog.yaml의 relation_types 필드로 필터:
candidates = [b for b in catalog.blocks
if relation_type in b.relation_types or not b.relation_types]
(2) expression_hint → 블록 세분화 (2차 필터 — 키워드 포함 여부)
expression_hint는 긴 문장이므로 정확한 문자열 매칭이 아니라 키워드 포함(substring) 매칭:
VISUAL_TYPE_KEYWORDS = {
"인과": {"keywords": ["인과", "현상->결과", "야기", "원인"], "blocks": ["callout-warning", "dark-bullet-list"]},
"나열_병렬": {"keywords": ["독립적 나열", "병렬 나열", "개별 증거"], "blocks": ["dark-bullet-list", "card-icon-desc"]},
"나열_정의": {"keywords": ["독립적 정의", "용어", "참조용"], "blocks": ["card-numbered"]},
"포함_계층": {"keywords": ["상위-하위", "포함 관계", "계층적"], "blocks": ["venn-diagram", "keyword-circle-row"]},
"강조_결론": {"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조"], "blocks": ["banner-gradient", "quote-big-mark"]},
"비교": {"keywords": ["대등 비교", "좌우 대조", "vs"], "blocks": ["compare-2col-split", "compare-3col-badge"]},
"순서": {"keywords": ["시간 순서", "단계별", "A->B->C"], "blocks": ["flow-arrow-horizontal", "process-horizontal"]},
}
def match_visual_type(expression_hint: str) -> str:
"""expression_hint에서 키워드를 찾아 시각적 유형 반환"""
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
if any(kw in expression_hint for kw in spec["keywords"]):
return vtype
return "default"
시각 매핑 근거 (Gestalt 원칙):
- 폐합(Closure) → hierarchy/inclusion → 원형(벤 다이어그램)
- 근접(Proximity) → comparison → 좌우 표/비교
- 연속(Continuity) → sequence → 화살표 흐름
- 유사(Similarity) → definition → 동일 형태 카드 반복
- PPTAgent(EMNLP 2025): "참고 기반 생성"의 효과를 학술 입증
(3) 컨테이너 크기 적합성 검사
candidates = [b for b in candidates
if b.min_height_px <= container.height_px]
(4) 블록 변형(variant) + 레이아웃 자동 선택
def select_block_variant(block, container, content):
if not block.variants or len(block.variants) <= 1:
return block.id, "default"
for variant in block.variants:
if variant.id == "compact" and container.height_px < 150:
return block.id, "compact"
if variant.id == "wide" and container_ratio[0] >= 70: # body 70% 이상
return block.id, "wide"
return block.id, "default"
(5) fallback 정의
모든 필터를 통과하는 후보가 없을 때의 카테고리별 기본 블록:
| 카테고리 | fallback 블록 | 이유 |
|---|---|---|
| cards | card-numbered | 가장 범용, compact~xlarge 대응 |
| emphasis | dark-bullet-list | 텍스트 중심, 높이 유연 |
| visuals | venn-diagram | N개 자동 배치 가능 |
| tables | compare-2col-split | 가장 기본적 비교 |
| media | image-side-text | 텍스트+이미지 조합 |
디자인 레퍼런스 HTML 생성
Jinja 변수를 샘플 데이터로 치환한 완성된 HTML + 구조 의도 주석. LLM이 이 구조를 70~90% 복사 → 레이아웃을 "발명"하지 않고 검증된 구조를 따름.
def generate_design_reference(block, variant, catalog_entry):
template = load_template(block.template)
sample_data = build_sample_data(catalog_entry.slots)
rendered = template.render(**sample_data)
# 구조 의도 주석 추가 (LLM이 의도를 정확히 파악)
annotated = f"<!-- {block.id}: {catalog_entry.visual} -->\n"
if catalog_entry.get("visual_diff"):
annotated += f"<!-- 차별점: {catalog_entry.visual_diff} -->\n"
annotated += rendered
return annotated
- 출력:
{
"block_id": "dark-bullet-list",
"variant": "default",
"visual_type": "인과",
"schema": {
"title": {"max_lines": 1, "font_size": 16, "max_chars": 30},
"bullet_item": {"max_lines": 1, "font_size": 14, "max_chars": 86},
"max_bullets": 5
},
"design_reference_html": "<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->\n<div ...>..."
}
- 검증: 선택된 블록이 catalog.yaml에 실제 존재, min_height_px ≤ container.height_px
- 저장:
context.references["본심"].*
Stage 1.5b: 디자인 예산 재계산 (블록 선택 후)
- 담당: 코드 (AI 아님)
- 입력: Stage 1.7에서 선택된 블록의 schema + Stage 1.5a의 컨테이너 스펙
- 목적: 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. 텍스트를 줄이는 것이 아니라 도형·이미지·CSS 요소의 크기를 맞추는 방향.
def calculate_design_budget(container, text_budget, block_schema):
# 블록 schema에서 텍스트 슬롯별 높이 합산
text_height = 0
for slot_name, spec in block_schema.items():
if slot_name.startswith("max_"):
continue
slot_lines = spec.get("max_lines", 1)
slot_font = spec.get("font_size", 14)
text_height += slot_lines * (slot_font * 1.6)
remaining_height = container.height_px - text_height - padding
remaining_width = container.width_px - padding
return DesignBudget(
available_height_px=remaining_height,
available_width_px=remaining_width,
max_circle_diameter=min(remaining_height, remaining_width) - 4,
max_img_width=remaining_width * 0.4,
max_img_height=remaining_height,
fits=remaining_height >= 0,
)
- 검증: available_height_px ≥ 0 (음수 = 블록이 컨테이너에 안 맞음 → Stage 1.7 재선택 또는 ADJUSTABLE)
- 저장:
context.containers["본심"].design_budget
Stage 2: HTML 생성
- 담당: AI (Claude Sonnet 4, Anthropic API 직접, 현재 모델:
claude-sonnet-4-20250514) - 입력: 원본 텍스트 + 누적 컨텍스트 전체
- 처리: 영역별(배경/본심/첨부/결론) 각각 개별 호출로 HTML 생성
프롬프트 구성 — 모든 수치를 구체적으로 전달 (Phase S 교훈: 추상적 프롬프트는 실패):
| 출처 | 포함 내용 |
|---|---|
| Stage 0 | clean_text (원본 텍스트 — "이 텍스트를 그대로 사용하라") |
| Stage 1A | core_message |
| Stage 1B | expression_hint, relation_type |
| Stage 1.5a | 확정된 폰트 크기, 줄 수, 글자 수, 컨테이너 px |
| Stage 1.5b | 디자인 요소 크기 제약 (max_circle_px, max_img_width 등) |
| Stage 1.7 | 디자인 레퍼런스 HTML + visual_diff 설명 |
프롬프트 예시:
[디자인 레퍼런스]
아래 HTML의 구조와 색상 패턴을 따르되 콘텐츠를 교체하세요.
<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->
<!-- 차별점: 같은 다크 계열 callout-warning과 달리 경고 아이콘 없음. 순수 나열용. -->
<div style="background:#1a2332; padding:20px; border-radius:6px;">
<!-- SLOT: title (1줄, 16px bold, max 30자) -->
<h3 style="color:#4a9eff; font-size:16px; font-weight:700;">샘플 제목</h3>
<!-- SLOT: bullets (1줄씩, 14px, max 86자, max 5개) -->
<ul style="list-style:none; padding:0;">
<li style="color:#e2e8f0; font-size:14px; padding:4px 0;">• 샘플 항목 1</li>
</ul>
</div>
[수치 제약 — 반드시 준수]
- 컨테이너: 너비 707px, 높이 176px
- 폰트: 11px (배경 영역 위계)
- 줄당 최대 68자
- 최대 10줄
- 디자인 요소 예산: 높이 84px, 너비 707px
[원본 텍스트 — 축약/변형 금지]
"DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음..."
[필수 규칙]
- inline style만 사용, <style> 블록 금지
- overflow:hidden 금지
- 디자인 레퍼런스의 구조를 따르되 콘텐츠에 맞게 커스텀
- 개조식 통일 (서술형 ~하다/~이다 → 개조식 ~에 해당/~인식되는 중)
- 이미지 처리: Stage 0에서 추출된
images[]의 경로와 크기 정보를 프롬프트에 포함. Stage 5에서 base64 인라인 변환. - 팝업 처리: Stage 0에서 추출된
popups[]를<details>/<summary>HTML로 변환 지시. - 출력:
{body_html, sidebar_html, footer_html} - 저장:
context.generated_html
분산 검증 시스템
5층 검증을 한 곳에 집중하지 않고, 각 Layer가 적합한 시점에 분산 실행.
재시도 프롬프트는 Self-Refine(NeurIPS 2023) 패턴: localization + evidence + instruction.
VASCAR(2024)의 Scorer+Suggester 분리: 점수 매기기와 피드백 생성을 분리.
Stage 2 직후: L1 + L2 + L3 (코드 검증)
| Layer | 도구 | 검증 내용 |
|---|---|---|
| L1 | kiwipiepy + regex | 키워드 보존율 80% 이상 |
| L2 | regex | Kei 메모("간결한 문제 제기용" 등)가 출력에 포함 안 됐는지 |
| L3 | regex | overflow:hidden 없는지, 폰트 위계 위반 없는지, inline style만 사용했는지 |
실패 시 → Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회):
[재생성 요청 - 시도 2/3]
이전 생성의 문제:
1. L1: 키워드 보존율 65%. 누락: {'BIM', '혼용', '설계오류'}
2. L3: overflow:hidden 감지
수정 지시 (Self-Refine):
- localization: 키워드 보존 실패, 구조 위반
- evidence: 원본 핵심 키워드 3개 누락, overflow:hidden 존재
- instruction: 누락 키워드 포함, overflow:hidden 제거, 나머지 제약 동일
Stage 3: 렌더링 (조립)
- 담당: 코드 (AI 아님)
- 입력: L1~L3 통과한 body/sidebar/footer HTML + 프리셋 grid + 동적 비율
- 처리:
- tokens.css + base.css 인라인 병합
- CSS Grid 프레임 구성 — 동적 비율 적용 (예:
72fr 28fror65fr 35fr) - 각 영역 HTML을
<div class="area-body">등에 삽입 - Pretendard 폰트 CDN 링크 포함
- 출력: 완전한 단독 실행 HTML
- 저장:
context.rendered_html
Stage 3 직후: L4 (Selenium 실측)
def validate_after_stage3(context, rendered_html):
measurements = selenium_measure(rendered_html)
errors = []
for area, m in measurements.items():
if m.scroll_height > m.client_height:
overflow = m.scroll_height - m.client_height
errors.append({
"layer": "L4", "severity": "RETRYABLE",
"localization": f"{area} overflow {overflow}px",
"evidence": f"scrollHeight {m.scroll_height} > clientHeight {m.client_height}",
"instruction": f"이 영역의 디자인 요소를 {overflow+10}px 줄이거나 bullet 1개 제거"
})
return errors # 실패 → 해당 영역만 Stage 2로 (최대 2회)
Stage 4: 품질 게이트 (L5)
- 담당: Selenium (스크린샷 캡처) + Opus Vision (품질 판정, 현재 모델:
claude-opus-4-0-20250514) - 처리:
- 전체 페이지 스크린샷 캡처 → Opus Vision에 base64 전송
- 5가지 평가 기준 (VASCAR 방식):
- 콘텐츠 겹침/잘림 없는가?
- 본심 영역이 시각적으로 가장 두드러지는가?
- 폰트가 읽을 수 있는 크기인가? 폰트 위계가 유지되는가?
- 한국어 비즈니스 프레젠테이션으로서 적절한가?
- 블록 유형에 다양성이 있는가?
- 0~100점 평가:
- 30점 미만 → 출력 차단 (FATAL)
- 30~60점 → Opus 피드백으로 Stage 2 재실행
- 60점 이상 → Stage 5로
- L4와의 차이: L4는 영역 단위 px 실측(Stage 3 직후), L5는 조립 후 전체 페이지 시각적 평가
- 저장:
context.measurement,context.quality_score
Stage 5: 서빙
- 담당: 코드
- 처리:
- 이미지 경로 → base64 인라인 변환 (다운로드 HTML에서도 이미지 표시)
<details>인쇄 시 자동 펼침 JS 삽입 (window.onbeforeprint)- final.html 저장
- 저장:
data/runs/{run_id}/final.html
3. 검증 흐름 요약
Stage 1A (Kei 분석)
↓
1A 검증 (Pydantic + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
↓ 통과
Stage 1B (컨셉 구체화)
↓
1B 검증 (모순 탐지 + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
↓ 통과
Stage 1.5a → 1.7 → 1.5b (코드, 결정론적)
↓
Stage 2 (HTML 생성)
↓
L1+L2+L3 ──실패──→ Self-Refine → Stage 2 재실행 (최대 2회)
↓ 통과
Stage 3 (조립)
↓
L4 ──실패──→ 실패 영역만 Stage 2로 (최대 2회)
↓ 통과
Stage 4 (L5 최종 판정)
↓
30점 미만 → 차단 (FATAL)
30~60점 → Opus 피드백으로 Stage 2 재실행 (최대 1회)
60점 이상 → Stage 5
재시도 총 예산
| 지점 | 최대 재시도 | 대상 |
|---|---|---|
| Stage 1A | 2회 | Kei 전체 |
| Stage 1B | 2회 | 실패 topic만 |
| L1~L3 | 2회 | 실패 영역만 |
| L4 | 2회 | 실패 영역만 |
| L5 | 1회 | 전체 |
| 최악 합계 | Stage 2 최대 5회 | 영역별 독립이므로 1영역 기준 |
전체 파이프라인 타임아웃: 300초. 초과 시 최선 결과 반환 + 경고.
4. 카탈로그 시스템 (catalog.yaml)
4.1 블록 구조
38개 블록, 6개 카테고리:
| 카테고리 | 블록 수 | 용도 |
|---|---|---|
| headers | 5 | 꼭지/섹션 제목 |
| cards | 9 | 항목 나열/비교 |
| tables | 3 | 비교 표, 스트라이프 표 |
| visuals | 6 | 벤 다이어그램, 프로세스, 흐름 |
| emphasis | 10 | 강조/콜아웃/배너/불릿 |
| media | 5 | 이미지 배치 |
별도 섹션: 4개 레이아웃 프리셋 (sidebar-right, two-column, hero-detail, single-column)
4.2 블록 메타데이터 (현재 상태 + Phase T 추가)
- id: block-id
name: 한글 이름
category: headers | cards | tables | visuals | emphasis | media
template: blocks/category/block-id.html
height_cost: compact | medium | large | xlarge
min_height_px: 80
relation_types: [comparison, cause_effect] # 빈 배열 = 모든 relation에 가능
min_items: 2 # 19/38 블록에 존재
max_items: 5 # 19/38 블록에 존재
visual: "시각적 설명"
when: "사용 적합 상황"
not_for: "부적합 상황"
purpose_fit: [핵심전달, 문제제기]
zone: full-width-only # 4/38 블록에 존재 (선택)
slots:
required: [title, description]
optional: [icon, source]
# --- Phase T 필수 추가 ---
schema: # ★ 현재 19/38 → 38/38 완성 필요
title: {max_lines: 1, font_size: 16, ref_chars: {body: 30, sidebar: 20}}
description: {max_lines: 3, font_size: 14, ref_chars: {body: 150, sidebar: 90}}
visual_diff: | # ★ 신규 (T-4), 유사 블록 20개에 추가
유사 블록과의 차이: ...
variants: # 현재 4/38 → 필요 시 확장
- id: default
- id: compact
4.3 expression_hint → 블록 매핑 (키워드 포함 매칭)
| 시각적 유형 | 매칭 키워드 | 매핑 블록 |
|---|---|---|
| 인과 | "인과", "현상->결과", "야기" | callout-warning, dark-bullet-list |
| 나열_병렬 | "독립적 나열", "병렬", "개별 증거" | dark-bullet-list, card-icon-desc |
| 나열_정의 | "독립적 정의", "참조용", "용어" | card-numbered |
| 포함_계층 | "상위-하위", "포함 관계", "계층적" | venn-diagram, keyword-circle-row |
| 강조_결론 | "핵심 메시지 강조", "임팩트" | banner-gradient, quote-big-mark |
| 비교 | "대등 비교", "좌우 대조", "vs" | compare-2col-split, compare-3col-badge |
| 순서 | "시간 순서", "단계별", "A->B->C" | flow-arrow-horizontal, process-horizontal |
4.4 Phase T에서 필요한 개선
| 항목 | 현재 | 목표 | 우선순위 |
|---|---|---|---|
| schema 완성 | 19/38 | 38/38 | 높음 (Stage 1.5b 필수) |
| visual_diff 추가 | 0/38 | 20/38 (유사 블록 그룹) | 중간 (T-3 프롬프트 품질) |
| ref_chars ↔ 컨테이너 폭 정합성 검증 | 없음 | 시작 시 자동 검증 | 높음 |
| 블록 독립 렌더링 스크린샷 | 없음 | 38개 PNG | 중간 (visual_diff 근거) |
5. 데이터 흐름 요약
[원본 MDX]
│
▼
Stage 0 ─── 코드 ──→ context.normalized
│ (python-frontmatter + markdown-it-py)
│ {clean_text, title, images[], popups[], tables[], sections[]}
▼
Stage 1A ── Kei ──→ context.analysis
│ {topics[], page_structure, core_message}
│ ✓ Pydantic 검증 + 원본 대조
▼
Stage 1B ── Kei ──→ context.topics[].relation_type, .expression_hint, .source_data
│ ✓ 모순 탐지 + 원본 패턴 대조
▼
Stage 1.5a ─ 코드 ──→ context.font_hierarchy, .container_ratio, .containers[].text_budget
│ (폰트 위계 확정 → 비율 역산 → 텍스트 예산)
▼
Stage 1.7 ── 코드 ──→ context.references[]
│ (relation_type 1차 + expression_hint 2차 → 블록+변형)
│ (Jinja 치환 → 디자인 레퍼런스 HTML)
▼
Stage 1.5b ─ 코드 ──→ context.containers[].design_budget
│ (블록 schema 기반 디자인 요소 크기 역산)
▼
Stage 2 ── Sonnet ──→ context.generated_html
│ (디자인 레퍼런스 + 구체적 수치 + 원본 텍스트)
│ ✓ L1+L2+L3 코드 검증
▼
Stage 3 ── 코드 ──→ context.rendered_html
│ (동적 비율 grid 조립)
│ ✓ L4 Selenium 실측
▼
Stage 4 ── Opus ──→ context.quality_score
│ (스크린샷 기반 시각 평가, 30점 미만 차단)
▼
Stage 5 ── 코드 ──→ final.html
(이미지 base64 변환, details JS 삽입)
6. 에러 핸들링
| 실패 지점 | 등급 | 복구 전략 |
|---|---|---|
| Stage 0 검증 실패 | FATAL | 원본 MDX 자체 문제 — 사용자에게 에러 반환 |
| Stage 1A Pydantic 실패 | RETRYABLE | Self-Refine 피드백 포함 Kei 재요청 (최대 2회) |
| Stage 1B 모순 탐지 | RETRYABLE | 모순 topic만 피드백 포함 재요청 (최대 2회) |
| Stage 1.5a 수치 이상 | FATAL | 결정론적이므로 재시도 무의미 — 입력 점검 필요 |
| Stage 1.7 적합 블록 없음 | ADJUSTABLE | 카테고리별 fallback 블록 선택 + 경고 기록 |
| Stage 1.5b 음수 예산 | ADJUSTABLE | 폰트 1px 축소 or 블록 재선택 |
| L1~L3 실패 | RETRYABLE | Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회) |
| L4 overflow | RETRYABLE | 실패 영역만 Stage 2로 + 구체적 px 피드백 (최대 2회) |
| L5 30점 미만 | FATAL | 출력 차단 + 에러 기록 |
| L5 30~60점 | RETRYABLE | Opus 피드백으로 Stage 2 재실행 (최대 1회) |
| 타임아웃 (300초) | FATAL | 최선 결과 반환 + 경고 |
7. 기술 스택 (Phase T)
| 구성 요소 | 기술 | 비고 |
|---|---|---|
| 웹 서버 | FastAPI + uvicorn | 포트 8001, SSE 스트리밍 |
| 파이프라인 런타임 | Python (async) | Pydantic BaseModel (PipelineContext) |
| MDX 파싱 | python-frontmatter + markdown-it-py + mdit-py-plugins | ~1MB 추가 |
| 콘텐츠 판단 | Kei Persona API (localhost:8000, Opus, SSE) | httpx streaming |
| HTML 생성 | Claude Sonnet 4 (Anthropic API) | 영역별 개별 호출 |
| 한국어 키워드 추출 | kiwipiepy | L1 검증 + Stage 1A/1B 원본 대조 |
| 관계 표현 패턴 | regex 7종 (relation_type별) | Stage 1B 검증 보조 |
| 시각 품질 평가 | Opus Vision (Anthropic API) | L5, 스크린샷 기반 |
| 실측 렌더링 | Selenium headless Chrome | L4, 1280×920 viewport |
| 블록 카탈로그 | catalog.yaml (38개 블록) | schema 38/38 완성 필요 |
| 템플릿 엔진 | Jinja2 | 블록 HTML 렌더링 |
| 디자인 토큰 | tokens.css + base.css | Pretendard Variable |
| HTTP 클라이언트 | httpx | Kei API SSE 통신 |
| 스냅샷 저장 | JSON (Pydantic model_dump_json) | data/runs/{run_id}/ |
8. 마이그레이션 맵 (현재 코드 → Phase T)
신규 생성
| 파일 | 역할 |
|---|---|
src/pipeline_context.py |
PipelineContext Pydantic 모델 |
src/mdx_normalizer.py |
Stage 0 MDX 파서 (4-Layer) |
src/validators.py |
Stage 1A/1B Pydantic 스키마 + 모순 탐지 + 원본 대조 |
src/block_reference.py |
Stage 1.7 블록 선택 + 디자인 레퍼런스 생성 |
scripts/capture_block_screenshots.py |
38개 블록 독립 렌더링 스크린샷 |
수정
| 파일 | 변경 내용 |
|---|---|
src/pipeline.py |
run_stage 패턴 + 11-Stage 러너 + PipelineContext 기반 |
src/html_generator.py |
프롬프트에 context 기반 수치+레퍼런스 주입, 하드코딩 CSS 제거 |
src/space_allocator.py |
폰트 위계 + 동적 비율 역산 + design_budget 계산 |
src/content_verifier.py |
L1에 kiwipiepy 추가, L3에 폰트 위계 검증 추가 |
templates/catalog.yaml |
schema 19개 추가 완성 + visual_diff 20개 추가 |
미사용 (Phase S에서 이미 미사용, 삭제 후보)
| 파일/함수 | 이유 |
|---|---|
src/block_selector.py |
Phase R'에서 제거됨. Stage 1.7의 block_reference.py로 대체 |
src/content_editor.py |
Phase S에서 별도 텍스트 편집 Stage 제거됨 |
src/design_director.py |
Step B 프롬프트 제거됨. 프리셋 선택 로직만 space_allocator로 이동 |
9. Phase T 범위 vs Phase ZZ 예고
Phase T (현재) — 폰트 위계 + 파이프라인 안정화
- 11-Stage 파이프라인 전체 구현 (PipelineContext + run_stage 패턴)
- Stage 0: MDX 4-Layer 파서
- Stage 1A/1B: Pydantic 검증 + 모순 탐지 + 원본 대조
- Stage 1.5a: 폰트 위계 확정 + 동적 비율 역산 (Phase T 핵심)
- Stage 1.7: 블록 참고 선택 (키워드 매칭 + fallback)
- Stage 1.5b: 블록 schema 기반 디자인 예산 역산
- catalog.yaml schema 38/38 완성 + visual_diff 20개
- 분산 검증 (L1~L5) + Self-Refine 재시도
- 합격 기준: 어떤 MDX에서도 폰트 위계 유지, overflow 없음, 품질 60점+
Phase ZZ (최종 전환) — 판단 체계 전환 + 워크플로우
- Kei Persona API → Opus 직접 + Gitea 위키 판단 기준 전환 (비교 평가 후 결정)
- 청크별 보존율 차등화 (verbatim / summary / core_80)
- Stage 1.5a → 1A/1B 역방향 협상 루프 (weight 재조정 요청)
- Gitea 이슈 기반 워크플로우 전환
- Starlight
.astro임베딩 - 반응형 전환 여부 판단