Add Type B slide pipeline and recipe rendering updates

This commit is contained in:
2026-04-15 16:39:50 +09:00
parent 51548fdc41
commit 66c00924ed
22 changed files with 6260 additions and 1322 deletions

View File

@@ -149,25 +149,128 @@ def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
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]:
"""참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback).
"""참고 블록 선택 (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.yaml의 해당 블록 전체
"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 = [
@@ -462,6 +565,12 @@ def select_and_generate_references(
_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)는 블록 안에 하위 요소로 포함
@@ -477,12 +586,17 @@ def select_and_generate_references(
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"],
@@ -507,50 +621,134 @@ def select_and_generate_references(
f"주={primary_tid}, 종={supporting_tids}"
)
else:
# 동급: 꼭지별 블록 선택
topic_count = len(topic_ids)
available_for_topics = total_height_px - gap_between * max(0, topic_count - 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 // topic_count)
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,
)
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,
})
# 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}/꼭지{tid}: {selection['block_id']} "
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
f"budget={per_topic_height}px)"
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