Files
C.E.L_Slide_test2/src/block_selector.py
kyeongmin 0e4b8c091c Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입
블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성.
생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증.

주요 변경:
- src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출
- src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프
- src/html_validator.py: 보안 검증(script/iframe 제거)
- src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden
- scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장
- 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙

다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:37:05 +09:00

268 lines
10 KiB
Python

"""Phase Q-2: 제약 기반 블록 선택 엔진.
relation_type → 블록 카테고리 결정론적 매핑 + 컨테이너 제약 필터링 + catalog 검증.
AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
주요 함수:
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
- load_catalog(): catalog.yaml 로딩 + 캐싱
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
logger = logging.getLogger(__name__)
CATALOG_PATH = Path("templates/catalog.yaml")
_catalog_cache: dict | None = None
_catalog_mtime: float = 0.0
# ──────────────────────────────────────
# relation_type → 블록 카테고리 매핑 (Napkin.ai 방식)
# ──────────────────────────────────────
RELATION_TO_CATEGORIES: dict[str, list[str]] = {
"hierarchy": ["visuals"],
"inclusion": ["visuals"],
"comparison": ["tables", "emphasis", "visuals"],
"sequence": ["visuals", "cards"],
"cause_effect": ["emphasis"],
"definition": ["cards"],
"none": ["emphasis", "cards"],
}
# sidebar에 배치할 수 없는 카테고리
SIDEBAR_FORBIDDEN_CATEGORIES = {"visuals", "media"}
# 블록이 콘텐츠 형태 변환을 강제하는 경우 (원문 보존도 저하)
# key: block_id, value: 강제하는 형태
# 원문이 서술형인데 이 블록이 선택되면 재작성이 불가피
BLOCKS_FORCING_FORMAT_CHANGE = {
"quote-question", # question 슬롯 필수 → 서술형 원문을 질문으로 변환 강제
}
# zone: full-width-only 블록은 sidebar에 배치 불가
# (catalog.yaml의 zone 필드로도 관리)
# ──────────────────────────────────────
# catalog.yaml 로딩 (mtime 캐시)
# ──────────────────────────────────────
def load_catalog() -> dict:
"""catalog.yaml을 로딩한다. mtime 기반 캐싱."""
global _catalog_cache, _catalog_mtime
if not CATALOG_PATH.exists():
logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}")
return {"blocks": []}
current_mtime = CATALOG_PATH.stat().st_mtime
if _catalog_cache is not None and current_mtime == _catalog_mtime:
return _catalog_cache
with open(CATALOG_PATH, encoding="utf-8") as f:
_catalog_cache = yaml.safe_load(f)
_catalog_mtime = current_mtime
block_count = len(_catalog_cache.get("blocks", []))
logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록")
return _catalog_cache
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
"""catalog에서 블록 ID로 검색."""
for block in catalog.get("blocks", []):
if block.get("id") == block_id:
return block
return None
# ──────────────────────────────────────
# 핵심: 블록 후보 선택
# ──────────────────────────────────────
def select_block_candidates(
topic: dict[str, Any],
container_spec: ContainerSpec,
used_blocks: set[str],
catalog: dict | None = None,
) -> list[dict]:
"""topic + 컨테이너 → 물리적으로 가능한 블록 후보를 결정론적으로 필터링.
AI 호출 없음. 결과는 보통 2-4개.
Args:
topic: {"id", "title", "purpose", "relation_type", ...}
container_spec: 이 topic이 속한 컨테이너
used_blocks: 슬라이드 내 이미 사용된 블록 ID 집합
catalog: catalog.yaml 딕셔너리 (None이면 자동 로딩)
Returns:
[{"id": "venn-diagram", "category": "visuals", "min_height_px": 300, ...}, ...]
"""
if catalog is None:
catalog = load_catalog()
relation_type = topic.get("relation_type", "none")
categories = RELATION_TO_CATEGORIES.get(relation_type, ["emphasis", "cards"])
# topic당 가용 높이
topic_count_in_container = max(1, len(container_spec.topic_ids))
per_topic_px = container_spec.height_px // topic_count_in_container
candidates = []
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
block_category = block.get("category", "")
# ── 필터 1: 카테고리 매칭 ──
if block_category not in categories:
continue
# ── 필터 2: headers 제외 (headers는 슬라이드 제목용) ──
if block_category == "headers":
continue
# ── 필터 3: 최소 생존 크기 (10% tolerance) ──
# 7px 차이로 가장 적합한 블록이 탈락하는 것을 방지
min_height = block.get("min_height_px", 0)
min_height_with_tolerance = min_height * 0.9
if min_height_with_tolerance > per_topic_px:
continue
# ── 필터 4: height_cost 범위 ──
block_cost = block.get("height_cost", "medium")
if HEIGHT_COST_ORDER.get(block_cost, 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
continue
# ── 필터 5: sidebar 제한 ──
if container_spec.zone == "sidebar":
if block_category in SIDEBAR_FORBIDDEN_CATEGORIES:
continue
if block.get("zone") == "full-width-only":
continue
# ── 필터 6: full-width-only 블록은 body/sidebar 나뉜 프리셋에서 body에만 ──
if block.get("zone") == "full-width-only" and container_spec.zone == "sidebar":
continue
# ── 필터 7: 중복 사용 제한 ──
if block_id in used_blocks:
continue
# ── 필터 8 (Phase Q fix): 형태 변환 강제 블록 제외 ──
# 원문 보존이 중요하므로, 콘텐츠 형태를 강제로 바꾸는 블록은 제외
if block_id in BLOCKS_FORCING_FORMAT_CHANGE:
continue
# ── 필터 9: relation_types 명시적 매칭 ──
# relation_types가 명시되어 있고 현재 relation_type이 포함 안 되면 제외
# (deprioritize가 아니라 exclude — 의미 왜곡 방지)
# 예: process-horizontal(sequence)은 hierarchy 콘텐츠에서 제외
block_relations = block.get("relation_types", [])
if block_relations and relation_type not in block_relations:
continue
# Phase R: 블록에 available variants 정보 첨부
variants = block.get("variants", [])
if variants:
block["_available_variants"] = variants
else:
block["_available_variants"] = [{"id": "default", "description": "기본"}]
candidates.append(block)
logger.info(
f"[Q-2] topic {topic.get('id')} (relation={relation_type}, "
f"container={per_topic_px}px): {len(candidates)}개 후보 "
f"[{', '.join(c['id'] for c in candidates[:5])}]"
)
return candidates
# ──────────────────────────────────────
# 폴백: 카테고리 제한 없이 검색
# ──────────────────────────────────────
def select_fallback_candidates(
container_spec: ContainerSpec,
used_blocks: set[str],
catalog: dict | None = None,
) -> list[dict]:
"""relation_type 매핑에서 후보가 없을 때, 물리적 제약만으로 검색.
최소 크기 + height_cost + zone만 검사. 카테고리 무시.
"""
if catalog is None:
catalog = load_catalog()
topic_count = max(1, len(container_spec.topic_ids))
per_topic_px = container_spec.height_px // topic_count
candidates = []
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
if block.get("category") == "headers":
continue
if block.get("min_height_px", 0) > per_topic_px:
continue
if HEIGHT_COST_ORDER.get(block.get("height_cost", "medium"), 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
continue
if container_spec.zone == "sidebar" and block.get("zone") == "full-width-only":
continue
if block_id in used_blocks:
continue
candidates.append(block)
# compact 블록 우선 (작은 컨테이너에 적합)
cost_order = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
candidates.sort(key=lambda c: cost_order.get(c.get("height_cost", "medium"), 1))
logger.info(
f"[Q-2 fallback] container={per_topic_px}px: {len(candidates)}개 후보"
)
return candidates
# ──────────────────────────────────────
# 후보 설명 텍스트 생성 (Kei 프롬프트용)
# ──────────────────────────────────────
def format_candidates_for_prompt(
candidates: list[dict],
budget: dict | None = None,
) -> str:
"""블록 후보 목록을 Kei 프롬프트에 포함할 텍스트로 포맷. Phase R: variant 정보 포함."""
lines = []
for i, c in enumerate(candidates[:5], 1): # 최대 5개
block_id = c.get("id", "")
name = c.get("name", "")
visual = c.get("visual", "")[:80] # 80자로 축약
height_cost = c.get("height_cost", "")
when = c.get("when", "")[:60]
budget_info = ""
if budget and block_id in budget:
b = budget[block_id]
budget_info = f" | 예산: 최대 {b['max_items']}항목, 총 {b['total_chars']}"
# Phase R: variant 정보
variants = c.get("_available_variants", [])
variant_lines = ""
if len(variants) > 1: # default만 있으면 표시 안 함
v_descs = [f" - {v['id']}: {v.get('description', '')}" for v in variants]
variant_lines = "\n 변형:\n" + "\n".join(v_descs)
lines.append(
f" {i}. {block_id} ({name})\n"
f" 시각: {visual}\n"
f" 적합: {when}\n"
f" 크기: {height_cost}{budget_info}{variant_lines}"
)
return "\n".join(lines)