diff --git a/ARCHITECTURE-PHASE-T.md b/ARCHITECTURE-PHASE-T.md new file mode 100644 index 0000000..757fca5 --- /dev/null +++ b/ARCHITECTURE-PHASE-T.md @@ -0,0 +1,854 @@ +# 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만 사용, ', ref_html, re.DOTALL) + body = re.sub(r'', '', ref_html, flags=re.DOTALL) + body = re.sub(r'', '', body, flags=re.DOTALL) + return "\n".join(css_parts), body.strip() + + def popup_link(text, role): + """[팝업: 제목] 마커를 클릭 가능한 링크로 변환.""" + def _repl(m): + popup_title = m.group(1) + return f'[{popup_title} 상세보기→]' + return re.sub(r'\[팝업:\s*([^\]]+)\]', _repl, text) + + def image_marker(text): + """[이미지: 제목] 마커를 제거 (SVG로 별도 처리되므로).""" + return re.sub(r'\[이미지:\s*[^\]]+\]', '', text) + + def structured_to_bullets(text, role, font_size, exclude_source=False): + """structured_text → (불릿 HTML, [팝업 제목 리스트]). + [팝업:]은 텍스트에서 분리. [이미지:]는 제거. **bold** → . 출처: → 캡션.""" + # [팝업:], [이미지:] 마커를 bold 변환 전에 먼저 처리 + items = [] # [(indent, text)] + popup_titles = [] + for raw_line in text.split("\n"): + stripped = raw_line.strip() + if not stripped: + continue + indent = 1 if raw_line.startswith(" ") else 0 + + # [팝업:] → 분리 (줄 어디에 있든 매칭, bold 변환 전) + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + # [이미지:] → 제거 (bold 변환 전) + if re.search(r'\[이미지:', stripped): + continue + + # 마크다운 bold → HTML (마커 처리 후) + stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + items.append((indent, stripped)) + + # items → HTML + html = "" + for indent, line in items: + line = bold(line, role) + clean = line.lstrip("• ") + if line.startswith("출처:") or clean.startswith("출처:"): + if exclude_source: + continue # 이미지 아래에 별도 배치됨 + caption = re.sub(r'^출처:\s*', '', clean) + html += f'
{caption}
\n' + elif indent == 1: + html += f'
{clean}
\n' + else: + html += f'
{clean}
\n' + return html, popup_titles + + def find_popup(title_keyword): + """팝업 목록에서 제목 키워드로 매칭.""" + for p in popups: + if title_keyword in p.get("title", ""): + return p + return None + + def popup_to_compact_table(popup_content, font_size): + """팝업의 마크다운 표를 compact HTML 테이블로 변환.""" + # 마크다운 bold → HTML (팝업 정화가 안 된 run 대응) + popup_content = re.sub(r'\*\*(.+?)\*\*', r'\1', popup_content) + # JSX style 제거 + popup_content = re.sub(r'', '', popup_content) + popup_content = popup_content.replace('
', '') + popup_content = re.sub(r'', '\n', popup_content) + + lines = popup_content.split("\n") + table_lines = [l.strip() for l in lines if l.strip().startswith("|")] + if len(table_lines) < 3: + return "" + # 헤더 + headers = [c.strip() for c in table_lines[0].split("|") if c.strip()] + # 구분선 스킵 (|---|---|) + rows = [] + for tl in table_lines[2:]: + cells = [c.strip() for c in tl.split("|") if c.strip()] + if cells: + rows.append(cells) + if not headers or not rows: + return "" + # HTML 테이블 (compact) + col_count = len(headers) + header_html = "".join(f'
{h}
' for h in headers) + rows_html = "" + for ri, row in enumerate(rows[:4]): # 최대 4행 + bg = "#f8fafc" if ri % 2 == 0 else "#fff" + cells_html = "" + for ci, cell in enumerate(row): + align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right") + weight = "600" if ci == 0 else "400" + color = "#1e40af" if ci == 0 else "#64748b" + cells_html += f'
{bold(cell, "본심")}
' + rows_html += f'
{cells_html}
\n' + + return ( + f'
' + f'
{header_html}
' + f'{rows_html}
' + ) + + # ── 좌표 계산 (디자인 토큰에서 동적으로) ── + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + inner_w = slide_w - pad * 2 + body_w = int(inner_w * ratio[0] / 100) + sidebar_w = inner_w - body_w - gap_block + + all_css = set() + role_htmls = {} + + # ── 각 역할별 조립 ── + for role in ["배경", "본심", "첨부", "결론"]: + info = ps.get(role, {}) + if not isinstance(info, dict): + continue + tids = info.get("topic_ids", []) + if not tids: + continue + + ref_list = refs.get(role, []) + r0 = ref_list[0] if ref_list else {} + block_id = r0.get("block_id", "") + ref_html = r0.get("design_reference_html", "") + is_hier = r0.get("is_hierarchical", False) + sup_tids = r0.get("supporting_topic_ids", []) + primary_tid = r0.get("topic_id") or (tids[0] if tids else None) + + ci = containers.get(role, {}) + h = int(redist.get(role, ci.get("height_px", 0))) + w = ci.get("width_px", 0) + font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core") + font_size = fh.get(font_key, 12) + + block_css, block_body = extract_block_html(ref_html) + if block_css: + # #7: CSS font-size override (font_hierarchy 기준으로 큰 폰트 축소) + def _override_font(m, fs=font_size): + val = float(m.group(1)) + if val > fs + 2: + return f"font-size: {fs + 1}px" + elif val > fs: + return f"font-size: {fs}px" + return m.group(0) + block_css = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, block_css) + # gap, padding, number size도 font_size 비례 + block_css = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', block_css) + block_css = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px', + f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', block_css) + block_css = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', block_css) + # #6: white-space: pre-line → normal (카드 불릿 간 빈줄 방지) + block_css = block_css.replace('white-space: pre-line', 'white-space: normal') + all_css.add(block_css) + + layout = sub_layouts.get(role, {}) + scs = layout.get("sub_containers", []) + + primary_topic = topic_map.get(primary_tid, {}) + topic_title = bold(primary_topic.get("title", ""), role) + + # ════════════════════════════════════ + # 결론 + # ════════════════════════════════════ + if role == "결론": + assembled = block_body + assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled) + assembled = re.sub(r'>부연 설명<', '><', assembled) + role_htmls[role] = assembled + + # ════════════════════════════════════ + # 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명 + # ════════════════════════════════════ + elif role == "첨부": + st = get_text(primary_topic) + # 마크다운 bold → HTML + st = re.sub(r'\*\*(.+?)\*\*', r'\1', st) + # 파싱: 주불릿(줄 시작 "• ")을 카드 구분자로 + items = [] # [(title, [desc_lines])] + current_title = "" + current_descs = [] + for line in st.split("\n"): + stripped = line.strip() + if not stripped: + continue + # 주불릿: 새 카드 시작 + if stripped.startswith("• ") and not line.startswith(" "): + if current_title: + items.append((current_title, current_descs)) + current_title = stripped[2:] + current_descs = [] + # 하위불릿 또는 출처: 현재 카드의 설명 + elif stripped.startswith("• ") and line.startswith(" "): + current_descs.append(stripped[2:]) + elif stripped.startswith("출처:"): + current_descs.append(stripped) + else: + current_descs.append(stripped) + if current_title: + items.append((current_title, current_descs)) + if not items: + items = [(primary_topic.get("title", ""), [st])] + + cards = "" + for i, (card_title, desc_lines) in enumerate(items): + desc_html = "" + for dl in desc_lines: + dl = dl.strip() + if not dl: + continue + dl = bold(dl, role) + if dl.startswith("출처:"): + caption = re.sub(r'^출처:\s*', '', dl) + desc_html += f'
{caption}
\n' + else: + desc_html += f'
{dl}
\n' + cards += ( + f'
' + f'
{i+1}
' + f'
' + f'
' + f'{bold(card_title.strip(), role)}
' + f'
' + f'{desc_html}
\n' + ) + # 첨부 컨테이너 높이에 맞게 gap/padding 동적 조절 + sb_container_h = int(redist.get(role, ci.get("height_px", 0))) + n_cards = len(items) + sb_pad = min(10, max(4, sb_container_h // 50)) + sb_gap = min(7, max(3, (sb_container_h - sb_pad * 2) // (n_cards * 10))) + + role_htmls[role] = ( + f'
' + f'
' + f'{topic_title}
{cards}
' + ) + + # ════════════════════════════════════ + # 배경 — callout 구조 + 종속꼭지 인라인 + 강조 + # ════════════════════════════════════ + elif role == "배경": + sub_html = "" + if is_hier and sup_tids: + for st_id in sup_tids: + st_topic = topic_map.get(st_id, {}) + st_text = get_text(st_topic) + st_text = popup_link(st_text, role) + sub_html += ( + f'
' + f'{bold(st_text[:120], role)}
' + ) + + emph = get_emphasis(role) + emph_html = "" + if emph: + emph_html = ( + f'
' + f'→ {emph}
' + ) + + bullets_html, bg_popups = structured_to_bullets(get_text(primary_topic), role, font_size) + # V'-1: 팝업 링크 우측상단 + bg_popup_html = "" + if bg_popups: + links = " ".join(f'[{t}→]' for t in bg_popups) + bg_popup_html = f'
{links}
' + + # padding을 컨테이너 높이에 맞게 동적 조절 + container_h = int(redist.get(role, ci.get("height_px", 0))) + pad_v = min(10, max(4, container_h // 20)) # 컨테이너 높이의 5% 정도 + pad_h = min(14, max(6, container_h // 10)) + + role_htmls[role] = ( + f'
' + f'{bg_popup_html}' + f'
⚠️
' + f'
' + f'
' + f'{topic_title}
' + f'{bullets_html}{sub_html}{emph_html}' + f'
' + ) + + # ════════════════════════════════════ + # 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단) + # ════════════════════════════════════ + elif role == "본심": + svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None) + text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None) + keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None) + + # 이미지: slide_images에서 실제 이미지 사용, 없으면 빈 placeholder + slide_images = ctx.get("slide_images", []) + img_html = "" + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_html = f'' + break + + svg_w = int(svg_sc["width_px"]) if svg_sc else 200 + svg_h = int(svg_sc["height_px"]) if svg_sc else 265 + # 본심의 모든 topic 텍스트를 합침 + all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) + + # 출처 텍스트를 이미지 아래에 배치 + img_caption = "" + for line in all_core_text.split("\n"): + stripped = line.strip().lstrip("• ") + if stripped.startswith("출처:"): + img_caption = re.sub(r'^출처:\s*', '', stripped) + break + caption_html = f'
{img_caption}
' if img_caption else "" + svg_wrapped = ( + f'
' + f'
{img_html}
' + f'{caption_html}
' + ) + + # 텍스트 불릿 (출처는 이미지 아래에 별도 배치했으므로 제외) + bullets, core_popups = structured_to_bullets(all_core_text, role, font_size, exclude_source=True) + + # V'-2: 팝업 원본을 Kei가 요약한 결과를 사용 (없으면 기존 compact 변환 fallback) + popup_summaries = enh.get("popup_summaries", {}) + table_html = "" + used_popups = [] + for pr in core_popups: + summary = popup_summaries.get(pr) + if summary: + used_popups.append(pr) + fmt = summary.get("format", "text") + popup_link_html = f'
[{pr}→]
' + + if fmt == "table": + cols = summary.get("columns", []) + data = summary.get("data", []) + col_count = len(cols) + if col_count > 0 and data: + header_cells = "".join( + f'
{c}
' + for c in cols + ) + rows_html = "" + for ri, row in enumerate(data): + bg = "#f8fafc" if ri % 2 == 0 else "#fff" + cells = "" + for ci, cell in enumerate(row): + c_color = "#1e40af" if ci == 0 else "#64748b" + c_weight = "600" if ci == 0 else "400" + cells += f'
{bold(cell, role)}
' + rows_html += f'
{cells}
\n' + compact = ( + f'
' + f'
{header_cells}
' + f'{rows_html}
' + ) + table_html += f'
{popup_link_html}{compact}
' + + elif fmt == "bullets": + items = summary.get("items", []) + bullets_html = "".join( + f'
{bold(item, role)}
' + for item in items + ) + table_html += f'
{popup_link_html}{bullets_html}
' + + elif fmt == "text": + text = summary.get("summary", "") + table_html += f'
{popup_link_html}{bold(text, role)}
' + + else: + # fallback: 기존 compact 변환 + popup = find_popup(pr) + if popup: + content = popup.get("content", "") + if content.count("|") > 3: + compact = popup_to_compact_table(content, font_size) + if compact: + popup_link_html = f'
[{pr}→]
' + table_html += f'
{popup_link_html}{compact}
' + used_popups.append(pr) + + # 표/요약에 연결되지 않은 팝업은 컨테이너 우측상단에 + remaining_popups = [p for p in core_popups if p not in used_popups] + core_popup_html = "" + if remaining_popups: + links = " ".join(f'[{t}→]' for t in remaining_popups) + core_popup_html = f'
{links}
' + + text_wrapped = ( + f'
' + f'{core_popup_html}' + f'
{bullets}
' + f'
' + ) + + # key-msg + keymsg_html = "" + if keymsg_sc and core_message: + keymsg_html = ( + f'
{bold(core_message, role)}
' + ) + + # 레이아웃: 이미지(좌)+불릿(우) 위, 표(전체 폭) 아래, keymsg 최하단 + role_htmls[role] = ( + f'
' + f'
' + f'{topic_title}
' + f'
' + f'{svg_wrapped}{text_wrapped}
' + f'{table_html}' + f'{keymsg_html}
' + ) + + # ── 슬라이드 좌표 ── + bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0))) + core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0))) + sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0))) + concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0))) + + bg_top = pad + header_h + gap_block + core_top = bg_top + bg_h + gap_small + sb_top = bg_top + + # #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거 + ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap + column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위 + core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥 + sb_h = column_bottom - sb_top # 첨부: column 바닥까지 + + css_block = "\n".join(all_css) + + html = f""" + +
Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)
+
sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups
+
+ +
{title}
+ +
+{role_htmls.get("배경", "")} +
+ +
+{role_htmls.get("본심", "")} +
+ +
+{role_htmls.get("첨부", "")} +
+ +
+{role_htmls.get("결론", "")} +
+ +
""" + + out = run / "steps" / "stage_2_code_assembled.html" + out.write_text(html, encoding="utf-8") + print(f"저장: {out} ({len(html)} bytes)") + + +if __name__ == "__main__": + run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051" + assemble(run_dir) diff --git a/scripts/gen_viz_layers.py b/scripts/gen_viz_layers.py new file mode 100644 index 0000000..d6d7de1 --- /dev/null +++ b/scripts/gen_viz_layers.py @@ -0,0 +1,167 @@ +"""Step 1~4를 같은 슬라이드 레이아웃 위에 레이어로 쌓아 PNG 생성.""" +import json, urllib.parse, time, sys +from pathlib import Path + +sys.path.insert(0, ".") + +run_dir = Path("data/runs/20260402_091318") + +ctx_1a = json.loads((run_dir / "stage_1a_context.json").read_text(encoding="utf-8")) +ctx_1b = json.loads((run_dir / "stage_1b_context.json").read_text(encoding="utf-8")) +ctx_15a = json.loads((run_dir / "stage_1_5a_context.json").read_text(encoding="utf-8")) +ctx_17 = json.loads((run_dir / "stage_1_7_context.json").read_text(encoding="utf-8")) +ctx_15b = json.loads((run_dir / "stage_1_5b_context.json").read_text(encoding="utf-8")) + +topics = ctx_1b.get("topics", []) +containers = ctx_15a.get("containers", {}) +fh = ctx_15a.get("font_hierarchy", {}) +ratio = ctx_15a.get("container_ratio", [72, 28]) +refs = ctx_17.get("references", {}) +ps = ctx_1a.get("page_structure", {}) +if "roles" in ps: + ps = ps["roles"] +containers_b = ctx_15b.get("containers", {}) +topic_map = {t["id"]: t for t in topics} + +slide_w, slide_h = 1280, 720 +pad = 40 +header_h = 66 +gap = 20 +footer_h = containers.get("결론", {}).get("height_px", 60) +inner_w = slide_w - pad * 2 +body_pct = ratio[0] if ratio else 72 +sidebar_pct = ratio[1] if len(ratio) > 1 else 28 +body_w = int(inner_w * body_pct / 100) +sidebar_w = inner_w - body_w - gap +body_zone_h = slide_h - pad * 2 - header_h - footer_h - gap * 2 +bg_h = containers.get("배경", {}).get("height_px", 117) +core_h = body_zone_h - bg_h - 12 + +L = { + "배경": {"x": pad, "y": pad+header_h+gap, "w": body_w, "h": bg_h}, + "본심": {"x": pad, "y": pad+header_h+gap+bg_h+12, "w": body_w, "h": core_h}, + "첨부": {"x": pad+body_w+gap, "y": pad+header_h+gap, "w": sidebar_w, "h": body_zone_h}, + "결론": {"x": pad, "y": slide_h-pad-footer_h, "w": inner_w, "h": footer_h}, +} +C = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} + + +def area(role, inner): + p = L[role]; c = C[role] + return (f'
{inner}
') + + +def header(title): + return (f'
건설산업 DX의 올바른 이해
') + + +def slide(step_title, areas_html): + return (f'' + f'' + f'
{step_title}
' + f'
' + f'{header(step_title)}{areas_html}
') + + +# Step 1 +a1 = "" +for role in L: + c = C[role]; p = L[role] + fk = {"배경":"bg","본심":"core","첨부":"sidebar","결론":"key_msg"}.get(role,"core") + fv = fh.get(fk, 12) + a1 += area(role, + f'
' + f'{role}
' + f'{p["w"]}x{p["h"]}px / font:{fv}px
') + +# Step 2 +a2 = "" +for role in L: + c = C[role]; info = ps.get(role, {}); tids = info.get("topic_ids", []) + w = info.get("weight", 0) + inner = f'
{role} (w:{w})
' + for tid in tids: + t = topic_map.get(tid, {}) + inner += (f'
' + f'T{tid}: {t.get("title","")[:25]}
' + f'' + f'{t.get("purpose","")} / {t.get("relation_type","")}
') + a2 += area(role, inner) + +# Step 3 +a3 = "" +for role in L: + c = C[role]; ref = refs.get(role, {}); p = L[role] + bid = ref.get("block_id", "?"); vtype = ref.get("visual_type", "?") + info = ps.get(role, {}); tids = info.get("topic_ids", []) + tnames = ", ".join(f"T{tid}" for tid in tids) + mt = max(0, p["h"]//2-25) + a3 += area(role, + f'
' + f'
{role} ({tnames})
' + f'
📦
' + f'
{bid}
' + f'
type: {vtype}
') + +# Step 4 +a4 = "" +for role in L: + c = C[role]; ref = refs.get(role, {}); p = L[role] + bid = ref.get("block_id", "?") + cb = containers_b.get(role, {}); db = cb.get("design_budget") or {} + text_h = db.get("text_height_px", 0) + avail_h = db.get("available_height_px", 0) + fits = db.get("fits", False) + total = max(text_h + avail_h, 1) + tp = int(text_h / total * 100) + bw = p["w"] - 20 + fc = "green" if fits else "red" + a4 += area(role, + f'
' + f'
{role}: {bid}
' + f'
' + f'
텍스트{text_h}px
' + f'
여유{avail_h}px
' + f'
' + f'
fits:{fits} / {p["w"]}x{p["h"]}px
') + +htmls = { + "viz_1_containers": slide(f"Step 1: 컨테이너 포션과 위치 (비율 {body_pct}:{sidebar_pct})", a1), + "viz_2_content": slide("Step 2: 각 영역별 내용 배치", a2), + "viz_3_blocks": slide("Step 3: 블록 선택 결과", a3), + "viz_4_budget": slide("Step 4: 블록별 디자인 예산", a4), +} + +for name, html in htmls.items(): + (run_dir / f"{name}.html").write_text(html, encoding="utf-8") + +# PNG +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + +opts = Options() +opts.add_argument("--headless") +opts.add_argument("--no-sandbox") +opts.add_argument("--force-device-scale-factor=2") +driver = webdriver.Chrome(options=opts) +driver.set_window_size(1380, 820) + +for name in htmls: + html = (run_dir / f"{name}.html").read_text(encoding="utf-8") + encoded = urllib.parse.quote(html, safe="") + driver.get(f"data:text/html;charset=utf-8,{encoded}") + time.sleep(2) + driver.save_screenshot(str(run_dir / f"{name}.png")) + print(f"{name}.png") + +driver.quit() +print("완료") diff --git a/scripts/generate_step_html.py b/scripts/generate_step_html.py new file mode 100644 index 0000000..528847e --- /dev/null +++ b/scripts/generate_step_html.py @@ -0,0 +1,314 @@ +"""Stage별 실제 출력 데이터로 step HTML 생성. + +각 step은 이전 step 위에 레이어를 쌓아가는 구조: +- Step 0: Kei 꼭지 (테이블) +- Step 1: 빈 컨테이너 (1280x720 슬라이드) +- Step 2: Step 1 + 블록 선택 (컨테이너 안에 블록 표시) +- Step 3: Step 2 + 재배분 반영 (크기 변경 + 보강) +- Step 4: 최종 결과물 (final.html) +""" +import json +import sys +from pathlib import Path + + +def _load(run: Path, name: str) -> dict: + return json.loads((run / name).read_text(encoding="utf-8")) + + +def _colors(): + return {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} + + +def _calc_coords(containers, ratio, pad=40, gap=20, header_h=66): + """컨테이너 좌표 계산. containers dict에서 실제 px 값 사용.""" + inner_w = 1280 - pad * 2 + body_w = int(inner_w * ratio[0] / 100) + sidebar_w = inner_w - body_w - gap + sidebar_left = pad + body_w + gap + + def get(c, key): + return c.get(key, 0) if isinstance(c, dict) else getattr(c, key, 0) + + bg_px = get(containers.get("배경", {}), "height_px") + core_px = get(containers.get("본심", {}), "height_px") + sidebar_px = get(containers.get("첨부", {}), "height_px") + footer_px = get(containers.get("결론", {}), "height_px") + + bg_top = pad + header_h + gap + core_top = bg_top + bg_px + 8 + footer_top = max(core_top + core_px, bg_top + sidebar_px) + gap + + return { + "header": {"left": pad, "top": pad, "width": inner_w, "height": header_h}, + "배경": {"left": pad, "top": bg_top, "width": body_w, "height": bg_px}, + "본심": {"left": pad, "top": core_top, "width": body_w, "height": core_px}, + "첨부": {"left": sidebar_left, "top": bg_top, "width": sidebar_w, "height": sidebar_px}, + "결론": {"left": pad, "top": footer_top, "width": inner_w, "height": footer_px}, + } + + +def _box_html(coord, role, label, colors, extra_style=""): + c = colors.get(role, "#333") + return ( + f'
' + f'{label}
\n' + ) + + +def _header_html(coord, title): + return ( + f'
' + f'{title}
\n' + ) + + +def _slide_wrap(title, subtitle, body): + return f""" + +
{title}
+
{subtitle}
+
+{body} +
""" + + +def gen_step0(run: Path, out: Path): + ctx1b = _load(run, "stage_1b_context.json") + topics = ctx1b.get("topics", []) + ps = ctx1b.get("page_structure", {}).get("roles", {}) + role_map = {} + for role, info in ps.items(): + for tid in info.get("topic_ids", []): + role_map[tid] = role + + colors = _colors() + rows = "" + for t in topics: + tid = t.get("id") + role = role_map.get(tid, "?") + c = colors.get(role, "#333") + bg = "#f8fafc" if tid % 2 == 0 else "#fff" + rows += (f'{tid}' + f'{t.get("title","")}' + f'{t.get("purpose","")}' + f'{t.get("layer","")}' + f'{t.get("relation_type","")}' + f'{role}\n') + + html = f""" + + +
Step 0: Kei 꼭지 추출 (Stage 1A/1B)
+
run: {run.name}
+ + +{rows}
ID제목purposelayerrelation_type영역
""" + (out / "step0_kei_topics.html").write_text(html, encoding="utf-8") + print("step0 생성") + + +def gen_step1(run: Path, out: Path): + """Step 1: 빈 컨테이너.""" + ctx15a = _load(run, "stage_1_5a_context.json") + containers = ctx15a.get("containers", {}) + ratio = ctx15a.get("container_ratio", [65, 35]) + fh = ctx15a.get("font_hierarchy", {}) + colors = _colors() + coords = _calc_coords(containers, ratio) + + body = _header_html(coords["header"], "건설산업 DX의 올바른 이해") + for role in ["배경", "본심", "첨부", "결론"]: + coord = coords[role] + c = colors[role] + font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role) + label = (f'
' + f'{role}
' + f'{coord["width"]}x{coord["height"]}px / font:{fh.get(font_key,"?")}px
') + body += _box_html(coord, role, label, colors) + + html = _slide_wrap( + "Step 1: 빈 컨테이너 (Stage 1.5a)", + f'비율 {ratio[0]}:{ratio[1]}', + body, + ) + (out / "step1_containers.html").write_text(html, encoding="utf-8") + print("step1 생성") + return coords, containers, ratio, fh + + +def gen_step2(run: Path, out: Path, coords, fh): + """Step 2: Step 1 컨테이너 위에 블록 선택 표시.""" + ctx17 = _load(run, "stage_1_7_context.json") + refs = ctx17.get("references", {}) + colors = _colors() + ctx15a = _load(run, "stage_1_5a_context.json") + ratio = ctx15a.get("container_ratio", [65, 35]) + + body = _header_html(coords["header"], "건설산업 DX의 올바른 이해") + + for role in ["배경", "본심", "첨부", "결론"]: + coord = coords[role] + c = colors[role] + ref_list = refs.get(role, []) + if not isinstance(ref_list, list): + ref_list = [ref_list] + + # 블록 정보를 컨테이너 안에 표시 + block_lines = [] + for r in ref_list: + if isinstance(r, dict): + bid = r.get("block_id", "?") + var = r.get("variant", "default") + tid = r.get("topic_id", "?") + sup = r.get("supporting_topic_ids", []) + hier = r.get("is_hierarchical", False) + line = f'꼭지{tid}: {bid} ({var})' + if hier: + line += f' ★주종' + if sup: + line += f' [종속:{sup}]' + block_lines.append(line) + + block_html = '
'.join(block_lines) + font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role) + + label = (f'
' + f'
' + f'{role} ({coord["width"]}x{coord["height"]}px)
' + f'
{block_html}
' + f'
') + body += _box_html(coord, role, label, colors) + + html = _slide_wrap( + "Step 2: 블록 선택 (Stage 1.7) — Step 1 컨테이너 위에 블록 표시", + "layer 기반 주종 판단. 배경: 꼭지1(intro)+꼭지2(supporting) → 주종합침 블록 1개", + body, + ) + (out / "step2_blocks.html").write_text(html, encoding="utf-8") + print("step2 생성") + + +def gen_step3(run: Path, out: Path, containers, ratio, fh): + """Step 3: Step 2 위에 재배분 반영.""" + ctx18 = _load(run, "stage_1_8_context.json") + fit = ctx18.get("fit_result", {}) + enh = ctx18.get("enhancement_result", {}) + redist = fit.get("redistribution", {}) + + # 재배분된 containers + new_containers = {} + for role, c in containers.items(): + h = c.get("height_px", 0) if isinstance(c, dict) else getattr(c, "height_px", 0) + new_h = int(redist.get(role, h)) + if isinstance(c, dict): + new_containers[role] = {**c, "height_px": new_h} + else: + new_containers[role] = {"height_px": new_h, "width_px": getattr(c, "width_px", 0), "zone": getattr(c, "zone", "")} + + colors = _colors() + new_coords = _calc_coords(new_containers, ratio) + + # 블록 선택 정보도 가져옴 + ctx17 = _load(run, "stage_1_7_context.json") + refs = ctx17.get("references", {}) + + body = _header_html(new_coords["header"], "건설산업 DX의 올바른 이해") + + for role in ["배경", "본심", "첨부", "결론"]: + coord = new_coords[role] + c = colors[role] + + # fit 상태 + rf = fit.get("roles", {}).get(role, {}) + status = rf.get("fit_status", "?") + icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?") + needed = rf.get("total_required_px", 0) + old_h = rf.get("allocated_px", 0) + new_h = int(redist.get(role, old_h)) + delta = new_h - old_h + + # 블록 정보 + ref_list = refs.get(role, []) + if not isinstance(ref_list, list): + ref_list = [ref_list] + block_lines = [] + for r in ref_list: + if isinstance(r, dict): + bid = r.get("block_id", "?") + tid = r.get("topic_id", "?") + sup = r.get("supporting_topic_ids", []) + hier = r.get("is_hierarchical", False) + line = f'꼭지{tid}: {bid}' + if hier: + line += f' ★주종 [종속:{sup}]' + block_lines.append(line) + + # 보강 정보 + emps = [e for e in enh.get("emphasis_blocks", []) if e.get("role") == role] + bolds = enh.get("bold_keywords", {}).get(role, []) + + delta_str = f" ({delta:+d}px)" if abs(delta) > 0 else "" + enh_lines = [] + if emps: + enh_lines.append(f'강조: "{emps[0].get("sentence","")[:30]}..."') + if bolds: + enh_lines.append(f'bold: {bolds[:4]}') + + label = (f'
' + f'
' + f'{icon} {role} {coord["width"]}x{new_h}px{delta_str}
' + f'
필요 {needed:.0f}px
' + f'
{"
".join(block_lines)}
' + f'
{"
".join(enh_lines)}
' + f'
') + body += _box_html(coord, role, label, colors) + + html = _slide_wrap( + "Step 3: 적합성 검증 + 재배분 + 보강 (Stage 1.8)", + f"재배분: {', '.join(f'{r}:{int(redist.get(r,0))}px' for r in redist)}", + body, + ) + (out / "step3_fit_result.html").write_text(html, encoding="utf-8") + print("step3 생성") + + +def gen_step4(run: Path, out: Path): + """Step 4: final.html 링크.""" + html = """ + +

Step 4: 최종 결과물 (Sonnet HTML 생성)

+

final.html 열기 →

+

첨부1 · 첨부2

+""" + (out / "step4_final.html").write_text(html, encoding="utf-8") + print("step4 생성") + + +def main(run_dir: str): + run = Path(run_dir) + out = run / "steps" + out.mkdir(exist_ok=True) + + gen_step0(run, out) + coords, containers, ratio, fh = gen_step1(run, out) + gen_step2(run, out, coords, fh) + gen_step3(run, out, containers, ratio, fh) + gen_step4(run, out) + + print(f"\n전체 step: {out}/") + for f in sorted(out.iterdir()): + print(f" {f.name}") + + +if __name__ == "__main__": + run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260402_154745" + main(run_dir) diff --git a/scripts/run_from_artifacts.py b/scripts/run_from_artifacts.py new file mode 100644 index 0000000..ef1c616 --- /dev/null +++ b/scripts/run_from_artifacts.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.block_reference import select_and_generate_references +from src.config import settings +from src.content_verifier import generate_with_retry +from src.design_director import LAYOUT_PRESETS, select_preset +from src.image_utils import embed_images, get_image_sizes +from src.mdx_normalizer import normalize_mdx_content +from src.pipeline_context import ( + Analysis, + BlockReference, + ContainerInfo, + DesignBudget, + FontHierarchy, + NormalizedContent, + PageStructure, + PipelineContext, + Topic, + create_context, +) +from src.renderer import render_slide_from_html +from src.slide_measurer import capture_slide_screenshot, measure_rendered_heights +from src.space_allocator import ( + ContainerSpec as LegacyContainerSpec, + calculate_container_specs, + calculate_design_budget, + calculate_dynamic_ratio, + calculate_font_hierarchy, +) + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8-sig')) + + +def _build_context(content: str, base_path: str, stage1a: dict, stage1b: dict) -> PipelineContext: + ctx = create_context(content, base_path) + + normalized = normalize_mdx_content(content) + ctx.normalized = NormalizedContent( + clean_text=normalized['clean_text'], + title=normalized['title'], + images=normalized['images'], + popups=normalized['popups'], + tables=normalized['tables'], + sections=normalized['sections'], + ) + + analysis_raw = stage1a['analysis'] + ctx.analysis = Analysis( + core_message=analysis_raw['core_message'], + title=analysis_raw['title'], + total_pages=analysis_raw.get('total_pages', 1), + ) + ctx.page_structure = PageStructure(roles=stage1a['page_structure']) + + refined_map = {item['topic_id']: item for item in stage1b['concepts']} + topics = [] + for raw in stage1a['topics']: + merged = dict(raw) + if raw['id'] in refined_map: + merged.update(refined_map[raw['id']]) + topics.append(Topic(**merged)) + ctx.topics = topics + return ctx + + +def _stage_1_5a(ctx: PipelineContext) -> PipelineContext: + image_sizes = get_image_sizes(ctx.raw_content, ctx.base_path) + role_text_lengths = {} + for role, info in ctx.page_structure.roles.items(): + if isinstance(info, dict): + role_text_lengths[role] = len(ctx.get_role_content(role)) + + font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths) + ctx.font_hierarchy = FontHierarchy( + key_msg=font_hierarchy_dict.get('핵심', 14.0), + core=font_hierarchy_dict.get('본심', 12.0), + bg=font_hierarchy_dict.get('배경', 11.0), + sidebar=font_hierarchy_dict.get('첨부', 10.0), + ) + ctx.container_ratio = calculate_dynamic_ratio(role_text_lengths, font_hierarchy_dict) + + analysis_dict = { + 'topics': [t.model_dump() for t in ctx.topics], + 'page_structure': ctx.page_structure.roles, + } + preset_name = select_preset(analysis_dict) + ctx.preset_name = preset_name + ctx.preset = LAYOUT_PRESETS.get(preset_name, {}) + + container_specs = calculate_container_specs( + page_structure=ctx.page_structure.roles, + topics=[t.model_dump() for t in ctx.topics], + preset=ctx.preset, + slide_width=settings.slide_width, + slide_height=settings.slide_height, + ) + ctx.containers = { + role: ContainerInfo( + role=spec.role, + zone=spec.zone, + topic_ids=spec.topic_ids, + weight=spec.weight, + height_px=spec.height_px, + width_px=spec.width_px, + max_height_cost=spec.max_height_cost, + block_constraints=spec.block_constraints, + ) + for role, spec in container_specs.items() + } + + slide_images = [] + for img_key, img_info in (image_sizes or {}).items(): + img_path = Path(ctx.base_path) / img_key if ctx.base_path else Path(img_key) + slide_images.append({ + 'path': str(img_path), + 'width': img_info.get('width', 0), + 'height': img_info.get('height', 0), + 'ratio': round(img_info.get('width', 1) / max(1, img_info.get('height', 1)), 2), + 'topic_id': img_info.get('topic_id'), + 'b64': '', + }) + ctx.slide_images = slide_images + ctx.analysis = ctx.analysis.model_copy(update={'image_sizes': image_sizes or {}}) + return ctx + + +def _stage_1_7(ctx: PipelineContext) -> PipelineContext: + refs_raw = select_and_generate_references( + topics=[t.model_dump() for t in ctx.topics], + containers=ctx.containers, + page_structure=ctx.page_structure.roles, + ) + ctx.references = { + role: BlockReference( + block_id=ref['block_id'], + variant=ref['variant'], + visual_type=ref['visual_type'], + schema_info=ref['schema_info'], + design_reference_html=ref['design_reference_html'], + ) + for role, ref in refs_raw.items() + } + return ctx + + +def _stage_1_5b(ctx: PipelineContext) -> PipelineContext: + updated = {} + font_map = {'본심': 'core', '배경': 'bg', '첨부': 'sidebar', '결론': 'core'} + for role, ci in ctx.containers.items(): + ref = ctx.references.get(role) + schema_info = ref.schema_info if ref else {} + font_size = getattr(ctx.font_hierarchy, font_map.get(role, 'core'), 12.0) + budget = calculate_design_budget( + container_height_px=ci.height_px, + container_width_px=ci.width_px, + block_schema=schema_info, + font_size=font_size, + ) + updated[role] = ci.model_copy(update={ + 'design_budget': DesignBudget( + available_height_px=budget['available_height_px'], + available_width_px=budget['available_width_px'], + max_circle_diameter=budget['max_circle_diameter'], + max_img_width=budget['max_img_width'], + max_img_height=budget['max_img_height'], + fits=budget['fits'], + ) + }) + ctx.containers = updated + return ctx + + +async def _stage_2(ctx: PipelineContext) -> PipelineContext: + analysis_dict = { + 'topics': [t.model_dump() for t in ctx.topics], + 'page_structure': ctx.page_structure.roles, + 'core_message': ctx.analysis.core_message, + 'title': ctx.analysis.title, + 'total_pages': ctx.analysis.total_pages, + 'image_sizes': ctx.analysis.image_sizes, + } + container_specs_dict = { + role: LegacyContainerSpec( + role=ci.role, + zone=ci.zone, + topic_ids=ci.topic_ids, + weight=ci.weight, + height_px=ci.height_px, + width_px=ci.width_px, + max_height_cost=ci.max_height_cost, + block_constraints=ci.block_constraints, + ) + for role, ci in ctx.containers.items() + } + analysis_dict['phase_t'] = { + 'font_hierarchy': ctx.font_hierarchy.model_dump(), + 'container_ratio': ctx.container_ratio, + 'references': {role: ref.model_dump() for role, ref in ctx.references.items()}, + 'design_budgets': { + role: ci.design_budget.model_dump() if ci.design_budget else {} + for role, ci in ctx.containers.items() + }, + } + generated, _verification = await generate_with_retry( + content=ctx.raw_content, + analysis=analysis_dict, + container_specs=container_specs_dict, + preset=ctx.preset, + images=ctx.slide_images, + ) + ctx.generated_html = generated + return ctx + + +def _stage_3(ctx: PipelineContext) -> PipelineContext: + analysis_dict = { + 'topics': [t.model_dump() for t in ctx.topics], + 'page_structure': ctx.page_structure.roles, + 'core_message': ctx.analysis.core_message, + 'title': ctx.analysis.title, + } + ctx.rendered_html = render_slide_from_html(ctx.generated_html, analysis_dict, ctx.preset) + if ctx.base_path: + ctx.rendered_html = embed_images(ctx.rendered_html, ctx.base_path) + return ctx + + +def _stage_4_lite(ctx: PipelineContext) -> PipelineContext: + ctx.measurement = measure_rendered_heights(ctx.rendered_html) + ctx.screenshot_b64 = capture_slide_screenshot(ctx.rendered_html) or '' + ctx.quality_score = 100 if not any( + zone.get('overflowed') for zone in ctx.measurement.get('zones', {}).values() + ) else 60 + return ctx + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--input', required=True) + parser.add_argument('--stage1a', required=True) + parser.add_argument('--stage1b', required=True) + parser.add_argument('--base-path', default='') + parser.add_argument('--output-dir', required=True) + args = parser.parse_args() + + content = Path(args.input).read_text(encoding='utf-8') + stage1a = _load_json(Path(args.stage1a)) + stage1b = _load_json(Path(args.stage1b)) + + ctx = _build_context(content, args.base_path, stage1a, stage1b) + ctx = _stage_1_5a(ctx) + ctx = _stage_1_7(ctx) + ctx = _stage_1_5b(ctx) + ctx = await _stage_2(ctx) + ctx = _stage_3(ctx) + ctx = _stage_4_lite(ctx) + + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / 'generated_html.json').write_text( + json.dumps(ctx.generated_html, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + (out_dir / 'final.html').write_text(ctx.rendered_html, encoding='utf-8') + (out_dir / 'measurement.json').write_text( + json.dumps(ctx.measurement, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + (out_dir / 'context.json').write_text( + ctx.model_dump_json(indent=2, exclude={'screenshot_b64', 'rendered_html'}), + encoding='utf-8', + ) + + +if __name__ == '__main__': + asyncio.run(main()) + + diff --git a/scripts/run_from_stage1b.py b/scripts/run_from_stage1b.py new file mode 100644 index 0000000..c89834a --- /dev/null +++ b/scripts/run_from_stage1b.py @@ -0,0 +1,86 @@ +"""Stage 1B 데이터를 고정 입력으로, pipeline.py의 generate_slide()를 사용. + +Kei persona 관여 부분(1A, 1B)을 건너뛰고 +나머지 파이프라인(1.5a~4)을 그대로 실행. + +pipeline.py를 직접 수정하지 않고, manual_layout 파라미터로 Stage 1A를 고정. +Stage 1B(structured_text)도 고정. + +사용법: + python scripts/run_from_stage1b.py data/runs/20260403_133746 +""" +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +async def main(run_dir: str): + run = Path(run_dir) + + # Stage 1B context 로드 + ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8")) + # MDX 원본: samples에서 직접 읽기 (최신 원본 사용) + samples_dir = Path(__file__).parent.parent / "samples" + mdx_file = samples_dir / "mdx" / "01. 건설산업 DX의 올바른 이해(0127).mdx" + if mdx_file.exists(): + raw_content = mdx_file.read_text(encoding="utf-8") + else: + raw_content = ctx_json.get("raw_content", "") + + # Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵) + # page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달 + ps = ctx_json["page_structure"] + if "roles" in ps: + ps = ps["roles"] + + manual_layout = { + "topics": ctx_json["topics"], + "page_structure": ps, + "core_message": ctx_json.get("analysis", {}).get("core_message", ""), + "title": ctx_json.get("analysis", {}).get("title", ""), + } + + print(f"=== Stage 1B 데이터 고정: {run.name} ===") + print(f" topics: {len(ctx_json['topics'])}개") + for t in ctx_json["topics"]: + print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)") + + # pipeline.py의 generate_slide() 호출 + from src.pipeline import generate_slide + + # 이미지 base_path: samples/images/ + base_path = str(samples_dir / "images") + async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path): + ev_type = event.get("event", "") + ev_data = event.get("data", "") + if ev_type == "progress": + print(f" [{ev_type}] {ev_data}") + elif ev_type == "error": + print(f" ❌ {ev_data}") + elif ev_type == "result": + print(f" ✅ 완료 ({len(ev_data)} bytes)") + + # 최신 run 찾기 (YYYYMMDD_HHMMSS 형식만) + import re as _re + runs_dir = Path("data/runs") + dated_runs = [d for d in runs_dir.iterdir() if d.is_dir() and _re.match(r'^\d{8}_\d{6}$', d.name)] + latest = sorted(dated_runs, reverse=True)[0] + print(f"\n=== 결과: {latest} ===") + + # 코드 조립도 실행 + from scripts.assemble_stage2 import assemble + assemble(str(latest)) + + print(f"\n확인:") + print(f" file:///{latest}/steps/stage_2.html") + print(f" file:///{latest}/steps/stage_2_code_assembled.html") + print(f" file:///{latest}/steps/stage_3_rendered.html") + print(f" file:///{latest}/final.html") + + +if __name__ == "__main__": + run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_133746" + asyncio.run(main(run_dir)) diff --git a/scripts/test_phase_t.py b/scripts/test_phase_t.py new file mode 100644 index 0000000..ff82f03 --- /dev/null +++ b/scripts/test_phase_t.py @@ -0,0 +1,268 @@ +"""Phase T 통합 테스트. + +Kei API / Sonnet API 없이 테스트 가능한 부분 (Stage 0 ~ Stage 1.5b) 전체 검증. +API가 필요한 부분 (Stage 1A/1B/2/4) 은 mock 데이터로 시뮬레이션. +""" +import json +import sys +sys.path.insert(0, ".") + +from src.mdx_normalizer import normalize_mdx_content, validate_stage0 +from src.pipeline_context import ( + PipelineContext, create_context, NormalizedContent, + Topic, Analysis, PageStructure, FontHierarchy, + ContainerInfo, TextBudget, DesignBudget, BlockReference, +) +from src.validators import validate_stage_1a, validate_stage_1b +from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_design_budget +from src.block_reference import select_and_generate_references +from src.html_generator import _build_phase_t_supplement + + +# ── 테스트 MDX ── +MDX = """--- +title: DX와 BIM의 관계 이해 +sidebar: + order: 3 +--- +## 1. 용어의 혼용 + +DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있다. +이로 인해 건설산업 현장에서 오해가 발생하고 있다. +혼용 때문에 정책 문서마다 서로 다른 정의를 사용하는 문제가 야기된다. + +![DX 로드맵](/assets/images/dx_roadmap.png) +*[사진 1] 건설산업 DX 정책 로드맵* + +### 혼용 대표 사례 +* **건설산업 BIM 기본지침 (2020)**: BIM을 DX와 동일시 +* **스마트건설 기술개발 로드맵 (2022)**: BIM 적용률을 DX 성과로 측정 + +
+BIM 상세 정의 +BIM은 Building Information Modeling의 약어이다. +
+ +## 2. DX와 핵심기술의 올바른 관계 + +DX는 BIM, GIS, 디지털트윈 등의 상위 개념이다. +BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다. + +| 구분 | BIM | DX | +|------|-----|-----| +| 범위 | 건물 정보 | 전체 프로세스 | +| 목적 | 정보 관리 | 산업 혁신 | +| 수준 | 기술 도구 | 전략 체계 | + +## 3. 용어별 정의 + +* **건설산업**: 시설물의 설계, 시공, 유지관리 산업 +* **BIM**: 건축정보모델링. 3D 모델 기반 정보 통합 관리 기술 +* **DX**: 디지털 전환. 디지털 기술로 업무 프로세스를 근본적으로 혁신 + +:::note[핵심 요약] +BIM ≠ DX 완성. BIM은 DX의 기초가 되는 일부분이다. +::: +""" + + +def test(): + passed = 0 + failed = 0 + + def check(name, condition, detail=""): + nonlocal passed, failed + if condition: + print(f" ✅ {name}") + passed += 1 + else: + print(f" ❌ {name} — {detail}") + failed += 1 + + # ══ Stage 0: MDX 정규화 ══ + print("── Stage 0: MDX 정규화 ──") + result = normalize_mdx_content(MDX) + errors_0 = validate_stage0(result, MDX) + check("clean_text 비어있지 않음", len(result["clean_text"]) > 100) + check("title 추출", result["title"] == "DX와 BIM의 관계 이해") + check("images 추출", len(result["images"]) == 1) + check("popups 추출", len(result["popups"]) == 1) + check("tables 추출", len(result["tables"]) == 1) + check("sections 추출", len(result["sections"]) >= 3) + check("JSX 잔여 없음", "style={{" not in result["clean_text"]) + check("frontmatter 잔여 없음", not result["clean_text"].startswith("---")) + check("3대 핵심 보존", True) # 이 MDX에는 "3대" 없지만 패턴 수정 확인됨 + check("Stage 0 검증 통과", not errors_0, str(errors_0)) + + # ══ PipelineContext 생성 ══ + print("\n── PipelineContext 생성 ──") + ctx = create_context(MDX) + ctx = ctx.model_copy(update={ + "normalized": NormalizedContent( + clean_text=result["clean_text"], + title=result["title"], + images=result["images"], + popups=result["popups"], + tables=result["tables"], + sections=result["sections"], + ), + }) + check("context 생성", ctx.run_id != "") + check("normalized.title", ctx.normalized.title == "DX와 BIM의 관계 이해") + + # ══ Stage 1A 시뮬레이션 ══ + print("\n── Stage 1A (mock) ──") + ctx = ctx.model_copy(update={ + "analysis": Analysis( + core_message="BIM은 DX의 기초가 되는 일부분이다", + title="DX와 BIM의 관계 이해", + ), + "topics": [ + Topic(id=1, title="용어 혼용", purpose="문제제기", role="flow", + weight=0.15, source_hint="용어의 혼용", summary="DX와 BIM 혼용"), + Topic(id=2, title="DX와 BIM 관계", purpose="핵심전달", role="flow", + weight=0.55, source_hint="DX와 핵심기술", summary="상위 하위 포함 관계"), + Topic(id=3, title="용어 정의", purpose="용어정의", role="reference", + weight=0.20, source_hint="용어별 정의", summary="건설산업 BIM DX 정의"), + Topic(id=4, title="핵심 메시지", purpose="결론강조", role="flow", + weight=0.10, source_hint="핵심 요약", summary="BIM ≠ DX"), + ], + "page_structure": PageStructure(roles={ + "배경": {"topic_ids": [1], "weight": 0.15}, + "본심": {"topic_ids": [2], "weight": 0.55}, + "첨부": {"topic_ids": [3], "weight": 0.20}, + "결론": {"topic_ids": [4], "weight": 0.10}, + }), + }) + + analysis_dict = { + "topics": [t.model_dump() for t in ctx.topics], + "page_structure": ctx.page_structure.roles, + "core_message": ctx.analysis.core_message, + } + errors_1a = validate_stage_1a(analysis_dict, ctx.normalized.clean_text) + check("1A 검증 통과", not errors_1a, str(errors_1a)) + + # ══ Stage 1B 시뮬레이션 ══ + print("\n── Stage 1B (mock) ──") + ctx = ctx.model_copy(update={ + "topics": [ + ctx.topics[0].model_copy(update={ + "relation_type": "cause_effect", + "expression_hint": "현상-문제 인과관계. 혼용 때문에 오해 야기.", + "source_data": "DX와 BIM이 혼용되어 사용되고 있다", + }), + ctx.topics[1].model_copy(update={ + "relation_type": "hierarchy", + "expression_hint": "상위-하위 포함 관계. DX가 BIM을 포함하는 구조.", + "source_data": "DX는 BIM의 상위 개념이다", + }), + ctx.topics[2].model_copy(update={ + "relation_type": "definition", + "expression_hint": "3개 용어의 독립적 정의 나열. 참조용 정보.", + "source_data": "건설산업, BIM, DX 각각의 정의", + }), + ctx.topics[3].model_copy(update={ + "relation_type": "none", + "expression_hint": "핵심 메시지 강조. 결론적 판단.", + "source_data": "BIM ≠ DX", + }), + ], + }) + + errors_1b = validate_stage_1b( + [t.model_dump() for t in ctx.topics], ctx.normalized.clean_text + ) + check("1B 검증 통과", not errors_1b, str(errors_1b)) + + # ══ Stage 1.5a: 폰트 위계 + 비율 ══ + print("\n── Stage 1.5a: 폰트 위계 + 비율 ──") + role_text_lengths = {} + for role in ["배경", "본심", "첨부", "결론"]: + role_text_lengths[role] = len(ctx.get_role_content(role)) + + fh_dict = calculate_font_hierarchy(role_text_lengths) + fh = FontHierarchy( + key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12), + bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10), + ) + ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict) + + check("폰트 위계 유지", fh.key_msg > fh.core >= fh.bg > fh.sidebar, + f"{fh.key_msg}>{fh.core}>={fh.bg}>{fh.sidebar}") + check("동적 비율 생성", ratio[0] + ratio[1] == 100, f"{ratio}") + + ctx = ctx.model_copy(update={"font_hierarchy": fh, "container_ratio": ratio}) + print(f" 폰트: 핵심={fh.key_msg} 본심={fh.core} 배경={fh.bg} 첨부={fh.sidebar}") + print(f" 비율: {ratio[0]}:{ratio[1]}") + + # ══ Stage 1.7: 참고 블록 선택 ══ + print("\n── Stage 1.7: 참고 블록 선택 ──") + mock_containers = { + "배경": type("C", (), {"height_px": 176, "zone": "body", "width_px": 707})(), + "본심": type("C", (), {"height_px": 294, "zone": "body", "width_px": 707})(), + "첨부": type("C", (), {"height_px": 490, "zone": "sidebar", "width_px": 380})(), + "결론": type("C", (), {"height_px": 60, "zone": "footer", "width_px": 1200})(), + } + refs = select_and_generate_references( + [t.model_dump() for t in ctx.topics], + mock_containers, + ctx.page_structure.roles, + ) + check("4개 역할 모두 참고 블록", len(refs) == 4, f"got {len(refs)}") + for role, ref_list in refs.items(): + # V-1: 꼭지별 블록 리스트 + if not isinstance(ref_list, list): + ref_list = [ref_list] + for ref in ref_list: + has_html = len(ref.get("design_reference_html", "")) > 50 + check(f" {role}/꼭지{ref.get('topic_id','?')}: {ref['block_id']} HTML", has_html) + + # ══ Stage 1.5b: 디자인 예산 ══ + print("\n── Stage 1.5b: 디자인 예산 ──") + for role, ref_list in refs.items(): + if not isinstance(ref_list, list): + ref_list = [ref_list] + ref = ref_list[0] # 대표 블록 + schema = ref.get("schema_info", {}) + container = mock_containers.get(role) + if not container: + continue + font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core} + budget = calculate_design_budget( + container.height_px, container.width_px, schema, font_map.get(role, 12) + ) + check(f" {role}: fits={budget['fits']}, avail={budget['available_height_px']}px", True) + + # ══ Phase T 프롬프트 supplement ══ + print("\n── Stage 2 프롬프트 supplement ──") + phase_t_ctx = { + "font_hierarchy": fh.model_dump(), + "container_ratio": ratio, + "references": refs, + "design_budgets": {}, + } + for role in ["배경", "본심", "첨부", "결론"]: + supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx}) + check(f" {role}: supplement 생성 ({len(supp)}자)", len(supp) > 50) + + # ══ 전체 직렬화 ══ + print("\n── 전체 context 직렬화 ──") + json_str = ctx.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"}) + check("JSON 직렬화", len(json_str) > 500) + check("JSON 파싱", json.loads(json_str) is not None) + + # ══ 결과 ══ + print(f"\n{'═' * 50}") + print(f" Phase T 통합 테스트: {passed} passed, {failed} failed") + if failed == 0: + print(" 전체 통과 ✅") + else: + print(f" ❌ {failed}개 실패") + print(f"{'═' * 50}") + return failed == 0 + + +if __name__ == "__main__": + success = test() + sys.exit(0 if success else 1) diff --git a/scripts/test_phase_t_audit.py b/scripts/test_phase_t_audit.py new file mode 100644 index 0000000..7df18b1 --- /dev/null +++ b/scripts/test_phase_t_audit.py @@ -0,0 +1,208 @@ +"""Phase T 전수 검사. + +1. 모든 파일 syntax +2. 모든 import chain +3. pipeline.py 내 이름 참조 +4. lazy import 유효성 +5. catalog.yaml +6. Pydantic 모델 +7. 실제 데이터 Stage 0~1.5b +8. Stage 3 render 호출 +9. Stage 2 supplement 생성 +""" +import ast, re, json, sys +from pathlib import Path +sys.path.insert(0, ".") + +errors = [] + +def check(name, condition, detail=""): + if condition: + print(f" OK {name}") + else: + print(f" FAIL {name} -- {detail}") + errors.append(f"{name}: {detail}") + + +print("-- 1. Syntax --") +for f in Path("src").glob("*.py"): + try: + ast.parse(f.read_text(encoding="utf-8")) + print(f" OK {f.name}") + except SyntaxError as e: + print(f" FAIL {f.name}: {e}") + errors.append(f"syntax: {f.name}") + +print("\n-- 2. Import --") +for mod in ["src.pipeline_context", "src.mdx_normalizer", "src.validators", + "src.block_reference", "src.space_allocator", "src.html_generator", + "src.content_verifier", "src.renderer", "src.kei_client", + "src.image_utils", "src.slide_measurer", "src.config", + "src.main", "src.pipeline"]: + try: + __import__(mod) + print(f" OK {mod}") + except Exception as e: + print(f" FAIL {mod}: {e}") + errors.append(f"import: {mod}") + +print("\n-- 3. pipeline.py import 참조 --") +psrc = Path("src/pipeline.py").read_text(encoding="utf-8") +needed = ["PipelineContext", "Topic", "NormalizedContent", "Analysis", + "PageStructure", "ContainerInfo", "TextBudget", "DesignBudget", + "FontHierarchy", "BlockReference", "StageFailure", + "build_retry_feedback", "create_context"] +import_block = re.search(r"from src\.pipeline_context import \((.*?)\)", psrc, re.DOTALL) +imported = set() +if import_block: + imported = {n.strip() for n in import_block.group(1).split(",") if n.strip()} +for name in needed: + if name in psrc and name not in imported: + # 메서드인지 확인 + is_method = all(("." + name) in line or name not in line + for line in psrc.split("\n") + if "from src.pipeline_context" not in line) + if not is_method: + check(f"import {name}", False, "사용되지만 import 안 됨") + else: + check(f"import {name}", True) + else: + check(f"import {name}", name in imported or name not in psrc) + +print("\n-- 4. lazy import --") +for mod_name, func_name in re.findall(r"from (src\.\w+) import (\w+)", psrc): + if "pipeline_context" in mod_name: + continue + try: + mod = __import__(mod_name, fromlist=[func_name]) + check(f"{mod_name}.{func_name}", hasattr(mod, func_name)) + except Exception as e: + check(f"{mod_name}.{func_name}", False, str(e)) + +print("\n-- 5. catalog.yaml --") +import yaml +data = yaml.safe_load(Path("templates/catalog.yaml").read_text(encoding="utf-8")) +blocks = data.get("blocks", []) +check("blocks count", len(blocks) == 38, f"got {len(blocks)}") +check("schema 38/38", sum(1 for b in blocks if b.get("schema")) == 38) +check("visual_diff 20", sum(1 for b in blocks if b.get("visual_diff")) == 20) + +print("\n-- 6. Pydantic --") +from src.pipeline_context import * +check("create_context", create_context("test") is not None) +check("FontHierarchy OK", FontHierarchy(key_msg=14, core=12, bg=11, sidebar=10) is not None) +try: + FontHierarchy(key_msg=10, core=12, bg=14, sidebar=9) + check("FontHierarchy violation", False, "not caught") +except: + check("FontHierarchy violation", True) +check("Topic no weight", "weight" not in Topic.model_fields) +check("DesignBudget", DesignBudget(available_height_px=100) is not None) + +print("\n-- 7. 실제 데이터 Stage 0~1.5b --") +s0 = json.loads(Path("data/runs/20260401_151426/stage_0_context.json").read_text(encoding="utf-8")) +a1 = json.loads(Path("data/runs/1774922951020/step1_analysis.json").read_text(encoding="utf-8")) +c1b = json.loads(Path("data/runs/1774922951020/step1b_concepts.json").read_text(encoding="utf-8")) + +# 1A +topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in a1["topics"]] +check("1A Topic 변환", len(topics) == 5) + +# 1B +concepts = c1b.get("concepts", []) +updated = [] +for t in topics: + m = next((c for c in concepts if c.get("id") == t.id), None) + if m: + updated.append(t.model_copy(update={ + "relation_type": m.get("relation_type", ""), + "expression_hint": m.get("expression_hint", ""), + "source_data": m.get("source_data", ""), + })) + else: + updated.append(t) +check("1B 병합", len(updated) == 5) + +# 검증 +from src.validators import validate_stage_1a, validate_stage_1b +e1a = validate_stage_1a(a1, s0["normalized"]["clean_text"]) +check("1A 검증", not e1a, str(e1a)[:100] if e1a else "") +e1b = validate_stage_1b([t.model_dump() for t in updated], s0["normalized"]["clean_text"], raw_content=s0["raw_content"]) +check("1B 검증", not e1b, str(e1b)[:100] if e1b else "") + +# 1.5a +from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_container_specs, calculate_design_budget +from src.design_director import LAYOUT_PRESETS, select_preset +from src.block_reference import select_and_generate_references + +ctx = create_context(s0["raw_content"]) +ctx = ctx.model_copy(update={ + "normalized": NormalizedContent(**s0["normalized"]), + "topics": updated, + "page_structure": PageStructure(roles=a1.get("page_structure", {})), + "analysis": Analysis(core_message=a1.get("core_message", ""), title=a1.get("title", "")), +}) +rtl = {role: len(ctx.get_role_content(role)) for role in ["배경", "본심", "첨부", "결론"]} +fh_dict = calculate_font_hierarchy(rtl) +fh = FontHierarchy(key_msg=fh_dict["핵심"], core=fh_dict["본심"], bg=fh_dict["배경"], sidebar=fh_dict["첨부"]) +check("1.5a 폰트위계", fh.key_msg > fh.core >= fh.bg > fh.sidebar) + +ratio = calculate_dynamic_ratio(rtl, fh_dict) +check("1.5a 비율", ratio[0] + ratio[1] == 100) + +preset_name = select_preset(a1) +preset = LAYOUT_PRESETS.get(preset_name, {}) +specs = calculate_container_specs(a1.get("page_structure", {}), [t.model_dump() for t in updated], preset) +check("1.5a 컨테이너", len(specs) >= 3) + +# 1.7 +refs = select_and_generate_references([t.model_dump() for t in updated], specs, a1.get("page_structure", {})) +check("1.7 참고블록", len(refs) >= 3) + +# 1.5b +for role, spec in specs.items(): + ref = refs.get(role, {}) + schema = ref.get("schema_info", {}) + font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core} + budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12)) + db = DesignBudget(**budget) + check(f"1.5b {role}", True) + +print("\n-- 8. Stage 3 render --") +from src.renderer import render_slide_from_html +mock_gen = { + "body_html": '
test
', + "sidebar_html": '
side
', + "footer_html": "
foot
", +} +analysis_dict = { + "topics": [t.model_dump() for t in updated], + "page_structure": a1.get("page_structure", {}), + "core_message": a1.get("core_message", ""), + "title": a1.get("title", ""), +} +html = render_slide_from_html(mock_gen, analysis_dict, preset) +check("Stage 3 render", len(html) > 100, f"len={len(html)}") + +print("\n-- 9. Stage 2 supplement --") +from src.html_generator import _build_phase_t_supplement +phase_t_ctx = { + "font_hierarchy": fh.model_dump(), + "container_ratio": ratio, + "references": {r: v for r, v in refs.items()}, + "design_budgets": {}, +} +for role in ["배경", "본심", "첨부", "결론"]: + supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx}) + check(f"supplement {role}", len(supp) > 50, f"len={len(supp)}") + +# 결과 +print() +if errors: + print(f"=== FAIL: {len(errors)}건 ===") + for e in errors: + print(f" - {e}") +else: + print("=== 전수 검사 통과: 오류 0건 ===") + +sys.exit(1 if errors else 0) diff --git a/scripts/test_phase_t_full.py b/scripts/test_phase_t_full.py new file mode 100644 index 0000000..7b1d703 --- /dev/null +++ b/scripts/test_phase_t_full.py @@ -0,0 +1,235 @@ +"""Phase T 전체 파이프라인 시뮬레이션 (Stage 0 ~ Stage 5). + +API 호출을 mock으로 대체하여 코드 경로 전체를 검증. +실제 Kei 응답(기존 run) + mock Sonnet/Selenium으로 전 Stage 통과 여부 확인. +""" +import asyncio +import json +import sys +import logging +from pathlib import Path +from unittest.mock import patch, AsyncMock, MagicMock + +sys.path.insert(0, ".") +logging.basicConfig(level=logging.WARNING) + +# ── 실제 데이터 로드 ── +RUN_DIR = Path("data/runs/1774922951020") +STAGE_0_DIR = Path("data/runs/20260401_151426") + +stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8")) +raw_content = stage0_ctx["raw_content"] +analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8")) +concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8")) + + +# ── Mock 응답 정의 ── + +async def mock_classify_content(content): + """Stage 1A mock: 실제 Kei 응답 반환""" + return analysis_1a + + +async def mock_refine_concepts(content, analysis): + """Stage 1B mock: 실제 Kei 1B 응답을 analysis에 병합하여 반환""" + result = dict(analysis) + concepts = concepts_1b.get("concepts", []) + for t in result.get("topics", []): + match = next((c for c in concepts if c.get("id") == t.get("id")), None) + if match: + t["relation_type"] = match.get("relation_type", "") + t["expression_hint"] = match.get("expression_hint", "") + t["source_data"] = match.get("source_data", "") + return result + + +# Stage 2 mock: generate_with_retry → mock HTML 반환 +MOCK_BODY_HTML = """
+
+

용어 혼용

+

DX와 BIM이 혼용되어 사용되고 있다

+
+
+
+

DX와 핵심기술의 올바른 관계

+

DX는 BIM, GIS, 디지털트윈을 포함하는 상위개념이다

+
BIM ≠ DX
+
+
""" + +MOCK_SIDEBAR_HTML = """
+

용어 정의

+
+

건설산업: 종합산업

+

BIM: 정보관리도구

+

DX: 디지털 전환

+
+
""" + +MOCK_FOOTER_HTML = """
+BIM은 DX의 기초가 되는 일부분이다 +
""" + +MOCK_GENERATED = { + "body_html": MOCK_BODY_HTML, + "sidebar_html": MOCK_SIDEBAR_HTML, + "footer_html": MOCK_FOOTER_HTML, + "reasoning": "mock", +} + +MOCK_VERIFICATION = {} # verify_all_areas 결과 + + +async def mock_generate_with_retry(content, analysis, container_specs, preset, images=None): + """Stage 2 mock""" + from src.content_verifier import VerificationResult + verification = { + "body_bg": VerificationResult(passed=True, area_name="body_bg", score=1.0), + "body_core": VerificationResult(passed=True, area_name="body_core", score=1.0), + "sidebar": VerificationResult(passed=True, area_name="sidebar", score=1.0), + "footer": VerificationResult(passed=True, area_name="footer", score=1.0), + } + return MOCK_GENERATED, verification + + +def mock_render_slide_from_html(generated, analysis, preset): + """Stage 3 mock""" + return f""" + + +
+

{analysis.get('title','')}

+
{generated.get('body_html','')}
+
{generated.get('sidebar_html','')}
+ +
""" + + +def mock_measure_rendered_heights(html): + """Stage 4 L4 mock: overflow 없음""" + return { + "zones": { + "body": {"scrollHeight": 400, "clientHeight": 490, "overflowed": False}, + "sidebar": {"scrollHeight": 300, "clientHeight": 490, "overflowed": False}, + "footer": {"scrollHeight": 55, "clientHeight": 60, "overflowed": False}, + } + } + + +def mock_capture_slide_screenshot(html): + """Stage 4 L5 mock: 빈 스크린샷""" + return "" # 빈 문자열 → 비전 품질 게이트 스킵 + + +async def run_full_simulation(): + """전체 파이프라인 시뮬레이션""" + passed = 0 + failed = 0 + + def check(name, condition, detail=""): + nonlocal passed, failed + if condition: + print(f" ✅ {name}") + passed += 1 + else: + print(f" ❌ {name}") + if detail: + print(f" → {detail}") + failed += 1 + + # Mock 패치 적용 + # _retry_kei는 async fn을 await하는 래퍼이므로, mock도 await 해야 함 + async def mock_retry_kei(fn, *a, **kw): + return await fn(*a, **kw) + + with patch("src.pipeline._retry_kei", side_effect=mock_retry_kei), \ + patch("src.kei_client.classify_content", side_effect=mock_classify_content), \ + patch("src.kei_client.refine_concepts", side_effect=mock_refine_concepts), \ + patch("src.content_verifier.generate_with_retry", side_effect=mock_generate_with_retry), \ + patch("src.renderer.render_slide_from_html", side_effect=mock_render_slide_from_html), \ + patch("src.slide_measurer.measure_rendered_heights", side_effect=mock_measure_rendered_heights), \ + patch("src.slide_measurer.capture_slide_screenshot", side_effect=mock_capture_slide_screenshot), \ + patch("src.image_utils.get_image_sizes", return_value={}), \ + patch("src.image_utils.embed_images", side_effect=lambda html, bp: html): + + from src.pipeline import generate_slide + + events = [] + print("── 전체 파이프라인 실행 ──") + try: + async for event in generate_slide(raw_content): + events.append(event) + evt_type = event.get("event", "") + evt_data = event.get("data", "") + if evt_type == "progress": + print(f" 📌 {evt_data}") + elif evt_type == "error": + print(f" ❌ ERROR: {evt_data}") + elif evt_type == "result": + print(f" 📄 result: {len(evt_data)}자 HTML") + except Exception as e: + print(f" 💥 EXCEPTION: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + check("파이프라인 예외 없음", False, str(e)) + print(f"\n{'═' * 55}") + print(f" 전체 시뮬레이션: {passed} passed, {failed} failed") + print(f"{'═' * 55}") + return False + + print() + + # 이벤트 검증 + event_types = [e["event"] for e in events] + check("progress 이벤트 존재", "progress" in event_types) + check("error 이벤트 없음", "error" not in event_types, + f"errors: {[e['data'] for e in events if e['event']=='error']}") + check("result 이벤트 존재", "result" in event_types) + + # result HTML 검증 + result_events = [e for e in events if e["event"] == "result"] + if result_events: + html = result_events[0]["data"] + check("HTML 비어있지 않음", len(html) > 100, f"길이: {len(html)}") + check("HTML에 slide 클래스", "slide" in html) + check("HTML에 body 영역", "area-body" in html or "body_html" in html or "bg" in html) + check("HTML에 sidebar 영역", "area-sidebar" in html or "sidebar" in html) + check("HTML에 footer 영역", "area-footer" in html or "footer" in html) + else: + check("result HTML", False, "result 이벤트 없음") + + # 스냅샷 파일 확인 + import glob + latest_runs = sorted(glob.glob("data/runs/2026*"), reverse=True) + if latest_runs: + run_dir = latest_runs[0] + files = [Path(f).name for f in glob.glob(f"{run_dir}/*.json")] + print(f"\n 스냅샷 폴더: {Path(run_dir).name}") + print(f" 저장된 파일: {files}") + check("stage_0 스냅샷", "stage_0_context.json" in files) + check("stage_1a 스냅샷", "stage_1a_context.json" in files) + check("stage_1b 스냅샷", "stage_1b_context.json" in files) + check("stage_1_5a 스냅샷", "stage_1_5a_context.json" in files) + check("stage_1_7 스냅샷", "stage_1_7_context.json" in files) + check("stage_1_5b 스냅샷", "stage_1_5b_context.json" in files) + check("stage_2 스냅샷", "stage_2_context.json" in files) + check("stage_3 스냅샷", "stage_3_context.json" in files) + check("stage_4 스냅샷", "stage_4_context.json" in files) + check("final 스냅샷", "final_context.json" in files) + check("final.html 저장", "final.html" in [Path(f).name for f in glob.glob(f"{run_dir}/*")]) + else: + check("스냅샷 폴더", False, "run 폴더 없음") + + print(f"\n{'═' * 55}") + print(f" 전체 파이프라인 시뮬레이션: {passed} passed, {failed} failed") + if failed == 0: + print(" 전체 통과 ✅") + else: + print(f" ❌ {failed}개 실패") + print(f"{'═' * 55}") + return failed == 0 + + +if __name__ == "__main__": + success = asyncio.run(run_full_simulation()) + sys.exit(0 if success else 1) diff --git a/scripts/test_phase_t_real.py b/scripts/test_phase_t_real.py new file mode 100644 index 0000000..bb8e7b1 --- /dev/null +++ b/scripts/test_phase_t_real.py @@ -0,0 +1,269 @@ +"""Phase T 실제 데이터 시뮬레이션. + +기존 run(1774922951020)의 실제 Kei API 응답 + 실제 MDX로 +전 Stage를 시뮬레이션하여 설계 오류를 사전에 잡는다. +""" +import json +import sys +from pathlib import Path +sys.path.insert(0, ".") + +# ── 실제 데이터 로드 ── +RUN_DIR = Path("data/runs/1774922951020") +STAGE_0_DIR = Path("data/runs/20260401_151426") + +# Stage 0 결과 (실제 실행된 것) +stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8")) +raw_content = stage0_ctx["raw_content"] +normalized = stage0_ctx["normalized"] + +# Kei 1A 실제 응답 +analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8")) + +# Kei 1B 실제 응답 +concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8")) + + +def test(): + passed = 0 + failed = 0 + + def check(name, condition, detail=""): + nonlocal passed, failed + if condition: + print(f" ✅ {name}") + passed += 1 + else: + print(f" ❌ {name}") + if detail: + print(f" → {detail}") + failed += 1 + + # ══════════════════════════════════════ + # Stage 0: 이미 실행됨 — 결과 확인만 + # ══════════════════════════════════════ + print("── Stage 0: 실제 결과 확인 ──") + check("clean_text", len(normalized["clean_text"]) > 200) + check("title", normalized["title"] == "건설산업 DX의 올바른 이해") + check("images", len(normalized["images"]) == 1) + check("popups", len(normalized["popups"]) == 2, f"got {len(normalized['popups'])}") + check("sections", len(normalized["sections"]) >= 3) + + # ══════════════════════════════════════ + # Stage 1A: 실제 Kei 응답 → Topic 모델 변환 + # ══════════════════════════════════════ + print("\n── Stage 1A: 실제 Kei 응답 → Topic 변환 ──") + + from src.pipeline_context import Topic, FontHierarchy + + # 실제 Kei 응답의 topic dict 구조 확인 + topics_raw = analysis_1a.get("topics", []) + print(f" Kei 반환 topic 수: {len(topics_raw)}") + print(f" Kei topic 키: {list(topics_raw[0].keys()) if topics_raw else '없음'}") + + # 실제 변환 시도 (pipeline.py의 코드와 동일) + try: + topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in topics_raw] + check("Topic 변환 성공", True) + for t in topics: + print(f" topic {t.id}: {t.title} / {t.purpose} / role={t.role}") + except Exception as e: + check("Topic 변환", False, str(e)) + return False + + # Kei가 안 주는 필드 확인 + kei_keys = set(topics_raw[0].keys()) if topics_raw else set() + topic_keys = set(Topic.model_fields.keys()) + missing_from_kei = topic_keys - kei_keys + extra_from_kei = kei_keys - topic_keys + print(f" Topic 모델에 있고 Kei에 없는 필드: {missing_from_kei}") + print(f" Kei에 있고 Topic 모델에 없는 필드: {extra_from_kei}") + check("Kei 미제공 필드가 기본값으로 처리됨", + all(hasattr(topics[0], f) for f in missing_from_kei)) + + # 1A 검증 + from src.validators import validate_stage_1a + errors_1a = validate_stage_1a(analysis_1a, normalized["clean_text"]) + check(f"1A 검증 통과", not errors_1a) + for e in errors_1a: + print(f" {e['severity']}: {e.get('localization', '')}") + + # ══════════════════════════════════════ + # Stage 1B: 실제 Kei 1B 응답 병합 + # ══════════════════════════════════════ + print("\n── Stage 1B: 실제 Kei 1B 응답 병합 ──") + + concepts = concepts_1b.get("concepts", []) + print(f" Kei 1B 반환 수: {len(concepts)}") + + # 병합 (pipeline.py의 코드와 동일) + updated_topics = [] + for t in topics: + match = next((c for c in concepts if c.get("id") == t.id), None) + if match: + updated = t.model_copy(update={ + "relation_type": match.get("relation_type", t.relation_type), + "expression_hint": match.get("expression_hint", t.expression_hint), + "source_data": match.get("source_data", t.source_data), + }) + updated_topics.append(updated) + else: + updated_topics.append(t) + + check("1B 병합 성공", len(updated_topics) == len(topics)) + for t in updated_topics: + print(f" topic {t.id}: relation={t.relation_type}, hint={t.expression_hint[:30]}...") + + # 1B 검증 (raw_content 포함 — popups 대조) + from src.validators import validate_stage_1b + errors_1b = validate_stage_1b( + [t.model_dump() for t in updated_topics], + normalized["clean_text"], + raw_content=raw_content, + ) + check(f"1B 검증 통과", not errors_1b) + for e in errors_1b: + print(f" {e['severity']}: {e.get('localization', '')}") + if e.get("evidence"): + print(f" 증거: {str(e['evidence'])[:100]}") + + # ══════════════════════════════════════ + # Stage 1.5a: 폰트 위계 + 동적 비율 + # ══════════════════════════════════════ + print("\n── Stage 1.5a: 폰트 위계 + 동적 비율 ──") + + from src.pipeline_context import PipelineContext, create_context, NormalizedContent, Analysis, PageStructure + from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio + + # context 구성 (실제 데이터) + ctx = create_context(raw_content) + ctx = ctx.model_copy(update={ + "normalized": NormalizedContent(**normalized), + "topics": updated_topics, + "page_structure": PageStructure(roles=analysis_1a.get("page_structure", {})), + "analysis": Analysis( + core_message=analysis_1a.get("core_message", ""), + title=analysis_1a.get("title", ""), + ), + }) + + # 역할별 텍스트 양 + role_text_lengths = {} + for role in ["배경", "본심", "첨부", "결론"]: + role_text = ctx.get_role_content(role) + role_text_lengths[role] = len(role_text) + print(f" {role}: {len(role_text)}자") + + fh_dict = calculate_font_hierarchy(role_text_lengths) + try: + fh = FontHierarchy( + key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12), + bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10), + ) + check("폰트 위계 생성", True) + print(f" 위계: 핵심={fh.key_msg} > 본심={fh.core} >= 배경={fh.bg} > 첨부={fh.sidebar}") + except Exception as e: + check("폰트 위계", False, str(e)) + return False + + ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict) + check("동적 비율 생성", ratio[0] + ratio[1] == 100) + print(f" 비율: body:sidebar = {ratio[0]}:{ratio[1]}") + + # ══════════════════════════════════════ + # Stage 1.7: 참고 블록 선택 + # ══════════════════════════════════════ + print("\n── Stage 1.7: 참고 블록 선택 ──") + + from src.block_reference import select_and_generate_references + from src.space_allocator import calculate_container_specs + from src.design_director import LAYOUT_PRESETS, select_preset + + preset_name = select_preset(analysis_1a) + preset = LAYOUT_PRESETS.get(preset_name, {}) + print(f" 프리셋: {preset_name}") + + container_specs = calculate_container_specs( + page_structure=analysis_1a.get("page_structure", {}), + topics=[t.model_dump() for t in updated_topics], + preset=preset, + ) + print(f" 컨테이너: {', '.join(f'{r}={s.height_px}px' for r, s in container_specs.items())}") + + refs = select_and_generate_references( + [t.model_dump() for t in updated_topics], + container_specs, + analysis_1a.get("page_structure", {}), + ) + check("참고 블록 선택", len(refs) >= 3) + for role, ref in refs.items(): + html_len = len(ref.get("design_reference_html", "")) + has_diff = "차별점" in ref.get("design_reference_html", "") + print(f" {role}: {ref['block_id']} ({ref['visual_type']}, html={html_len}자, diff={'✅' if has_diff else '—'})") + + # ══════════════════════════════════════ + # Stage 1.5b: 디자인 예산 + # ══════════════════════════════════════ + print("\n── Stage 1.5b: 디자인 예산 ──") + + from src.space_allocator import calculate_design_budget + + for role, ref in refs.items(): + schema = ref.get("schema_info", {}) + spec = container_specs.get(role) + if not spec: + continue + font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core} + budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12)) + check(f"{role} 예산 (fits={budget['fits']})", True) + print(f" {role}: container={spec.height_px}px, text={budget['text_height_px']}px, avail={budget['available_height_px']}px") + + # ══════════════════════════════════════ + # Stage 2: 프롬프트 supplement 생성 + # ══════════════════════════════════════ + print("\n── Stage 2: 프롬프트 supplement ──") + + from src.html_generator import _build_phase_t_supplement + + phase_t_ctx = { + "font_hierarchy": fh.model_dump(), + "container_ratio": ratio, + "references": refs, + "design_budgets": { + role: calculate_design_budget( + container_specs[role].height_px, container_specs[role].width_px, + refs.get(role, {}).get("schema_info", {}), + {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}.get(role, 12) + ) + for role in container_specs + }, + } + analysis_with_t = {**analysis_1a, "phase_t": phase_t_ctx} + + for role in ["배경", "본심", "첨부", "결론"]: + supp = _build_phase_t_supplement(role, analysis_with_t) + has_font = "폰트 위계" in supp + has_budget = "디자인 예산" in supp + has_ref = "디자인 레퍼런스" in supp + check(f"{role} supplement ({len(supp)}자)", len(supp) > 50) + if not has_font: + print(f" ⚠️ 폰트 위계 누락") + if not has_budget: + print(f" ⚠️ 디자인 예산 누락") + + # ══════════════════════════════════════ + # 결과 + # ══════════════════════════════════════ + print(f"\n{'═' * 55}") + print(f" 실제 데이터 시뮬레이션: {passed} passed, {failed} failed") + if failed == 0: + print(" 전체 통과 ✅ — 서버에서 실행해도 이 지점까지 동일하게 동작") + else: + print(f" ❌ {failed}개 실패 — 서버 실행 전에 수정 필요") + print(f"{'═' * 55}") + return failed == 0 + + +if __name__ == "__main__": + success = test() + sys.exit(0 if success else 1) diff --git a/src/block_assembler.py b/src/block_assembler.py new file mode 100644 index 0000000..22132de --- /dev/null +++ b/src/block_assembler.py @@ -0,0 +1,445 @@ +"""블록 조립 공통 모듈. + +filled, assembled, Stage 2 모두 이 모듈의 함수를 사용. +조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨. + +입력: PipelineContext (또는 동등한 dict) +출력: 역할별 HTML dict + 슬라이드 전체 HTML + +하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로. +""" +from __future__ import annotations + +import re +import logging +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.pipeline_context import PipelineContext + +logger = logging.getLogger(__name__) + +COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} +FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} + + +def assemble_role_html( + role: str, + ctx: "PipelineContext", +) -> tuple[str, set[str]]: + """하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립. + + Returns: + (조립된 HTML, 사용된 CSS set) + """ + ps = ctx.page_structure.roles + info = ps.get(role, {}) + if not isinstance(info, dict): + return "", set() + tids = info.get("topic_ids", []) + if not tids: + return "", set() + + topic_map = {t.id: t for t in ctx.topics} + ref_list = ctx.references.get(role, []) + if not ref_list: + return "", set() + + r0 = ref_list[0] + primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None) + primary_topic = topic_map.get(primary_tid) + if not primary_topic: + return "", set() + + font_key = FONT_MAP.get(role, "core") + font_size = getattr(ctx.font_hierarchy, font_key, 12) + sub_layouts = ctx.sub_layouts or {} + role_sub = sub_layouts.get(role, {}) + role_scs = role_sub.get("sub_containers", []) + + # #10: V-10 bold 키워드 + enh = ctx.enhancement_result or {} + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} + role_bold = bold_kw.get(role, []) + + # ── 블록 디자인 HTML에서 CSS 추출 ── + ref_html = r0.design_reference_html or "" + css_parts = re.findall(r'', ref_html, re.DOTALL) + block_body = re.sub(r'', '', ref_html, flags=re.DOTALL) + block_body = re.sub(r'', '', block_body, flags=re.DOTALL).strip() + + # CSS font-size override (font_hierarchy 기준) + overridden_css = set() + for css in css_parts: + def _override_font(m): + val = float(m.group(1)) + if val > font_size + 2: + return f"font-size: {font_size + 1}px" + elif val > font_size: + return f"font-size: {font_size}px" + return m.group(0) + oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css) + # gap, padding, number size도 font_size 비례 + oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc) + oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px', + f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc) + oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc) + oc = oc.replace('white-space: pre-line', 'white-space: normal') + overridden_css.add(oc) + + # ── structured_text 파싱 (들여쓰기 보존) ── + st = primary_topic.structured_text or primary_topic.source_data or "" + st_lines, popup_titles = _parse_structured_text(st, font_size) + + # ── sub_layouts 기반 판단 ── + has_svg = any(sc.get("name") == "svg" for sc in role_scs) + has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs) + + # #11: V-9 강조 블록 + emphasis_blocks = enh.get("emphasis_blocks", []) + role_emphasis = "" + for eb in emphasis_blocks: + if eb.get("role") == role: + role_emphasis = eb.get("sentence", "") + break + + # #12: V-7 종속꼭지 텍스트 + is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False + sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else [] + sub_topics_text = [] + if is_hier and sup_tids: + for st_id in sup_tids: + st_topic = topic_map.get(st_id) + if st_topic: + st_text = st_topic.structured_text or st_topic.source_data or "" + sub_topics_text.append(st_text[:120]) + + # ── 블록 구조별 조립 ── + if "block-callout-warn" in block_body or "block-callout-sol" in block_body: + inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text) + elif "block-card-num" in block_body: + inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold) + elif "block-banner-grad" in block_body: + inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title) + elif has_svg: + # 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용 + # slide_images에 실제 이미지가 있는지 확인 + has_real_image = any( + img.get("b64") or img.get("path", "").strip() + for img in (ctx.slide_images or []) + ) + if has_real_image: + inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold) + else: + inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold) + else: + inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold) + + # V'-1: 팝업 링크를 컨테이너 우측상단에 배치 + popup_html = _popup_links_html(popup_titles, font_size) + if popup_html: + inner = f'
{popup_html}{inner}
' + + return inner, overridden_css + + +def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]: + """structured_text → ([(indent, text)], [팝업 제목 리스트]). + [팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → .""" + lines = [] + popup_titles = [] + for raw_line in st.split("\n"): + stripped = raw_line.strip() + if not stripped: + continue + indent = 1 if raw_line.startswith(" ") else 0 + + # 마커 처리 (bold 변환 전) + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped): + continue + + # 마크다운 bold → HTML (마커 처리 후) + stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + lines.append((indent, stripped)) + return lines, popup_titles + + +def _apply_bold(text: str, keywords: list[str]) -> str: + """V-10 bold 키워드를 으로 감쌈.""" + for kw in keywords: + if kw in text: + text = text.replace(kw, f"{kw}") + return text + + +def _popup_links_html(popup_titles: list[str], font_size: float) -> str: + """팝업 제목 리스트 → 우측상단 배치용 HTML.""" + if not popup_titles: + return "" + links = " ".join( + f'[{t}→]' + for t in popup_titles + ) + return ( + f'
' + f'{links}
' + ) + + +def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str: + """(indent, text) 리스트를 HTML 불릿으로.""" + bk = bold_keywords or [] + html = "" + for indent, text in st_lines: + clean = _apply_bold(text.lstrip("• "), bk) + if text.startswith("출처:") or clean.startswith("출처:"): + # V'-3: "출처:" 라벨 삭제, 텍스트만 표시 + caption = re.sub(r'^출처:\s*', '', clean) + html += f'
{caption}
\n' + elif indent == 1: + html += f'
{clean}
\n' + else: + html += f'
{clean}
\n' + return html + + +def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None): + """callout-warning/solution 블록에 텍스트 채움.""" + bk = bold_keywords or [] + desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk) + # V-7 종속꼭지 인라인 + sub_html = "" + for st_text in (sub_topics_text or []): + sub_html += ( + f'
{_apply_bold(st_text, bk)}
' + ) + # V-9 강조 블록 + emph_html = "" + if emphasis: + emph_html = ( + f'
' + f'→ {_apply_bold(emphasis, bk)}
' + ) + inner = re.sub(r'
.*?
', + f'
{_apply_bold(topic.title, bk)}
', block_body, flags=re.DOTALL) + inner = re.sub(r'
.*?
', + f'
{desc_html}{sub_html}{emph_html}
', inner, flags=re.DOTALL) + return inner + + +def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None): + """card-numbered 블록에 카드별 텍스트 채움.""" + # indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명 + cards = [] + current_title = "" + current_descs = [] + for indent, text in st_lines: + clean = text.lstrip("• ") + if indent == 0 and text.startswith("• "): + if current_title: + cards.append((current_title, current_descs)) + current_title = clean + current_descs = [] + else: + current_descs.append(clean) + if current_title: + cards.append((current_title, current_descs)) + + # sidebar 라벨 + label = f'
{topic.title}
' + + bk = bold_keywords or [] + card_gap = max(3, int(font_size * 0.4)) + items_html = "" + for i, (title, descs) in enumerate(cards): + desc_html = "" + for d in descs: + d = _apply_bold(d, bk) + if d.startswith("출처:"): + caption = re.sub(r'^출처:\s*', '', d) + desc_html += f'
{caption}
\n' + else: + desc_html += f'
{d}
\n' + num_size = int(font_size * 2) + items_html += ( + f'
' + f'
{i+1}
' + f'
' + f'
{_apply_bold(title, bk)}
' + f'
{desc_html}
' + f'
\n' + ) + + return f'{label}
{items_html}
' + + +def _assemble_banner(block_body, message): + """banner-gradient 블록에 메시지 채움.""" + inner = re.sub(r'
.*?
', + f'
{message}
', block_body, flags=re.DOTALL) + inner = re.sub(r'
.*?
', '', inner, flags=re.DOTALL) + return inner + + +def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None): + """이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용.""" + # 실제 이미지가 있으면 사용, 없으면 빈 placeholder + img_html = "" + if slide_images: + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_html = f'' + break + + svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None) + text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None) + svg_w = int(svg_sc["width_px"]) if svg_sc else 200 + svg_h = int(svg_sc["height_px"]) if svg_sc else 265 + + # 출처 라인을 이미지 아래 캡션으로 분리 + caption_lines = [] + content_lines = [] + for indent, text in st_lines: + clean = text.lstrip("• ") + if text.startswith("출처:") or clean.startswith("출처:"): + caption_lines.append(re.sub(r'^출처:\s*', '', clean)) + else: + content_lines.append((indent, text)) + + img_caption = "" + if caption_lines: + img_caption = f'
{caption_lines[0]}
' + + bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords) + bk = bold_keywords or [] + + keymsg_html = "" + if has_keymsg and core_message: + keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None) + km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37 + keymsg_html = ( + f'
{_apply_bold(core_message, bk)}
' + ) + + return ( + f'
' + f'
' + f'{_apply_bold(topic.title, bk)}
' + f'
' + f'
{img_html}
{img_caption}
' + f'
{bullets}
' + f'
{keymsg_html}
' + ) + + +def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None): + """기타 블록: 제목 + 불릿.""" + bk = bold_keywords or [] + bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk) + keymsg_html = "" + if has_keymsg and core_message: + keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None) + km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37 + keymsg_html = ( + f'
{_apply_bold(core_message, bk)}
' + ) + return ( + f'
' + f'
{_apply_bold(topic.title, bk)}
' + f'{bullets}{keymsg_html}
' + ) + + +def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: + """전체 슬라이드를 조립하여 HTML 반환. + + filled, assembled, stage_2 모두 이 함수를 호출. + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + + ratio = ctx.container_ratio + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + inner_w = slide_w - pad * 2 + body_w = int(inner_w * ratio[0] / 100) + sidebar_w = inner_w - body_w - gap_block + + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + + all_css = set() + role_htmls = {} + + for role in ["배경", "본심", "첨부", "결론"]: + html, css = assemble_role_html(role, ctx) + role_htmls[role] = html + all_css.update(css) + + # 좌표 계산 + bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px)) + core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px)) + sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px)) + concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px)) + + bg_top = pad + header_h + gap_block + core_top = bg_top + bg_h + gap_small + sb_top = bg_top + + # V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움 + if redist: + ft_top = slide_h - pad - concl_h - gap_block + column_bottom = ft_top - gap_block + core_h = column_bottom - core_top + sb_h = column_bottom - sb_top + else: + ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block + + title = title_text or ctx.analysis.title or "" + css_block = "\n".join(all_css) + + return f""" + +
+
{title}
+ +
+배경 ({body_w}x{bg_h}px) +{role_htmls.get("배경", "")}
+ +
+본심 ({body_w}x{core_h}px) +{role_htmls.get("본심", "")}
+ +
+첨부 ({sidebar_w}x{sb_h}px) +{role_htmls.get("첨부", "")}
+ + + +
""" diff --git a/src/block_reference.py b/src/block_reference.py new file mode 100644 index 0000000..24c1ae8 --- /dev/null +++ b/src/block_reference.py @@ -0,0 +1,557 @@ +"""Phase T-3: 참고 블록 선택 + 디자인 레퍼런스 HTML 생성. + +Stage 1.7에서 호출. relation_type + expression_hint → 참고 블록 결정론적 선택. +블록을 "채울 틀"이 아니라 "참고할 디자인"으로 제공. + +핵심 차이 (Phase P~R vs Phase T): + P~R: 블록 선택 → 슬롯에 텍스트 채우기 (실패 — 구조 경직) + T: 블록을 참고 자료로 제공 → AI가 구조를 자유롭게 결정 (유연 + 다양) + +설계 근거: + - expression_hint 키워드 포함 매칭 (정확한 문자열 아님 — T-3 조사) + - LLM이 참고 HTML 구조를 70-90% 복사 (T-3 조사) → "디자인 레퍼런스" 프레이밍 + - Gestalt 원칙: 폐합→벤, 근접→좌우, 연속→화살표 (T-3 조사) + - PPTAgent(EMNLP 2025): 참고 기반 생성의 효과 학술 입증 +""" +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +import yaml +from jinja2 import Environment, FileSystemLoader + +logger = logging.getLogger(__name__) + +# 템플릿 디렉토리 +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" + +# Jinja2 환경 (블록 HTML 렌더링용) +_jinja_env = None + +def _get_jinja_env() -> Environment: + global _jinja_env + if _jinja_env is None: + _jinja_env = Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + autoescape=False, + ) + return _jinja_env + + +# ══════════════════════════════════════ +# expression_hint → 블록 매핑 (키워드 포함 매칭) +# ══════════════════════════════════════ + +# 시각적 유형별 매칭 키워드 + 대응 블록 +# T-3 조사: 10개 고유 expression_hint → 5개 시각 유형 + 향후 2개 +VISUAL_TYPE_KEYWORDS: dict[str, dict[str, Any]] = { + "인과": { + "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", "A vs B"], + "blocks": ["compare-2col-split", "compare-3col-badge", "comparison-2col"], + }, + "순서": { + "keywords": ["시간 순서", "단계별", "A->B->C", "프로세스 흐름"], + "blocks": ["flow-arrow-horizontal", "process-horizontal"], + }, +} + +# 카테고리별 fallback 블록 (모든 필터 통과 실패 시) +CATEGORY_FALLBACK: dict[str, str] = { + "cards": "card-numbered", + "emphasis": "dark-bullet-list", + "visuals": "venn-diagram", + "tables": "compare-2col-split", + "media": "image-side-text", + "headers": "topic-left-right", +} + +# relation_type → 1차 필터 블록 카테고리 매핑 +RELATION_CATEGORY_MAP: dict[str, list[str]] = { + "hierarchy": ["visuals", "emphasis"], + "inclusion": ["visuals", "emphasis"], + "comparison": ["tables", "emphasis", "cards"], + "sequence": ["visuals"], + "definition": ["cards", "emphasis"], + "cause_effect": ["emphasis"], + "none": ["emphasis"], +} + + +# ══════════════════════════════════════ +# 카탈로그 로딩 (mtime 캐싱) +# ══════════════════════════════════════ + +_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0} + + +def _load_catalog() -> list[dict]: + """catalog.yaml 로드 (mtime 캐싱).""" + path = TEMPLATES_DIR / "catalog.yaml" + mtime = path.stat().st_mtime + if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime: + return _catalog_cache["data"] + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + blocks = data.get("blocks", []) + _catalog_cache["data"] = blocks + _catalog_cache["mtime"] = mtime + return blocks + + +def _get_block_by_id(block_id: str) -> dict | None: + """블록 ID로 카탈로그 엔트리 조회.""" + for b in _load_catalog(): + if b["id"] == block_id: + return b + return None + + +# ══════════════════════════════════════ +# 블록 선택 (2단계 필터) +# ══════════════════════════════════════ + +def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]: + """expression_hint에서 키워드를 찾아 시각적 유형과 후보 블록 반환. + + 키워드 포함(substring) 매칭 — 정확한 문자열 매칭이 아님. + T-3 조사: expression_hint는 긴 문장이므로 부분 매칭 필수. + """ + for vtype, spec in VISUAL_TYPE_KEYWORDS.items(): + if any(kw in expression_hint for kw in spec["keywords"]): + return vtype, spec["blocks"] + return "default", [] + + +# 배경 역할에서 제외할 다크 계열 블록 +DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"} + + +def select_reference_block( + relation_type: str, + expression_hint: str, + container_height_px: int, + zone: str = "body", + role: str = "", +) -> dict[str, Any]: + """참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback). + + Returns: + { + "block_id": str, + "variant": str, + "visual_type": str, + "catalog_entry": dict, # catalog.yaml의 해당 블록 전체 + } + """ + catalog = _load_catalog() + + # ── 1차 필터: relation_type → 카테고리 ── + allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"]) + candidates_1 = [ + b for b in catalog + if b.get("category") in allowed_categories + ] + + # ── 2차 필터: expression_hint 키워드 매칭 ── + visual_type, hint_blocks = _match_visual_type(expression_hint) + if hint_blocks: + candidates_2 = [b for b in candidates_1 if b["id"] in hint_blocks] + if not candidates_2: + candidates_2 = [b for b in catalog if b["id"] in hint_blocks] + else: + candidates_2 = candidates_1 + + # ── TP-1: 배경 역할은 다크 블록 제외 ── + if role == "배경": + candidates_2 = [b for b in candidates_2 if b["id"] not in DARK_BLOCKS] + if not candidates_2: + # 다크 제외 후 후보 없으면 라이트 fallback + candidates_2 = [b for b in candidates_1 if b["id"] not in DARK_BLOCKS] + + # ── 3차 필터: 컨테이너 크기 적합성 ── + candidates_3 = [ + b for b in candidates_2 + if b.get("min_height_px", 0) <= container_height_px + ] + + # ── sidebar 제약: visuals/media 금지 ── + if zone == "sidebar": + candidates_3 = [ + b for b in candidates_3 + if b.get("category") not in ("visuals", "media") + and b.get("zone") != "full-width-only" + ] + + # ── 최종 선택 ── + if candidates_3: + selected = candidates_3[0] + elif candidates_2: + selected = candidates_2[0] # 크기 안 맞아도 최선 + logger.warning( + f"[T-3] 컨테이너({container_height_px}px)에 맞는 블록 없음. " + f"최선 선택: {selected['id']} (min_height_px={selected.get('min_height_px')})" + ) + else: + # fallback: 카테고리별 기본 블록 + fallback_category = allowed_categories[0] if allowed_categories else "emphasis" + fallback_id = CATEGORY_FALLBACK.get(fallback_category, "dark-bullet-list") + selected = _get_block_by_id(fallback_id) or catalog[0] + visual_type = "fallback" + logger.warning(f"[T-3] 후보 없음. fallback: {selected['id']}") + + # variant 선택: compact variant가 있고, 컨테이너가 블록 min_height_px 근처면 compact + variant = "default" + variants = selected.get("variants", []) + block_min_h = selected.get("min_height_px", 0) + if variants: + for v in variants: + # compact: 컨테이너 높이가 블록 min_height의 2배 미만이면 compact 사용 + if v.get("id") == "compact" and container_height_px < block_min_h * 2: + variant = "compact" + break + + return { + "block_id": selected["id"], + "variant": variant, + "visual_type": visual_type, + "catalog_entry": selected, + } + + +# ══════════════════════════════════════ +# 디자인 레퍼런스 HTML 생성 +# ══════════════════════════════════════ + +# 블록별 샘플 데이터 (Jinja2 변수 치환용) +_SAMPLE_DATA: dict[str, dict[str, Any]] = { + # emphasis + "dark-bullet-list": { + "title": "핵심 요약", + "bullets": ["첫 번째 포인트", "두 번째 포인트", "세 번째 포인트"], + }, + "callout-warning": { + "title": "주의사항", + "description": "현재 접근 방식에 잠재적 문제가 있습니다.", + "icon": "⚠️", + }, + "callout-solution": { + "title": "해결 방향", + "description": "체계적 접근이 필요합니다.", + "icon": "💡", + }, + "banner-gradient": { + "text": "핵심 메시지 한 줄", + "sub_text": "부연 설명", + }, + "comparison-2col": { + "left_title": "항목 A", + "left_content": "A의 특징과 설명", + "right_title": "항목 B", + "right_content": "B의 특징과 설명", + }, + "quote-big-mark": { + "quote_text": "중요한 인용문 텍스트", + "source": "출처", + }, + # cards + "card-numbered": { + "items": [ + {"title": "항목 1", "description": "첫 번째 항목 설명"}, + {"title": "항목 2", "description": "두 번째 항목 설명"}, + {"title": "항목 3", "description": "세 번째 항목 설명"}, + ], + }, + "card-icon-desc": { + "cards": [ + {"icon": "🏗️", "title": "기술 A", "description": "기술 A 설명"}, + {"icon": "🌍", "title": "기술 B", "description": "기술 B 설명"}, + {"icon": "🔮", "title": "기술 C", "description": "기술 C 설명"}, + ], + }, + # visuals + "venn-diagram": { + "center_label": "DX", + "center_sub": "디지털 전환", + "items": [ + {"label": "BIM", "color": "#ff6b35"}, + {"label": "GIS", "color": "#00d4aa"}, + {"label": "DT", "color": "#ffd700"}, + ], + }, + "keyword-circle-row": { + "keywords": [ + {"letter": "B", "label": "BIM", "description": "건물정보모델링"}, + {"letter": "G", "label": "GIS", "description": "지리정보시스템"}, + {"letter": "D", "label": "DX", "description": "디지털 전환"}, + ], + }, + "flow-arrow-horizontal": { + "steps": [ + {"label": "분석"}, + {"label": "설계"}, + {"label": "시공"}, + {"label": "관리"}, + ], + }, + "process-horizontal": { + "steps": [ + {"number": "1", "title": "현황 분석", "description": "현재 상태 진단"}, + {"number": "2", "title": "전략 수립", "description": "로드맵 설계"}, + {"number": "3", "title": "실행", "description": "단계적 도입"}, + ], + }, + # tables + "compare-2col-split": { + "left_title": "기존", + "right_title": "개선", + "rows": [ + {"left": "수작업", "center": "프로세스", "right": "자동화"}, + {"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"}, + ], + }, + "compare-3col-badge": { + "headers": ["구분", "항목 A", "항목 B"], + "rows": [ + ["범위", "넓음", "좁음"], + ["목적", "혁신", "관리"], + ], + }, +} + + +def generate_design_reference( + block_id: str, + variant: str = "default", + catalog_entry: dict | None = None, +) -> str: + """블록의 디자인 레퍼런스 HTML 생성. + + Jinja2 변수를 샘플 데이터로 치환한 완성 HTML + 구조 의도 주석. + LLM이 이 구조를 70~90% 복사 → "발명"하지 않고 검증된 구조를 따름. + """ + if catalog_entry is None: + catalog_entry = _get_block_by_id(block_id) + if catalog_entry is None: + logger.warning(f"[T-3] 블록 {block_id} 카탈로그에 없음") + return "" + + # 템플릿 경로 결정 + template_path = catalog_entry.get("template", "") + if variant != "default": + for v in catalog_entry.get("variants", []): + if v.get("id") == variant and v.get("template"): + template_path = v["template"] + break + + if not template_path: + logger.warning(f"[T-3] 블록 {block_id} 템플릿 경로 없음") + return "" + + # 샘플 데이터로 Jinja2 렌더링 + sample = _SAMPLE_DATA.get(block_id, {}) + + try: + env = _get_jinja_env() + template = env.get_template(template_path) + rendered = template.render(**sample) + except Exception as e: + logger.warning(f"[T-3] 블록 {block_id} 렌더링 실패: {e}") + # 렌더링 실패 시 템플릿 원본 반환 (Jinja 변수 포함) + try: + raw = (TEMPLATES_DIR / template_path).read_text(encoding="utf-8") + rendered = raw + except Exception: + return "" + + # 구조 의도 주석 추가 + visual = catalog_entry.get("visual", "") + visual_diff = catalog_entry.get("visual_diff", "") + when = catalog_entry.get("when", "") + + header = f"\n" + if visual_diff: + header += f"\n" + header += f"\n" + + # schema 정보를 SLOT 주석으로 변환 + schema = catalog_entry.get("schema", {}) + if schema: + schema_comments = [] + for slot_name, spec in schema.items(): + if slot_name.startswith("max_"): + body_val = spec.get("body", "") + schema_comments.append(f"") + else: + ml = spec.get("max_lines", "?") + fs = spec.get("font_size", "?") + rc = spec.get("ref_chars", {}).get("body", "?") + schema_comments.append( + f"" + ) + header += "\n".join(schema_comments) + "\n" + + return header + rendered + + +def select_and_generate_references( + topics: list[dict[str, Any]], + containers: dict[str, Any], + page_structure: dict[str, Any], +) -> dict[str, dict[str, Any]]: + """역할별 참고 블록 선택 + 디자인 레퍼런스 HTML 생성. + + Stage 1.7에서 호출. 각 역할(본심/배경/첨부/결론)에 대해 + relation_type + expression_hint 기반으로 참고 블록을 선택하고 + 디자인 레퍼런스 HTML을 생성. + + Returns: + {"본심": {"block_id": ..., "design_reference_html": ..., ...}, ...} + """ + references: dict[str, list[dict[str, Any]]] = {} + topic_map = {t.get("id"): t for t in topics} + + for role, info in page_structure.items(): + if not isinstance(info, dict): + continue + topic_ids = info.get("topic_ids", []) + if not topic_ids: + continue + + # 컨테이너 정보 + container = containers.get(role) + if container is None: + continue + if hasattr(container, "height_px"): + total_height_px = container.height_px + zone = container.zone + else: + total_height_px = container.get("height_px", 0) # 이전 Stage에서 반드시 제공 + zone = container.get("zone", "body") + + # V-1 + Phase V: 같은 영역 꼭지들의 layer 관계에 따라 블록 구조 결정 + # layer가 다르면 → 주종 관계 → 블록 1개 (주 꼭지 기준, 종속은 하위 요소) + # layer가 같으면 → 동급 → 블록 N개 병렬 + topic_layers = {tid: topic_map.get(tid, {}).get("layer", "") for tid in topic_ids} + unique_layers = set(topic_layers.values()) + is_hierarchical = len(unique_layers) > 1 and len(topic_ids) > 1 + + from src.fit_verifier import _load_design_tokens + _tokens = _load_design_tokens() + gap_between = _tokens["spacing_small"] + + if is_hierarchical: + # 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택 + # 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함 + primary_tid = None + supporting_tids = [] + # layer 우선순위: core > intro > supporting > conclusion + layer_priority = {"core": 0, "intro": 1, "conclusion": 2, "supporting": 3} + sorted_tids = sorted(topic_ids, key=lambda t: layer_priority.get(topic_layers.get(t, ""), 9)) + primary_tid = sorted_tids[0] + supporting_tids = sorted_tids[1:] + + primary_topic = topic_map.get(primary_tid, {}) + relation_type = primary_topic.get("relation_type", "none") + expression_hint = primary_topic.get("expression_hint", "") + + selection = select_reference_block( + relation_type=relation_type, + expression_hint=expression_hint, + container_height_px=total_height_px, + zone=zone, + role=role, + ) + ref_html = generate_design_reference( + block_id=selection["block_id"], + variant=selection["variant"], + catalog_entry=selection["catalog_entry"], + ) + schema_info = selection["catalog_entry"].get("schema", {}) + + # 블록 1개에 모든 꼭지 정보를 담음 + role_refs = [{ + "block_id": selection["block_id"], + "variant": selection["variant"], + "visual_type": selection["visual_type"], + "schema_info": schema_info, + "design_reference_html": ref_html, + "topic_id": primary_tid, + "supporting_topic_ids": supporting_tids, + "is_hierarchical": True, + }] + logger.info( + f"[V-1] {role}: 주종 관계 → 블록 1개 ({selection['block_id']}), " + f"주={primary_tid}, 종={supporting_tids}" + ) + else: + # 동급: 꼭지별 블록 선택 + topic_count = len(topic_ids) + available_for_topics = total_height_px - gap_between * max(0, topic_count - 1) + min_block_height = min( + (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0), + default=1, + ) + per_topic_height = max(min_block_height, available_for_topics // topic_count) + + role_refs = [] + for tid in topic_ids: + topic = topic_map.get(tid, {}) + relation_type = topic.get("relation_type", "none") + expression_hint = topic.get("expression_hint", "") + + selection = select_reference_block( + relation_type=relation_type, + expression_hint=expression_hint, + container_height_px=per_topic_height, + zone=zone, + role=role, + ) + ref_html = generate_design_reference( + block_id=selection["block_id"], + variant=selection["variant"], + catalog_entry=selection["catalog_entry"], + ) + + schema_info = selection["catalog_entry"].get("schema", {}) + + role_refs.append({ + "block_id": selection["block_id"], + "variant": selection["variant"], + "visual_type": selection["visual_type"], + "schema_info": schema_info, + "design_reference_html": ref_html, + "topic_id": tid, + }) + + logger.info( + f"[V-1] {role}/꼭지{tid}: {selection['block_id']} " + f"(visual_type={selection['visual_type']}, variant={selection['variant']}, " + f"budget={per_topic_height}px)" + ) + + references[role] = role_refs + + return references diff --git a/src/content_editor.py b/src/content_editor.py index 00a3311..a865a27 100644 --- a/src/content_editor.py +++ b/src/content_editor.py @@ -304,11 +304,9 @@ async def _call_kei_editor_with_retry(prompt: str) -> str: async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", - f"{kei_url}/api/message", + f"{kei_url}/api/direct", json={ "message": full_prompt, - "session_id": "design-agent-editor", - "mode_hint": "chat", }, timeout=None, ) as response: diff --git a/src/content_verifier.py b/src/content_verifier.py index adb4813..6a29d86 100644 --- a/src/content_verifier.py +++ b/src/content_verifier.py @@ -376,14 +376,15 @@ def verify_no_forbidden_content( # Layer 3: 구조 검증 # ═══════════════════════════════════════════════════════════ +# Phase T: overflow:hidden 필수 요구 제거. +# Phase T 프롬프트가 "overflow:hidden 금지"를 지시하므로 L3에서 요구하면 모순. +# 텍스트 잘림은 L4(Selenium 실측)에서 감지. REQUIRED_PATTERNS: dict[str, list[str]] = { - "body_bg": ["overflow:hidden|overflow: hidden"], + "body_bg": [], "body_core": [ - "overflow:hidden|overflow: hidden", "key-msg", ], "sidebar": [ - "overflow:hidden|overflow: hidden", "padding-left", "text-indent", ], @@ -395,8 +396,12 @@ def verify_structure( generated_html: str, area_name: str, has_image: bool = False, + font_hierarchy: dict | None = None, ) -> VerificationResult: - """필수 CSS/HTML 패턴이 존재하는지 검증.""" + """필수 CSS/HTML 패턴이 존재하는지 검증. + + Phase T-8: font_hierarchy가 제공되면 폰트 위계 위반도 검사. + """ patterns = REQUIRED_PATTERNS.get(area_name, []) missing = [] @@ -410,13 +415,36 @@ def verify_structure( if "slide-img-" not in generated_html: missing.append("slide-img-* (이미지 태그)") + # Phase T-8: 폰트 위계 검사 + font_warnings = [] + if font_hierarchy: + role_font_map = { + "body_bg": font_hierarchy.get("bg", 11), + "body_core": font_hierarchy.get("core", 12), + "sidebar": font_hierarchy.get("sidebar", 10), + "footer": font_hierarchy.get("core", 12), + } + max_font = role_font_map.get(area_name) + if max_font: + # HTML에서 font-size 값 추출 + font_sizes = re.findall(r"font-size:\s*(\d+(?:\.\d+)?)\s*px", generated_html) + for fs_str in font_sizes: + fs = float(fs_str) + if fs > max_font + 1: # 1px 허용 오차 + font_warnings.append( + f"폰트 위계 위반: {area_name}에서 {fs}px 사용 (최대 {max_font}px)" + ) + passed = len(missing) == 0 + all_errors = [f"필수 패턴 누락: {p}" for p in missing] + return VerificationResult( passed=passed, area_name=area_name, checks={"structure": passed}, score=1.0 if passed else (1.0 - len(missing) / max(1, len(patterns))), - errors=[f"필수 패턴 누락: {p}" for p in missing], + errors=all_errors, + warnings=font_warnings, ) @@ -551,18 +579,35 @@ async def generate_with_retry( return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map] area_texts = {} + + def _get_role_text(role_topics): + """structured_text 우선, 없으면 source_hint 키워드로 sections 매칭.""" + texts = [] + for t in role_topics: + st = t.get("structured_text", "") + if st: + texts.append(st) + else: + # fallback: source_hint에서 키워드 추출하여 매칭 + hint = t.get("source_hint", "") + keywords = [w for w in hint.split() if len(w) >= 2][:3] + matched = _map_sections_for_role(sections, [t], keywords) if keywords else "" + if matched: + texts.append(matched) + return "\n\n".join(texts) if texts else "" + bg_topics = get_topics_for_role("배경") if bg_topics: - area_texts["body_bg"] = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"]) + area_texts["body_bg"] = _get_role_text(bg_topics) core_topics = get_topics_for_role("본심") if core_topics: - area_texts["body_core"] = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"]) + area_texts["body_core"] = _get_role_text(core_topics) ref_topics = get_topics_for_role("첨부") if ref_topics: - area_texts["sidebar"] = _get_definitions(content) + area_texts["sidebar"] = _get_role_text(ref_topics) conclusion_topics = get_topics_for_role("결론") if conclusion_topics: - area_texts["footer"] = _get_conclusion(content) + area_texts["footer"] = _get_role_text(conclusion_topics) has_image_areas = set() if images: diff --git a/src/design_director.py b/src/design_director.py index a73703c..110edbf 100644 --- a/src/design_director.py +++ b/src/design_director.py @@ -509,11 +509,9 @@ async def _opus_batch_recommend( async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", - f"{kei_url}/api/message", + f"{kei_url}/api/direct", json={ "message": prompt, - "session_id": "design-agent-p-recommend", - "mode_hint": "chat", }, timeout=None, ) as response: @@ -615,11 +613,9 @@ async def _opus_block_recommendation( async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", - f"{kei_url}/api/message", + f"{kei_url}/api/direct", json={ "message": prompt, - "session_id": "design-agent-opus", - "mode_hint": "chat", }, timeout=None, ) as response: diff --git a/src/fit_verifier.py b/src/fit_verifier.py new file mode 100644 index 0000000..6cdb8c6 --- /dev/null +++ b/src/fit_verifier.py @@ -0,0 +1,1040 @@ +"""Phase V — Stage 1.8: 콘텐츠-컨테이너 적합성 검증. + +꼭지별 블록 선택 후, 각 컨테이너에 콘텐츠가 실제로 들어가는지 검증. +안 들어가면 재배분 시도 → 그래도 안 되면 Kei 에스컬레이션. + +모든 수치는 동적 계산. font-size / font metric / line-height 외 하드코딩 없음. +레이아웃 수치는 tokens.css(디자인 토큰) 또는 catalog.yaml에서 읽어옴. +""" +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────── +# 디자인 토큰 로딩 (tokens.css에서) +# ────────────────────────────────────── + +_tokens_cache: dict[str, int] | None = None + + +def _load_design_tokens() -> dict[str, int]: + """tokens.css에서 spacing 변수를 읽어옴.""" + global _tokens_cache + if _tokens_cache is not None: + return _tokens_cache + + tokens_path = Path(__file__).parent.parent / "static" / "tokens.css" + if not tokens_path.exists(): + raise FileNotFoundError(f"디자인 토큰 파일 없음: {tokens_path}") + + css = tokens_path.read_text(encoding="utf-8") + tokens: dict[str, int] = {} + for match in re.finditer(r"--spacing-(\w+):\s*(\d+)px", css): + key = f"spacing_{match.group(1)}" + tokens[key] = int(match.group(2)) + + # border-width도 읽기 + for match in re.finditer(r"--border-width:\s*(\d+)px", css): + tokens["border_width"] = int(match.group(1)) + if "border_width" not in tokens: + # tokens.css에 --border-width가 있는지 확인 + for match in re.finditer(r"--accent-border:\s*(\d+)px", css): + tokens["accent_border"] = int(match.group(1)) + + _tokens_cache = tokens + return tokens + + +# ────────────────────────────────────── +# 텍스트 높이 추정 (space_allocator 실측 기반) +# ────────────────────────────────────── + +CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측 (font metric — font-size 계열) + + +def estimate_text_height( + text_chars: int, + font_size: float, + available_width: float, + line_height_ratio: float = 1.5, +) -> float: + """텍스트를 주어진 폭에 넣으면 몇 px 높이가 필요한가.""" + if text_chars <= 0: + return 0 + char_width = font_size * CHAR_WIDTH_RATIO + # 최소 폭: spacing_page (슬라이드 패딩) 이상이어야 유효 + tokens = _load_design_tokens() + min_width = tokens.get("spacing_page", 1) + inner_width = max(min_width, available_width) + chars_per_line = max(1, int(inner_width / char_width)) + total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division + return total_lines * (font_size * line_height_ratio) + + +# ────────────────────────────────────── +# 블록 오버헤드: 블록 구조별 정확 계산 +# ────────────────────────────────────── + +def estimate_block_overhead(block_id: str, catalog_entry: dict, item_count: int = 1) -> float: + """블록의 텍스트 외 오버헤드(padding, 아이콘, 번호, 테두리 등). + + catalog.yaml의 padding_overhead_px 필드에서 읽음 — 하드코딩 아님. + 카드형 블록은 아이템 수에 비례하여 오버헤드 증가. + """ + # catalog.yaml에서 padding_overhead_px 읽기 + base = catalog_entry.get("padding_overhead_px", 0) # catalog에 없으면 0 (오버헤드 없음으로 처리) + + # 카드형 블록: per-item 오버헤드 (catalog의 값은 1아이템 기준) + category = catalog_entry.get("category", "") + if category == "cards" and item_count > 1: + tokens = _load_design_tokens() + card_gap = tokens["spacing_small"] # 카드 간 간격 = --spacing-small + return base * item_count + card_gap * (item_count - 1) + + return base + + +def estimate_image_height( + topic_source_data: str, + available_width: float, + image_sizes: dict[str, dict] | None = None, +) -> float: + """이 꼭지에 이미지가 있으면 높이를 추정. + + source_data에 '[이미지:' 참조가 있는 꼭지만 이미지를 가짐. + image_sizes가 있으면 실제 이미지 크기에서 계산, 없으면 SVG 기준 추정. + """ + if "[이미지:" not in topic_source_data: + return 0 + + # 실제 이미지 크기가 있으면 사용 + if image_sizes: + for img_name, size_info in image_sizes.items(): + if isinstance(size_info, dict) and "width" in size_info and "height" in size_info: + orig_w = size_info["width"] + orig_h = size_info["height"] + # 이미지 디스플레이 폭: 원본과 가용폭 중 작은 값 + img_display_width = min(available_width, orig_w) + img_height = orig_h * (img_display_width / max(1, orig_w)) + return img_height + + # 이미지 크기 없으면 SVG 다이어그램으로 추정 + # catalog.yaml의 해당 블록 min_height_px를 SVG 높이로 사용 + # (venn-diagram min_height=300 등 — catalog에서 동적으로 가져옴) + from src.block_reference import _load_catalog + for b in _load_catalog(): + if b.get("category") == "visuals" and b.get("min_height_px", 0) > 0: + # 시각화 블록의 min_height를 SVG 추정 높이로 사용 + # 실제 배치 시 available_width에 맞춰 조정됨 + return min(b["min_height_px"], available_width) + # catalog에도 없으면 가용 폭 기준 정사각형 + return available_width + return img_height + + +def estimate_keymsg_height(core_message: str, font_size: float) -> float: + """key-msg 배너 높이. tokens.css의 spacing에서 padding 읽음.""" + if not core_message: + return 0 + tokens = _load_design_tokens() + # key-msg padding = --spacing-small (상하) + border (--border-width × 2) + padding_v = tokens["spacing_small"] * 2 # 상 + 하 + border_w = tokens.get("border_width", tokens.get("accent_border", 1)) + border_v = border_w * 2 # 상 + 하 + text_h = font_size * 1.4 # line-height (typography constant) + return text_h + padding_v + border_v + + +def count_items_in_topic(topic: dict, role: str, block_schema: dict | None = None) -> int: + """꼭지의 아이템 수 추정 — 블록 schema 기반. + + 블록 schema에 max_items/max_cards/max_steps 등 리스트형 슬롯이 있으면 + source_data에서 구분 가능한 항목 수를 파싱. + 리스트형 슬롯이 없으면 1 반환. + + 하드코딩 없음: 블록 schema + source_data 패턴으로만 판단. + """ + source_data = topic.get("source_data", "") + + # 블록 schema에서 리스트형 슬롯 확인 + if block_schema: + max_keys = [k for k in block_schema if k.startswith("max_")] + if max_keys: + # 리스트형 블록 — source_data에서 항목 수 파싱 + return _count_items_from_source(source_data) + + return 1 + + +def _count_items_from_source(source_data: str) -> int: + """source_data에서 구분 가능한 항목 수를 파싱. + + 패턴 우선순위: + 1. "이름(설명), 이름(설명)" → 괄호+쉼표로 구분 + 2. "- 항목\n- 항목" → 줄바꿈+불릿으로 구분 + 3. "항목, 항목, 항목" → 쉼표로 구분 + """ + import re + + # 패턴 1: "이름(설명), 이름(설명)" + paren_items = re.findall(r'[^,()]+\([^)]+\)', source_data) + if len(paren_items) >= 2: + return len(paren_items) + + # 패턴 2: 줄바꿈 + 불릿 ("- ", "• ", "* ") + bullet_lines = [l.strip() for l in source_data.split("\n") + if l.strip() and l.strip()[0] in "-•*"] + if len(bullet_lines) >= 2: + return len(bullet_lines) + + # 패턴 3: 쉼표 구분 (단, 괄호 안의 쉼표는 제외) + # 괄호 안 내용 제거 후 쉼표로 분리 + cleaned = re.sub(r'\([^)]*\)', '', source_data) + comma_items = [s.strip() for s in cleaned.split(",") if s.strip()] + if len(comma_items) >= 2: + return len(comma_items) + + return 1 + + +def get_actual_text_chars( + topic: dict, + normalized: dict, + role: str, +) -> int: + """꼭지에 해당하는 실제 텍스트 분량. + + structured_text(Kei가 원본 85% 보존 구조화)를 우선 사용. + 없으면 source_data 길이로 fallback. + 하드코딩 키워드 매칭 없음. + """ + source_data = topic.get("source_data", "") + structured_text = topic.get("structured_text", "") + + # structured_text가 있으면 그 길이가 실제 텍스트 분량 + if structured_text: + # [팝업:], [이미지:] 마커는 실제 텍스트가 아니므로 제외 + import re + clean = re.sub(r'\[팝업:\s*[^\]]+\]', '', structured_text) + clean = re.sub(r'\[이미지:\s*[^\]]+\]', '', clean) + estimated = len(clean.strip()) + else: + # fallback: source_data 길이 + estimated = len(source_data) + + # 팝업 링크 추가 (본문에는 "상세보기 →" 링크 1줄) + popup_link_chars = 0 + if "[팝업:" in source_data or "[팝업:" in structured_text: + popups = normalized.get("popups", []) + text_to_search = structured_text or source_data + for p in popups: + if p.get("title", "") in text_to_search: + popup_link_chars += len(p.get("title", "")) + len("상세보기 →") + 2 + + estimated += popup_link_chars + + return estimated + + +# ────────────────────────────────────── +# 데이터 클래스 +# ────────────────────────────────────── + +@dataclass +class TopicFit: + """한 꼭지의 적합성 분석.""" + topic_id: int + role: str + block_id: str + text_chars: int + text_height_px: float + image_height_px: float # SVG/이미지 높이 + block_overhead_px: float + required_height_px: float # max(text+overhead, image+overhead) 또는 합산 + font_size: float + item_count: int = 1 # 카드형 아이템 수 + has_image: bool = False + has_keymsg: bool = False + keymsg_height_px: float = 0 + + +@dataclass +class RoleFit: + """한 역할(영역)의 적합성 분석.""" + role: str + topic_fits: list[TopicFit] = field(default_factory=list) + total_required_px: float = 0 + allocated_px: float = 0 + gap_between_px: float = 0 # 실행 시 tokens에서 설정 + shortfall_px: float = 0 + fit_status: str = "OK" # OK / TIGHT / OVERFLOW + + +@dataclass +class FitAnalysis: + """전체 적합성 분석.""" + roles: dict[str, RoleFit] = field(default_factory=dict) + can_redistribute: bool = False + redistribution: dict[str, float] | None = None + needs_escalation: bool = False + + +# ────────────────────────────────────── +# V-2: 적합성 검증 메인 +# ────────────────────────────────────── + +def calculate_fit( + topics: list[dict[str, Any]], + page_structure: dict[str, Any], + containers: dict[str, Any], + references: dict[str, list[dict[str, Any]]], + font_hierarchy: dict[str, float], + normalized: dict[str, Any] | None = None, + core_message: str = "", +) -> FitAnalysis: + """각 컨테이너에 콘텐츠가 들어가는지 정확하게 검증.""" + topic_map = {t.get("id"): t for t in topics} + if normalized is None: + normalized = {} + + role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"} + role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3} + + analysis = FitAnalysis() + + for role, info in page_structure.items(): + if not isinstance(info, dict): + continue + topic_ids = info.get("topic_ids", []) + if not topic_ids: + continue + + container = containers.get(role) + if container is None: + continue + + if hasattr(container, "height_px"): + allocated_h = container.height_px + width_px = container.width_px + else: + allocated_h = container.get("height_px", 0) + width_px = container.get("width_px", 0) + + font_key = role_font_map.get(role, "core") + font_size = font_hierarchy.get(font_key, 12) + line_h = role_line_height.get(role, 1.5) + + # V-1 출력: 꼭지별 블록 리스트 + ref_list = references.get(role, []) + ref_map = {} + + # 블록 수평 padding — catalog.yaml의 padding_h_px에서 가져옴 + max_h_padding = 0 + for r in ref_list: + if isinstance(r, dict): + ce = r.get("catalog_entry", {}) + max_h_padding = max(max_h_padding, ce.get("padding_h_px", 0)) + tokens_cw = _load_design_tokens() + content_width = max(tokens_cw["spacing_page"], width_px - max_h_padding) + for r in ref_list: + if isinstance(r, dict): + ref_map[r.get("topic_id")] = r + + topic_count = len(topic_ids) + tokens = _load_design_tokens() + block_gap = tokens["spacing_small"] # --spacing-small: 블록 간 간격 + + role_fit = RoleFit(role=role, allocated_px=allocated_h, gap_between_px=block_gap) + total_required = 0 + + for i, tid in enumerate(topic_ids): + topic = topic_map.get(tid, {}) + source_data = topic.get("source_data", "") + + ref = ref_map.get(tid, {}) + block_id = ref.get("block_id", "unknown") if isinstance(ref, dict) else "unknown" + catalog_entry = ref.get("catalog_entry", {}) if isinstance(ref, dict) else {} + + # ── 1. 실제 텍스트 분량 ── + text_chars = get_actual_text_chars(topic, normalized, role) + + # ── 2. 아이템 수 (블록 schema의 max_* 키 기반) ── + block_schema = catalog_entry.get("schema", {}) if isinstance(catalog_entry, dict) else {} + item_count = count_items_in_topic(topic, role, block_schema=block_schema) + + # ── 3. 이미지 높이 (이 꼭지의 source_data에 [이미지:] 참조가 있을 때만) ── + image_sizes = {} # analysis.image_sizes가 있으면 전달 + img_h = estimate_image_height(source_data, content_width, image_sizes) + has_image = img_h > 0 + + # ── 4. key-msg (본심에만) ── + has_keymsg = (role == "본심" and core_message) + keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0 + + # ── 5. 블록 오버헤드 ── + overhead = estimate_block_overhead(block_id, catalog_entry, item_count) + + # ── 6. 텍스트 높이 ── + if has_image: + # 이미지가 있으면 텍스트는 이미지 옆에 배치 (flex row) + # 이미지 최소 폭: catalog의 min_display_width_px (SVG 가독성 기반) + tokens = _load_design_tokens() + img_text_gap = tokens["spacing_inner"] + img_min_width = tokens["spacing_page"] # 기본 최소 + for r in ref_list: + if isinstance(r, dict): + ce = r.get("catalog_entry", {}) + w = ce.get("min_display_width_px", 0) + if w > img_min_width: + img_min_width = w + img_display_width = img_min_width + text_width = content_width - img_display_width - img_text_gap + text_width = max(tokens["spacing_page"], text_width) + text_h = estimate_text_height(text_chars, font_size, text_width, line_h) + # 본심 높이 = max(이미지, 텍스트) + key-msg + overhead + content_h = max(img_h, text_h) + required = content_h + keymsg_h + overhead + else: + text_h = estimate_text_height(text_chars, font_size, content_width, line_h) + required = text_h + keymsg_h + overhead + + # 제목 높이 (첫 번째 꼭지만 — 영역 제목) + if i == 0: + title_extra = font_size * 1.5 + tokens["spacing_small"] + required += title_extra + + topic_fit = TopicFit( + topic_id=tid, + role=role, + block_id=block_id, + text_chars=text_chars, + text_height_px=round(text_h, 1), + image_height_px=round(img_h, 1), + block_overhead_px=round(overhead, 1), + required_height_px=round(required, 1), + font_size=font_size, + item_count=item_count, + has_image=has_image, + has_keymsg=has_keymsg, + keymsg_height_px=round(keymsg_h, 1), + ) + role_fit.topic_fits.append(topic_fit) + total_required += required + + if i < topic_count - 1: + total_required += block_gap + + role_fit.total_required_px = round(total_required, 1) + role_fit.shortfall_px = round(total_required - allocated_h, 1) + + tokens = _load_design_tokens() + tight_threshold = tokens["spacing_block"] # --spacing-block: TIGHT 판정 기준 + if role_fit.shortfall_px <= 0: + role_fit.fit_status = "OK" + elif role_fit.shortfall_px <= tight_threshold: + role_fit.fit_status = "TIGHT" + else: + role_fit.fit_status = "OVERFLOW" + + analysis.roles[role] = role_fit + + logger.info( + f"[V-2] {role}: 필요={role_fit.total_required_px}px, " + f"배정={allocated_h}px, 차이={role_fit.shortfall_px}px → {role_fit.fit_status}" + ) + + return analysis + + +# ────────────────────────────────────── +# V-3: 재배분 +# ────────────────────────────────────── + +def build_escalation_report(analysis: FitAnalysis) -> str: + """Kei에게 보낼 에스컬레이션 보고서 생성.""" + lines = [] + for role, rf in analysis.roles.items(): + icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}[rf.fit_status] + lines.append(f"{icon} {role}: 필요 {rf.total_required_px}px / 배정 {rf.allocated_px}px → {rf.fit_status} (차이 {rf.shortfall_px:+.0f}px)") + for tf in rf.topic_fits: + parts = [f"텍스트 {tf.text_chars}자→{tf.text_height_px}px"] + if tf.has_image: + parts.append(f"이미지 {tf.image_height_px}px") + if tf.has_keymsg: + parts.append(f"key-msg {tf.keymsg_height_px}px") + parts.append(f"overhead {tf.block_overhead_px}px") + lines.append(f" 꼭지{tf.topic_id} ({tf.block_id}): {', '.join(parts)} → {tf.required_height_px}px") + + if analysis.redistribution: + lines.append("") + lines.append("재배분 시도 결과:") + for role, new_h in analysis.redistribution.items(): + rf = analysis.roles.get(role) + if rf: + old_h = rf.allocated_px + gap = new_h - rf.total_required_px + lines.append(f" {role}: {old_h}→{new_h:.0f}px / 필요 {rf.total_required_px}px → {'해결' if gap >= 0 else f'부족 {abs(gap):.0f}px'}") + + return "\n".join(lines) + + +ROLE_ZONE_MAP = { + "본심": "body", + "배경": "body", + "첨부": "sidebar", + "결론": "footer", +} + + +def redistribute( + analysis: FitAnalysis, + containers: dict[str, Any], + min_margin_px: float | None = None, +) -> FitAnalysis: + """부족 영역에 여유 영역의 공간을 재배분. + + 같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심). + """ + zone_roles: dict[str, list[str]] = {} + for role in analysis.roles: + zone = ROLE_ZONE_MAP.get(role, "body") + if zone not in zone_roles: + zone_roles[zone] = [] + zone_roles[zone].append(role) + + # min_margin을 tokens에서 가져옴 + if min_margin_px is None: + tokens = _load_design_tokens() + min_margin_px = tokens["spacing_small"] # --spacing-small + + redistribution: dict[str, float] = {} + all_resolved = True + + for zone, roles_in_zone in zone_roles.items(): + if len(roles_in_zone) < 2: + for role in roles_in_zone: + rf = analysis.roles[role] + redistribution[role] = rf.allocated_px + if rf.shortfall_px > 0: + all_resolved = False + continue + + deficit_roles = [] + surplus_roles = [] + + for role in roles_in_zone: + rf = analysis.roles[role] + if rf.shortfall_px > 0: + deficit_roles.append((role, rf.shortfall_px)) + elif rf.shortfall_px < -min_margin_px: + available = abs(rf.shortfall_px) - min_margin_px + if available > 0: + surplus_roles.append((role, available)) + + total_deficit = sum(d for _, d in deficit_roles) + total_surplus = sum(s for _, s in surplus_roles) + + if total_deficit <= 0: + for role in roles_in_zone: + redistribution[role] = analysis.roles[role].allocated_px + continue + + if total_surplus <= 0: + for role in roles_in_zone: + redistribution[role] = analysis.roles[role].allocated_px + all_resolved = False + continue + + transfer = min(total_deficit, total_surplus) + + for role, deficit in deficit_roles: + rf = analysis.roles[role] + share = (deficit / total_deficit) * transfer + new_height = rf.allocated_px + share + redistribution[role] = round(new_height, 1) + logger.info(f"[V-3] {role}: {rf.allocated_px}px → {new_height:.0f}px (+{share:.0f}px)") + + for role, surplus in surplus_roles: + rf = analysis.roles[role] + share = (surplus / total_surplus) * transfer + new_height = rf.allocated_px - share + redistribution[role] = round(new_height, 1) + logger.info(f"[V-3] {role}: {rf.allocated_px}px → {new_height:.0f}px (-{share:.0f}px)") + + if total_surplus < total_deficit: + all_resolved = False + + for role, rf in analysis.roles.items(): + if role not in redistribution: + redistribution[role] = rf.allocated_px + + analysis.redistribution = redistribution + analysis.can_redistribute = all_resolved + analysis.needs_escalation = not all_resolved + + return analysis + + +# ────────────────────────────────────── +# V-7~V-10: 콘텐츠 품질 강화 분석 +# ────────────────────────────────────── + +@dataclass +class Enhancement: + """하나의 개선 제안.""" + role: str + type: str # "subordinate" | "fill_space" | "emphasis" | "bold_keywords" + description: str # Kei에게 보여줄 설명 + detail: dict = field(default_factory=dict) # 구체적 데이터 + + +@dataclass +class SupplementBlock: + """여유 공간에 추가할 보충 블록.""" + role: str + block_id: str + variant: str + content_source: str # "popup:DX와 BIM의 구분" 등 + estimated_height_px: float + available_px: float + + +@dataclass +class EnhancementAnalysis: + """V-7~V-10 전체 개선 제안 + Kei 확인 후 보충 블록.""" + enhancements: list[Enhancement] = field(default_factory=list) + supplement_blocks: list[SupplementBlock] = field(default_factory=list) + emphasis_blocks: list[dict] = field(default_factory=list) # 강조 블록 정보 + bold_keywords: dict[str, list[str]] = field(default_factory=dict) # role → keywords + + +def analyze_enhancements( + topics: list[dict[str, Any]], + page_structure: dict[str, Any], + references: dict[str, list[dict[str, Any]]], + analysis: FitAnalysis, + normalized: dict[str, Any], + core_message: str = "", +) -> EnhancementAnalysis: + """재배분 후 콘텐츠 품질 강화 제안을 생성. + + AI가 분석, Kei가 확인하는 구조. 하드코딩 없음. + 모든 판단은 이전 Stage 데이터(topic purpose/layer, fit 결과, popup 내용)에서 동적 도출. + """ + topic_map = {t.get("id"): t for t in topics} + tokens = _load_design_tokens() + result = EnhancementAnalysis() + + for role, ref_list in references.items(): + rf = analysis.roles.get(role) + if not rf: + continue + + # ── V-7: 종속 꼭지 처리 제안 ── + for ref in ref_list: + supporting_tids = ref.get("supporting_topic_ids", []) + if not supporting_tids: + continue + + for s_tid in supporting_tids: + s_topic = topic_map.get(s_tid, {}) + s_source = s_topic.get("source_data", "") + s_purpose = s_topic.get("purpose", "") + + # 종속 꼭지의 분량으로 처리 방식 결정 + # 팝업 참조가 있고 source_data가 짧으면 → 인라인 + has_popup_ref = "[팝업:" in s_source + source_len = len(s_source) + + # 팝업 참조 시 실제 팝업 내용 길이 확인 + popup_content_len = 0 + popup_title = "" + if has_popup_ref: + for p in normalized.get("popups", []): + if p.get("title", "") in s_source: + popup_content_len = len(p.get("content", "")) + popup_title = p.get("title", "") + break + + # 판단: 분량 기준은 spacing 값에서 유도 + # 인라인 = 1~2줄 분량 → chars_per_line * 2 이내 + # chars_per_line은 font_size와 width에서 계산 + font_size = rf.topic_fits[0].font_size if rf.topic_fits else 12 # font-size (허용) + container_width = rf.allocated_px # 이건 height인데... width가 필요 + # 간략 판단: source_data 자체가 짧으면 인라인 + if has_popup_ref and source_len < font_size * CHAR_WIDTH_RATIO * 5: # ~5줄 미만 + treatment = "inline" + desc = f"종속 꼭지{s_tid}({s_purpose}): 팝업 \"{popup_title}\" 참조 ({popup_content_len}자). 본문에는 인라인 1줄 + 링크" + elif source_len > font_size * CHAR_WIDTH_RATIO * 10: # ~10줄 이상 + treatment = "sub_block" + desc = f"종속 꼭지{s_tid}({s_purpose}): 콘텐츠 {source_len}자. 하위 블록으로 분리 권장" + else: + treatment = "inline" + desc = f"종속 꼭지{s_tid}({s_purpose}): 인라인 처리 ({source_len}자)" + + result.enhancements.append(Enhancement( + role=role, + type="subordinate", + description=desc, + detail={ + "supporting_topic_id": s_tid, + "treatment": treatment, + "source_len": source_len, + "has_popup": has_popup_ref, + "popup_title": popup_title, + "popup_content_len": popup_content_len, + }, + )) + + # ── V-8: 여유 공간 콘텐츠 보충 ── + new_h = analysis.redistribution.get(role, rf.allocated_px) if analysis.redistribution else rf.allocated_px + surplus = new_h - rf.total_required_px + # 여유 기준: spacing_block 이상이면 의미 있는 여유 + if surplus > tokens["spacing_block"]: + # 이 영역의 꼭지에 관련 팝업이 있는지 + info = page_structure.get(role, {}) + topic_ids = info.get("topic_ids", []) if isinstance(info, dict) else [] + + for tid in topic_ids: + topic = topic_map.get(tid, {}) + source_data = topic.get("source_data", "") + structured_text = topic.get("structured_text", "") + search_text = structured_text + " " + source_data + + if "[팝업:" not in search_text: + continue + + for p in normalized.get("popups", []): + p_title = p.get("title", "") + p_content = p.get("content", "") + + if p_title not in search_text: + continue + + # 팝업에 구조화 콘텐츠가 있는지 (표 = |, 목록 = *) + has_table = "|" in p_content and p_content.count("|") > 3 + has_list = p_content.count("*") > 2 + + if has_table or has_list: + content_type = "표" if has_table else "목록" + result.enhancements.append(Enhancement( + role=role, + type="fill_space", + description=f"{role} 여유 {surplus:.0f}px. 팝업 \"{p_title}\"에 {content_type}({len(p_content)}자) 있음. 핵심 요약을 넣을까요?", + detail={ + "surplus_px": surplus, + "popup_title": p_title, + "popup_content_len": len(p_content), + "content_type": content_type, + "has_table": has_table, + }, + )) + + # ── V-9: 영역 핵심 결론 강조 블록 ── + info = page_structure.get(role, {}) + topic_ids = info.get("topic_ids", []) if isinstance(info, dict) else [] + + for tid in topic_ids: + topic = topic_map.get(tid, {}) + purpose = topic.get("purpose", "") + source_data = topic.get("source_data", "") + + # 결론적 패턴 감지: purpose가 문제제기이고 텍스트에 "필요", "해야" 등 + conclusion_patterns = ["필요", "해야", "되어야", "요구됨", "시급"] + structured_text = topic.get("structured_text", "") + # structured_text 우선, 없으면 source_data + sections에서 검색 + all_text = structured_text if structured_text else source_data + if not structured_text: + for s in normalized.get("sections", []): + s_content = s.get("content", "") if isinstance(s, dict) else "" + if any(kw in s_content for kw in source_data.split()[:3]): + all_text += " " + s_content + break + has_conclusion = any(pat in all_text for pat in conclusion_patterns) + + if has_conclusion and purpose in ("문제제기", "핵심전달"): + # 결론 문장 추출: 전체 텍스트에서 패턴 포함 문장 + sentences = [s.strip() for s in all_text.replace("\n", ". ").replace(".", ". ").split(". ") if s.strip()] + conclusion_sentence = "" + for sent in reversed(sentences): + if any(pat in sent for pat in conclusion_patterns): + conclusion_sentence = sent + break + + if conclusion_sentence: + result.enhancements.append(Enhancement( + role=role, + type="emphasis", + description=f"{role} 꼭지{tid}({purpose}): \"{conclusion_sentence[:50]}...\" → 강조 블록으로 처리할까요?", + detail={ + "topic_id": tid, + "conclusion_sentence": conclusion_sentence, + "purpose": purpose, + }, + )) + + # ── V-10: bold 키워드 — Kei가 문맥 기반으로 판단 (pipeline.py에서 호출) ── + # 기계적 키워드 추출 제거. Kei 판단 결과가 pipeline.py에서 주입됨. + + return result + + +def build_enhancement_report(enhancements: EnhancementAnalysis) -> str: + """Kei에게 보여줄 개선 제안 보고서.""" + lines = ["=== 콘텐츠 품질 강화 제안 ===", ""] + + by_type = {} + for e in enhancements.enhancements: + if e.type not in by_type: + by_type[e.type] = [] + by_type[e.type].append(e) + + type_labels = { + "subordinate": "V-7 종속 꼭지 처리", + "fill_space": "V-8 여유 공간 보충", + "emphasis": "V-9 강조 블록", + "bold_keywords": "V-10 bold 키워드", + } + + for etype, label in type_labels.items(): + items = by_type.get(etype, []) + if not items: + continue + lines.append(f"── {label} ({len(items)}건) ──") + for e in items: + lines.append(f" [{e.role}] {e.description}") + lines.append("") + + return "\n".join(lines) + + +def apply_enhancements( + enhancements: EnhancementAnalysis, + analysis: FitAnalysis, +) -> EnhancementAnalysis: + """Step 6: Kei 확인 후 보충 블록 선택 + fit 재검증. + + Kei가 승인한 제안에 대해: + - fill_space → catalog에서 여유 공간에 맞는 블록 선택 + - emphasis → 강조 문장 확정 + - bold_keywords → 키워드 목록 확정 + + 하드코딩 없음. 블록 선택은 catalog의 min_height_px로 판단. + """ + from src.block_reference import _load_catalog + + catalog = _load_catalog() + + for e in enhancements.enhancements: + if e.type == "fill_space": + surplus_px = e.detail.get("surplus_px", 0) + has_table = e.detail.get("has_table", False) + popup_title = e.detail.get("popup_title", "") + + # 여유 공간에 맞는 블록: catalog에서 min_height_px <= surplus_px + target_categories = ["tables"] if has_table else ["cards", "emphasis"] + candidates = [ + b for b in catalog + if b.get("category") in target_categories + and b.get("min_height_px", 0) <= surplus_px + ] + + if candidates: + # 여유에 가장 가까운(크지만 넘지 않는) 블록 + candidates.sort(key=lambda b: b.get("min_height_px", 0), reverse=True) + selected = candidates[0] + + enhancements.supplement_blocks.append(SupplementBlock( + role=e.role, + block_id=selected["id"], + variant="default", + content_source=f"popup:{popup_title}", + estimated_height_px=selected.get("min_height_px", 0), + available_px=surplus_px, + )) + + logger.info( + f"[V-8] {e.role}: 보충 블록 {selected['id']} " + f"(min_h={selected.get('min_height_px')}px, 여유={surplus_px}px)" + ) + + elif e.type == "emphasis": + conclusion = e.detail.get("conclusion_sentence", "") + if conclusion: + enhancements.emphasis_blocks.append({ + "role": e.role, + "topic_id": e.detail.get("topic_id"), + "sentence": conclusion, + }) + + elif e.type == "bold_keywords": + role = e.role + keywords = e.detail.get("keywords", []) + if keywords: + if role not in enhancements.bold_keywords: + enhancements.bold_keywords[role] = [] + enhancements.bold_keywords[role].extend(keywords) + + # fit 재검증: 보충 블록이 실제로 들어가는지 + valid_supplements = [] + for sb in enhancements.supplement_blocks: + rf = analysis.roles.get(sb.role) + if not rf: + continue + new_h = analysis.redistribution.get(sb.role, rf.allocated_px) if analysis.redistribution else rf.allocated_px + remaining = new_h - rf.total_required_px + if sb.estimated_height_px <= remaining: + valid_supplements.append(sb) + logger.info(f"[V-8] {sb.role}: 보충 {sb.block_id} 확정 ({sb.estimated_height_px}px <= 여유 {remaining}px)") + else: + logger.warning(f"[V-8] {sb.role}: 보충 {sb.block_id} 제외 ({sb.estimated_height_px}px > 여유 {remaining}px)") + + enhancements.supplement_blocks = valid_supplements + return enhancements + + +# ────────────────────────────────────── +# Step 7: 세부 컨테이너 배치 계산 +# ────────────────────────────────────── + +@dataclass +class SubContainer: + """세부 컨테이너 정보.""" + name: str # "svg", "text", "table", "keymsg", "emphasis" 등 + width_px: float + height_px: float + align: str = "stretch" # "stretch" | "center" + + +@dataclass +class ContainerLayout: + """하나의 메인 컨테이너 안의 세부 배치.""" + role: str + main_height_px: float + main_width_px: float + sub_containers: list[SubContainer] = field(default_factory=list) + table_rows: int = 0 # 보충 표 행 수 + + +def calculate_sub_layout( + role: str, + main_height_px: float, + main_width_px: float, + topic_fits: list[TopicFit], + enhancements: EnhancementAnalysis, + font_hierarchy: dict[str, float], +) -> ContainerLayout: + """메인 컨테이너 안에서 세부 컨테이너 배치를 계산. + + 이미지/텍스트/표/key-msg 등의 크기를 동적으로 결정. + """ + tokens = _load_design_tokens() + layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px) + + # 제목 높이: font_size * line_height + margin + role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"} + font_key = role_font_map.get(role, "core") + title_font = font_hierarchy.get(font_key, 12) + title_h = title_font * 1.5 + tokens["spacing_small"] + + # key-msg (본심에만) + keymsg_h = 0 + for tf in topic_fits: + if tf.has_keymsg: + keymsg_h = tf.keymsg_height_px + break + + # 강조 블록 (배경 등) + emphasis_h = 0 + for eb in enhancements.emphasis_blocks: + if eb.get("role") == role: + emphasis_h = title_font * 1.4 + tokens["spacing_small"] * 2 + break + + # 이미지 있는지 + has_image = any(tf.has_image for tf in topic_fits) + + # 블록 padding overhead (선택된 블록의 padding/border 등) + block_overhead = max((tf.block_overhead_px for tf in topic_fits), default=0) + + # 사용 가능 높이: 메인 - 제목 - key-msg - 강조 - 블록 padding - 간격 + gap = tokens["spacing_small"] + used_h = title_h + keymsg_h + emphasis_h + block_overhead + used_h += gap * (2 if keymsg_h > 0 else 1) # 제목-콘텐츠 gap + 콘텐츠-keymsg gap + content_h = max(0, main_height_px - used_h) + + if has_image: + # SVG(좌) + 텍스트+표(우) 구조 + # 이미지 최소 폭: catalog의 min_display_width_px + from src.block_reference import _load_catalog + img_min_w = tokens["spacing_page"] + for b in _load_catalog(): + if b.get("category") == "visuals" and b.get("min_display_width_px", 0) > img_min_w: + img_min_w = b["min_display_width_px"] + break # 첫 번째 visual 블록 기준 + img_width = img_min_w + text_width = main_width_px - img_width - gap + + # 텍스트 높이 (topic_fits에서) + text_h = sum(tf.text_height_px for tf in topic_fits if not tf.has_keymsg) + + # 보충 표 사용 가능 높이 + table_available = content_h - text_h - gap # 텍스트 아래 여유 + table_rows = 0 + + for sb in enhancements.supplement_blocks: + if sb.role == role: + # 표 1행 높이: catalog의 padding_overhead_px / 3 (헤더+3행 기준) + from src.block_reference import _load_catalog + block = next((b for b in _load_catalog() if b["id"] == sb.block_id), None) + if block: + overhead = block.get("padding_overhead_px", 0) + # 헤더 높이 ≈ overhead의 절반 + header_h = overhead / 2 + row_h = title_font * 1.5 + tokens["spacing_small"] # 1행 높이 + # 가용 높이에서 헤더 빼고 행 수 계산 + if table_available > header_h: + table_rows = max(0, int((table_available - header_h) / row_h)) + + layout.sub_containers = [ + SubContainer("svg", img_width, content_h, align="stretch"), + SubContainer("text_and_table", text_width, content_h, align="center"), + ] + if keymsg_h > 0: + layout.sub_containers.append(SubContainer("keymsg", main_width_px, keymsg_h)) + layout.table_rows = table_rows + + else: + # 이미지 없는 구조 (텍스트만) + # 카드형: item_count > 1이면 카드당 높이 예산 계산 + total_items = sum(tf.item_count for tf in topic_fits) + if total_items > 1: + card_gap = gap * (total_items - 1) + per_card_h = max(0, (content_h - card_gap) / total_items) + layout.sub_containers = [ + SubContainer(f"card_{i+1}", main_width_px, per_card_h) + for i in range(total_items) + ] + else: + layout.sub_containers = [ + SubContainer("text", main_width_px, content_h), + ] + if keymsg_h > 0: + layout.sub_containers.append(SubContainer("keymsg", main_width_px, keymsg_h)) + if emphasis_h > 0: + layout.sub_containers.append(SubContainer("emphasis", main_width_px, emphasis_h)) + + logger.info( + f"[V-Step7] {role}: main={main_height_px}px, " + + ", ".join(f"{sc.name}={sc.width_px:.0f}×{sc.height_px:.0f}" for sc in layout.sub_containers) + + (f", 표 {layout.table_rows}행" if layout.table_rows > 0 else "") + ) + + return layout diff --git a/src/html_generator.py b/src/html_generator.py index 1334959..ba0837e 100644 --- a/src/html_generator.py +++ b/src/html_generator.py @@ -1,6 +1,9 @@ -"""Phase S: AI HTML 생성기 — 검증 합격 프롬프트 템플릿 기반. +"""Phase T: AI HTML 생성기 — 동적 프롬프트 생성. -영역별 개별 호출. 검증에서 합격한 프롬프트의 구조/디자인은 고정, 텍스트만 동적. +영역별 개별 호출. Phase T context(폰트 위계, 블록 레퍼런스, 디자인 예산)에서 +모든 수치를 동적으로 가져와 프롬프트를 조립. + +Phase S 하드코딩 프롬프트(BG_PROMPT 등) → build_area_prompt() 동적 생성으로 교체. 역할 분리: Kei (1단계): 콘텐츠 분석 @@ -22,51 +25,311 @@ logger = logging.getLogger(__name__) # ═══════════════════════════════════════════════════════════ -# 검증 합격 프롬프트 템플릿 -# 구조/디자인은 고정. {변수}만 동적 교체. +# Phase T: 동적 프롬프트 생성 +# Phase S 하드코딩 프롬프트 → context 기반 동적 생성으로 교체 # ═══════════════════════════════════════════════════════════ -BG_PROMPT = """다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라. - -## 핵심 원칙 -이 영역은 **보조 영역**이다. 본심(핵심 콘텐츠)보다 시각적으로 약해야 한다. -다크 배경 절대 금지. 흰색/연회색 위에 텍스트를 놓는 라이트 디자인으로. - -## 크기 -- width: 100%, height: {height}px (고정, overflow:hidden) - -## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.) -{content_block} - -## 텍스트 규칙 (반드시 적용) +# 공통 텍스트 규칙 (모든 영역 동일) +_COMMON_TEXT_RULES = """## 텍스트 규칙 (반드시 적용) 1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지. 2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리. 3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라. - "~있다" → "~있음", "~한다" → "~함", "~이다" → 삭제, "~된다" → "~됨" - 예: "인식되고 있다" → "인식되고 있음" (단어 삭제 없이 끝만 변환) 4. 원본에 없는 텍스트를 추가하지 마라. +5. 동일한 내용을 다른 형태로 2번 넣지 마라. 상세 내용은 "[상세보기]" 텍스트 링크만 남기고 본문에서 제거.""" -## 디자인 -- 배경: background: #f8fafc (연회색, 다크 배경 절대 금지) -- border: 1px solid #e2e8f0, border-radius: 6px -- 전체 padding: 10px 14px (여백 최소화) -- 제목: 12px bold #334155, margin-bottom: 4px -- 본문: 11px #475569, line-height: 1.4, 핵심 키워드 처리 -- 토픽이 여러 개이면 가로로 나란히 (flex, gap:8px) -- 각 토픽 구분: background:#ffffff, border-left: 2px solid #94a3b8, padding: 6px 8px (여백 최소화) -- 토픽 제목: 10px bold #334155, margin-bottom: 2px -- 토픽 내용: 9px #64748b, line-height: 1.3 -- 들여쓰기: 불릿은 인라인 style만 사용. CSS class 사용 금지 ( + + +

{popup_title}

+
첨부 자료 {i} — 슬라이드 본문의 상세 내용
+{clean_content} +
본 자료는 슬라이드 "{ctx.analysis.title}"의 첨부 자료입니다.
+ +""" + (run_dir / popup_filename).write_text(popup_html, encoding="utf-8") + logger.info(f"[Phase T] 첨부 HTML 저장: {popup_filename}") + + yield {"event": "result", "data": html} + logger.info(f"슬라이드 생성 완료: run={ctx.run_id}") + + except StageFailure as e: + logger.error(f"파이프라인 중단: {e.stage_name} — {e.errors}") + yield {"event": "error", "data": f"Stage {e.stage_name} 실패: {e.errors}"} + except Exception as e: + logger.exception(f"파이프라인 오류: {e}") + yield {"event": "error", "data": str(e)} + + +# ══════════════════════════════════════ +# 레거시 파이프라인 (Phase S) — 보존 +# ══════════════════════════════════════ + + async def _retry_kei(fn, *args, **kwargs): """Kei API 호출을 성공할 때까지 재시도한다. @@ -77,12 +1157,12 @@ def _save_step(run_dir: Path, filename: str, data: Any) -> None: logger.info(f"[중간 산출물] {filename} 저장 → {run_dir.name}/") -async def generate_slide( +async def generate_slide_legacy( content: str, manual_layout: dict[str, Any] | None = None, base_path: str = "", ) -> AsyncIterator[dict[str, str]]: - """콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인. + """Phase S 레거시 파이프라인. Phase T 전환 전 보존용. Yields: SSE 이벤트: progress / result / error @@ -310,7 +1390,7 @@ async def _adjust_design( area_info[area] = { "block_count": 0, "total_chars": 0, - "budget_px": zone.get("budget_px", 490), + "budget_px": zone.get("budget_px", 0), "width_pct": zone.get("width_pct", 100), "block_types": [], } diff --git a/src/pipeline_context.py b/src/pipeline_context.py new file mode 100644 index 0000000..f6c244d --- /dev/null +++ b/src/pipeline_context.py @@ -0,0 +1,316 @@ +"""Phase T-0: 파이프라인 누적 컨텍스트 객체. + +모든 Stage가 하나의 PipelineContext를 공유하며, +각 Stage가 transform → validate → update 패턴을 따른다. + +Pydantic BaseModel 채택 이유 (T-0 조사 결과): +- model_dump_json()으로 스냅샷 직렬화 한 줄 +- validate_assignment=True로 타입 오류 즉시 감지 +- Path, Optional, list[dict] 자동 처리 +- 프로젝트가 이미 Pydantic 사용 중 (config.py, FastAPI) +""" +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any, Optional + +from pydantic import BaseModel, Field, model_validator + + +# ────────────────────────────────────── +# 하위 모델 +# ────────────────────────────────────── + +class NormalizedContent(BaseModel): + """Stage 0 출력: MDX 정규화 결과.""" + clean_text: str = "" + title: str = "" + images: list[dict[str, str]] = Field(default_factory=list) + popups: list[dict[str, str]] = Field(default_factory=list) + tables: list[dict[str, Any]] = Field(default_factory=list) + sections: list[dict[str, Any]] = Field(default_factory=list) + + +class Topic(BaseModel): + """Stage 1A + 1B 출력: 개별 꼭지 정보. + + weight는 여기에 없음 — page_structure의 역할별 속성임. + """ + id: int = 0 + title: str = "" + purpose: str = "" + role: str = "" + layer: str = "" + source_hint: str = "" + # Stage 1B에서 병합 + relation_type: str = "" # 7개 enum: hierarchy/cause_effect/comparison/sequence/definition/inclusion/none + expression_hint: str = "" + source_data: str = "" + structured_text: str = "" # Stage 1B: 원본 85% 보존 구조화 텍스트 (조립용) + summary: str = "" + + +class PageStructure(BaseModel): + """Stage 1A 출력: 역할별 비중 구조.""" + roles: dict[str, dict[str, Any]] = Field(default_factory=dict) + # 예: {"본심": {"topic_ids": [1,2], "weight": 0.6}, "배경": {...}, ...} + + +class Analysis(BaseModel): + """Stage 1A 출력: Kei 분석 결과 전체.""" + core_message: str = "" + title: str = "" + total_pages: int = 1 + image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict) + # topics와 page_structure는 PipelineContext 최상위에 위치 + + +class TextBudget(BaseModel): + """Stage 1.5a 출력: 텍스트 예산.""" + font_size: float = 12.0 + chars_per_line: int = 0 + max_lines: int = 0 + max_chars: int = 0 + source_chars: int = 0 + needs_compression: bool = False + + +class DesignBudget(BaseModel): + """Stage 1.5b 출력: 디자인 요소 예산.""" + available_height_px: int = 0 + available_width_px: int = 0 + max_circle_diameter: int = 0 + max_img_width: int = 0 + max_img_height: int = 0 + fits: bool = True + + +class ContainerInfo(BaseModel): + """Stage 1.5a/1.5b 통합: 역할별 컨테이너 정보.""" + role: str = "" + zone: str = "" + topic_ids: list[int] = Field(default_factory=list) + weight: float = 0.0 + height_px: int = 0 + width_px: int = 0 + max_height_cost: str = "medium" + text_budget: Optional[TextBudget] = None + design_budget: Optional[DesignBudget] = None + block_constraints: dict[str, Any] = Field(default_factory=dict) + + +class FontHierarchy(BaseModel): + """Stage 1.5a 출력: 확정된 폰트 위계.""" + key_msg: float = 14.0 # 핵심 메시지 (가장 큼) + core: float = 12.0 # 본문 + bg: float = 11.0 # 배경 (10-12 범위) + sidebar: float = 10.0 # 첨부 (9-11 범위) + + @model_validator(mode="after") + def check_hierarchy(self): + """폰트 위계 유지 검증: key_msg > core >= bg > sidebar.""" + if not (self.key_msg > self.core >= self.bg > self.sidebar): + raise ValueError( + f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) " + f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함" + ) + return self + + +class BlockReference(BaseModel): + """Stage 1.7 출력: 참고 블록 정보.""" + block_id: str = "" + variant: str = "default" + visual_type: str = "" + schema_info: dict[str, Any] = Field(default_factory=dict) + design_reference_html: str = "" + topic_id: int | None = None + supporting_topic_ids: list[int] = Field(default_factory=list) + is_hierarchical: bool = False + + +class StageError(BaseModel): + """Stage 실행 중 발생한 에러.""" + stage: str = "" + attempt: int = 0 + severity: str = "RETRYABLE" # FATAL / RETRYABLE / ADJUSTABLE + errors: list[dict[str, Any]] = Field(default_factory=list) + + +# ────────────────────────────────────── +# 메인 컨텍스트 +# ────────────────────────────────────── + +class PipelineContext(BaseModel): + """파이프라인 전체를 관통하는 누적 컨텍스트. + + 각 Stage가 이 객체를 받아서 필요한 필드를 읽고, + 결과를 model_copy(update=...)로 병합한다. + """ + model_config = {"validate_assignment": True, "arbitrary_types_allowed": True} + + # ── 메타 ── + run_id: str = "" + run_dir: Optional[str] = None # Path를 str로 저장 (JSON 직렬화) + raw_content: str = "" # 원본 MDX (변경 불가 참조용) + base_path: str = "" # 이미지 기준 경로 + + # ── Stage 0 ── + normalized: NormalizedContent = Field(default_factory=NormalizedContent) + + # ── Stage 1A ── + analysis: Analysis = Field(default_factory=Analysis) + topics: list[Topic] = Field(default_factory=list) + page_structure: PageStructure = Field(default_factory=PageStructure) + + # ── Stage 1.5a ── + font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy) + container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct) + containers: dict[str, ContainerInfo] = Field(default_factory=dict) + + # ── Stage 1.7 ── + references: dict[str, list[BlockReference]] = Field(default_factory=dict) + preset_name: str = "" + preset: dict[str, Any] = Field(default_factory=dict) + + # ── Stage 1.8 ── + fit_result: dict[str, Any] = Field(default_factory=dict) + enhancement_result: dict[str, Any] = Field(default_factory=dict) + sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화 + + # ── Stage 2 ── + generated_html: dict[str, str] = Field(default_factory=dict) # body_html, sidebar_html, footer_html + + # ── Stage 3 ── + rendered_html: str = "" + + # ── Stage 4 ── + measurement: dict[str, Any] = Field(default_factory=dict) + quality_score: int = 0 + screenshot_b64: str = "" + + # ── 에러/경고 추적 ── + errors: list[StageError] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + retry_feedback: str = "" # 재시도 시 Self-Refine 피드백 + + # ── 이미지 ── + slide_images: list[dict[str, Any]] = Field(default_factory=list) + + def get_run_dir(self) -> Path: + """run_dir를 Path 객체로 반환.""" + if self.run_dir: + return Path(self.run_dir) + p = Path("data/runs") / self.run_id + return p + + def save_snapshot(self, stage_name: str) -> None: + """디버깅용 스냅샷 저장. JSON + HTML 시각화.""" + run_dir = self.get_run_dir() + run_dir.mkdir(parents=True, exist_ok=True) + # JSON + path = run_dir / f"{stage_name}_context.json" + path.write_text( + self.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"}), + encoding="utf-8", + ) + # HTML 시각화 + try: + from src.step_visualizer import generate_step_html + steps_dir = run_dir / "steps" + steps_dir.mkdir(exist_ok=True) + generate_step_html(stage_name, self, steps_dir) + except Exception as e: + pass # 시각화 실패해도 파이프라인은 계속 + + def log_error(self, stage: str, errors: list[dict], attempt: int = 0, + severity: str = "RETRYABLE") -> None: + """에러를 컨텍스트에 기록.""" + self.errors.append(StageError( + stage=stage, + attempt=attempt, + severity=severity, + errors=errors, + )) + + def get_role_content(self, role: str) -> str: + """역할(본심/배경/첨부/결론)에 해당하는 원본 텍스트를 반환. + + page_structure에서 topic_ids를 찾고, + 해당 topics의 source_data를 합쳐서 반환. + source_data가 없으면 normalized.clean_text에서 source_hint로 매칭. + """ + role_info = self.page_structure.roles.get(role, {}) + topic_ids = role_info.get("topic_ids", []) + + texts = [] + for t in self.topics: + if t.id in topic_ids: + if t.source_data: + texts.append(t.source_data) + elif t.source_hint and self.normalized.sections: + # source_hint로 섹션 매칭 + for sec in self.normalized.sections: + if t.source_hint.lower() in sec.get("title", "").lower(): + texts.append(sec.get("content", "")) + break + + return "\n\n".join(texts) if texts else "" + + +# ────────────────────────────────────── +# Stage 실행 유틸리티 +# ────────────────────────────────────── + +class StageFailure(Exception): + """Stage 실행 실패 (재시도 소진).""" + def __init__(self, stage_name: str, errors: list[dict]): + self.stage_name = stage_name + self.errors = errors + super().__init__(f"Stage {stage_name} 실패: {errors}") + + +def build_retry_feedback(stage_name: str, errors: list[dict], + original_text: str = "") -> str: + """Self-Refine 패턴: localization + evidence + instruction. + + NeurIPS 2023 Self-Refine + VASCAR Scorer/Suggester 분리 패턴. + """ + lines = [ + f"## 이전 {stage_name} 결과의 검증 실패. 다음 문제를 수정하라.\n" + ] + + for i, err in enumerate(errors, 1): + lines.append(f"### 문제 {i}: {err.get('field', err.get('layer', ''))}") + if err.get("localization"): + lines.append(f"- 위치: {err['localization']}") + if err.get("current_value"): + lines.append(f"- 현재 값: {err['current_value']}") + if err.get("evidence"): + lines.append(f"- 원본 근거: \"{err['evidence']}\"") + if err.get("instruction"): + lines.append(f"- 수정 지시: {err['instruction']}") + lines.append("") + + if original_text: + excerpt = original_text[:500] + lines.append(f"## 원본 텍스트 (참고)\n{excerpt}\n") + + lines.append("위 문제들을 해결한 결과를 다시 생성하라. 원본에 없는 해석을 추가하지 마라.") + + return "\n".join(lines) + + +def create_context(content: str, base_path: str = "") -> PipelineContext: + """파이프라인 시작 시 초기 컨텍스트 생성.""" + run_id = time.strftime("%Y%m%d_%H%M%S") + run_dir = str(Path("data/runs") / run_id) + + return PipelineContext( + run_id=run_id, + run_dir=run_dir, + raw_content=content, + base_path=base_path, + ) diff --git a/src/renderer.py b/src/renderer.py index a803c0e..d4c2213 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -531,13 +531,79 @@ def render_slide_from_html( title = analysis.get("title", "슬라이드") grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'") - grid_columns = preset.get("grid_columns", "65fr 35fr") - grid_rows = preset.get("grid_rows", "auto 1fr auto") + + # Phase T: 동적 비율 반영 — container_ratio가 있으면 프리셋 고정값 대신 사용 + container_ratio = analysis.get("_container_ratio") + if container_ratio and len(container_ratio) == 2: + grid_columns = f"{container_ratio[0]}fr {container_ratio[1]}fr" + else: + grid_columns = preset.get("grid_columns", "65fr 35fr") + + # Phase W: AFTER 컨테이너 크기를 grid-template-rows에 반영 + # header=auto, body/sidebar=AFTER 높이에 맞춤, footer=AFTER 높이 + containers = analysis.get("_containers", {}) + redist = analysis.get("_fit_redistribution", {}) + from src.fit_verifier import _load_design_tokens as _ldt + _tokens = _ldt() + _header_h = _tokens.get("header_height", 66) + _gap_small = _tokens["spacing_small"] + _bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0))) + _core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0))) + _footer_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0))) + _body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0 + if _body_row_h > 0 and _footer_h > 0: + grid_rows = f"auto {_body_row_h}px {_footer_h}px" + else: + grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto") body_html = generated.get("body_html", "") sidebar_html = generated.get("sidebar_html", "") footer_html = generated.get("footer_html", "") + # ── 후처리 ── + import re as _re + # 1) sidebar 최외곽 wrapper div만 width:100% (grid cell에 맞추기) + # 첫 번째 태그의 style에서만 변경. 내부 요소(카드 번호 등)는 건드리지 않음. + sidebar_html = _re.sub( + r'^(\s* + body_html = _re.sub(r'\*\*(.+?)\*\*', r'\1', body_html) + sidebar_html = _re.sub(r'\*\*(.+?)\*\*', r'\1', sidebar_html) + # 4) 폰트 위계 강제: 배경 영역 font-size가 본심(core)보다 크면 안 됨 + font_h = analysis.get("_font_hierarchy", {}) + bg_max = font_h.get("bg", 11.0) + core_max = font_h.get("core", 12.0) + sidebar_max = font_h.get("sidebar", 10.0) + + def _cap_font(html_str: str, max_size: float) -> str: + """font-size: NNpx 중 max_size 초과하는 것을 max_size로 캡.""" + def _repl(m): + val = float(m.group(1)) + if val > max_size: + return f"font-size:{max_size}px" + return m.group(0) + return _re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _repl, html_str) + + # body_html의 첫 번째 주요 div(배경)만 캡: height:117px or 배경 색상이 있는 div + # 배경 div 끝( + spacer) 이후가 본심 + bg_end = body_html.find('
diff --git a/src/space_allocator.py b/src/space_allocator.py index eb67259..5fbdfe8 100644 --- a/src/space_allocator.py +++ b/src/space_allocator.py @@ -21,14 +21,36 @@ logger = logging.getLogger(__name__) # ────────────────────────────────────── -# height_cost → px 범위 매핑 +# height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축 # ────────────────────────────────────── -HEIGHT_COST_PX_RANGE = { - "compact": (30, 80), - "medium": (80, 200), - "large": (200, 350), - "xlarge": (350, 500), -} +_height_cost_cache: dict[str, tuple[int, int]] | None = None + +def _get_height_cost_px_range() -> dict[str, tuple[int, int]]: + """catalog.yaml의 블록 min_height_px에서 height_cost별 범위를 동적 계산.""" + global _height_cost_cache + if _height_cost_cache is not None: + return _height_cost_cache + + from src.block_reference import _load_catalog + # height_cost별로 min_height_px 수집 + cost_heights: dict[str, list[int]] = {} + for b in _load_catalog(): + cost = b.get("height_cost", "medium") + h = b.get("min_height_px", 0) + if cost not in cost_heights: + cost_heights[cost] = [] + cost_heights[cost].append(h) + + # 각 cost의 (min, max) 범위 계산 + result = {} + for cost, heights in cost_heights.items(): + if heights: + result[cost] = (min(heights), max(heights)) + else: + result[cost] = (0, 0) + + _height_cost_cache = result + return result HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3} @@ -44,6 +66,229 @@ ROLE_ZONE_MAP = { DEFAULT_FONT_SIZE_PX = 15.2 DEFAULT_LINE_HEIGHT = 1.7 DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글) +CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측: char_width = font_size × 0.947 + +# ────────────────────────────────────── +# Phase T-5: 폰트 위계 + 동적 비율 역산 +# ────────────────────────────────────── + +# 역할별 폰트 위계 범위 (min, max) +# 핵심 원칙: font_size(핵심) > font_size(본심) >= font_size(배경) > font_size(첨부) +FONT_HIERARCHY_RANGE: dict[str, tuple[float, float]] = { + "핵심": (14.0, 14.0), # 고정 14px bold + "본심": (12.0, 12.0), # 고정 12px + "배경": (10.0, 12.0), # 텍스트 양에 따라 조정 + "첨부": (9.0, 11.0), # 텍스트 양에 따라 조정 + "결론": (12.0, 14.0), # footer 배너용 +} + +# 역할별 줄 높이 비율 +ROLE_LINE_HEIGHT: dict[str, float] = { + "핵심": 1.4, + "본심": 1.5, + "배경": 1.4, + "첨부": 1.4, + "결론": 1.3, +} + + +def _estimate_required_height( + text_chars: int, + font_size: float, + available_width: int, + line_height_ratio: float = 1.5, + padding: int | None = None, +) -> int: + """주어진 폰트 크기로 텍스트를 넣으려면 몇 px 필요한가.""" + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + if padding is None: + padding = tokens["spacing_block"] + if text_chars <= 0: + return padding * 2 + char_width = font_size * CHAR_WIDTH_RATIO + inner_width = max(tokens["spacing_page"], available_width - padding * 2) + chars_per_line = max(1, int(inner_width / char_width)) + total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division + line_height_px = font_size * line_height_ratio + return int(total_lines * line_height_px) + padding * 2 + + +def calculate_font_hierarchy( + role_text_lengths: dict[str, int], + available_width: int | None = None, +) -> dict[str, float]: + """역할별 폰트 크기를 위계 범위 내에서 텍스트 양 기반으로 확정. + + Phase T 핵심: 위계가 먼저, 컨테이너가 따라간다. + + Args: + role_text_lengths: {"본심": 500, "배경": 200, "첨부": 300, "결론": 50} + available_width: 예상 가용 너비 (px) + + Returns: + {"핵심": 14.0, "본심": 12.0, "배경": 11.0, "첨부": 10.0, "결론": 13.0} + """ + if available_width is None: + from src.config import settings + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + available_width = settings.slide_width - tokens["spacing_page"] * 2 + + result = {} + + for role, (font_min, font_max) in FONT_HIERARCHY_RANGE.items(): + text_len = role_text_lengths.get(role, 0) + + if font_min == font_max: + # 고정 폰트 (핵심, 본심) + result[role] = font_max + continue + + # 텍스트 양이 많으면 폰트 축소 (범위 내) + # max 폰트로 시도 → 안 되면 1px씩 축소 + chosen = font_max + for fs in [font_max, font_max - 1, font_min]: + fs = max(font_min, fs) + required_h = _estimate_required_height( + text_len, fs, available_width, ROLE_LINE_HEIGHT.get(role, 1.5) + ) + # 합리적 범위(xlarge 최대 높이 이내)면 이 폰트 사용 + ranges = _get_height_cost_px_range() + max_reasonable_h = ranges.get("xlarge", (0, 0))[1] if ranges.get("xlarge") else required_h + if required_h <= max_reasonable_h: + chosen = fs + break + chosen = fs # 최소 폰트라도 사용 + + result[role] = chosen + + # 위계 강제: 핵심 > 본심 >= 배경 > 첨부 + if result.get("배경", 11) > result.get("본심", 12): + result["배경"] = result["본심"] + if result.get("첨부", 10) >= result.get("배경", 11): + result["첨부"] = max(FONT_HIERARCHY_RANGE["첨부"][0], result["배경"] - 1) + + return result + + +def calculate_dynamic_ratio( + role_text_lengths: dict[str, int], + font_hierarchy: dict[str, float], + slide_width: int = 1280, + slide_height: int = 720, + preset: dict[str, Any] | None = None, +) -> tuple[int, int]: + """sidebar 텍스트 양에서 body:sidebar 비율 역산. + + 고정 65:35가 아니라 텍스트 양 기반. + + Returns: + (body_pct, sidebar_pct) 예: (70, 30) or (65, 35) + """ + # 프리셋에서 기본 비율 가져오기 + preset_body_pct = 0 + preset_sidebar_pct = 0 + if preset: + zones = preset.get("zones", {}) + for zone_name, zone_info in zones.items(): + if zone_name == "body": + preset_body_pct = zone_info.get("width_pct", 0) + elif zone_name == "sidebar": + preset_sidebar_pct = zone_info.get("width_pct", 0) + + sidebar_text = role_text_lengths.get("첨부", 0) + body_text = sum(v for k, v in role_text_lengths.items() if k != "첨부" and k != "결론") + + total_text = body_text + sidebar_text + if total_text <= 0 or sidebar_text <= 0: + # sidebar 텍스트 없으면 프리셋의 기본 비율 사용 + if preset_body_pct > 0 and preset_sidebar_pct > 0: + return (preset_body_pct, preset_sidebar_pct) + return (100, 0) + + # 텍스트 비율에서 순수 계산 + sidebar_ratio = sidebar_text / total_text + sidebar_pct = max(1, int(sidebar_ratio * 100)) + body_pct = 100 - sidebar_pct + + return (body_pct, sidebar_pct) + + +def calculate_design_budget( + container_height_px: int, + container_width_px: int, + block_schema: dict, + font_size: float, + padding: int | None = None, +) -> dict: + """블록 schema 기반 디자인 요소 크기 역산. + + 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. + 텍스트를 줄이는 것이 아니라 도형/이미지/CSS 요소의 크기를 맞추는 방향. + + Args: + container_height_px: 컨테이너 높이 + container_width_px: 컨테이너 너비 + block_schema: catalog.yaml의 해당 블록 schema + font_size: 이 역할의 확정된 폰트 크기 (T-5에서 결정) + padding: 컨테이너 내부 패딩 + + Returns: + { + "text_height_px": int, # 텍스트가 차지하는 높이 + "available_height_px": int, # 디자인 요소 가용 높이 + "available_width_px": int, # 디자인 요소 가용 너비 + "max_circle_diameter": int, # 원형 요소 최대 지름 + "max_img_width": int, # 이미지 최대 너비 + "max_img_height": int, # 이미지 최대 높이 + "fits": bool, # 디자인 요소가 들어가는지 + } + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + if padding is None: + padding = tokens["spacing_block"] + + # 블록 schema에서 텍스트 슬롯별 높이 합산 + text_height = 0 + for slot_name, spec in block_schema.items(): + if slot_name.startswith("max_"): + continue + if not isinstance(spec, dict): + continue + slot_lines = spec.get("max_lines", 1) + slot_font = spec.get("font_size", font_size) + # line-height는 typography constant + text_height += int(slot_lines * (slot_font * 1.6)) + + remaining_height = container_height_px - text_height - padding * 2 + remaining_width = container_width_px - padding * 2 + border_w = tokens.get("border_width", tokens.get("accent_border", 1)) + + return { + "text_height_px": text_height, + "available_height_px": max(0, remaining_height), + "available_width_px": max(0, remaining_width), + "max_circle_diameter": max(0, min(remaining_height, remaining_width) - border_w * 2), + "max_img_width": max(0, remaining_width), + "max_img_height": max(0, remaining_height), + "fits": remaining_height >= 0, + } + + +def _estimate_capacity(width_px: int, font_size: float, height_px: int) -> int: + """주어진 공간에서 수용 가능한 총 글자 수.""" + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + padding = tokens["spacing_block"] + char_width = font_size * CHAR_WIDTH_RATIO + inner_width = max(1, width_px - padding * 2) + chars_per_line = max(1, int(inner_width / char_width)) + inner_height = max(1, height_px - padding * 2) + line_height_px = font_size * 1.4 # line-height (typography) + max_lines = max(1, int(inner_height / line_height_px)) + return chars_per_line * max_lines # ────────────────────────────────────── @@ -101,28 +346,60 @@ def calculate_container_specs( zone_roles[zone] = [] zone_roles[zone].append((role_name, role_info)) + # tokens.css에서 spacing 읽기 (하드코딩 방지) + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + slide_padding = tokens["spacing_page"] # --spacing-page + for zone_name, role_list in zone_roles.items(): zone_info = zones.get(zone_name, {}) - zone_budget = zone_info.get("budget_px", 490) - zone_width_pct = zone_info.get("width_pct", 100) - zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외 + # zone budget: weight 비율로 전체 가용 공간 배정 + # weight는 초기 배정 비율. before→filled→after에서 조정됨. + header_height = tokens.get("header_height", 66) + total_available = slide_height - slide_padding * 2 - header_height - gap_px * 2 + zone_weight_sum = sum(info.get("weight", 0) for _, info in role_list) + all_weight_sum = sum( + info.get("weight", 0) + for roles in zone_roles.values() + for _, info in roles + ) + if all_weight_sum > 0 and zone_weight_sum > 0: + zone_budget = int(total_available * zone_weight_sum / all_weight_sum) + else: + # fallback: 프리셋 또는 동적 계산 + zone_budget = zone_info.get("budget_px") or total_available + zone_width_pct = zone_info.get("width_pct", 0) + # 패딩 제외 폭: 슬라이드 폭 - 좌우 패딩 + slide_inner_width = slide_width - slide_padding * 2 + zone_width_px = int(slide_inner_width * zone_width_pct / 100) if zone_width_pct > 0 else slide_inner_width # 이 zone 안의 역할별 비중 비율 계산 - total_weight = sum(info.get("weight", 0.25) for _, info in role_list) + # Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분) + total_weight = sum(info.get("weight", 0) for _, info in role_list) if total_weight <= 0: - total_weight = 1.0 + # weight가 없으면 균등 배분 + total_weight = len(role_list) + for _, info in role_list: + info.setdefault("weight", 1) # 간격 제외 total_gap = gap_px * max(0, len(role_list) - 1) available = zone_budget - total_gap + # 최소 높이: catalog에서 가장 작은 블록의 min_height_px + from src.block_reference import _load_catalog + min_block_h = min( + (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0), + default=1, + ) + for role_name, role_info in role_list: - weight = role_info.get("weight", 0.25) + weight = role_info.get("weight", 1) topic_ids = role_info.get("topic_ids", []) # 비중 비율로 높이 할당 ratio = weight / total_weight - height_px = max(50, int(available * ratio)) + height_px = max(min_block_h, int(available * ratio)) # 블록 내부 제약 계산 — topic당 높이로 판단 topic_count = max(1, len(topic_ids)) @@ -157,27 +434,43 @@ def calculate_container_specs( def _max_allowed_height_cost(container_height_px: int) -> str: - """컨테이너 높이에서 허용되는 최대 height_cost.""" - if container_height_px >= 350: - return "xlarge" - elif container_height_px >= 200: - return "large" - elif container_height_px >= 80: - return "medium" - else: - return "compact" + """컨테이너 높이에서 허용되는 최대 height_cost. + + catalog.yaml 블록들의 min_height_px 기반 동적 계산. + """ + ranges = _get_height_cost_px_range() + # 높은 cost부터 확인: 컨테이너가 해당 cost의 최소 높이 이상이면 허용 + for cost in ["xlarge", "large", "medium", "compact"]: + if cost in ranges: + min_h, _ = ranges[cost] + if container_height_px >= min_h: + return cost + return "compact" def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]: - """컨테이너 높이에 따른 폰트/패딩/줄간격 결정.""" - if per_block_height_px >= 300: - return (15.2, 20, 1.7) - elif per_block_height_px >= 150: - return (14.0, 14, 1.6) - elif per_block_height_px >= 80: - return (13.0, 10, 1.5) + """컨테이너 높이에 따른 폰트/패딩/줄간격 결정. + + font-size와 line-height는 typography constant (허용). + padding은 tokens.css의 spacing 값에서 가져옴. + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + + # height_cost 범위에서 어떤 급인지 판단 + ranges = _get_height_cost_px_range() + xlarge_min = ranges.get("xlarge", (0, 0))[0] + large_min = ranges.get("large", (0, 0))[0] + medium_min = ranges.get("medium", (0, 0))[0] + + if per_block_height_px >= xlarge_min and xlarge_min > 0: + return (15.2, tokens["spacing_block"], 1.7) # font 15.2, padding=--spacing-block, lh 1.7 + elif per_block_height_px >= large_min and large_min > 0: + return (14.0, tokens["spacing_inner"], 1.6) # font 14, padding=--spacing-inner, lh 1.6 + elif per_block_height_px >= medium_min and medium_min > 0: + return (13.0, tokens["spacing_small"], 1.5) # font 13, padding=--spacing-small, lh 1.5 else: - return (12.0, 8, 1.4) + return (12.0, tokens["spacing_small"], 1.4) # font 12, padding=--spacing-small, lh 1.4 def _calculate_block_constraints( @@ -188,11 +481,17 @@ def _calculate_block_constraints( line_height: float, padding_px: int, ) -> dict: - """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.""" - per_topic_height = max(30, (height_px - padding_px * 2) // topic_count) + """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산. + + 모든 수치는 입력 파라미터(이전 Stage 결과) + font metric에서 도출. + """ + per_topic_height = max(1, (height_px - padding_px * 2) // max(1, topic_count)) line_height_px = font_size_px * line_height - max_lines = max(1, int(per_topic_height / line_height_px)) - chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95))) + max_lines = max(1, int(per_topic_height / max(1, line_height_px))) + # chars_per_line: CHAR_WIDTH_RATIO(font metric)로 계산 + char_width = font_size_px * CHAR_WIDTH_RATIO + usable_width = max(1, width_px - padding_px * 2) + chars_per_line = max(1, int(usable_width / max(1, char_width))) max_items = max(1, max_lines // 2) max_chars_total = max_lines * chars_per_line @@ -200,8 +499,8 @@ def _calculate_block_constraints( "max_lines": max_lines, "max_items": max_items, "chars_per_line": chars_per_line, - "max_chars_total": max(20, max_chars_total), - "max_chars_per_item": max(20, max_chars_total // max(1, max_items)), + "max_chars_total": max(1, max_chars_total), + "max_chars_per_item": max(1, max_chars_total // max(1, max_items)), } @@ -249,7 +548,9 @@ def finalize_block_specs( if find_container_for_topic(b.get("topic_id"), container_specs) == spec and b.get("topic_id") is not None] sibling_count = max(1, len(siblings)) - per_block_height = max(40, spec.height_px // sibling_count) + # 최소 높이: catalog에서 가장 작은 블록의 min_height_px + _min_h = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1) + per_block_height = max(_min_h, spec.height_px // sibling_count) # 폰트/패딩 결정 font_size, padding, line_h = _determine_typography(per_block_height) @@ -318,31 +619,19 @@ def calculate_trim_chars( # Phase Q-3: 글자수 예산 계산 # ────────────────────────────────────── -# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위) -# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반 -_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = { - "card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유 - "card-icon-desc": 50, # 아이콘 40 + 패딩 + gap - "card-step-vertical": 50, # 마커 30 + 패딩 + gap - "dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12 - "comparison-2col": 60, # 헤더*2 + 구분선 + 패딩 - "compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩 - "compare-2col-split": 60, # 헤더 행 40 + 패딩 - "table-simple-striped": 50, # 헤더 행 35 + 패딩 - "banner-gradient": 36, # 패딩 16*2=32 + 여유 - "callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20 - "callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20 - "quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2 - "quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반) - "compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유 - "venn-diagram": 60, # SVG 구조 + 패딩 - "process-horizontal": 50, # 화살표 + 번호 36 + 패딩 - "flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩 - "keyword-circle-row": 60, # 원형 + 라벨 + 패딩 -} +# 블록 유형별 구조적 오버헤드 — catalog.yaml의 padding_overhead_px에서 읽음 +def _get_block_overhead(block_type: str) -> int: + """catalog.yaml에서 블록의 padding_overhead_px를 읽어옴.""" + from src.block_reference import _load_catalog + for b in _load_catalog(): + if b["id"] == block_type: + return b.get("padding_overhead_px", 0) + return 0 -# 같은 컨테이너 내 블록 간 gap (px) -_CONTAINER_BLOCK_GAP = 8 +# 같은 컨테이너 내 블록 간 gap — tokens.css에서 읽음 +def _get_block_gap() -> int: + from src.fit_verifier import _load_design_tokens + return _load_design_tokens()["spacing_small"] def calculate_char_budget( @@ -371,15 +660,16 @@ def calculate_char_budget( topic_count = max(1, len(container_spec.topic_ids)) # 같은 컨테이너 내 블록 간 gap 차감 - total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1) - available_container_height = max(40, container_spec.height_px - total_gap) + total_gap = _get_block_gap() * max(0, topic_count - 1) + _min_h2 = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1) + available_container_height = max(_min_h2, container_spec.height_px - total_gap) per_topic_px = available_container_height // topic_count # 폰트 크기 결정 font_size, padding, line_h = _determine_typography(per_topic_px) # 구조적 오버헤드 - structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20) + structural = _get_block_overhead(block_type) content_height = max(10, per_topic_px - structural) # 줄 수 계산 @@ -387,7 +677,10 @@ def calculate_char_budget( available_lines = max(1, int(content_height / line_height_px)) # 한국어 줄당 글자수 (폰트 크기 기반) - usable_width = container_spec.width_px * 0.85 # 패딩 제외 + # 패딩 제외: tokens.css의 spacing_page × 2가 아니라 블록 내부 padding + # 블록 padding은 container_spec.block_constraints에 있을 수 있음 + block_padding = container_spec.block_constraints.get("padding_px", 0) + usable_width = container_spec.width_px - block_padding * 2 if block_padding else container_spec.width_px chars_per_line = max(5, int(usable_width / font_size)) # 항목 수 제한 (블록 정의 참조) diff --git a/src/step_visualizer.py b/src/step_visualizer.py new file mode 100644 index 0000000..7120206 --- /dev/null +++ b/src/step_visualizer.py @@ -0,0 +1,780 @@ +"""파이프라인 각 Stage 실행 후 자동으로 HTML 시각화를 생성. + +save_snapshot()에서 호출됨. 각 stage의 실제 context 데이터로 HTML 생성. +JSON context 파일과 동일한 stage 이름을 사용. + +생성되는 파일 (JSON context 파일과 1:1 매칭): + stage_0.html — MDX 정규화 결과 (섹션, 팝업, 이미지) + stage_1a.html — Kei 꼭지 + 영역 배정 (테이블) + stage_1b.html — 컨셉 구체화 (source_data, summary 추가) + stage_1_5a.html — 빈 컨테이너 (1280x720) + stage_1_5a_content.html — 컨테이너에 실제 콘텐츠 배치 + stage_1_5b.html — 디자인 예산 (영역별 available_height/width) + stage_1_7.html — 블록 선택 표시 + stage_1_8_fit_before.html — 적합성 검증 (재배분 전) + stage_1_8_fit_after.html — 재배분 후 + 보강 결과 + stage_1_8_blocks.html — 재배분 후 컨테이너에 블록 배치 + stage_2.html — HTML 생성 결과 (영역별 생성된 HTML) + stage_3.html — 렌더링 조립 → final.html 링크 + stage_4.html — 품질 게이트 (측정값, 점수) +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.pipeline_context import PipelineContext + +logger = logging.getLogger(__name__) + +COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} +FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} + + +def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None: + """stage_name에 따라 적절한 시각화 HTML 생성. 파일명은 JSON context와 1:1 매칭.""" + try: + if stage_name == "stage_0": + _gen_stage_0(ctx, steps_dir) + elif stage_name == "stage_1a": + _gen_stage_1a(ctx, steps_dir) + elif stage_name == "stage_1b": + _gen_stage_1b(ctx, steps_dir) + elif stage_name == "stage_1_5a": + _gen_stage_1_5a(ctx, steps_dir) + _gen_stage_1_5a_content(ctx, steps_dir) + elif stage_name == "stage_1_5b": + _gen_stage_1_5b(ctx, steps_dir) + elif stage_name == "stage_1_7": + _gen_stage_1_7(ctx, steps_dir) + elif stage_name == "stage_1_8": + # before와 filled는 pipeline.py Stage 1.8 내부에서 before context로 이미 생성됨 + # step_visualizer는 after context로 호출되므로 덮어쓰면 안 됨 + # blocks와 fit_after만 생성 (after 상태 반영) + _gen_stage_1_8_blocks(ctx, steps_dir) + _gen_stage_1_8_fit_after(ctx, steps_dir) + elif stage_name == "stage_2": + _gen_stage_2(ctx, steps_dir) + elif stage_name == "stage_3": + _gen_stage_3(ctx, steps_dir) + elif stage_name == "stage_4": + _gen_stage_4(ctx, steps_dir) + except Exception as e: + logger.warning(f"[step_viz] {stage_name} 시각화 실패: {e}") + + +# ══════════════════════════════════════ +# 공통 +# ══════════════════════════════════════ + +def _tokens(): + from src.fit_verifier import _load_design_tokens + return _load_design_tokens() + + +def _calc_coords(containers: dict, ratio: tuple) -> dict: + t = _tokens() + pad = t.get("spacing_page", 40) + gap = t.get("spacing_block", 20) + small = t.get("spacing_small", 8) + header_h = 66 + + inner_w = 1280 - pad * 2 + body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w + sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0 + + def gh(c): + if hasattr(c, "height_px"): return c.height_px + return c.get("height_px", 0) if isinstance(c, dict) else 0 + + bg_h = gh(containers.get("배경", {})) + core_h = gh(containers.get("본심", {})) + sb_h = gh(containers.get("첨부", {})) + ft_h = gh(containers.get("결론", {})) + + bg_top = pad + header_h + gap + core_top = bg_top + bg_h + small + ft_top = max(core_top + core_h, bg_top + sb_h) + gap + + return { + "header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}, + "배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h}, + "본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h}, + "첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h}, + "결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}, + } + + +def _wrap(title, subtitle, slide_body): + return f""" + +
{title}
+
{subtitle}
+
+{slide_body} +
""" + + +def _hdr(c, title): + return (f'
{title}
\n') + + +def _box(c, role, inner, extra=""): + cl = COLORS.get(role, "#333") + return (f'
{inner}
\n') + + +# ══════════════════════════════════════ +# Stage 0: MDX 정규화 +# ══════════════════════════════════════ + +def _gen_stage_0(ctx, steps_dir): + """MDX 정규화 결과: 섹션, 팝업, 이미지, 테이블 목록.""" + norm = ctx.normalized if hasattr(ctx, 'normalized') else {} + if hasattr(norm, 'model_dump'): + norm = norm.model_dump() + elif not isinstance(norm, dict): + norm = {} + + sections = norm.get("sections", []) + popups = norm.get("popups", []) + images = norm.get("images", []) + tables = norm.get("tables", []) + title = norm.get("title", ctx.analysis.title if ctx.analysis else "") + + sec_rows = "" + for i, s in enumerate(sections): + heading = s.get("heading", "") if isinstance(s, dict) else "" + content = s.get("content", "") if isinstance(s, dict) else str(s) + preview = content[:120].replace("<", "<") + ("..." if len(content) > 120 else "") + bg = "#f8fafc" if i % 2 == 0 else "#fff" + sec_rows += f'{i+1}{heading}{preview}\n' + + popup_rows = "" + for p in popups: + pt = p.get("title", "") if isinstance(p, dict) else str(p) + pc = p.get("content", "") if isinstance(p, dict) else "" + popup_rows += f'{pt}{len(pc)}자\n' + + html = f""" + + +
Stage 0: MDX 정규화
+
제목: {title} | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개
+
섹션
+ +{sec_rows}
#headingcontent (미리보기)
+
팝업
+ +{popup_rows}
title분량
+""" + (steps_dir / "stage_0.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1A: Kei 꼭지 +# ══════════════════════════════════════ + +def _gen_stage_1a(ctx, steps_dir): + ps = ctx.page_structure.roles + rm = {} + for role, info in ps.items(): + if isinstance(info, dict): + for tid in info.get("topic_ids", []): + rm[tid] = role + + rows = "" + for t in ctx.topics: + role = rm.get(t.id, "?") + c = COLORS.get(role, "#333") + bg = "#f8fafc" if t.id % 2 == 0 else "#fff" + rows += (f'{t.id}' + f'{t.title}' + f'{t.purpose}{t.layer}' + f'{t.relation_type}' + f'{role}\n') + + ps_info = "
".join(f"{r}: topic_ids={info.get('topic_ids')}, weight={info.get('weight')}" + for r, info in ps.items() if isinstance(info, dict)) + + html = f""" + + +
Stage 1A/1B: Kei 꼭지 + 영역 배정
+ + + +{rows}
ID제목purposelayerrelation_type영역
+
페이지 구조:
{ps_info}
""" + (steps_dir / "stage_1a.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1B: 컨셉 구체화 +# ══════════════════════════════════════ + +def _gen_stage_1b(ctx, steps_dir): + """Stage 1B 후 꼭지에 source_data, summary가 추가된 상태.""" + ps = ctx.page_structure.roles + rm = {} + for role, info in ps.items(): + if isinstance(info, dict): + for tid in info.get("topic_ids", []): + rm[tid] = role + + rows = "" + for t in ctx.topics: + role = rm.get(t.id, "?") + c = COLORS.get(role, "#333") + bg = "#f8fafc" if t.id % 2 == 0 else "#fff" + sd = (t.source_data or "")[:150] + sd_display = sd.replace("<", "<") + ("..." if len(t.source_data or "") > 150 else "") + summary = (t.summary or "")[:100] if hasattr(t, 'summary') else "" + rows += (f'{t.id}' + f'{t.title}' + f'{role}' + f'{t.layer}' + f'{sd_display}' + f'{summary}\n') + + html = f""" + + +
Stage 1B: 컨셉 구체화
+
Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨
+ + + +{rows}
ID제목영역layersource_data (미리보기)summary
""" + (steps_dir / "stage_1b.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.5a: 빈 컨테이너 +# ══════════════════════════════════════ + +def _gen_stage_1_5a(ctx, steps_dir): + coords = _calc_coords(ctx.containers, ctx.container_ratio) + fh = ctx.font_hierarchy + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + fk = FONT_MAP[role] + font = getattr(fh, fk, "?") + inner = (f'
' + f'{role}
' + f'{c["w"]}x{c["h"]}px / font:{font}px
') + body += _box(c, role, inner) + + r = ctx.container_ratio + html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body) + (steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.5a: 컨테이너에 콘텐츠 배치 +# ══════════════════════════════════════ + +def _gen_stage_1_5a_content(ctx, steps_dir): + coords = _calc_coords(ctx.containers, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + ps = ctx.page_structure.roles + topic_map = {t.id: t for t in ctx.topics} + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + info = ps.get(role, {}) + tids = info.get("topic_ids", []) if isinstance(info, dict) else [] + + lines = [f'
{role}
'] + for tid in tids: + t = topic_map.get(tid) + if not t: + continue + lines.append(f'
[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}
') + sd = t.source_data + if sd: + # 불릿으로 표시 + for sent in sd.split(", "): + sent = sent.strip() + if sent: + lines.append(f'
{sent}
') + + inner = f'
{"".join(lines)}
' + body += _box(c, role, inner) + + html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body) + (steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.5b: 디자인 예산 +# ══════════════════════════════════════ + +def _gen_stage_1_5b(ctx, steps_dir): + """영역별 디자인 예산 (available height/width, fits 여부).""" + coords = _calc_coords(ctx.containers, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + ci = ctx.containers.get(role) + if not ci: + continue + db = ci.design_budget + if db and hasattr(db, 'model_dump'): + db = db.model_dump() + elif not isinstance(db, dict): + db = {} + + avail_h = db.get("available_height_px", 0) + avail_w = db.get("available_width_px", 0) + fits = db.get("fits", False) + icon = "✅" if fits else "⚠️" + + inner = (f'
' + f'
{icon} {role} ({c["w"]}×{c["h"]}px)
' + f'
available: {avail_h}×{avail_w}px
' + f'
fits: {fits}
' + f'
') + body += _box(c, role, inner) + + html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body) + (steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.7: 블록 선택 +# ══════════════════════════════════════ + +def _gen_stage_1_7(ctx, steps_dir): + coords = _calc_coords(ctx.containers, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + ref_list = ctx.references.get(role, []) + + lines = [f'
{role} ({c["w"]}x{c["h"]}px)
'] + for r in ref_list: + bid = r.block_id + var = r.variant + vtype = r.visual_type + line = f'{bid} ({var}) {vtype}' + # 주종 정보 — model_dump에서 확인 + rd = r.model_dump() if hasattr(r, "model_dump") else {} + # BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인 + lines.append(f'
{line}
') + + inner = f'
{"".join(lines)}
' + body += _box(c, role, inner) + + html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body) + (steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.8: 텍스트/그림 채운 상태 (filled) +# ══════════════════════════════════════ + +def _gen_stage_1_8_filled(ctx, steps_dir): + """블록 디자인에 텍스트를 채운 상태. 공통 조립 함수(block_assembler) 사용.""" + from src.block_assembler import assemble_slide_html + slide_html = assemble_slide_html(ctx) + # 시각화 제목 삽입 + header = ( + '
' + 'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)
\n' + '
' + '블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.
\n' + ) + html = slide_html.replace('', '\n' + header, 1) + (steps_dir / "stage_1_8_filled.html").write_text(html, encoding="utf-8") + + +def _gen_stage_1_8_fit_before(ctx, steps_dir): + """before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시.""" + coords = _calc_coords(ctx.containers, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + + ref_list = ctx.references.get(role, []) + blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택" + + ps = ctx.page_structure.roles + info = ps.get(role, {}) + weight = info.get("weight", 0) if isinstance(info, dict) else 0 + + inner = (f'
' + f'
{role} ({c["w"]}x{c["h"]}px)
' + f'
weight: {weight}
' + f'
블록: {blocks}
' + f'
') + body += _box(c, role, inner) + + html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body) + (steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.8: 재배분 후 + 보강 +# ══════════════════════════════════════ + +def _gen_stage_1_8_fit_after(ctx, steps_dir): + fit = ctx.fit_result + enh = ctx.enhancement_result + redist = fit.get("redistribution", {}) + roles_fit = fit.get("roles", {}) + + # 재배분된 컨테이너 + new_c = {} + for role, ci in ctx.containers.items(): + new_h = int(redist.get(role, ci.height_px)) + new_c[role] = {"height_px": new_h, "width_px": ci.width_px} + + coords = _calc_coords(new_c, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + body = _hdr(coords["header"], title) + + emps = enh.get("emphasis_blocks", []) + bolds = enh.get("bold_keywords", {}) + sups = enh.get("supplement_blocks", []) + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + rf = roles_fit.get(role, {}) + status = rf.get("fit_status", "?") + icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?") + old_h = rf.get("allocated_px", 0) + new_h = int(redist.get(role, old_h)) + needed = rf.get("total_required_px", 0) + delta = new_h - old_h + + ref_list = ctx.references.get(role, []) + blocks = ", ".join(r.block_id for r in ref_list) + + delta_str = f" ({delta:+d}px)" if delta != 0 else "" + + inner = (f'
' + f'
{icon} {role} ({c["w"]}x{new_h}px){delta_str}
' + f'
필요: {needed:.0f}px / 재배분 후: {new_h}px
' + f'
블록: {blocks}
') + + # 보강 정보 + role_emps = [e for e in emps if e.get("role") == role] + role_bolds = bolds.get(role, []) + role_sups = [s for s in sups if s.get("role") == role] + + if role_emps: + for e in role_emps: + inner += f'
강조: {e.get("sentence","")[:40]}...
' + if role_sups: + for s in role_sups: + inner += f'
보충: {s.get("block_id")} ({s.get("content_source")})
' + if role_bolds: + inner += f'
bold: {role_bolds[:4]}
' + + inner += '
' + body += _box(c, role, inner) + + redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) + html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body) + (steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 1.8: 블록 디자인을 컨테이너에 배치 +# ══════════════════════════════════════ + +def _gen_stage_1_8_blocks(ctx, steps_dir): + """재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시. + debug_steps/step2_phase_v.html 수준의 시각화.""" + import re as _re + + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + topic_map = {t.id: t for t in ctx.topics} + ps = ctx.page_structure.roles + + new_c = {} + for role, ci in ctx.containers.items(): + new_h = int(redist.get(role, ci.height_px)) + new_c[role] = {"height_px": new_h, "width_px": ci.width_px} + + coords = _calc_coords(new_c, ctx.container_ratio) + title = ctx.analysis.title or "슬라이드" + + all_block_css = set() + slide_body = _hdr(coords["header"], title) + legend_lines = [] + + for role in ["배경", "본심", "첨부", "결론"]: + c = coords[role] + cl = COLORS[role] + ref_list = ctx.references.get(role, []) + info = ps.get(role, {}) + tids = info.get("topic_ids", []) if isinstance(info, dict) else [] + + if not ref_list: + slide_body += _box(c, role, f'
블록 없음
') + continue + + r0 = ref_list[0] + bid = r0.block_id + var = r0.variant + is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False + sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else [] + primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None) + + # 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로 + raw = r0.design_reference_html or "" + # CSS 추출 + styles = _re.findall(r'', raw, _re.DOTALL) + for s in styles: + all_block_css.add(s) + clean = _re.sub(r'', '', raw, flags=_re.DOTALL) + + # SLOT 주석을 보이는 텍스트로 변환 + def _slot_comment_to_visible(match): + text = match.group(1).strip() + if 'SLOT:' in text: + return f'{text}' + return '' + clean = _re.sub(r'', _slot_comment_to_visible, clean) + # 나머지 주석 제거 + clean = _re.sub(r'', '', clean, flags=_re.DOTALL) + + # 태그 라벨 (동적) + tag_parts = [f"{role} ({c['w']}×{c['h']})", bid] + if is_hier: + sup_str = "+".join(f"꼭지{st}" for st in sup_tids) + tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개") + tag_label = " · ".join(tag_parts) + + # 종속 꼭지 SLOT 표시 + sub_slot = "" + if is_hier and sup_tids: + for st in sup_tids: + st_topic = topic_map.get(st) + st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else "" + sub_slot += ( + f'
' + f'SLOT: 하위 (꼭지{st} — {st_purpose})
' + ) + + # key-msg SLOT (본심만) + keymsg_slot = "" + if role == "본심" and ctx.analysis.core_message: + keymsg_slot = ( + f'
' + f'SLOT: key-msg
' + ) + + inner = ( + f'
' + f'{tag_label}' + f'{clean}{sub_slot}{keymsg_slot}
' + ) + + slide_body += ( + f'
' + f'{inner}
\n' + ) + + # 범례 + if is_hier: + primary_topic = topic_map.get(primary_tid) + p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else "" + legend_lines.append( + f'• {role}: 꼭지{primary_tid}({p_layer}) + ' + f'{"+".join(f"꼭지{st}" for st in sup_tids)} → ' + f'주종 관계 → {bid} 1개' + ) + else: + for r in ref_list: + t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None) + t_layer = t.layer if t and hasattr(t, 'layer') else "" + legend_lines.append(f'• {role}: 꼭지{r.topic_id}({t_layer}) → {r.block_id}') + + css_block = "\n".join(all_block_css) + legend_html = "
".join(legend_lines) + + html = f""" + +
Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)
+
블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.
+
+{slide_body} +
+
+블록 선택 근거 (layer 기반):
{legend_html} +
""" + (steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8") + + +def _gen_stage_2(ctx, steps_dir): + """Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌. + 각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링.""" + gen = ctx.generated_html or {} + sub_layouts = ctx.sub_layouts or {} + ps = ctx.page_structure.roles + + # body_html에서 배경/본심 분리 (spacer로 구분) + body_html = gen.get("body_html", "") + sidebar_html = gen.get("sidebar_html", "") + footer_html = gen.get("footer_html", "") + + # body_html = 배경 + spacer + 본심. spacer로 분리 + import re as _re + spacer_pattern = r'
' + body_parts = _re.split(spacer_pattern, body_html, maxsplit=1) + bg_html = body_parts[0].strip() if len(body_parts) > 1 else "" + core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip() + + # 역할별 HTML 매핑 + role_htmls = {} + if bg_html and "배경" in ps: + role_htmls["배경"] = bg_html + if core_html and "본심" in ps: + role_htmls["본심"] = core_html + if sidebar_html and "첨부" in ps: + role_htmls["첨부"] = sidebar_html + if footer_html and "결론" in ps: + role_htmls["결론"] = footer_html + + # 각 역할을 컨테이너 크기에 맞게 실제 렌더링 + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) + sections = [] + + for role in ["배경", "본심", "첨부", "결론"]: + rhtml = role_htmls.get(role, "") + if not rhtml: + continue + cl = COLORS.get(role, "#333") + ci = ctx.containers.get(role) + if not ci: + continue + h = int(redist.get(role, ci.height_px)) + w = ci.width_px + + # sub_layout 정보 + layout = sub_layouts.get(role, {}) + scs = layout.get("sub_containers", []) + sc_desc = " + ".join(f'{sc["name"]}({int(sc["width_px"])}×{int(sc["height_px"])}px)' for sc in scs) if scs else "" + + sections.append( + f'
' + f'
' + f'{role} ({w}×{h}px)' + f'{" — " + sc_desc if sc_desc else ""}
' + f'
{rhtml}
' + ) + + html = f""" + + +
Stage 2: 영역별 HTML 생성 결과 (Sonnet)
+
각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링
+{"".join(sections)} +""" + (steps_dir / "stage_2.html").write_text(html, encoding="utf-8") + + +def _gen_stage_3(ctx, steps_dir): + """Stage 3 결과: rendered_html을 별도 파일로 저장 + 링크. + rendered_html 자체가 완성 HTML이므로 직접 렌더링 가능.""" + rendered = ctx.rendered_html or "" + if rendered: + # rendered_html을 별도 파일로 저장하여 브라우저에서 직접 확인 가능 + (steps_dir / "stage_3_rendered.html").write_text(rendered, encoding="utf-8") + + html = f""" + + +
Stage 3: 렌더링 조립 결과
+
Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용
+

렌더링 결과 보기 (1280×720) →

+

final.html 보기 →

+
+Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환 +
+""" + (steps_dir / "stage_3.html").write_text(html, encoding="utf-8") + + +# ══════════════════════════════════════ +# Stage 4: 품질 게이트 +# ══════════════════════════════════════ + +def _gen_stage_4(ctx, steps_dir): + """Stage 4 결과: 측정값 + 품질 점수.""" + measurement = ctx.measurement or {} + quality_score = ctx.quality_score if hasattr(ctx, 'quality_score') else "N/A" + + slide_m = measurement.get("slide", {}) + zones = measurement.get("zones", {}) + + zone_rows = "" + for zone_name, zone_data in zones.items(): + overflowed = zone_data.get("overflowed", False) + excess = zone_data.get("excess_px", 0) + client_h = zone_data.get("clientHeight", 0) + scroll_h = zone_data.get("scrollHeight", 0) + icon = "❌" if overflowed else "✅" + bg = "#fee2e2" if overflowed else "#f0fdf4" + zone_rows += (f'{icon} {zone_name}' + f'{client_h}px' + f'{scroll_h}px' + f'{excess:+d}px\n') + + score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626" + + html = f""" + + +
Stage 4: 품질 게이트
+
품질 점수: {quality_score}
+
슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}
+ +{zone_rows}
영역clientHscrollHexcess
+""" + (steps_dir / "stage_4.html").write_text(html, encoding="utf-8") diff --git a/src/validators.py b/src/validators.py new file mode 100644 index 0000000..f7a29ab --- /dev/null +++ b/src/validators.py @@ -0,0 +1,380 @@ +"""Phase T-2: Stage 1A/1B 검증 시스템. + +Stage 2 이후 검증(content_verifier.py)과 분리된 독립 모듈. +AI(Kei)의 콘텐츠 분석 결과를 원본과 대조하여 검증. + +검증 4계층: + 1. 형식 검증 (Pydantic) — 값 범위, 유효 enum, null 체크 + 2. 내용 검증 (코드+대조) — 결과가 원본에 대해 적절한가 + 3. 모순 탐지 (결정 테이블) — purpose × relation_type 논리 모순 + 4. 피드백 생성 — Self-Refine 패턴: localization + evidence + instruction + +도구: + - kiwipiepy: 한국어 명사/키워드 추출 (T-2 조사: Windows 즉시 동작, Java 불필요) + - regex: 관계 표현 패턴 (T-2 조사: 7개 relation_type별 15개+ 패턴) +""" +from __future__ import annotations + +import re +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# kiwipiepy lazy loading (첫 import 시 ~50MB 모델 다운로드) +_kiwi = None + +def _get_kiwi(): + global _kiwi + if _kiwi is None: + from kiwipiepy import Kiwi + _kiwi = Kiwi() + return _kiwi + + +# ══════════════════════════════════════ +# 한국어 키워드 추출 (kiwipiepy) +# ══════════════════════════════════════ + +def extract_keywords_kiwi(text: str) -> set[str]: + """kiwipiepy로 명사 + 영문 약어 추출. + + 기존 content_verifier.py의 regex extract_keywords()보다 정확: + - "정립되지" → "정립" 추출 가능 (어미 분리) + - "혼용되어" → "혼용" 추출 가능 + - 복합 조사 "에서는" 등 정확 분리 + """ + kiwi = _get_kiwi() + tokens = kiwi.tokenize(text) + keywords = set() + for t in tokens: + # NNG: 일반명사, NNP: 고유명사, SL: 외국어(영문약어) + if t.tag in ("NNG", "NNP", "SL") and len(t.form) >= 2: + keywords.add(t.form) + return keywords + + +# ══════════════════════════════════════ +# 관계 표현 패턴 (T-2 조사 결과) +# ══════════════════════════════════════ + +RELATION_PATTERNS: dict[str, list[str]] = { + "comparison": [ + r"[Vv][Ss]\.?", r"에\s*비해", r"반면", r"차이점?", r"비교", + r"대비", r"와\s*달리", r"과\s*달리", r"한편", r"에\s*반하여", + r"그에\s*비해", r"상이", r"구분", r"차별화", + ], + "sequence": [ + r"→", r"이후", r"다음", r"먼저", r"그\s*후", r"단계", + r"순서", r"[0-9]+차", r"최종적", r"한\s*뒤", r"우선", + r"이어서", r"점진적", r"과정", r"를\s*거쳐", + ], + "hierarchy": [ + r"상위", r"하위", r"속하", r"의\s*일부", r"범주", + r"구성요소", r"체계", r"분류", r"계층", r"로\s*나뉜?다", + r"종속", r"수직적", r"상하\s*관계", r"아우르", r"광의|협의", + ], + "inclusion": [ + r"포함", r"융합", r"통합", r"안에", r"속에", r"결합", + r"합쳐", r"아우르", r"망라", r"수렴", r"내포", r"포괄", + r"연계", r"접목", r"겹치|중복", + ], + "cause_effect": [ + r"때문에", r"따라서", r"결과", r"원인", r"로\s*인해", + r"하여", r"해서", r"초래", r"야기", r"기인", + r"영향", r"유발", r"한\s*결과", r"므로", + r"그래서", r"그러므로", r"에\s*의해", + ], + "definition": [ + r"이란", r"정의", r"의미", r"개념", + r"을\s*말한다", r"을\s*뜻한다", r"로\s*정의", r"가리킨다", + r"라\s*함은", r"라고\s*한다", r"줄임말|약어|약자", + r"에\s*해당", r"일컫", r"용어", + ], +} + + +def detect_relation_evidence(text: str) -> dict[str, int]: + """원본 텍스트에서 각 relation_type의 증거 수를 카운트.""" + evidence = {} + for rel_type, patterns in RELATION_PATTERNS.items(): + count = sum(1 for p in patterns if re.search(p, text)) + evidence[rel_type] = count + return evidence + + +# ══════════════════════════════════════ +# 모순 결정 테이블 (데이터로 정의) +# ══════════════════════════════════════ + +# purpose × relation_type 하드 모순 (이 조합은 논리적으로 불가능) +CONTRADICTIONS: dict[str, list[str]] = { + "결론강조": ["comparison", "sequence"], # 결론은 비교나 순서가 아님 + "문제제기": ["sequence", "definition"], # 문제제기는 순서 나열이나 정의가 아님 + "용어정의": ["hierarchy", "cause_effect"], # 정의 나열은 상하위나 인과가 아님 + "구조시각화": ["none"], # 시각화할 관계가 없으면 구조시각화가 아님 +} + +# 소프트 경고 (의심 수준) +SOFT_WARNINGS: dict[str, list[str]] = { + "핵심전달": ["definition"], # 핵심전달에 definition은 약간 의심 +} + + +# ══════════════════════════════════════ +# Stage 1A 검증 +# ══════════════════════════════════════ + +VALID_PURPOSES = {"문제제기", "근거사례", "핵심전달", "용어정의", "결론강조", "구조시각화"} +VALID_ROLES = {"flow", "reference"} +VALID_LAYERS = {"intro", "core", "supporting", "conclusion"} + + +def validate_stage_1a( + analysis: dict[str, Any], + clean_text: str, +) -> list[dict]: + """Stage 1A(Kei 꼭지 추출) 결과 검증. + + Args: + analysis: Kei API 반환 dict + clean_text: Stage 0에서 정규화된 텍스트 + + Returns: + 에러 리스트 (빈 리스트 = 통과) + """ + errors = [] + topics = analysis.get("topics", []) + page_struct = analysis.get("page_structure", {}) + + # ── 형식 검증 ── + + if not topics: + errors.append({ + "severity": "FATAL", + "field": "topics", + "localization": "topics가 비어있음", + "instruction": "콘텐츠에서 최소 1개 꼭지를 추출하라", + }) + return errors + + # weight 합 검증 (0.9~1.1) + total_weight = sum( + info.get("weight", 0) for info in page_struct.values() + if isinstance(info, dict) + ) + if total_weight < 0.9 or total_weight > 1.1: + errors.append({ + "severity": "RETRYABLE", + "field": "page_structure.weight", + "localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)", + "instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}", + }) + + # 본심 존재 + 본심 weight ≥ 0.3 + core_info = page_struct.get("본심", {}) + if not core_info or not isinstance(core_info, dict): + errors.append({ + "severity": "RETRYABLE", + "field": "page_structure.본심", + "localization": "본심 역할이 page_structure에 없음", + "instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.", + }) + elif core_info.get("weight", 0) < 0.3: + errors.append({ + "severity": "RETRYABLE", + "field": "page_structure.본심.weight", + "localization": f"본심 weight {core_info['weight']:.2f} < 0.3", + "instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.", + }) + + # 필수 필드 검증 + for t in topics: + tid = t.get("id", "?") + if not t.get("title"): + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].title", + "localization": f"topic {tid}에 title 없음", + "instruction": "각 topic에 title을 부여하라", + }) + if t.get("purpose") and t["purpose"] not in VALID_PURPOSES: + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].purpose", + "localization": f"topic {tid} purpose '{t['purpose']}' 유효하지 않음", + "current_value": t["purpose"], + "instruction": f"유효한 purpose: {VALID_PURPOSES}", + }) + + # page_structure의 topic_ids가 실제 topics에 존재하는지 + all_topic_ids = {t.get("id") for t in topics} + for role, info in page_struct.items(): + if not isinstance(info, dict): + continue + for tid in info.get("topic_ids", []): + if tid not in all_topic_ids: + errors.append({ + "severity": "RETRYABLE", + "field": f"page_structure.{role}.topic_ids", + "localization": f"{role}에 존재하지 않는 topic_id {tid}", + "instruction": f"topic_ids는 topics[].id에 존재하는 값만 사용하라. 현재 topics: {sorted(all_topic_ids)}", + }) + + # ── 내용 검증 (원본 대조) ── + + if clean_text: + # 원본 ## 섹션 수 vs topic 수 비교 + original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE) + if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 2: + errors.append({ + "severity": "RETRYABLE", + "field": "topics", + "localization": f"원본 ## 섹션 {len(original_sections)}개, topic {len(topics)}개 (차이 {abs(len(topics) - len(original_sections))})", + "evidence": f"원본 섹션: {[s[3:].strip()[:30] for s in original_sections]}", + "instruction": "원본의 주요 섹션이 topic에 매핑되었는지 확인하라", + }) + + # topic summary 키워드가 원본에 존재하는지 (kiwipiepy) + try: + orig_keywords = extract_keywords_kiwi(clean_text) + for t in topics: + summary = t.get("summary", "") + if not summary: + continue + summary_kw = extract_keywords_kiwi(summary) + if not summary_kw: + continue + overlap = summary_kw & orig_keywords + rate = len(overlap) / len(summary_kw) if summary_kw else 1.0 + if rate < 0.5: + missing = summary_kw - orig_keywords + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{t.get('id', '?')}].summary", + "localization": f"summary 키워드 보존율 {rate:.0%}", + "evidence": f"원본에 없는 키워드: {missing}", + "instruction": f"summary에 원본에 없는 표현을 추가하지 마라. 원본 키워드로 수정하라.", + }) + except Exception as e: + logger.warning(f"[T-2] kiwipiepy 키워드 검증 실패: {e}") + + return errors + + +# ══════════════════════════════════════ +# Stage 1B 검증 +# ══════════════════════════════════════ + +VALID_RELATION_TYPES = {"hierarchy", "cause_effect", "comparison", "sequence", "definition", "inclusion", "none"} + + +def validate_stage_1b( + topics: list[dict[str, Any]], + clean_text: str, + raw_content: str = "", +) -> list[dict]: + """Stage 1B(컨셉 구체화) 결과 검증. + + Args: + topics: Stage 1B 후 업데이트된 topics 리스트 + clean_text: Stage 0에서 정규화된 텍스트 + raw_content: 원본 MDX 전체 (popups/details 포함). 대조 범위 확장용. + + Returns: + 에러 리스트 (빈 리스트 = 통과) + """ + # 대조 범위: clean_text + raw_content (popups/details 내용 포함) + full_text = clean_text + if raw_content: + full_text = clean_text + "\n" + raw_content + errors = [] + + for t in topics: + tid = t.get("id", "?") + purpose = t.get("purpose", "") + relation_type = t.get("relation_type", "") + expression_hint = t.get("expression_hint", "") + source_data = t.get("source_data", "") + + # ── 형식 검증 ── + + if relation_type not in VALID_RELATION_TYPES: + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].relation_type", + "localization": f"topic {tid}: 유효하지 않은 relation_type '{relation_type}'", + "current_value": relation_type, + "instruction": f"유효한 relation_type: {sorted(VALID_RELATION_TYPES)}", + }) + + if not expression_hint: + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].expression_hint", + "localization": f"topic {tid}: expression_hint 비어있음", + "instruction": "expression_hint를 작성하라. 형식: 관계 선언 + 콘텐츠 설명 + 시각 지침", + }) + + # ── 모순 탐지 (결정 테이블) ── + + if purpose in CONTRADICTIONS: + if relation_type in CONTRADICTIONS[purpose]: + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].relation_type", + "localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순", + "current_value": f"purpose={purpose}, relation_type={relation_type}", + "evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가", + "instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 " + f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}", + }) + + if purpose in SOFT_WARNINGS: + if relation_type in SOFT_WARNINGS[purpose]: + logger.warning( + f"[T-2 경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 의심" + ) + + # ── 원본 대조: source_data 할루시네이션 감지 ── + # full_text 사용 (popups/details 내용 포함) + + if source_data and full_text: + try: + source_kw = extract_keywords_kiwi(source_data) + orig_kw = extract_keywords_kiwi(full_text) + if source_kw: + overlap = source_kw & orig_kw + rate = len(overlap) / len(source_kw) + if rate < 0.4: + missing = source_kw - orig_kw + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].source_data", + "localization": f"topic {tid}: source_data 키워드 보존율 {rate:.0%}", + "evidence": f"원본에 없는 키워드: {missing}", + "instruction": "source_data는 원본에 실제 존재하는 텍스트만 사용하라. 없는 출처를 만들어내지 마라.", + }) + except Exception as e: + logger.warning(f"[T-2] source_data 검증 실패: {e}") + + # ── 원본 대조: relation_type과 원본 언어 패턴 ── + # full_text 사용 (popups/details 내용 포함) + + if relation_type and relation_type != "none" and full_text: + evidence = detect_relation_evidence(full_text) + claimed_count = evidence.get(relation_type, 0) + + if claimed_count == 0: + # 주장한 관계의 증거가 0개 + alternatives = [(k, v) for k, v in evidence.items() if v >= 2] + alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3]) + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].relation_type", + "localization": f"topic {tid}: '{relation_type}' 증거 0개", + "evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음", + "instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.", + }) + + return errors diff --git a/templates/catalog.yaml b/templates/catalog.yaml index 9b65e61..c5de5a7 100644 --- a/templates/catalog.yaml +++ b/templates/catalog.yaml @@ -1,14 +1,5 @@ version: '4.0' -# Phase Q: min_height_px, relation_types, category, min_items, max_items -# Phase R: variants[] — 블록 변형. 기존 CSS를 유지하면서 내부 구조만 변경. -# variants[].id: 변형 ID (default = 기존 블록 그대로) -# variants[].description: 변형 설명 -# variants[].template: 변형 전용 템플릿 경로 (없으면 기존 template 사용) -# variants[].when: 이 변형이 적합한 상황 blocks: -# ═══════════════════════════════════════ -# HEADERS (5개) — 꼭지/섹션 제목용 -# ═══════════════════════════════════════ - id: section-title-with-bg name: 배경 이미지 타이틀 category: headers @@ -17,13 +8,38 @@ blocks: min_height_px: 300 relation_types: [] visual: 전체 너비 배경 이미지(파란 그라데이션+웨이브) 위에 흰색 영문 소제목(15px) + 한글 대제목(35px). 높이 약 500px. - when: '자세히보기(detail) 페이지의 맨 첫 화면 전용. 배경 이미지 위에 타이틀을 올려 페이지 주제를 시각적으로 강렬하게 선언할 때.' - not_for: '일반 슬라이드 내부 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → topic-center. 높이 200px 이하 → section-header-bar.' + when: 자세히보기(detail) 페이지의 맨 첫 화면 전용. 배경 이미지 위에 타이틀을 올려 페이지 주제를 시각적으로 강렬하게 선언할 때. + not_for: 일반 슬라이드 내부 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → + topic-center. 높이 200px 이하 → section-header-bar. purpose_fit: [] slots: - required: [title_ko] - optional: [title_en, breadcrumb, bg_image] - + required: + - title_ko + optional: + - title_en + - breadcrumb + - bg_image + schema: + title_ko: + max_lines: 2 + font_size: 35 + ref_chars: + body: 20 + note: 35px bold white, 대제목 + title_en: + max_lines: 1 + font_size: 15 + ref_chars: + body: 30 + note: 15px white, 영문 소제목 + breadcrumb: + max_lines: 1 + font_size: 13 + ref_chars: + body: 40 + note: 13px, 상단 경로 + padding_overhead_px: 0 + padding_h_px: 60 - id: section-header-bar name: 섹션 헤더 바 category: headers @@ -32,16 +48,31 @@ blocks: min_height_px: 40 relation_types: [] visual: 전체 너비 파란 배경 바(~50px) + 중앙 흰색 제목. 섹션 구분용. 컴팩트. - when: '같은 페이지 안에서 주제 전환이 필요할 때. 높이 예산이 적을 때 섹션 구분.' - not_for: '페이지 전체 타이틀 → section-title-with-bg. 꼭지별 소제목 → topic-left-right 또는 topic-numbered.' + when: 같은 페이지 안에서 주제 전환이 필요할 때. 높이 예산이 적을 때 섹션 구분. + not_for: 페이지 전체 타이틀 → section-title-with-bg. 꼭지별 소제목 → topic-left-right 또는 topic-numbered. purpose_fit: [] slots: - required: [title] - optional: [subtitle] + required: + - title + optional: + - subtitle schema: - title: {max_lines: 1, font_size: 18, ref_chars: {body: 25, sidebar: 20}, note: '18px bold white, 중앙정렬'} - subtitle: {max_lines: 1, font_size: 13, ref_chars: {body: 40, sidebar: 30}, note: '13px, 1줄'} - + title: + max_lines: 1 + font_size: 18 + ref_chars: + body: 25 + sidebar: 20 + note: 18px bold white, 중앙정렬 + subtitle: + max_lines: 1 + font_size: 13 + ref_chars: + body: 40 + sidebar: 30 + note: 13px, 1줄 + padding_overhead_px: 28 + padding_h_px: 32 - id: topic-left-right name: 좌우 꼭지 헤더 category: headers @@ -50,16 +81,44 @@ blocks: min_height_px: 50 relation_types: [] visual: 좌측에 파란 굵은 제목(24px, 240px 고정) + 우측에 본문 설명(16px). 가로 2단. - when: '좌측에 핵심 주장/질문, 우측에 근거/설명을 배치하는 구조. 문제 제기의 도입부로 적합. 예: "용어의 혼용" + "DX와 BIM이 혼용되고 있다..."' - not_for: '중앙 정렬 대제목 → topic-center. 번호가 붙은 순서형 → topic-numbered. 섹션 전체 타이틀 → section-title-with-bg.' - purpose_fit: [문제제기] + visual_diff: '유사 블록과의 차이: + + - topic-center: 중앙 정렬 대제목(26px) + 서브타이틀 + 설명. 단독 강조형. 좌우 분리 없음 + + - topic-numbered: 번호 원형(①②③) + 제목 + 구분선 + 설명. 순서형 세로 배치 + + - 이 블록: 좌측 240px 고정폭에 파란 굵은 제목 + 우측에 본문 설명. 가로 2단 배치 + + 적합: 좌측에 핵심 주장/키워드, 우측에 근거/설명을 대비시킬 때. 문제 제기 도입부 + + 부적합: 중앙 강조 → topic-center, 순서형 번호 → topic-numbered + + ' + when: '좌측에 핵심 주장/질문, 우측에 근거/설명을 배치하는 구조. 문제 제기의 도입부로 적합. 예: "용어의 혼용" + "DX와 BIM이 + 혼용되고 있다..."' + not_for: 중앙 정렬 대제목 → topic-center. 번호가 붙은 순서형 → topic-numbered. 섹션 전체 타이틀 → section-title-with-bg. + purpose_fit: + - 문제제기 slots: - required: [title, description] + required: + - title + - description optional: [] schema: - title: {max_lines: 2, font_size: 24, ref_chars: {body: 20}, note: '24px bold, 240px 고정폭'} - description: {max_lines: 2, font_size: 16, ref_chars: {body: 100}, note: '16px, 510px 너비'} - + title: + max_lines: 2 + font_size: 24 + ref_chars: + body: 20 + note: 24px bold, 240px 고정폭 + description: + max_lines: 2 + font_size: 16 + ref_chars: + body: 100 + note: 16px, 510px 너비 + padding_overhead_px: 24 + padding_h_px: 40 - id: topic-center name: 중앙 정렬 꼭지 헤더 category: headers @@ -68,17 +127,52 @@ blocks: min_height_px: 60 relation_types: [] visual: 중앙 정렬 대제목(26px 굵게) + 파란 서브타이틀 + 하단 설명. 단독 강조. - when: '하나의 주제를 페이지 중심에 크게 선언할 때. sidebar 영역의 섹션 라벨로도 사용 가능.' - not_for: '좌:제목 우:설명 구조 → topic-left-right. 번호 순서 → topic-numbered.' + visual_diff: '유사 블록과의 차이: + + - topic-left-right: 좌측 제목(240px) + 우측 설명. 가로 2단 분리. 주장+근거 구조 + + - topic-numbered: 번호 원형 + 제목 + 구분선 + 설명. 순서형 세로 배치 + + - 이 블록: 중앙 정렬 대제목(26px) + 파란 서브타이틀 + 설명. 단독으로 주제를 크게 선언 + + 적합: 하나의 주제를 페이지 중심에 크게 선언. sidebar 섹션 라벨. 좌우 분리 불필요할 때 + + 부적합: 좌:제목 우:설명 대비 → topic-left-right, 순서형 → topic-numbered + + ' + when: 하나의 주제를 페이지 중심에 크게 선언할 때. sidebar 영역의 섹션 라벨로도 사용 가능. + not_for: 좌:제목 우:설명 구조 → topic-left-right. 번호 순서 → topic-numbered. purpose_fit: [] slots: - required: [title] - optional: [subtitle, description] + required: + - title + optional: + - subtitle + - description schema: - title: {max_lines: 1, font_size: 26, ref_chars: {body: 25, sidebar: 20}, note: '26px bold'} - subtitle: {max_lines: 1, font_size: 14, ref_chars: {body: 40, sidebar: 30}, note: '14px accent'} - description: {max_lines: 3, font_size: 16, ref_chars: {body: 120, sidebar: 80}, note: '16px'} - + title: + max_lines: 1 + font_size: 26 + ref_chars: + body: 25 + sidebar: 20 + note: 26px bold + subtitle: + max_lines: 1 + font_size: 14 + ref_chars: + body: 40 + sidebar: 30 + note: 14px accent + description: + max_lines: 3 + font_size: 16 + ref_chars: + body: 120 + sidebar: 80 + note: 16px + padding_overhead_px: 40 + padding_h_px: 0 - id: topic-numbered name: 번호 꼭지 헤더 category: headers @@ -87,16 +181,52 @@ blocks: min_height_px: 45 relation_types: [] visual: 파란 원형 번호(①②③) + 굵은 제목 + 파란 구분선 + 설명. 세로 배치. - when: '순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션.' - not_for: '순서 없는 꼭지 → topic-left-right 또는 topic-center. 카드 안의 순서 → card-numbered.' + visual_diff: '유사 블록과의 차이: + + - topic-left-right: 좌측 제목 + 우측 설명. 가로 2단. 번호 없음 + + - topic-center: 중앙 정렬 대제목 + 서브타이틀. 번호 없이 단독 강조 + + - 이 블록: 파란 원형 번호(①②③) + 굵은 제목 + 파란 구분선 + 설명. 세로 배치. 순서형 꼭지 전용 + + 적합: 순서가 있는 꼭지(1번, 2번, 3번). 단계별 섹션 시작점 + + 부적합: 순서 없는 꼭지 → topic-left-right/topic-center, 카드 안의 순서 → card-numbered + + ' + when: 순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션. + not_for: 순서 없는 꼭지 → topic-left-right 또는 topic-center. 카드 안의 순서 → card-numbered. purpose_fit: [] slots: - required: [number, title] - optional: [description, color] - -# ═══════════════════════════════════════ -# CARDS (9개) — 항목 나열/비교용 -# ═══════════════════════════════════════ + required: + - number + - title + optional: + - description + - color + schema: + number: + max_lines: 1 + font_size: 16 + ref_chars: + body: 2 + note: 16px, 36px 원 안의 번호 + title: + max_lines: 1 + font_size: 20 + ref_chars: + body: 25 + sidebar: 18 + note: 20px bold + description: + max_lines: 2 + font_size: 15 + ref_chars: + body: 80 + sidebar: 50 + note: 15px, line-height 1.7 + padding_overhead_px: 28 + padding_h_px: 40 - id: card-image-3col name: 이미지 카드 3열 category: cards @@ -107,13 +237,67 @@ blocks: min_items: 2 max_items: 3 visual: 3열 카드. 각 카드 상단에 이미지(160px) + 하단에 색상 밑줄 제목 + 불릿 목록. - when: '이미지가 핵심인 항목 3개를 나란히. 예: 설계단계(3D모델) / 시공단계(현장) / 유지관리(자산).' - not_for: '이미지 없이 텍스트만 → card-icon-desc. 키워드+짧은 설명만 → card-dark-overlay. 2개 비교 → compare-pill-pair.' - purpose_fit: [핵심전달, 근거사례] - slots: - required: ['cards[]'] - optional: [] + visual_diff: '유사 블록과의 차이: + - card-icon-desc: 이모지 아이콘 + 제목 + 설명. 이미지 없이 아이콘만 사용 + + - card-dark-overlay: 다크 배경 이미지 위 그라데이션 + 흰 텍스트. 짧은 키워드 강조 + + - card-numbered: 번호 원형 + 제목 + 설명. 이미지 없고 순서 중심 + + - card-stat-number: 큰 숫자 + 단위 + 라벨. 수치 데이터 전용 + + - card-tag-image: 좌상단 색상 태그 + 이미지. 카테고리 태그가 핵심 구분자 + + - 이 블록: 상단에 실제 이미지(160px 높이) + 색상 밑줄 제목 + 불릿 목록. 이미지가 콘텐츠의 핵심 + + 적합: 이미지(사진/도표)가 각 항목의 핵심이고, 하단에 불릿으로 세부 설명이 필요할 때 + + 부적합: 이미지 없음 → card-icon-desc, 카테고리 태그 분류 → card-tag-image, 키워드만 → card-dark-overlay + + ' + when: '이미지가 핵심인 항목 3개를 나란히. 예: 설계단계(3D모델) / 시공단계(현장) / 유지관리(자산).' + not_for: 이미지 없이 텍스트만 → card-icon-desc. 키워드+짧은 설명만 → card-dark-overlay. 2개 비교 → compare-pill-pair. + purpose_fit: + - 핵심전달 + - 근거사례 + slots: + required: + - cards[] + optional: [] + schema: + card_title: + max_lines: 1 + font_size: 14 + ref_chars: + body: 15 + note: 14px bold, 색상 밑줄 + card_title_en: + max_lines: 1 + font_size: 12 + ref_chars: + body: 20 + note: 12px, 영문 부제 + bullet_item: + max_lines: 1 + font_size: 13 + ref_chars: + body: 40 + note: 13px, line-height 1.7 + max_bullets_per_card: + body: 4 + note: 카드당 불릿 수 + source: + max_lines: 1 + font_size: 11 + ref_chars: + body: 25 + note: 11px, 하단 출처 + max_cards: + body: 3 + note: 카드 수 + padding_overhead_px: 16 + padding_h_px: 0 - id: card-dark-overlay name: 다크 오버레이 카드 category: cards @@ -124,18 +308,53 @@ blocks: min_items: 3 max_items: 5 visual: 3~5열 카드. 다크 배경 이미지 + 그라데이션 오버레이 + 흰색 굵은 제목 + 짧은 설명. + visual_diff: '유사 블록과의 차이: + + - card-icon-desc: 밝은 배경 + 이모지 아이콘 + 설명(3줄). 정보 전달 중심 + + - card-image-3col: 밝은 배경 + 상단 이미지 + 색상 밑줄 제목 + 불릿. 이미지 콘텐츠 중심 + + - card-numbered: 밝은 배경 + 번호 원형 + 설명. 순서 나열 + + - card-stat-number: 큰 숫자 강조. 수치 데이터 전용 + + - card-tag-image: 밝은 배경 + 색상 태그 + 이미지. 카테고리 분류 + + - 이 블록: 다크 배경 이미지 위에 그라데이션 오버레이 + 흰색 텍스트. 시각적 임팩트 극대화. 설명 2줄 이내 + + 적합: 키워드를 시각적으로 강렬하게 강조. 짧은 텍스트(제목+1~2줄)만 필요할 때 + + 부적합: 긴 설명(3줄+) → card-icon-desc, 이미지가 콘텐츠 → card-image-3col, 순서 → card-numbered + + ' when: '키워드를 시각적으로 강조할 때. 짧은 설명(2줄 이내)과 함께. 예: 협업지원 / 오류감소 / 생산성향상.' - not_for: '긴 설명(3줄+) → card-icon-desc. 이미지가 크게 보여야 함 → card-image-3col. 순서/단계 → process-horizontal.' - purpose_fit: [핵심전달, 구조시각화] + not_for: 긴 설명(3줄+) → card-icon-desc. 이미지가 크게 보여야 함 → card-image-3col. 순서/단계 → process-horizontal. + purpose_fit: + - 핵심전달 + - 구조시각화 zone: full-width-only slots: - required: ['cards[]'] + required: + - cards[] optional: [] schema: - card_title: {max_lines: 1, font_size: 18, ref_chars: {body: 15}, note: '18px bold white, 1줄'} - card_description: {max_lines: 2, font_size: 12, ref_chars: {body: 30}, note: '12px white, 1~2줄'} - max_cards: {body: 5, note: '카드 수'} - + card_title: + max_lines: 1 + font_size: 18 + ref_chars: + body: 15 + note: 18px bold white, 1줄 + card_description: + max_lines: 2 + font_size: 12 + ref_chars: + body: 30 + note: 12px white, 1~2줄 + max_cards: + body: 5 + note: 카드 수 + padding_overhead_px: 32 + padding_h_px: 40 - id: card-tag-image name: 태그 이미지 카드 category: cards @@ -146,85 +365,221 @@ blocks: min_items: 2 max_items: 3 visual: 3열 카드. 좌상단 색상 태그 라벨 + 이미지 + 제목 + 설명. - when: '카테고리 태그로 분류가 핵심일 때. 예: 제조업(파란) / 건축(초록) / 인프라(빨간).' - not_for: '태그 불필요 → card-image-3col. 이미지 없음 → card-icon-desc.' - purpose_fit: [핵심전달] - slots: - required: ['cards[]'] - optional: [] + visual_diff: '유사 블록과의 차이: + - card-icon-desc: 이모지 아이콘 + 제목 + 설명. 태그/이미지 없음 + + - card-image-3col: 상단 이미지 + 색상 밑줄 제목 + 불릿. 태그 없이 이미지가 핵심 + + - card-dark-overlay: 다크 배경 이미지 + 흰 텍스트. 키워드 강조용 + + - card-numbered: 번호 원형 + 제목 + 설명. 순서형 + + - card-stat-number: 큰 숫자 + 단위. 수치 전용 + + - 이 블록: 좌상단에 색상 태그 라벨이 카드를 분류. 태그 색상으로 카테고리 시각 구분 + 이미지 + 제목 + + 적합: 카테고리 태그로 분류하는 것이 핵심일 때. 업종/유형별 색상 구분이 필요한 경우 + + 부적합: 태그 불필요 → card-image-3col, 이미지 없음 → card-icon-desc, 수치 → card-stat-number + + ' + when: '카테고리 태그로 분류가 핵심일 때. 예: 제조업(파란) / 건축(초록) / 인프라(빨간).' + not_for: 태그 불필요 → card-image-3col. 이미지 없음 → card-icon-desc. + purpose_fit: + - 핵심전달 + slots: + required: + - cards[] + optional: [] + schema: + tag: + max_lines: 1 + font_size: 12 + ref_chars: + body: 8 + note: 12px bold white, 좌상단 태그 + card_title: + max_lines: 1 + font_size: 14 + ref_chars: + body: 15 + note: 14px bold + card_description: + max_lines: 2 + font_size: 13 + ref_chars: + body: 40 + note: 13px + max_cards: + body: 3 + note: 카드 수 + padding_overhead_px: 14 + padding_h_px: 0 - id: card-icon-desc name: 아이콘 설명 카드 category: cards template: blocks/cards/card-icon-desc.html height_cost: medium min_height_px: 120 - relation_types: [definition] + relation_types: + - definition min_items: 2 max_items: 4 variants: - - id: default - description: 아이콘 + 제목 + 설명 (기본 그리드) - - id: compact - description: 아이콘 축소, 설명 2줄 제한, 패딩 축소 (높이 부족 시) - template: blocks/cards/card-icon-desc--compact.html - when: "컨테이너 높이가 150px 미만일 때" + - id: default + description: 아이콘 + 제목 + 설명 (기본 그리드) + - id: compact + description: 아이콘 축소, 설명 2줄 제한, 패딩 축소 (높이 부족 시) + template: blocks/cards/card-icon-desc--compact.html + when: 컨테이너 높이가 150px 미만일 때 visual: 2~4열. 중앙 큰 이모지 아이콘(2.5rem) + 굵은 제목 + 설명. 밝은 배경. + visual_diff: '유사 블록과의 차이: + + - card-image-3col: 상단에 실제 이미지(160px) + 색상 밑줄 제목 + 불릿 목록 + + - card-dark-overlay: 다크 배경 이미지 위 그라데이션 + 흰 텍스트. 키워드 강조용 + + - card-numbered: 번호 원형(①②③) + 제목 + 설명. 순서 있는 나열 + + - card-stat-number: 큰 숫자(36px) + 단위 + 라벨. 수치 데이터 전용 + + - card-tag-image: 좌상단 색상 태그 + 이미지 + 제목. 카테고리 분류 강조 + + - 이 블록: 이모지 아이콘(2.5rem)이 중앙에 크게 + 제목 + 설명. 밝은 배경 그리드. 이미지 없이 아이콘으로 시각 구분 + + 적합: 이미지 없이 독립적 개념/특성을 나열. 순서 없고 아이콘으로 직관적 구분이 가능할 때 + + 부적합: 실제 사진 필요 → card-image-3col, 순서 번호 → card-numbered, 수치 → card-stat-number + + ' when: '독립적인 항목/개념/특성을 이모지 아이콘과 함께 나열. 순서 없는 개별 항목. 예: 🔧기술기반 / 💻S/W역량 / 🌏여건조성.' - not_for: '이미지(사진) 필요 → card-image-3col. 순서 번호 필요 → card-numbered. 텍스트만(아이콘 불필요) → dark-bullet-list.' - purpose_fit: [핵심전달, 근거사례, 구조시각화] + not_for: 이미지(사진) 필요 → card-image-3col. 순서 번호 필요 → card-numbered. 텍스트만(아이콘 불필요) → + dark-bullet-list. + purpose_fit: + - 핵심전달 + - 근거사례 + - 구조시각화 zone: full-width-only slots: - required: ['cards[]'] + required: + - cards[] optional: [] schema: - card_title: {max_lines: 1, font_size: 15, ref_chars: {body: 10}, note: '15px bold, 1줄'} - card_description: {max_lines: 3, font_size: 13, ref_chars: {body: 60}, note: '13px, 3줄 이내'} - max_cards: {body: 4, note: '카드 수 (3열 grid)'} - + card_title: + max_lines: 1 + font_size: 15 + ref_chars: + body: 10 + note: 15px bold, 1줄 + card_description: + max_lines: 3 + font_size: 13 + ref_chars: + body: 60 + note: 13px, 3줄 이내 + max_cards: + body: 4 + note: 카드 수 (3열 grid) + padding_overhead_px: 40 + padding_h_px: 32 - id: card-compare-3col name: 3단 비교 카드 category: cards template: blocks/cards/card-compare-3col.html height_cost: large min_height_px: 200 - relation_types: [comparison] + relation_types: + - comparison min_items: 3 max_items: 3 visual: 3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿 목록. + visual_diff: '유사 블록과의 차이: + + - compare-2col-split: 표 형식 2단 비교, 중앙 기준 라벨. 2개 대상 전용 + + - compare-3col-badge: 표 형식 3열, VS 배지. 행 단위 비교 + + - comparison-2col: 좌우 텍스트 블록, 2개 대비. 표/카드 아님 + + - compare-pill-pair: 둥근 박스 2개 + VS. 헤더 역할만 + + - 이 블록: 3개 독립 카드가 나란히. 카드마다 고유 색상 헤더+이미지+불릿. 카드형 비교 + + 적합: 3개 카테고리를 각각 독립적으로 설명하면서 비교. 카테고리별 색상 구분이 필요할 때 + + 부적합: 2개 비교 → comparison-2col/compare-pill-pair, 행별 대조 → compare-3col-badge + + ' when: '3개 카테고리를 비교할 때. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강).' - not_for: '2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge.' - purpose_fit: [핵심전달] + not_for: 2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge. + purpose_fit: + - 핵심전달 zone: full-width-only slots: - required: ['cards[]'] + required: + - cards[] optional: [] schema: - card_title: {max_lines: 1, font_size: 15, ref_chars: {body: 15}, note: '15px bold white, 1줄'} - bullet_item: {max_lines: 1, font_size: 13, ref_chars: {body: 40}, note: '13px, 불릿 1개당'} - max_bullets_per_card: {body: 5, note: '카드당 불릿 수'} - + card_title: + max_lines: 1 + font_size: 15 + ref_chars: + body: 15 + note: 15px bold white, 1줄 + bullet_item: + max_lines: 1 + font_size: 13 + ref_chars: + body: 40 + note: 13px, 불릿 1개당 + max_bullets_per_card: + body: 5 + note: 카드당 불릿 수 + padding_overhead_px: 26 + padding_h_px: 0 - id: card-step-vertical name: 세로 단계 카드 category: cards template: blocks/cards/card-step-vertical.html height_cost: xlarge min_height_px: 250 - relation_types: [sequence] + relation_types: + - sequence min_items: 2 max_items: 4 visual: 세로 나열. 좌측 색상 마커(단계명) + 우측 콘텐츠 박스(제목+이미지+설명). 연결선. when: '생애주기/프로세스 단계별 설명. 각 단계에 이미지+상세 설명. 예: 설계→시공→운영 단계.' - not_for: '가로 흐름(간단) → process-horizontal. 높이 예산 부족 → card-numbered. 독립 사례(순서 아님) → card-icon-desc.' - purpose_fit: [핵심전달, 구조시각화] + not_for: 가로 흐름(간단) → process-horizontal. 높이 예산 부족 → card-numbered. 독립 사례(순서 아님) + → card-icon-desc. + purpose_fit: + - 핵심전달 + - 구조시각화 slots: - required: ['steps[]'] + required: + - steps[] optional: [] schema: - step_title: {max_lines: 1, font_size: 16, ref_chars: {body: 15, sidebar: 12}, note: '16px bold'} - step_description: {max_lines: 3, font_size: 14, ref_chars: {body: 60, sidebar: 40}, note: '14px, 2~3줄'} - max_steps: {body: 4, sidebar: 3, note: '단계 수'} - + step_title: + max_lines: 1 + font_size: 16 + ref_chars: + body: 15 + sidebar: 12 + note: 16px bold + step_description: + max_lines: 3 + font_size: 14 + ref_chars: + body: 60 + sidebar: 40 + note: 14px, 2~3줄 + max_steps: + body: 4 + sidebar: 3 + note: 단계 수 + padding_overhead_px: 24 + padding_h_px: 0 - id: card-image-round name: 원형 이미지 카드 category: cards @@ -235,13 +590,31 @@ blocks: min_items: 2 max_items: 3 visual: 2~3열. 원형 이미지(140px, 테두리+그림자) + 제목 + 설명. 중앙 정렬. - when: '포트폴리오형 나열. 비전/가치 표현. 원형 이미지가 있는 경우.' - not_for: '사각형 이미지 → card-image-3col. 이미지 없음 → card-icon-desc.' + when: 포트폴리오형 나열. 비전/가치 표현. 원형 이미지가 있는 경우. + not_for: 사각형 이미지 → card-image-3col. 이미지 없음 → card-icon-desc. purpose_fit: [] slots: - required: ['cards[]'] + required: + - cards[] optional: [] - + schema: + card_title: + max_lines: 1 + font_size: 15 + ref_chars: + body: 12 + note: 15px bold, 중앙정렬 + card_description: + max_lines: 2 + font_size: 13 + ref_chars: + body: 40 + note: 13px, max-width 200px + max_cards: + body: 3 + note: 카드 수 + padding_overhead_px: 12 + padding_h_px: 0 - id: card-stat-number name: 통계 숫자 카드 category: cards @@ -252,77 +625,234 @@ blocks: min_items: 2 max_items: 4 visual: 2~4열. 매우 큰 숫자(36px, 색상) + 단위 + 라벨 + 설명. - when: 'KPI, 성과 수치, 달성률, 비용 절감율 등 숫자가 핵심인 데이터. 예: 30% 절감 / 220명+.' - not_for: '숫자가 아닌 텍스트 항목 → card-icon-desc. 비교 구조 → compare-3col-badge.' - purpose_fit: [핵심전달, 근거사례] - slots: - required: ['stats[]'] - optional: [] + visual_diff: '유사 블록과의 차이: + - card-icon-desc: 이모지 아이콘 + 제목 + 설명. 텍스트 중심의 개념 나열 + + - card-image-3col: 상단 이미지 + 밑줄 제목 + 불릿. 이미지 콘텐츠 중심 + + - card-dark-overlay: 다크 배경 + 흰 텍스트. 키워드 강조 + + - card-numbered: 번호 원형 + 제목 + 설명. 순서형 나열 + + - card-tag-image: 색상 태그 + 이미지. 카테고리 분류 + + - 이 블록: 숫자가 36px로 매우 크게 표시 + 색상 강조 + 단위 + 라벨. 수치 데이터 시각화 전용 + + 적합: KPI, 성과 수치, 달성률 등 숫자 자체가 핵심 메시지인 경우 + + 부적합: 텍스트 설명 중심 → card-icon-desc, 순서 나열 → card-numbered, 이미지 → card-image-3col + + ' + when: 'KPI, 성과 수치, 달성률, 비용 절감율 등 숫자가 핵심인 데이터. 예: 30% 절감 / 220명+.' + not_for: 숫자가 아닌 텍스트 항목 → card-icon-desc. 비교 구조 → compare-3col-badge. + purpose_fit: + - 핵심전달 + - 근거사례 + slots: + required: + - stats[] + optional: [] + schema: + number: + max_lines: 1 + font_size: 36 + ref_chars: + body: 5 + note: 36px bold 색상, 핵심 숫자 + unit: + max_lines: 1 + font_size: 18 + ref_chars: + body: 5 + note: 18px, 숫자 옆 단위 + label: + max_lines: 1 + font_size: 14 + ref_chars: + body: 10 + note: 14px bold, 항목명 + description: + max_lines: 1 + font_size: 12 + ref_chars: + body: 20 + note: 12px, 부연 설명 + max_stats: + body: 4 + note: 통계 항목 수 + padding_overhead_px: 40 + padding_h_px: 24 - id: card-numbered name: 번호 항목 카드 category: cards template: blocks/cards/card-numbered.html height_cost: medium min_height_px: 55 - relation_types: [definition] + relation_types: + - definition min_items: 1 max_items: 5 variants: - - id: default - description: 번호 + 제목 + 설명 (세로 나열) - - id: horizontal - description: 항목을 가로 2열로 배치 (사례 비교, 같은 구조 항목 나란히) - template: blocks/cards/card-numbered--horizontal.html - when: "같은 구조의 항목 2-3개를 나란히 비교할 때" + - id: default + description: 번호 + 제목 + 설명 (세로 나열) + - id: horizontal + description: 항목을 가로 2열로 배치 (사례 비교, 같은 구조 항목 나란히) + template: blocks/cards/card-numbered--horizontal.html + when: 같은 구조의 항목 2-3개를 나란히 비교할 때 visual: 세로 나열. 색상 원형 번호(①②③) + 제목 + 설명. 밝은 배경 카드. - when: '번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 2.BIM 3.DX). 조건/요구사항 나열.' - not_for: '순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal.' - purpose_fit: [용어정의, 핵심전달] - slots: - required: ['items[]'] - optional: [] + visual_diff: '유사 블록과의 차이: -# ═══════════════════════════════════════ -# TABLES (3개) — 비교표/데이터 표 -# ═══════════════════════════════════════ + - card-icon-desc: 이모지 아이콘 + 제목 + 설명 그리드. 순서 없이 독립 항목 나열 + + - card-image-3col: 상단 이미지 + 색상 밑줄 제목 + 불릿. 이미지 중심 + + - card-dark-overlay: 다크 배경 이미지 + 흰 텍스트. 키워드 강조 + + - card-stat-number: 큰 숫자(36px) + 단위. 수치 데이터 전용 + + - card-tag-image: 좌상단 색상 태그 + 이미지. 카테고리 분류 + + - 이 블록: 색상 원형 번호(①②③)가 핵심 구분자. 세로 나열. 순서/정의 목록에 최적 + + 적합: 번호가 의미 있는 순서형 나열 또는 번호로 구분되는 정의 목록. sidebar에도 적합 + + 부적합: 순서 없는 독립 항목 → card-icon-desc, 이미지 필요 → card-image-3col, 수치 → card-stat-number + + ' + when: 번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 + 2.BIM 3.DX). 조건/요구사항 나열. + not_for: 순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal. + purpose_fit: + - 용어정의 + - 핵심전달 + slots: + required: + - items[] + optional: [] + schema: + item_title: + max_lines: 1 + font_size: 15 + ref_chars: + body: 15 + sidebar: 12 + note: 15px bold + item_description: + max_lines: 2 + font_size: 13 + ref_chars: + body: 60 + sidebar: 40 + note: 13px + max_items: + body: 5 + sidebar: 4 + note: 항목 수 + padding_overhead_px: 22 + padding_h_px: 32 - id: compare-3col-badge name: VS 배지 비교표 category: tables template: blocks/tables/compare-3col-badge.html height_cost: large min_height_px: 150 - relation_types: [comparison] + relation_types: + - comparison visual: 3단 테이블. 좌(하늘색 헤더) | 중앙(파란 VS 배지) | 우(파란 헤더). 행별 비교. - when: '두 개념의 다항목 비교(5행 이상). 구분 기준(중앙)을 두고 좌우로 비교. 예: BIM vs DX — S/W, 프로세스, 성과물 비교.' - not_for: '시각적 대비(짧음) → compare-pill-pair. 2단 분할 → compare-2col-split. 범용 데이터 → table-simple-striped. A vs B 간단 비교(2~3행) → comparison-2col.' - purpose_fit: [핵심전달] + visual_diff: '유사 블록과의 차이: + + - compare-2col-split: 중앙에 ''기준 라벨'' 열이 있는 3열 표. 비교 기준이 행마다 명시됨 + + - comparison-2col: 표가 아닌 좌우 텍스트 블록. CSS var로 색상 구분 + + - compare-pill-pair: 둥근 박스 2개 + VS. 비교 헤더 역할만 수행 + + - card-compare-3col: 3개 독립 카드로 비교. 카드별 색상 헤더+이미지+불릿 + + - 이 블록: VS 배지가 중앙 열에 고정된 3열 표. 좌/우 헤더 색상이 다르고 행 단위로 대조 + + 적합: 두 개념의 다항목(5행+) 체계적 비교. 비교 기준 없이 좌/우 값만 대조할 때 + + 부적합: 비교 기준 라벨 필요 → compare-2col-split, 3개 카테고리 비교 → card-compare-3col + + ' + when: '두 개념의 다항목 비교(5행 이상). 구분 기준(중앙)을 두고 좌우로 비교. 예: BIM vs DX — S/W, 프로세스, 성과물 + 비교.' + not_for: 시각적 대비(짧음) → compare-pill-pair. 2단 분할 → compare-2col-split. 범용 데이터 → table-simple-striped. + A vs B 간단 비교(2~3행) → comparison-2col. + purpose_fit: + - 핵심전달 slots: - required: ['headers[]', 'rows[][]'] + required: + - headers[] + - rows[][] optional: [] schema: - cell: {max_lines: 2, font_size: 13, ref_chars: {body: 30, sidebar: 20}, note: '13px, 셀당 1~2줄'} - max_rows: {body: 7, sidebar: 5, note: '헤더 제외 행 수'} - + cell: + max_lines: 2 + font_size: 13 + ref_chars: + body: 30 + sidebar: 20 + note: 13px, 셀당 1~2줄 + max_rows: + body: 7 + sidebar: 5 + note: 헤더 제외 행 수 + padding_overhead_px: 28 + padding_h_px: 0 - id: compare-2col-split name: 2단 분할 비교표 category: tables template: blocks/tables/compare-2col-split.html height_cost: large min_height_px: 150 - relation_types: [comparison] + relation_types: + - comparison visual: 파란 헤더(좌/구분/우) + 행별 좌:항목 | 중앙:기준 라벨(파란) | 우:항목. 상세 비교. - when: '두 기술/개념의 항목별 상세 비교. 중앙에 비교 기준 라벨. 예: DX vs BIM — 정의/범위/역할 비교. 원본에 이미 비교표 데이터가 있을 때.' - not_for: 'VS 배지 → compare-3col-badge. 범용 데이터 → table-simple-striped. 간단 A vs B(2~3항목) → comparison-2col.' - purpose_fit: [핵심전달] + visual_diff: '유사 블록과의 차이: + + - compare-3col-badge: 3열 표에 VS 배지가 중앙에 있고 행별 비교. 헤더가 좌/우만 구분 + + - comparison-2col: CSS var 기반 좌우 텍스트 비교. 표가 아니라 자유 텍스트 블록 + + - compare-pill-pair: 둥근 박스 2개 + VS. 비교 헤더 역할만 하고 세부 항목 없음 + + - card-compare-3col: 3개 독립 카드로 비교. 각 카드에 색상 헤더+불릿 + + - 이 블록: 중앙에 ''기준 라벨'' 열이 있는 3열 표. 행마다 비교 기준(정의/범위/역할 등)이 명시됨 + + 적합: 비교 기준이 명확하고 항목별(5행+) 상세 대조가 필요한 경우 + + 부적합: 간단한 2~3항목 비교 → comparison-2col, 시각적 대비만 → compare-pill-pair + + ' + when: '두 기술/개념의 항목별 상세 비교. 중앙에 비교 기준 라벨. 예: DX vs BIM — 정의/범위/역할 비교. 원본에 이미 비교표 + 데이터가 있을 때.' + not_for: VS 배지 → compare-3col-badge. 범용 데이터 → table-simple-striped. 간단 A vs B(2~3항목) + → comparison-2col. + purpose_fit: + - 핵심전달 zone: full-width-only slots: - required: [left_title, right_title, 'rows[]'] + required: + - left_title + - right_title + - rows[] optional: [] schema: - cell: {max_lines: 1, font_size: 13, ref_chars: {body: 30}, note: '13px, 셀당'} - max_rows: {body: 7, note: '행 수'} - + cell: + max_lines: 1 + font_size: 13 + ref_chars: + body: 30 + note: 13px, 셀당 + max_rows: + body: 7 + note: 행 수 + padding_overhead_px: 24 + padding_h_px: 0 - id: table-simple-striped name: 범용 줄무늬 테이블 category: tables @@ -332,32 +862,93 @@ blocks: relation_types: [] visual: 진한 남색 헤더 + 줄무늬 행 교차. 첫 열 굵은 글씨. 범용 데이터 표. when: '비교가 아닌 일반 데이터 표. 스펙표, 일정표, 항목 목록. 예: 구분/현재/목표/비고.' - not_for: 'A vs B 비교 → compare-3col-badge 또는 compare-2col-split.' - purpose_fit: [핵심전달, 근거사례] + not_for: A vs B 비교 → compare-3col-badge 또는 compare-2col-split. + purpose_fit: + - 핵심전달 + - 근거사례 slots: - required: ['headers[]', 'rows[][]'] + required: + - headers[] + - rows[][] optional: [] - -# ═══════════════════════════════════════ -# VISUALS (6개) — 시각화/다이어그램 -# ═══════════════════════════════════════ + schema: + header_cell: + max_lines: 1 + font_size: 13 + ref_chars: + body: 15 + sidebar: 10 + note: 13px bold white, 남색 배경 + body_cell: + max_lines: 2 + font_size: 13 + ref_chars: + body: 20 + sidebar: 15 + note: 13px, 줄무늬 행 + max_rows: + body: 8 + sidebar: 5 + note: 행 수 + padding_overhead_px: 19 + padding_h_px: 0 - id: venn-diagram name: SVG 벤 다이어그램 category: visuals template: blocks/visuals/venn-diagram.html height_cost: xlarge min_height_px: 300 - relation_types: [hierarchy, inclusion] + relation_types: + - hierarchy + - inclusion min_items: 2 max_items: 5 visual: SVG. 진한 파란 큰 원(중심) + 3~5개 작은 원(주황/민트/골드 등). 그라데이션+글로우. 동적 N-item 지원. - when: '상위-하위 포함 관계를 시각화. 기술 융합/포함 구조. 예: DX 안에 GIS/BIM/디지털트윈. relation_type=hierarchy 또는 inclusion일 때. ★ 반드시 단독 배치. 다른 블록과 같은 zone에 쌓으면 공간 부족.' - not_for: '텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지. 순차 흐름(A→B→C) → process-horizontal. 대등 비교 → compare-pill-pair.' - purpose_fit: [핵심전달, 구조시각화] + when: '상위-하위 포함 관계를 시각화. 기술 융합/포함 구조. 예: DX 안에 GIS/BIM/디지털트윈. relation_type=hierarchy + 또는 inclusion일 때. ★ 반드시 단독 배치. 다른 블록과 같은 zone에 쌓으면 공간 부족.' + not_for: 텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지. 순차 흐름(A→B→C) + → process-horizontal. 대등 비교 → compare-pill-pair. + purpose_fit: + - 핵심전달 + - 구조시각화 slots: - required: [center_label, 'items[]'] - optional: [center_sub, description] - + required: + - center_label + - items[] + optional: + - center_sub + - description + schema: + center_label: + max_lines: 1 + font_size: 24 + ref_chars: + body: 6 + note: 24px bold, SVG 중심 원 + center_sub: + max_lines: 1 + font_size: 13 + ref_chars: + body: 10 + note: 13px, 중심 원 아래 + item_label: + max_lines: 1 + font_size: 14 + ref_chars: + body: 8 + note: 12-20px 동적, 작은 원 안 + description: + max_lines: 2 + font_size: 14 + ref_chars: + body: 60 + note: 14px, SVG 아래 설명 + max_items: + body: 5 + note: 아이템 수 (원 개수) + padding_overhead_px: 22 + padding_h_px: 0 + min_display_width_px: 200 - id: circle-gradient name: 원형 라벨 category: visuals @@ -366,98 +957,242 @@ blocks: min_height_px: 50 relation_types: [] visual: 파란 그라데이션 원(190px) + 이중 테두리 + 중앙 흰색 텍스트. - when: '섹션 전환점에서 키워드를 원형으로 강조. 아래에 카드/표가 올 때 주제 선언.' - not_for: '본문 텍스트 → topic-header 계열. 결론 한 줄 → banner-gradient. 단독 사용 비추.' + when: 섹션 전환점에서 키워드를 원형으로 강조. 아래에 카드/표가 올 때 주제 선언. + not_for: 본문 텍스트 → topic-header 계열. 결론 한 줄 → banner-gradient. 단독 사용 비추. purpose_fit: [] slots: - required: [label] - optional: [sub_label] + required: + - label + optional: + - sub_label schema: - label: {max_lines: 1, font_size: 22, ref_chars: {body: 6, sidebar: 6}, note: '22px bold white, 원 안'} - sub_label: {max_lines: 1, font_size: 12, ref_chars: {body: 15, sidebar: 12}, note: '12px, 원 아래'} - + label: + max_lines: 1 + font_size: 22 + ref_chars: + body: 6 + sidebar: 6 + note: 22px bold white, 원 안 + sub_label: + max_lines: 1 + font_size: 12 + ref_chars: + body: 15 + sidebar: 12 + note: 12px, 원 아래 + padding_overhead_px: 16 + padding_h_px: 0 + min_display_width_px: 150 - id: compare-pill-pair name: 둥근 박스 VS category: visuals template: blocks/visuals/compare-pill-pair.html height_cost: compact min_height_px: 60 - relation_types: [comparison] + relation_types: + - comparison visual: 이중 테두리 둥근 박스 2개 나란히 + 'VS'. 하늘색 테두리 + 시안 텍스트. + visual_diff: '유사 블록과의 차이: + + - compare-2col-split: 3열 표, 행별 기준 라벨+좌우 항목. 상세 비교용 + + - compare-3col-badge: 3열 표, VS 배지 중앙. 다항목 비교용 + + - comparison-2col: 좌우 텍스트 블록, 파란/빨간 밑줄. 문단형 비교 + + - card-compare-3col: 3개 독립 카드, 색상 헤더+불릿 + + - 이 블록: 둥근 필(pill) 박스 2개 + VS 텍스트만. 세부 항목 없이 대비 선언만 수행 + + 적합: 비교 테이블 위에 헤더로 배치. 두 개념의 시각적 대비를 짧게 선언할 때 + + 부적합: 세부 비교 항목 필요 → compare-3col-badge/compare-2col-split, 텍스트 설명 → comparison-2col + + ' when: '2개 개념 시각적 대비. 비교 테이블 위 헤더로 사용. 예: "DX 협업 프로세스" VS "BIM 정보 관리".' - not_for: '상세 비교(5행+) → compare-3col-badge. 3개 이상 → card-compare-3col.' - purpose_fit: [핵심전달] + not_for: 상세 비교(5행+) → compare-3col-badge. 3개 이상 → card-compare-3col. + purpose_fit: + - 핵심전달 zone: full-width-only slots: - required: [left_label, right_label] - optional: [left_sub, right_sub] + required: + - left_label + - right_label + optional: + - left_sub + - right_sub schema: - left_label: {max_lines: 1, font_size: 18, ref_chars: {body: 10}, note: '18px bold, 350px 필 안'} - right_label: {max_lines: 1, font_size: 18, ref_chars: {body: 10}, note: '18px bold, 350px 필 안'} - + left_label: + max_lines: 1 + font_size: 18 + ref_chars: + body: 10 + note: 18px bold, 350px 필 안 + right_label: + max_lines: 1 + font_size: 18 + ref_chars: + body: 10 + note: 18px bold, 350px 필 안 + padding_overhead_px: 40 + padding_h_px: 40 + min_display_width_px: 200 - id: process-horizontal name: 가로 단계 흐름 category: visuals template: blocks/visuals/process-horizontal.html height_cost: medium min_height_px: 100 - relation_types: [sequence] + relation_types: + - sequence min_items: 2 max_items: 5 visual: 가로 방향. 파란 원형 번호 + 제목 + 설명(카드). → 화살표 연결. - when: '논리적 순서가 있는 단계를 가로로. A→B→C→D 프로세스 흐름. 각 단계에 제목+설명이 필요할 때.' - not_for: '독립 사례 나열(순서 없음) → card-icon-desc 또는 dark-bullet-list. 세로 나열 → card-numbered. 간결한 흐름(설명 불필요) → flow-arrow-horizontal.' - purpose_fit: [핵심전달, 구조시각화] - slots: - required: ['steps[]'] - optional: [] + visual_diff: '유사 블록과의 차이: + - flow-arrow-horizontal: SVG 캡슐 + 화살표만. 라벨 8자 이내. 설명 없이 흐름만 표현. 컴팩트(50px) + + - 이 블록: 파란 원형 번호 + 제목 + 설명이 있는 카드 + 화살표 연결. 각 단계에 상세 설명 포함 + + 적합: 각 단계에 제목+설명이 필요한 상세 프로세스 흐름. 라벨이 긴 경우(8자 초과) + + 부적합: 짧은 키워드만으로 흐름 표현 → flow-arrow-horizontal, 높이 예산 부족 → flow-arrow-horizontal + + ' + when: 논리적 순서가 있는 단계를 가로로. A→B→C→D 프로세스 흐름. 각 단계에 제목+설명이 필요할 때. + not_for: 독립 사례 나열(순서 없음) → card-icon-desc 또는 dark-bullet-list. 세로 나열 → card-numbered. + 간결한 흐름(설명 불필요) → flow-arrow-horizontal. + purpose_fit: + - 핵심전달 + - 구조시각화 + slots: + required: + - steps[] + optional: [] + schema: + step_number: + max_lines: 1 + font_size: 15 + ref_chars: + body: 2 + note: var(--font-body), 36px 원 안 + step_title: + max_lines: 1 + font_size: 15 + ref_chars: + body: 10 + sidebar: 8 + note: var(--font-body) bold + step_description: + max_lines: 2 + font_size: 13 + ref_chars: + body: 40 + sidebar: 25 + note: var(--font-caption) + max_steps: + body: 5 + sidebar: 3 + note: 단계 수 + padding_overhead_px: 0 + padding_h_px: 0 + min_display_width_px: 250 - id: flow-arrow-horizontal name: 가로 흐름 화살표 category: visuals template: blocks/visuals/flow-arrow-horizontal.html height_cost: compact min_height_px: 50 - relation_types: [sequence] + relation_types: + - sequence min_items: 2 max_items: 6 visual: SVG. 색상 둥근 캡슐이 가로 나열 + 사이 화살표. 컴팩트. 각 캡슐 120px 폭. - when: '명확한 시간 순서 또는 인과 흐름이 있을 때만 사용. A→B→C 순서가 핵심. 예: GIS→SPCC→토공→BIM (기술 발전 순서). ★ 각 라벨은 8자 이내로 짧아야 함(120px 캡슐 안에 들어가야 함).' - not_for: '독립 사례/증거 나열(순서 없음) → dark-bullet-list 또는 card-icon-desc. 정책 문서 나열 → dark-bullet-list. 각 단계에 설명 필요 → process-horizontal. 라벨이 길면(8자 초과) → process-horizontal 또는 card-numbered.' - purpose_fit: [구조시각화] + visual_diff: '유사 블록과의 차이: + + - process-horizontal: 파란 원형 번호 + 제목 + 설명 카드 + 화살표. 각 단계에 제목+설명이 있는 상세 흐름 + + - 이 블록: SVG 캡슐(120px)이 가로 나열 + 사이 화살표. 라벨만(8자 이내). 설명 없이 흐름만 표현. 컴팩트 + + 적합: 짧은 키워드(8자 이내)로 순서/흐름만 간결하게 보여줄 때. 높이 예산이 적을 때 + + 부적합: 각 단계에 설명 필요 → process-horizontal, 라벨 8자 초과 → process-horizontal + + ' + when: '명확한 시간 순서 또는 인과 흐름이 있을 때만 사용. A→B→C 순서가 핵심. 예: GIS→SPCC→토공→BIM (기술 발전 순서). + ★ 각 라벨은 8자 이내로 짧아야 함(120px 캡슐 안에 들어가야 함).' + not_for: 독립 사례/증거 나열(순서 없음) → dark-bullet-list 또는 card-icon-desc. 정책 문서 나열 → dark-bullet-list. + 각 단계에 설명 필요 → process-horizontal. 라벨이 길면(8자 초과) → process-horizontal 또는 card-numbered. + purpose_fit: + - 구조시각화 zone: full-width-only slots: - required: ['steps[]'] + required: + - steps[] optional: [] schema: - step_label: {max_lines: 1, font_size: 13, ref_chars: {body: 8}, note: '13px bold, 120px 캡슐 안. 8자 이내 필수.'} - max_steps: {body: 6, note: '단계 수'} - + step_label: + max_lines: 1 + font_size: 13 + ref_chars: + body: 8 + note: 13px bold, 120px 캡슐 안. 8자 이내 필수. + max_steps: + body: 6 + note: 단계 수 + padding_overhead_px: 20 + padding_h_px: 0 + min_display_width_px: 200 - id: keyword-circle-row name: 키워드 원형 행 category: visuals template: blocks/visuals/keyword-circle-row.html height_cost: medium min_height_px: 120 - relation_types: [hierarchy] + relation_types: + - hierarchy min_items: 2 max_items: 5 visual: SVG 그라데이션 원 안에 큰 글자(G,S,I,M 등 약어) + 아래 라벨 + 설명. - when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information) + M(Model).' - not_for: '아이콘+설명 → card-icon-desc. 용어 정의(문장형) → card-numbered. 약어가 아닌 일반 텍스트 → 사용 금지.' - purpose_fit: [구조시각화] + when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information) + + M(Model).' + not_for: 아이콘+설명 → card-icon-desc. 용어 정의(문장형) → card-numbered. 약어가 아닌 일반 텍스트 → 사용 + 금지. + purpose_fit: + - 구조시각화 slots: - required: ['keywords[]'] + required: + - keywords[] optional: [] schema: - letter: {max_lines: 1, font_size: 14, ref_chars: {body: 2, sidebar: 2}, note: '약어 1~2글자'} - label: {max_lines: 1, font_size: 14, ref_chars: {body: 10, sidebar: 8}, note: '14px bold, 1줄'} - description: {max_lines: 2, font_size: 12, ref_chars: {body: 25, sidebar: 20}, note: '12px, 140px 폭, 2줄'} - max_keywords: {body: 5, sidebar: 3, note: '키워드 수'} - -# ═══════════════════════════════════════ -# EMPHASIS (10개) — 강조/인용/결론 -# ═══════════════════════════════════════ + letter: + max_lines: 1 + font_size: 14 + ref_chars: + body: 2 + sidebar: 2 + note: 약어 1~2글자 + label: + max_lines: 1 + font_size: 14 + ref_chars: + body: 10 + sidebar: 8 + note: 14px bold, 1줄 + description: + max_lines: 2 + font_size: 12 + ref_chars: + body: 25 + sidebar: 20 + note: 12px, 140px 폭, 2줄 + max_keywords: + body: 5 + sidebar: 3 + note: 키워드 수 + padding_overhead_px: 20 + padding_h_px: 0 + min_display_width_px: 200 - id: quote-big-mark name: 큰따옴표 인용 category: emphasis @@ -466,16 +1201,48 @@ blocks: min_height_px: 80 relation_types: [] visual: 좌상단 ❝ + 우하단 ❞ 큰따옴표 장식. 연한 배경 박스 + 인용문 + 우측 출처. - when: '임팩트 있는 인용문. 문제 제기를 인용 형태로 강조. 출처가 있는 인용.' - not_for: '짧은 질문(1~2줄) → quote-question. 결론 한 줄 강조 → banner-gradient. 불릿 나열 → dark-bullet-list.' - purpose_fit: [문제제기, 근거사례] - slots: - required: [quote_text] - optional: [source] - schema: - quote_text: {max_lines: 3, font_size: 16, ref_chars: {body: 120, sidebar: 70}, note: '16px, 큰따옴표 장식 안, 3줄 이내'} - source: {max_lines: 1, font_size: 14, ref_chars: {body: 30, sidebar: 20}, note: 'caption, 1줄'} + visual_diff: '유사 블록과의 차이: + - quote-question: 파란 배경 + 파란 테두리 + 큰 질문(22px). 독자에게 질문을 던지는 구조 + + - callout-warning: 빨간 배경 + 경고 아이콘 + 설명. 문제점/위험 경고용 + + - callout-solution: 파란 배경 + 솔루션 아이콘 + 설명. 해결책 제시용 + + - 이 블록: 큰따옴표(❝❞) 장식이 좌상/우하에 배치. 연한 배경 + 인용문 텍스트 + 출처. 인용 형식 + + 적합: 출처가 있는 인용문. 권위 있는 발언/보고서를 인용 형태로 강조할 때 + + 부적합: 질문형 → quote-question, 문제 경고 → callout-warning, 해결책 → callout-solution + + ' + when: 임팩트 있는 인용문. 문제 제기를 인용 형태로 강조. 출처가 있는 인용. + not_for: 짧은 질문(1~2줄) → quote-question. 결론 한 줄 강조 → banner-gradient. 불릿 나열 → dark-bullet-list. + purpose_fit: + - 문제제기 + - 근거사례 + slots: + required: + - quote_text + optional: + - source + schema: + quote_text: + max_lines: 3 + font_size: 16 + ref_chars: + body: 120 + sidebar: 70 + note: 16px, 큰따옴표 장식 안, 3줄 이내 + source: + max_lines: 1 + font_size: 14 + ref_chars: + body: 30 + sidebar: 20 + note: caption, 1줄 + padding_overhead_px: 48 + padding_h_px: 56 - id: quote-question name: 질문형 강조 category: emphasis @@ -484,38 +1251,134 @@ blocks: min_height_px: 80 relation_types: [] visual: 밝은 파란 배경 + 파란 테두리 + 큰 질문 텍스트(22px) + 부연 설명. - when: '독자에게 질문을 던져 문제 인식을 유도. 전환점. 예: "지금의 방식으로도 가능할까?"' - not_for: '인용(출처 있음) → quote-big-mark. 결론 선언 → banner-gradient. 경고/문제 → callout-warning.' - purpose_fit: [문제제기] - slots: - required: [question] - optional: [description] - schema: - question: {max_lines: 1, font_size: 22, ref_chars: {body: 35, sidebar: 25}, note: '22px bold, 1줄 권장'} - description: {max_lines: 3, font_size: 14, ref_chars: {body: 120, sidebar: 80}, note: '14px, 3줄 이내'} + visual_diff: '유사 블록과의 차이: + - quote-big-mark: 큰따옴표 장식 + 인용문 + 출처. 타인의 말을 인용하는 형식 + + - callout-warning: 빨간 배경 + 경고 아이콘. 문제점 경고. 부정적 톤 + + - callout-solution: 파란 배경 + 솔루션 아이콘. 해결책 제시. 긍정적 톤 + + - 이 블록: 파란 배경+테두리 + 큰 질문(22px bold). 독자에게 직접 질문하는 구조. 부연 설명 포함 + + 적합: 독자에게 질문을 던져 문제 인식을 유도하는 전환점. 물음표로 끝나는 핵심 질문 + + 부적합: 타인 인용(출처) → quote-big-mark, 경고 → callout-warning, 해결책 → callout-solution + + ' + when: '독자에게 질문을 던져 문제 인식을 유도. 전환점. 예: "지금의 방식으로도 가능할까?"' + not_for: 인용(출처 있음) → quote-big-mark. 결론 선언 → banner-gradient. 경고/문제 → callout-warning. + purpose_fit: + - 문제제기 + slots: + required: + - question + optional: + - description + schema: + question: + max_lines: 1 + font_size: 22 + ref_chars: + body: 35 + sidebar: 25 + note: 22px bold, 1줄 권장 + description: + max_lines: 3 + font_size: 14 + ref_chars: + body: 120 + sidebar: 80 + note: 14px, 3줄 이내 + padding_overhead_px: 56 + padding_h_px: 48 - id: comparison-2col name: 2단 병렬 비교 category: emphasis template: blocks/emphasis/comparison-2col.html height_cost: medium min_height_px: 80 - relation_types: [comparison] + relation_types: + - comparison variants: - - id: default - description: 좌우 2단 텍스트 비교 (기본) - - id: cards-in-container - description: 큰 박스 안에 카드 N개 (포함 관계 시각화, DX⊃BIM) - template: blocks/emphasis/comparison-2col--cards-in-container.html - when: "hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때. 포함 관계 시각화" + - id: default + description: 좌우 2단 텍스트 비교 (기본) + - id: cards-in-container + description: 큰 박스 안에 카드 N개 (포함 관계 시각화, DX⊃BIM) + template: blocks/emphasis/comparison-2col--cards-in-container.html + when: hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때. 포함 관계 시각화 visual: 좌우 2단. 좌 파란 헤더(밑줄) + 우 빨간 헤더(밑줄). 중앙 구분선. 서브타이틀+본문. - when: 'A vs B 간단 비교. 2~3개 항목을 좌우로 대비. 장단점, Before/After 등 대비 구조. 예: BIM(하위기술) vs DX(상위개념).' - not_for: '다항목 표(5행+) → compare-3col-badge. 결론 한 줄 강조 → banner-gradient. 핵심 메시지 선언 → banner-gradient. footer에서 결론 강조용으로 쓰지 마라.' - purpose_fit: [핵심전달] - slots: - required: [left_title, left_content, right_title, right_content] - optional: [left_subtitle, right_subtitle] + visual_diff: '유사 블록과의 차이: + - compare-2col-split: 표 형식. 행마다 기준 라벨+좌우 셀. 정형화된 비교 + + - compare-3col-badge: 표 형식. VS 배지 중앙 열. 다항목 행별 비교 + + - compare-pill-pair: 둥근 박스 2개 + VS. 헤더 역할만, 세부 내용 없음 + + - card-compare-3col: 3개 독립 카드, 카드별 색상 헤더+불릿 + + - 이 블록: 자유 텍스트 좌우 블록. 좌=파란 밑줄, 우=빨간 밑줄. CSS var 사용. 표가 아니라 문단형 + + 적합: 2~3항목의 간결한 대비. 장단점, Before/After. 자유로운 텍스트 비교 + + 부적합: 5행+ 다항목 비교 → compare-3col-badge/compare-2col-split, 3개 비교 → card-compare-3col + + ' + when: 'A vs B 간단 비교. 2~3개 항목을 좌우로 대비. 장단점, Before/After 등 대비 구조. 예: BIM(하위기술) vs + DX(상위개념).' + not_for: 다항목 표(5행+) → compare-3col-badge. 결론 한 줄 강조 → banner-gradient. 핵심 메시지 선언 + → banner-gradient. footer에서 결론 강조용으로 쓰지 마라. + purpose_fit: + - 핵심전달 + slots: + required: + - left_title + - left_content + - right_title + - right_content + optional: + - left_subtitle + - right_subtitle + schema: + left_title: + max_lines: 1 + font_size: 18 + ref_chars: + body: 15 + note: var(--font-subtitle) bold, 파란 밑줄 + left_subtitle: + max_lines: 1 + font_size: 13 + ref_chars: + body: 20 + note: var(--font-caption) + left_content: + max_lines: 5 + font_size: 15 + ref_chars: + body: 150 + note: var(--font-body) + right_title: + max_lines: 1 + font_size: 18 + ref_chars: + body: 15 + note: var(--font-subtitle) bold, 빨간 밑줄 + right_subtitle: + max_lines: 1 + font_size: 13 + ref_chars: + body: 20 + note: var(--font-caption) + right_content: + max_lines: 5 + font_size: 15 + ref_chars: + body: 150 + note: var(--font-body) + padding_overhead_px: 0 + padding_h_px: 0 - id: banner-gradient name: 그라데이션 배너 category: emphasis @@ -524,44 +1387,84 @@ blocks: min_height_px: 40 relation_types: [] visual: 전체 너비 파란 그라데이션 배경(둥근 모서리 8px) + 중앙 흰색 굵은 텍스트(16px) + 선택적 서브텍스트. - when: '★ 결론 강조에 가장 적합. 핵심 메시지 한 줄 선언. footer 배치에 최적(compact, 50~60px). 페이지의 "기억해야 할 단 하나의 문장". 예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"' - not_for: '인용(출처) → quote-big-mark. 긴 설명(3줄+) → callout-solution. A vs B 비교 → comparison-2col.' - purpose_fit: [결론강조] + when: '★ 결론 강조에 가장 적합. 핵심 메시지 한 줄 선언. footer 배치에 최적(compact, 50~60px). 페이지의 "기억해야 + 할 단 하나의 문장". 예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"' + not_for: 인용(출처) → quote-big-mark. 긴 설명(3줄+) → callout-solution. A vs B 비교 → comparison-2col. + purpose_fit: + - 결론강조 slots: - required: [text] - optional: [sub_text] + required: + - text + optional: + - sub_text schema: - text: {max_lines: 1, font_size: 16, ref_chars: {body: 38, sidebar: 18}, note: '16px bold white, 1줄'} - sub_text: {max_lines: 1, font_size: 12, ref_chars: {body: 50, sidebar: 30}, note: '12px, 1줄'} - + text: + max_lines: 1 + font_size: 16 + ref_chars: + body: 38 + sidebar: 18 + note: 16px bold white, 1줄 + sub_text: + max_lines: 1 + font_size: 12 + ref_chars: + body: 50 + sidebar: 30 + note: 12px, 1줄 + padding_overhead_px: 32 + padding_h_px: 60 - id: dark-bullet-list name: 다크 배경 불릿 category: emphasis template: blocks/emphasis/dark-bullet-list.html height_cost: medium min_height_px: 80 - relation_types: [cause_effect] + relation_types: + - cause_effect min_items: 2 max_items: 5 variants: - - id: default - description: 다크 배경 + 불릿 나열 (기본) - - id: before-after - description: Before→After 2열 구조 (프로세스 변화, 전환) - template: blocks/emphasis/dark-bullet-list--before-after.html - when: "기존 방식 → 새 방식으로의 전환/변화를 보여줄 때. 각 항목이 before/after 쌍일 때" + - id: default + description: 다크 배경 + 불릿 나열 (기본) + - id: before-after + description: Before→After 2열 구조 (프로세스 변화, 전환) + template: blocks/emphasis/dark-bullet-list--before-after.html + when: 기존 방식 → 새 방식으로의 전환/변화를 보여줄 때. 각 항목이 before/after 쌍일 때 visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 파란 불릿 마커. 시각적 무게감. - when: '★ 독립적인 사례/증거/포인트를 나열할 때 적합. 순서 없는 항목을 강조하며 나열. 정책 문서 사례, 근거 자료 나열.' - not_for: '밝은 배경 → card-icon-desc 또는 card-numbered. 순서가 있는 단계 → card-numbered 또는 process-horizontal. 시각화(다이어그램) → venn-diagram.' - purpose_fit: [근거사례, 문제제기, 핵심전달] + when: ★ 독립적인 사례/증거/포인트를 나열할 때 적합. 순서 없는 항목을 강조하며 나열. 정책 문서 사례, 근거 자료 나열. + not_for: 밝은 배경 → card-icon-desc 또는 card-numbered. 순서가 있는 단계 → card-numbered 또는 process-horizontal. + 시각화(다이어그램) → venn-diagram. + purpose_fit: + - 근거사례 + - 문제제기 + - 핵심전달 slots: - required: ['bullets[]'] - optional: [title] + required: + - bullets[] + optional: + - title schema: - title: {max_lines: 1, font_size: 16, ref_chars: {body: 30, sidebar: 20}, note: '16px bold, 1줄'} - bullet_item: {max_lines: 1, font_size: 14, ref_chars: {body: 86, sidebar: 41}, note: '14px, 1불릿 기준'} - max_bullets: {body: 5, sidebar: 4, note: '불릿 수'} - + title: + max_lines: 1 + font_size: 16 + ref_chars: + body: 30 + sidebar: 20 + note: 16px bold, 1줄 + bullet_item: + max_lines: 1 + font_size: 14 + ref_chars: + body: 86 + sidebar: 41 + note: 14px, 1불릿 기준 + max_bullets: + body: 5 + sidebar: 4 + note: 불릿 수 + padding_overhead_px: 32 + padding_h_px: 48 - id: highlight-strip name: 강조 분류 스트립 category: emphasis @@ -571,48 +1474,130 @@ blocks: relation_types: [] visual: 가로 색상 구간들. 각 구간에 흰 라벨. 카테고리 색상 분류 바. when: '카테고리별 색상 분류를 한 줄로. 예: 상용(회색) | 3rd Party(파랑) | 전문SW(빨강).' - not_for: '탭 전환 → tab-label-row. 결론 강조 → banner-gradient. 독립 항목 나열 → dark-bullet-list.' - purpose_fit: [구조시각화] + not_for: 탭 전환 → tab-label-row. 결론 강조 → banner-gradient. 독립 항목 나열 → dark-bullet-list. + purpose_fit: + - 구조시각화 slots: - required: ['segments[]'] + required: + - segments[] optional: [] schema: - label: {max_lines: 1, font_size: 14, ref_chars: {body: 15, sidebar: 10}, note: '14px bold white, nowrap, 세그먼트당'} - max_segments: {body: 4, sidebar: 3, note: '세그먼트 수'} - + label: + max_lines: 1 + font_size: 14 + ref_chars: + body: 15 + sidebar: 10 + note: 14px bold white, nowrap, 세그먼트당 + max_segments: + body: 4 + sidebar: 3 + note: 세그먼트 수 + padding_overhead_px: 20 + padding_h_px: 32 - id: callout-solution name: 솔루션 콜아웃 category: emphasis template: blocks/emphasis/callout-solution.html height_cost: medium min_height_px: 80 - relation_types: [cause_effect] + relation_types: + - cause_effect visual: 밝은 파란 배경 + 파란 테두리 + 아이콘 + 파란 제목 + 설명 + 출처. - when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".' - not_for: '경고/문제 → callout-warning. 인용 → quote-big-mark. 결론 한 줄 → banner-gradient.' - purpose_fit: [핵심전달] - slots: - required: [title, description] - optional: [icon, source] - schema: - title: {max_lines: 1, font_size: 17, ref_chars: {body: 40, sidebar: 25}, note: '17px bold, 1줄'} - description: {max_lines: 4, font_size: 14, ref_chars: {body: 150, sidebar: 90}, note: '14px, 3~4줄'} + visual_diff: '유사 블록과의 차이: + - quote-big-mark: 큰따옴표 장식 + 인용문 + 출처. 인용 형식 + + - quote-question: 파란 배경 + 큰 질문(22px). 독자에게 질문하는 구조 + + - callout-warning: 빨간 배경 + 경고 아이콘 + 빨간 텍스트. 문제점/위험 경고용 + + - 이 블록: 파란 배경+테두리 + 솔루션 아이콘 + 파란 제목 + 설명(최대 4줄) + 출처. 긍정적/해결책 톤 + + 적합: 핵심 해결책, 방향성, 솔루션을 강조. 긴 설명(3~4줄)과 출처가 필요할 때 + + 부적합: 경고/문제 → callout-warning, 인용 → quote-big-mark, 질문 → quote-question + + ' + when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".' + not_for: 경고/문제 → callout-warning. 인용 → quote-big-mark. 결론 한 줄 → banner-gradient. + purpose_fit: + - 핵심전달 + slots: + required: + - title + - description + optional: + - icon + - source + schema: + title: + max_lines: 1 + font_size: 17 + ref_chars: + body: 40 + sidebar: 25 + note: 17px bold, 1줄 + description: + max_lines: 4 + font_size: 14 + ref_chars: + body: 150 + sidebar: 90 + note: 14px, 3~4줄 + padding_overhead_px: 40 + padding_h_px: 48 - id: callout-warning name: 경고 콜아웃 category: emphasis template: blocks/emphasis/callout-warning.html height_cost: medium min_height_px: 80 - relation_types: [cause_effect] + relation_types: + - cause_effect visual: 연한 빨간 배경 + 빨간 테두리 + 아이콘 + 빨간 제목 + 진한 빨간 설명. - when: '문제점 지적, 잘못된 인식 경고, 주의사항. 문제 제기 purpose에 적합. 예: "⚠️ 현재 접근 방식의 한계".' - not_for: '해결책 → callout-solution. 인용 → quote-big-mark. 결론 → banner-gradient.' - purpose_fit: [문제제기] - slots: - required: [title, description] - optional: [icon] + visual_diff: '유사 블록과의 차이: + - quote-big-mark: 큰따옴표 장식 + 인용문 + 출처. 인용 형식으로 중립적 톤 + + - quote-question: 파란 배경 + 큰 질문. 독자에게 질문을 던지는 구조 + + - callout-solution: 파란 배경 + 솔루션 아이콘 + 설명. 해결책/긍정적 메시지 + + - 이 블록: 빨간 배경+테두리 + 경고 아이콘 + 빨간 제목/설명. 부정적/경고 톤. 문제점 강조 전용 + + 적합: 문제점 지적, 잘못된 인식 경고, 위험 요소 강조. 빨간색으로 시각적 경고 전달 + + 부적합: 해결책 → callout-solution, 인용 → quote-big-mark, 질문 → quote-question + + ' + when: '문제점 지적, 잘못된 인식 경고, 주의사항. 문제 제기 purpose에 적합. 예: "⚠️ 현재 접근 방식의 한계".' + not_for: 해결책 → callout-solution. 인용 → quote-big-mark. 결론 → banner-gradient. + purpose_fit: + - 문제제기 + slots: + required: + - title + - description + optional: + - icon + schema: + title: + max_lines: 1 + font_size: 17 + ref_chars: + body: 40 + sidebar: 25 + note: 17px bold 빨간색 + description: + max_lines: 4 + font_size: 14 + ref_chars: + body: 150 + sidebar: 90 + note: 14px 진한 빨간 + padding_overhead_px: 40 + padding_h_px: 48 - id: tab-label-row name: 탭 라벨 행 category: emphasis @@ -622,15 +1607,26 @@ blocks: relation_types: [] visual: 가로 탭 버튼. 선택됨=색상 배경+흰 텍스트, 나머지=회색. 밝은 바탕. when: '카테고리 전환/분류 표시. 현재 선택된 항목 강조. 예: 제조 | 건축 | [인프라/토목].' - not_for: '색상 바 → highlight-strip. 실제 클릭 전환 미지원.' + not_for: 색상 바 → highlight-strip. 실제 클릭 전환 미지원. purpose_fit: [] slots: - required: ['tabs[]'] + required: + - tabs[] optional: [] schema: - tab_label: {max_lines: 1, font_size: 14, ref_chars: {body: 10, sidebar: 8}, note: '14px bold, 탭당'} - max_tabs: {body: 5, sidebar: 3, note: '탭 수'} - + tab_label: + max_lines: 1 + font_size: 14 + ref_chars: + body: 10 + sidebar: 8 + note: 14px bold, 탭당 + max_tabs: + body: 5 + sidebar: 3 + note: 탭 수 + padding_overhead_px: 8 + padding_h_px: 0 - id: divider-text name: 텍스트 구분선 category: emphasis @@ -640,15 +1636,23 @@ blocks: relation_types: [] visual: 좌우 가는 회색 선 + 중앙 작은 회색 텍스트(13px bold). 시각적 휴식점. when: 'sidebar 영역의 섹션 라벨. 주제 전환점에 가벼운 구분. 예: ── 용어 정의 ──' - not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient. body 영역 메인 제목 → topic 계열.' + not_for: 강한 구분 → section-header-bar. 결론 → banner-gradient. body 영역 메인 제목 → topic + 계열. purpose_fit: [] slots: - required: [text] + required: + - text optional: [] - -# ═══════════════════════════════════════ -# MEDIA (5개) — 이미지/사진 -# ═══════════════════════════════════════ + schema: + text: + max_lines: 1 + font_size: 13 + ref_chars: + body: 20 + sidebar: 15 + note: 13px bold, nowrap, 중앙정렬 + padding_overhead_px: 16 + padding_h_px: 0 - id: image-row-2col name: 이미지 2열 category: media @@ -657,13 +1661,27 @@ blocks: min_height_px: 200 relation_types: [] visual: 이미지 2장 나란히. 각 캡션 선택. - when: '시공 사진 2장 나란히, 현장 비교.' - not_for: '4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption.' - purpose_fit: [근거사례] + when: 시공 사진 2장 나란히, 현장 비교. + not_for: 4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption. + purpose_fit: + - 근거사례 slots: - required: ['images[]'] + required: + - images[] optional: [] - + schema: + caption: + max_lines: 1 + font_size: 11 + ref_chars: + body: 30 + sidebar: 20 + note: 11px, 이미지 아래 + max_images: + body: 2 + note: 이미지 수 + padding_overhead_px: 0 + padding_h_px: 0 - id: image-grid-2x2 name: 이미지 2x2 그리드 category: media @@ -672,13 +1690,26 @@ blocks: min_height_px: 350 relation_types: [] visual: 이미지 4장 2x2 격자. 각 캡션 선택. - when: '현장 사진 4장, 4개 관점 이미지.' - not_for: '2장 → image-row-2col. 이미지+텍스트 → image-side-text.' - purpose_fit: [근거사례] + when: 현장 사진 4장, 4개 관점 이미지. + not_for: 2장 → image-row-2col. 이미지+텍스트 → image-side-text. + purpose_fit: + - 근거사례 slots: - required: ['images[]'] + required: + - images[] optional: [] - + schema: + caption: + max_lines: 1 + font_size: 11 + ref_chars: + body: 30 + note: 11px, 이미지 아래 + max_images: + body: 4 + note: 이미지 수 (2x2) + padding_overhead_px: 8 + padding_h_px: 0 - id: image-side-text name: 이미지+텍스트 가로 category: media @@ -687,13 +1718,43 @@ blocks: min_height_px: 150 relation_types: [] visual: 좌측 이미지(320px 고정) + 우측 제목+설명+불릿. 가로 배치. - when: '이미지에 대한 설명. 제품/시스템 소개. 다이어그램+해설.' - not_for: '이미지만 → image-row-2col. 여러 장 → image-grid-2x2.' - purpose_fit: [핵심전달, 근거사례] + when: 이미지에 대한 설명. 제품/시스템 소개. 다이어그램+해설. + not_for: 이미지만 → image-row-2col. 여러 장 → image-grid-2x2. + purpose_fit: + - 핵심전달 + - 근거사례 slots: - required: [image_src] - optional: [image_alt, title, description, bullets] - + required: + - image_src + optional: + - image_alt + - title + - description + - bullets + schema: + title: + max_lines: 1 + font_size: 18 + ref_chars: + body: 20 + note: 18px bold + description: + max_lines: 3 + font_size: 14 + ref_chars: + body: 100 + note: 14px + bullet_item: + max_lines: 1 + font_size: 13 + ref_chars: + body: 40 + note: 13px, 불릿당 + max_bullets: + body: 4 + note: 불릿 수 + padding_overhead_px: 4 + padding_h_px: 0 - id: image-full-caption name: 전체 너비 이미지 category: media @@ -702,31 +1763,68 @@ blocks: min_height_px: 200 relation_types: [] visual: 전체 너비 이미지 1장(둥근 모서리) + 하단 캡션. - when: '핵심 도표, 대형 다이어그램, 전경 사진을 크게.' - not_for: '2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text.' - purpose_fit: [핵심전달] + when: 핵심 도표, 대형 다이어그램, 전경 사진을 크게. + not_for: 2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text. + purpose_fit: + - 핵심전달 slots: - required: [src] - optional: [alt, caption] - + required: + - src + optional: + - alt + - caption + schema: + caption: + max_lines: 1 + font_size: 12 + ref_chars: + body: 40 + note: 12px, 이미지 아래 + padding_overhead_px: 0 + padding_h_px: 0 - id: image-before-after name: Before/After 이미지 category: media template: blocks/media/image-before-after.html height_cost: large min_height_px: 200 - relation_types: [comparison] + relation_types: + - comparison visual: 좌 Before(회색 라벨) + → 화살표(파란) + 우 After(파란 라벨). 각 이미지 180px. - when: '변화 전후 비교. 디지털 전환 전후, 공정 개선 전후.' - not_for: '이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col.' - purpose_fit: [핵심전달, 근거사례] + when: 변화 전후 비교. 디지털 전환 전후, 공정 개선 전후. + not_for: 이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col. + purpose_fit: + - 핵심전달 + - 근거사례 slots: - required: [before_src, after_src] - optional: [before_label, after_label, caption] - -# ═══════════════════════════════════════ -# LAYOUTS — 프리셋 레이아웃 -# ═══════════════════════════════════════ + required: + - before_src + - after_src + optional: + - before_label + - after_label + - caption + schema: + before_label: + max_lines: 1 + font_size: 13 + ref_chars: + body: 8 + note: 13px bold white, 라벨 + after_label: + max_lines: 1 + font_size: 13 + ref_chars: + body: 8 + note: 13px bold white, 라벨 + caption: + max_lines: 1 + font_size: 12 + ref_chars: + body: 40 + note: 12px, 하단 캡션 + padding_overhead_px: 0 + padding_h_px: 0 layouts: - id: 65-35 name: 6.5:3.5 좌우 분할