포함 내용: - Phase P/Q/R/S 설계 문서 (IMPROVEMENT-PHASE-*.md) - 영역별 검증 스크립트 (scripts/verify_*.py, test_*.py) - 블록 템플릿 추가 (cards, emphasis 변형) - 코드 수정: block_search, content_editor, design_director, slide_measurer - catalog.yaml 블록 목록 업데이트 - CLAUDE.md, PROGRESS.md, README.md 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
14 KiB
Python
326 lines
14 KiB
Python
"""3가지 방향 비교 테스트.
|
|
|
|
기존 run의 step1 결과 + 6차 테스트의 블록 선택/텍스트를 재사용.
|
|
컨테이너 배분만 3가지 방향으로 달리하여 렌더링 비교.
|
|
|
|
방향 1: 컨테이너 고정, 블록을 컨테이너에 맞춤 (폰트 축소 + 간격 압축)
|
|
방향 2: 텍스트 분량 기반 컨테이너 재조정 (비중 ±조정)
|
|
방향 3: Two-Pass (텍스트 먼저 → 컨테이너 재조정)
|
|
|
|
사용법:
|
|
python scripts/test_3directions.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import copy
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
async def main():
|
|
from src.renderer import render_slide
|
|
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
|
|
from src.design_director import select_preset, LAYOUT_PRESETS
|
|
from src.space_allocator import calculate_container_specs
|
|
import base64
|
|
|
|
# 기존 데이터 로딩
|
|
run_dir = ROOT / "data" / "runs" / "1774736083771"
|
|
analysis = json.loads((run_dir / "step1_analysis.json").read_text(encoding="utf-8"))
|
|
concepts = json.loads((run_dir / "step1b_concepts.json").read_text(encoding="utf-8"))
|
|
|
|
# concepts 병합
|
|
concept_map = {c["id"]: c for c in concepts.get("concepts", [])}
|
|
for topic in analysis.get("topics", []):
|
|
tid = topic["id"]
|
|
if tid in concept_map:
|
|
topic["relation_type"] = concept_map[tid].get("relation_type", "none")
|
|
topic["source_data"] = concept_map[tid].get("source_data", "")
|
|
|
|
topics = analysis["topics"]
|
|
page_structure = analysis["page_structure"]
|
|
preset_name = select_preset(analysis)
|
|
preset = LAYOUT_PRESETS[preset_name]
|
|
|
|
out_dir = ROOT / "data" / "runs" / "direction_comparison"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 6차 결과의 텍스트 데이터 (이미 Kei가 채운 것)
|
|
# 실제 원본 수준의 풍부한 텍스트를 직접 구성
|
|
filled_data = {
|
|
1: {
|
|
"type": "dark-bullet-list",
|
|
"area": "body",
|
|
"purpose": "문제제기",
|
|
"data": {
|
|
"title": "용어의 혼용",
|
|
"bullets": [
|
|
"건설산업에서 DX와 BIM이 동일 개념으로 인식되고 있다",
|
|
"DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다",
|
|
"BIM 도입만으로 DX가 완성된 것으로 오인하는 사례가 빈번하다"
|
|
]
|
|
}
|
|
},
|
|
2: {
|
|
"type": "card-numbered",
|
|
"area": "body",
|
|
"purpose": "근거사례",
|
|
"data": {
|
|
"items": [
|
|
{
|
|
"title": "스마트 건설 활성화 방안(2022.07)",
|
|
"description": "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입, BIM 전문인력 양성"
|
|
},
|
|
{
|
|
"title": "제7차 건설기술진흥 기본계획(2023.12)",
|
|
"description": "• 추진방향: 디지털 전환을 통한 스마트 건설 확산\n• 추진과제: BIM 도입으로 건설산업 디지털화"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
3: {
|
|
"type": "keyword-circle-row",
|
|
"area": "body",
|
|
"purpose": "핵심전달",
|
|
"data": {
|
|
"keywords": [
|
|
{"letter": "D", "label": "DX", "description": "BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념"},
|
|
{"letter": "G", "label": "GIS", "description": "지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공"},
|
|
{"letter": "B", "label": "BIM", "description": "시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리"},
|
|
{"letter": "T", "label": "디지털트윈", "description": "현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현하는 기술"}
|
|
]
|
|
}
|
|
},
|
|
4: {
|
|
"type": "card-numbered",
|
|
"area": "sidebar",
|
|
"purpose": "용어정의",
|
|
"data": {
|
|
"items": [
|
|
{
|
|
"title": "건설산업",
|
|
"description": "부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업"
|
|
},
|
|
{
|
|
"title": "BIM",
|
|
"description": "형상정보와 속성정보가 포함된 3D 모델로 건설 정보 기반의 Process와 Product를 제공하는 도구"
|
|
},
|
|
{
|
|
"title": "DX",
|
|
"description": "디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
5: {
|
|
"type": "banner-gradient",
|
|
"area": "footer",
|
|
"purpose": "결론강조",
|
|
"data": {
|
|
"text": "BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다",
|
|
"sub_text": "각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요"
|
|
}
|
|
}
|
|
}
|
|
|
|
# ═══════════════════════════════════════
|
|
# 방향 1: 컨테이너 고정, 블록을 맞춤 (폰트 축소 + 간격 압축)
|
|
# ═══════════════════════════════════════
|
|
print("=== 방향 1: 컨테이너 고정, 블록 축소 ===")
|
|
|
|
container_specs_1 = calculate_container_specs(page_structure, topics, preset)
|
|
blocks_1 = _build_blocks(filled_data, topics)
|
|
layout_1 = _build_layout(analysis, preset, blocks_1, container_specs_1)
|
|
|
|
# body area에 강제 축소 CSS
|
|
layout_1["pages"][0]["area_styles"] = {
|
|
"body": "--font-body: 0.7rem; --spacing-inner: 6px; --spacing-block: 6px; --font-subtitle: 0.9rem;",
|
|
"sidebar": "--font-body: 0.8rem; --spacing-inner: 10px;",
|
|
"footer": "",
|
|
}
|
|
|
|
html_1 = render_slide(layout_1)
|
|
m_1 = await asyncio.to_thread(measure_rendered_heights, html_1)
|
|
s_1 = await asyncio.to_thread(capture_slide_screenshot, html_1)
|
|
_save_result(out_dir, "direction_1", html_1, s_1, m_1)
|
|
_print_measurement(m_1, "방향 1")
|
|
|
|
# ═══════════════════════════════════════
|
|
# 방향 2: 텍스트 분량 기반 컨테이너 재조정
|
|
# ═══════════════════════════════════════
|
|
print("\n=== 방향 2: 컨테이너 재조정 (텍스트 기반) ===")
|
|
|
|
# 배경 비중을 올리고 본심을 줄임
|
|
adjusted_structure = copy.deepcopy(page_structure)
|
|
adjusted_structure["배경"]["weight"] = 0.45 # 0.3 → 0.45
|
|
adjusted_structure["본심"]["weight"] = 0.40 # 0.5 → 0.40
|
|
adjusted_structure["결론"]["weight"] = 0.05 # 0.1 → 0.05
|
|
|
|
container_specs_2 = calculate_container_specs(adjusted_structure, topics, preset)
|
|
blocks_2 = _build_blocks(filled_data, topics)
|
|
layout_2 = _build_layout(analysis, preset, blocks_2, container_specs_2)
|
|
layout_2["pages"][0]["area_styles"] = {
|
|
"body": "--font-body: 0.85rem; --spacing-inner: 10px; --spacing-block: 12px;",
|
|
"sidebar": "--font-body: 0.85rem; --spacing-inner: 10px;",
|
|
"footer": "--font-body: 0.85rem;",
|
|
}
|
|
|
|
html_2 = render_slide(layout_2)
|
|
m_2 = await asyncio.to_thread(measure_rendered_heights, html_2)
|
|
s_2 = await asyncio.to_thread(capture_slide_screenshot, html_2)
|
|
_save_result(out_dir, "direction_2", html_2, s_2, m_2)
|
|
_print_measurement(m_2, "방향 2")
|
|
|
|
# ═══════════════════════════════════════
|
|
# 방향 3: Two-Pass (텍스트 기반 + Kei 비중 보정)
|
|
# ═══════════════════════════════════════
|
|
print("\n=== 방향 3: Two-Pass (Kei 비중 ± 보정) ===")
|
|
|
|
# 1st pass: 원래 비중으로 컨테이너 계산
|
|
container_specs_3_raw = calculate_container_specs(page_structure, topics, preset)
|
|
|
|
# 텍스트 분량 추정 (글자 수 기반)
|
|
topic_char_counts = {}
|
|
for tid, data in filled_data.items():
|
|
chars = len(json.dumps(data["data"], ensure_ascii=False))
|
|
topic_char_counts[tid] = chars
|
|
|
|
# 각 역할의 텍스트 총량
|
|
role_chars = {}
|
|
for role, spec in container_specs_3_raw.items():
|
|
total = sum(topic_char_counts.get(tid, 0) for tid in spec.topic_ids)
|
|
role_chars[role] = total
|
|
|
|
# 2nd pass: 텍스트 비율로 비중 보정 (Kei 비중 ±20% 범위)
|
|
total_chars = sum(role_chars.values()) or 1
|
|
adjusted_structure_3 = copy.deepcopy(page_structure)
|
|
|
|
for role in adjusted_structure_3:
|
|
if not isinstance(adjusted_structure_3[role], dict):
|
|
continue
|
|
original_weight = adjusted_structure_3[role].get("weight", 0.25)
|
|
char_ratio = role_chars.get(role, 0) / total_chars
|
|
|
|
# Kei 비중과 텍스트 비율의 가중 평균 (Kei 60%, 텍스트 40%)
|
|
adjusted_weight = original_weight * 0.6 + char_ratio * 0.4
|
|
|
|
# ±20% 범위 제한
|
|
min_w = original_weight * 0.8
|
|
max_w = original_weight * 1.2
|
|
adjusted_weight = max(min_w, min(max_w, adjusted_weight))
|
|
|
|
adjusted_structure_3[role]["weight"] = round(adjusted_weight, 3)
|
|
|
|
# 비중 합계 정규화
|
|
total_w = sum(
|
|
v["weight"] for v in adjusted_structure_3.values() if isinstance(v, dict) and "weight" in v
|
|
)
|
|
if total_w > 0:
|
|
for role in adjusted_structure_3:
|
|
if isinstance(adjusted_structure_3[role], dict) and "weight" in adjusted_structure_3[role]:
|
|
adjusted_structure_3[role]["weight"] = round(
|
|
adjusted_structure_3[role]["weight"] / total_w, 3
|
|
)
|
|
|
|
container_specs_3 = calculate_container_specs(adjusted_structure_3, topics, preset)
|
|
blocks_3 = _build_blocks(filled_data, topics)
|
|
layout_3 = _build_layout(analysis, preset, blocks_3, container_specs_3)
|
|
layout_3["pages"][0]["area_styles"] = {
|
|
"body": "--font-body: 0.85rem; --spacing-inner: 10px; --spacing-block: 12px;",
|
|
"sidebar": "--font-body: 0.85rem; --spacing-inner: 10px;",
|
|
"footer": "--font-body: 0.85rem;",
|
|
}
|
|
|
|
html_3 = render_slide(layout_3)
|
|
m_3 = await asyncio.to_thread(measure_rendered_heights, html_3)
|
|
s_3 = await asyncio.to_thread(capture_slide_screenshot, html_3)
|
|
_save_result(out_dir, "direction_3", html_3, s_3, m_3)
|
|
_print_measurement(m_3, "방향 3")
|
|
|
|
# 비중 비교 출력
|
|
print("\n=== 비중 비교 ===")
|
|
print(f"{'역할':<6} {'원본':<8} {'방향2':<8} {'방향3':<8}")
|
|
for role in ["본심", "배경", "첨부", "결론"]:
|
|
orig = page_structure.get(role, {}).get("weight", 0)
|
|
d2 = adjusted_structure.get(role, {}).get("weight", 0)
|
|
d3 = adjusted_structure_3.get(role, {}).get("weight", 0)
|
|
print(f"{role:<6} {orig:<8.2f} {d2:<8.2f} {d3:<8.3f}")
|
|
|
|
print(f"\n결과물: {out_dir}")
|
|
print(" direction_1_screenshot.png — 방향 1: 컨테이너 고정, 폰트/간격 축소")
|
|
print(" direction_2_screenshot.png — 방향 2: 컨테이너 재조정 (수동)")
|
|
print(" direction_3_screenshot.png — 방향 3: Two-Pass (자동 보정)")
|
|
|
|
|
|
def _build_blocks(filled_data, topics):
|
|
blocks = []
|
|
# sidebar label
|
|
sidebar_tids = [tid for tid, d in filled_data.items() if d["area"] == "sidebar"]
|
|
if sidebar_tids:
|
|
blocks.append({
|
|
"area": "sidebar", "type": "divider-text",
|
|
"topic_id": None, "purpose": "_label",
|
|
"data": {"text": "용어 정의"}, "size": "compact",
|
|
})
|
|
|
|
role_order = {"배경": [1, 2], "본심": [3], "첨부": [4], "결론": [5]}
|
|
for role, tids in role_order.items():
|
|
for tid in tids:
|
|
if tid in filled_data:
|
|
block = {
|
|
"type": filled_data[tid]["type"],
|
|
"topic_id": tid,
|
|
"area": filled_data[tid]["area"],
|
|
"purpose": filled_data[tid]["purpose"],
|
|
"data": filled_data[tid]["data"],
|
|
}
|
|
blocks.append(block)
|
|
return blocks
|
|
|
|
|
|
def _build_layout(analysis, preset, blocks, container_specs):
|
|
return {
|
|
"title": analysis.get("title", "슬라이드"),
|
|
"_container_specs": container_specs,
|
|
"pages": [{
|
|
"grid_areas": preset["grid_areas"],
|
|
"grid_columns": preset["grid_columns"],
|
|
"grid_rows": preset["grid_rows"],
|
|
"blocks": blocks,
|
|
"area_styles": {},
|
|
}],
|
|
}
|
|
|
|
|
|
def _save_result(out_dir, name, html, screenshot_b64, measurement):
|
|
import base64
|
|
(out_dir / f"{name}.html").write_text(html, encoding="utf-8")
|
|
if screenshot_b64:
|
|
(out_dir / f"{name}_screenshot.png").write_bytes(base64.b64decode(screenshot_b64))
|
|
(out_dir / f"{name}_measurement.json").write_text(
|
|
json.dumps(measurement, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _print_measurement(m, label):
|
|
for name, data in m.get("containers", {}).items():
|
|
status = "✅" if not data.get("overflowed") else f"❌ +{data.get('excess_px', 0)}px"
|
|
print(f" {name}: {data.get('scrollHeight', 0)}px / {data.get('allocatedHeight', 0)}px {status}")
|
|
slide = m.get("slide", {})
|
|
status = "✅" if not slide.get("overflowed") else "❌"
|
|
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px {status}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import logging
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("selenium").setLevel(logging.WARNING)
|
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
asyncio.run(main())
|