Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리

- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정
- Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div
- Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제
- Selenium: container div 감지 추가
- catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드
- 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 15:20:51 +09:00
parent ffad1ba82a
commit b0bcffc0f6
28 changed files with 8450 additions and 1530 deletions

View File

@@ -11,19 +11,60 @@ from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, AsyncIterator
import anthropic
from src.kei_client import classify_content, manual_classify, refine_concepts, call_kei_overflow_judgment
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset, _downgrade_fallback
from src.kei_client import classify_content, refine_concepts, call_kei_overflow_judgment, call_kei_final_review
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
from src.content_editor import fill_content
from src.renderer import render_slide
from src.image_utils import get_image_sizes, embed_images
from src.space_allocator import calculate_container_specs, finalize_block_specs, find_container_for_topic, calculate_trim_chars
from src.slide_measurer import measure_rendered_heights, format_measurement_for_kei, capture_slide_screenshot
from src.config import settings
logger = logging.getLogger(__name__)
# Kei API 재시도 간격(초). 제한 없음 — 성공할 때까지 무한 재시도.
KEI_RETRY_INTERVAL = 10
async def _retry_kei(fn, *args, **kwargs):
"""Kei API 호출을 성공할 때까지 무한 재시도한다.
Kei API는 필수 인프라. fallback 없음. 제한 없음.
10분이든 20분이든 Kei가 응답할 때까지 기다린다.
"""
import asyncio
attempt = 0
while True:
attempt += 1
result = await fn(*args, **kwargs)
if result is not None:
if attempt > 1:
logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
return result
logger.warning(
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}). "
f"{KEI_RETRY_INTERVAL}초 후 재시도..."
)
await asyncio.sleep(KEI_RETRY_INTERVAL)
def _save_step(run_dir: Path, filename: str, data: Any) -> None:
"""스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
run_dir.mkdir(parents=True, exist_ok=True)
filepath = run_dir / filename
if filename.endswith(".html"):
filepath.write_text(data, encoding="utf-8")
else:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"[중간 산출물] {filename} 저장 → {run_dir.name}/")
async def generate_slide(
content: str,
@@ -35,6 +76,10 @@ async def generate_slide(
Yields:
SSE 이벤트: progress / result / error
"""
# K-1: 중간 산출물 저장용 디렉토리
run_id = str(int(time.time() * 1000))
run_dir = Path("data/runs") / run_id
try:
# 1단계: Kei 실장 — 꼭지 추출 + 분석
yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}
@@ -42,18 +87,24 @@ async def generate_slide(
if manual_layout:
analysis = manual_layout
else:
analysis = await classify_content(content)
if analysis is None:
analysis = manual_classify(content)
analysis = await _retry_kei(classify_content, content)
# _retry_kei는 무한 재시도. None이 올 수 없다.
topic_count = len(analysis.get("topics", []))
page_count = analysis.get("total_pages", 1)
logger.info(f"1단계-A 완료: {topic_count}개 꼭지, {page_count}페이지")
_save_step(run_dir, "step1_analysis.json", analysis)
# 1단계-B: 각 꼭지 컨셉 구체화
yield {"event": "progress", "data": "1.5/5 Kei 실장이 각 꼭지의 컨셉을 구체화 중..."}
analysis = await refine_concepts(content, analysis)
logger.info("1단계-B 완료: 컨셉 구체화")
_save_step(run_dir, "step1b_concepts.json", {
"concepts": [
{k: t.get(k) for k in ("id", "title", "purpose", "relation_type", "expression_hint", "source_data")}
for t in analysis.get("topics", [])
]
})
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
from difflib import SequenceMatcher
@@ -75,10 +126,34 @@ async def generate_slide(
analysis["image_sizes"] = image_sizes
logger.info(f"이미지 측정: {len(image_sizes)}")
# 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
# ★ Phase O-1: 컨테이너 스펙 계산 (Kei 비중 → px 확정)
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
page_struct = analysis.get("page_structure", {})
container_specs = calculate_container_specs(
page_structure=page_struct,
topics=analysis.get("topics", []),
preset=preset,
slide_width=settings.slide_width,
slide_height=settings.slide_height,
)
_save_step(run_dir, "step1c_containers.json", {
role: {
"height_px": spec.height_px,
"width_px": spec.width_px,
"max_height_cost": spec.max_height_cost,
"topic_ids": spec.topic_ids,
"weight": spec.weight,
"block_constraints": spec.block_constraints,
}
for role, spec in container_specs.items()
})
# 2단계: 디자인 팀장 — Step A(프리셋) + Step A-2(Kei 블록 확정) + Step B(zone 배치)
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
layout_concept = await create_layout_concept(content, analysis)
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
total_blocks = sum(
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
@@ -87,12 +162,59 @@ async def generate_slide(
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
f"{total_blocks}개 블록"
)
_save_step(run_dir, "step2_layout.json", {
"preset": layout_concept.get("pages", [{}])[0].get("grid_areas", ""),
"blocks": [
{
"area": b.get("area"), "type": b.get("type"),
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
"reason": b.get("reason", ""), "size": b.get("size", ""),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
],
"overflow": layout_concept.get("overflow", []),
})
# ★ Phase O-3: 블록 스펙 확정 (컨테이너 크기 → 항목수/글자수/폰트)
for page in layout_concept.get("pages", []):
finalize_block_specs(page.get("blocks", []), container_specs)
# 컨테이너 스펙을 layout_concept에 저장 (렌더러에서 사용)
layout_concept["_container_specs"] = container_specs
_save_step(run_dir, "step2c_block_specs.json", {
"blocks": [
{
"type": b.get("type"), "topic_id": b.get("topic_id"),
"area": b.get("area"),
"_container_height_px": b.get("_container_height_px"),
"_max_items": b.get("_max_items"),
"_max_chars_per_item": b.get("_max_chars_per_item"),
"_max_chars_total": b.get("_max_chars_total"),
"_font_size_px": b.get("_font_size_px"),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
]
})
# 3단계: 텍스트 편집자 — 텍스트 정리
yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
layout_concept = await fill_content(content, layout_concept, analysis)
logger.info("3단계 완료: 텍스트 정리")
_save_step(run_dir, "step3_filled_blocks.json", {
"blocks": [
{
"area": b.get("area"), "type": b.get("type"),
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
"data": b.get("data", {}),
"char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
]
})
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
@@ -100,14 +222,117 @@ async def generate_slide(
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info("4단계 완료: HTML 조립")
_save_step(run_dir, "step4_css_adjustment.json", {
"area_styles": layout_concept.get("pages", [{}])[0].get("area_styles", {})
})
_save_step(run_dir, "step4_rendered.html", html)
# 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
# Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
import asyncio
MAX_MEASURE_ROUNDS = 3
measurement = None
for review_round in range(MAX_REVIEW_ROUNDS):
for measure_round in range(MAX_MEASURE_ROUNDS):
measurement = await asyncio.to_thread(measure_rendered_heights, html)
_save_step(run_dir, f"step4_measurement_round{measure_round + 1}.json", measurement)
# overflow 감지 — zone + container 양쪽 체크
has_overflow = False
for zone_name, zone_data in measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
has_overflow = True
break
# Phase O: container 레벨 overflow도 체크
for cont_name, cont_data in measurement.get("containers", {}).items():
if cont_data.get("overflowed"):
has_overflow = True
logger.warning(
f"[측정] container-{cont_name}: "
f"scroll={cont_data.get('scrollHeight')}px > "
f"allocated={cont_data.get('allocatedHeight')}px "
f"(+{cont_data.get('excess_px')}px)"
)
break
if not has_overflow:
logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
break
logger.warning(f"[측정] overflow 감지 (round {measure_round + 1})")
# 수학적 축약량 계산 → 편집자 재호출
adjusted = False
for zone_name, zone_data in measurement.get("zones", {}).items():
if not zone_data.get("overflowed"):
continue
excess = zone_data.get("excess_px", 0)
zone_info = preset.get("zones", {}).get(zone_name, {})
width_px = int(settings.slide_width * zone_info.get("width_pct", 100) / 100 * 0.85)
# Phase O: overflow 블록의 _max_chars_total 축소
for block_m in zone_data.get("blocks", []):
if block_m.get("overflowed"):
trim_chars = calculate_trim_chars(
block_m.get("excess_px", excess),
width_px,
)
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == zone_name:
current_max = block.get("_max_chars_total", 400)
block["_max_chars_total"] = max(20, current_max - trim_chars)
if "data" in block:
del block["data"]
adjusted = True
logger.info(
f"[측정 조정] {zone_name}/{block_m.get('block_type')}: "
f"{block_m.get('excess_px')}px 초과 → "
f"_max_chars_total {current_max}{block['_max_chars_total']} ({trim_chars}자 축약)"
)
break
if not adjusted:
logger.info("[측정] 조정 대상 없음, 현재 결과 확정")
break
# 편집자 재호출 → 재렌더링
layout_concept = await fill_content(content, layout_concept, analysis)
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
logger.info(f"[측정] round {measure_round + 1} 재렌더링 완료")
# 측정 결과 텍스트 (Kei 검수에 전달)
measurement_text = format_measurement_for_kei(measurement) if measurement else ""
# Phase N-4: 5단계 — Kei 실장 최종 검수 (스크린샷 기반, 최대 1회)
# overflow 없으면 skip (시간 절약)
has_any_overflow = False
if measurement:
for zone_data in measurement.get("zones", {}).values():
if zone_data.get("overflowed"):
has_any_overflow = True
break
if measurement.get("slide", {}).get("overflowed"):
has_any_overflow = True
MAX_REVIEW_ROUNDS = 1
screenshot_b64 = None
if not has_any_overflow:
logger.info("5단계 skip: overflow 없음. 검수 불필요.")
else:
yield {"event": "progress", "data": "5/5 Kei 실장이 최종 검수 중..."}
# 스크린샷 캡처 (Selenium)
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
if screenshot_b64:
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
logger.info("[5단계] 스크린샷 캡처 완료 → Kei에게 전달")
for review_round in range(MAX_REVIEW_ROUNDS if has_any_overflow else 0):
review_result = await _review_balance(
html, layout_concept, content, analysis
html, layout_concept, content, analysis, measurement_text,
screenshot_b64=screenshot_b64,
)
if not review_result or not review_result.get("needs_adjustment"):
@@ -122,6 +347,7 @@ async def generate_slide(
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
f"조정 필요 — {issues}"
)
_save_step(run_dir, f"step5_review_round{review_round + 1}.json", review_result)
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
overflow_adjs = [
@@ -137,14 +363,12 @@ async def generate_slide(
)
if kei_judgment is None:
logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체")
for page in layout_concept.get("pages", []):
_downgrade_fallback(
page.get("blocks", []), overflow_context
)
else:
_convert_kei_judgment(review_result, kei_judgment)
logger.info(
# 넘침 판단도 Kei 필수 — 성공할 때까지 무한 재시도
kei_judgment = await _retry_kei(
call_kei_overflow_judgment, overflow_context, content, analysis
)
_convert_kei_judgment(review_result, kei_judgment)
logger.info(
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
)
@@ -164,8 +388,9 @@ async def generate_slide(
html = embed_images(html, base_path)
logger.info("이미지 base64 삽입 완료")
_save_step(run_dir, "final.html", html)
yield {"event": "result", "data": html}
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지, run={run_id}")
except Exception as e:
logger.exception(f"파이프라인 오류: {e}")
@@ -279,18 +504,18 @@ async def _review_balance(
layout_concept: dict[str, Any],
content: str,
analysis: dict[str, Any] | None = None,
measurement_text: str = "",
screenshot_b64: str | None = None,
) -> dict[str, Any] | None:
"""5단계: 디자인 팀장이 조립 결과를 재검토한다.
"""5단계: Kei 실장이 조립 결과를 최종 검수한다. (J-7 + Phase L)
HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지:
- 빈 블록 감지
- 블록 간 채움 비율 불균형
- 이미지/표 크기 적절성
- 높이 초과 감지 → overflow_detected (Kei 판단 필요)
Kei가 콘텐츠 관점 + 실제 렌더링 측정 결과 기반으로 검수:
- 핵심 메시지 전달 여부
- 콘텐츠 흐름 ↔ 블록 배치 일치
- 실제 px 기반 높이/비중 검증 (Phase L)
- 중요 내용 누락/축소 여부
"""
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# 블록별 텍스트 양 요약
block_summary = []
for page in layout_concept.get("pages", []):
@@ -329,51 +554,16 @@ async def _review_balance(
+ "\n".join(hint_lines)
)
system = (
"당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n"
"## 점검 항목\n"
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n"
"6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n"
" - 텍스트 양/블록 수를 보고 판단\n"
" - shrink로 해결 가능하면 shrink 사용\n"
" - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n\n"
"## 조정 action 설명\n"
"- expand: 텍스트를 늘린다. target_ratio로 지정 (예: 1.3 = 30% 증가)\n"
"- shrink: 텍스트를 줄인다. target_ratio로 지정 (예: 0.7 = 30% 감소)\n"
"- rewrite: 텍스트를 완전히 재작성한다. detail에 방향 명시.\n"
"- overflow_detected: 높이 초과로 콘텐츠 판단 필요. 해당 zone과 초과 블록을 detail에 명시.\n\n"
"## 출력 형식 (JSON만)\n"
'{"needs_adjustment": true/false, '
'"issues": ["이슈1", "이슈2"], '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", '
'"target_ratio": 1.3, "detail": "..."}]}'
)
# Phase L: 렌더링 측정 결과를 overflow_hint에 추가 (실제 px 기반)
if measurement_text:
overflow_hint_text += f"\n\n{measurement_text}"
user_prompt = (
f"## 조립 HTML\n{html}\n\n"
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
zone_budget_text +
overflow_hint_text +
f"\n\n## 레이아웃 구조\n"
f"페이지 수: {len(layout_concept.get('pages', []))}\n"
f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n"
f"위 HTML과 데이터를 보고 조정이 필요한지 판단해. JSON으로 답해."
# Kei로 최종 검수 (Sonnet 절대 금지, 스크린샷 있으면 이미지 기반)
return await call_kei_final_review(
html, block_summary, zone_budget_text, overflow_hint_text, analysis,
screenshot_b64=screenshot_b64,
)
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=system,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
return _parse_json(result_text)
except Exception as e:
logger.warning(f"재검토 실패: {e}")
return None