Add Type B slide pipeline and recipe rendering updates
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user