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
746 lines
29 KiB
Python
746 lines
29 KiB
Python
"""Phase T-3: 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
|
|
|
|
Stage 1.7에서 호출. relation_type + expression_hint → 참고 블록 결정론적 선택.
|
|
블록을 "채울 틀"이 아니라 "참고할 디자인"으로 제공.
|
|
|
|
핵심 차이 (Phase P~R vs Phase T):
|
|
P~R: 블록 선택 → 슬롯에 텍스트 채우기 (실패 — 구조 경직)
|
|
T: 블록을 참고 자료로 제공 → AI가 구조를 자유롭게 결정 (유연 + 다양)
|
|
|
|
설계 근거:
|
|
- expression_hint 키워드 포함 매칭 (정확한 문자열 아님 — T-3 조사)
|
|
- LLM이 참고 HTML 구조를 70-90% 복사 (T-3 조사) → "디자인 레퍼런스" 프레이밍
|
|
- Gestalt 원칙: 폐합→벤, 근접→좌우, 연속→화살표 (T-3 조사)
|
|
- PPTAgent(EMNLP 2025): 참고 기반 생성의 효과 학술 입증
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
from src import catalog as _catalog_mod
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 템플릿 디렉토리
|
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
|
|
|
# Jinja2 환경 (블록 HTML 렌더링용)
|
|
_jinja_env = None
|
|
|
|
def _get_jinja_env() -> Environment:
|
|
global _jinja_env
|
|
if _jinja_env is None:
|
|
_jinja_env = Environment(
|
|
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
autoescape=False,
|
|
)
|
|
return _jinja_env
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# expression_hint → 블록 매핑 (키워드 포함 매칭)
|
|
# ══════════════════════════════════════
|
|
|
|
# 시각적 유형별 매칭 키워드 + 대응 블록
|
|
# T-3 조사: 10개 고유 expression_hint → 5개 시각 유형 + 향후 2개
|
|
VISUAL_TYPE_KEYWORDS: dict[str, dict[str, Any]] = {
|
|
"인과": {
|
|
"keywords": ["인과", "현상->결과", "야기", "원인", "문제 상황"],
|
|
"blocks": ["callout-warning", "dark-bullet-list"],
|
|
},
|
|
"나열_병렬": {
|
|
"keywords": ["독립적 나열", "병렬 나열", "개별 증거", "병렬"],
|
|
"blocks": ["dark-bullet-list", "card-icon-desc"],
|
|
},
|
|
"나열_정의": {
|
|
"keywords": ["독립적 정의", "참조용", "용어", "정의 나열"],
|
|
"blocks": ["card-numbered"],
|
|
},
|
|
"포함_계층": {
|
|
"keywords": ["상위-하위", "포함 관계", "계층적", "포함하는", "구성요소"],
|
|
"blocks": ["venn-diagram", "keyword-circle-row"],
|
|
},
|
|
"강조_결론": {
|
|
"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조", "결론적 판단"],
|
|
"blocks": ["banner-gradient", "quote-big-mark"],
|
|
},
|
|
"비교": {
|
|
"keywords": ["대등 비교", "좌우 대조", "vs", "A vs B"],
|
|
"blocks": ["compare-2col-split", "compare-3col-badge", "comparison-2col"],
|
|
},
|
|
"순서": {
|
|
"keywords": ["시간 순서", "단계별", "A->B->C", "프로세스 흐름"],
|
|
"blocks": ["flow-arrow-horizontal", "process-horizontal"],
|
|
},
|
|
}
|
|
|
|
# 카테고리별 fallback 블록 (모든 필터 통과 실패 시)
|
|
CATEGORY_FALLBACK: dict[str, str] = {
|
|
"cards": "card-numbered",
|
|
"emphasis": "dark-bullet-list",
|
|
"visuals": "venn-diagram",
|
|
"tables": "compare-2col-split",
|
|
"media": "image-side-text",
|
|
"headers": "topic-left-right",
|
|
}
|
|
|
|
# relation_type → 1차 필터 블록 카테고리 매핑
|
|
RELATION_CATEGORY_MAP: dict[str, list[str]] = {
|
|
"hierarchy": ["visuals", "emphasis"],
|
|
"inclusion": ["visuals", "emphasis"],
|
|
"comparison": ["tables", "emphasis", "cards"],
|
|
"sequence": ["visuals"],
|
|
"definition": ["cards", "emphasis"],
|
|
"cause_effect": ["emphasis"],
|
|
"none": ["emphasis"],
|
|
}
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# 카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임)
|
|
# ══════════════════════════════════════
|
|
|
|
|
|
def _load_catalog() -> list[dict]:
|
|
"""catalog.yaml blocks list (IMP-27: shared loader delegation)."""
|
|
return _catalog_mod.load_blocks()
|
|
|
|
|
|
def _get_block_by_id(block_id: str) -> dict | None:
|
|
"""블록 ID로 카탈로그 엔트리 조회 (IMP-27: shared loader delegation)."""
|
|
return _catalog_mod.get_block_by_id(block_id)
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# 블록 선택 (2단계 필터)
|
|
# ══════════════════════════════════════
|
|
|
|
def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
|
|
"""expression_hint에서 키워드를 찾아 시각적 유형과 후보 블록 반환.
|
|
|
|
키워드 포함(substring) 매칭 — 정확한 문자열 매칭이 아님.
|
|
T-3 조사: expression_hint는 긴 문장이므로 부분 매칭 필수.
|
|
"""
|
|
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
|
|
if any(kw in expression_hint for kw in spec["keywords"]):
|
|
return vtype, spec["blocks"]
|
|
return "default", []
|
|
|
|
|
|
# 배경 역할에서 제외할 다크 계열 블록
|
|
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
|
|
|
|
|
|
def _match_by_tags(
|
|
catalog: list[dict],
|
|
topic_count: int,
|
|
topic_titles: list[str],
|
|
container_height_px: int,
|
|
zone: str,
|
|
) -> dict | None:
|
|
"""catalog의 tags 필드로 콘텐츠 패턴 매칭.
|
|
|
|
매칭 기준 (AND 조건):
|
|
1. item_count가 topic_count와 일치 (필수)
|
|
2. content_example에 topic 제목 키워드 포함 (가산)
|
|
3. container 크기 적합 (감점)
|
|
|
|
threshold: item_count 매칭(필수) + content_example 매칭 1개 이상
|
|
"""
|
|
if topic_count <= 0:
|
|
return None
|
|
|
|
scored = []
|
|
|
|
for block in catalog:
|
|
tags = block.get("tags", {})
|
|
if not tags:
|
|
continue
|
|
|
|
item_count_matched = False
|
|
content_matched = 0
|
|
|
|
# item_count 매칭
|
|
tag_item_count = tags.get("item_count")
|
|
if tag_item_count:
|
|
try:
|
|
if int(tag_item_count) == topic_count:
|
|
item_count_matched = True
|
|
except (ValueError, TypeError):
|
|
parts = str(tag_item_count).split("-")
|
|
if len(parts) == 2:
|
|
try:
|
|
lo, hi = int(parts[0]), int(parts[1])
|
|
if lo <= topic_count <= hi:
|
|
item_count_matched = True
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if not item_count_matched:
|
|
continue # item_count 안 맞으면 스킵
|
|
|
|
# content_example에 topic 제목 키워드 포함되는지
|
|
score = 50 # item_count 매칭 기본점
|
|
content_example = tags.get("content_example", "").lower()
|
|
if content_example:
|
|
for t in topic_titles:
|
|
key = t.split("(")[0].strip().lower()
|
|
if key and len(key) >= 2 and key in content_example:
|
|
content_matched += 1
|
|
score += 20
|
|
|
|
# source_mdx 매칭
|
|
if tags.get("source_mdx"):
|
|
score += 3
|
|
|
|
# Y-11c: shape 특성 가산점
|
|
# redesign 블록 (특화) > 범용 블록
|
|
if block.get("category") == "redesign":
|
|
score += 5
|
|
# 비교 표가 있는 블록은 비대칭 구조에서 우선
|
|
if tags.get("has_compare_table"):
|
|
score += 5
|
|
|
|
# threshold: item_count 매칭 + content_example 1개 이상 매칭
|
|
if content_matched >= 1:
|
|
scored.append((score, block))
|
|
logger.info(
|
|
f"[T-3 tag] {block['id']}: score={score} "
|
|
f"(content_matched={content_matched}/{len(topic_titles)})"
|
|
)
|
|
|
|
if not scored:
|
|
return None
|
|
|
|
scored.sort(key=lambda x: -x[0])
|
|
return scored[0][1]
|
|
|
|
|
|
def select_reference_block(
|
|
relation_type: str,
|
|
expression_hint: str,
|
|
container_height_px: int,
|
|
zone: str = "body",
|
|
role: str = "",
|
|
topic_count: int = 0,
|
|
topic_titles: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""참고 블록 선택 (tag 매칭 → relation_type → fallback).
|
|
|
|
1순위: catalog tags의 content_pattern/item_count로 정확 매칭
|
|
2순위: relation_type → 카테고리 필터
|
|
3순위: fallback
|
|
|
|
Returns:
|
|
{
|
|
"block_id": str,
|
|
"variant": str,
|
|
"visual_type": str,
|
|
"catalog_entry": dict,
|
|
}
|
|
"""
|
|
catalog = _load_catalog()
|
|
|
|
# ══ 0순위: tag 기반 정확 매칭 ══
|
|
tag_match = _match_by_tags(catalog, topic_count, topic_titles or [], container_height_px, zone)
|
|
if tag_match:
|
|
logger.info(f"[T-3] tag 매칭 성공: {tag_match['id']} (content_pattern={tag_match.get('tags',{}).get('content_pattern','')})")
|
|
variant = "default"
|
|
return {
|
|
"block_id": tag_match["id"],
|
|
"variant": variant,
|
|
"visual_type": "tag_match",
|
|
"catalog_entry": tag_match,
|
|
}
|
|
|
|
# ── 1차 필터: relation_type → 카테고리 ──
|
|
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
|
|
candidates_1 = [
|
|
b for b in catalog
|
|
if b.get("category") in allowed_categories
|
|
]
|
|
|
|
# ── 2차 필터: expression_hint 키워드 매칭 ──
|
|
visual_type, hint_blocks = _match_visual_type(expression_hint)
|
|
if hint_blocks:
|
|
candidates_2 = [b for b in candidates_1 if b["id"] in hint_blocks]
|
|
if not candidates_2:
|
|
candidates_2 = [b for b in catalog if b["id"] in hint_blocks]
|
|
else:
|
|
candidates_2 = candidates_1
|
|
|
|
# ── TP-1: 배경 역할은 다크 블록 제외 ──
|
|
if role == "배경":
|
|
candidates_2 = [b for b in candidates_2 if b["id"] not in DARK_BLOCKS]
|
|
if not candidates_2:
|
|
# 다크 제외 후 후보 없으면 라이트 fallback
|
|
candidates_2 = [b for b in candidates_1 if b["id"] not in DARK_BLOCKS]
|
|
|
|
# ── 3차 필터: 컨테이너 크기 적합성 ──
|
|
candidates_3 = [
|
|
b for b in candidates_2
|
|
if b.get("min_height_px", 0) <= container_height_px
|
|
]
|
|
|
|
# ── sidebar 제약: visuals/media 금지 ──
|
|
if zone == "sidebar":
|
|
candidates_3 = [
|
|
b for b in candidates_3
|
|
if b.get("category") not in ("visuals", "media")
|
|
and b.get("zone") != "full-width-only"
|
|
]
|
|
|
|
# ── 최종 선택 ──
|
|
if candidates_3:
|
|
selected = candidates_3[0]
|
|
elif candidates_2:
|
|
selected = candidates_2[0] # 크기 안 맞아도 최선
|
|
logger.warning(
|
|
f"[T-3] 컨테이너({container_height_px}px)에 맞는 블록 없음. "
|
|
f"최선 선택: {selected['id']} (min_height_px={selected.get('min_height_px')})"
|
|
)
|
|
else:
|
|
# fallback: 카테고리별 기본 블록
|
|
fallback_category = allowed_categories[0] if allowed_categories else "emphasis"
|
|
fallback_id = CATEGORY_FALLBACK.get(fallback_category, "dark-bullet-list")
|
|
selected = _get_block_by_id(fallback_id) or catalog[0]
|
|
visual_type = "fallback"
|
|
logger.warning(f"[T-3] 후보 없음. fallback: {selected['id']}")
|
|
|
|
# variant 선택: compact variant가 있고, 컨테이너가 블록 min_height_px 근처면 compact
|
|
variant = "default"
|
|
variants = selected.get("variants", [])
|
|
block_min_h = selected.get("min_height_px", 0)
|
|
if variants:
|
|
for v in variants:
|
|
# compact: 컨테이너 높이가 블록 min_height의 2배 미만이면 compact 사용
|
|
if v.get("id") == "compact" and container_height_px < block_min_h * 2:
|
|
variant = "compact"
|
|
break
|
|
|
|
return {
|
|
"block_id": selected["id"],
|
|
"variant": variant,
|
|
"visual_type": visual_type,
|
|
"catalog_entry": selected,
|
|
}
|
|
|
|
|
|
# ══════════════════════════════════════
|
|
# 디자인 레퍼런스 HTML 생성
|
|
# ══════════════════════════════════════
|
|
|
|
# 블록별 샘플 데이터 (Jinja2 변수 치환용)
|
|
_SAMPLE_DATA: dict[str, dict[str, Any]] = {
|
|
# emphasis
|
|
"dark-bullet-list": {
|
|
"title": "핵심 요약",
|
|
"bullets": ["첫 번째 포인트", "두 번째 포인트", "세 번째 포인트"],
|
|
},
|
|
"callout-warning": {
|
|
"title": "주의사항",
|
|
"description": "현재 접근 방식에 잠재적 문제가 있습니다.",
|
|
"icon": "⚠️",
|
|
},
|
|
"callout-solution": {
|
|
"title": "해결 방향",
|
|
"description": "체계적 접근이 필요합니다.",
|
|
"icon": "💡",
|
|
},
|
|
"banner-gradient": {
|
|
"text": "핵심 메시지 한 줄",
|
|
"sub_text": "부연 설명",
|
|
},
|
|
"comparison-2col": {
|
|
"left_title": "항목 A",
|
|
"left_content": "A의 특징과 설명",
|
|
"right_title": "항목 B",
|
|
"right_content": "B의 특징과 설명",
|
|
},
|
|
"quote-big-mark": {
|
|
"quote_text": "중요한 인용문 텍스트",
|
|
"source": "출처",
|
|
},
|
|
# cards
|
|
"card-numbered": {
|
|
"items": [
|
|
{"title": "항목 1", "description": "첫 번째 항목 설명"},
|
|
{"title": "항목 2", "description": "두 번째 항목 설명"},
|
|
{"title": "항목 3", "description": "세 번째 항목 설명"},
|
|
],
|
|
},
|
|
"card-icon-desc": {
|
|
"cards": [
|
|
{"icon": "🏗️", "title": "기술 A", "description": "기술 A 설명"},
|
|
{"icon": "🌍", "title": "기술 B", "description": "기술 B 설명"},
|
|
{"icon": "🔮", "title": "기술 C", "description": "기술 C 설명"},
|
|
],
|
|
},
|
|
# visuals
|
|
"venn-diagram": {
|
|
"center_label": "DX",
|
|
"center_sub": "디지털 전환",
|
|
"items": [
|
|
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
|
{"label": "BIM", "color": "#ff6b35"},
|
|
{"label": "GIS", "color": "#00d4aa"},
|
|
{"label": "DT", "color": "#ffd700"},
|
|
],
|
|
},
|
|
"keyword-circle-row": {
|
|
"keywords": [
|
|
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
|
{"letter": "B", "label": "BIM", "description": "건물정보모델링"},
|
|
{"letter": "G", "label": "GIS", "description": "지리정보시스템"},
|
|
{"letter": "D", "label": "DX", "description": "디지털 전환"},
|
|
],
|
|
},
|
|
"flow-arrow-horizontal": {
|
|
"steps": [
|
|
{"label": "분석"},
|
|
{"label": "설계"},
|
|
{"label": "시공"},
|
|
{"label": "관리"},
|
|
],
|
|
},
|
|
"process-horizontal": {
|
|
"steps": [
|
|
{"number": "1", "title": "현황 분석", "description": "현재 상태 진단"},
|
|
{"number": "2", "title": "전략 수립", "description": "로드맵 설계"},
|
|
{"number": "3", "title": "실행", "description": "단계적 도입"},
|
|
],
|
|
},
|
|
# tables
|
|
"compare-2col-split": {
|
|
"left_title": "기존",
|
|
"right_title": "개선",
|
|
"rows": [
|
|
{"left": "수작업", "center": "프로세스", "right": "자동화"},
|
|
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
|
{"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"},
|
|
],
|
|
},
|
|
"compare-3col-badge": {
|
|
"headers": ["구분", "항목 A", "항목 B"],
|
|
"rows": [
|
|
["범위", "넓음", "좁음"],
|
|
["목적", "혁신", "관리"],
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def generate_design_reference(
|
|
block_id: str,
|
|
variant: str = "default",
|
|
catalog_entry: dict | None = None,
|
|
) -> str:
|
|
"""블록의 디자인 레퍼런스 HTML 생성.
|
|
|
|
Jinja2 변수를 샘플 데이터로 치환한 완성 HTML + 구조 의도 주석.
|
|
LLM이 이 구조를 70~90% 복사 → "발명"하지 않고 검증된 구조를 따름.
|
|
"""
|
|
if catalog_entry is None:
|
|
catalog_entry = _get_block_by_id(block_id)
|
|
if catalog_entry is None:
|
|
logger.warning(f"[T-3] 블록 {block_id} 카탈로그에 없음")
|
|
return ""
|
|
|
|
# 템플릿 경로 결정
|
|
template_path = catalog_entry.get("template", "")
|
|
if variant != "default":
|
|
for v in catalog_entry.get("variants", []):
|
|
if v.get("id") == variant and v.get("template"):
|
|
template_path = v["template"]
|
|
break
|
|
|
|
if not template_path:
|
|
logger.warning(f"[T-3] 블록 {block_id} 템플릿 경로 없음")
|
|
return ""
|
|
|
|
# 샘플 데이터로 Jinja2 렌더링
|
|
sample = _SAMPLE_DATA.get(block_id, {})
|
|
|
|
try:
|
|
env = _get_jinja_env()
|
|
template = env.get_template(template_path)
|
|
rendered = template.render(**sample)
|
|
except Exception as e:
|
|
logger.warning(f"[T-3] 블록 {block_id} 렌더링 실패: {e}")
|
|
# 렌더링 실패 시 템플릿 원본 반환 (Jinja 변수 포함)
|
|
try:
|
|
raw = (TEMPLATES_DIR / template_path).read_text(encoding="utf-8")
|
|
rendered = raw
|
|
except Exception:
|
|
return ""
|
|
|
|
# 구조 의도 주석 추가
|
|
visual = catalog_entry.get("visual", "")
|
|
visual_diff = catalog_entry.get("visual_diff", "")
|
|
when = catalog_entry.get("when", "")
|
|
|
|
header = f"<!-- {block_id}: {visual[:80]} -->\n"
|
|
if visual_diff:
|
|
header += f"<!-- 차별점: {visual_diff[:100]} -->\n"
|
|
header += f"<!-- 적합 상황: {when[:80]} -->\n"
|
|
|
|
# schema 정보를 SLOT 주석으로 변환
|
|
schema = catalog_entry.get("schema", {})
|
|
if schema:
|
|
schema_comments = []
|
|
for slot_name, spec in schema.items():
|
|
if slot_name.startswith("max_"):
|
|
body_val = spec.get("body", "")
|
|
schema_comments.append(f"<!-- SLOT: {slot_name} = {body_val} -->")
|
|
else:
|
|
ml = spec.get("max_lines", "?")
|
|
fs = spec.get("font_size", "?")
|
|
rc = spec.get("ref_chars", {}).get("body", "?")
|
|
schema_comments.append(
|
|
f"<!-- SLOT: {slot_name} ({ml}줄, {fs}px, max {rc}자) -->"
|
|
)
|
|
header += "\n".join(schema_comments) + "\n"
|
|
|
|
return header + rendered
|
|
|
|
|
|
def select_and_generate_references(
|
|
topics: list[dict[str, Any]],
|
|
containers: dict[str, Any],
|
|
page_structure: dict[str, Any],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""역할별 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
|
|
|
|
Stage 1.7에서 호출. 각 역할(본심/배경/첨부/결론)에 대해
|
|
relation_type + expression_hint 기반으로 참고 블록을 선택하고
|
|
디자인 레퍼런스 HTML을 생성.
|
|
|
|
Returns:
|
|
{"본심": {"block_id": ..., "design_reference_html": ..., ...}, ...}
|
|
"""
|
|
references: dict[str, list[dict[str, Any]]] = {}
|
|
topic_map = {t.get("id"): t for t in topics}
|
|
|
|
for role, info in page_structure.items():
|
|
if not isinstance(info, dict):
|
|
continue
|
|
topic_ids = info.get("topic_ids", [])
|
|
if not topic_ids:
|
|
continue
|
|
|
|
# 컨테이너 정보
|
|
container = containers.get(role)
|
|
if container is None:
|
|
continue
|
|
if hasattr(container, "height_px"):
|
|
total_height_px = container.height_px
|
|
zone = container.zone
|
|
else:
|
|
total_height_px = container.get("height_px", 0) # 이전 Stage에서 반드시 제공
|
|
zone = container.get("zone", "body")
|
|
|
|
# V-1 + Phase V: 같은 영역 꼭지들의 layer 관계에 따라 블록 구조 결정
|
|
# layer가 다르면 → 주종 관계 → 블록 1개 (주 꼭지 기준, 종속은 하위 요소)
|
|
# layer가 같으면 → 동급 → 블록 N개 병렬
|
|
topic_layers = {tid: topic_map.get(tid, {}).get("layer", "") for tid in topic_ids}
|
|
unique_layers = set(topic_layers.values())
|
|
is_hierarchical = len(unique_layers) > 1 and len(topic_ids) > 1
|
|
|
|
from src.fit_verifier import _load_design_tokens
|
|
_tokens = _load_design_tokens()
|
|
gap_between = _tokens["spacing_small"]
|
|
|
|
# _plus_visual schema는 주종 관계 무시 → recipe executor가 처리
|
|
role_info_for_schema = page_structure.get(role, {})
|
|
role_schema = role_info_for_schema.get("group_schema", "") if isinstance(role_info_for_schema, dict) else ""
|
|
if "_plus_visual" in role_schema:
|
|
is_hierarchical = False # recipe로 보냄
|
|
|
|
if is_hierarchical:
|
|
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
|
|
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
|
|
primary_tid = None
|
|
supporting_tids = []
|
|
# layer 우선순위: core > intro > supporting > conclusion
|
|
layer_priority = {"core": 0, "intro": 1, "conclusion": 2, "supporting": 3}
|
|
sorted_tids = sorted(topic_ids, key=lambda t: layer_priority.get(topic_layers.get(t, ""), 9))
|
|
primary_tid = sorted_tids[0]
|
|
supporting_tids = sorted_tids[1:]
|
|
|
|
primary_topic = topic_map.get(primary_tid, {})
|
|
relation_type = primary_topic.get("relation_type", "none")
|
|
expression_hint = primary_topic.get("expression_hint", "")
|
|
|
|
# tag 매칭용: 이 role에 속한 모든 topic 제목
|
|
all_topic_titles = [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
|
|
|
|
selection = select_reference_block(
|
|
relation_type=relation_type,
|
|
expression_hint=expression_hint,
|
|
container_height_px=total_height_px,
|
|
zone=zone,
|
|
role=role,
|
|
topic_count=len(topic_ids),
|
|
topic_titles=all_topic_titles,
|
|
)
|
|
ref_html = generate_design_reference(
|
|
block_id=selection["block_id"],
|
|
variant=selection["variant"],
|
|
catalog_entry=selection["catalog_entry"],
|
|
)
|
|
schema_info = selection["catalog_entry"].get("schema", {})
|
|
|
|
# 블록 1개에 모든 꼭지 정보를 담음
|
|
role_refs = [{
|
|
"block_id": selection["block_id"],
|
|
"variant": selection["variant"],
|
|
"visual_type": selection["visual_type"],
|
|
"schema_info": schema_info,
|
|
"design_reference_html": ref_html,
|
|
"topic_id": primary_tid,
|
|
"supporting_topic_ids": supporting_tids,
|
|
"is_hierarchical": True,
|
|
}]
|
|
logger.info(
|
|
f"[V-1] {role}: 주종 관계 → 블록 1개 ({selection['block_id']}), "
|
|
f"주={primary_tid}, 종={supporting_tids}"
|
|
)
|
|
else:
|
|
# Phase Y: sub_titles 기반 블록 매칭 (Kei topic 수에 의존 안 함)
|
|
role_refs = [] # 초기화
|
|
role_info = page_structure.get(role, {})
|
|
sub_titles = role_info.get("sub_titles", []) if isinstance(role_info, dict) else []
|
|
slot_count = len(sub_titles) if sub_titles else len(topic_ids)
|
|
slot_titles = sub_titles if sub_titles else [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
|
|
|
|
# _plus_visual schema는 direct block 선택 금지 → recipe executor가 처리
|
|
group_schema = role_info.get("group_schema", "") if isinstance(role_info, dict) else ""
|
|
if "_plus_visual" in group_schema:
|
|
from src.section_parser import get_recipe_for_schema
|
|
recipe = get_recipe_for_schema(group_schema)
|
|
recipe_type = recipe.get("recipe", "") if recipe else ""
|
|
role_refs = [{
|
|
"block_id": "__needs_recipe__",
|
|
"variant": "default",
|
|
"visual_type": "recipe",
|
|
"schema_info": {"recipe": recipe_type, "group_schema": group_schema},
|
|
"design_reference_html": "",
|
|
"topic_id": topic_ids[0],
|
|
"supporting_topic_ids": topic_ids[1:],
|
|
"is_hierarchical": True,
|
|
}]
|
|
logger.info(
|
|
f"[V-1] {role}: _plus_visual → recipe '{recipe_type}' "
|
|
f"(direct block 선택 건너뜀)"
|
|
)
|
|
else:
|
|
# Y-14: tag_match와 schema_match 동등 비교
|
|
zone_tag_match = _match_by_tags(
|
|
_load_catalog(), slot_count, slot_titles,
|
|
total_height_px, zone,
|
|
)
|
|
|
|
# schema_match
|
|
zone_schema_match = None
|
|
if group_schema:
|
|
from src.section_parser import get_candidate_blocks_for_schema
|
|
schema_candidates = get_candidate_blocks_for_schema(group_schema)
|
|
catalog_all = _load_catalog()
|
|
for cand_id in schema_candidates:
|
|
cand = next((b for b in catalog_all if b.get("id") == cand_id), None)
|
|
if cand:
|
|
zone_schema_match = cand
|
|
break
|
|
|
|
best_match = zone_tag_match or zone_schema_match
|
|
if zone_tag_match and zone_schema_match:
|
|
best_match = zone_tag_match
|
|
match_type = "tag_match"
|
|
logger.info(f"[V-1] {role}: tag={zone_tag_match['id']}, schema={zone_schema_match['id']} → tag 우선")
|
|
elif zone_tag_match:
|
|
match_type = "tag_match"
|
|
elif zone_schema_match:
|
|
match_type = "schema_match"
|
|
best_match = zone_schema_match
|
|
else:
|
|
match_type = None
|
|
|
|
if best_match:
|
|
ref_html = generate_design_reference(
|
|
block_id=best_match["id"],
|
|
variant="default",
|
|
catalog_entry=best_match,
|
|
)
|
|
schema_info = best_match.get("schema", {})
|
|
role_refs = [{
|
|
"block_id": best_match["id"],
|
|
"variant": "default",
|
|
"visual_type": match_type or "fallback",
|
|
"schema_info": schema_info,
|
|
"design_reference_html": ref_html,
|
|
"topic_id": topic_ids[0],
|
|
"supporting_topic_ids": topic_ids[1:],
|
|
"is_hierarchical": True,
|
|
}]
|
|
logger.info(
|
|
f"[V-1] {role}: {match_type} → {best_match['id']} "
|
|
f"(topics {topic_ids} → 블록 1개)"
|
|
)
|
|
else:
|
|
# tag도 schema도 없음 → 기존 fallback: 꼭지별 블록 선택
|
|
if not role_refs:
|
|
n_topics = len(topic_ids)
|
|
available_for_topics = total_height_px - gap_between * max(0, n_topics - 1)
|
|
min_block_height = min(
|
|
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
|
|
default=1,
|
|
)
|
|
per_topic_height = max(min_block_height, available_for_topics // max(1, n_topics))
|
|
|
|
role_refs = []
|
|
for tid in topic_ids:
|
|
topic = topic_map.get(tid, {})
|
|
relation_type = topic.get("relation_type", "none")
|
|
expression_hint = topic.get("expression_hint", "")
|
|
|
|
selection = select_reference_block(
|
|
relation_type=relation_type,
|
|
expression_hint=expression_hint,
|
|
container_height_px=per_topic_height,
|
|
zone=zone,
|
|
role=role,
|
|
topic_count=1,
|
|
topic_titles=[topic.get("title", "")],
|
|
)
|
|
ref_html = generate_design_reference(
|
|
block_id=selection["block_id"],
|
|
variant=selection["variant"],
|
|
catalog_entry=selection["catalog_entry"],
|
|
)
|
|
|
|
schema_info = selection["catalog_entry"].get("schema", {})
|
|
|
|
role_refs.append({
|
|
"block_id": selection["block_id"],
|
|
"variant": selection["variant"],
|
|
"visual_type": selection["visual_type"],
|
|
"schema_info": schema_info,
|
|
"design_reference_html": ref_html,
|
|
"topic_id": tid,
|
|
})
|
|
|
|
logger.info(
|
|
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
|
|
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
|
|
f"budget={per_topic_height}px)"
|
|
)
|
|
|
|
references[role] = role_refs
|
|
|
|
return references
|