IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D) - A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade - A-2: 5단계 HTML 전문 프롬프트 전달 - A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반 - A-4: rewrite action 구현 - A-5: overflow: visible (area 레벨 텍스트 잘림 방지) - A-6: object-fit cover → contain (이미지 crop 방지) - A-7: table-layout: fixed - A-8: container query 폰트 스케일링 - B-1: details-block 템플릿 신규 (CSS 변수만 사용) - B-2: 인쇄 시 details 자동 펼침 JS - B-3: catalog에 details-block 등록 - B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화 - B-8: fallback card-grid → topic-header + char_guide 제거 - C-1: CLAUDE.md gradient 원칙 완화 - C-3: border-radius 9개 파일 var(--radius) 통일 - C-4: box-shadow 2레벨 → 1레벨 - D-0: 이미지 경로 입력 UI + API base_path - D-1: Pillow 의존성 + image_utils.py - D-2~D-4: 이미지 비율/축소방지 프롬프트 전달 - D-5: HTML에 이미지 base64 삽입 ## Phase 2 (다른 Claude 작업) - P2-A: FAISS 블록 검색 (bge-m3, 46개 블록) - P2-B: SVG N개 자동 배치 (svg_calculator.py) - P2-C: Opus 블록 추천 (Kei API 경유) - P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2) - P2-E: details-block fallback 연동 ## 버그 수정 (BF-8~10) - BF-8: 컨테이너 예산 기반 블록 배치 - BF-9: grid와 Sonnet 역할 분리 - BF-10: catalog mtime 캐시 자동 갱신 ## 블록 라이브러리 - 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화 - 구 블록 제거 (quote-block, card-grid, comparison) - 13개 _legacy 블록 보존 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
193
src/pipeline.py
193
src/pipeline.py
@@ -16,9 +16,10 @@ from typing import Any, AsyncIterator
|
||||
import anthropic
|
||||
|
||||
from src.kei_client import classify_content, manual_classify
|
||||
from src.design_director import create_layout_concept
|
||||
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.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
base_path: str = "",
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
"""콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인.
|
||||
|
||||
@@ -48,6 +50,12 @@ async def generate_slide(
|
||||
page_count = analysis.get("total_pages", 1)
|
||||
logger.info(f"1단계 완료: {topic_count}개 꼭지, {page_count}페이지")
|
||||
|
||||
# 이미지 크기 측정 (base_path 있을 때만)
|
||||
image_sizes = get_image_sizes(content, base_path)
|
||||
if image_sizes:
|
||||
analysis["image_sizes"] = image_sizes
|
||||
logger.info(f"이미지 측정: {len(image_sizes)}개")
|
||||
|
||||
# 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
|
||||
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
|
||||
@@ -67,29 +75,48 @@ async def generate_slide(
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
logger.info("3단계 완료: 텍스트 정리")
|
||||
|
||||
# 4단계: 디자인 실무자 — HTML 조립
|
||||
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
|
||||
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
|
||||
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("4단계 완료: HTML 조립")
|
||||
|
||||
# 5단계: 디자인 팀장 — 전체 재검토
|
||||
# 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
|
||||
MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
|
||||
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
|
||||
|
||||
review_result = await _review_balance(html, layout_concept, content)
|
||||
for review_round in range(MAX_REVIEW_ROUNDS):
|
||||
review_result = await _review_balance(html, layout_concept, content)
|
||||
|
||||
if review_result and review_result.get("needs_adjustment"):
|
||||
if not review_result or not review_result.get("needs_adjustment"):
|
||||
if review_round == 0:
|
||||
logger.info("5단계 완료: 조정 불필요")
|
||||
else:
|
||||
logger.info(f"5단계 완료: {review_round}차 조정 후 균형 확인")
|
||||
break
|
||||
|
||||
issues = review_result.get("issues", [])
|
||||
logger.info(
|
||||
f"5단계: 조정 필요 — {review_result.get('issues', [])}"
|
||||
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
|
||||
f"조정 필요 — {issues}"
|
||||
)
|
||||
# 조정 지시에 따라 텍스트 재편집 또는 레이아웃 재조정
|
||||
|
||||
layout_concept = await _apply_adjustments(
|
||||
layout_concept, review_result, content
|
||||
)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("5단계 완료: 2차 조정 반영")
|
||||
logger.info(f"5단계: {review_round + 1}차 조정 반영, 재검토 진행")
|
||||
else:
|
||||
logger.info("5단계 완료: 조정 불필요")
|
||||
# MAX_REVIEW_ROUNDS 초과
|
||||
logger.warning(
|
||||
f"5단계: 최대 재조정 횟수({MAX_REVIEW_ROUNDS}) 도달. 현재 결과로 확정."
|
||||
)
|
||||
|
||||
# D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
|
||||
if base_path:
|
||||
html = embed_images(html, base_path)
|
||||
logger.info("이미지 base64 삽입 완료")
|
||||
|
||||
yield {"event": "result", "data": html}
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
|
||||
@@ -99,6 +126,108 @@ async def generate_slide(
|
||||
yield {"event": "error", "data": str(e)}
|
||||
|
||||
|
||||
async def _adjust_design(
|
||||
layout_concept: dict[str, Any],
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""4단계 전반: 디자인 실무자가 텍스트 양에 맞게 CSS를 조정한다.
|
||||
|
||||
각 area별 블록 수, 텍스트 총량, zone 예산을 계산하고,
|
||||
Sonnet이 area별 CSS 변수 override를 결정한다.
|
||||
블록 템플릿이 이미 CSS 변수(var(--font-body) 등)를 사용하므로,
|
||||
area div에서 변수를 override하면 내부 블록이 자동 조정된다.
|
||||
"""
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# 프리셋 정보 가져오기
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
zones = preset.get("zones", {})
|
||||
|
||||
for page in layout_concept.get("pages", []):
|
||||
# area별 블록 수 + 텍스트 총량 집계
|
||||
area_info = {}
|
||||
for block in page.get("blocks", []):
|
||||
area = block.get("area", "body")
|
||||
if area not in area_info:
|
||||
zone = zones.get(area, {})
|
||||
area_info[area] = {
|
||||
"block_count": 0,
|
||||
"total_chars": 0,
|
||||
"budget_px": zone.get("budget_px", 490),
|
||||
"width_pct": zone.get("width_pct", 100),
|
||||
"block_types": [],
|
||||
}
|
||||
data = block.get("data", {})
|
||||
text_len = len(json.dumps(data, ensure_ascii=False))
|
||||
area_info[area]["block_count"] += 1
|
||||
area_info[area]["total_chars"] += text_len
|
||||
area_info[area]["block_types"].append(block.get("type", ""))
|
||||
|
||||
# area 정보 텍스트 구성
|
||||
area_lines = []
|
||||
for area_name, info in area_info.items():
|
||||
area_lines.append(
|
||||
f"- {area_name} (예산 {info['budget_px']}px, 너비 {info['width_pct']}%): "
|
||||
f"{info['block_count']}개 블록, 총 {info['total_chars']}자\n"
|
||||
f" 블록 타입: {', '.join(info['block_types'])}"
|
||||
)
|
||||
|
||||
system = (
|
||||
"당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.\n\n"
|
||||
"## 원칙\n"
|
||||
"- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.\n"
|
||||
"- 빈 공간을 방치하지 않는다.\n"
|
||||
"- 텍스트가 많으면: 폰트/여백을 줄여서 맞춘다.\n"
|
||||
"- 텍스트가 적으면: 폰트/여백을 늘려서 채운다.\n\n"
|
||||
"## 조정 가능한 CSS 변수\n"
|
||||
"- --font-body (기본 0.95rem): 본문 폰트 크기\n"
|
||||
"- --font-subtitle (기본 1.25rem): 소제목 폰트 크기\n"
|
||||
"- --font-caption (기본 0.8rem): 캡션 폰트 크기\n"
|
||||
"- --spacing-inner (기본 16px): 블록 내부 여백\n"
|
||||
"- --spacing-block (기본 20px): 블록 간 간격\n"
|
||||
"- --spacing-small (기본 8px): 작은 여백\n\n"
|
||||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||||
"각 area에 적용할 CSS 변수 override를 inline style 문자열로 반환.\n"
|
||||
"조정 불필요한 area는 빈 문자열.\n"
|
||||
'{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"## 각 영역 현황\n" + "\n".join(area_lines) +
|
||||
f"\n\n위 영역별로 CSS 변수 조정이 필요한지 판단하여 JSON으로 반환해줘."
|
||||
)
|
||||
|
||||
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
|
||||
result = _parse_json(result_text)
|
||||
|
||||
if result and "area_styles" in result:
|
||||
page["area_styles"] = result["area_styles"]
|
||||
logger.info(
|
||||
f"디자인 조정: {', '.join(f'{k}={bool(v)}' for k, v in result['area_styles'].items())}"
|
||||
)
|
||||
else:
|
||||
page["area_styles"] = {}
|
||||
logger.info("디자인 조정: 조정 불필요 또는 파싱 실패")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"디자인 조정 실패 (기존 스타일로 렌더링): {e}")
|
||||
# 실패 시 area_styles 없음 → 기존과 동일하게 렌더링
|
||||
for page in layout_concept.get("pages", []):
|
||||
if "area_styles" not in page:
|
||||
page["area_styles"] = {}
|
||||
|
||||
return layout_concept
|
||||
|
||||
|
||||
async def _review_balance(
|
||||
html: str,
|
||||
layout_concept: dict[str, Any],
|
||||
@@ -126,24 +255,31 @@ async def _review_balance(
|
||||
)
|
||||
|
||||
system = (
|
||||
"당신은 디자인 팀장이다. 1차 조립 결과를 검토하여 균형을 점검한다.\n\n"
|
||||
"당신은 디자인 팀장이다. 1차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n"
|
||||
"## 점검 항목\n"
|
||||
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
|
||||
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
|
||||
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
|
||||
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n\n"
|
||||
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
|
||||
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\n"
|
||||
"## 조정 action 설명\n"
|
||||
"- expand: 텍스트를 늘린다. target_ratio로 얼마나 늘릴지 지정 (예: 1.3 = 30% 증가)\n"
|
||||
"- shrink: 텍스트를 줄인다. target_ratio로 얼마나 줄일지 지정 (예: 0.7 = 30% 감소)\n"
|
||||
"- rewrite: 텍스트를 완전히 재작성한다. detail에 재작성 방향 명시.\n\n"
|
||||
"## 출력 형식 (JSON만)\n"
|
||||
'{"needs_adjustment": true/false, '
|
||||
'"issues": ["이슈1", "이슈2"], '
|
||||
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}'
|
||||
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", '
|
||||
'"target_ratio": 1.3, "detail": "..."}]}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"## 1차 조립 HTML\n{html}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
|
||||
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"조정이 필요한가? JSON으로 답해."
|
||||
f"위 HTML과 데이터를 보고 조정이 필요한지 판단해. JSON으로 답해."
|
||||
)
|
||||
|
||||
response = await client.messages.create(
|
||||
@@ -175,18 +311,33 @@ async def _apply_adjustments(
|
||||
for adj in adjustments:
|
||||
area = adj.get("block_area", "")
|
||||
action = adj.get("action", "")
|
||||
ratio = adj.get("target_ratio")
|
||||
detail = adj.get("detail", "")
|
||||
|
||||
for page in layout_concept.get("pages", []):
|
||||
for block in page.get("blocks", []):
|
||||
if block.get("area") == area and action in ("expand", "rewrite"):
|
||||
# 해당 블록의 char_guide를 조정하여 재편집 유도
|
||||
if action == "expand":
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * 1.5
|
||||
)
|
||||
logger.info(f"조정: {area} → {action} ({detail})")
|
||||
if block.get("area") != area:
|
||||
continue
|
||||
|
||||
if action == "expand" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * ratio
|
||||
)
|
||||
logger.info(f"조정: {area} → expand ×{ratio} ({detail})")
|
||||
|
||||
elif action == "shrink" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * ratio
|
||||
)
|
||||
logger.info(f"조정: {area} → shrink ×{ratio} ({detail})")
|
||||
|
||||
elif action == "rewrite":
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
block["reason"] = f"재작성: {detail}"
|
||||
logger.info(f"조정: {area} → rewrite ({detail})")
|
||||
|
||||
# 조정된 가이드로 재편집
|
||||
layout_concept = await fill_content(content, layout_concept)
|
||||
|
||||
Reference in New Issue
Block a user