Phase X-B 진행중: 유형 B 조립 + 텍스트 보존 강화 + 원본 MDX 복구

X-B-3~5 완료:
- space_allocator: build_containers_type_b() 추가
- assemble_stage2: _assemble_type_b() 추가 (소제목 카드형)
- pipeline.py: layout_template 분기 (A/B)
- pipeline_context: Analysis.layout_template 필드
- validators: 유형 B 검증 완화

텍스트 보존 강화:
- KEI_PROMPT: 제목 원본 그대로, 텍스트 재작성 금지
- KEI_STRUCTURED_TEXT_PROMPT: 소제목 유지, 원본 문장 그대로

원본 MDX 복구:
- samples/mdx_batch/02.mdx: 표 데이터 누락 수정 (원본에서 재복사)

미해결 (다음 세션):
- 들여쓰기: 대제목→중제목→소제목→본문 계층 구조
- 이미지 캡션: [그림 제목] 형식 (대괄호 포함)
- 상단 컨테이너: 빈칸 위로 붙이기
- 카드 디자인: 안전과품질/생산성향상/소통과신뢰 디자인 개선
- 제목: Kei가 원본 제목 바꾸는 문제 잔존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:28:03 +09:00
parent bc7829b08b
commit a8fe20e08e
7 changed files with 545 additions and 32 deletions

View File

@@ -33,6 +33,14 @@ import DxEffect from '../../../../components/dx.astro';
<br/> <br/>
### 2.2 DX 시행 주체별 기대효과 ### 2.2 DX 시행 주체별 기대효과
| 구분 | 발주자 | 시공자 | 설계자 |
|------|--------|--------|--------|
| **필요 역량** | 실행 의지와 합리적 판단 역량 | 기술 투자와 운영 역량 | 기술개발 투자에 의한 S/W 역량 |
| **수작업 의존 → S/W 기반 체계화** | - 행정서류 자동 생성 및 최소화로 업무 생산성 향상<br/>- 건설기간 단축, 건설비 및 유지관리비 총비용 최소화 | - 체계적 공정/자원 관리를 통한 신뢰성 확보 및 생산성 향상<br/>- Model에서의 도면 추출로 쉽고 정확한 시공상세도 작성 용이<br/>- 시스템 구축 시, 품질·안전·관리 등에 필요한 도서 작성 용이 | - SW기반 설계프로세스 체계화로 설계 생산성 향상<br/>- 프로젝트 정보의 일관 유지 및 관리를 통한 오류 최소화<br/>- 다양한 성과물과 정보물 활용으로 추가 부가가치 창출 |
| **2D → 3D 기반 인지·검토** | - 3D 모델을 통한 직관적 시각화로 품질 향상 및 안전성 제고<br/>- 건설단계별 수행상태에 대한 쉬운 이해로 관리 편의성 증대 | - 직관적 시각화로 계획시공 등을 관리하여 안전성 제고 및 품질 향상<br/>- 중간태, 완성태 측량을 통한 시·공간적 관리의 편리성 향상 | - 3D 모델을 통한 확인/검증으로 설계 오류 최소화 및 Claim 예방 |
| **문서 중심 → 데이터 통합 기반 협업** | - 현장 실무자와 발주자의 원활한 의사소통으로 오류 최소화<br/>- 디지털 환경 구축을 통한 건설 정보 통합관리 활용성 강화 | - 불필요한 행정서류 감소를 통한 협업 및 의사소통 효율 향상 | - 설계 신뢰도 확보 및 발주자 이익 기여로 상호신뢰 증진 |
| **사후 대응 → 사전 검증 중심 관리** | - 설계변경, 민원, 재작업, 소송 등의 사전 예방, 최소화 | - 설계 및 시공 오류 예방과 원활한 의사 소통으로 공사 Risk 최소화 | - 시공 전 설계검증 강화로 설계 책임 리스크 감소 |
<DxEffect /> <DxEffect />
<br/> <br/>
<br/> <br/>

View File

@@ -44,8 +44,13 @@ def assemble(run_dir: str):
popups = ctx.get("normalized", {}).get("popups", []) popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "") title = ctx.get("analysis", {}).get("title", "")
ratio = ctx.get("container_ratio", [71, 29]) ratio = ctx.get("container_ratio", [71, 29])
layout_template = ctx.get("analysis", {}).get("layout_template", "A")
# ── 유틸 ── # Phase X-B: 유형 B면 별도 함수로 분기
if layout_template == "B":
return _assemble_type_b(run, ctx)
# ── 유틸 (유형 A) ──
def bold(text, role): def bold(text, role):
"""V-10 bold 키워드 적용.""" """V-10 bold 키워드 적용."""
for kw in bold_kw.get(role, []): for kw in bold_kw.get(role, []):
@@ -563,3 +568,335 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
if __name__ == "__main__": if __name__ == "__main__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051" run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
assemble(run_dir) assemble(run_dir)
# ══════════════════════════════════════
# Phase X-B: 유형 B 조립
# ══════════════════════════════════════
def _assemble_type_b(run: Path, ctx: dict):
"""유형 B: 상단(top+이미지) + 하단 2분할 + 결론.
기존 유형 A 코드를 건드리지 않는 별도 함수.
"""
import re
from src.fit_verifier import _load_design_tokens
topics = ctx["topics"]
topic_map = {t["id"]: t for t in topics}
ps = ctx["page_structure"]
if "roles" in ps:
ps = ps["roles"]
containers = ctx["containers"]
fh = ctx.get("font_hierarchy", {})
enh = ctx.get("enhancement_result", {})
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "")
core_message = ctx.get("analysis", {}).get("core_message", "")
slide_images = ctx.get("slide_images", [])
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"]
slide_w = tokens.get("slide_width", 1280)
slide_h = tokens.get("slide_height", 720)
inner_w = slide_w - pad * 2
# ── 유틸 ──
def get_text(topic):
if isinstance(topic, dict):
return topic.get("structured_text", "") or topic.get("source_data", "")
return ""
def bold(text, role):
for kw in bold_kw.get(role, []):
if kw in text:
text = text.replace(kw, f"<strong>{kw}</strong>")
return text
def find_popup(title_keyword):
for p in popups:
if title_keyword in p.get("title", ""):
return p
return None
# ── zone별 역할 분류 ──
top_role = None
bottom_left_role = None
bottom_right_role = None
footer_role = None
for role_name, info in ps.items():
if not isinstance(info, dict):
continue
zone = info.get("zone", "")
if zone == "top":
top_role = (role_name, info)
elif zone == "bottom_left":
bottom_left_role = (role_name, info)
elif zone == "bottom_right":
bottom_right_role = (role_name, info)
elif zone == "footer":
footer_role = (role_name, info)
# ── 좌표 계산 (containers에서 동적으로) ──
# footer
footer_ci = containers.get(footer_role[0], {}) if footer_role else {}
footer_h = footer_ci.get("height_px", 53) if isinstance(footer_ci, dict) else 53
ft_top = slide_h - pad - footer_h
# 상단
top_ci = containers.get(top_role[0], {}) if top_role else {}
top_h = top_ci.get("height_px", 200) if isinstance(top_ci, dict) else 200
top_w = top_ci.get("width_px", inner_w) if isinstance(top_ci, dict) else inner_w
top_top = pad + header_h + gap_block
# 이미지 크기
img_constraints = top_ci.get("block_constraints", {}) if isinstance(top_ci, dict) else {}
img_w = img_constraints.get("img_width_px", 0)
has_image = img_constraints.get("has_image", False)
# 이미지 높이: 실제 비율로
img_h = 0
img_html = ""
if has_image and slide_images:
for img in slide_images:
b64 = img.get("b64", "")
if b64:
img_ratio = img.get("ratio", 1)
img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
break
# 하단
bottom_top = top_top + top_h + gap_small
# V'-4: 결론 바로 위까지 채움
column_bottom = ft_top - gap_block
bottom_h = column_bottom - bottom_top
bottom_col_w = (inner_w - gap_block) // 2
# ── 역할별 HTML 조립 ──
font_size = fh.get("core", 12)
# 상단 (텍스트 + 이미지 나란히)
top_html = ""
if top_role:
rn, info = top_role
tids = info.get("topic_ids", [])
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
# 마크다운 bold → HTML
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
# 팝업 분리
popup_titles = []
content_lines = []
for line in all_text_clean.split("\n"):
stripped = line.strip()
if not stripped:
continue
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
if popup_match:
popup_titles.append(popup_match.group(1))
continue
if re.search(r'\[이미지:', stripped):
continue
content_lines.append(stripped)
# 팝업 링크 우측상단
popup_html = ""
if popup_titles:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles)
popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
# 소제목(###) + 불릿을 카드형으로 분리
sections = [] # [(소제목, [불릿들])]
current_section = ("", [])
for line in content_lines:
if line.startswith("### ") or line.startswith("###"):
if current_section[0] or current_section[1]:
sections.append(current_section)
current_section = (line.lstrip("# ").strip(), [])
else:
clean = line.lstrip("")
if clean.startswith("출처:"):
continue
current_section[1].append(bold(clean, rn))
if current_section[0] or current_section[1]:
sections.append(current_section)
# 카드형 HTML 생성
bullets = ""
if len(sections) > 1 and sections[0][0]:
# 소제목이 있는 경우 → 카드형
card_gap = max(3, int(font_size * 0.4))
for sec_title, sec_items in sections:
items_html = "".join(
f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{item}</span></div>'
for item in sec_items
)
if sec_title:
bullets += (
f'<div style="background:#1e293b;color:#fff;border-radius:4px;'
f'padding:{int(font_size*0.4)}px {int(font_size*0.6)}px;margin-bottom:{card_gap}px;">'
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;margin-bottom:2px;">{bold(sec_title, rn)}</div>'
f'<div style="font-size:{font_size-1}px;line-height:1.5;">{items_html}</div></div>\n'
)
else:
bullets += items_html
else:
# 소제목 없는 경우 → 일반 불릿
for sec_title, sec_items in sections:
for item in sec_items:
bullets += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{item}</span></div>\n'
# 이미지 캡션: 출처 → [이미지:] 마커 → 없으면 빈 문자열
img_caption = ""
for line in all_text.split("\n"):
stripped = line.strip().lstrip("")
if stripped.startswith("출처:"):
img_caption = re.sub(r'^출처:\s*', '', stripped)
break
if not img_caption:
img_marker = re.search(r'\[이미지:\s*([^\]]+)\]', all_text)
if img_marker:
img_caption = img_marker.group(1)
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
# 이미지 블록
img_block = ""
if has_image and img_html:
img_block = (
f'<div style="width:{img_w}px;flex-shrink:0;">'
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
f'{caption_html}</div>'
)
# 제목
primary_topic = topic_map.get(tids[0], {}) if tids else {}
topic_title = bold(primary_topic.get("title", ""), rn)
top_html = (
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'{popup_html}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;">'
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
f'{img_block}</div></div>'
)
# 하단 좌측
bl_html = ""
if bottom_left_role:
rn, info = bottom_left_role
tids = info.get("topic_ids", [])
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
all_text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
primary_topic = topic_map.get(tids[0], {}) if tids else {}
topic_title = bold(primary_topic.get("title", ""), rn)
bullets = ""
for line in all_text.split("\n"):
stripped = line.strip()
if not stripped or re.search(r'\[팝업:|\[이미지:', stripped):
continue
clean = stripped.lstrip("")
clean = bold(clean, rn)
bullets += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
bl_html = (
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div></div>'
)
# 하단 우측
br_html = ""
if bottom_right_role:
rn, info = bottom_right_role
tids = info.get("topic_ids", [])
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
all_text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
primary_topic = topic_map.get(tids[0], {}) if tids else {}
topic_title = bold(primary_topic.get("title", ""), rn)
# 팝업 분리
popup_titles_br = []
content_lines_br = []
for line in all_text.split("\n"):
stripped = line.strip()
if not stripped:
continue
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
if popup_match:
popup_titles_br.append(popup_match.group(1))
continue
if re.search(r'\[이미지:', stripped):
continue
content_lines_br.append(stripped)
popup_html_br = ""
if popup_titles_br:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles_br)
popup_html_br = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
bullets = ""
for line in content_lines_br:
clean = line.lstrip("")
clean = bold(clean, rn)
bullets += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
br_html = (
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'{popup_html_br}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div></div>'
)
# 결론
footer_html = ""
if footer_role:
rn, info = footer_role
footer_html = (
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
f'display:flex;align-items:center;justify-content:center;">'
f'<div style="font-size:{fh.get("key_msg",14)}px;font-weight:700;">{bold(core_message, rn)}</div></div>'
)
# ── HTML 조립 ──
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
</style></head><body>
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 (유형 B)</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:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
{top_html}</div>
<div style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{bottom_col_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
{bl_html}</div>
<div style="position:absolute;left:{pad + bottom_col_w + gap_block}px;top:{bottom_top}px;width:{bottom_col_w}px;height:{bottom_h}px;border:2px solid {_color_palette[2]};border-radius:6px;overflow:hidden;">
{br_html}</div>
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;">
{footer_html}</div>
</div></body></html>"""
out = run / "steps" / "stage_2_code_assembled.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html, encoding="utf-8")
print(f"저장: {out} ({len(html)} bytes)")

View File

@@ -57,11 +57,16 @@ KEI_PROMPT = (
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n" "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n" "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
"page_structure 필드에 기록.\n\n" "page_structure 필드에 기록.\n\n"
"## 원본 텍스트 보존 원칙\n" "## 원본 텍스트 보존 원칙 (절대 규칙)\n"
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n" "- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n" " 원본'## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n" " 임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n"
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시\n\n" "- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n"
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
"## 배치 규칙\n" "## 배치 규칙\n"
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n" "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n" "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
@@ -258,14 +263,20 @@ async def refine_concepts(
KEI_STRUCTURED_TEXT_PROMPT = ( KEI_STRUCTURED_TEXT_PROMPT = (
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n" "아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n" "각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
"## 규칙\n" "## 절대 규칙\n"
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n" "1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n"
"2. 각 문장을 불릿(•)으로 구분하라.\n" " 원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n" " → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n" " ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n"
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n" "2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n" "3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n"
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n" " 원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n"
"4. 각 문장을 불릿(•)으로 구분하라.\n"
"5. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"6. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n" "## 출력 형식 (JSON만. 설명 없이.)\n"
"```json\n" "```json\n"
'{"structured_texts": [' '{"structured_texts": ['

View File

@@ -176,6 +176,7 @@ async def generate_slide(
core_message=analysis_raw.get("core_message", ""), core_message=analysis_raw.get("core_message", ""),
title=analysis_raw.get("title", ""), title=analysis_raw.get("title", ""),
total_pages=analysis_raw.get("total_pages", 1), total_pages=analysis_raw.get("total_pages", 1),
layout_template=analysis_raw.get("layout_template", "A"),
) )
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
@@ -248,6 +249,7 @@ async def generate_slide(
[t.model_dump() for t in updated_topics], [t.model_dump() for t in updated_topics],
context.normalized.clean_text, context.normalized.clean_text,
raw_content=context.raw_content, raw_content=context.raw_content,
layout_template=context.analysis.layout_template,
) )
if validation_errors: if validation_errors:
return {"_errors": validation_errors} return {"_errors": validation_errors}
@@ -327,7 +329,20 @@ async def generate_slide(
f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}" f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
) )
# 컨테이너 스펙 계산 (기존 space_allocator 활용) # Phase X-B: 유형에 따라 컨테이너 생성 분기
if context.analysis.layout_template == "B":
from src.space_allocator import build_containers_type_b
container_specs = build_containers_type_b(
page_structure=context.page_structure.roles,
slide_width=settings.slide_width,
slide_height=settings.slide_height,
image_sizes=image_sizes if isinstance(image_sizes, list) else (
[{**v, "key": k} for k, v in image_sizes.items()] if image_sizes else None
),
)
logger.info(f"[X-B] 유형 B 컨테이너 생성")
else:
# 유형 A: 기존 코드 그대로
container_specs = calculate_container_specs( container_specs = calculate_container_specs(
page_structure=context.page_structure.roles, page_structure=context.page_structure.roles,
topics=[t.model_dump() for t in context.topics], topics=[t.model_dump() for t in context.topics],

View File

@@ -63,6 +63,7 @@ class Analysis(BaseModel):
core_message: str = "" core_message: str = ""
title: str = "" title: str = ""
total_pages: int = 1 total_pages: int = 1
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict) image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
# topics와 page_structure는 PipelineContext 최상위에 위치 # topics와 page_structure는 PipelineContext 최상위에 위치

View File

@@ -439,6 +439,143 @@ def calculate_container_specs(
return specs return specs
# ══════════════════════════════════════
# Phase X-B: 유형 B 컨테이너 생성
# ══════════════════════════════════════
def build_containers_type_b(
page_structure: dict[str, Any],
slide_width: int = 1280,
slide_height: int = 720,
image_sizes: list[dict] | None = None,
) -> dict[str, ContainerSpec]:
"""유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).
기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.
Args:
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
slide_width: 슬라이드 너비
slide_height: 슬라이드 높이
image_sizes: 이미지 정보 (비율 계산용)
"""
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
# 역할을 zone별로 분류
top_roles = [] # zone=top
bottom_roles = [] # zone=bottom_left, bottom_right
footer_role = None # zone=footer
for role_name, info in page_structure.items():
if not isinstance(info, dict):
continue
zone = info.get("zone", "")
if zone == "top":
top_roles.append((role_name, info))
elif zone in ("bottom_left", "bottom_right"):
bottom_roles.append((role_name, info))
elif zone == "footer":
footer_role = (role_name, info)
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
total_available = slide_height - pad * 2 - header_h - gap_block
# footer 높이: weight 비율 (최소 보장)
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
footer_h_raw = int(total_available * footer_weight)
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
footer_h = max(_footer_min, footer_h_raw)
# 중간 영역: footer + gap 제외
middle_h = total_available - footer_h - gap_block
# 상단/하단 높이: weight 비율로
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
total_mid_weight = top_weight + bottom_weight
if total_mid_weight <= 0:
total_mid_weight = 1
top_h = int(middle_h * top_weight / total_mid_weight)
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
img_ratio = 0
if image_sizes:
for img in image_sizes:
r = img.get("ratio", 0)
if r > 0:
img_ratio = r
break
if img_ratio > 0:
# 이미지 높이 = top_h, 이미지 폭 = top_h * ratio
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45%
text_w = inner_w - img_w - gap_block
else:
text_w = inner_w
img_w = 0
specs = {}
# 상단 역할
for role_name, info in top_roles:
specs[role_name] = ContainerSpec(
role=role_name,
zone="top",
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=top_h,
width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만
max_height_cost=_max_allowed_height_cost(top_h),
block_constraints={
"img_width_px": img_w,
"img_height_px": top_h if img_w > 0 else 0,
"has_image": img_w > 0,
},
)
# 하단 역할: 2분할
bottom_col_w = (inner_w - gap_block) // 2
for role_name, info in bottom_roles:
specs[role_name] = ContainerSpec(
role=role_name,
zone=info.get("zone", "bottom_left"),
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=bottom_h,
width_px=bottom_col_w,
max_height_cost=_max_allowed_height_cost(bottom_h),
block_constraints={},
)
# 결론
if footer_role:
rn, info = footer_role
specs[rn] = ContainerSpec(
role=rn,
zone="footer",
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=footer_h,
width_px=inner_w,
max_height_cost="low",
block_constraints={},
)
logger.info(
f"[X-B-3] 유형 B 컨테이너: "
+ ", ".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: def _max_allowed_height_cost(container_height_px: int) -> str:
"""컨테이너 높이에서 허용되는 최대 height_cost. """컨테이너 높이에서 허용되는 최대 height_cost.

View File

@@ -291,6 +291,7 @@ def validate_stage_1b(
topics: list[dict[str, Any]], topics: list[dict[str, Any]],
clean_text: str, clean_text: str,
raw_content: str = "", raw_content: str = "",
layout_template: str = "A",
) -> list[dict]: ) -> list[dict]:
"""Stage 1B(컨셉 구체화) 결과 검증. """Stage 1B(컨셉 구체화) 결과 검증.
@@ -384,7 +385,10 @@ def validate_stage_1b(
claimed_count = evidence.get(relation_type, 0) claimed_count = evidence.get(relation_type, 0)
if claimed_count == 0: if claimed_count == 0:
# 주장한 관계의 증거가 0개 if layout_template == "B":
# 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유)
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning")
else:
alternatives = [(k, v) for k, v in evidence.items() if v >= 2] alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3]) alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
errors.append({ errors.append({