Consolidate three duplicated catalog readers and two _get_block_by_id implementations behind a single shared module (src/catalog.py) that owns file-read + mtime cache. All caller signatures and return contracts remain byte-identical. Units: - u1 NEW src/catalog.py (76 lines): load_root_catalog / load_blocks / get_block_by_id / get_catalog_mtime as the sole file-read + mtime-cache owner. - u2 src/block_reference.py: _load_catalog delegates to load_blocks (list[dict] preserved); _get_block_by_id (no-arg) delegates to catalog.get_block_by_id. Module-level _catalog_cache removed. - u3 src/block_selector.py: load_catalog delegates to load_root_catalog (root dict preserved); _get_block_by_id (catalog-injected sig preserved) delegates to catalog.get_block_by_id. Module-level _catalog_cache / _catalog_mtime / CATALOG_PATH removed. - u4 src/renderer.py: _load_catalog_map and _load_catalog_map_with_variants consume catalog.load_blocks; renderer projection caches kept local but keyed via catalog.get_catalog_mtime(). Per-projection invalidation keys (_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME) introduced. import yaml, CATALOG_PATH, legacy _CATALOG_MTIME removed. - tests NEW tests/test_catalog_shared_loader.py (421 lines, 23 cases): shared loader + 3 wrappers covering single file-read, contract preservation, signature preservation, shared cache, private state absence, mtime invalidation propagation to renderer projections. Verification: - pytest tests/test_catalog_shared_loader.py -v: 23/23 PASS in 0.13s. - pytest tests/ -q --ignore=tests/matching: 365/365 PASS in 38.10s. - src/fit_verifier.py, src/space_allocator.py, src/pipeline.py and templates/catalog.yaml unchanged (git diff empty). Out of scope: - catalog.yaml schema/path unchanged. - Catalog direct-read call sites in fit_verifier / space_allocator / pipeline left for a separate follow-up axis. - Phase Z 22-step runtime, frame_selection, light_edit/restructure flows untouched. Refs: IMP-27 (gitea #27), INSIGHT-MAP §5 K5, PHASE-Q-AUDIT §2.10
243 lines
9.8 KiB
Python
243 lines
9.8 KiB
Python
"""Phase Q-2: 제약 기반 블록 선택 엔진.
|
|
|
|
relation_type → 블록 카테고리 결정론적 매핑 + 컨테이너 제약 필터링 + catalog 검증.
|
|
AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
|
|
|
|
주요 함수:
|
|
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
|
|
- load_catalog(): catalog.yaml 로딩 + 캐싱 (IMP-27: src.catalog 공유 로더 위임)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from src import catalog as _catalog_mod
|
|
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# 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 로딩 (IMP-27: src.catalog 공유 로더 위임)
|
|
# ──────────────────────────────────────
|
|
def load_catalog() -> dict:
|
|
"""catalog.yaml root dict (IMP-27: shared loader delegation)."""
|
|
return _catalog_mod.load_root_catalog()
|
|
|
|
|
|
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
|
|
"""catalog-injected 블록 ID 조회 (IMP-27: shared loader delegation)."""
|
|
return _catalog_mod.get_block_by_id(block_id, catalog)
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# 핵심: 블록 후보 선택
|
|
# ──────────────────────────────────────
|
|
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)
|