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:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user