Phase P~S 전체 작업물: 검증 스크립트, 블록 템플릿, 설계 문서, 코드 수정

포함 내용:
- 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>
This commit is contained in:
2026-03-31 08:38:06 +09:00
parent 0e4b8c091c
commit 29f56187c0
44 changed files with 9431 additions and 313 deletions

325
scripts/test_3directions.py Normal file
View File

@@ -0,0 +1,325 @@
"""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())