diff --git a/PHASE-X.md b/PHASE-X.md index f4af425..e7ab35e 100644 --- a/PHASE-X.md +++ b/PHASE-X.md @@ -1,39 +1,189 @@ -# Phase X: 콘텐츠 기반 레이아웃 판단 프로세스 +# Phase X: 템플릿 기반 동적 레이아웃 + +> 작성일: 2026-04-06 +> 상태: 계획 수립 + +--- ## 배경 -현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다. +현재 파이프라인은 모든 MDX를 "배경/본심/첨부/결론" 4칸에 억지로 끼워넣는다. +배경이 없는 콘텐츠도 배경을 만들어내고, 3분할이 적절한 콘텐츠도 2분할로 넣는다. +Kei가 내용을 잘못 파악한 게 아니라, **"4칸을 채워라"는 지시가 잘못**된 것이다. -## 현재 프로세스 (문제) +## 핵심 아이디어 + +**미리 정의된 레이아웃 템플릿 중 Kei가 콘텐츠에 맞는 것을 선택한다.** + +- Kei가 자유롭게 구조를 만드는 것이 아님 (불안정) +- 옵션을 주고 고르게 함 (안정적) +- 하드코딩 아님 — 어떤 MDX가 와도 적절한 템플릿이 선택됨 + +## 고정 영역 + +모든 템플릿 공통: +- **상단**: 슬라이드 제목 헤더 (항상 존재) +- **하단**: 결론 footer (항상 존재) +- **중간**: 템플릿에 따라 달라지는 영역 + +## 중간 영역 템플릿 옵션 + +### A. body + sidebar +``` +┌──────────┬─────┐ +│ 본심1 │참조 │ +│ 본심2 │ │ +└──────────┴─────┘ +``` +적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠 +예시: 01번 MDX (DX/BIM 용어 정립) + +### B. 상단 wide + 하단 2분할 +``` +┌─────────────────┐ +│ 강조/핵심 │ +├────────┬────────┤ +│ 항목1 │ 항목2 │ +└────────┴────────┘ +``` +적합: 핵심 1개 + 두 가지 측면 비교/설명 +예시: 03번 MDX (필수요건 + 과정혁신/결과변화) + +### C. 상단 wide + 하단 3분할 +``` +┌─────────────────┐ +│ 강조/핵심 │ +├─────┬─────┬─────┤ +│항목1│항목2│항목3 │ +└─────┴─────┴─────┘ +``` +적합: 핵심 1개 + 세 가지 항목 병렬 +예시: 02번 MDX (궁극적 목표 + 발주처/설계사/시공사) + +### D. 2분할 (좌우 대등) +``` +┌────────┬────────┐ +│ 항목1 │ 항목2 │ +│ │ │ +└────────┴────────┘ +``` +적합: 두 가지 비교/대비 + +### E. 단일 전체 +``` +┌─────────────────┐ +│ │ +│ 본심 (전체) │ +│ │ +└─────────────────┘ +``` +적합: 하나의 흐름, 분할 불필요 + +### F. 상단 wide + 하단 2분할 + 보조 sidebar +``` +┌─────────────┬───┐ +│ 강조/핵심 │참조│ +├──────┬──────┤ │ +│항목1 │항목2 │ │ +└──────┴──────┴───┘ +``` +적합: B + sidebar 조합 + +--- + +## 프로세스 ``` -Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분 +1. Kei가 MDX 원본을 읽고 내용 분석 + → 핵심 메시지, 콘텐츠 구조 파악 + +2. Kei가 꼭지를 나눔 (개수와 역할명 자유) + → "핵심목표 1개, 주체별 기대효과 3개, 결론 1개" + → 역할명: 고정 4칸 아님. 콘텐츠에 맞는 이름 사용 + +3. Kei가 템플릿 선택 + → "꼭지 구조를 보니 C템플릿이 맞다" + → 옵션 A~F 중 하나 + +4. Kei가 각 영역에 꼭지 배정 + weight + → 상단: 핵심목표(0.3) + → 하단좌: 발주처(0.2), 하단중: 설계사(0.2), 하단우: 시공사(0.2) + → footer: 결론(0.1) + +5. 파이프라인이 템플릿대로 컨테이너 생성 → BEFORE + +6. 콘텐츠 채움 → 측정 → 재배분 → FILLED → AFTER + +7. 조립 → code_assembled / final ``` -- `reference` 있으면 → `sidebar-right` (무조건) -- 콘텐츠 양/특성 기반 판단 없음 -- 레이아웃이 콘텐츠에 맞는지 검증 없음 +--- -## 목표 프로세스 +## 작업 리스트 -``` -1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음) -2. FILLED: 콘텐츠 채움 -3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등) -4. 판단2: 결정된 레이아웃에서 크기 재배분 -5. AFTER: 최종 레이아웃 + 크기 -``` +### X-1: 템플릿 정의 +- 옵션 A~F의 구체적 컨테이너 구조 정의 +- 각 옵션의 zone 이름, 비율 계산 공식, 좌표 계산 로직 +- `src/design_director.py`에 `LAYOUT_TEMPLATES` 정의 +- 하드코딩 아님: 템플릿은 구조만 정의, 크기는 weight와 슬라이드 크기에서 동적 계산 -- 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과** -- 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨 +### X-2: Kei 프롬프트 수정 +- `KEI_PROMPT`에 템플릿 A~F 옵션 제시 +- Kei가 콘텐츠를 보고 `layout: "C"` 선택 +- page_structure의 역할명이 자유 (배경/본심 고정 아님) +- 각 역할에 zone 배정 (상단/하단좌/하단우 등) +- 하드코딩 아님: Kei가 콘텐츠마다 다른 선택을 함 -## 관련 코드 +### X-3: space_allocator 템플릿 기반 컨테이너 생성 +- 선택된 템플릿에 따라 컨테이너 좌표/크기 생성 +- weight 비율로 각 영역 크기 결정 +- `select_preset()` → `build_layout_from_template()` +- 하드코딩 아님: 템플릿 구조 + weight + 슬라이드 크기로 동적 계산 -- `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()` -- `src/pipeline.py`: Stage 1.5a에서 preset 선택 -- `src/kei_client.py`: Stage 1A에서 role 태그 부여 -- `src/space_allocator.py`: zone 기반 컨테이너 배분 +### X-4: block_assembler / assemble_stage2 동적 역할 +- `["배경", "본심", "첨부", "결론"]` 고정 루프 → `page_structure.keys()` 동적 루프 +- 좌표 계산은 X-3에서 생성한 컨테이너 정보 사용 +- 색상/폰트: 역할 수에 맞게 동적 배분 +- 하드코딩 아님: 역할 수가 3개든 5개든 동작 -## 상태 +### X-5: 나머지 파일 동적화 +- step_visualizer: before/after 시각화에서 동적 역할 루프 +- fit_verifier: 4역할 고정 → 동적 역할 +- html_generator: Sonnet에게 동적 영역 수만큼 생성 요청 +- renderer: 동적 grid-template 생성 +- 하드코딩 아님: 모두 ctx.containers.keys() 기반 -Phase W (before→filled→after 파이프라인) 완료 후 착수. +### X-6: 검증 +- 01번 MDX → A템플릿 → 기존과 동일하거나 더 나은 결과 +- 02번 MDX → C템플릿 → 상단 강조 + 하단 3분할 +- 03번 MDX → B템플릿 → 상단 요건 + 하단 2분할 +- 텍스트가 컨테이너 안에 있음 +- 공란 최소 +- 01번이 깨지면 롤백 + +--- + +## 주의사항 + +- 하드코딩 절대 금지: 특정 MDX에만 동작하는 코드 없음 +- 01번 보호: Phase X 전에 git commit 완료 (1f7579c). 깨지면 롤백 +- 점진적 진행: X-1 → X-2 후 Kei 응답 확인 → X-3~X-5 순차 진행 +- 각 단계마다 검증 + +--- + +## 관련 코드 (고정 역할 참조 현황) + +| 파일 | 참조 수 | 수정 범위 | +|------|---------|----------| +| src/html_generator.py | 54건 | X-5 | +| src/step_visualizer.py | 32건 | X-5 | +| src/space_allocator.py | 26건 | X-3 | +| scripts/assemble_stage2.py | 26건 | X-4 | +| src/kei_client.py | 18건 | X-2 | +| src/block_assembler.py | 17건 | X-4 | +| src/fit_verifier.py | 16건 | X-5 | +| src/pipeline.py | 15건 | X-3~X-5 | +| src/renderer.py | 7건 | X-5 | +| src/pipeline_context.py | 4건 | 필요 시 | +| **합계** | **215건** | | diff --git a/scripts/assemble_stage2.py b/scripts/assemble_stage2.py index bbe0f95..c13ead8 100644 --- a/scripts/assemble_stage2.py +++ b/scripts/assemble_stage2.py @@ -130,7 +130,7 @@ def assemble(run_dir: str): return p return None - def popup_to_compact_table(popup_content, font_size): + def popup_to_compact_table(popup_content, font_size, role_name=""): """팝업의 마크다운 표를 compact HTML 테이블로 변환.""" # 마크다운 bold → HTML (팝업 정화가 안 된 run 대응) popup_content = re.sub(r'\*\*(.+?)\*\*', r'\1', popup_content) @@ -164,7 +164,7 @@ def assemble(run_dir: str): 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, "본심")}
' + cells_html += f'
{bold(cell, role_name)}
' rows_html += f'
{cells_html}
\n' return ( @@ -189,8 +189,8 @@ def assemble(run_dir: str): all_css = set() role_htmls = {} - # ── 각 역할별 조립 ── - for role in ["배경", "본심", "첨부", "결론"]: + # ── 각 역할별 조립 (동적 — page_structure의 모든 역할) ── + for role in ps.keys(): info = ps.get(role, {}) if not isinstance(info, dict): continue @@ -209,8 +209,14 @@ def assemble(run_dir: str): 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) + # font_size: zone 기반 동적 결정 + zone = ci.get("zone", "") if isinstance(ci, dict) else "" + if zone == "footer": + font_size = fh.get("key_msg", 14) + elif zone == "sidebar": + font_size = fh.get("sidebar", 10) + else: + font_size = fh.get("core", 12) block_css, block_body = extract_block_html(ref_html) if block_css: @@ -241,7 +247,8 @@ def assemble(run_dir: str): # ════════════════════════════════════ # 결론 # ════════════════════════════════════ - if role == "결론": + # Phase X: zone 기반 분기 (역할명이 아닌 zone/블록타입으로) + if zone == "footer": assembled = block_body assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled) assembled = re.sub(r'>부연 설명<', '><', assembled) @@ -250,7 +257,7 @@ def assemble(run_dir: str): # ════════════════════════════════════ # 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명 # ════════════════════════════════════ - elif role == "첨부": + elif "block-card-num" in block_body: # 카드 넘버링 블록 st = get_text(primary_topic) # 마크다운 bold → HTML st = re.sub(r'\*\*(.+?)\*\*', r'\1', st) @@ -321,7 +328,7 @@ def assemble(run_dir: str): # ════════════════════════════════════ # 배경 — callout 구조 + 종속꼭지 인라인 + 강조 # ════════════════════════════════════ - elif role == "배경": + elif "block-callout-warn" in block_body or "block-callout-sol" in block_body: # callout 블록 sub_html = "" if is_hier and sup_tids: for st_id in sup_tids: @@ -371,7 +378,7 @@ def assemble(run_dir: str): # ════════════════════════════════════ # 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단) # ════════════════════════════════════ - elif role == "본심": + elif any(sc.get("name") == "svg" for sc in scs): # 이미지 포함 블록 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) @@ -461,7 +468,7 @@ def assemble(run_dir: str): if popup: content = popup.get("content", "") if content.count("|") > 3: - compact = popup_to_compact_table(content, font_size) + compact = popup_to_compact_table(content, font_size, role_name=role) if compact: popup_link_html = f'
[{pr}→]
' table_html += f'
{popup_link_html}{compact}
' @@ -502,23 +509,120 @@ def assemble(run_dir: str): 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))) + else: + # 범용 텍스트 조립 (블록 타입 무관) + all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) + bullets_html, _ = structured_to_bullets(all_text, role, font_size) + role_htmls[role] = ( + f'
' + f'
{topic_title}
' + f'{bullets_html}
' + ) - bg_top = pad + header_h + gap_block - core_top = bg_top + bg_h + gap_small - sb_top = bg_top + # ── Phase X: 동적 좌표 계산 (block_assembler와 동일 로직) ── + # zone별로 역할 그룹핑 + zone_roles = {} + footer_role = None + for role_name in ps.keys(): + ci = containers.get(role_name, {}) + z = ci.get("zone", "") if isinstance(ci, dict) else "" + if z == "footer": + footer_role = role_name + else: + if z not in zone_roles: + zone_roles[z] = [] + zone_roles[z].append(role_name) - # #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 바닥까지 + # row 그룹핑 + row_map = {} + for zone_name in zone_roles: + if zone_name.startswith("top"): + row = "top" + elif zone_name.startswith("bottom"): + row = "bottom" + else: + row = "main" + if row not in row_map: + row_map[row] = [] + if zone_name not in row_map[row]: + row_map[row].append(zone_name) + # footer 높이 + footer_h = 0 + if footer_role: + ci = containers.get(footer_role, {}) + footer_h = int(redist.get(footer_role, ci.get("height_px", 0))) + ft_top = slide_h - pad - footer_h + + content_top = pad + header_h + gap_block + content_bottom = ft_top - gap_block + + # row별 높이 + row_weights = {} + for row_name, zone_list in row_map.items(): + w = 0 + for zn in zone_list: + for rn in zone_roles.get(zn, []): + w += containers.get(rn, {}).get("weight", 0) if isinstance(containers.get(rn), dict) else 0 + row_weights[row_name] = w + total_rw = sum(row_weights.values()) or 1 + num_rows = len(row_map) + row_gap_total = gap_small * max(0, num_rows - 1) + available_h = content_bottom - content_top - row_gap_total + + row_layout = {} + current_top = content_top + for row_name in sorted(row_map.keys()): + rw = row_weights.get(row_name, 1) + rh = int(available_h * rw / total_rw) + row_layout[row_name] = {"top": current_top, "height": rh} + current_top += rh + gap_small + + # 컨테이너 좌표 + container_boxes = {} + _color_palette = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"] + all_role_names = list(ps.keys()) + + for row_name, zone_list in row_map.items(): + rl = row_layout[row_name] + num_cols = len(zone_list) + col_gap_total = gap_block * max(0, num_cols - 1) + col_available = inner_w - col_gap_total + zone_widths = [] + for zn in zone_list: + roles_in_zone = zone_roles.get(zn, []) + if roles_in_zone: + w = containers.get(roles_in_zone[0], {}).get("width_px", col_available // num_cols) if isinstance(containers.get(roles_in_zone[0]), dict) else col_available // num_cols + else: + w = col_available // num_cols + zone_widths.append(w) + total_zw = sum(zone_widths) or 1 + zone_widths = [int(col_available * w / total_zw) for w in zone_widths] + + current_left = pad + for i, zn in enumerate(zone_list): + zw = zone_widths[i] + for rn in zone_roles.get(zn, []): + container_boxes[rn] = {"left": current_left, "top": rl["top"], "width": zw, "height": rl["height"]} + current_left += zw + gap_block + + if footer_role: + container_boxes[footer_role] = {"left": pad, "top": ft_top, "width": inner_w, "height": footer_h} + + # HTML 조립 css_block = "\n".join(all_css) + containers_html = "" + for rn in all_role_names: + box = container_boxes.get(rn) + if not box: + continue + idx = all_role_names.index(rn) % len(_color_palette) + color = _color_palette[idx] + containers_html += ( + f'\n
' + f'{role_htmls.get(rn, "")}
\n' + ) html = f""" -
Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)
-
sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups
+
Stage 2: 코드 조립 결과
-
{title}
- -
-{role_htmls.get("배경", "")} -
- -
-{role_htmls.get("본심", "")} -
- -
-{role_htmls.get("첨부", "")} -
- -
-{role_htmls.get("결론", "")} -
- +{containers_html}
""" out = run / "steps" / "stage_2_code_assembled.html" diff --git a/src/block_assembler.py b/src/block_assembler.py index 22132de..258a572 100644 --- a/src/block_assembler.py +++ b/src/block_assembler.py @@ -19,8 +19,31 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} -FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} +# Phase X: 동적 색상 팔레트 (역할 수에 맞게 순환) +_COLOR_PALETTE = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"] + + +def _get_role_color(role: str, all_roles: list[str]) -> str: + """역할명 → 색상. 역할 수에 관계없이 동적 배분.""" + if role in all_roles: + idx = all_roles.index(role) % len(_COLOR_PALETTE) + return _COLOR_PALETTE[idx] + return _COLOR_PALETTE[0] + + +def _get_font_size_for_role(role: str, ctx: "PipelineContext") -> float: + """역할명 → font_size. font_hierarchy에서 zone 기반으로 동적 결정.""" + ci = ctx.containers.get(role) + if not ci: + return getattr(ctx.font_hierarchy, "core", 12) + zone = ci.zone + # zone에 따라 font_hierarchy 매핑 + if zone == "footer": + return getattr(ctx.font_hierarchy, "key_msg", 14) + elif zone in ("sidebar",): + return getattr(ctx.font_hierarchy, "sidebar", 10) + else: + return getattr(ctx.font_hierarchy, "core", 12) def assemble_role_html( @@ -51,8 +74,7 @@ def assemble_role_html( if not primary_topic: return "", set() - font_key = FONT_MAP.get(role, "core") - font_size = getattr(ctx.font_hierarchy, font_key, 12) + font_size = _get_font_size_for_role(role, ctx) sub_layouts = ctx.sub_layouts or {} role_sub = sub_layouts.get(role, {}) role_scs = role_sub.get("sub_containers", []) @@ -366,7 +388,7 @@ def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: """전체 슬라이드를 조립하여 HTML 반환. - filled, assembled, stage_2 모두 이 함수를 호출. + Phase X: 동적 역할. ctx.containers의 모든 역할을 zone 기반 좌표로 배치. """ from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() @@ -374,47 +396,158 @@ def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str: 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 = {} + all_roles = list(ctx.page_structure.roles.keys()) - for role in ["배경", "본심", "첨부", "결론"]: + for role in all_roles: 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)) + # 좌표 계산: ctx.containers에서 zone/height/width를 읽어 position:absolute 배치 + # 템플릿 rows 구조를 재구성하여 좌표 계산 + containers = ctx.containers + fit = ctx.fit_result or {} + redist = fit.get("redistribution", {}) - bg_top = pad + header_h + gap_block - core_top = bg_top + bg_h + gap_small - sb_top = bg_top + # zone별로 역할 그룹핑 + zone_roles = {} # zone → [role_name, ...] + footer_role = None + for role_name, ci in containers.items(): + zone = ci.zone + if zone == "footer": + footer_role = role_name + else: + if zone not in zone_roles: + zone_roles[zone] = [] + zone_roles[zone].append(role_name) - # 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 + # row 그룹핑: 같은 row에 속하는 zone들 (템플릿 정보가 없으면 containers에서 추론) + # 간단한 방식: zone 이름에서 row를 추론 + # top* → top row, bottom* → bottom row, body/sidebar/left/right/main → main row + row_map = {} # row_name → [zone_name, ...] + for zone_name in zone_roles: + if zone_name.startswith("top"): + row = "top" + elif zone_name.startswith("bottom"): + row = "bottom" + else: + row = "main" + if row not in row_map: + row_map[row] = [] + if zone_name not in row_map[row]: + row_map[row].append(zone_name) + + # footer 높이 + footer_h = 0 + if footer_role: + ci = containers[footer_role] + footer_h = int(redist.get(footer_role, ci.height_px)) + + # footer 위치: 슬라이드 바닥 - pad - footer_h + ft_top = slide_h - pad - footer_h + + # 중간 영역: header 아래 ~ footer 위 + content_top = pad + header_h + gap_block + content_bottom = ft_top - gap_block + + # row별 높이 계산: weight 비율로 + row_weights = {} + for row_name, zone_list in row_map.items(): + w = 0 + for zn in zone_list: + for rn in zone_roles.get(zn, []): + ci = containers.get(rn) + if ci: + w += ci.weight + row_weights[row_name] = w + + total_rw = sum(row_weights.values()) + if total_rw <= 0: + total_rw = 1 + + num_rows = len(row_map) + row_gap_total = gap_small * max(0, num_rows - 1) + available_h = content_bottom - content_top - row_gap_total + + # 각 row의 top/height 계산 + row_layout = {} # row_name → {"top": px, "height": px} + current_top = content_top + for row_name in sorted(row_map.keys()): # top → bottom → main 순서 + rw = row_weights.get(row_name, 1) + rh = int(available_h * rw / total_rw) + row_layout[row_name] = {"top": current_top, "height": rh} + current_top += rh + gap_small + + # 각 컨테이너의 좌표 계산 + container_boxes = {} # role_name → {"left", "top", "width", "height"} + for row_name, zone_list in row_map.items(): + rl = row_layout[row_name] + # 이 row의 zone들 — 가로 분할 + num_cols = len(zone_list) + col_gap_total = gap_block * max(0, num_cols - 1) + col_available = inner_w - col_gap_total + + # 각 zone의 폭: containers의 width_px 비율로 + zone_widths = [] + for zn in zone_list: + roles_in_zone = zone_roles.get(zn, []) + if roles_in_zone: + w = containers[roles_in_zone[0]].width_px + else: + w = col_available // num_cols + zone_widths.append(w) + # 비율 정규화 + total_zw = sum(zone_widths) + if total_zw > 0: + zone_widths = [int(col_available * w / total_zw) for w in zone_widths] + + current_left = pad + for i, zn in enumerate(zone_list): + zw = zone_widths[i] + for rn in zone_roles.get(zn, []): + container_boxes[rn] = { + "left": current_left, + "top": rl["top"], + "width": zw, + "height": rl["height"], + } + current_left += zw + gap_block + + # footer + if footer_role: + container_boxes[footer_role] = { + "left": pad, + "top": ft_top, + "width": inner_w, + "height": footer_h, + } title = title_text or ctx.analysis.title or "" css_block = "\n".join(all_css) + # HTML 조립 + containers_html = "" + for role_name in all_roles: + box = container_boxes.get(role_name) + if not box: + continue + color = _get_role_color(role_name, all_roles) + content = role_htmls.get(role_name, "") + containers_html += ( + f'\n
' + f'' + f'{role_name} ({box["width"]}x{box["height"]}px)' + f'{content}
\n' + ) + 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("첨부", "")}
- - - +{containers_html}
""" diff --git a/src/design_director.py b/src/design_director.py index 110edbf..7fec6e7 100644 --- a/src/design_director.py +++ b/src/design_director.py @@ -369,6 +369,98 @@ LAYOUT_PRESETS = { } +# ══════════════════════════════════════ +# Phase X: 레이아웃 템플릿 (Kei가 선택) +# ══════════════════════════════════════ +# 중간 영역의 컨테이너 구조 정의. +# 상단(header)과 하단(footer/결론)은 모든 템플릿 공통. +# zones의 각 영역: +# - width_pct: 슬라이드 내부 폭 대비 % (gap 제외 전) +# - row: 어느 행에 속하는지 ("top", "bottom", "full") +# - col_span: 해당 행에서 몇 칸 중 하나인지 (행 내 등분) +# 크기는 weight와 슬라이드 크기에서 동적 계산. budget_px 없음. +LAYOUT_TEMPLATES = { + "A": { + "name": "body-sidebar", + "description": "좌측 본문 흐름 + 우측 참조 사이드바", + "rows": [ + {"name": "main", "zones": ["body", "sidebar"], "splits": [65, 35]}, + ], + "zones": { + "body": {"row": "main", "desc": "본문 꼭지 (위→아래 순서)"}, + "sidebar": {"row": "main", "desc": "참조/보조 정보"}, + }, + }, + "B": { + "name": "top-wide-bottom-2col", + "description": "상단 강조 + 하단 2분할", + "rows": [ + {"name": "top", "zones": ["top"], "splits": [100]}, + {"name": "bottom", "zones": ["bottom_left", "bottom_right"], "splits": [50, 50]}, + ], + "zones": { + "top": {"row": "top", "desc": "강조/핵심 콘텐츠 (전체 폭)"}, + "bottom_left": {"row": "bottom", "desc": "하단 좌측 항목"}, + "bottom_right": {"row": "bottom", "desc": "하단 우측 항목"}, + }, + }, + "C": { + "name": "top-wide-bottom-3col", + "description": "상단 강조 + 하단 3분할", + "rows": [ + {"name": "top", "zones": ["top"], "splits": [100]}, + {"name": "bottom", "zones": ["bottom_1", "bottom_2", "bottom_3"], "splits": [33, 34, 33]}, + ], + "zones": { + "top": {"row": "top", "desc": "강조/핵심 콘텐츠 (전체 폭)"}, + "bottom_1": {"row": "bottom", "desc": "하단 첫째 항목"}, + "bottom_2": {"row": "bottom", "desc": "하단 둘째 항목"}, + "bottom_3": {"row": "bottom", "desc": "하단 셋째 항목"}, + }, + }, + "D": { + "name": "2col-equal", + "description": "좌우 대등 2분할", + "rows": [ + {"name": "main", "zones": ["left", "right"], "splits": [50, 50]}, + ], + "zones": { + "left": {"row": "main", "desc": "좌측 항목"}, + "right": {"row": "main", "desc": "우측 항목"}, + }, + }, + "E": { + "name": "single-full", + "description": "단일 전체 (분할 불필요)", + "rows": [ + {"name": "main", "zones": ["main"], "splits": [100]}, + ], + "zones": { + "main": {"row": "main", "desc": "본심 전체"}, + }, + }, + "F": { + "name": "top-wide-bottom-2col-sidebar", + "description": "상단 강조 + 하단 2분할 + 우측 참조", + "rows": [ + {"name": "top", "zones": ["top", "sidebar"], "splits": [65, 35]}, + {"name": "bottom", "zones": ["bottom_left", "bottom_right", "sidebar"], "splits": [32, 33, 35]}, + ], + "zones": { + "top": {"row": "top", "desc": "강조/핵심 콘텐츠"}, + "sidebar": {"row": "top+bottom", "desc": "참조/보조 정보 (세로 관통)"}, + "bottom_left": {"row": "bottom", "desc": "하단 좌측 항목"}, + "bottom_right": {"row": "bottom", "desc": "하단 우측 항목"}, + }, + }, +} + + +def get_template(template_id: str) -> dict[str, Any] | None: + """템플릿 ID(A~F)로 템플릿 정의를 반환한다.""" + return LAYOUT_TEMPLATES.get(template_id) + + # ────────────────────────────────────── # Step A: 프리셋 선택 (규칙 기반) # ────────────────────────────────────── diff --git a/src/fit_verifier.py b/src/fit_verifier.py index 6cdb8c6..0824350 100644 --- a/src/fit_verifier.py +++ b/src/fit_verifier.py @@ -304,8 +304,16 @@ def calculate_fit( if normalized is None: normalized = {} - role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"} - role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3} + # Phase X: zone 기반 font/line_height 결정 (고정 역할명 불필요) + def _font_key_for_role(role_name): + zone = containers.get(role_name, {}).get("zone", "") + if zone == "footer": return "key_msg" + if zone == "sidebar": return "sidebar" + return "core" + def _line_height_for_role(role_name): + zone = containers.get(role_name, {}).get("zone", "") + if zone == "footer": return 1.3 + return 1.5 analysis = FitAnalysis() @@ -327,9 +335,9 @@ def calculate_fit( allocated_h = container.get("height_px", 0) width_px = container.get("width_px", 0) - font_key = role_font_map.get(role, "core") + font_key = _font_key_for_role(role) font_size = font_hierarchy.get(font_key, 12) - line_h = role_line_height.get(role, 1.5) + line_h = _line_height_for_role(role) # V-1 출력: 꼭지별 블록 리스트 ref_list = references.get(role, []) @@ -375,7 +383,8 @@ def calculate_fit( has_image = img_h > 0 # ── 4. key-msg (본심에만) ── - has_keymsg = (role == "본심" and core_message) + # keymsg: 이미지가 있는 역할에만 (zone 무관, 블록 기반) + has_keymsg = (has_image and core_message) keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0 # ── 5. 블록 오버헤드 ── @@ -485,12 +494,7 @@ def build_escalation_report(analysis: FitAnalysis) -> str: return "\n".join(lines) -ROLE_ZONE_MAP = { - "본심": "body", - "배경": "body", - "첨부": "sidebar", - "결론": "footer", -} +# Phase X: ROLE_ZONE_MAP 제거. containers에서 zone을 직접 읽음. def redistribute( @@ -504,7 +508,9 @@ def redistribute( """ zone_roles: dict[str, list[str]] = {} for role in analysis.roles: - zone = ROLE_ZONE_MAP.get(role, "body") + # Phase X: containers에서 zone 직접 읽기 (고정 맵 불필요) + ci = containers.get(role, {}) + zone = ci.get("zone", "body") if isinstance(ci, dict) else "body" if zone not in zone_roles: zone_roles[zone] = [] zone_roles[zone].append(role) @@ -939,8 +945,8 @@ def calculate_sub_layout( 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") + # Phase X: font_key는 font_hierarchy에서 가장 가까운 키 사용 + font_key = "core" # 기본값. zone 정보가 없으면 core title_font = font_hierarchy.get(font_key, 12) title_h = title_font * 1.5 + tokens["spacing_small"] diff --git a/src/html_generator.py b/src/html_generator.py index ba0837e..eb303eb 100644 --- a/src/html_generator.py +++ b/src/html_generator.py @@ -82,9 +82,14 @@ def build_area_prompt( refs = phase_t.get("references", {}) budgets = phase_t.get("design_budgets", {}) - # 역할별 폰트 매핑 - role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} - font_size = fh.get(role_font_map.get(role, "core"), 12) + # Phase X: zone 기반 font 결정 (고정 역할명 불필요) + # phase_t에서 이 역할의 zone을 찾아 font 매핑 + _ps = phase_t.get("fit_result", {}).get("redistribution", {}) + # containers 정보가 있으면 zone 사용, 없으면 core 기본 + _containers = phase_t.get("_containers", {}) + _zone = _containers.get(role, {}).get("zone", "") if _containers else "" + _fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core") + font_size = fh.get(_fk, 12) # 들여쓰기 (폰트 크기 기반) indent_pl, indent_ti = _calc_indent(font_size) @@ -192,8 +197,8 @@ def build_area_prompt( indent_example = f"""
• 첫줄 텍스트가 여기서 시작하고 둘째줄도 정확히 같은 위치에서 시작한다
""" - # ── 역할별 지시 ── - if role == "배경": + # ── Phase X: zone 기반 역할 지시 (고정 역할명 불필요) ── + if _zone in ("top",) and not images: # 상단 강조/도입 영역 (이미지 없음) parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라. ## 핵심 원칙 @@ -214,7 +219,7 @@ def build_area_prompt( 불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""") - elif role == "본심": + elif images or _zone in ("main", "body"): # 이미지 포함 또는 메인 콘텐츠 img_instruction = "" if images: for img in images: @@ -264,7 +269,7 @@ def build_area_prompt( - 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라. {img_instruction}""") - elif role == "첨부": + elif _zone == "sidebar": # 참조/보조 sidebar parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라. ## 크기 (TP-3: 잘림 방지) @@ -286,7 +291,7 @@ def build_area_prompt( - 카드 간 간격 8px. - 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""") - elif role == "결론": + elif _zone == "footer": # 결론/footer parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라. ## 크기 @@ -296,6 +301,21 @@ def build_area_prompt( - 핵심 메시지: {font_size}px bold white - 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""") + else: # bottom_left, bottom_right, bottom_1~3 등 범용 콘텐츠 영역 + parts.append(f"""다음 콘텐츠를 "{role}" 영역 HTML로 만들어라. + +## 크기 +- width: 100%, height: {height_px}px +- 이 크기 안에 모든 내용이 들어가야 한다. + +## 폰트 +- 본문: {font_size}px. 제목: {font_size + 1}px bold. + +## 들여쓰기 +{indent_example} + +불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""") + # ── 공통: 콘텐츠 ── parts.append(f""" ## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.) @@ -544,8 +564,10 @@ def _build_phase_t_supplement(role: str, analysis: dict) -> str: # 1. 폰트 위계 (역할별 확정 폰트) fh = phase_t.get("font_hierarchy", {}) - role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"} - font_key = role_font_map.get(role, "core") + # Phase X: zone 기반 font 결정 + _regen_containers = phase_t.get("_containers", {}) + _regen_zone = _regen_containers.get(role, {}).get("zone", "") if _regen_containers else "" + font_key = "key_msg" if _regen_zone == "footer" else ("sidebar" if _regen_zone == "sidebar" else "core") font_size = fh.get(font_key, 12) parts.append( f"\n[폰트 위계 — 반드시 준수]\n" @@ -614,132 +636,66 @@ async def generate_slide_html( return [] return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map] - bg_topics = get_topics_for_role("배경") - core_topics = get_topics_for_role("본심") - ref_topics = get_topics_for_role("첨부") - conclusion_topics = get_topics_for_role("결론") - - bg_spec = container_specs.get("배경") - core_spec = container_specs.get("본심") - ref_spec = container_specs.get("첨부") - concl_spec = container_specs.get("결론") - + # Phase X: 동적 역할 루프 — page_structure의 모든 역할을 순회 result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""} - # ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ── from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() - bg_h = bg_spec.height_px if bg_spec else 0 - core_h = core_spec.height_px if core_spec else 0 - footer_h = concl_spec.height_px if concl_spec else 0 - sidebar_h = ref_spec.height_px if ref_spec else 0 - # body zone = 배경 + 본심 + gap bg_core_gap = tokens["spacing_small"] - body_zone_h = bg_h + core_h + (bg_core_gap if bg_topics and core_topics else 0) - sidebar_zone_h = sidebar_h if sidebar_h > 0 else body_zone_h - # core_max_h: 본심 컨테이너 높이에서 key-msg 높이를 빼야 Sonnet이 넘치지 않음 phase_t = analysis.get("phase_t", {}) - core_sub = phase_t.get("sub_layouts", {}).get("본심", {}) - keymsg_sub_h = 0 - for sc in core_sub.get("sub_containers", []): - if sc.get("name") == "keymsg": - keymsg_sub_h = sc.get("height_px", 0) - core_max_h = core_h - keymsg_sub_h if core_h > 0 else (body_zone_h - bg_h - bg_core_gap if bg_topics else body_zone_h) - logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px (keymsg={keymsg_sub_h}px 제외)") - - # Phase T context - phase_t = analysis.get("phase_t", {}) - - # 원본 텍스트 매핑 sections = _slice_mdx_sections(content) - # ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ── def _get_role_content(role_topics): - """structured_text를 우선 사용. 없으면 기존 sections 매칭.""" texts = [] for t in role_topics: st = t.get("structured_text", "") if st: texts.append(st) else: - # fallback: source_hint 키워드로 sections에서 매칭 keywords = _extract_keywords_from_hints([t]) matched = _map_sections_for_role(sections, [t], keywords) if matched: texts.append(matched) return "\n\n".join(texts) if texts else "" - # ── 배경 ── - if bg_topics: - logger.info("[Phase T] 배경 생성...") - bg_content = _get_role_content(bg_topics) - body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0) - prompt = build_area_prompt( - role="배경", - content_block=bg_content, - phase_t=phase_t, - height_px=bg_h, - width_px=body_width, - ) - html = await _call_claude(client, prompt) - if html: - result["body_html"] += html + f'\n
\n' - logger.info(f"[Phase T] 배경 완료: {len(html)}자") + for role_name, role_info in page_struct.items(): + if not isinstance(role_info, dict): + continue + role_topics = get_topics_for_role(role_name) + if not role_topics: + continue + spec = container_specs.get(role_name) + if not spec: + continue - # ── 본심 ── - if core_topics: - logger.info("[Phase T] 본심 생성...") - core_content = _get_role_content(core_topics) - core_images = [img for img in images if img.get("topic_id") in [t["id"] for t in core_topics]] - body_width = core_spec.width_px if core_spec else (bg_spec.width_px if bg_spec else 0) - prompt = build_area_prompt( - role="본심", - content_block=core_content, - phase_t=phase_t, - height_px=core_max_h, - width_px=body_width, - images=core_images, - core_message=analysis.get("core_message", ""), - ) - html = await _call_claude(client, prompt) - if html: - html = _replace_img_placeholder(html, images) - result["body_html"] += html + "\n" - logger.info(f"[Phase T] 본심 완료: {len(html)}자") + zone = spec.zone + role_content = _get_role_content(role_topics) + role_images = [img for img in images if img.get("topic_id") in [t["id"] for t in role_topics]] - # ── sidebar ── - if ref_topics: - logger.info("[Phase T] sidebar 생성...") - sidebar_content = _get_role_content(ref_topics) - sidebar_width = ref_spec.width_px if ref_spec else 0 + logger.info(f"[Phase X] {role_name}({zone}) 생성...") prompt = build_area_prompt( - role="첨부", - content_block=sidebar_content, + role=role_name, + content_block=role_content, phase_t=phase_t, - height_px=sidebar_zone_h, - width_px=sidebar_width, + height_px=spec.height_px, + width_px=spec.width_px, + images=role_images if role_images else None, + core_message=analysis.get("core_message", "") if zone != "footer" else "", ) html = await _call_claude(client, prompt) if html: - result["sidebar_html"] = html - logger.info(f"[Phase T] sidebar 완료: {len(html)}자") + if role_images: + html = _replace_img_placeholder(html, images) + # zone에 따라 적절한 result 키에 추가 + if zone == "footer": + result["footer_html"] += html + "\n" + elif zone == "sidebar": + result["sidebar_html"] += html + "\n" + else: + result["body_html"] += html + f'\n
\n' + logger.info(f"[Phase X] {role_name} 완료: {len(html)}자") - # ── footer ── - if conclusion_topics: - logger.info("[Phase T] footer 생성...") - footer_content = _get_role_content(conclusion_topics) or _get_conclusion(content) - footer_width = concl_spec.width_px if concl_spec else 0 - prompt = build_area_prompt( - role="결론", - content_block=footer_content.strip(), - phase_t=phase_t, - height_px=concl_spec.height_px if concl_spec else 0, - width_px=footer_width, - ) - html = await _call_claude(client, prompt) - if html: - result["footer_html"] = html - logger.info(f"[Phase T] footer 완료: {len(html)}자") + # Phase X: 동적 루프에서 모든 역할 처리 완료. 기존 sidebar/footer 개별 블록 제거. result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용." return result diff --git a/src/kei_client.py b/src/kei_client.py index 3bef83c..c9ffbea 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -23,61 +23,69 @@ KEI_PROMPT = ( "- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n" "- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n" "- core_message 필드에 기록.\n\n" - "## 2단계: 정보 구조 파악\n" - "- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n" - "- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n" - "- info_structure 필드에 기술.\n\n" - "## 3단계: 슬라이드 스토리라인 설계\n" - "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n" - "각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n" - "## 4단계: 페이지 구조 판단 (비중 시스템)\n" - "콘텐츠를 분석하여 이 페이지의 **구조와 비중**을 판단하라:\n\n" - "- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.\n" - " 비교라면 비교표, 관계라면 관계도, 프로세스라면 흐름도로 구조화.\n" - " 비교 구조일 때 비교 목적(왜 비교하는가)을 summary에 명시.\n" - "- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게. 2-3줄이면 충분.\n" - "- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.\n" - " role: 'reference'로 표시. 본문 흐름을 방해하지 않도록.\n" - "- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.\n\n" - "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n" - "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n" - "page_structure 필드에 기록.\n\n" + "## 2단계: 콘텐츠 분석 및 꼭지 설계\n" + "콘텐츠를 분석하여 꼭지(topic)를 나눠라.\n" + "- 꼭지 수와 역할명은 **콘텐츠에 맞게 자유롭게** 결정하라.\n" + "- '배경/본심/첨부/결론' 같은 고정 틀에 억지로 끼우지 마라.\n" + "- 배경이 없으면 배경을 만들지 마라. 3분할이 적절하면 3분할하라.\n" + "- 결론 꼭지는 반드시 1개 있어야 한다 (슬라이드 하단 핵심 한 줄).\n" + "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n" + "## 3단계: 레이아웃 템플릿 선택\n" + "꼭지 구조를 보고, 아래 템플릿 중 **가장 적합한 것을 선택**하라.\n\n" + "### 템플릿 옵션:\n" + "**A. body-sidebar** — 좌측 본문 + 우측 참조\n" + " zones: body(65%), sidebar(35%) — 1행\n" + " 적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠\n\n" + "**B. top-wide + bottom-2col** — 상단 강조 + 하단 2분할\n" + " zones: top(100%) / bottom_left(50%), bottom_right(50%) — 2행\n" + " 적합: 핵심 1개 + 두 가지 측면 비교/설명\n\n" + "**C. top-wide + bottom-3col** — 상단 강조 + 하단 3분할\n" + " zones: top(100%) / bottom_1(33%), bottom_2(34%), bottom_3(33%) — 2행\n" + " 적합: 핵심 1개 + 세 가지 항목 병렬\n\n" + "**D. 2col-equal** — 좌우 대등 2분할\n" + " zones: left(50%), right(50%) — 1행\n" + " 적합: 두 가지 비교/대비\n\n" + "**E. single-full** — 단일 전체\n" + " zones: main(100%) — 1행\n" + " 적합: 하나의 흐름, 분할 불필요\n\n" + "**F. top-wide + bottom-2col + sidebar** — 상단 강조 + 하단 2분할 + 참조\n" + " zones: top(65%)+sidebar(35%) / bottom_left(32%)+bottom_right(33%)+sidebar(35%) — 2행\n" + " 적합: B + 참조자료 조합\n\n" + "## 4단계: page_structure 작성\n" + "선택한 템플릿의 zone에 꼭지를 배정하고 weight(공간 비중, 합계 1.0)를 결정하라.\n" + "- page_structure의 키 = zone 이름 + 결론(footer)\n" + "- 결론은 항상 \"결론\" 키로, zone은 항상 \"footer\"\n" + "- 나머지 키 이름은 콘텐츠에 맞게 자유롭게 (예: \"핵심목표\", \"발주처_기대효과\" 등)\n" + "- 각 항목에 zone, topic_ids, weight를 기록\n" + "- **weight는 콘텐츠에 따라 매번 달라진다. 고정값이 아니다.**\n\n" "## 원본 텍스트 보존 원칙\n" "- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n" "- 원본 텍스트는 최대한 보존. 약간의 편집만.\n" - "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n" - "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시\n\n" - "## 배치 규칙\n" - "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n" - "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n" - "- 결론은 layer: 'conclusion' → 하단 배치\n" - "- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n" + "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n\n" + "## 규칙\n" "- 이미지/표가 있으면 images[], tables[]에 기록\n" - "- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n" - "- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n" + "- 1페이지 적정 꼭지: 3~6개. 분량에 맞게.\n" + "- **슬라이드 제목(title)과 꼭지 제목은 달라야 한다.**\n\n" "## 출력 형식 (JSON만)\n" "```json\n" - '{"title": "제목", ' - '"core_message": "이 슬라이드의 핵심 메시지 한 줄", ' + '{"title": "슬라이드 제목", ' + '"core_message": "핵심 메시지 한 줄", ' '"total_pages": 1, ' - '"info_structure": "정보 구조 설명", ' + '"layout_template": "B", ' '"page_structure": {' - '"본심": {"topic_ids": [2, 3], "weight": 0.60}, ' - '"배경": {"topic_ids": [1], "weight": 0.15}, ' - '"첨부": {"topic_ids": [4], "weight": 0.15}, ' - '"결론": {"topic_ids": [5], "weight": 0.10}}, ' + '"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.25}, ' + '"발주처_기대효과": {"zone": "bottom_1", "topic_ids": [2], "weight": 0.20}, ' + '"설계사_기대효과": {"zone": "bottom_2", "topic_ids": [3], "weight": 0.20}, ' + '"시공사_기대효과": {"zone": "bottom_3", "topic_ids": [4], "weight": 0.20}, ' + '"결론": {"zone": "footer", "topic_ids": [5], "weight": 0.15}}, ' '"topics": [' '{"id": 1, "title": "꼭지 제목", "summary": "요약", ' '"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", ' '"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", ' - '"layer": "intro|core|supporting|conclusion", ' - '"role": "flow|reference", ' - '"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", ' - '"emphasis": true, "direction": "vertical|horizontal|flexible", ' '"content_type": "text|image|table|mixed", ' - '"detail_target": false, "page": 1}], ' - '"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], ' - '"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n' + '"page": 1}], ' + '"images": [{"topic_id": 1, "description": "이미지 설명"}], ' + '"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "description": "표 설명"}]}\n' "```\n\n" "## 콘텐츠:\n" ) @@ -1263,8 +1271,8 @@ async def call_kei_bold_keywords( - 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다. - 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다. -JSON으로 응답하라: -{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}} +JSON으로 응답하라. 키는 아래 역할명을 그대로 사용: +{{{", ".join(f'"{r}": ["키워드", ...]' for r in role_texts.keys())}}} 빈 역할은 빈 리스트로. {role_section}""" diff --git a/src/pipeline.py b/src/pipeline.py index c450a8b..be4e64b 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -176,6 +176,7 @@ async def generate_slide( core_message=analysis_raw.get("core_message", ""), title=analysis_raw.get("title", ""), total_pages=analysis_raw.get("total_pages", 1), + layout_template=analysis_raw.get("layout_template", "E"), ) # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 @@ -299,43 +300,58 @@ async def generate_slide( # T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식) font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths) + # Phase X: zone 기반 font_hierarchy (역할명 자유) + # footer → key_msg, sidebar → sidebar, 나머지 → core/bg + # 텍스트 양이 가장 많은 역할 = core, 적은 역할 = bg + ps_roles = context.page_structure.roles + non_footer = {r: l for r, l in role_text_lengths.items() + if ps_roles.get(r, {}).get("zone") != "footer"} + sidebar_roles = {r: l for r, l in role_text_lengths.items() + if ps_roles.get(r, {}).get("zone") == "sidebar"} + core_roles = {r: l for r, l in non_footer.items() if r not in sidebar_roles} + + # 가장 긴 텍스트 역할의 font → core, 나머지 → bg + core_font = max(font_hierarchy_dict.values()) if font_hierarchy_dict else 12.0 + bg_font = min(v for v in font_hierarchy_dict.values() if v < core_font) if len(font_hierarchy_dict) > 1 else core_font - 1 + sidebar_font = min(font_hierarchy_dict.values()) if sidebar_roles else bg_font 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), + key_msg=14.0, + core=core_font, + bg=bg_font, + sidebar=sidebar_font, ) - # 프리셋 선택 (비율 계산보다 먼저 — 프리셋의 기본 비율을 fallback으로 사용) - analysis_dict = { - "topics": [t.model_dump() for t in context.topics], - "page_structure": context.page_structure.roles, - } - preset_name = select_preset(analysis_dict) - preset = LAYOUT_PRESETS.get(preset_name, {}) + # Phase X: 템플릿 기반 컨테이너 생성 + from src.design_director import LAYOUT_TEMPLATES, get_template + from src.space_allocator import build_containers_from_template - # T-5: 동적 비율 역산 (sidebar 텍스트 양 기반 + 프리셋 기본 비율) - container_ratio = calculate_dynamic_ratio( - role_text_lengths, font_hierarchy_dict, - slide_width=settings.slide_width, - slide_height=settings.slide_height, - preset=preset, - ) - logger.info( - f"[T-5] 폰트 위계: 핵심={font_hierarchy.key_msg}, 본심={font_hierarchy.core}, " - f"배경={font_hierarchy.bg}, 첨부={font_hierarchy.sidebar} / " - f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}" - ) + # Kei가 선택한 layout_template (Stage 1A에서 저장됨) + layout_template_id = context.analysis.layout_template or "E" + template = get_template(layout_template_id) or LAYOUT_TEMPLATES.get("E", {}) + preset_name = layout_template_id + preset = template # 하위 호환 - # 컨테이너 스펙 계산 (기존 space_allocator 활용) - container_specs = calculate_container_specs( + # 컨테이너 스펙 계산 (Phase X 템플릿 기반) + container_specs = build_containers_from_template( page_structure=context.page_structure.roles, - topics=[t.model_dump() for t in context.topics], - preset=preset, + template=template, slide_width=settings.slide_width, slide_height=settings.slide_height, ) + # container_ratio: 템플릿에서 추론 (하위 호환) + rows = template.get("rows", []) + if rows and len(rows[0].get("zones", [])) == 2: + splits = rows[0].get("splits", [50, 50]) + container_ratio = splits + else: + container_ratio = [100, 0] + + logger.info( + f"[Phase X] 템플릿={layout_template_id}, " + f"폰트: core={font_hierarchy.core}, bg={font_hierarchy.bg}, sidebar={font_hierarchy.sidebar}" + ) + # ContainerSpec → ContainerInfo 변환 containers = {} for role, spec in container_specs.items(): @@ -696,9 +712,10 @@ async def generate_slide( text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None) if not text_sc: continue - # 텍스트 줄 수 계산 - role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} - fk = role_font_map.get(role, "core") + # 텍스트 줄 수 계산 (zone 기반 font_size) + ci = updated_containers.get(role) + zone = ci.zone if ci else "" + fk = "key_msg" if zone == "footer" else ("sidebar" if zone == "sidebar" else "core") fs = font_h.get(fk, 12) # structured_text에서 팝업 마커 찾기 ps_info = context.page_structure.roles.get(role, {}) @@ -789,9 +806,10 @@ async def generate_slide( # V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용 ref_list = context.references.get(role, []) schema_info = ref_list[0].schema_info if ref_list else {} - font_size = getattr(context.font_hierarchy, { - "본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "core" - }.get(role, "core"), 12.0) + # Phase X: zone 기반 font_size + _zone = ci.zone if ci else "" + _fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core") + font_size = getattr(context.font_hierarchy, _fk, 12.0) budget = calculate_design_budget( container_height_px=ci.height_px, diff --git a/src/pipeline_context.py b/src/pipeline_context.py index f6c244d..4725ab4 100644 --- a/src/pipeline_context.py +++ b/src/pipeline_context.py @@ -63,6 +63,7 @@ class Analysis(BaseModel): core_message: str = "" title: str = "" total_pages: int = 1 + layout_template: str = "E" # Phase X: Kei가 선택한 템플릿 ID (A~F) image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict) # topics와 page_structure는 PipelineContext 최상위에 위치 @@ -111,7 +112,7 @@ class FontHierarchy(BaseModel): @model_validator(mode="after") def check_hierarchy(self): """폰트 위계 유지 검증: key_msg > core >= bg > sidebar.""" - if not (self.key_msg > self.core >= self.bg > self.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}) 이어야 함" diff --git a/src/renderer.py b/src/renderer.py index d4c2213..93a27ad 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -231,7 +231,7 @@ def _group_blocks_by_area( container_htmls = [] assigned_ids = set() - role_order = ["배경", "본심"] + role_order = [r for r, s in container_specs.items() if s.zone == area] for role in role_order: spec = container_specs.get(role) if not spec or spec.zone != area: @@ -547,12 +547,19 @@ def render_slide_from_html( _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" + # Phase X: 동적 grid-template-rows (역할명 무관) + _footer_h = 0 + _content_h = 0 + for rn, ci_data in containers.items(): + h = int(redist.get(rn, ci_data.get("height_px", 0))) + zone = ci_data.get("zone", "") + if zone == "footer": + _footer_h = h + else: + _content_h += h + _content_h += _gap_small * max(0, len([c for c in containers.values() if c.get("zone") != "footer"]) - 1) + if _content_h > 0 and _footer_h > 0: + grid_rows = f"auto {_content_h}px {_footer_h}px" else: grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto") diff --git a/src/space_allocator.py b/src/space_allocator.py index 5fbdfe8..d1d6199 100644 --- a/src/space_allocator.py +++ b/src/space_allocator.py @@ -433,6 +433,164 @@ def calculate_container_specs( return specs +# ══════════════════════════════════════ +# Phase X: 템플릿 기반 컨테이너 생성 +# ══════════════════════════════════════ +def build_containers_from_template( + page_structure: dict[str, Any], + template: dict[str, Any], + slide_width: int = 1280, + slide_height: int = 720, +) -> dict[str, ContainerSpec]: + """템플릿 + Kei page_structure → 역할별 ContainerSpec. + + 템플릿의 rows/zones 구조와 Kei의 weight로 동적 계산. + 하드코딩 없음. 모든 크기는 슬라이드 크기 + weight + 템플릿 splits에서 계산. + + Args: + page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.3}, ...} + template: LAYOUT_TEMPLATES["B"] 등 + slide_width: 슬라이드 너비 + slide_height: 슬라이드 높이 + + Returns: + {"핵심목표": ContainerSpec(...), "Process_혁신": ContainerSpec(...), ...} + """ + 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"] + inner_w = slide_width - pad * 2 + + # 결론(footer) weight와 나머지 분리 + footer_role = None + footer_weight = 0 + content_roles = {} # zone → [(role_name, info)] + for role_name, info in page_structure.items(): + if not isinstance(info, dict): + continue + zone = info.get("zone", "") + if zone == "footer": + footer_role = role_name + footer_weight = info.get("weight", 0.1) + else: + if zone not in content_roles: + content_roles[zone] = [] + content_roles[zone].append((role_name, info)) + + # 전체 가용 높이 (header + footer 제외) + total_content_h = slide_height - pad * 2 - header_h - gap_block # header 아래 ~ 슬라이드 바닥-pad + + # footer 높이 + footer_h = max(40, int(total_content_h * footer_weight)) if footer_weight > 0 else 53 + # 중간 영역 높이 (footer와 gap 제외) + middle_h = total_content_h - footer_h - gap_block + + # 템플릿 rows에서 각 row의 높이 계산 + rows = template.get("rows", []) + num_rows = len(rows) + row_gap_total = gap_small * max(0, num_rows - 1) + available_row_h = middle_h - row_gap_total + + # 각 row의 높이: row에 속한 역할들의 weight 합 비율로 + row_weights = {} + for row_def in rows: + row_name = row_def["name"] + zones_in_row = row_def["zones"] + # 이 row에 속한 역할들의 weight 합 + w_sum = 0 + for zone_name in zones_in_row: + for rn, ri in content_roles.get(zone_name, []): + w_sum += ri.get("weight", 0) + row_weights[row_name] = w_sum + + total_row_weight = sum(row_weights.values()) + if total_row_weight <= 0: + total_row_weight = 1 + + specs = {} + + for row_def in rows: + row_name = row_def["name"] + zones_in_row = row_def["zones"] + splits = row_def["splits"] + + # row 높이 + row_h = int(available_row_h * row_weights.get(row_name, 1) / total_row_weight) + + # row 내 각 zone의 폭 + for i, zone_name in enumerate(zones_in_row): + # sidebar가 여러 row에 걸치는 경우 (F 템플릿) — 첫 번째에서만 생성 + if zone_name in specs: + continue + + width_pct = splits[i] + # gap 제외 폭 계산 + num_cols = len(zones_in_row) + col_gap_total = gap_block * max(0, num_cols - 1) + zone_w = int((inner_w - col_gap_total) * width_pct / 100) + + # sidebar가 여러 row에 걸치면 높이 = middle_h 전체 + zone_zones = template.get("zones", {}) + zone_def = zone_zones.get(zone_name, {}) + if "+" in zone_def.get("row", ""): + zone_h = middle_h # top+bottom 관통 + else: + zone_h = row_h + + # 이 zone에 배정된 역할들 + roles_here = content_roles.get(zone_name, []) + if not roles_here: + continue + + for rn, ri in roles_here: + topic_ids = ri.get("topic_ids", []) + weight = ri.get("weight", 0) + topic_count = max(1, len(topic_ids)) + per_topic_px = zone_h // topic_count + font_size, padding_px, line_h = _determine_typography(per_topic_px) + max_cost = _max_allowed_height_cost(per_topic_px) + constraints = _calculate_block_constraints( + zone_h, zone_w, topic_count, font_size, line_h, padding_px + ) + constraints["font_size_px"] = font_size + constraints["padding_px"] = padding_px + constraints["line_height"] = line_h + + specs[rn] = ContainerSpec( + role=rn, + zone=zone_name, + topic_ids=topic_ids, + weight=weight, + height_px=zone_h, + width_px=zone_w, + max_height_cost=max_cost, + block_constraints=constraints, + ) + + # footer + if footer_role: + footer_info = page_structure[footer_role] + specs[footer_role] = ContainerSpec( + role=footer_role, + zone="footer", + topic_ids=footer_info.get("topic_ids", []), + weight=footer_weight, + height_px=footer_h, + width_px=inner_w, + max_height_cost="low", + block_constraints={}, + ) + + logger.info( + f"[X-3] 템플릿 컨테이너: " + + ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items()) + ) + return specs + + def _max_allowed_height_cost(container_height_px: int) -> str: """컨테이너 높이에서 허용되는 최대 height_cost. diff --git a/src/step_visualizer.py b/src/step_visualizer.py index 7120206..b831bce 100644 --- a/src/step_visualizer.py +++ b/src/step_visualizer.py @@ -29,8 +29,15 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"} -FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"} +# Phase X: 동적 색상 팔레트 (역할 수에 관계없이 순환) +_COLOR_PALETTE = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"] +# 하위호환: 기존 코드가 COLORS를 참조할 경우 fallback +COLORS = {} + + +def _get_color(role: str, all_roles: list[str]) -> str: + idx = all_roles.index(role) % len(_COLOR_PALETTE) if role in all_roles else 0 + return _COLOR_PALETTE[idx] def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None: @@ -75,36 +82,93 @@ def _tokens(): def _calc_coords(containers: dict, ratio: tuple) -> dict: + """Phase X: 동적 역할 좌표 계산. containers의 모든 역할을 zone 기반으로 배치.""" 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 + header_h = t.get("header_height", 66) + slide_w = t.get("slide_width", 1280) + slide_h = t.get("slide_height", 720) + inner_w = slide_w - pad * 2 def gh(c): if hasattr(c, "height_px"): return c.height_px return c.get("height_px", 0) if isinstance(c, dict) else 0 + def gz(c): + if hasattr(c, "zone"): return c.zone + return c.get("zone", "main") if isinstance(c, dict) else "main" + def gw(c): + if hasattr(c, "width_px"): return c.width_px + return c.get("width_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("결론", {})) + result = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}} - 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 + # zone별 그룹핑 + zone_roles = {} + footer_role = None + for role_name, ci in containers.items(): + zone = gz(ci) + if zone == "footer": + footer_role = role_name + else: + if zone not in zone_roles: + zone_roles[zone] = [] + zone_roles[zone].append(role_name) - 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}, - } + # footer + footer_h = gh(containers[footer_role]) if footer_role else 0 + ft_top = slide_h - pad - footer_h + + content_top = pad + header_h + gap + content_bottom = ft_top - gap + + # row 그룹핑 + row_map = {} + for zone_name in zone_roles: + if zone_name.startswith("top"): + row = "top" + elif zone_name.startswith("bottom"): + row = "bottom" + else: + row = "main" + if row not in row_map: + row_map[row] = [] + if zone_name not in row_map[row]: + row_map[row].append(zone_name) + + # row 높이 + row_weights = {} + for row_name, zone_list in row_map.items(): + w = sum(gh(containers.get(rn, {})) for zn in zone_list for rn in zone_roles.get(zn, [])) + row_weights[row_name] = max(1, w) + total_rw = sum(row_weights.values()) or 1 + num_rows = len(row_map) + available_h = content_bottom - content_top - small * max(0, num_rows - 1) + + current_top = content_top + for row_name in sorted(row_map.keys()): + rh = int(available_h * row_weights[row_name] / total_rw) + zone_list = row_map[row_name] + num_cols = len(zone_list) + col_gap_total = gap * max(0, num_cols - 1) + col_available = inner_w - col_gap_total + # zone 폭 + zone_widths = [gw(containers.get(zone_roles.get(zn, [""])[0], {})) or (col_available // num_cols) for zn in zone_list] + total_zw = sum(zone_widths) or 1 + zone_widths = [int(col_available * w / total_zw) for w in zone_widths] + + current_left = pad + for i, zn in enumerate(zone_list): + for rn in zone_roles.get(zn, []): + result[rn] = {"l": current_left, "t": current_top, "w": zone_widths[i], "h": rh} + current_left += zone_widths[i] + gap + current_top += rh + small + + if footer_role: + result[footer_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": footer_h} + + return result def _wrap(title, subtitle, slide_body): @@ -268,9 +332,9 @@ def _gen_stage_1_5a(ctx, steps_dir): title = ctx.analysis.title or "슬라이드" body = _hdr(coords["header"], title) - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) fk = FONT_MAP[role] font = getattr(fh, fk, "?") inner = (f'
' @@ -294,9 +358,9 @@ def _gen_stage_1_5a_content(ctx, steps_dir): ps = ctx.page_structure.roles topic_map = {t.id: t for t in ctx.topics} - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) info = ps.get(role, {}) tids = info.get("topic_ids", []) if isinstance(info, dict) else [] @@ -331,9 +395,9 @@ def _gen_stage_1_5b(ctx, steps_dir): title = ctx.analysis.title or "슬라이드" body = _hdr(coords["header"], title) - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) ci = ctx.containers.get(role) if not ci: continue @@ -368,9 +432,9 @@ def _gen_stage_1_7(ctx, steps_dir): title = ctx.analysis.title or "슬라이드" body = _hdr(coords["header"], title) - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) ref_list = ctx.references.get(role, []) lines = [f'
{role} ({c["w"]}x{c["h"]}px)
'] @@ -416,9 +480,9 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir): title = ctx.analysis.title or "슬라이드" body = _hdr(coords["header"], title) - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) ref_list = ctx.references.get(role, []) blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택" @@ -462,9 +526,9 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir): bolds = enh.get("bold_keywords", {}) sups = enh.get("supplement_blocks", []) - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) rf = roles_fit.get(role, {}) status = rf.get("fit_status", "?") icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?") @@ -531,9 +595,9 @@ def _gen_stage_1_8_blocks(ctx, steps_dir): slide_body = _hdr(coords["header"], title) legend_lines = [] - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): c = coords[role] - cl = COLORS[role] + cl = _get_color(role, list(ctx.containers.keys())) ref_list = ctx.references.get(role, []) info = ps.get(role, {}) tids = info.get("topic_ids", []) if isinstance(info, dict) else [] @@ -680,7 +744,7 @@ def _gen_stage_2(ctx, steps_dir): redist = fit.get("redistribution", {}) sections = [] - for role in ["배경", "본심", "첨부", "결론"]: + for role in ctx.containers.keys(): rhtml = role_htmls.get(role, "") if not rhtml: continue diff --git a/src/validators.py b/src/validators.py index f7a29ab..63fe825 100644 --- a/src/validators.py +++ b/src/validators.py @@ -171,22 +171,20 @@ def validate_stage_1a( "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 이상 필요.", - }) + # Phase X: 결론(footer) 역할 존재 검증 (역할명은 자유, zone="footer"가 1개 있어야 함) + has_footer = any( + isinstance(info, dict) and info.get("zone") == "footer" + for info in page_struct.values() + ) + if not has_footer: + # 하위호환: "결론" 키가 있으면 OK + if "결론" not in page_struct: + errors.append({ + "severity": "RETRYABLE", + "field": "page_structure.footer", + "localization": "결론(footer) 역할이 page_structure에 없음", + "instruction": "page_structure에 결론 역할(zone: footer)을 추가하라.", + }) # 필수 필드 검증 for t in topics: @@ -226,7 +224,7 @@ def validate_stage_1a( 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: + if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 4: errors.append({ "severity": "RETRYABLE", "field": "topics", @@ -366,15 +364,7 @@ def validate_stage_1b( 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}' 관계를 나타내는 표현이 없음. 재판단하라.", - }) + # Phase X: relation_type 증거 0개는 warning으로 처리 (역할 구조가 자유이므로) + logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — warning으로 처리") return errors