..."
+}
+```
+
+- **검증:** 선택된 블록이 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의 구조와 색상 패턴을 따르되 콘텐츠를 교체하세요.
+
+
+
+
+[수치 제약 — 반드시 준수]
+- 컨테이너: 너비 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}
+
+| ID | 제목 | purpose | layer | relation_type | 영역 |
+{rows}
"""
+ (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이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있다.
+이로 인해 건설산업 현장에서 오해가 발생하고 있다.
+혼용 때문에 정책 문서마다 서로 다른 정의를 사용하는 문제가 야기된다.
+
+
+*[사진 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": '',
+ "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"""
+
+
+
+
+
{generated.get('body_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'
'
+ 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("본심", "")}
+
+
+
+
+
+
"""
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}"의 첨부 자료입니다.
+
+