Phase I 실행 완료 + 프로세스 재설계 (Stage 2.5 → Stage 5)

Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개 항목)
- I-14: SSE 유틸 공통 추출 (src/sse_utils.py 신규, 3개 파일 중복 제거)
- I-13: dead code 3건 삭제 (_call_anthropic_direct, _extract_sse_text x2) + import anthropic 제거
- I-1: STEP_B_PROMPT purpose 가이드 미존재 블록 3개 → 실존 블록 교체
- I-2: catalog.yaml not_for 13건 미존재 블록 참조 교체/제거
- I-12: BLOCK_SLOTS 주석 개수 수정 (cards 9, visuals 6, emphasis 10)
- I-10: INDEX.md 38개 동기화 (삭제된 8개 블록 행 제거)
- I-11: README.md 38개 동기화 (_legacy 제거, 트리/개수 정리)
- I-3: PURPOSE_FALLBACK 상수 + purpose 기반 미등록 블록 교체
- I-7: compare-pill-pair 단독 사용 금지 검증
- I-4: 38개 블록 전체에 slot_desc 추가
- I-5: 편집자 프롬프트에 slot_desc 전달 로직
- I-6: 제목 유사도 70% 초과 시 자동 교정
- I-9: 넘침 판단 Kei API 호출 (KEI_OVERFLOW_PROMPT, call_kei_overflow_judgment)
- I-8: 대형 콘텐츠 정보 Kei overflow 프롬프트에 포함

프로세스 재설계:
- Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합
- _review_balance() 확장: zone 예산 + overflow_detected action 추가
- Stage 5 루프에 Kei 넘침 판단 호출 통합
- _apply_adjustments()에 kei_trim/kei_restructure action 추가
- _build_overflow_context(), _convert_kei_judgment() 헬퍼 함수 추가
- DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 13:06:21 +09:00
parent 1c65255f04
commit ffad1ba82a
11 changed files with 1982 additions and 535 deletions

View File

@@ -15,8 +15,8 @@ from typing import Any, AsyncIterator
import anthropic
from src.kei_client import classify_content, manual_classify, refine_concepts
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
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.content_editor import fill_content
from src.renderer import render_slide
from src.image_utils import get_image_sizes, embed_images
@@ -55,6 +55,20 @@ async def generate_slide(
analysis = await refine_concepts(content, analysis)
logger.info("1단계-B 완료: 컨셉 구체화")
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
from difflib import SequenceMatcher
title = analysis.get("title", "")
topics = analysis.get("topics", [])
if topics:
first_title = topics[0].get("title", "")
similarity = SequenceMatcher(None, title, first_title).ratio()
if similarity > 0.7:
purpose = topics[0].get("purpose", "문제제기")
topics[0]["title"] = f"{purpose}: {topics[0].get('summary', '')[:30]}"
logger.warning(
f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경"
)
# 이미지 크기 측정 (base_path 있을 때만)
image_sizes = get_image_sizes(content, base_path)
if image_sizes:
@@ -92,7 +106,9 @@ async def generate_slide(
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
for review_round in range(MAX_REVIEW_ROUNDS):
review_result = await _review_balance(html, layout_concept, content)
review_result = await _review_balance(
html, layout_concept, content, analysis
)
if not review_result or not review_result.get("needs_adjustment"):
if review_round == 0:
@@ -107,6 +123,31 @@ async def generate_slide(
f"조정 필요 — {issues}"
)
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
overflow_adjs = [
adj for adj in review_result.get("adjustments", [])
if adj.get("action") == "overflow_detected"
]
if overflow_adjs:
overflow_context = _build_overflow_context(
layout_concept, overflow_adjs
)
kei_judgment = await call_kei_overflow_judgment(
overflow_context, content, analysis
)
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(
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
)
layout_concept = await _apply_adjustments(
layout_concept, review_result, content
)
@@ -237,13 +278,15 @@ async def _review_balance(
html: str,
layout_concept: dict[str, Any],
content: str,
analysis: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""5단계: 디자인 팀장이 1차 조립 결과를 재검토한다.
"""5단계: 디자인 팀장이 조립 결과를 재검토한다.
HTML 코드 기반으로 구조적 점검:
HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지:
- 빈 블록 감지
- 블록 간 채움 비율 불균형
- 이미지/표 크기 적절성
- 높이 초과 감지 → overflow_detected (Kei 판단 필요)
"""
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
@@ -259,28 +302,62 @@ async def _review_balance(
f"데이터 {text_len}"
)
# zone 예산 정보 (analysis에서 프리셋 추출)
zone_budget_text = ""
overflow_hint_text = ""
if analysis:
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
zone_lines = [
f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)"
for name, z in preset.get("zones", {}).items()
]
zone_budget_text = (
"\n\n## zone별 높이 예산\n" + "\n".join(zone_lines)
)
# Stage 2에서 감지한 예상 overflow 힌트
overflow_hint = layout_concept.get("overflow", [])
if overflow_hint:
hint_lines = [
f"- {o['area']}: 예상 {o['total_px']}px > 예산 {o['budget_px']}px "
f"(+{o['overflow_px']}px 초과)"
for o in overflow_hint
]
overflow_hint_text = (
"\n\n## 높이 초과 힌트 (2단계 예상치, 참고용)\n"
+ "\n".join(hint_lines)
)
system = (
"당신은 디자인 팀장이다. 1차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n"
"당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n"
"## 점검 항목\n"
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\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\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", '
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", '
'"target_ratio": 1.3, "detail": "..."}]}'
)
user_prompt = (
f"## 1차 조립 HTML\n{html}\n\n"
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"
@@ -344,11 +421,105 @@ async def _apply_adjustments(
block["reason"] = f"재작성: {detail}"
logger.info(f"조정: {area} → rewrite ({detail})")
elif action == "kei_trim":
max_chars = adj.get("max_chars", 200)
if "char_guide" not in block:
block["char_guide"] = {}
for key in block.get("char_guide", {}):
block["char_guide"][key] = min(
block["char_guide"][key], max_chars
)
if not block["char_guide"]:
block["char_guide"] = {"text": max_chars}
logger.info(
f"조정: {area} → kei_trim max_chars={max_chars} "
f"({detail})"
)
elif action == "kei_restructure":
block["detail_target"] = True
if "data" in block:
del block["data"]
block["reason"] = f"재구성: {detail}"
logger.info(
f"조정: {area} → kei_restructure (detail_target)"
)
# 조정된 가이드로 재편집
layout_concept = await fill_content(content, layout_concept)
return layout_concept
def _build_overflow_context(
layout_concept: dict[str, Any],
overflow_adjs: list[dict],
) -> list[dict]:
"""Sonnet이 감지한 overflow_detected를 Kei에게 전달할 형태로 변환한다.
실제 채워진 블록 데이터(텍스트)를 포함하여 Kei가 판단할 수 있도록 한다.
"""
overflows = []
for adj in overflow_adjs:
area = adj.get("block_area", "")
# 해당 zone의 블록 정보 + 실제 텍스트 추출
area_blocks = []
for page in layout_concept.get("pages", []):
for block in page.get("blocks", []):
if block.get("area") == area:
data = block.get("data", {})
text_preview = json.dumps(data, ensure_ascii=False)[:300]
area_blocks.append({
"type": block.get("type", ""),
"purpose": block.get("purpose", ""),
"topic_id": block.get("topic_id"),
"text_preview": text_preview,
})
overflows.append({
"area": area,
"detail": adj.get("detail", ""),
"blocks": area_blocks,
})
return overflows
def _convert_kei_judgment(
review_result: dict[str, Any],
kei_judgment: dict[str, Any],
) -> None:
"""Kei의 trim/restructure 판단을 review_result.adjustments에 반영한다.
기존 overflow_detected 항목을 kei_trim 또는 kei_restructure로 교체.
"""
decision = kei_judgment.get("decision", "")
new_adjs = []
for adj in review_result.get("adjustments", []):
if adj.get("action") == "overflow_detected":
# overflow_detected → Kei 판단으로 교체
if decision == "trim":
for target in kei_judgment.get("trim_targets", []):
new_adjs.append({
"block_area": adj.get("block_area", ""),
"action": "kei_trim",
"max_chars": target.get("max_chars", 200),
"topic_id": target.get("topic_id"),
"detail": target.get("reason", ""),
})
elif decision == "restructure":
for tid in kei_judgment.get("detail_topics", []):
new_adjs.append({
"block_area": adj.get("block_area", ""),
"action": "kei_restructure",
"topic_id": tid,
"detail": kei_judgment.get("reason", ""),
})
else:
# 기존 expand/shrink/rewrite는 그대로 유지
new_adjs.append(adj)
review_result["adjustments"] = new_adjs
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [