"""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 import yaml from jinja2 import Environment, FileSystemLoader 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"], } # ══════════════════════════════════════ # 카탈로그 로딩 (mtime 캐싱) # ══════════════════════════════════════ _catalog_cache: dict[str, Any] = {"data": None, "mtime": 0} def _load_catalog() -> list[dict]: """catalog.yaml 로드 (mtime 캐싱).""" path = TEMPLATES_DIR / "catalog.yaml" mtime = path.stat().st_mtime if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime: return _catalog_cache["data"] data = yaml.safe_load(path.read_text(encoding="utf-8")) blocks = data.get("blocks", []) _catalog_cache["data"] = blocks _catalog_cache["mtime"] = mtime return blocks def _get_block_by_id(block_id: str) -> dict | None: """블록 ID로 카탈로그 엔트리 조회.""" for b in _load_catalog(): if b["id"] == block_id: return b return None # ══════════════════════════════════════ # 블록 선택 (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": [ {"label": "BIM", "color": "#ff6b35"}, {"label": "GIS", "color": "#00d4aa"}, {"label": "DT", "color": "#ffd700"}, ], }, "keyword-circle-row": { "keywords": [ {"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": "자동화"}, {"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"\n" if visual_diff: header += f"\n" header += f"\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"") else: ml = spec.get("max_lines", "?") fs = spec.get("font_size", "?") rc = spec.get("ref_chars", {}).get("body", "?") schema_comments.append( f"" ) 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