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:
195
src/pipeline.py
195
src/pipeline.py
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user