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