Add Type B slide pipeline and recipe rendering updates

This commit is contained in:
2026-04-15 16:39:50 +09:00
parent 51548fdc41
commit 66c00924ed
22 changed files with 6260 additions and 1322 deletions

View File

@@ -141,12 +141,23 @@ async def generate_slide(
if errors:
return {"_errors": errors}
# popup_id 부여 (Stage 0 시점)
from src.pipeline_context import PopupItem
raw_popups = result.get("popups", [])
popup_items = []
for pi, rp in enumerate(raw_popups, 1):
popup_items.append(PopupItem(
popup_id=f"popup_{pi}",
title=rp.get("title", ""),
content=rp.get("content", ""),
))
return {
"normalized": NormalizedContent(
clean_text=result["clean_text"],
title=result["title"],
images=result["images"],
popups=result["popups"],
popups=popup_items,
tables=result["tables"],
sections=result["sections"],
),
@@ -176,6 +187,7 @@ async def generate_slide(
original_title = context.normalized.title or analysis_raw.get("title", "")
analysis = Analysis(
core_message=analysis_raw.get("core_message", ""),
conclusion_text=analysis_raw.get("conclusion_text", ""),
title=original_title,
total_pages=analysis_raw.get("total_pages", 1),
layout_template=analysis_raw.get("layout_template", "A"),
@@ -200,14 +212,197 @@ async def generate_slide(
if validation_errors:
return {"_errors": validation_errors}
# Phase Y: page_structure는 Kei가 만들지 않음.
# Kei 응답에 page_structure가 있어도 무시.
# 코드가 section_parser + 블록 매칭으로 생성 (Stage 1A 후 별도 단계)
return {
"analysis": analysis,
"topics": topics,
"page_structure": page_structure,
"page_structure": PageStructure(roles={}), # 빈 상태, 아래에서 채움
}
ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2)
# ── Phase Y: 영역 확정 (코드: normalized.sections 기반 + 블록 매칭) ──
from src.section_parser import extract_major_sections, extract_conclusion_text, map_topics_to_sections, classify_group_relations, get_candidate_blocks_for_schema, detect_component_popups
from src.block_reference import _match_by_tags, _load_catalog
# source of truth = normalized.sections (Stage 0 산출물)
norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else []
if hasattr(norm_sections, '__iter__') and norm_sections:
if hasattr(norm_sections[0], 'model_dump'):
norm_sections = [s.model_dump() for s in norm_sections]
elif not isinstance(norm_sections[0], dict):
norm_sections = [dict(s) for s in norm_sections]
major_sections = extract_major_sections(norm_sections)
# popup 대상 sub_title 목록 (컴포넌트 태그가 있던 섹션)
popup_sub_titles = []
component_tags = re.findall(r'<([A-Z]\w+)\s*/>', ctx.raw_content)
if component_tags:
raw_lines = ctx.raw_content.split("\n")
for tag_name in component_tags:
current_section = ""
for line in raw_lines:
if line.strip().startswith("### "):
current_section = re.sub(r'^#{1,3}\s*\d*\.?\d*\s*', '', line.strip()).strip()
if f"<{tag_name}" in line:
if current_section:
popup_sub_titles.append(current_section)
break
# Y-13b: group relation 분류
major_sections = classify_group_relations(
major_sections, normalized_sections=norm_sections,
popup_sub_titles=popup_sub_titles,
)
# conclusion_text: raw MDX에서 추출 또는 기존 값 정제
conclusion_text = ctx.analysis.conclusion_text or ""
if not conclusion_text:
conclusion_text = extract_conclusion_text(ctx.raw_content)
# 선행 불릿 마커 정제 (Kei가 * 포함해서 넣을 수 있음)
if conclusion_text:
conclusion_text = re.sub(r'^[\*•\-]\s*', '', conclusion_text).strip()
conclusion_text = re.sub(r'\*+', '', conclusion_text).strip()
if conclusion_text != (ctx.analysis.conclusion_text or ""):
ctx = ctx.model_copy(update={
"analysis": ctx.analysis.model_copy(update={"conclusion_text": conclusion_text}),
})
# 꼭지-대목차 매핑
topic_dicts = [t.model_dump() for t in ctx.topics]
section_topic_map = map_topics_to_sections(topic_dicts, major_sections)
# 대목차별 묶음으로 블록 tag 매칭 → 영역 확정
# 블록 매칭 기준: Kei topic 수가 아니라 normalized sub_titles 수
catalog = _load_catalog()
page_struct_roles = {}
zone_names = ["top", "bottom"]
# major_sections를 title로 빠르게 찾기
major_sec_map = {s["title"]: s for s in major_sections}
for i, (sec_title, tids) in enumerate(section_topic_map.items()):
# sub_titles = normalized.sections에서 파싱된 소항목 목록
sec = major_sec_map.get(sec_title, {})
sub_titles = sec.get("sub_titles", [])
# sidebar 판단: 모든 꼭지가 reference면 sidebar
all_reference = all(
t.role == "reference" for t in ctx.topics if t.id in tids
)
if all_reference:
zone = "sidebar"
elif i < len(zone_names):
zone = zone_names[i]
else:
zone = f"bottom_{i}"
# 블록 매칭: sub_titles 수 기준 (Kei topic 수 아님)
slot_count = len(sub_titles) if sub_titles else len(tids)
tag_match = _match_by_tags(catalog, slot_count, sub_titles, 300, zone)
if tag_match:
logger.info(f"[Phase Y] '{sec_title}' → 블록 {tag_match['id']} (tag_match) 확정")
else:
# Y-13d: tag 매칭 실패 → group schema 후보로 블록 찾기
group_schema = sec.get("group_schema", "")
schema_candidates = get_candidate_blocks_for_schema(group_schema)
if schema_candidates:
# catalog에서 첫 번째 존재하는 후보 선택
for cand_id in schema_candidates:
cand = next((b for b in catalog if b.get("id") == cand_id), None)
if cand:
tag_match = cand # schema 기반 선택
logger.info(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 {cand_id}")
break
if not tag_match:
logger.warning(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 매칭 실패")
# weight: 콘텐츠 양(content 길이) 기반
sec_content_len = len(sec.get("content", ""))
page_struct_roles[sec_title] = {
"zone": zone,
"topic_ids": tids,
"weight": sec_content_len,
"sub_titles": sub_titles,
"sub_types": sec.get("sub_types", []),
"group_schema": sec.get("group_schema", ""),
}
# weight를 비율로 변환 (합계 1.0)
total_content = sum(info["weight"] for info in page_struct_roles.values())
if total_content > 0:
for role in page_struct_roles:
page_struct_roles[role]["weight"] = round(page_struct_roles[role]["weight"] / total_content, 2)
else:
# content가 없으면 균등 분배
n = len(page_struct_roles)
for role in page_struct_roles:
page_struct_roles[role]["weight"] = round(1.0 / n, 2)
# Phase Y: layout_template도 코드가 결정
# sidebar zone이 있으면 Type A, 없으면 Type B
has_sidebar = any(
info.get("zone") == "sidebar" for info in page_struct_roles.values()
)
determined_layout = "A" if has_sidebar else "B"
logger.info(f"[Phase Y] 영역 확정: {list(page_struct_roles.keys())} → layout={determined_layout}")
ctx = ctx.model_copy(update={
"page_structure": PageStructure(roles=page_struct_roles),
"mdx_sections": major_sections, # normalized.sections 기반 대목차 (assembler용)
"analysis": ctx.analysis.model_copy(update={"layout_template": determined_layout}),
})
# Phase Y: page_structure 검증 (section_parser가 만든 결과)
from src.validators import validate_page_structure
ps_errors = validate_page_structure(page_struct_roles)
if ps_errors:
logger.warning(f"[Phase Y] page_structure 검증 경고: {ps_errors}")
# Y-14: 컴포넌트 popup 감지 + target_role 확정
component_popups = detect_component_popups(ctx.raw_content, ctx.base_path or "samples/mdx")
if component_popups:
from src.pipeline_context import PopupItem
existing_popups = list(ctx.normalized.popups or [])
# target_role 결정: raw MDX에서 컴포넌트 태그가 어느 ## 섹션에 있었는지
raw_lines = ctx.raw_content.split("\n")
for cp in component_popups:
tag = cp.get("tag", f"<{cp['name']} />")
target_role = None
current_section = None
for line in raw_lines:
if line.strip().startswith("## "):
# ## 번호 제거: "## 2. DX 기반..." → "DX 기반..."
# "## 2. DX 기반..." → "DX 기반..."
sec_title = re.sub(r'^#{1,3}\s*\d*\.?\s*', '', line.strip()).strip()
# page_structure roles에서 매칭
for rname in page_struct_roles:
if sec_title and len(sec_title) >= 3 and sec_title[:6] in rname:
current_section = rname
break
if tag.replace(" ", "") in line.replace(" ", ""):
target_role = current_section
break
existing_popups.append(PopupItem(
popup_id=f"comp_{cp['name']}",
title=f"상세: {cp['name']}",
content=cp["content_html"],
source=cp["source"],
is_component=True,
target_role=target_role,
))
logger.info(f"[Y-14] 컴포넌트 popup: {cp['name']} → target_role='{target_role}'")
ctx = ctx.model_copy(update={
"normalized": ctx.normalized.model_copy(update={"popups": existing_popups}),
})
logger.info(f"[Y-14] 컴포넌트 popup {len(component_popups)}개 추가")
# ── Stage 1B: 컨셉 구체화 ──
yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."}
@@ -449,7 +644,7 @@ async def generate_slide(
build_enhancement_report, calculate_sub_layout,
EnhancementAnalysis,
)
from src.block_assembler import assemble_slide_html
from src.block_assembler import assemble_slide_html_final as assemble_slide_html
from src.slide_measurer import measure_rendered_heights
refs_dict = {}
@@ -520,6 +715,9 @@ async def generate_slide(
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
kei_decisions = []
updated_containers = dict(context.containers)
fit_analysis = None
filled_measurement = {}
font_scale = 1.0 # fit 루프에서 축소
MAX_FIT_RETRIES = 3
for fit_round in range(MAX_FIT_RETRIES):
@@ -533,8 +731,8 @@ async def generate_slide(
"containers": updated_containers,
})
# ── filled: 컨테이너에 블록+텍스트 채움 ──
filled_html = assemble_slide_html(context)
# ── filled: 컨테이너에 블록+텍스트 채움 (측정용: overflow:auto) ──
filled_html = assemble_slide_html(context, measure_mode=True, font_scale=font_scale)
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
filled_html.replace('</head><body>', '</head><body>\n'
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
@@ -642,6 +840,11 @@ async def generate_slide(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
# ── Phase Y: font_scale 축소 (재배분만으로 부족할 때) ──
# 재배분 후에도 여전히 overflow면 font를 줄임
font_scale = max(0.7, font_scale - 0.1)
logger.info(f"[Stage 1.8] round {fit_round+1}: font_scale → {font_scale:.1f}")
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
if fit_analysis.needs_escalation or has_overflow:
@@ -676,15 +879,19 @@ async def generate_slide(
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
break
# Step 4: 보강 제안 분석
enhancements = analyze_enhancements(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
references=refs_dict,
analysis=fit_analysis,
normalized=normalized,
core_message=core_message,
)
# Step 4: 보강 제안 분석 (fit_analysis가 있을 때만)
if fit_analysis:
enhancements = analyze_enhancements(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
references=refs_dict,
analysis=fit_analysis,
normalized=normalized,
core_message=core_message,
)
else:
enhancements = EnhancementAnalysis()
logger.info("[Stage 1.8] overflow 없음 — 보강 분석 스킵")
# Step 5: Kei에게 보강 제안 확인 요청
if enhancements.enhancements:
@@ -744,15 +951,19 @@ async def generate_slide(
# 재배분된 컨테이너 크기 업데이트
updated_containers = {}
for role, ci in context.containers.items():
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
if fit_analysis and fit_analysis.redistribution:
new_h = fit_analysis.redistribution.get(role, ci.height_px)
else:
new_h = ci.height_px
updated_containers[role] = ci.model_copy(update={
"height_px": int(new_h),
})
# Step 7: 세부 컨테이너 배치 계산
sub_layouts = {}
for role, rf in fit_analysis.roles.items():
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis.redistribution else rf.allocated_px
fit_roles = fit_analysis.roles if fit_analysis else {}
for role, rf in fit_roles.items():
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis and fit_analysis.redistribution else rf.allocated_px
ci = context.containers.get(role)
if not ci or not rf.topic_fits:
continue
@@ -801,7 +1012,7 @@ async def generate_slide(
popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
for pr in popup_refs:
# 팝업 원본 찾기
popup = next((p for p in popups if pr in p.get("title", "")), None)
popup = next((p for p in popups if pr in (p.title if hasattr(p, 'title') else p.get("title", ""))), None)
if not popup:
continue
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
@@ -842,7 +1053,7 @@ async def generate_slide(
continue # 공간 부족하면 건너뜀
summary = await call_kei_summarize_popup(
popup_title=pr,
popup_content=popup.get("content", ""),
popup_content=popup.content if hasattr(popup, 'content') else popup.get("content", ""),
available_width_px=available_w,
available_height_px=available_h,
font_size=fs,
@@ -891,6 +1102,7 @@ async def generate_slide(
"containers": updated_containers,
"sub_layouts": sub_layouts,
"measurement": filled_measurement,
"font_scale": font_scale, # Phase Y: fit 루프에서 확정된 font 축소 비율
"fit_result": {
"roles": {
role: {
@@ -899,10 +1111,10 @@ async def generate_slide(
"allocated_px": rf.allocated_px,
"shortfall_px": rf.shortfall_px,
}
for role, rf in fit_analysis.roles.items()
for role, rf in (fit_analysis.roles.items() if fit_analysis else {}.items())
},
"redistribution": fit_analysis.redistribution,
"needs_escalation": fit_analysis.needs_escalation,
"redistribution": fit_analysis.redistribution if fit_analysis else {},
"needs_escalation": fit_analysis.needs_escalation if fit_analysis else False,
},
"enhancement_result": {
"kei_decisions": kei_decisions,
@@ -972,9 +1184,10 @@ async def generate_slide(
async def stage_2(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
if context.analysis.layout_template in ("B", "B'", "B''"):
from src.block_assembler import assemble_slide_html
generated = assemble_slide_html(context)
logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
from src.block_assembler import assemble_slide_html_final
fs = context.font_scale if hasattr(context, 'font_scale') else 1.0
generated = assemble_slide_html_final(context, font_scale=fs)
logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
return {"generated_html": generated}
# Type A: 기존 Sonnet 재구성 코드 그대로
@@ -1094,7 +1307,7 @@ async def generate_slide(
capture_slide_screenshot, context.rendered_html
)
quality_score = 100
quality_score = -1 # 비전 미평가 시 -1 (거짓 100점 방지)
if screenshot_b64:
analysis_dict = {
"topics": [t.model_dump() for t in context.topics],
@@ -1111,6 +1324,8 @@ async def generate_slide(
"localization": f"품질 {quality_score}/100 < 30",
"instruction": "출력 차단",
}]}
else:
logger.warning("[Stage 4] 비전 품질 평가 실패 — quality_score=-1 (미평가)")
return {
"measurement": measurement,
@@ -1139,7 +1354,10 @@ async def generate_slide(
]
logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")
if quality < 30:
if quality < 0:
# 비전 미평가: 차단하지 않고 경고만. Selenium overflow 검사는 통과한 상태.
logger.warning(f"[Stage 5] 비전 미평가 (quality={quality}) — Selenium 측정만으로 통과")
elif quality < 30:
logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
return
@@ -1149,22 +1367,44 @@ async def generate_slide(
html = embed_images(html, ctx.base_path)
ctx = ctx.model_copy(update={"rendered_html": html})
ctx.save_snapshot("final")
# final.html 저장
# Stage 5: popup_file 확정 (save_snapshot 전에 완료)
run_dir = ctx.get_run_dir()
run_dir.mkdir(parents=True, exist_ok=True)
popups = ctx.normalized.popups
if popups:
updated_popups = []
for i, popup in enumerate(popups, 1):
popup_title = popup.title
popup_content = popup.content
pid = popup.popup_id or f"popup_{i}"
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
popup_filename = f"첨부{i}_{safe_title}.html"
# popup_file 확정 → 새 PopupItem으로 (Pydantic immutable 대응)
updated_popups.append(popup.model_copy(update={"popup_file": popup_filename}))
ctx = ctx.model_copy(update={
"normalized": ctx.normalized.model_copy(update={"popups": updated_popups}),
})
popups = ctx.normalized.popups # 업데이트된 참조
ctx.save_snapshot("final")
# stage_4 검증판을 final 시점 context로 재생성 (popup_file 등 반영)
from src.step_visualizer import generate_step_html
try:
generate_step_html(ctx, "stage_4")
except Exception as e:
logger.warning(f"[Stage 5] stage_4 재생성 실패: {e}")
# final.html 저장
(run_dir / "final.html").write_text(html, encoding="utf-8")
# Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
popups = ctx.normalized.popups
if popups:
for i, popup in enumerate(popups, 1):
popup_title = popup.get("title", f"첨부{i}")
popup_content = popup.get("content", "")
# 파일명에서 특수문자 제거
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
popup_filename = f"첨부{i}_{safe_title}.html"
popup_title = popup.title
popup_content = popup.content
popup_filename = popup.popup_file or f"첨부{i}.html"
# TP-6: 첨부 HTML에 디자인 토큰 적용
import re as _re
# JSX style={{}} 잔여 정리
@@ -1179,27 +1419,27 @@ async def generate_slide(
# 콘텐츠 유형별 CSS
if has_table:
# 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
content_css = """
table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
th:nth-child(2) {{ width: 16%; }}
td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
content_css = (
"table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }\n"
"th { background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }\n"
"th:nth-child(1), th:nth-child(3) { width: 42%; }\n"
"th:nth-child(2) { width: 16%; }\n"
"td { padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }\n"
"tr:nth-child(even) { background: var(--color-bg-subtle); }"
)
elif has_list:
# 카드형 리스트: 항목별 박스, 하위 항목은 인라인
content_css = """
ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
content_css = (
"ul { padding-left: 0; margin: 12px 0; list-style: none; }\n"
"li { margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }\n"
"li ul { margin-top: 8px; margin-bottom: 0; padding-left: 0; }\n"
"li li { background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }\n"
'li li::before { content: "\\2022"; color: var(--color-accent); margin-right: 8px; }'
)
else:
# 기본 (텍스트)
content_css = """
ul {{ padding-left: 20px; margin: 8px 0; }}
li {{ margin-bottom: 4px; font-size: 13px; }}"""
content_css = (
"ul { padding-left: 20px; margin: 8px 0; }\n"
"li { margin-bottom: 4px; font-size: 13px; }"
)
popup_html = f"""<!DOCTYPE html>
<html lang="ko">