Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리

- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정
- Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div
- Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제
- Selenium: container div 감지 추가
- catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드
- 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 15:20:51 +09:00
parent ffad1ba82a
commit b0bcffc0f6
28 changed files with 8450 additions and 1530 deletions

View File

@@ -11,7 +11,6 @@ import re
from pathlib import Path
from typing import Any
import anthropic
import httpx
import yaml
@@ -446,90 +445,9 @@ def _load_catalog() -> str:
- banner-gradient: 섹션 강조 배너."""
STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다.
당신의 핵심 역할: **컨테이너(zone)의 크기 예산 안에서** 블록을 배정하는 것이다.
## 슬라이드 물리적 제약 (절대 조건)
- 프레임: 1280×720px (16:9 고정)
- 패딩: 상하좌우 40px → 가용 영역: 1200×640px
- 블록 간 간격: 20px
- **overflow: hidden** — 넘치는 콘텐츠는 잘려서 보이지 않는다!
## 선택된 레이아웃 프리셋: {preset_name}
{preset_description}
### CSS Grid (변경하지 마라):
grid-template-areas: {grid_areas}
grid-template-columns: {grid_columns}
grid-template-rows: {grid_rows}
### Zone별 컨테이너 예산:
{zone_descriptions}
## ★ 사고 순서 (반드시 이 순서로 판단하라)
### 1단계: 컨테이너 크기 확인
위 zone별 높이 예산(px)과 너비(%)를 확인한다. 이것이 절대 제약이다.
header/footer는 고정이므로 건드리지 않는다.
### 2단계: 꼭지 → zone 배정
- flow 꼭지 → body / left / hero zone
- reference 꼭지 → sidebar zone
- conclusion 꼭지 → footer zone (banner-gradient 권장)
### 3단계: zone별 블록 선택 + 높이 예산 계산
각 zone에 대해:
a) 배정된 꼭지 수를 확인한다
b) catalog에서 블록을 선택한다 (각 블록의 height_cost 확인!)
c) 총 높이를 계산한다: Σ(블록 height_cost) + 간격(20px × (블록수-1))
d) **총 높이 ≤ zone 예산** 인지 반드시 확인한다
e) 초과 시: ① 더 작은(compact) 블록으로 교체 ② 꼭지를 다음 페이지로 분리
### 4단계: 최종 검증
모든 zone의 블록 총 높이가 예산 이내인지 재확인한 후 출력한다.
## 블록 선택 규칙 (절대 규칙)
- **아래 허용 목록에 있는 블록만 선택하라. 목록에 없는 블록은 절대 사용 금지.**
- **텍스트 블록 우선** — 텍스트로 충분히 전달 가능하면 시각화(SVG) 블록 쓰지 마라
- **시각화 블록은 높이 비용이 크다** — 한 zone에 시각화 블록은 최대 1개
- 너비 35% 이하 zone(sidebar)에는 카드 1열, 시각화 블록 금지
- catalog의 when/not_for와 height_cost를 반드시 읽고 선택
- 같은 블록 타입 반복 금지 — 다양한 블록 활용
- **section-title-with-bg는 body/sidebar/footer zone에서 사용 금지.** 이 블록은 자세히보기 전용 페이지 상단에만 사용.
- 각 꼭지의 relation_type과 expression_hint를 보고 적합한 블록을 선택하라
## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
각 꼭지의 purpose에 맞는 블록 계열을 선택하라:
- 문제제기 → callout-warning, quote-big-mark, quote-question
- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
- 결론강조 → banner-gradient (footer)
- 구조시각화 → venn-diagram (단독 배치)
## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라)
{allowed_ids}
## 블록 상세 설명 (위 목록의 when/not_for 참고)
{catalog}
## 출력 형식 (반드시 JSON만. 설명 없이.)
grid는 이미 확정되었으므로 출력하지 마라. blocks 배열만 출력한다.
```json
{{{{
"blocks": [
{{{{
"area": "zone이름",
"type": "블록타입",
"topic_id": 1,
"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화",
"reason": "이유",
"size": "small|medium|large",
"char_guide": {{{{"slot": 글자수}}}}
}}}}
]
}}}}
```"""
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
# STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제.
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
async def _opus_block_recommendation(
@@ -537,16 +455,16 @@ async def _opus_block_recommendation(
block_candidates: str,
preset_name: str,
preset: dict[str, Any],
container_specs: dict | None = None,
) -> dict[str, Any] | None:
"""P2-C: Opus(Kei API)가 블록 후보에서 최종 블록을 추천한다.
"""Phase O: Kei(Opus)가 컨테이너 제약을 보고 블록을 확정한다.
Kei API를 통해 Opus가 사고하여:
- 각 꼭지에 가장 적합한 블록 선정
- 배치 방향/크기 가이드 제시
- 컨테이너 크기(px)에 맞는 블록 선정
- height_cost가 컨테이너보다 큰 블록은 선택 금지
- 도메인 지식 기반 판단
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
fallback: None 반환 → Step B(Sonnet)가 직접 선택.
"""
import httpx
@@ -563,6 +481,20 @@ async def _opus_block_recommendation(
for t in analysis.get("topics", [])
)
# Phase O: 컨테이너 제약 텍스트
container_text = ""
if container_specs:
from src.space_allocator import ContainerSpec
lines = ["## 컨테이너 제약 (반드시 준수)\n각 꼭지는 아래 컨테이너 안에 들어가야 한다. height_cost가 허용 범위를 초과하면 선택 금지.\n"]
for role, spec in container_specs.items():
for tid in spec.topic_ids:
lines.append(
f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
f"허용 height_cost: **{spec.max_height_cost} 이하**, "
f"최대 항목 수: {spec.block_constraints.get('max_items', '?')}"
)
container_text = "\n".join(lines) + "\n\n"
prompt = (
f"슬라이드 디자인 블록 추천을 해줘.\n\n"
f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
@@ -572,12 +504,13 @@ async def _opus_block_recommendation(
f"- reference 꼭지 → sidebar zone\n"
f"- conclusion 꼭지 → **반드시 footer zone** (banner-gradient 권장)\n"
f"- sidebar(35%)에는 시각화 블록 금지\n\n"
f"{container_text}"
f"## 꼭지 목록\n{topics_text}\n\n"
f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
f"## 요청\n"
f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택하고,\n"
f"zone별 높이 예산을 고려하여 배치 방향과 크기 가이드를 제시해.\n\n"
f"컨테이너 높이(px)와 허용 height_cost를 반드시 확인하고,\n"
f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택해.\n\n"
f"## 출력 형식 (JSON만)\n"
f'{{"recommendations": ['
f'{{"topic_id": 1, "block_type": "...", "area": "...", '
@@ -627,6 +560,7 @@ async def _opus_block_recommendation(
async def create_layout_concept(
content: str,
analysis: dict[str, Any],
container_specs: dict | None = None,
) -> dict[str, Any]:
"""2단계: Step A(프리셋) + Step B(블록 매핑).
@@ -641,231 +575,153 @@ async def create_layout_concept(
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS[preset_name]
# Step B: 프리셋 내 블록 매핑 (Sonnet)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# P2-A: FAISS 검색으로 관련 블록만 추출 (fallback: catalog 전문)
# P2-A: FAISS 검색으로 관련 블록만 추출
from src.block_search import search_blocks_for_topics
topics = analysis.get("topics", [])
catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10)
logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)")
# P2-C: Step A-2 — Opus(Kei API)가 블록 추천
# Phase N-1: Step A-2 — Kei(Opus)가 블록 확정. Sonnet은 zone + char_guide만.
opus_recommendation = await _opus_block_recommendation(
analysis, catalog_text, preset_name, preset
analysis, catalog_text, preset_name, preset,
container_specs=container_specs,
)
opus_hint = ""
# Kei 확정 블록 매핑 (topic_id → block_type)
kei_confirmed_blocks: dict[int, str] = {}
kei_confirmed_areas: dict[int, str] = {}
if opus_recommendation and opus_recommendation.get("recommendations"):
recs = opus_recommendation["recommendations"]
hint_lines = ["## Opus(실장) 블록 추천 (참고, 최종 선택은 팀장 판단)"]
for rec in recs:
hint_lines.append(
f"- 꼭지 {rec.get('topic_id', '?')}: "
f"{rec.get('block_type', '?')} ({rec.get('area', '?')}) "
f"{rec.get('reason', '')}"
)
opus_hint = "\n".join(hint_lines)
logger.info(f"[Step A-2] Opus 추천 {len(recs)}개 → Step B에 전달")
# Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 체크
tid = rec.get("topic_id") or rec.get("id")
if tid is not None:
kei_confirmed_blocks[tid] = rec.get("block_type", "")
kei_confirmed_areas[tid] = rec.get("area", "")
logger.info(f"[Step A-2] Kei 블록 확정: {kei_confirmed_blocks}")
else:
logger.info("[Step A-2] Opus 추천 없음 (Kei API 미연결 또는 실패). Step B가 직접 선택.")
# zone 설명 텍스트 (높이 예산 + 너비 포함)
zone_desc = "\n".join(
f"- {name}: {z['desc']} [높이 예산: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
for name, z in preset["zones"].items()
)
# 꼭지 요약
topics_summary = []
for t in analysis.get("topics", []):
role = t.get("role", "flow")
line = (
f"꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
f"[{t.get('layer', '?')}, ROLE:{role}, "
f"강조:{t.get('emphasis', False)}, "
f"관계:{t.get('relation_type', '?')}, "
f"표현:{t.get('expression_hint', '?')}, "
f"원본데이터:{t.get('source_data', '?')}]"
)
if t.get("detail_target"):
line += " → ★detail_target (callout-solution으로 요약 배치 권장)"
topics_summary.append(line)
# 허용 블록 ID 목록 생성 (catalog.yaml에 등록된 블록만)
allowed_ids_list = _get_registered_block_ids()
allowed_ids_str = ", ".join(sorted(allowed_ids_list))
system = STEP_B_PROMPT.format(
preset_name=preset_name,
preset_description=preset["description"],
grid_areas=preset["grid_areas"],
grid_columns=preset["grid_columns"],
grid_rows=preset["grid_rows"],
zone_descriptions=zone_desc,
allowed_ids=allowed_ids_str,
catalog=catalog_text,
)
info_structure = analysis.get("info_structure", "")
# 이미지 크기 정보 (D-2/D-3: Pillow 측정 결과)
image_info = ""
image_sizes = analysis.get("image_sizes", [])
if image_sizes:
image_lines = []
for img in image_sizes:
line = f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
if img.get("has_text"):
line += " (텍스트 포함 도표 — 과도한 축소 금지)"
image_lines.append(line)
image_info = (
"\n\n## 이미지 크기 정보\n"
"가로형(landscape) → 전체 너비 배치 권장. "
"세로형(portrait) → 텍스트 옆 배치 권장. "
"텍스트 포함 도표 → 과도한 축소 금지.\n"
+ "\n".join(image_lines)
)
# Opus 추천이 있으면 user_prompt에 포함
opus_section = ""
if opus_hint:
opus_section = f"\n\n{opus_hint}\n"
user_prompt = (
f"## 실장 분석 결과\n"
f"제목: {analysis.get('title', '')}\n"
f"정보 구조: {info_structure}\n\n"
f"꼭지 목록:\n" + "\n".join(topics_summary) +
image_info +
opus_section +
f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
f"## 요청\n"
f"위 꼭지를 프리셋의 zone에 배정하고 블록 타입을 선택해줘.\n"
f"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
f"JSON만."
)
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=system,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
concept = _parse_json(result_text)
# BF-9: Sonnet 출력에서 blocks만 추출. grid는 프리셋에서 강제.
blocks = None
if concept:
if "blocks" in concept:
# 새 형식: {"blocks": [...]}
blocks = concept["blocks"]
elif "pages" in concept:
# 구 형식 호환: {"pages": [{"blocks": [...]}]}
all_blocks = []
for p in concept["pages"]:
all_blocks.extend(p.get("blocks", []))
blocks = all_blocks
if blocks is not None:
# 블록 ID 검증: catalog에 없는 블록은 거부하고 안전한 대체 블록 사용
registered_ids = _get_registered_block_ids()
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"'{fallback}'으로 교체 (purpose={purpose})"
)
block["type"] = fallback
# area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
valid_zones = {z for z in preset["zones"] if z != "header"}
default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
for block in blocks:
if block.get("area") not in valid_zones:
logger.warning(
f"zone '{block.get('area')}''{default_zone}' 자동 매핑"
)
block["area"] = default_zone
# 6번: conclusion 꼭지 → footer zone 강제
for block in blocks:
topic = next(
(t for t in analysis.get("topics", [])
if t.get("id") == block.get("topic_id")),
None,
)
if topic and topic.get("layer") == "conclusion":
if block.get("area") != "footer":
logger.warning(
f"conclusion 꼭지 {block.get('topic_id')} → footer 강제 이동"
)
block["area"] = "footer"
# 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 "")
# Kei API 필수. 응답 없으면 성공할 때까지 무한 재시도.
import asyncio
RETRY_INTERVAL = 10
attempt = 0
while not opus_recommendation or not opus_recommendation.get("recommendations"):
attempt += 1
logger.warning(f"[Step A-2] Kei API 응답 없음 (시도 {attempt}). {RETRY_INTERVAL}초 후 재시도...")
await asyncio.sleep(RETRY_INTERVAL)
opus_recommendation = await _opus_block_recommendation(
analysis, catalog_text, preset_name, preset
)
result = {
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": preset["grid_areas"],
"grid_columns": preset["grid_columns"],
"grid_rows": preset["grid_rows"],
"blocks": blocks,
}],
}
if overflows:
result["overflow"] = overflows
return result
else:
logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
# 재시도 성공 → 확정 블록 매핑
for rec in opus_recommendation["recommendations"]:
tid = rec.get("topic_id") or rec.get("id")
if tid is not None:
kei_confirmed_blocks[tid] = rec.get("block_type", "")
kei_confirmed_areas[tid] = rec.get("area", "")
logger.info(f"[Step A-2] Kei 블록 확정 (재시도 후): {kei_confirmed_blocks}")
except Exception as e:
logger.error(f"Step B 호출 실패: {e}", exc_info=True)
# Phase O: Kei 확정 블록 + 코드 검증으로 직접 layout_concept 생성
# Step B(Sonnet) 제거됨 — Kei가 블록/zone을 확정, 코드가 스펙 계산
# fallback: 프리셋 기반 기본 배치
# (검증 함수는 아래에 정의)
return _fallback_layout(analysis, preset_name, preset)
def _fallback_layout(
analysis: dict[str, Any],
preset_name: str,
preset: dict[str, Any],
) -> dict[str, Any]:
"""Step B 실패 시 프리셋 기반 기본 배치."""
blocks = []
for topic in analysis.get("topics", []):
registered_ids = _get_registered_block_ids()
valid_zones = {z for z in preset["zones"] if z != "header"}
default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
for topic in topics:
tid = topic.get("id")
role = topic.get("role", "flow")
if role == "reference" and preset_name == "sidebar-right":
area = "sidebar"
elif topic.get("layer") == "conclusion":
area = "footer"
else:
area = "body" if preset_name != "two-column" else "left"
# 블록 타입: Kei 확정값
block_type = kei_confirmed_blocks.get(tid, "topic-left-right")
# conclusion → banner-gradient, 그 외 → topic-left-right
block_type = "banner-gradient" if topic.get("layer") == "conclusion" else "topic-left-right"
# 블록 ID 검증: catalog에 없으면 에러 로그 (fallback 없음)
if block_type not in registered_ids:
logger.error(f"[블록 검증] Kei 확정 블록 '{block_type}'이 catalog에 없음. topic {tid}")
block_type = "topic-left-right" # 최소 안전 블록
# zone 배치: Kei 확정값 → 검증
area = kei_confirmed_areas.get(tid, "")
if not area or area not in valid_zones:
# Kei가 area를 안 줬으면 role에서 결정
if role == "reference" and "sidebar" in valid_zones:
area = "sidebar"
elif topic.get("layer") == "conclusion" and "footer" in valid_zones:
area = "footer"
else:
area = default_zone
# conclusion 꼭지 → footer 강제
if topic.get("layer") == "conclusion" and "footer" in valid_zones:
area = "footer"
# body/sidebar 금지 블록 검증
if area in ("body", "left", "right", "hero", "detail") and block_type in BODY_FORBIDDEN_MAP:
replacement = BODY_FORBIDDEN_MAP[block_type]
if replacement:
logger.warning(f"[블록 검증] body 금지 '{block_type}''{replacement}'")
block_type = replacement
else:
continue # None이면 삭제
if area == "sidebar" and block_type in SIDEBAR_FORBIDDEN_BLOCKS:
replacement = SIDEBAR_FORBIDDEN_BLOCKS[block_type]
if replacement:
logger.warning(f"[블록 검증] sidebar 금지 '{block_type}''{replacement}'")
block_type = replacement
else:
continue
blocks.append({
"area": area,
"type": block_type,
"topic_id": topic.get("id", 0),
"reason": topic.get("title", ""),
"topic_id": tid,
"purpose": topic.get("purpose", ""),
"reason": kei_confirmed_blocks.get(tid, ""),
"size": "medium",
})
return {
# Phase N-2: sidebar에 reference 블록이 있으면 section label 자동 삽입
sidebar_blocks = [b for b in blocks if b.get("area") == "sidebar"]
if sidebar_blocks:
first_sidebar = sidebar_blocks[0]
sidebar_topic = next(
(t for t in topics if t.get("id") == first_sidebar.get("topic_id")),
None,
)
section_title = ""
if sidebar_topic:
section_title = sidebar_topic.get("section_title", "")
if not section_title:
purpose = first_sidebar.get("purpose", "")
section_title = {
"용어정의": "용어 정의",
"근거사례": "참고 자료",
}.get(purpose, "")
if section_title:
first_sidebar_idx = next(
i for i, b in enumerate(blocks) if b.get("area") == "sidebar"
)
blocks.insert(first_sidebar_idx, {
"area": "sidebar",
"type": "divider-text",
"topic_id": None,
"purpose": "_label",
"data": {"text": section_title},
"size": "compact",
"_is_label": True,
})
logger.info(f"[N-2] sidebar 섹션 제목 삽입: '{section_title}'")
# zone별 height_cost 합산 검증
overflows = _validate_height_budget(blocks, preset)
logger.info(
f"[레이아웃] 블록 배치 완료: {preset_name}, {len(blocks)}개 블록"
+ (f", overflow {len(overflows)}" if overflows else "")
)
result = {
"title": analysis.get("title", "슬라이드"),
"pages": [{
"grid_areas": preset["grid_areas"],
@@ -874,6 +730,9 @@ def _fallback_layout(
"blocks": blocks,
}],
}
if overflows:
result["overflow"] = overflows
return result
# height_cost → px 변환 (결정론적)
@@ -884,31 +743,30 @@ 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 헤더로
"section-header-bar": None, # body에서 제거 — header에 이미 slide-title 있음 (J-2)
}
# xlarge/large → medium/compact 교체 후보
DOWNGRADE_MAP = {
"venn-diagram": "card-icon-desc",
"card-step-vertical": "card-numbered",
"image-grid-2x2": "image-row-2col",
"compare-3col-badge": "comparison-2col",
"card-image-3col": "card-icon-desc",
"card-tag-image": "card-icon-desc",
"card-compare-3col": "comparison-2col",
"card-image-round": "card-icon-desc",
# Phase M: 블록-zone 적합성 맵
# sidebar(35% 너비)에서 사용 불가한 블록 → 대체 블록
SIDEBAR_FORBIDDEN_BLOCKS = {
"card-compare-3col": "card-numbered",
"card-dark-overlay": "card-numbered",
"card-icon-desc": "card-numbered",
"card-image-3col": "card-numbered",
"card-image-round": "card-numbered",
"card-stat-number": "card-numbered",
"card-tag-image": "card-numbered",
"comparison-2col": "dark-bullet-list",
"compare-2col-split": "dark-bullet-list",
"compare-pill-pair": "dark-bullet-list",
"section-title-with-bg": None,
"section-header-bar": None,
"topic-center": "topic-left-right",
"quote-big-mark": "quote-question",
"image-full-caption": "image-row-2col",
}
@@ -932,14 +790,58 @@ def _load_catalog_map_for_height() -> dict[str, str]:
return {}
def _load_catalog_purpose_fit() -> dict[str, list[str]]:
"""catalog.yaml에서 id → purpose_fit 매핑을 로드."""
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
if not catalog_path.exists():
return {}
try:
with open(catalog_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return {
b["id"]: b.get("purpose_fit", [])
for b in data.get("blocks", [])
}
except Exception:
return {}
def _validate_purpose_fit(blocks: list[dict]) -> int:
"""각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다.
Returns:
교체된 블록 수.
"""
purpose_fit_map = _load_catalog_purpose_fit()
replaced = 0
for block in blocks:
block_type = block.get("type", "")
purpose = block.get("purpose", "")
if not block_type or not purpose:
continue
allowed_purposes = purpose_fit_map.get(block_type, [])
# purpose_fit이 빈 리스트면 범용 블록 → 검증 스킵
if not allowed_purposes:
continue
if purpose not in allowed_purposes:
# Kei가 확정한 블록이므로 경고만 출력. 강제 교체 안 함.
logger.warning(
f"[purpose_fit 검증] '{block_type}'의 purpose_fit={allowed_purposes}"
f"'{purpose}' 없음 — Kei 확정이므로 유지"
)
return replaced
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"""zone별 height_cost 합산을 검증한다. (I-9 개정)
금지 블록 교체, pill-pair 단독 검증은 수행하되,
높이 초과 시 블록을 자동 교체하지 않는다.
대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용.
Returns:
overflow 정보 리스트. 초과 없으면 빈 리스트.
"""
@@ -954,16 +856,55 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
zone_blocks[area] = []
zone_blocks[area].append(block)
# 금지 블록 교체 (body/sidebar/footer에서 사용 불가한 블록)
# 금지 블록 처리: 교체 또는 삭제 (J-2: None이면 삭제)
blocks_to_remove = []
for block in blocks:
area = block.get("area", "body")
block_type = block.get("type", "")
if area != "header" and block_type in BODY_FORBIDDEN_MAP:
replacement = BODY_FORBIDDEN_MAP[block_type]
logger.warning(
f"[금지 블록 교체] {block_type}{replacement} (area={area})"
)
block["type"] = replacement
if replacement is None:
blocks_to_remove.append(block)
logger.warning(
f"[금지 블록 삭제] {block_type} (area={area})"
)
else:
block["type"] = replacement
logger.warning(
f"[금지 블록 교체] {block_type}{replacement} (area={area})"
)
for block in blocks_to_remove:
blocks.remove(block)
# 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
zone_blocks.clear()
for block in blocks:
area = block.get("area", "body")
if area not in zone_blocks:
zone_blocks[area] = []
zone_blocks[area].append(block)
# Phase M: sidebar 블록-zone 적합성 검증 (P-6)
for block in blocks:
if block.get("area") == "sidebar" and block.get("type") in SIDEBAR_FORBIDDEN_BLOCKS:
replacement = SIDEBAR_FORBIDDEN_BLOCKS[block["type"]]
if replacement is None:
logger.warning(f"[zone 적합성] sidebar에서 {block['type']} 삭제")
else:
logger.warning(f"[zone 적합성] sidebar: {block['type']}{replacement}")
block["type"] = replacement
# sidebar 카드 블록 1열 강제 (J-6)
CARD_BLOCKS = {
"card-tag-image", "card-icon-desc", "card-image-3col",
"card-dark-overlay", "card-compare-3col", "card-image-round",
"card-stat-number",
}
for block in blocks:
if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
if "data" not in block:
block["data"] = {}
block["data"]["column_override"] = 1
# compare-pill-pair 단독 사용 금지 (I-7)
COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
@@ -977,7 +918,7 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
"[pill-pair 단독 금지] compare-pill-pair → comparison-2col"
)
# 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함)
# 높이 예산 검증 — 초과 시 자동 조치 + overflow 정보 수집
overflows: list[dict] = []
for area, area_blocks in zone_blocks.items():
zone_info = zones.get(area, {})
@@ -989,6 +930,29 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
if total <= budget:
continue
overflow_px = total - budget
# footer 초과 자동 조치: banner-gradient의 sub_text 제거로 높이 축소
if area == "footer" and overflow_px <= 30:
for block in area_blocks:
if block.get("type") == "banner-gradient":
if "data" not in block:
block["data"] = {}
block["data"]["_strip_sub_text"] = True
logger.info(
f"[높이 자동 조치] footer 초과 {overflow_px}px → "
f"banner-gradient sub_text 제거"
)
# sub_text 제거 시 compact(50px)로 줄어들므로 재계산
total_after = sum(
50 if (b.get("type") == "banner-gradient" and b.get("data", {}).get("_strip_sub_text"))
else _get_block_height(b.get("type", ""))
for b in area_blocks
)
total_after += gap_px * max(0, len(area_blocks) - 1)
if total_after <= budget:
continue # 조치 후 예산 이내 → overflow 아님
logger.warning(
f"[높이 예산 초과] {area}: {total}px > {budget}px. "
f"블록: {[b.get('type') for b in area_blocks]}"
@@ -1013,42 +977,6 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
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", "")
block_height = _get_block_height(block_type)
if block_type in DOWNGRADE_MAP and block_height >= 250:
replacement = DOWNGRADE_MAP[block_type]
old_height = block_height
new_height = _get_block_height(replacement)
block["type"] = replacement
total = total - old_height + new_height
logger.warning(
f"[DOWNGRADE 비상] {block_type}({old_height}px) → "
f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
)
if total <= budget:
break
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.