Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26319f6bd9 | ||
|
|
edfc3f29df |
+174
-24
@@ -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
@@ -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
@@ -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>"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user