diff --git a/samples/mdx_batch/02.mdx b/samples/mdx_batch/02.mdx index 997f1a2..7c4e1e6 100644 --- a/samples/mdx_batch/02.mdx +++ b/samples/mdx_batch/02.mdx @@ -33,6 +33,14 @@ import DxEffect from '../../../../components/dx.astro';
### 2.2 DX 시행 주체별 기대효과 +| 구분 | 발주자 | 시공자 | 설계자 | +|------|--------|--------|--------| +| **필요 역량** | 실행 의지와 합리적 판단 역량 | 기술 투자와 운영 역량 | 기술개발 투자에 의한 S/W 역량 | +| **수작업 의존 → S/W 기반 체계화** | - 행정서류 자동 생성 및 최소화로 업무 생산성 향상
- 건설기간 단축, 건설비 및 유지관리비 총비용 최소화 | - 체계적 공정/자원 관리를 통한 신뢰성 확보 및 생산성 향상
- Model에서의 도면 추출로 쉽고 정확한 시공상세도 작성 용이
- 시스템 구축 시, 품질·안전·관리 등에 필요한 도서 작성 용이 | - SW기반 설계프로세스 체계화로 설계 생산성 향상
- 프로젝트 정보의 일관 유지 및 관리를 통한 오류 최소화
- 다양한 성과물과 정보물 활용으로 추가 부가가치 창출 | +| **2D → 3D 기반 인지·검토** | - 3D 모델을 통한 직관적 시각화로 품질 향상 및 안전성 제고
- 건설단계별 수행상태에 대한 쉬운 이해로 관리 편의성 증대 | - 직관적 시각화로 계획시공 등을 관리하여 안전성 제고 및 품질 향상
- 중간태, 완성태 측량을 통한 시·공간적 관리의 편리성 향상 | - 3D 모델을 통한 확인/검증으로 설계 오류 최소화 및 Claim 예방 | +| **문서 중심 → 데이터 통합 기반 협업** | - 현장 실무자와 발주자의 원활한 의사소통으로 오류 최소화
- 디지털 환경 구축을 통한 건설 정보 통합관리 활용성 강화 | - 불필요한 행정서류 감소를 통한 협업 및 의사소통 효율 향상 | - 설계 신뢰도 확보 및 발주자 이익 기여로 상호신뢰 증진 | +| **사후 대응 → 사전 검증 중심 관리** | - 설계변경, 민원, 재작업, 소송 등의 사전 예방, 최소화 | - 설계 및 시공 오류 예방과 원활한 의사 소통으로 공사 Risk 최소화 | - 시공 전 설계검증 강화로 설계 책임 리스크 감소 | +

diff --git a/scripts/assemble_stage2.py b/scripts/assemble_stage2.py index e4c3492..452efa6 100644 --- a/scripts/assemble_stage2.py +++ b/scripts/assemble_stage2.py @@ -44,8 +44,13 @@ def assemble(run_dir: str): popups = ctx.get("normalized", {}).get("popups", []) title = ctx.get("analysis", {}).get("title", "") ratio = ctx.get("container_ratio", [71, 29]) + layout_template = ctx.get("analysis", {}).get("layout_template", "A") - # ── 유틸 ── + # Phase X-B: 유형 B면 별도 함수로 분기 + if layout_template == "B": + return _assemble_type_b(run, ctx) + + # ── 유틸 (유형 A) ── def bold(text, role): """V-10 bold 키워드 적용.""" for kw in bold_kw.get(role, []): @@ -563,3 +568,335 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa if __name__ == "__main__": run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051" assemble(run_dir) + + +# ══════════════════════════════════════ +# Phase X-B: 유형 B 조립 +# ══════════════════════════════════════ +def _assemble_type_b(run: Path, ctx: dict): + """유형 B: 상단(top+이미지) + 하단 2분할 + 결론. + + 기존 유형 A 코드를 건드리지 않는 별도 함수. + """ + import re + from src.fit_verifier import _load_design_tokens + + topics = ctx["topics"] + topic_map = {t["id"]: t for t in topics} + ps = ctx["page_structure"] + if "roles" in ps: + ps = ps["roles"] + containers = ctx["containers"] + fh = ctx.get("font_hierarchy", {}) + enh = ctx.get("enhancement_result", {}) + bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {} + popups = ctx.get("normalized", {}).get("popups", []) + title = ctx.get("analysis", {}).get("title", "") + core_message = ctx.get("analysis", {}).get("core_message", "") + slide_images = ctx.get("slide_images", []) + + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + slide_w = tokens.get("slide_width", 1280) + slide_h = tokens.get("slide_height", 720) + inner_w = slide_w - pad * 2 + + # ── 유틸 ── + def get_text(topic): + if isinstance(topic, dict): + return topic.get("structured_text", "") or topic.get("source_data", "") + return "" + + def bold(text, role): + for kw in bold_kw.get(role, []): + if kw in text: + text = text.replace(kw, f"{kw}") + return text + + def find_popup(title_keyword): + for p in popups: + if title_keyword in p.get("title", ""): + return p + return None + + # ── zone별 역할 분류 ── + top_role = None + bottom_left_role = None + bottom_right_role = None + footer_role = None + + for role_name, info in ps.items(): + if not isinstance(info, dict): + continue + zone = info.get("zone", "") + if zone == "top": + top_role = (role_name, info) + elif zone == "bottom_left": + bottom_left_role = (role_name, info) + elif zone == "bottom_right": + bottom_right_role = (role_name, info) + elif zone == "footer": + footer_role = (role_name, info) + + # ── 좌표 계산 (containers에서 동적으로) ── + # footer + footer_ci = containers.get(footer_role[0], {}) if footer_role else {} + footer_h = footer_ci.get("height_px", 53) if isinstance(footer_ci, dict) else 53 + ft_top = slide_h - pad - footer_h + + # 상단 + top_ci = containers.get(top_role[0], {}) if top_role else {} + top_h = top_ci.get("height_px", 200) if isinstance(top_ci, dict) else 200 + top_w = top_ci.get("width_px", inner_w) if isinstance(top_ci, dict) else inner_w + top_top = pad + header_h + gap_block + + # 이미지 크기 + img_constraints = top_ci.get("block_constraints", {}) if isinstance(top_ci, dict) else {} + img_w = img_constraints.get("img_width_px", 0) + has_image = img_constraints.get("has_image", False) + + # 이미지 높이: 실제 비율로 + img_h = 0 + img_html = "" + if has_image and slide_images: + for img in slide_images: + b64 = img.get("b64", "") + if b64: + img_ratio = img.get("ratio", 1) + img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h + img_html = f'' + break + + # 하단 + bottom_top = top_top + top_h + gap_small + # V'-4: 결론 바로 위까지 채움 + column_bottom = ft_top - gap_block + bottom_h = column_bottom - bottom_top + bottom_col_w = (inner_w - gap_block) // 2 + + # ── 역할별 HTML 조립 ── + font_size = fh.get("core", 12) + + # 상단 (텍스트 + 이미지 나란히) + top_html = "" + if top_role: + rn, info = top_role + tids = info.get("topic_ids", []) + all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) + # 마크다운 bold → HTML + all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text) + + # 팝업 분리 + popup_titles = [] + content_lines = [] + for line in all_text_clean.split("\n"): + stripped = line.strip() + if not stripped: + continue + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped): + continue + content_lines.append(stripped) + + # 팝업 링크 우측상단 + popup_html = "" + if popup_titles: + links = " ".join(f'[{t}→]' for t in popup_titles) + popup_html = f'
{links}
' + + # 소제목(###) + 불릿을 카드형으로 분리 + sections = [] # [(소제목, [불릿들])] + current_section = ("", []) + for line in content_lines: + if line.startswith("### ") or line.startswith("###"): + if current_section[0] or current_section[1]: + sections.append(current_section) + current_section = (line.lstrip("# ").strip(), []) + else: + clean = line.lstrip("• ") + if clean.startswith("출처:"): + continue + current_section[1].append(bold(clean, rn)) + if current_section[0] or current_section[1]: + sections.append(current_section) + + # 카드형 HTML 생성 + bullets = "" + if len(sections) > 1 and sections[0][0]: + # 소제목이 있는 경우 → 카드형 + card_gap = max(3, int(font_size * 0.4)) + for sec_title, sec_items in sections: + items_html = "".join( + f'
{item}
' + for item in sec_items + ) + if sec_title: + bullets += ( + f'
' + f'
{bold(sec_title, rn)}
' + f'
{items_html}
\n' + ) + else: + bullets += items_html + else: + # 소제목 없는 경우 → 일반 불릿 + for sec_title, sec_items in sections: + for item in sec_items: + bullets += f'
{item}
\n' + + # 이미지 캡션: 출처 → [이미지:] 마커 → 없으면 빈 문자열 + img_caption = "" + for line in all_text.split("\n"): + stripped = line.strip().lstrip("• ") + if stripped.startswith("출처:"): + img_caption = re.sub(r'^출처:\s*', '', stripped) + break + if not img_caption: + img_marker = re.search(r'\[이미지:\s*([^\]]+)\]', all_text) + if img_marker: + img_caption = img_marker.group(1) + + caption_html = f'
{img_caption}
' if img_caption else "" + + # 이미지 블록 + img_block = "" + if has_image and img_html: + img_block = ( + f'
' + f'
{img_html}
' + f'{caption_html}
' + ) + + # 제목 + primary_topic = topic_map.get(tids[0], {}) if tids else {} + topic_title = bold(primary_topic.get("title", ""), rn) + + top_html = ( + f'
' + f'{popup_html}' + f'
{topic_title}
' + f'
' + f'
{bullets}
' + f'{img_block}
' + ) + + # 하단 좌측 + bl_html = "" + if bottom_left_role: + rn, info = bottom_left_role + tids = info.get("topic_ids", []) + all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) + all_text = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text) + + primary_topic = topic_map.get(tids[0], {}) if tids else {} + topic_title = bold(primary_topic.get("title", ""), rn) + + bullets = "" + for line in all_text.split("\n"): + stripped = line.strip() + if not stripped or re.search(r'\[팝업:|\[이미지:', stripped): + continue + clean = stripped.lstrip("• ") + clean = bold(clean, rn) + bullets += f'
{clean}
\n' + + bl_html = ( + f'
' + f'
{topic_title}
' + f'
{bullets}
' + ) + + # 하단 우측 + br_html = "" + if bottom_right_role: + rn, info = bottom_right_role + tids = info.get("topic_ids", []) + all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) + all_text = re.sub(r'\*\*(.+?)\*\*', r'\1', all_text) + + primary_topic = topic_map.get(tids[0], {}) if tids else {} + topic_title = bold(primary_topic.get("title", ""), rn) + + # 팝업 분리 + popup_titles_br = [] + content_lines_br = [] + for line in all_text.split("\n"): + stripped = line.strip() + if not stripped: + continue + popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped) + if popup_match: + popup_titles_br.append(popup_match.group(1)) + continue + if re.search(r'\[이미지:', stripped): + continue + content_lines_br.append(stripped) + + popup_html_br = "" + if popup_titles_br: + links = " ".join(f'[{t}→]' for t in popup_titles_br) + popup_html_br = f'
{links}
' + + bullets = "" + for line in content_lines_br: + clean = line.lstrip("• ") + clean = bold(clean, rn) + bullets += f'
{clean}
\n' + + br_html = ( + f'
' + f'{popup_html_br}' + f'
{topic_title}
' + f'
{bullets}
' + ) + + # 결론 + footer_html = "" + if footer_role: + rn, info = footer_role + footer_html = ( + f'
' + f'
{bold(core_message, rn)}
' + ) + + # ── HTML 조립 ── + _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"] + + html = f""" + +
Stage 2: 코드 조립 (유형 B)
+
+ +
{title}
+ +
+{top_html}
+ +
+{bl_html}
+ +
+{br_html}
+ +
+{footer_html}
+ +
""" + + out = run / "steps" / "stage_2_code_assembled.html" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(html, encoding="utf-8") + print(f"저장: {out} ({len(html)} bytes)") diff --git a/src/kei_client.py b/src/kei_client.py index 830a69f..0826a3c 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -57,11 +57,16 @@ KEI_PROMPT = ( "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n" "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n" "page_structure 필드에 기록.\n\n" - "## 원본 텍스트 보존 원칙\n" - "- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n" - "- 원본 텍스트는 최대한 보존. 약간의 편집만.\n" - "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n" - "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시\n\n" + "## 원본 텍스트 보존 원칙 (절대 규칙)\n" + "- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n" + " 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n" + " 임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n" + "- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n" + " 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n" + "- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n" + "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n" + "- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n" + "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n" "## 배치 규칙\n" "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n" "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n" @@ -258,14 +263,20 @@ async def refine_concepts( KEI_STRUCTURED_TEXT_PROMPT = ( "아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n" "각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n" - "## 규칙\n" - "1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n" - "2. 각 문장을 불릿(•)으로 구분하라.\n" - "3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n" - "4. 출처가 있으면 반드시 포함하라 (출처: ...).\n" - "5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n" - "6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n" - "7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n" + "## 절대 규칙\n" + "1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n" + " 원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n" + " → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n" + " ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n" + "2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n" + "3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n" + " 원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n" + "4. 각 문장을 불릿(•)으로 구분하라.\n" + "5. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n" + "6. 출처가 있으면 반드시 포함하라 (출처: ...).\n" + "7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n" + "8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n" + "9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n" "## 출력 형식 (JSON만. 설명 없이.)\n" "```json\n" '{"structured_texts": [' diff --git a/src/pipeline.py b/src/pipeline.py index c5c74a7..5071b57 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -176,6 +176,7 @@ async def generate_slide( core_message=analysis_raw.get("core_message", ""), title=analysis_raw.get("title", ""), total_pages=analysis_raw.get("total_pages", 1), + layout_template=analysis_raw.get("layout_template", "A"), ) # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 @@ -248,6 +249,7 @@ async def generate_slide( [t.model_dump() for t in updated_topics], context.normalized.clean_text, raw_content=context.raw_content, + layout_template=context.analysis.layout_template, ) if validation_errors: return {"_errors": validation_errors} @@ -327,14 +329,27 @@ async def generate_slide( f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}" ) - # 컨테이너 스펙 계산 (기존 space_allocator 활용) - container_specs = calculate_container_specs( - page_structure=context.page_structure.roles, - topics=[t.model_dump() for t in context.topics], - preset=preset, - slide_width=settings.slide_width, - slide_height=settings.slide_height, - ) + # Phase X-B: 유형에 따라 컨테이너 생성 분기 + if context.analysis.layout_template == "B": + from src.space_allocator import build_containers_type_b + container_specs = build_containers_type_b( + page_structure=context.page_structure.roles, + slide_width=settings.slide_width, + slide_height=settings.slide_height, + image_sizes=image_sizes if isinstance(image_sizes, list) else ( + [{**v, "key": k} for k, v in image_sizes.items()] if image_sizes else None + ), + ) + logger.info(f"[X-B] 유형 B 컨테이너 생성") + else: + # 유형 A: 기존 코드 그대로 + container_specs = calculate_container_specs( + page_structure=context.page_structure.roles, + topics=[t.model_dump() for t in context.topics], + preset=preset, + slide_width=settings.slide_width, + slide_height=settings.slide_height, + ) # ContainerSpec → ContainerInfo 변환 containers = {} diff --git a/src/pipeline_context.py b/src/pipeline_context.py index f6c244d..0e5e6c6 100644 --- a/src/pipeline_context.py +++ b/src/pipeline_context.py @@ -63,6 +63,7 @@ class Analysis(BaseModel): core_message: str = "" title: str = "" total_pages: int = 1 + layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B) image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict) # topics와 page_structure는 PipelineContext 최상위에 위치 diff --git a/src/space_allocator.py b/src/space_allocator.py index b086784..98f2368 100644 --- a/src/space_allocator.py +++ b/src/space_allocator.py @@ -439,6 +439,143 @@ def calculate_container_specs( return specs +# ══════════════════════════════════════ +# Phase X-B: 유형 B 컨테이너 생성 +# ══════════════════════════════════════ +def build_containers_type_b( + page_structure: dict[str, Any], + slide_width: int = 1280, + slide_height: int = 720, + image_sizes: list[dict] | None = None, +) -> dict[str, ContainerSpec]: + """유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer). + + 기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수. + 모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음. + + Args: + page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...} + slide_width: 슬라이드 너비 + slide_height: 슬라이드 높이 + image_sizes: 이미지 정보 (비율 계산용) + """ + from src.fit_verifier import _load_design_tokens + tokens = _load_design_tokens() + pad = tokens["spacing_page"] + header_h = tokens.get("header_height", 66) + gap_block = tokens["spacing_block"] + gap_small = tokens["spacing_small"] + inner_w = slide_width - pad * 2 + + # 역할을 zone별로 분류 + top_roles = [] # zone=top + bottom_roles = [] # zone=bottom_left, bottom_right + footer_role = None # zone=footer + + for role_name, info in page_structure.items(): + if not isinstance(info, dict): + continue + zone = info.get("zone", "") + if zone == "top": + top_roles.append((role_name, info)) + elif zone in ("bottom_left", "bottom_right"): + bottom_roles.append((role_name, info)) + elif zone == "footer": + footer_role = (role_name, info) + + # 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap + total_available = slide_height - pad * 2 - header_h - gap_block + + # footer 높이: weight 비율 (최소 보장) + footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1 + footer_h_raw = int(total_available * footer_weight) + _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad) + footer_h = max(_footer_min, footer_h_raw) + + # 중간 영역: footer + gap 제외 + middle_h = total_available - footer_h - gap_block + + # 상단/하단 높이: weight 비율로 + top_weight = sum(info.get("weight", 0) for _, info in top_roles) + bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles) + total_mid_weight = top_weight + bottom_weight + if total_mid_weight <= 0: + total_mid_weight = 1 + + top_h = int(middle_h * top_weight / total_mid_weight) + bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이 + + # 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할 + img_ratio = 0 + if image_sizes: + for img in image_sizes: + r = img.get("ratio", 0) + if r > 0: + img_ratio = r + break + + if img_ratio > 0: + # 이미지 높이 = top_h, 이미지 폭 = top_h * ratio + img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45% + text_w = inner_w - img_w - gap_block + else: + text_w = inner_w + img_w = 0 + + specs = {} + + # 상단 역할 + for role_name, info in top_roles: + specs[role_name] = ContainerSpec( + role=role_name, + zone="top", + topic_ids=info.get("topic_ids", []), + weight=info.get("weight", 0), + height_px=top_h, + width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만 + max_height_cost=_max_allowed_height_cost(top_h), + block_constraints={ + "img_width_px": img_w, + "img_height_px": top_h if img_w > 0 else 0, + "has_image": img_w > 0, + }, + ) + + # 하단 역할: 2분할 + bottom_col_w = (inner_w - gap_block) // 2 + for role_name, info in bottom_roles: + specs[role_name] = ContainerSpec( + role=role_name, + zone=info.get("zone", "bottom_left"), + topic_ids=info.get("topic_ids", []), + weight=info.get("weight", 0), + height_px=bottom_h, + width_px=bottom_col_w, + max_height_cost=_max_allowed_height_cost(bottom_h), + block_constraints={}, + ) + + # 결론 + if footer_role: + rn, info = footer_role + specs[rn] = ContainerSpec( + role=rn, + zone="footer", + topic_ids=info.get("topic_ids", []), + weight=info.get("weight", 0), + height_px=footer_h, + width_px=inner_w, + max_height_cost="low", + block_constraints={}, + ) + + logger.info( + f"[X-B-3] 유형 B 컨테이너: " + + ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items()) + ) + return specs + + def _max_allowed_height_cost(container_height_px: int) -> str: """컨테이너 높이에서 허용되는 최대 height_cost. diff --git a/src/validators.py b/src/validators.py index f3fa40d..8312493 100644 --- a/src/validators.py +++ b/src/validators.py @@ -291,6 +291,7 @@ def validate_stage_1b( topics: list[dict[str, Any]], clean_text: str, raw_content: str = "", + layout_template: str = "A", ) -> list[dict]: """Stage 1B(컨셉 구체화) 결과 검증. @@ -384,15 +385,18 @@ def validate_stage_1b( claimed_count = evidence.get(relation_type, 0) if claimed_count == 0: - # 주장한 관계의 증거가 0개 - alternatives = [(k, v) for k, v in evidence.items() if v >= 2] - alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3]) - errors.append({ - "severity": "RETRYABLE", - "field": f"topics[{tid}].relation_type", - "localization": f"topic {tid}: '{relation_type}' 증거 0개", - "evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음", - "instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.", - }) + if layout_template == "B": + # 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유) + logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning") + else: + alternatives = [(k, v) for k, v in evidence.items() if v >= 2] + alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3]) + errors.append({ + "severity": "RETRYABLE", + "field": f"topics[{tid}].relation_type", + "localization": f"topic {tid}: '{relation_type}' 증거 0개", + "evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음", + "instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.", + }) return errors