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

@@ -16,6 +16,7 @@ import httpx
import yaml
from src.config import settings
from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__)
@@ -24,49 +25,285 @@ logger = logging.getLogger(__name__)
# ──────────────────────────────────────
BLOCK_SLOTS = {
# headers/ (5개)
"section-title-with-bg": {"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"]},
"section-header-bar": {"required": ["title"], "optional": ["subtitle"]},
"topic-left-right": {"required": ["title", "description"], "optional": []},
"topic-center": {"required": ["title"], "optional": ["subtitle", "description"]},
"topic-numbered": {"required": ["number", "title"], "optional": ["description", "color"]},
# cards/ (10개)
"card-image-3col": {"required": ["cards"], "optional": []},
"card-dark-overlay": {"required": ["cards"], "optional": []},
"card-tag-image": {"required": ["cards"], "optional": []},
"card-icon-desc": {"required": ["cards"], "optional": []},
"card-compare-3col": {"required": ["cards"], "optional": []},
"card-step-vertical": {"required": ["steps"], "optional": []},
"card-image-round": {"required": ["cards"], "optional": []},
"card-stat-number": {"required": ["stats"], "optional": []},
"card-numbered": {"required": ["items"], "optional": []},
"section-title-with-bg": {
"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"],
"slot_desc": {
"title_ko": "한글 메인 타이틀",
"title_en": "영문 서브 타이틀 (없으면 생략)",
"breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)",
"bg_image": "배경 이미지 경로",
},
},
"section-header-bar": {
"required": ["title"], "optional": ["subtitle"],
"slot_desc": {
"title": "섹션 제목 (짧고 굵게)",
"subtitle": "보조 설명 (한 줄)",
},
},
"topic-left-right": {
"required": ["title", "description"], "optional": [],
"slot_desc": {
"title": "꼭지 제목 (좌측, 굵게)",
"description": "꼭지 설명 (우측, 2~3줄)",
},
},
"topic-center": {
"required": ["title"], "optional": ["subtitle", "description"],
"slot_desc": {
"title": "중앙 정렬 대제목",
"subtitle": "부제목 (작은 글씨)",
"description": "추가 설명 (1~2줄)",
},
},
"topic-numbered": {
"required": ["number", "title"], "optional": ["description", "color"],
"slot_desc": {
"number": "순번 (1, 2, 3 등)",
"title": "단계/항목 제목",
"description": "설명 텍스트",
"color": "원형 번호 색상 (CSS 색상값)",
},
},
# cards/ (9개)
"card-image-3col": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '이미지 경로', title: '제목', title_en: '영문', bullets: ['항목1', '항목2']}. 3장.",
},
},
"card-dark-overlay": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '배경 이미지', title: '키워드', description: '짧은 설명'}. 3~5장.",
},
},
"card-tag-image": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {tag: '카테고리 라벨', tag_color: '색상', image: '이미지', title: '제목', description: '설명'}. 3장.",
},
},
"card-icon-desc": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {icon: '이모지', title: '제목', description: '설명 (2~3줄)'}. 2~4장.",
},
},
"card-compare-3col": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "비교 카드 배열. 각 카드: {header: '카테고리명', header_color: '색상', bullets: ['항목1', '항목2']}. 3장.",
},
},
"card-step-vertical": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명', image: '이미지(선택)'}. 3~5단계.",
},
},
"card-image-round": {
"required": ["cards"], "optional": [],
"slot_desc": {
"cards": "카드 배열. 각 카드: {image: '원형 이미지', title: '제목', description: '설명'}. 2~3장.",
},
},
"card-stat-number": {
"required": ["stats"], "optional": [],
"slot_desc": {
"stats": "통계 배열. 각 항목: {number: '85', unit: '%', label: '비용 절감율'}. 2~4개. 숫자는 출처 있는 것만!",
},
},
"card-numbered": {
"required": ["items"], "optional": [],
"slot_desc": {
"items": "항목 배열. 각 항목: {title: '항목 제목', description: '설명'}. 3~5개.",
},
},
# tables/ (3개)
"compare-3col-badge": {"required": ["headers", "rows"], "optional": []},
"compare-2col-split": {"required": ["left_title", "right_title", "rows"], "optional": []},
"table-simple-striped": {"required": ["headers", "rows"], "optional": []},
# visuals/ (10개)
"venn-diagram": {"required": ["center_label", "items"], "optional": ["center_sub", "description"]},
"circle-gradient": {"required": ["label"], "optional": ["sub_label"]},
"compare-pill-pair": {"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"]},
"process-horizontal": {"required": ["steps"], "optional": []},
"flow-arrow-horizontal": {"required": ["steps"], "optional": []},
"keyword-circle-row": {"required": ["keywords"], "optional": []},
# emphasis/ (12개)
"quote-big-mark": {"required": ["quote_text"], "optional": ["source"]},
"quote-question": {"required": ["question"], "optional": ["description"]},
"comparison-2col": {"required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"]},
"banner-gradient": {"required": ["text"], "optional": ["sub_text"]},
"dark-bullet-list": {"required": ["bullets"], "optional": ["title"]},
"highlight-strip": {"required": ["segments"], "optional": []},
"callout-solution": {"required": ["title", "description"], "optional": ["icon", "source"]},
"callout-warning": {"required": ["title", "description"], "optional": ["icon"]},
"tab-label-row": {"required": ["tabs"], "optional": []},
"divider-text": {"required": ["text"], "optional": []},
"compare-3col-badge": {
"required": ["headers", "rows"], "optional": [],
"slot_desc": {
"headers": "3개 열 헤더 배열: ['항목', 'A 대상', 'B 대상']",
"rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: 'A 내용', right: 'B 내용'}. 최소 3행.",
},
},
"compare-2col-split": {
"required": ["left_title", "right_title", "rows"], "optional": [],
"slot_desc": {
"left_title": "왼쪽 열 헤더",
"right_title": "오른쪽 열 헤더",
"rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.",
},
},
"table-simple-striped": {
"required": ["headers", "rows"], "optional": [],
"slot_desc": {
"headers": "열 헤더 배열: ['열1', '열2', '열3']",
"rows": "데이터 행 배열. 각 행: ['셀1', '셀2', '셀3']. 행 수 자유.",
},
},
# visuals/ (6개)
"venn-diagram": {
"required": ["center_label", "items"], "optional": ["center_sub", "description"],
"slot_desc": {
"center_label": "중앙 교집합 라벨 (핵심 키워드)",
"items": "원 배열. 각 원: {label: '영역명', sub: '설명'}. 2~5개.",
"center_sub": "중앙 부가 설명",
"description": "다이어그램 하단 설명",
},
},
"circle-gradient": {
"required": ["label"], "optional": ["sub_label"],
"slot_desc": {
"label": "원 중앙 메인 텍스트 (키워드, 1~2단어)",
"sub_label": "원 아래 보조 텍스트",
},
},
"compare-pill-pair": {
"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"],
"slot_desc": {
"left_label": "왼쪽 개념명 (1~2단어)",
"right_label": "오른쪽 개념명 (1~2단어)",
"left_sub": "왼쪽 보조 설명",
"right_sub": "오른쪽 보조 설명",
},
},
"process-horizontal": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명'}. 3~5단계.",
},
},
"flow-arrow-horizontal": {
"required": ["steps"], "optional": [],
"slot_desc": {
"steps": "흐름 배열. 각 항목: {label: '단계명'}. 3~5개. 화살표로 연결됨.",
},
},
"keyword-circle-row": {
"required": ["keywords"], "optional": [],
"slot_desc": {
"keywords": "키워드 배열. 각 항목: {letter: '약어 (G)', label: '풀네임', description: '설명'}. 3~5개.",
},
},
# emphasis/ (10개)
"quote-big-mark": {
"required": ["quote_text"], "optional": ["source"],
"slot_desc": {
"quote_text": "인용할 본문 텍스트 (핵심 발언, 1~3문장)",
"source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!",
},
},
"quote-question": {
"required": ["question"], "optional": ["description"],
"slot_desc": {
"question": "독자에게 던지는 질문 (1문장, 물음표로 끝)",
"description": "질문에 대한 부연 (1~2줄)",
},
},
"comparison-2col": {
"required": ["left_title", "left_content", "right_title", "right_content"],
"optional": ["left_subtitle", "right_subtitle"],
"slot_desc": {
"left_title": "왼쪽 개념 제목 (파란색)",
"left_content": "왼쪽 본문 (불릿 또는 문장)",
"right_title": "오른쪽 개념 제목 (빨간색)",
"right_content": "오른쪽 본문 (불릿 또는 문장)",
"left_subtitle": "왼쪽 보조 제목",
"right_subtitle": "오른쪽 보조 제목",
},
},
"banner-gradient": {
"required": ["text"], "optional": ["sub_text"],
"slot_desc": {
"text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)",
"sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)",
},
},
"dark-bullet-list": {
"required": ["bullets"], "optional": ["title"],
"slot_desc": {
"title": "리스트 상단 제목 (파란색, 선택)",
"bullets": "불릿 항목 배열: ['핵심 포인트 1', '핵심 포인트 2']. 3~5개.",
},
},
"highlight-strip": {
"required": ["segments"], "optional": [],
"slot_desc": {
"segments": "색상 구간 배열. 각 구간: {label: '카테고리명', color: '색상'}. 3~5개.",
},
},
"callout-solution": {
"required": ["title", "description"], "optional": ["icon", "source"],
"slot_desc": {
"title": "솔루션/방향성 제목",
"description": "상세 설명 (2~3줄)",
"icon": "아이콘 이모지 (예: 💡)",
"source": "출처 (있으면)",
},
},
"callout-warning": {
"required": ["title", "description"], "optional": ["icon"],
"slot_desc": {
"title": "문제점/경고 제목",
"description": "상세 설명 (2~3줄)",
"icon": "아이콘 이모지 (예: ⚠️)",
},
},
"tab-label-row": {
"required": ["tabs"], "optional": [],
"slot_desc": {
"tabs": "탭 배열. 각 탭: {label: '탭 이름', active: true/false}. 3~5개. 하나만 active.",
},
},
"divider-text": {
"required": ["text"], "optional": [],
"slot_desc": {
"text": "구분선 중앙 텍스트 (짧은 전환 문구, 1~5단어)",
},
},
# media/ (5개)
"image-row-2col": {"required": ["images"], "optional": []},
"image-grid-2x2": {"required": ["images"], "optional": []},
"image-side-text": {"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"]},
"image-full-caption": {"required": ["src"], "optional": ["alt", "caption"]},
"image-before-after": {"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"]},
"image-row-2col": {
"required": ["images"], "optional": [],
"slot_desc": {
"images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명', caption: '캡션'}. 2장.",
},
},
"image-grid-2x2": {
"required": ["images"], "optional": [],
"slot_desc": {
"images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명'}. 4장 (2x2).",
},
},
"image-side-text": {
"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"],
"slot_desc": {
"image_src": "좌측 이미지 경로",
"image_alt": "이미지 대체 텍스트",
"title": "우측 제목",
"description": "우측 설명 텍스트",
"bullets": "우측 불릿 항목 배열: ['항목1', '항목2']",
},
},
"image-full-caption": {
"required": ["src"], "optional": ["alt", "caption"],
"slot_desc": {
"src": "전체 너비 이미지 경로",
"alt": "이미지 대체 텍스트",
"caption": "이미지 하단 캡션",
},
},
"image-before-after": {
"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"],
"slot_desc": {
"before_src": "Before 이미지 경로",
"after_src": "After 이미지 경로",
"before_label": "Before 라벨 (기본: Before)",
"after_label": "After 라벨 (기본: After)",
"caption": "비교 설명 캡션",
},
},
}
# ──────────────────────────────────────
@@ -264,11 +501,11 @@ header/footer는 고정이므로 건드리지 않는다.
## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
각 꼭지의 purpose에 맞는 블록 계열을 선택하라:
- 문제제기 → callout-warning, quote-big-mark, quote-question
- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)
- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)
- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
- 결론강조 → banner-gradient (footer)
- 구조시각화 → venn-diagram, layer-diagram (단독 배치)
- 구조시각화 → venn-diagram (단독 배치)
## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라)
{allowed_ids}
@@ -364,7 +601,7 @@ async def _opus_block_recommendation(
logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}")
return None
full_text = await _stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[Step A-2] Kei API 응답 텍스트 없음")
@@ -386,35 +623,6 @@ async def _opus_block_recommendation(
return None
async def _stream_sse_tokens(response: httpx.Response) -> str:
"""SSE 스트리밍 응답에서 토큰을 실시간 수집한다."""
tokens: list[str] = []
event_type = ""
async for line in response.aiter_lines():
line = line.strip()
if not line:
event_type = ""
continue
if line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
if event_type == "token" and data:
try:
token = json.loads(data)
if isinstance(token, str):
tokens.append(token)
except json.JSONDecodeError:
tokens.append(data)
elif event_type == "done":
break
elif event_type == "error":
logger.warning(f"Kei API SSE 에러: {data}")
break
return "".join(tokens)
async def create_layout_concept(
content: str,
@@ -567,11 +775,13 @@ async def create_layout_concept(
for block in blocks:
block_type = block.get("type", "")
if block_type and block_type not in registered_ids:
purpose = block.get("purpose", "")
fallback = PURPOSE_FALLBACK.get(purpose, "callout-solution")
logger.warning(
f"[Step B 검증] 미등록 블록 '{block_type}' 거부 → "
f"'callout-solution'으로 교체"
f"'{fallback}'으로 교체 (purpose={purpose})"
)
block["type"] = "callout-solution"
block["type"] = fallback
# area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
valid_zones = {z for z in preset["zones"] if z != "header"}
@@ -597,13 +807,14 @@ async def create_layout_concept(
)
block["area"] = "footer"
# 5번: zone별 height_cost 합산 검증 — 초과 시 큰 블록 교체
_validate_height_budget(blocks, preset)
# 5번: zone별 height_cost 합산 검증 (I-9: overflow 수집, 블록 교체 안 함)
overflows = _validate_height_budget(blocks, preset)
logger.info(
f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
+ (f", overflow {len(overflows)}" if overflows else "")
)
return {
result = {
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": preset["grid_areas"],
@@ -612,6 +823,9 @@ async def create_layout_concept(
"blocks": blocks,
}],
}
if overflows:
result["overflow"] = overflows
return result
else:
logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
@@ -670,6 +884,16 @@ HEIGHT_COST_PX = {
"xlarge": 400,
}
# 미등록 블록 거부 시 purpose 기반 대체 (I-3)
PURPOSE_FALLBACK = {
"문제제기": "callout-warning",
"근거사례": "quote-big-mark",
"핵심전달": "comparison-2col",
"용어정의": "card-icon-desc",
"결론강조": "banner-gradient",
"구조시각화": "card-icon-desc",
}
# body/sidebar/footer zone에서 사용 금지인 블록 → 교체
BODY_FORBIDDEN_MAP = {
"section-title-with-bg": "topic-center", # 500px 블록 → compact 헤더로
@@ -708,11 +932,16 @@ def _load_catalog_map_for_height() -> dict[str, str]:
return {}
def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
"""zone별 height_cost 합산을 검증하고, 초과 시 큰 블록을 교체한다.
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"""zone별 height_cost 합산을 검증한다. (I-9 개정)
또한 body/sidebar/footer zone에서 금지 블록 교체한다.
코드 레벨 검증 — Sonnet이 높이 예산을 안 지켜도 강제 교정.
금지 블록 교체, pill-pair 단독 검증은 수행하되,
높이 초과 시 블록을 자동 교체하지 않는다.
대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용.
Returns:
overflow 정보 리스트. 초과 없으면 빈 리스트.
"""
zones = preset.get("zones", {})
gap_px = 20 # --spacing-block
@@ -736,11 +965,24 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
)
block["type"] = replacement
# compare-pill-pair 단독 사용 금지 (I-7)
COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
for area, area_blocks in zone_blocks.items():
types = {b.get("type") for b in area_blocks}
if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS:
for block in area_blocks:
if block.get("type") == "compare-pill-pair":
block["type"] = "comparison-2col"
logger.warning(
"[pill-pair 단독 금지] compare-pill-pair → comparison-2col"
)
# 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함)
overflows: list[dict] = []
for area, area_blocks in zone_blocks.items():
zone_info = zones.get(area, {})
budget = zone_info.get("budget_px", 490)
# 총 높이 계산
total = sum(_get_block_height(b.get("type", "")) for b in area_blocks)
total += gap_px * max(0, len(area_blocks) - 1)
@@ -752,8 +994,39 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
f"블록: {[b.get('type') for b in area_blocks]}"
)
# 가장 큰 블록부터 교체 시도
area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True)
overflows.append({
"area": area,
"overflow_px": total - budget,
"budget_px": budget,
"total_px": total,
"blocks": [
{
"type": b.get("type", ""),
"purpose": b.get("purpose", ""),
"topic_id": b.get("topic_id"),
"height_px": _get_block_height(b.get("type", "")),
}
for b in area_blocks
],
})
return overflows
def _downgrade_fallback(blocks: list[dict], overflows: list[dict]) -> None:
"""Kei API 실패 시 비상용 기계적 블록 교체.
기존 DOWNGRADE_MAP 로직. 정상 경로가 아닌 비상 경로.
"""
for overflow in overflows:
area = overflow["area"]
area_blocks = [b for b in blocks if b.get("area") == area]
area_blocks.sort(
key=lambda b: _get_block_height(b.get("type", "")), reverse=True
)
total = overflow["total_px"]
budget = overflow["budget_px"]
for block in area_blocks:
block_type = block.get("type", "")
@@ -767,8 +1040,8 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
block["type"] = replacement
total = total - old_height + new_height
logger.info(
f"[높이 교체] {block_type}({old_height}px) → "
logger.warning(
f"[DOWNGRADE 비상] {block_type}({old_height}px) → "
f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
)