# 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` 타입 검증. ```python 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 공통 실행 패턴 ```python 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` → `[핵심요약]...[/핵심요약]` 마커 - `
제목내용
` → popups[] 추출 - JSX `style={{}}`, `import/export` 제거 - **Layer 3:** `markdown-it-py` AST 파싱 (`js-default` 프리셋, table 기본 포함): - heading 토큰 → 섹션 구조 추출 (tag, level, content, source line) - image 토큰 → images[] 추출 (alt, src) - table 토큰 → tables[] 추출 (header, rows) - 코드블록 placeholder 복원 - **Layer 4:** 텍스트 정리 — 남은 HTML 태그 제거, 빈 줄 정리, 최종 clean_text - **출력:** ```python { "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[] 수 = 원본 `
` 패턴 수 - **주의:** 기존 `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 / **none** - `expression_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) 폰트 위계에서 필요 공간 계산 ```python 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가 아니라 텍스트 양에서 역산: ```python 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) 텍스트 예산 계산 비율 확정 후, 각 영역의 텍스트 예산: ```python 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` 필드로 필터: ```python 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) 매칭**: ```python 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) 컨테이너 크기 적합성 검사 ```python candidates = [b for b in candidates if b.min_height_px <= container.height_px] ``` #### (4) 블록 변형(variant) + 레이아웃 자동 선택 ```python 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% 복사 → 레이아웃을 "발명"하지 않고 검증된 구조를 따름. ```python 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"\n" if catalog_entry.get("visual_diff"): annotated += f"\n" annotated += rendered return annotated ``` - **출력:** ```json { "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": "\n
..." } ``` - **검증:** 선택된 블록이 catalog.yaml에 실제 존재, min_height_px ≤ container.height_px - **저장:** `context.references["본심"].*` --- ### Stage 1.5b: 디자인 예산 재계산 (블록 선택 후) - **담당:** 코드 (AI 아님) - **입력:** Stage 1.7에서 선택된 블록의 schema + Stage 1.5a의 컨테이너 스펙 - **목적:** 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. **텍스트를 줄이는 것이 아니라 도형·이미지·CSS 요소의 크기를 맞추는 방향.** ```python 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의 구조와 색상 패턴을 따르되 콘텐츠를 교체하세요.

샘플 제목

  • • 샘플 항목 1
[수치 제약 — 반드시 준수] - 컨테이너: 너비 707px, 높이 176px - 폰트: 11px (배경 영역 위계) - 줄당 최대 68자 - 최대 10줄 - 디자인 요소 예산: 높이 84px, 너비 707px [원본 텍스트 — 축약/변형 금지] "DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음..." [필수 규칙] - inline style만 사용,