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

93
ACTION_PLAN.md Normal file
View File

@@ -0,0 +1,93 @@
# Design Agent 개선 사항 (Action Plan)
> 2026-03-28 갱신: Phase Q 설계 확정에 따른 재정리
## 상황 요약
- **Phase P 실행 완료**: 결과 20/100점. 다후보 렌더링 비교 방식의 구조적 한계 확인.
- **Phase Q 설계 확정**: 업계 조사 기반 재설계 — 제약 기반 블록 선택 + 글자수 예산 시스템.
- **기존 P0-P2 버그**: Phase Q에서 구조적으로 해결되는 항목과 여전히 유효한 항목 분류.
---
## 기존 버그 → Phase Q 영향 분석
### 🟡 P0: 무한 재시도 루프 — 여전히 유효
**Phase Q와 무관.** `_retry_kei()`는 Phase Q에서도 사용됨.
- **수정 필요:** MAX_RETRY_ATTEMPTS = 30, MAX_RETRY_DURATION = 300 추가
- **시점:** Phase Q 구현 시 함께 적용
- **파일:** `src/pipeline.py`
### ✅ P1: fill_candidates 실패 감지 — Phase Q에서 자동 해결
**Phase Q에서 `fill_candidates()` 자체가 제거됨.**
- Phase P의 3후보 텍스트 편집 → Phase Q의 단일 블록 텍스트 편집으로 변경
- `fill_candidates()` 대신 `fill_content()`에 예산 제약 추가
- **별도 수정 불필요** — Phase Q-5 (pipeline.py 재구성)에서 해결
### 🟡 P2: template 미발견 예외 처리 — 여전히 유효
**Phase Q와 무관.** 템플릿 로딩은 여전히 필요.
- **수정 필요:** `_resolve_template_path()` 명확한 에러 반환
- **추가 효과:** Phase Q의 catalog 검증(Q-2)이 사전에 잘못된 블록을 걸러내므로, 이 에러 발생 빈도 대폭 감소
- **시점:** Phase Q 구현 시 함께 적용
- **파일:** `src/renderer.py`
### ✅ P3: Phase L 비효율성 — Phase Q에서 자동 해결
**Phase Q에서 Phase L이 "피드백 루프 3회" → "검증 렌더링 1회"로 축소.**
- 글자수 예산 사전 계산으로 overflow 사전 방지
- 렌더링은 검증 목적 1회만
- overflow 발생 시 수학적 조정(LaTeX 글루 모델)으로 AI 없이 해결
- **별도 수정 불필요** — Phase Q-5, Q-7에서 해결
---
## Phase Q 실행 계획
**상세:** [IMPROVEMENT-PHASE-Q.md](IMPROVEMENT-PHASE-Q.md)
### 실행 순서
```
Q-1 (catalog 보강) ──┬──→ Q-2 (블록 필터) ──┐
└──→ Q-3 (예산 계산) ──┼──→ Q-4 (Kei 선택) ──→ Q-5 (파이프라인) ──→ Q-6 (품질 게이트) ──→ Q-8 (출력 차단)
Q-7 (글루 모델) ←──────────────────────────┘ (독립, 병렬 가능)
P0 수정 (재시도 제한) ←── Q-5에서 함께 적용
P2 수정 (템플릿 에러) ←── Q-5에서 함께 적용
```
### 기대 효과
| 지표 | 현재 (Phase P) | Phase Q 목표 |
|------|---------------|-------------|
| 슬라이드 품질 | 20/100 | 70-80/100 |
| 처리 시간 | ~40분 | ~8-12분 |
| API 호출 | ~25회 | ~8회 |
| Selenium 호출 | ~17회 | ~2회 |
| 유령 블록 | 5건 발생 | 불가능 (catalog 검증) |
| overflow 출력 | 허용 | 차단 (품질 게이트) |
| 블록 다양성 | 3/38 사용 | relation_type 기반 자동 분산 |
---
## Phase R': 접근 C — 블록 CSS 참고 + AI 구조 결정 (다음 단계)
**상세:** [IMPROVEMENT-PHASE-R-PRIME.md](IMPROVEMENT-PHASE-R-PRIME.md)
Phase R(variant 패치)은 실패 — P=Q=R 동일 구조 반복.
**Phase R'은 근본 구조를 변경:**
- 2-3단계(블록 선택 + 슬롯 채우기) **제거**
- AI가 블록 CSS를 참고하여 HTML 구조를 **직접 생성**
- 콘텐츠가 구조를 결정 (블록이 결정하는 것이 아님)
합격 기준: C_reference.png 수준 자동 생성.
회귀 방지: block_selector, fill_candidates, fill_content 호출 금지.
### Phase R' 이후 (참고)
- 디자인 참조 DB 구축 → 성공한 슬라이드를 few-shot으로 축적
- Playwright 마이그레이션 → 더 빠른 측정 + PDF 내보내기

137
CLAUDE.md
View File

@@ -12,27 +12,38 @@
--- ---
## 아키텍처 (5단계 파이프라인) ## 아키텍처 (Phase Q 파이프라인)
> Phase P(다후보 렌더링 비교) 실행 결과 20/100점. 업계 조사(Beautiful.ai, Napkin.ai, VASCAR 등) 기반으로 Phase Q에서 재설계.
> 핵심 전환: "계산 먼저, AI 판단 나중에, 렌더링은 검증만"
``` ```
[1단계] Kei 실장 (Sonnet) — AI 사고 [1단계] Kei 실장 (Opus) — AI 사고
꼭지 추출 → 정보 구조 파악 → 레이어/강조/배치/role 판단 꼭지 추출 → 정보 구조 파악 → 비중(page_structure) → relation_type
[2단계] 디자인 팀장 — 2-Step [1.5단계] Kei 실장 (Opus) — 컨셉 구체화
Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요) relation_type, expression_hint, source_data
- 실장의 role 분석을 보고 프리셋 자동 결정
Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
- 선택된 프리셋의 CSS가 프롬프트에 포함됨
- flow → body/main, reference → sidebar
[3단계] Kei 텍스트 편집자 (Sonnet) — AI 사고 [컨테이너 계산] 코드 — 결정론적
글자 수 가이드 참고하되 내용 의미 우선. 도메인 용어 보존하며 편집 Kei 비중 → 역할별 컨테이너 px 확정
[4단계] 디자인 실무자 (Sonnet + Jinja2 + CSS) — AI + 코드 [2단계] 블록 선택 (Phase Q) — 코드(결정론적) + Kei 1회
편집자가 정리한 텍스트에 맞게 디자인 조정 + HTML 조립 ① relation_type → 블록 카테고리 매핑 (코드, Napkin.ai 방식)
② 컨테이너 제약 필터링: min_height_px, height_cost, sidebar 제한 (코드)
③ catalog 존재 검증: 유령 블록 차단 (코드)
④ 글자수 예산 계산: 컨테이너 px → 최대 글자수/항목수 (코드)
⑤ Kei에게 필터된 2-3개 후보 제시 → 1개 선택 (AI 1회)
[5단계] 디자인 팀장 (Sonnet) — AI 사고 [3단계] Kei 편집자 (Opus) — AI 사고
전체 균형 재검토 → 공간 재배분 → 2차 조정 지시 글자수 예산 = 하드 제약. 예산 안에서 의미 우선 편집. 원본 보존.
[4단계] 디자인 실무자 (Sonnet + Jinja2) — CSS 조정 + HTML 조립
[검증] Selenium 1회 렌더링 — overflow 확인 (코드)
overflow 시: ① 간격 축소(LaTeX 글루) ② 폰트 축소(이진탐색) ③ 텍스트 압축(AI 1회)
[품질 게이트] Opus 멀티모달 (VASCAR식)
스크린샷 기반 시각 품질 평가 → 미달 시 교정 또는 출력 차단
``` ```
### 레이아웃 프리셋 (2단계 Step A) ### 레이아웃 프리셋 (2단계 Step A)
@@ -54,15 +65,26 @@ reference 꼭지 있음 → sidebar-right
나머지 → single-column 나머지 → single-column
``` ```
### 역할 분리 ### 역할 분리 (Phase R')
| 역할 | 담당 | 방식 | 하는 일 | 하지 않는 일 | | 역할 | 담당 | 방식 | 하는 일 | 하지 않는 일 |
|------|------|------|---------|------------| |------|------|------|---------|------------|
| Kei 실장 | Sonnet | AI | 꼭지 추출, 정보 구조 파악, 레이어/강조/배치/role 판단 | 디자인, 텍스트 편집 | | Kei 실장 | Opus (Kei API) | AI | 꼭지 추출, 비중 판단, relation_type + expression_hint 부여, 최종 검수 | HTML 생성, 레이아웃 계산 |
| 디자인 팀장 Step A | 코드 | 규칙 | 실장의 role에 따라 레이아웃 프리셋 자동 선택 | AI 판단 불필요 | | 컨테이너 계산 | 코드 | 결정론적 | Kei 비중 → 역할별 컨테이너 px 확정 | AI 판단 불필요 |
| 디자인 팀장 Step B | Sonnet | AI | 프리셋 안에서 블록 매핑, 글자 수 가이드, zone 배정 | 레이아웃 구조 결정 (이미 정해짐) | | 프리셋 선택 | 코드 | 규칙 | 실장의 role에 따라 프리셋 자동 선택 | AI 판단 불필요 |
| 텍스트 편집자 | Sonnet | AI | 도메인 용어 보존하며 편집, 출처 보존, 표 편집 | 레이아웃 결정 | | **HTML 생성** | **AI (Kei API)** | **AI** | **콘텐츠 전달 의도에 맞는 HTML 구조를 직접 생성. 블록 CSS를 참고하되 구조는 AI가 결정.** | 블록 "선택" 안 함. 슬롯 "채우기" 안 함. |
| 디자인 실무자 | Sonnet + 코드 | AI + 코드 | 텍스트에 맞게 디자인 조정, HTML/CSS 조립 | 콘텐츠 의미 판단 | | 검증 | 코드 + AI | Selenium + 비전 모델 | overflow 측정, 시각 품질 평가 | |
### HTML 생성 원칙 (Phase R')
```
블록이 구조를 결정 (P=Q=R, 실패) → 콘텐츠가 구조를 결정 (R', 접근 C)
```
- AI가 콘텐츠 전달 의도(expression_hint)에 맞는 HTML 구조를 직접 생성
- 기존 38개 블록의 CSS(색상, 폰트, 배경, radius)를 **스타일 참고**로 활용
- 블록을 **"선택"하지 않음**. topic 합침/분리, 포함 관계, 핵심 메시지 분리 가능
- 디자인 토큰(CSS 변수)으로 품질 제약 + Selenium 측정 + 비전 모델 검증
--- ---
@@ -99,23 +121,21 @@ reference 꼭지 있음 → sidebar-right
상세 콘텐츠 판단: 상세 콘텐츠 판단:
- 너무 구체적/세부적인 내용은 "자세히보기" 대상 - 너무 구체적/세부적인 내용은 "자세히보기" 대상
[2단계] 디자인 팀장 — Step A + Step B [2단계] 블록 선택 (Phase Q — 코드 결정론적 + Kei 1회)
Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요) Step A: 프리셋 선택 (규칙 기반, LLM 불필요)
- 실장의 role 분석을 보고 자동 선택: - 실장의 role 분석을 보고 자동 선택
reference 있음 → sidebar-right
대등 비교 → two-column
고강조 1개 → hero-detail
나머지 → single-column
- 선택된 프리셋의 CSS grid가 Step B 프롬프트에 포함됨
Step B: 프리셋 안에서 블록 매핑 (Sonnet) Step B: 제약 기반 블록 선택 (코드 결정론적 + Kei AI 1회)
- 선택된 프리셋의 zone(body/sidebar/footer)에 꼭지를 배정 ① relation_type → 블록 카테고리 매핑 (코드)
- flow 꼭지 → body/main zone hierarchy → visuals, comparison → tables/emphasis, definition → cards 등
- reference 꼭지 → sidebar zone ② 컨테이너 제약 필터링 (코드)
- detail_target 꼭지 → popup 연결 min_height_px, height_cost, sidebar 시각 블록 제한, 중복 블록 제한
- catalog에서 각 꼭지에 적합한 블록 타입 선택 catalog.yaml 존재 검증 (코드) — 유령 블록 차단
- 각 블록의 대략적 글자 가이드 글자수 예산 계산 (코드)
컨테이너 px → 최대 글자수/항목수 → AI 편집 시 하드 제약
⑤ Kei에게 필터된 2-3개 후보 제시 → 1개 선택 (AI 1회)
- AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙)
이미지 배치: 이미지 배치:
- 원본 이미지 크기 확인 (Pillow Image.open().size) - 원본 이미지 크기 확인 (Pillow Image.open().size)
@@ -129,13 +149,13 @@ reference 꼭지 있음 → sidebar-right
자세히보기: 자세히보기:
- detail_target 꼭지는 <details>/<summary>로 popup 연결 - detail_target 꼭지는 <details>/<summary>로 popup 연결
[3단계] Kei 텍스트 편집자 — 텍스트 정리 [3단계] Kei 텍스트 편집자 — 텍스트 정리 (글자수 예산 포함)
- 팀장의 글자 가이드 참고하되, 내용 의미가 우선 - 글자수 예산 = 하드 제약 (컨테이너 px에서 수학적으로 도출)
- 예산 안에서 내용 의미 우선 편집
- 전체 컨텍스트와 핵심 용어 유지 - 전체 컨텍스트와 핵심 용어 유지
- 도메인 전문가로서 세련된 표현으로 편집 - 도메인 전문가로서 세련된 표현으로 편집
- 출처 보존, 개조식 작성, 날조 금지 - 출처 보존, 개조식 작성, 날조 금지
- 결과 글자 수가 가이드와 다를 수 있음 (의미 > 글자 수)
- 표 내용도 편집 (핵심 행/열 선택, 요약 등) - 표 내용도 편집 (핵심 행/열 선택, 요약 등)
- 자세히보기 대상은 요약 버전 + 상세 버전 둘 다 작성 - 자세히보기 대상은 요약 버전 + 상세 버전 둘 다 작성
@@ -162,27 +182,32 @@ reference 꼭지 있음 → sidebar-right
- Jinja2 템플릿 렌더링 - Jinja2 템플릿 렌더링
- 다중 페이지 시 page-break 처리 - 다중 페이지 시 page-break 처리
[5단계] 디자인 팀장 — 전체 재검토 [검증] Selenium 1회 렌더링 (코드)
- overflow 확인 (글자수 예산으로 대부분 사전 방지됨)
균형 점검: - overflow 시 수학적 조정:
- 1차 조립 결과의 전체 균형 확인 ① 간격 축소 (LaTeX 글루 모델, AI 없음)
- 블록별 채움 비율 (텍스트 양 vs 공간) ② 폰트 크기 축소 (이진 탐색, AI 없음)
- 블록 간 균형 (한쪽만 빽빽하고 다른 쪽 비어있지 않은지) ③ 텍스트 압축 (최후 수단, AI 1회)
이미지/표 점검: [품질 게이트] Opus 멀티모달 (VASCAR식)
- 이미지 크기가 적절한지 (너무 작아서 안 보이지 않는지) - 스크린샷 기반 시각 품질 평가:
- 표가 읽을 수 있는 크기인지 ① 콘텐츠 겹침/잘림 없는가?
② 본심 영역이 시각적으로 가장 두드러지는가?
조정: ③ 폰트가 읽을 수 있는 크기인가?
- 필요 시 공간 재배분 → 실무자에게 2차 조정 지시 ④ 블록 유형에 다양성이 있는가?
- 좌우 불균형, 어색한 빈 공간 해소 ⑤ 한국어 비즈니스 프레젠테이션으로서 적절한가?
- 최종 HTML 출력 - 미달 시: 구체적 문제 교정 → 재렌더링 (최대 2회)
- 미달 지속 시: 출력 차단 (깨진 슬라이드를 사용자에게 전달하지 않음)
미리보기 → HTML 다운로드 미리보기 → HTML 다운로드
``` ```
**핵심 원칙:** **핵심 원칙 (Phase Q):**
- 디자인 팀장은 레이아웃 + 공간 배분. 텍스트를 건드리지 않는다. - **계산 먼저, AI 판단 나중에, 렌더링은 검증만** — 업계 조사 기반 핵심 원칙
- **블록 = 시각 패턴(구조), 크기가 아님** — 같은 블록이 컨테이너에 따라 항목수/폰트/패딩 변동
- **AI에게 불가능한 선택지를 주지 않는다** — 결정론적 필터링 후 Kei에게 제시
- **글자수 예산은 하드 제약** — 컨테이너 px에서 수학적으로 도출, 편집 전에 전달
- **overflow 상태에서 출력 금지** — 비전 모델 품질 게이트 통과 필수
- 텍스트 편집자가 정리한 텍스트가 기준. 디자인이 텍스트에 맞춘다. - 텍스트 편집자가 정리한 텍스트가 기준. 디자인이 텍스트에 맞춘다.
- 실무자는 텍스트를 자르지 않고, 디자인을 조정한다. - 실무자는 텍스트를 자르지 않고, 디자인을 조정한다.
- 이미지는 원본 그대로 사용, 크기만 조절. - 이미지는 원본 그대로 사용, 크기만 조절.

File diff suppressed because it is too large Load Diff

View File

@@ -1,176 +1,155 @@
# Phase P: 블록 재구성 + 실제 렌더링 비교 선택 # Phase P: 블록 재구성 + 실제 렌더링 비교 선택
> 작성일: 2026-03-27 > 작성일: 2026-03-27
> 상태: 계획 수립 중 (사용자 승인 대기) > 상태: 계획 확정 (사용자 승인 완료, 실행 대기)
> 선행 완료: Phase O (컨테이너 기반 레이아웃) > 선행 완료: Phase O (컨테이너 기반 레이아웃)
--- ---
## 핵심 원칙 ## 핵심 원칙
**"블록을 컨테이너에 맞게 재구성하고, 실제 렌더링해보고, Kei가 목적에 맞는 것을 고른다."** **"블록을 컨테이너에 맞게 재구성하고, 실제 렌더링해보고, Kei가 스크린샷을 보고 목적에 맞는 것을 고른다."**
``` ```
컨테이너 58px 확정 (Phase O) 컨테이너 px 확정 (Phase O)
후보 3개 선택 (FAISS 2개 + Opus 1개) 후보 3개 선택 (FAISS 2개 + Opus 1개)
3개 블록을 각각 58px에 맞게 재구성 3개 블록을 컨테이너 크기에 맞게 재구성 (폰트/패딩/항목수/레이아웃 — 동적 계산)
(폰트, 패딩, 항목 수, 레이아웃 변형)
재구성된 3개를 실제 렌더링 (Selenium) Kei가 3개 각각에 맞게 텍스트 편집
Kei가 "당초 목적에 가장 적합한 것은?" 선택 → 최종 1개 3개 실제 렌더링 (Selenium) + 스크린샷 캡처 (.png)
Kei가 스크린샷을 보고 "당초 목적에 가장 적합한 것" 선택
전부 안 맞으면 정확도 가장 높은 것으로 배치
``` ```
--- ---
## 이것이 해결하는 문제 ## 해결하는 문제 (14건)
| 문제 | 해결되나 | | # | 문제 | 해결 방법 |
|------|-----------| |---|------|---------|
| P-1: 키워드 반응으로 잘못된 블록 선택 | 3개를 실제 렌더링해서 Kei가 목적 기준으로 최종 선택. 키워드 반응으로 골라도 목적에 안 맞으면 탈락 | | P-1 | Kei가 표면 키워드 반응하여 블록 선택 | 후보 3개 렌더링 Kei가 스크린샷 보고 목적 기준으로 최종 선택 |
| P-4: 블록이 콘텐츠 의미 왜곡 | compare-pill-pair에 "혼용 문제"를 넣어봤더니 의미가 안 맞으면 Kei가 탈락시킴 | | P-2 | purpose_fit 위반 블록 통과 | Kei가 스크린샷으로 판단하므로 부적합 블록 탈락 |
| P-5/6/7: height_cost 부정확 | catalog의 height_cost를 믿지 않음. 실제 렌더링으로 확인 | | P-3 | 같은 블록 반복 사용 | 같은 컨테이너 topic을 함께 보여주고 "서로 다른 블록 선택" 명시 |
| P-8: 58px에 콘텐츠 전달 불가 | 블록을 58px에 맞게 재구성하므로 어떤 블록이든 넣을 수 있음 | | P-4 | 블록이 콘텐츠 의미 왜곡 | 왜곡된 결과를 Kei가 스크린샷으로 보고 탈락 |
| P-14: 피드백 루프 무력 | 처음부터 맞는 걸 고르니까 피드백 필요성 감소 | | P-5 | compare-pill-pair height_cost 부정확 | catalog를 믿지 않음. 실제 렌더링으로 확인. 추가로 Selenium 실측 스크립트로 전체 검증 |
| P-6 | banner-gradient height_cost 부정확 | P-5와 동일 |
| P-7 | 38개 전체 height_cost 미검증 | Selenium 실측 검증 스크립트 작성 → catalog 갱신 |
| P-8 | 배경 58px에 콘텐츠 전달 불가 | ① 블록을 58px에 맞게 재구성 ② Kei가 비중 판단 시 topic 수 고려 ③ 또는 Kei가 topic을 합침 |
| P-9 | sidebar 490px에 247px만 사용 | P-10 해결 시 공간 활용도 상승 |
| P-10 | 용어 정의 빈약 (출처 누락) | EDITOR_PROMPT 하드코딩 제거 완료. 컨테이너 제약에 맞게 Kei가 편집 |
| P-11 | EDITOR_PROMPT 하드코딩 분량 | **해결 완료** |
| P-12 | 블록 추천 프롬프트가 의미/논리 구조 미전달 | 후보 3개 렌더링 + Kei 스크린샷 판단으로 대체. 프롬프트 정확도에 의존하지 않음 |
| P-13 | 스크린샷 .txt로 저장 | base64 디코딩하여 .png 파일로 저장 |
| P-14 | 피드백 루프에 블록 교체 기능 없음 | 처음부터 3개 렌더링해서 맞는 걸 고르므로 피드백 부담 감소 |
--- ---
## 단계별 상세 ## 실행 파이프라인
### P-Step 1: 후보 선택
각 topic에 대해 후보 3개를 뽑는다.
**FAISS 2개:**
- 기존 `search_blocks_for_topics()`로 검색
- topic의 purpose, relation_type, expression_hint를 쿼리에 포함 (이미 Phase M에서 구현)
- 상위 2개 선택
**Opus 1개:**
- 기존 `_opus_block_recommendation()`에서 Kei가 추천한 블록
- 도메인 지식 + 콘텐츠 성격 기반
**중복 제거:** FAISS 결과에 Opus 추천이 이미 포함되어 있으면 FAISS 3번째를 올림. 항상 서로 다른 3개.
### P-Step 2: 블록 재구성
각 후보 블록을 해당 topic의 **컨테이너 크기에 맞게 재구성**한다.
**재구성 항목:**
- 폰트 크기: 컨테이너 높이에 따라 조정 (Phase O `_determine_typography()` 사용)
- 패딩: 컨테이너 높이에 따라 조정
- 항목 수: 컨테이너에 들어가는 만큼만 (Phase O `_calculate_block_constraints()` 사용)
- 글자 수: 항목당 최대 글자 수 계산
- 레이아웃: 세로 → 가로 전환 등 (컨테이너 가로/세로 비율에 따라)
**이 단계에서 텍스트도 채운다:**
- 각 후보 블록의 슬롯에 맞게 Kei 편집자가 텍스트를 채움
- 3개 블록 × 같은 원본 텍스트 → 3개 다른 편집 결과
- 또는: 1회 편집 후 3개 블록 슬롯에 재배치 (API 호출 절약)
### P-Step 3: 실제 렌더링
재구성된 3개 블록을 각각 Selenium으로 렌더링한다.
**측정 항목:**
- 실제 높이(scrollHeight) vs 컨테이너 높이
- overflow 여부
- 렌더링 결과 스크린샷 (base64 PNG)
**렌더링 방법:**
- `render_standalone_block(block_type, data)`로 각 블록 단독 렌더링
- 컨테이너 크기를 감싼 div 안에서 렌더링
- `slide_measurer.py`의 기존 Selenium 인프라 재사용
### P-Step 4: Kei 최종 선택
렌더링된 3개 결과를 Kei(Opus)에게 보여주고 최종 선택.
**Kei에게 전달하는 정보:**
- 이 topic의 원래 목적 (purpose, relation_type, expression_hint)
- 3개 렌더링 결과 (스크린샷 이미지 또는 HTML 요약)
- 각 블록의 overflow 여부
- 원본 콘텐츠
**Kei 판단 기준:**
1. 당초 목적에 적합한가? (문제 제기인데 비교 블록이면 부적합)
2. 콘텐츠 의미가 왜곡되지 않는가?
3. 컨테이너에 맞게 렌더링되었는가?
**Kei API 호출:** 1회. 3개를 한꺼번에 보여주고 1개 선택.
---
## 파이프라인 흐름 변경
``` ```
현재: Step 1: Kei 분석 (기존 그대로)
1A → 1B → 컨테이너 → A-2(Kei 1개 확정) → 블록 스펙 → 3(편집) → 4(렌더링) → 측정 → 5(검수) 1A: classify_content() → topics, page_structure(비중)
1B: refine_concepts() → relation_type, expression_hint
컨테이너 계산: calculate_container_specs() → 역할별 px 확정
Phase P 후: Step 2: 후보 선택 (Kei API 1회)
1A → 1B → 컨테이너 FAISS가 topic당 상위 2개 자동 검색
→ 각 topic마다: + Opus에게 전체 topic을 한꺼번에 보여주고 각각 1개 추천
후보 3개 (FAISS 2 + Opus 1) = topic당 후보 3개 (중복 시 FAISS 3번째로 대체)
→ 3개 재구성 (컨테이너에 맞게)
→ 3개 렌더링 (Selenium) Step 3: 블록 재구성 + 텍스트 편집 (Kei API 5회)
→ Kei 최종 선택 (목적 적합성) 각 topic마다:
→ 선택된 블록으로 전체 슬라이드 조립 3개 후보를 컨테이너 크기에 맞게 재구성
→ 4(CSS 조정 + 최종 렌더링) (폰트/패딩/항목수/레이아웃 — Phase O 동적 계산)
→ 측정 Kei 편집자에게 "3개 블록 슬롯 각각에 맞게 텍스트 편집" 1회 호출
→ 5(검수)
Step 4: 실제 렌더링 (Selenium 15회, 병렬)
15개 후보 블록을 각각 컨테이너 안에서 렌더링
스크린샷 캡처 (base64 → .png 파일 저장)
Step 5: Kei 최종 선택 (Kei API 3회)
같은 컨테이너의 topic을 묶어서 제시:
1회차: 배경 (topic 1+2) — 스크린샷 6개, "서로 다른 블록 선택" 명시
2회차: 본심 (topic 3) + 첨부 (topic 4) — 스크린샷 6개
3회차: 결론 (topic 5) — 스크린샷 3개
Kei가 각 topic별 "당초 목적에 가장 적합한 것" 선택
전부 안 맞으면 정확도 가장 높은 것으로 배치
Step 6: 전체 슬라이드 조립 (기존 그대로)
선택된 블록으로 4단계 (CSS 조정 + 렌더링)
Phase L 측정
5단계 Kei 검수
``` ```
--- ---
## 기존 코드 영향 ## 비용
| 파일 | 변경 | | 항목 | 횟수 | 시간 |
|------|------| |------|------|------|
| `pipeline.py` | Step A-2 단일 선택 → topic별 3후보 + 렌더링 + Kei 선택 루프로 변경 | | Kei API (기존 1A+1B+3+5) | 4회 | ~4분 |
| `design_director.py` | `_opus_block_recommendation()` — 기존 유지. 추가로 단일 topic 추천 함수 필요 | | Step 2: Opus 추천 배치 | 1회 | ~30초 |
| `block_search.py` | 기존 유지. topic별 상위 2개 추출 함수 추가 | | Step 3: 텍스트 편집 배치 | 5회 | ~2.5분 |
| `renderer.py` | `render_standalone_block()` — 기존 유지. 컨테이너 감싼 렌더링 함수 추가 | | Step 5: 최종 선택 | 3회 | ~1.5분 |
| `slide_measurer.py` | 기존 유지. 단일 블록 높이 측정 함수 추가 | | Step 4: Selenium 렌더링 | 15회 (병렬) | ~0.8초 |
| `space_allocator.py` | `finalize_block_specs()` — 기존 유지. 후보별 스펙 계산에 재사용 | | Step 6: CSS + 검수 | 2회 | ~2분 |
| `content_editor.py` | 기존 유지. 후보별 텍스트 채우기에 재사용 | | **총합** | **~15회** | **~10.5분** |
| `kei_client.py` | 3후보 비교 선택 프롬프트 함수 신규 |
--- ---
## 하드코딩 검증 ## 하드코딩 없음 검증
| 항목 | 하드코딩? | 근거 | | 항목 | 하드코딩? | 근거 |
|------|---------|------| |------|---------|------|
| 후보 수 3개 (FAISS 2 + Opus 1) | 구조적 설계 | 블록 추가해도 변경 불필요. 3개는 비교에 적절한 수 | | 후보 수 3개 (FAISS 2 + Opus 1) | 구조적 설계 | 블록 추가해도 변경 불필요 |
| 블록 재구성 (폰트/패딩/항목수) | 동적 계산 | Phase O `_determine_typography()`, `_calculate_block_constraints()` 사용 | | 블록 재구성 | 동적 계산 | Phase O `_determine_typography()`, `_calculate_block_constraints()` |
| 텍스트 분량 | 동적 계산 | 컨테이너 제약에서 자동 산출 |
| 최종 선택 | Kei 판단 | 코드가 선택하지 않음 | | 최종 선택 | Kei 판단 | 코드가 선택하지 않음 |
| 컨테이너 크기 | Kei 비중에서 동적 계산 | Phase O `calculate_container_specs()` | | 컨테이너 크기 | 동적 계산 | Kei 비중에서 자동 산출 |
| 최종 선택 묶음 (2+2+1) | 동적 그룹핑 | 컨테이너 역할별 자동 |
| 배경 topic 수 처리 | Kei 판단 | Kei가 비중 판단 시 topic 수 고려. 코드가 비중 덮어쓰지 않음 |
--- ---
## 비용 분석 ## 기존 코드 변경 범위
| 항목 | 현재 | Phase P 후 | | 파일 | 변경 | 신규/수정 |
|------|------|-----------| |------|------|---------|
| Kei API 호출 | 1A + 1B + A-2 + 3(편집) + 5(검수) = 5회 | + topic별 최종 선택 1회 × 5topics = +5회. 총 ~10회 | | `pipeline.py` | Step A-2 단일 선택 → Step 2~5 루프로 교체 | 수정 |
| Selenium 렌더링 | 1회 (전체 슬라이드) | + topic별 3후보 × 5topics = +15회. 단독 블록이라 빠름 (~50ms/회) | | `block_search.py` | topic별 상위 2개 반환 함수 | 추가 |
| Sonnet 호출 | 4단계 CSS 1회 | 변경 없음 | | `design_director.py` | 전체 topic 배치 Opus 추천 함수 | 추가 |
| 총 시간 증가 | — | Selenium +750ms, Kei API +5회 (각 ~30초) ≈ +2.5분 | | `renderer.py` | 컨테이너 감싼 단독 블록 렌더링 함수 | 추가 |
| `slide_measurer.py` | 단독 블록 스크린샷 캡처 함수 + .png 저장 | 추가 |
| `kei_client.py` | 3후보 스크린샷 비교 선택 프롬프트 함수 | 추가 |
| `content_editor.py` | 3블록 한꺼번에 편집 프롬프트 | 수정 |
| `space_allocator.py` | 기존 그대로 (재사용) | 변경 없음 |
| `catalog.yaml` | 기존 그대로 | 변경 없음 |
--- ---
## 미해결 사항 (사용자 논의 필요) ## 중간 산출물 추가
1. **3개 후보에 텍스트를 어떻게 채우나?** | 파일 | 내용 |
- 방법 A: Kei 편집자를 3회 호출 (각 블록 슬롯에 맞게) — 정확하지만 느림 |------|------|
- 방법 B: 1회 편집 후 3개 블록 슬롯에 기계적 재배치 — 빠르지만 슬롯 구조가 다르면 부정확 | `step2_candidates.json` | topic별 3개 (FAISS 2 + Opus 1) |
- 방법 C: Kei 편집자 1회 호출에 "3개 블록 각각의 슬롯에 맞게" 한꺼번에 요청 — 중간 | `step3_edited_variants.json` | topic별 3개 블록의 편집된 텍스트 |
| `step4_candidate_screenshots/` | 15개 .png 스크린샷 |
| `step5_selection.json` | Kei 최종 선택 결과 (topic별 선택 블록 + 이유) |
2. **Kei 최종 선택에 스크린샷을 보여줄까, HTML 요약을 보여줄까?** ---
- 스크린샷: Opus 멀티모달로 실제 렌더링을 보고 판단 — 정확
- HTML 요약: 텍스트 기반 — 빠르지만 시각 판단 불가
3. **3개 후보가 전부 목적에 안 맞으면?** ## 충돌/회귀 검증
- Kei가 "없음"을 선택할 수 있게 할까? 그러면 추가 후보를 어디서?
| 체크 항목 | 결과 |
|----------|------|
| 기존 함수 호환 | ✅ render_standalone_block, search_blocks, measure_rendered_heights, capture_slide_screenshot 전부 존재 |
| Phase O 컨테이너 시스템 | ✅ 그대로 사용. calculate_container_specs, finalize_block_specs 재사용 |
| Phase N fallback 제거 | ✅ 회귀 없음. fallback 코드 재도입 안 함 |
| Phase N 무한 재시도 | ✅ 유지. 모든 Kei API 호출에 적용 |
| 하드코딩 | ✅ 없음. 모든 수치가 동적 계산 또는 Kei 판단 |
| EDITOR_PROMPT | ✅ 하드코딩 분량 이미 제거됨 |

189
IMPROVEMENT-PHASE-Q-FIX.md Normal file
View File

@@ -0,0 +1,189 @@
# Phase Q 수정 계획: 정확한 문제 분석 + 정확한 해법
> 작성일: 2026-03-30
> 상태: 분석 완료, 수정 대기
> 근거: Phase Q 5차 테스트 결과 + Phase P/이전 run 비교 분석
---
## 1. 문제의 정확한 진단
### Phase Q에서 바꿔야 했던 것 vs 실제로 바꾼 것
| 구분 | 바꿔야 했던 것 | 실제로 바꾼 것 | 결과 |
|------|-------------|-------------|------|
| 블록 선택 | FAISS+Opus 환각 → 제약 기반 | ✅ 제대로 바꿈 | 블록 선택 개선 |
| 글자수 예산 | 없음 → 사전 계산 | ✅ 제대로 바꿈 | overflow 감소 |
| 텍스트 채우기 | **바꾸면 안 됐음** | ❌ fill_candidates → fill_content로 교체 | **텍스트 품질 파괴** |
| overflow 조정 | 피드백 루프 → 수학적 조정 | ✅ 글루 모델 추가 | 작동 |
| 품질 게이트 | 없음 → 비전 모델 | ✅ 추가 | 작동 |
**핵심 오류: 텍스트 채우기 방식을 바꿔서는 안 됐다.**
### Phase P의 텍스트 채우기 (잘 작동함)
```
fill_candidates(): topic 1개 + 후보 블록 3개 → Kei API 1회 호출
Kei가 topic의 source_data를 보고 블록 슬롯에 맞게 풍부하게 채움
결과: 604자 (DX vs BIM 상세 비교), 사례 2건, 출처 포함
```
**Phase P `step3_edited_variants.json` 실제 결과:**
- topic 2 (사례): 2건 모두 포함, 불릿 상세 (스마트건설방안 + 제7차 기본계획)
- topic 3 (핵심): DX vs BIM 8개 항목 비교, 604자
- topic 4 (용어): 3개 용어 풀 정의 + 출처 (국토교통부, 2020 / IBM, 2011)
- topic 5 (결론): 원문 그대로 ("BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서...")
### Phase Q의 텍스트 채우기 (파괴됨)
```
fill_content(): 전체 블록 5-6개를 한 번에 → Kei API 1회 호출
Kei가 한 번에 5-6개 블록을 처리하느라 각 블록을 축약
결과: topic당 30-50자 수준으로 과축약
```
**Phase Q `step3_fill_content.json` 실제 결과:**
- topic 2 (사례): 1건만 (제7차 기본계획 누락)
- topic 3 (핵심): "상위개념" 한 단어 수준 (604자 → ~20자)
- topic 4 (용어): 수식어 삭제, 출처 없음
- topic 5 (결론): 원문 보존 (이건 OK)
### 왜 이렇게 됐나
`fill_content()`는 원래 Phase O 이전부터 있던 함수로, **전체 슬라이드의 모든 블록을 한 번에 처리**한다.
한 번의 API 호출에 블록 5-6개의 슬롯 정보를 모두 담으니, 각 블록에 할당되는 응답 분량이 자연스럽게 줄어든다.
반면 `fill_candidates()`는 **topic 1개씩 개별 호출**이므로, Kei가 해당 topic에 집중하여 풍부한 텍스트를 생성한다.
**이건 프롬프트 문제가 아니라 호출 구조 문제.**
---
## 2. 정확한 해법
### 원칙
```
Phase Q가 개선한 것: 블록 선택 (FAISS → 제약 기반) ← 유지
Phase P에서 가져올 것: 텍스트 채우기 (topic별 개별 호출) ← 복원
합치면: 제약 기반 블록 선택 + topic별 풍부한 텍스트 채우기
```
### 수정 대상: pipeline.py의 Step 3
**현재 (Phase Q — 잘못된 방식):**
```python
# 전체 블록을 한 번에 fill_content() 호출
layout_concept = await fill_content(content, layout_concept, analysis)
```
**수정 (Phase P 방식 복원 + Phase Q 블록 선택 유지):**
```python
# topic별로 개별 호출 — Phase P의 fill_candidates() 방식
for topic in topics:
tid = topic.get("id")
block = selected_blocks.get(tid)
if not block:
continue
# Phase Q에서 선택된 단일 블록을 리스트로 감싸서 fill_candidates 호출
await fill_candidates(content, topic, [block], analysis)
```
### 변경 파일 + 범위
| 파일 | 변경 | 범위 |
|------|------|------|
| `src/pipeline.py` | Step 3에서 `fill_content()` → topic별 `fill_candidates()` 호출로 교체 | ~15줄 교체 |
| `src/content_editor.py` | `fill_candidates()`에 Phase Q 글자수 예산(`_char_budget`) 전달 추가 | ~5줄 추가 |
| `src/content_editor.py` | EDITOR_PROMPT 변경 **롤백** — Phase P 원본으로 복원 | 프롬프트 복원 |
### 건드리지 않는 것
| 파일 | 이유 |
|------|------|
| `src/block_selector.py` | Phase Q 블록 선택 — 잘 작동하고 있음 |
| `src/space_allocator.py` | 예산 계산 + 글루 모델 — 잘 작동하고 있음 |
| `src/kei_client.py` | Q-4 블록 선택 + Q-6 품질 게이트 — 잘 작동하고 있음 |
| `templates/catalog.yaml` | Phase Q 메타데이터 — 잘 작동하고 있음 |
| `personas/` | Kei persona — 절대 수정 금지 |
---
## 3. 구체적 수정 내용
### 3-A: pipeline.py Step 3 교체
```python
# 현재 (삭제 대상)
layout_concept = await fill_content(content, layout_concept, analysis)
# 수정 (Phase P 방식 복원)
from src.content_editor import fill_candidates
yield {"event": "progress", "data": "3/5 Kei 편집자가 텍스트를 정리 중..."}
for topic in topics:
tid = topic.get("id")
block = selected_blocks.get(tid)
if not block:
continue
# fill_candidates는 topic 1개 + 블록 리스트를 받으므로 [block]으로 감쌈
await fill_candidates(content, topic, [block], analysis)
logger.info(
f"[Q Step 3] topic {tid}: {block['type']}"
f"data={'있음' if block.get('data') else '없음'}"
)
```
### 3-B: fill_candidates()에 Phase Q 예산 전달
`fill_candidates()`의 컨테이너 제약 전달 부분에 `_char_budget`도 포함:
```python
# fill_candidates() 내부 — 이미 _container_height_px 전달하는 부분에 추가
char_budget = block.get("_char_budget", {})
if char_budget:
section += (
f"\n ★ 글자수 예산 (하드 제약):"
f"\n 총 글자: {char_budget.get('total_chars', '제한 없음')}"
f"\n 최대 항목: {char_budget.get('max_items', '제한 없음')}"
f"\n 항목당 글자: {char_budget.get('chars_per_item', '제한 없음')}"
)
```
### 3-C: EDITOR_PROMPT 롤백
Phase Q에서 5번 수정한 EDITOR_PROMPT를 **Phase P 원본 기반으로 복원**.
단, Phase Q의 핵심 규칙 2개만 추가:
1. "글자수 예산(★) 초과 금지"
2. "source_data가 있으면 그것을 우선 사용"
---
## 4. 기대 효과
| 지표 | Phase P (20점) | Phase Q 현재 | Phase Q 수정 후 (예상) |
|------|---------------|-------------|---------------------|
| 블록 선택 | 3종류, 유령 5개 | 5종류, 유령 0개 | 5종류, 유령 0개 (유지) |
| 텍스트 품질 | 풍부 (604자) | 축약 (~30자) | **풍부 (Phase P 수준 복원)** |
| overflow | 213px | 0~45px | 예산 제약으로 방지 |
| 사례 수 | 2건 | 1건 | **2건 (복원)** |
| 용어 정의 | 풀 버전 | 축약 | **풀 버전 + 출처 (복원)** |
| 의미 왜곡 | 있음 (순차↔포함) | 없음 | 없음 (유지) |
| 처리 시간 | ~40분 | ~6분 | ~8분 (topic별 호출 추가) |
---
## 5. 교훈
1. **작동하는 것을 바꾸지 마라.** Phase P의 텍스트 채우기는 잘 작동했다. Phase Q에서 바꿀 이유가 없었다.
2. **프롬프트 탓을 하기 전에 호출 구조를 확인하라.** 5번 프롬프트를 수정했지만, 문제는 "한 번에 6개 블록 요청"이라는 호출 구조였다.
3. **이전 결과물과 비교하라.** `step3_edited_variants.json`(Phase P)과 `step3_fill_content.json`(Phase Q)을 처음부터 비교했으면 원인을 즉시 찾았을 것이다.
4. **조사 결과를 적용할 때, 기존에 잘 작동하는 부분은 보존하라.** "계산 먼저, AI 판단 나중에"는 블록 선택에 적용할 원칙이었지, 텍스트 채우기에 적용할 원칙이 아니었다.

531
IMPROVEMENT-PHASE-Q.md Normal file
View File

@@ -0,0 +1,531 @@
# Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템
> 작성일: 2026-03-28
> 상태: 설계 확정 (사용자 승인 완료, 실행 대기)
> 선행 완료: Phase O (컨테이너 기반 레이아웃), Phase P (다후보 렌더링 비교)
---
## 배경: Phase P 실행 결과 분석
Phase P를 실행한 결과(run `1774599277829`) 최종 슬라이드 품질이 **20/100점**으로 평가됨.
### 발견된 근본 문제 5가지
| # | 근본 원인 | 증상 |
|---|----------|------|
| R1 | FAISS 텍스트 임베딩이 시각 블록을 매칭하지 못함 | "hierarchy" 관계인데 venn-diagram 대신 comparison-2col 선택 |
| R2 | Opus 추천에 catalog 검증 없음 | 존재하지 않는 블록 5개 환각 (arrow-flow, hierarchy-tree 등) |
| R3 | overflow 해소 실패 시 출력 차단/재배치 없음 | 배경 117px에 330px 콘텐츠 → 겹침 상태로 출력 |
| R4 | 블록 중복 사용 제한 없음 | 5개 topic에 3종류 블록만 사용 |
| R5 | 공간 배분이 일방향 | "안 맞아도 강제" — 배경 20%에 topic 2개 우겨넣기 |
### Phase P 접근법의 구조적 문제
```
Phase P: 3후보 렌더링 → 스크린샷 비교 → 선택
문제점: 15번 렌더링 + 15번 AI 호출 → 40분 소요, 10개 폐기
```
**업계 조사 결과**, 다후보 렌더링 비교 방식은 어떤 상용/오픈소스 도구도 사용하지 않음.
- Beautiful.ai: 규칙 엔진이 결정론적으로 배치 (AI는 콘텐츠만)
- Canva: 템플릿 검색 1개 → 커스터마이징
- PPTAgent: 참조 기반 편집 액션으로 1개 생성
**핵심 인사이트:** 블록 유형 선택은 렌더링 전에 결정할 수 있는 문제.
콘텐츠의 relation_type(계층/비교/정의/프로세스)으로 적합한 블록 카테고리가 결정됨.
---
## 핵심 원칙
**"계산 먼저, AI 판단 나중에, 렌더링은 검증만"**
```
Beautiful.ai에서: AI는 콘텐츠만, 레이아웃은 규칙 엔진이 결정론적으로
Napkin.ai에서: relation_type → 시각화 유형 자동 매핑
학술 연구에서: 글자수 예산을 사전 계산하여 AI에 제약으로 전달
VASCAR에서: 렌더링 → 비전 모델 검증 → 교정 루프
```
### 블록의 정체 재정의
```
블록 = 시각 패턴 (구조) ← 제목+본문이 세로 나열, 원이 겹침, 좌우 비교 등
블록 ≠ 고정 크기 컴포넌트 ← "제목 1줄 + 본문 1줄"이 아님
컨테이너가 크기를 결정:
같은 card-numbered라도
- 352px 컨테이너 → 항목 5개, 14px, 항목당 120자
- 117px 컨테이너 → 항목 2개, 12px, 항목당 40자
- 58px 컨테이너 → 항목 1개, 10px, 항목당 20자
각 블록에는 "최소 생존 크기"가 존재:
venn-diagram: 최소 ~150px (원이 의미 있으려면)
card-numbered: 최소 ~55px (항목 1개)
banner-gradient: 최소 ~40px (텍스트 1줄)
divider-text: 최소 ~25px (선 + 텍스트)
```
---
## 새 프로세스 vs 현재 프로세스
```
[현재 — Phase P] [Phase Q]
1. Kei 분석 (topics, weights) 1. Kei 분석 (동일)
2. 컨테이너 계산 (weight→px) 2. 컨테이너 계산 (동일)
3. FAISS 2개 + Opus 1개 = 3후보 3. relation_type → 블록 카테고리 (코드)
4. 3후보 × 5topics = 15개 텍스트 편집 → 컨테이너 제약 필터링 (코드)
5. 15개 Selenium 렌더링 + 스크린샷 → 글자수 예산 계산 (코드)
6. Kei 스크린샷 비교 → 5개 선택 → Kei에게 2-3개 후보 제시 → 1개 선택 (AI 1회)
7. 조립 → 렌더링
8. Selenium 측정 → overflow 발견 4. 텍스트 편집 (예산 포함, AI 5회)
9. 트림 → 재편집 → 재측정 5. 렌더링 1회 + Selenium 검증
10. Kei 최종 리뷰 6. 수학적 조정 (overflow 시, AI 없음)
7. 비전 모델 품질 게이트
API 호출: ~25회 API 호출: ~8회
Selenium: ~17회 Selenium: ~2회
소요: ~40분 소요: ~8-12분
```
---
## 실행 스텝 상세
### Q-1: catalog.yaml에 블록 메타데이터 보강
**현재 catalog.yaml 구조:**
```yaml
- id: venn-diagram
height_cost: large
when: "관계, 포함, 교집합"
not_for: "순서, 흐름"
```
**추가할 필드:**
```yaml
- id: venn-diagram
height_cost: large
min_height_px: 150 # ★ 최소 생존 크기
relation_types: # ★ 적합한 관계 유형
- hierarchy
- inclusion
category: visuals # ★ 블록 카테고리 (명시적)
max_items: 5 # ★ 최대 항목 수
min_items: 2 # ★ 최소 항목 수
when: "관계, 포함, 교집합"
not_for: "순서, 흐름"
```
**작업 내용:**
- 38개 블록 전체에 `min_height_px`, `relation_types`, `category`, `max_items`, `min_items` 추가
- `min_height_px`는 Selenium 실측으로 검증 (최소 콘텐츠로 렌더링하여 측정)
- **파일:** `templates/catalog.yaml`
- **의존성:** 없음
- **소요:** 2시간
---
### Q-2: relation_type → 블록 카테고리 매핑 엔진
**구현:**
```python
# src/block_selector.py (신규)
RELATION_TO_CATEGORIES: dict[str, list[str]] = {
"hierarchy": ["visuals"], # venn, circle, keyword-circle
"inclusion": ["visuals"], # venn
"comparison": ["tables", "emphasis"], # compare-2col-split, comparison-2col
"sequence": ["visuals"], # process-horizontal, flow-arrow
"cause_effect": ["emphasis"], # callout-warning, callout-solution
"definition": ["cards"], # card-numbered, card-icon-desc
"none": ["emphasis", "cards"], # dark-bullet-list, quote-big-mark
}
def select_block_candidates(
topic: dict,
container_spec: ContainerSpec,
catalog: dict,
used_blocks: set[str], # 슬라이드 내 이미 사용된 블록
) -> list[dict]:
"""결정론적으로 블록 후보를 필터링한다. AI 호출 없음."""
relation = topic.get("relation_type", "none")
categories = RELATION_TO_CATEGORIES.get(relation, ["emphasis", "cards"])
per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))
candidates = []
for block in catalog["blocks"]:
# 1. 카테고리 필터
if block["category"] not in categories:
continue
# 2. 최소 크기 필터
if block["min_height_px"] > per_topic_px:
continue
# 3. height_cost 필터
if HEIGHT_COST_RANK[block["height_cost"]] > HEIGHT_COST_RANK[container_spec.max_height_cost]:
continue
# 4. sidebar 시각 블록 제한
if container_spec.zone == "sidebar" and block["category"] == "visuals":
continue
# 5. 중복 사용 제한
if block["id"] in used_blocks:
continue
candidates.append(block)
return candidates # 보통 2-4개
```
- **파일:** 신규 `src/block_selector.py`
- **의존성:** Q-1 (catalog 메타데이터)
- **소요:** 3시간
---
### Q-3: 글자수 예산 계산 엔진
**구현:**
```python
# src/space_allocator.py에 추가
def calculate_char_budget(
block_type: str,
container_spec: ContainerSpec,
catalog: dict,
) -> dict:
"""블록이 컨테이너에서 수용 가능한 최대 글자수를 계산한다."""
block_def = catalog["blocks"][block_type]
per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))
# 폰트 크기 결정 (컨테이너 크기에 따라)
font_size = _select_font_size(per_topic_px)
# 구조적 오버헤드 (제목, 패딩, 간격)
structural = _estimate_structural_overhead(block_type, font_size)
content_height = per_topic_px - structural
# 한국어 줄당 글자수
chars_per_line = int(container_spec.width_px * 0.85 / font_size)
line_height_px = font_size * 1.6 # 한국어 line-height
available_lines = max(1, int(content_height / line_height_px))
# 항목 수 제한
max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄
max_items = min(max_items_by_space, block_def.get("max_items", 10))
return {
"total_chars": available_lines * chars_per_line,
"max_items": max_items,
"chars_per_item": (available_lines * chars_per_line) // max(1, max_items),
"font_size_px": font_size,
"available_lines": available_lines,
}
def _select_font_size(container_height_px: int) -> float:
"""컨테이너 높이에 따른 적정 폰트 크기."""
if container_height_px >= 300:
return 15.0
elif container_height_px >= 150:
return 13.0
elif container_height_px >= 80:
return 12.0
else:
return 10.0
```
- **파일:** `src/space_allocator.py`
- **의존성:** Q-1 (catalog 메타데이터)
- **소요:** 2시간
---
### Q-4: Kei 블록 선택 프롬프트 재설계
**현재:** FAISS 2개 + Opus 1개 = 3후보를 15개 렌더링 후 스크린샷 비교
**변경:** 코드가 필터링한 2-3개 후보를 Kei에게 제시, 1개 선택 (AI 1회)
```python
# src/kei_client.py에 추가
BLOCK_SELECTION_PROMPT = """
다음 topic에 가장 적합한 블록을 1개 선택하세요.
## Topic 정보
- 제목: {title}
- 목적: {purpose}
- 관계 유형: {relation_type}
- 핵심 콘텐츠 요약: {summary}
## 컨테이너 제약
- 영역: {zone} ({role}, 비중 {weight}%)
- 높이: {height_px}px, 너비: {width_px}px
## 후보 블록 (모두 이 컨테이너에 물리적으로 들어감)
{candidates_description}
## 선택 기준
1. 콘텐츠의 관계 유형({relation_type})을 가장 잘 시각화하는 블록
2. 이 topic의 목적({purpose})에 가장 부합하는 표현 방식
3. 글자수 예산 내에서 의미 전달이 가능한 블록
## 출력 (JSON)
{{"selected_block": "블록 id", "reason": "선택 근거 1문장"}}
"""
```
- **파일:** `src/kei_client.py`
- **의존성:** Q-2 (후보 필터링), Q-3 (예산 계산)
- **소요:** 2시간
---
### Q-5: pipeline.py 재구성 — Phase P 로직 교체
**핵심 변경:** Phase P의 15-render 루프를 제거하고 Q-2/Q-3/Q-4 기반 단일 경로로 교체.
```python
# pipeline.py 변경 개요
# Phase P 관련 코드 제거:
# - search_candidates_per_topic() 호출
# - _opus_batch_recommend() 호출
# - fill_candidates() 15회 호출
# - render_block_in_container() 15회 호출
# - measure_candidate_block() 15회 호출
# - select_best_candidate() 호출
# Phase Q 코드 추가:
async def generate_slide(...):
# Step 1-2: 동일 (Kei 분석 + 컨테이너 계산)
# Step 3: 블록 선택 (Phase Q)
yield {"event": "progress", "data": "2/5 블록 선택 중..."}
used_blocks = set()
for topic in topics:
# Q-2: 결정론적 후보 필터링
candidates = select_block_candidates(topic, container_spec, catalog, used_blocks)
# Q-3: 각 후보의 글자수 예산 계산
for c in candidates:
c["budget"] = calculate_char_budget(c["id"], container_spec, catalog)
# Q-4: Kei 1회 호출로 최종 선택
selected = await _retry_kei(select_block_for_topic, topic, candidates, container_spec)
used_blocks.add(selected["block_id"])
# Step 4: 텍스트 편집 (예산 포함)
yield {"event": "progress", "data": "3/5 텍스트 편집 중..."}
# fill_content()에 budget 전달
# Step 5: 렌더링 1회 + 검증
yield {"event": "progress", "data": "4/5 렌더링 + 검증 중..."}
html = render_slide(layout_concept)
measurement = measure_rendered_heights(html)
# Step 6: overflow 시 수학적 조정
if has_overflow(measurement):
html = apply_glue_compression(html, measurement) # AI 없음
# 그래도 overflow면 font-size 축소 (이진 탐색)
# 그래도 안 되면 해당 블록 텍스트 압축 (AI 1회)
# Step 7: 비전 모델 품질 게이트
yield {"event": "progress", "data": "5/5 품질 검증 중..."}
screenshot = capture_slide_screenshot(html)
quality = await vision_quality_gate(screenshot, analysis)
if not quality["passed"]:
# 문제 블록만 교정 → 재렌더링 (최대 2회)
```
- **파일:** `src/pipeline.py`
- **의존성:** Q-2, Q-3, Q-4
- **소요:** 4시간
---
### Q-6: 비전 모델 품질 게이트
**VASCAR 논문 기반 — 렌더링 → 스크린샷 → 비전 모델 평가 → 교정**
```python
# src/kei_client.py에 추가
VISION_QUALITY_PROMPT = """
이 슬라이드 스크린샷을 평가하세요.
## 체크리스트
1. 모든 텍스트가 컨테이너 안에 있는가? (겹침/잘림 없음)
2. 본심 영역(60%)이 시각적으로 가장 두드러지는가?
3. 각 블록의 폰트가 읽을 수 있는 크기인가? (최소 10px)
4. 블록 유형에 다양성이 있는가? (같은 블록 반복 아닌가)
5. 한국어 비즈니스 프레젠테이션으로서 적절한가?
## 출력 (JSON)
{
"passed": true/false,
"score": 0-100,
"issues": ["문제 설명"],
"fix_targets": [{"area": "body", "block_index": 0, "action": "shrink|replace|rewrite"}]
}
"""
```
- **파일:** `src/kei_client.py`
- **의존성:** Q-5 (파이프라인 통합)
- **소요:** 2시간
---
### Q-7: overflow 수학적 조정 (LaTeX 글루 모델)
**AI 없이 코드만으로 overflow를 흡수하는 메커니즘.**
```python
# src/space_allocator.py에 추가
@dataclass
class GlueSpec:
"""LaTeX 글루 모델 — 유연한 간격."""
natural: float # 기본 간격 (px)
stretch: float # 늘어날 수 있는 양 (px)
shrink: float # 줄어들 수 있는 양 (px)
SPACING_GLUE = {
"block_gap": GlueSpec(natural=20, stretch=4, shrink=12),
"inner_gap": GlueSpec(natural=16, stretch=4, shrink=8),
"title_gap": GlueSpec(natural=8, stretch=2, shrink=4),
"padding": GlueSpec(natural=16, stretch=0, shrink=8),
}
def apply_glue_compression(html: str, measurement: dict) -> str:
"""overflow 시 간격을 축소하여 흡수한다. AI 호출 없음."""
for container_name, data in measurement["containers"].items():
if not data["overflowed"]:
continue
excess = data["excess_px"]
total_shrinkable = sum(g.shrink for g in SPACING_GLUE.values()) * len(data["blocks"])
if excess <= total_shrinkable:
# 간격 축소로 해결 가능
ratio = excess / total_shrinkable
# CSS 변수 오버라이드 삽입
html = inject_compressed_spacing(html, container_name, ratio)
else:
# 간격만으로 불충분 → 폰트 축소 시도
html = try_font_reduction(html, container_name, excess - total_shrinkable)
return html
```
- **파일:** `src/space_allocator.py`
- **의존성:** 없음
- **소요:** 3시간
---
### Q-8: 출력 차단 정책
**overflow 상태에서 결과를 내보내지 않는 안전장치.**
```python
# src/pipeline.py에 추가
class SlideQualityError(Exception):
"""슬라이드 품질이 최소 기준 미달."""
def validate_output(measurement: dict, quality_check: dict) -> None:
"""최종 출력 전 품질 검증. 미달 시 예외 발생."""
# 1. 물리적 겹침 검사
for name, container in measurement["containers"].items():
if container["overflowed"] and container["excess_px"] > 10:
raise SlideQualityError(
f"컨테이너 '{name}'에서 {container['excess_px']}px overflow 미해결"
)
# 2. 비전 모델 점수 검사
if quality_check.get("score", 0) < 40:
raise SlideQualityError(
f"비전 품질 점수 {quality_check['score']}/100 — 최소 40점 미달"
)
```
- **파일:** `src/pipeline.py`
- **의존성:** Q-6 (품질 게이트)
- **소요:** 1시간
---
## 태스크 요약
| 스텝 | 내용 | 유형 | 파일 | 의존성 | 소요 |
|------|------|------|------|--------|------|
| Q-1 | catalog.yaml 메타데이터 보강 | 데이터 | `templates/catalog.yaml` | 없음 | 2h |
| Q-2 | relation_type → 블록 카테고리 매핑 | 신규 코드 | `src/block_selector.py` | Q-1 | 3h |
| Q-3 | 글자수 예산 계산 엔진 | 코드 추가 | `src/space_allocator.py` | Q-1 | 2h |
| Q-4 | Kei 블록 선택 프롬프트 재설계 | 코드 수정 | `src/kei_client.py` | Q-2, Q-3 | 2h |
| Q-5 | pipeline.py 재구성 (Phase P → Q) | 코드 수정 | `src/pipeline.py` | Q-2, Q-3, Q-4 | 4h |
| Q-6 | 비전 모델 품질 게이트 | 신규 코드 | `src/kei_client.py` | Q-5 | 2h |
| Q-7 | overflow 수학적 조정 (글루 모델) | 코드 추가 | `src/space_allocator.py` | 없음 | 3h |
| Q-8 | 출력 차단 정책 | 코드 추가 | `src/pipeline.py` | Q-6 | 1h |
**의존 관계:**
```
Q-1 (catalog) ──┬──→ Q-2 (블록 필터) ──┐
└──→ Q-3 (예산 계산) ──┼──→ Q-4 (Kei 선택) ──→ Q-5 (파이프라인) ──→ Q-6 (품질 게이트) ──→ Q-8 (출력 차단)
Q-7 (글루 모델) ←──────────────────────┘ (독립)
```
**총 소요:** ~19시간 (병렬 작업 시 ~12시간)
---
## 기대 효과
| 지표 | Phase P (현재) | Phase Q (목표) |
|------|---------------|---------------|
| 슬라이드 품질 | 20/100 | 70-80/100 |
| 처리 시간 | ~40분 | ~8-12분 |
| API 호출 수 | ~25회 | ~8회 |
| Selenium 호출 | ~17회 | ~2회 |
| 유령 블록 | 발생 (5건) | 불가능 (catalog 검증) |
| overflow 출력 | 허용 | 차단 |
| 블록 다양성 | 3/38 사용 | relation_type 기반 자동 분산 |
---
## Phase Q 이후 방향
Phase Q가 70-80점을 달성하면, 80점 이상을 위해:
1. **디자인 참조 DB 구축** — 고품질 슬라이드 레퍼런스 수집 → PPTAgent식 참조 기반 생성
2. **시각 임베딩 FAISS** — 블록 스크린샷을 임베딩하여 시각적 유사도 검색
3. **LayoutPrompter식 동적 예제** — 과거 성공 슬라이드-콘텐츠 쌍을 few-shot으로 활용
이 방향들은 디자인 참조 DB가 축적된 후에 검토.
---
## 참고 자료 (조사 기반)
| 출처 | 적용한 인사이트 |
|------|---------------|
| Beautiful.ai (상용) | AI는 콘텐츠만, 레이아웃은 규칙 엔진 |
| Napkin.ai (상용) | NLP → 관계 유형 → 시각화 유형 자동 매핑 |
| VASCAR (arXiv 2024) | 생성→렌더링→비전 모델 평가→교정, 훈련 불필요 |
| LayoutPrompter (NeurIPS 2023) | 제로 훈련 동적 예제 선택 |
| RALF (CVPR 2024 Oral) | 검색 증강 레이아웃 |
| Atlassian 디자인 시스템 + LLM | CSS 변수 제약 → "10번째 세션 = 1번째 품질" |
| DesignBench (2025) | LLM CSS 공간 추론 한계: 27.1% 정확도 |
| LaTeX Box/Glue 모델 | 유연한 간격으로 overflow 흡수 |

297
IMPROVEMENT-PHASE-R.md Normal file
View File

@@ -0,0 +1,297 @@
# Phase R: 하이브리드 블록 시스템 — 기존 블록 활용 + 변형 + 자유 생성
> 작성일: 2026-03-30
> 상태: 설계 확정, 실행 대기
> 선행: Phase Q (제약 기반 블록 선택 + 글자수 예산) 코드 완료
> 근거: Phase Q 6차 테스트 + 하이브리드 시뮬레이션 검증
---
## 1. 배경: 왜 Phase R이 필요한가
### Phase Q까지의 성과
- ✅ 유령 블록 제거 (catalog 검증)
- ✅ relation_type → 카테고리 결정론적 매핑
- ✅ 글자수 예산 사전 계산
- ✅ fill_candidates topic별 개별 호출 복원
- ✅ 비전 모델 품질 게이트
- ✅ overflow 수학적 조정 (LaTeX 글루 모델)
### Phase Q에서 해결 못한 문제
-**블록이 콘텐츠 구조에 안 맞는 경우** — 38개 고정 블록 중 "정확히 맞는 것"이 없을 때 억지로 끼워 맞춤
-**topic 1개 = 블록 1개 고정 규칙** — topic 합침/분리 불가
-**콘텐츠 전달 의도 반영 부족** — "이해시키는 시각화"가 아닌 "텍스트 나열"
### 하이브리드 시뮬레이션으로 증명된 것
DX 시행 목표 콘텐츠로 테스트한 결과:
| 블록 | 용도 | 활용 방식 | 블록 재사용률 |
|------|------|----------|-------------|
| `card-icon-desc` | 목표 3카드 | 기존 블록 **100%** | 그대로 |
| `dark-bullet-list` | 프로세스 변화 | 기존 색상/구조 + **Before→After 변형** | 80% |
| `divider-text` | 섹션 구분 | 기존 블록 **100%** | 그대로 |
| `table-simple-striped` | 주체별 효과 | 기존 블록 **100%** | 그대로 |
| `banner-gradient` | 결론 | 기존 블록 **100%** | 그대로 |
**결론: 38개 블록의 80%는 그대로 사용 가능. 빠진 것은 "변형 능력" 1가지.**
---
## 2. 핵심 원칙
```
블록 선택 → 맞는 블록 있으면 그대로 사용 (기존 Phase Q)
→ 80% 맞는 블록 있으면 변형해서 사용 (Phase R 추가)
→ 전혀 안 맞으면 디자인 토큰 안에서 자유 생성 (Phase R 추가)
```
### 3단계 렌더링 우선순위
| 우선순위 | 방식 | 조건 | 품질 안정성 |
|---------|------|------|-----------|
| **1순위** | 기존 블록 그대로 | catalog에 정확히 맞는 블록이 있을 때 | 가장 높음 (검증된 템플릿) |
| **2순위** | 기존 블록 변형 | 80% 맞는 블록 + variant로 보완 | 높음 (기존 CSS 기반) |
| **3순위** | 디자인 토큰 기반 자유 생성 | 어떤 블록으로도 안 맞을 때 | 중간 (토큰 제약 + 검증 필요) |
---
## 3. 구체적 설계
### R-1: catalog.yaml에 variants 메타데이터 추가
기존 블록에 "변형 가능한 형태"를 정의한다. 변형은 기존 CSS를 유지하면서 내부 구조만 달라지는 것.
```yaml
- id: dark-bullet-list
category: emphasis
# ... 기존 필드 유지 ...
variants:
- id: default
description: 기존 불릿 나열
template: blocks/emphasis/dark-bullet-list.html
- id: before-after
description: Before→After 2열 구조 (프로세스 변화)
template: blocks/emphasis/dark-bullet-list--before-after.html
when: "기존 방식 → 새 방식으로의 전환/변화를 보여줄 때"
- id: card-icon-desc
category: cards
variants:
- id: default
description: 아이콘 + 제목 + 설명 (기본)
- id: compact
description: 아이콘 축소, 설명 2줄 제한 (높이 부족 시)
- id: horizontal
description: 아이콘-제목-설명 가로 배치 (좁은 공간)
- id: comparison-2col
category: emphasis
variants:
- id: default
description: 좌우 텍스트 비교
- id: cards-in-container
description: 큰 박스 안에 카드 N개 (포함 관계 시각화)
when: "hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때"
```
- **파일:** `templates/catalog.yaml`
- **변경:** 기존 블록에 `variants[]` 필드 추가
- **변형 템플릿:** `blocks/{category}/{block-id}--{variant-id}.html` 파일 추가
### R-2: variant 템플릿 제작
블록별 변형 HTML 템플릿을 추가한다. 기존 블록의 CSS(색상, 배경, radius 등)를 그대로 사용하고 내부 구조만 변경.
**우선 제작 대상 (시뮬레이션에서 검증된 변형):**
| 블록 | variant | 용도 |
|------|---------|------|
| `dark-bullet-list` | `before-after` | 프로세스 변화 (Before→After 2열) |
| `comparison-2col` | `cards-in-container` | 포함 관계 (DX ⊃ GIS+BIM+DT) |
| `card-icon-desc` | `compact` | 높이 부족 시 축소 |
| `card-numbered` | `horizontal` | 사례 가로 비교 |
- **파일:** `templates/blocks/{category}/``--{variant}.html` 추가
- **원칙:** 기존 블록의 CSS 클래스/색상을 재사용. 새 CSS는 최소한만 추가.
### R-3: block_selector.py에 variant 선택 로직 추가
블록 선택 시 variant도 함께 결정. Kei에게 "이 블록의 어떤 변형이 적합한가"를 함께 제시.
```python
# block_selector.py 수정
def select_block_candidates(topic, container_spec, used_blocks, catalog):
# ... 기존 필터링 로직 유지 ...
# 각 후보 블록의 variants도 함께 반환
for block in candidates:
variants = block.get("variants", [{"id": "default"}])
# expression_hint와 매칭되는 variant 우선 정렬
block["_available_variants"] = variants
return candidates
```
### R-4: Kei 블록 선택 프롬프트에 variant + expression_hint 전달
Q-4 프롬프트를 확장하여 variant 선택과 expression_hint를 포함.
```
## 후보 블록
1. dark-bullet-list (다크 배경 불릿)
변형:
- default: 기존 불릿 나열
- before-after: Before→After 2열 구조
★ 표현 의도: "기존 방식에서 새 방식으로의 전환을 보여주는 구조"
→ before-after 변형이 적합
## 선택 (JSON)
{"block_id": "dark-bullet-list", "variant": "before-after", "reason": "..."}
```
### R-5: renderer.py에 variant 렌더링 지원
variant가 지정되면 해당 변형 템플릿을 사용하여 렌더링.
```python
# renderer.py 수정
def _resolve_template_path(env, block_type, variant="default"):
if variant and variant != "default":
# 변형 템플릿 우선
variant_path = f"blocks/{category}/{block_type}--{variant}.html"
if template_exists(env, variant_path):
return variant_path
# 기존 템플릿 fallback
return f"blocks/{category}/{block_type}.html"
```
### R-6: 3순위 자유 생성 (디자인 토큰 기반)
어떤 블록+변형으로도 안 맞을 때, AI가 디자인 토큰 안에서 HTML을 직접 생성.
```python
# 자유 생성 조건
if not suitable_block_found:
# 디자인 토큰 + 3-5개 예시 슬라이드를 프롬프트에 포함
# AI가 HTML 생성
# Selenium으로 검증
html = await generate_free_block_html(topic, container_spec, design_tokens)
```
**제약 사항:**
- 디자인 토큰(CSS 변수)만 사용 가능 — 하드코딩 색상/폰트 금지
- 감사 스크립트로 토큰 위반 검출 (Atlassian 방식)
- Selenium 측정으로 overflow 검증
- 비전 모델 품질 게이트 통과 필수
### R-7: expression_hint를 fill_candidates에 전달
1단계에서 Kei가 판단한 `expression_hint`를 편집자(fill_candidates)에게 전달하여 텍스트 구성에 반영.
```python
# fill_candidates 프롬프트에 추가
section += f"\n ★ 표현 의도: {topic.get('expression_hint', '')}"
section += f"\n ★ 변형: {block.get('_variant', 'default')}"
```
---
## 4. 실행 계획
### 의존 관계
```
R-1 (catalog variants) ──→ R-2 (variant 템플릿) ──→ R-5 (renderer variant 지원)
──→ R-3 (selector variant)──→ R-4 (Kei 프롬프트 확장)
──→ R-7 (expression_hint 전달)
R-6 (자유 생성) ← R-5 완료 후 독립 작업
```
### 우선순위
| 순서 | 스텝 | 내용 | 효과 |
|------|------|------|------|
| 1 | R-1 | catalog에 variants 추가 | 데이터 기반 |
| 2 | R-2 | before-after, cards-in-container 템플릿 제작 | 시뮬레이션에서 검증된 변형 우선 |
| 3 | R-5 | renderer variant 렌더링 | 변형 블록이 실제로 렌더링 |
| 4 | R-3 | block_selector variant 필터링 | variant 후보 제시 |
| 5 | R-4 | Kei 프롬프트 확장 | variant + expression_hint |
| 6 | R-7 | fill_candidates에 expression_hint 전달 | 텍스트 구성 개선 |
| 7 | R-6 | 자유 생성 (3순위) | 블록으로 안 맞을 때 대비 |
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `templates/catalog.yaml` | variants[] 필드 추가 |
| `templates/blocks/emphasis/dark-bullet-list--before-after.html` | 신규 |
| `templates/blocks/emphasis/comparison-2col--cards-in-container.html` | 신규 |
| `templates/blocks/cards/card-icon-desc--compact.html` | 신규 |
| `templates/blocks/cards/card-numbered--horizontal.html` | 신규 |
| `src/block_selector.py` | variant 필터링 로직 추가 |
| `src/kei_client.py` | Q-4 프롬프트에 variant + expression_hint |
| `src/renderer.py` | variant 템플릿 해석 |
| `src/content_editor.py` | fill_candidates에 expression_hint 전달 |
---
## 5. 기대 효과
| 지표 | Phase Q (현재) | Phase R (목표) |
|------|---------------|---------------|
| 블록 적합도 | 60% (억지로 끼워 맞춤) | 90%+ (변형으로 맞춤) |
| 콘텐츠 구조 반영 | 낮음 (텍스트 나열) | 높음 (Before→After, 포함관계 등) |
| 블록 재사용률 | 38개 중 5-6개 사용 | 38개 + variants로 실질 50+ |
| 자유 생성 비율 | 0% | 5-10% (안 맞을 때만) |
| 텍스트 보존도 | Phase P 수준 (fill_candidates) | 동일 유지 |
---
## 6. Phase P → Q → R 전체 흐름 정리
```
Phase P (20점): FAISS+Opus → 블록 선택 → 다후보 렌더링 비교 → 느리고 부정확
Phase Q (77점): relation_type → 결정론적 필터링 → 예산 사전 계산 → 빠르고 정확
→ 하지만 "맞는 블록이 없으면 억지로 끼워 맞춤"
Phase R (목표): Phase Q 유지 + variant 변형 + expression_hint 전달
→ 기존 블록 80% 활용 + 20% 변형/자유 생성
→ 콘텐츠 전달 의도에 맞는 시각화
```
---
## 7. 검증된 시뮬레이션 결과
### 콘텐츠 1: 건설산업 DX의 올바른 이해 (포함 관계)
- `ideal_v2` + `3approaches` 폴더: 접근 A, C가 우수
- `comparison-2col--cards-in-container` 변형이 핵심 (DX ⊃ GIS+BIM+DT)
### 콘텐츠 2: DX 시행 목표 및 기대 효과 (목표/프로세스)
- `hybrid_simulation` 폴더: 블록 80% 활용 확인
- `dark-bullet-list--before-after` 변형이 핵심 (프로세스 변화)
- `card-icon-desc` 기존 블록 그대로 사용 (목표 3카드)
- `table-simple-striped` 기존 블록 그대로 사용 (주체별 효과)
### 공통 확인
- 720px overflow 없음
- 디자인 토큰(색상, 폰트, 간격) 일관성 유지
- sidebar 프리셋 적절히 작동
---
## 8. 참고: 조사 기반 근거
| 출처 | 적용한 인사이트 |
|------|---------------|
| Atlassian + LLM | CSS 변수로 제한 → "10번째도 1번째와 동일 품질" |
| frontend-slides (11.5K stars) | 디자인 토큰 + 예시 기반 → 프로덕션 품질 HTML 생성 |
| TechGrid 인터뷰 | "모델의 자유도를 줄이고 모든 것을 검증" |
| VASCAR | 생성 → 렌더링 → 비전 모델 평가 → 교정 |
| Beautiful.ai | 템플릿 + 제약 엔진 (자동 조정 규칙) |
| AutoPresent (CVPR 2025) | 코드 API 기반 조합 생성 |

View File

@@ -481,7 +481,7 @@ CLAUDE.md 요구사항 전수검토 결과 발견된 미구현/부분구현/위
--- ---
## Phase O: 컨테이너 기반 레이아웃 시스템 🟡 진행 중 ## Phase O: 컨테이너 기반 레이아웃 시스템 ✅ 완료
> **실행 상세:** [IMPROVEMENT-PHASE-O.md](IMPROVEMENT-PHASE-O.md) > **실행 상세:** [IMPROVEMENT-PHASE-O.md](IMPROVEMENT-PHASE-O.md)
> Phase N 완료 후 여전히 비중이 시각에 반영 안 되는 근본 문제 해결. > Phase N 완료 후 여전히 비중이 시각에 반영 안 되는 근본 문제 해결.
@@ -522,11 +522,43 @@ Phase O에서 Kei(A-2) + 코드가 모든 것을 결정하면서 Step B(Sonnet)
- Phase L 축소 로직 — `_max_chars_total` 축소로 변경 - Phase L 축소 로직 — `_max_chars_total` 축소로 변경
- fonttools 의존성 + Pretendard .ttf 파일 추가 - fonttools 의존성 + Pretendard .ttf 파일 추가
### M-Step 3: [중요] 블록 안전성 (P-5 + P-6 + P-7 + P-8) ---
- Figma 블록 식별, zone 적합성 맵, 글자 수용량, 내부 overflow 감지
### M-Step 4: [보통] 원본 보존 (P-9) ## Phase P: 블록 재구성 + 실제 렌더링 비교 선택 ✅ 실행 완료 → Phase Q로 전환
- source_text 직접 전달, 재작성 금지 강화
> **실행 상세:** [IMPROVEMENT-PHASE-P.md](IMPROVEMENT-PHASE-P.md)
> **실행 결과:** `data/runs/1774599277829/` — 최종 품질 20/100점
> **결론:** 다후보 렌더링 비교 방식은 비효율적 (15렌더링 40분, 10개 폐기). 업계 조사 결과 어떤 도구도 이 방식을 사용하지 않음. Phase Q로 방향 전환.
---
## Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템 ✅ 코드 완료
> **실행 상세:** [IMPROVEMENT-PHASE-Q.md](IMPROVEMENT-PHASE-Q.md)
> Phase P 결과 분석 + 업계 조사(Beautiful.ai, Napkin.ai, VASCAR, PPTAgent 등) 기반 재설계.
**핵심 원칙:** "계산 먼저, AI 판단 나중에, 렌더링은 검증만"
**실행 스텝 (8개):**
- Q-1: catalog.yaml 메타데이터 보강 (min_height_px, relation_types, category, min/max_items)
- Q-2: relation_type → 블록 카테고리 결정론적 매핑 엔진 (신규 `src/block_selector.py`)
- Q-3: 글자수 예산 계산 엔진 (`src/space_allocator.py` 추가)
- Q-4: Kei 블록 선택 프롬프트 재설계 — 필터링된 2-3개만 제시 (`src/kei_client.py`)
- Q-5: pipeline.py 재구성 — Phase P 15-render 루프 → Phase Q 단일 경로
- Q-6: 비전 모델 품질 게이트 (VASCAR식, `src/kei_client.py`)
- Q-7: overflow 수학적 조정 (LaTeX 글루 모델, `src/space_allocator.py`)
- Q-8: 출력 차단 정책 (overflow/품질 미달 시 출력 금지)
**기대 효과:** 품질 20→70-80점, 시간 40분→8-12분, API 25→8회, 유령블록 불가능
**해결하는 근본 문제 5가지:**
| # | 근본 원인 | Phase Q 해결 방법 |
|---|----------|-----------------|
| R1 | FAISS 텍스트 매칭 → 시각 블록 무시 | relation_type → 블록 카테고리 결정론적 매핑 (Q-2) |
| R2 | Opus 유령 블록 환각 | catalog 존재 검증 + 필터링된 후보만 제시 (Q-2, Q-4) |
| R3 | overflow 해결 못하고 출력 | 글자수 예산 사전 계산 + 글루 모델 + 출력 차단 (Q-3, Q-7, Q-8) |
| R4 | 블록 중복 사용 | used_blocks 집합으로 중복 차단 (Q-2) |
| R5 | 공간 배분 일방향 강제 | min_height_px 필터 + 비중 재조정 요청 (Q-1, Q-2) |
--- ---
@@ -558,8 +590,43 @@ Phase F (향후) → Phase A~E 완료 후.
--- ---
## Phase R: 하이브리드 블록 시스템 ❌ 실패
> **기록:** [IMPROVEMENT-PHASE-R.md](IMPROVEMENT-PHASE-R.md)
> 접근 C로 가기로 합의했으나, 구현에서 기존 블록 선택 시스템 위에 variant 패치만 추가.
> **P = Q = R 동일 구조.** 결과물 34점.
---
## Phase R': 접근 C — 블록 CSS 참고 + AI 구조 결정 📋 설계 확정
> **실행 상세:** [IMPROVEMENT-PHASE-R-PRIME.md](IMPROVEMENT-PHASE-R-PRIME.md)
**핵심 전환:** 블록이 구조를 결정 → **콘텐츠가 구조를 결정, 블록 CSS는 참고만**
**2-3단계 교체:**
- 제거: block_selector(블록 선택), fill_candidates(슬롯 채우기)
- 추가: html_generator(AI가 HTML 구조 직접 생성)
**실행 스텝 (7개):**
- R'-1: 디자인 토큰 + 블록 CSS 패턴을 프롬프트용으로 추출 (`src/design_tokens.py`)
- R'-2: few-shot 예시 슬라이드 정리 (`data/examples/`)
- R'-3: AI HTML 생성 함수 구현 (`src/html_generator.py`)
- R'-4: pipeline.py 2-3단계 교체 (블록 선택+채우기 → html_generator)
- R'-5: 렌더러에 AI HTML 삽입 함수 추가 (`src/renderer.py`)
- R'-6: HTML 정화 + 토큰 위반 검증 (`src/html_validator.py`)
- R'-7: 테스트 2개 콘텐츠 검증 (`scripts/test_phase_r_prime.py`)
**합격 기준:** C_reference.png 수준 자동 생성 (topic 합침, 포함 관계, 핵심 메시지, 원본 보존)
**회귀 방지:** block_selector, fill_candidates, fill_content, finalize_block_specs 호출 금지
---
## 수정 이력 ## 수정 이력
| 날짜 | 내용 | | 날짜 | 내용 |
|------|------| |------|------|
| 2026-03-25 | 초안 작성. CLAUDE.md 전수검토 기반 33개 항목 도출. | | 2026-03-25 | 초안 작성. CLAUDE.md 전수검토 기반 33개 항목 도출. |
| 2026-03-28 | Phase P 실행 완료(20/100점). 업계 조사 기반 Phase Q 설계 확정. |
| 2026-03-30 | Phase Q 코드 완료. Phase R 설계+구현 → 실패(기존 구조 회귀). Phase R' 설계 확정. |

View File

@@ -1,12 +1,11 @@
# Design Agent — 진행 상황 # Design Agent — 진행 상황
## 현재 상태 요약 (2026-03-27 기준) ## 현재 상태 요약 (2026-03-28 기준)
| 상태 | 내용 | | 상태 | 내용 |
|------|------| |------|------|
| **완료** | Phase 1~5 기반 구축, Phase I~N 개선, Step B 제거 + 죽은 코드 정리 | | **완료** | Phase 1~5 기반 구축, Phase I~O 개선, Step B 제거, Phase P 실행 완료 |
| **진행 중** | Phase O 컨테이너 시스템 (코드 작성 완료, 미세 조정 필요) | | **다음** | Phase Q — 제약 기반 블록 선택 + 글자수 예산 시스템 (설계 확정, 실행 대기) |
| **미해결** | 컨테이너 크기 vs 블록 크기 불일치, Selenium container div 미감지 |
--- ---
@@ -43,44 +42,75 @@
- 이미지 크기 측정 + base64 삽입 (image_utils.py) - 이미지 크기 측정 + base64 삽입 (image_utils.py)
### 버그 수정 완료 ### 버그 수정 완료
- BF-1: SSE 파싱 실패 → static/index.html 분리 + 정규식 - BF-1~BF-10: 전부 수정 완료 (SSE 파싱, Jinja2 변수, 한글, body 겹침, 제목, topic_id, 예산, grid, catalog 캐시)
- BF-2: Jinja2 변수 전달 실패 → get_template().render() 방식
- BF-3: 한글 깨짐 → UTF-8 BOM 추가
- BF-4: body 블록 겹침 → _group_blocks_by_area() OrderedDict
- BF-5: 제목 미표시 → 프리셋 area명 header 통일
- BF-7: 블록 텍스트 비어있음 → topic_id 매칭 개선
- BF-8: 컨테이너 예산 기반 배치 → zone별 budget_px
- BF-9: grid와 Sonnet 역할 분리 → 코드가 grid 강제
- BF-10: catalog 캐시 갱신 → mtime 체크
--- ---
## 🟡 진행 중 ## ✅ Phase P 실행 완료 + 결과 분석 (2026-03-27~28)
### Phase O 컨테이너 시스템 ### 실행 결과
- **코드 작성 완료:** calculate_container_specs(), finalize_block_specs(), 렌더러 컨테이너 div - **실행 데이터:** `data/runs/1774599277829/`
- **문제 확인됨:** 배경 20%=117px에 topic 2개 → 각 58px. callout-warning(122px)이 안 맞음 - **최종 슬라이드 품질:** 20/100점
- **원인:** height_cost "medium"(80~200px)이 컨테이너 58px보다 큰데 통과됨
- **필요 조치:** 컨테이너 px가 작을 때 topic당 블록 높이를 더 정밀하게 제약
### Phase L 피드백 루프 ### 발견된 근본 문제 5가지
- **동작:** 측정 → overflow 감지 → _max_chars_total 축소 → 편집자 재호출
- **문제:** `_MEASURE_SCRIPT``.area-*`만 검색. Phase O의 `.container-*` div를 못 찾음
- **필요 조치:** slide_measurer.py에 container div 셀렉터 추가
### BF-6: sidebar 카드 찢어짐 | # | 근본 원인 | 증상 |
- Phase J에서 column_override + SIDEBAR_FORBIDDEN_BLOCKS 추가 |---|----------|------|
- 완전 해결 여부 테스트 필요 | R1 | FAISS 텍스트 임베딩이 시각 블록을 매칭하지 못함 | "hierarchy" 관계인데 venn 대신 comparison-2col 선택 |
| R2 | Opus 추천에 catalog 검증 없음 | 존재하지 않는 블록 5개 환각 (arrow-flow, hierarchy-tree 등) |
| R3 | overflow 해소 실패 시 출력 차단 없음 | 배경 117px에 330px 콘텐츠 → 겹침 상태로 출력 |
| R4 | 블록 중복 사용 제한 없음 | 5개 topic에 3종류 블록만 사용 (38개 중 7.9%) |
| R5 | 공간 배분이 일방향 | 배경 20%에 topic 2개 강제 → card-numbered(205px)가 컨테이너(117px)에 안 맞음 |
### Phase P 접근법의 구조적 한계
- 3후보 × 5topics = **15번 렌더링 + 15번 AI 호출 → ~40분 소요**
- 15개 중 10개 폐기 (작업의 2/3 낭비)
- 업계 조사 결과, **다후보 렌더링 비교 방식은 어떤 상용/오픈소스 도구도 사용하지 않음**
- 블록 유형 선택은 **렌더링 전에 결정할 수 있는 문제** (콘텐츠 relation_type 기반)
### 업계 조사 결과 (2026-03-28)
| 접근법 | 대표 사례 | 핵심 원리 |
|--------|----------|----------|
| 제약 기반 레이아웃 엔진 | Beautiful.ai | AI는 콘텐츠만, 레이아웃은 규칙 엔진이 결정론적으로 |
| 템플릿 검색 + AI 커스터마이징 | Canva | 벡터 검색으로 템플릿 매칭, AI가 텍스트/색상만 교체 |
| NLP 관계 유형 → 시각화 매핑 | Napkin.ai | 계층/비교/프로세스 감지 → 다이어그램 유형 자동 선택 |
| 시각적 자기교정 | VASCAR (2024) | 생성→렌더링→비전 모델 평가→개선, 훈련 불필요 |
| 참조 기반 학습 | PPTAgent (EMNLP 2025) | 기존 프레젠테이션에서 디자인 패턴 귀납적 학습 |
**업계 합의:** AI가 레이아웃을 직접 결정하면 안 된다. AI는 콘텐츠만, 레이아웃은 제약 엔진이 담당.
--- ---
## ❌ 미해결 → ✅ 해결됨 (2026-03-27) ## 📋 Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템 (설계 확정)
| 항목 | 해결 내용 | **상세:** [IMPROVEMENT-PHASE-Q.md](IMPROVEMENT-PHASE-Q.md)
|------|---------|
| 컨테이너 px vs 블록 높이 불일치 | `_max_allowed_height_cost()`를 topic당 높이(per_topic_px)로 판단하도록 수정 | **핵심 원칙:** "계산 먼저, AI 판단 나중에, 렌더링은 검증만"
| Selenium container div 미감지 | `_MEASURE_SCRIPT``.container-*` 셀렉터 추가 + pipeline.py에서 container overflow 체크 |
| catalog.yaml schema 글자수 하드코딩 | 37개 필드를 `ref_chars` + `max_lines` + `font_size` 구조로 변환. FAISS 재빌드 완료 | ### 실행 스텝
| 스텝 | 내용 | 유형 | 파일 | 의존성 | 상태 |
|------|------|------|------|--------|------|
| Q-1 | catalog.yaml 메타데이터 보강 (min_height_px, relation_types, category, min/max_items) | 데이터 | `templates/catalog.yaml` | ✅ 완료 |
| Q-2 | relation_type → 블록 카테고리 결정론적 매핑 엔진 | 신규 | `src/block_selector.py` | ✅ 완료 |
| Q-3 | 글자수 예산 계산 엔진 | 추가 | `src/space_allocator.py` | ✅ 완료 |
| Q-4 | Kei 블록 선택 프롬프트 재설계 (필터링된 2-3개만 제시) | 수정 | `src/kei_client.py` | ✅ 완료 |
| Q-5 | pipeline.py 재구성 (Phase P 15-render → Phase Q 단일 경로) | 수정 | `src/pipeline.py` | ✅ 완료 |
| Q-6 | 비전 모델 품질 게이트 (VASCAR식) | 신규 | `src/kei_client.py` | ✅ 완료 |
| Q-7 | overflow 수학적 조정 (LaTeX 글루 모델) | 추가 | `src/space_allocator.py` | ✅ 완료 |
| Q-8 | 출력 차단 정책 + P0 재시도 제한 (30회/300초) | 추가 | `src/pipeline.py` | ✅ 완료 |
### 기대 효과
| 지표 | Phase P | Phase Q 목표 |
|------|---------|-------------|
| 슬라이드 품질 | 20/100 | 70-80/100 |
| 처리 시간 | ~40분 | ~8-12분 |
| API 호출 | ~25회 | ~8회 |
| 유령 블록 | 5건 발생 | 불가능 |
| overflow 출력 | 허용 | 차단 |
--- ---
@@ -98,11 +128,86 @@
| J | 블록 선택 권한 재정의 | 완료 | Step B 제거로 일부 무력화 | | J | 블록 선택 권한 재정의 | 완료 | Step B 제거로 일부 무력화 |
| K | purpose 기반 시각적 위계 | 완료 | | | K | purpose 기반 시각적 위계 | 완료 | |
| K-1 | 중간 산출물 저장 | 완료 | | | K-1 | 중간 산출물 저장 | 완료 | |
| L | Selenium 렌더링 측정 | 완료 | container div 감지 미완 | | L | Selenium 렌더링 측정 | 완료 | |
| M | Kei 비중 시스템 | 완료 | Phase O로 교체 | | M | Kei 비중 시스템 | 완료 | Phase O로 교체 |
| N | 4대 핵심 문제 해결 | 완료 | catalog, fallback, topic_id, 무한재시도 | | N | 4대 핵심 문제 해결 | 완료 | |
| **O** | **컨테이너 기반 레이아웃** | **진행 중** | 코드 완료, 미세 조정 필요 | | **O** | **컨테이너 기반 레이아웃** | **완료** | 코드 + 미해결 3건 해결 + Step B 제거 |
| — | Step B 제거 + 죽은 코드 정리 | 완료 | Phase O 후속 | | **P** | **다후보 렌더링 비교 선택** | **완료** | 실행됨. 결과 20/100점 → Phase Q로 방향 전환 |
| **Q** | **제약 기반 블록 선택 + 글자수 예산** | **코드 완료** | Q-1~Q-8 구현 + fill_candidates 복원. 블록 선택 개선 확인 |
| **R** | **하이브리드 블록 시스템 (variant 추가)** | **실패** | 기존 블록 선택 구조 위에 패치만 추가. P=Q=R 동일 구조. |
| **R'** | **접근 C: 블록 CSS 참고 + AI 구조 결정** | **설계** | 방향만 확정. Kei API HTML 생성 실패 확인. |
| **S** | **검증 기반 확정 — Claude HTML 생성 + 검증된 프롬프트 규칙** | **설계 확정** | 영역별 검증 합격. Claude Sonnet 확정. 실행 대기. |
---
## ❌ Phase R 실패 기록
Phase R은 접근 C로 가기로 합의했으나, 구현에서 기존 블록 선택 시스템 위에 variant 패치만 추가.
**P = Q = R: 세 개 다 "블록 선택 → 슬롯 채우기" 근본 구조가 동일.**
결과물 34점. C_reference.png(70점) 수준에 전혀 도달 못함.
근본 원인: 기존 코드(block_selector, catalog, fill_candidates)를 유지하면서 최소 변경으로 해결하려는 관성.
---
## 📋 Phase R': 접근 C — 블록 CSS 참고 + AI 구조 결정 (설계 확정)
**상세:** [IMPROVEMENT-PHASE-R-PRIME.md](IMPROVEMENT-PHASE-R-PRIME.md)
### 핵심 전환
```
P=Q=R (실패): 블록이 구조를 결정 → 콘텐츠를 슬롯에 채움
R' (접근 C): 콘텐츠가 구조를 결정 → 블록 CSS를 참고하여 HTML 생성
```
### 프로세스 변경
| 단계 | 현재 (P=Q=R) | R' (접근 C) |
|------|-------------|------------|
| 1단계 Kei 분석 | 유지 | 유지 |
| 1.5단계 컨셉 구체화 | 유지 | 유지 |
| 컨테이너 계산 | 유지 | 유지 |
| 프리셋 선택 | 유지 | 유지 |
| **2단계** | block_selector → 블록 선택 | **제거** → html_generator가 AI HTML 생성 |
| **3단계** | fill_candidates → 슬롯 채우기 | **제거** → html_generator에 통합 |
| 4단계 렌더링 | render_slide (블록 템플릿) | render_slide_from_html (AI HTML 삽입) |
| 검증 | Selenium + 비전 모델 | 유지 |
### 실행 스텝
| 스텝 | 내용 | 파일 | 상태 |
|------|------|------|------|
| R'-1 | 디자인 토큰 + 블록 CSS 패턴을 프롬프트용 텍스트로 추출 | 신규 `src/design_tokens.py` | 대기 |
| R'-2 | few-shot 예시 슬라이드 정리 | `data/examples/` | 대기 |
| R'-3 | AI HTML 생성 함수 구현 | 신규 `src/html_generator.py` | 대기 |
| R'-4 | pipeline.py 2-3단계 교체 (블록 선택+채우기 → html_generator) | `src/pipeline.py` | 대기 |
| R'-5 | 렌더러에 AI HTML 삽입 함수 추가 | `src/renderer.py` | 대기 |
| R'-6 | HTML 정화 + 토큰 위반 검증 | 신규 `src/html_validator.py` | 대기 |
| R'-7 | 테스트 (2개 콘텐츠) | `scripts/test_phase_r_prime.py` | 대기 |
### 회귀 방지 — 호출하면 안 되는 함수
- `select_block_candidates()` — 블록 선택 회귀
- `fill_candidates()` / `fill_content()` — 슬롯 채우기 회귀
- `select_block_for_topics()` — 블록 선택 AI 회귀
- `finalize_block_specs()` — 블록 스펙 회귀
### 합격 기준
C_reference.png와 동일 수준의 결과를 **자동으로** 생성:
- topic 합침 가능
- 포함 관계 시각화 가능
- 핵심 메시지 별도 강조 가능
- 원본 텍스트 보존 (자유도 15-20)
- 720px overflow 없음
---
## Phase R' 이후 방향
- 디자인 참조 DB 구축 → 성공한 슬라이드를 few-shot으로 축적
- Playwright 마이그레이션 → 더 빠른 측정 + PDF 내보내기
--- ---
@@ -110,9 +215,11 @@
| 항목 | 파일 | 상태 | | 항목 | 파일 | 상태 |
|------|------|------| |------|------|------|
| 프로젝트 규칙 | CLAUDE.md | 완료 | | 프로젝트 규칙 | CLAUDE.md | Phase R' 반영 |
| 개선 계획 | IMPROVEMENT.md | Phase O까지 반영 | | 개선 계획 | IMPROVEMENT.md | Phase R' 반영 |
| 진행 추적 | PROGRESS.md | 이 파일 (2026-03-27 갱신) | | 진행 추적 | PROGRESS.md | 이 파일 (2026-03-30 갱신) |
| 전체 감사 | CLEANUP-AUDIT.md | 유효/무력화 분류 완료 | | 전체 감사 | CLEANUP-AUDIT.md | 유효/무력화 분류 완료 |
| Phase별 상세 | IMPROVEMENT-PHASE-{A~O}.md | 각 Phase 기록 | | Phase별 상세 | IMPROVEMENT-PHASE-{A~R'}.md | 각 Phase 기록 |
| README | README.md | Phase O + Step B 제거 반영 | | Phase R 실패 기록 | IMPROVEMENT-PHASE-R.md | 블록 선택 위에 variant 패치 — 실패 |
| Phase R' 설계 | IMPROVEMENT-PHASE-R-PRIME.md | 접근 C 기반 재설계 |
| README | README.md | Phase R' 반영 |

View File

@@ -24,42 +24,47 @@
[컨테이너 계산] 비중 → px 확정 (코드, 결정론적) [컨테이너 계산] 비중 → px 확정 (코드, 결정론적)
[2단계] 블록 확정 + 배치 (Kei API + Sonnet) [2단계] 블록 선택 (Phase Q) (코드: 결정론적 + Kei 1회)
│ relation_type → 카테고리 매핑 (코드)
│ → 컨테이너 제약 필터링 (코드)
│ → 글자수 예산 계산 (코드)
│ → Kei에게 2-3개 후보 제시 → 1개 선택 (AI)
[블록 스펙 확정] 항목수/글자수/폰트 (코드, 결정론적) [3단계] Kei 편집자 — 텍스트 정리 (예산 포함) (Kei API / Opus)
[3단계] Kei 편집자 — 텍스트 정리 (Kei API / Opus)
[4단계] 디자인 실무자 — CSS 조정 + HTML 조립 (Sonnet + Jinja2) [4단계] 디자인 실무자 — CSS 조정 + HTML 조립 (Sonnet + Jinja2)
[Phase L] Selenium 렌더링 측정 → 피드백 루프 [검증] Selenium 렌더링 1회 → 수학적 조정 (코드, AI 없음)
[5단계] Kei 실장 — 최종 검수 (스크린샷) (Opus 멀티모달) [품질 게이트] 비전 모델 평가 → 미달 시 교정/차단 (Opus 멀티모달)
완성 슬라이드 HTML 완성 슬라이드 HTML
``` ```
### 단계별 상세 ### 단계별 상세
| 단계 | 담당 | AI | 역할 | | 단계 | 담당 | AI/코드 | 역할 |
|------|------|-----|------| |------|------|---------|------|
| **1A** | Kei 실장 | Kei API (Opus) | 핵심 메시지, 꼭지 추출, page_structure(비중), purpose 부여 | | **1A** | Kei 실장 | Kei API (Opus) | 핵심 메시지, 꼭지 추출, page_structure(비중), purpose 부여 |
| **1B** | Kei 실장 | Kei API (Opus) | relation_type, expression_hint, source_data | | **1B** | Kei 실장 | Kei API (Opus) | relation_type, expression_hint, source_data |
| **컨테이너** | 코드 | | Kei 비중 → 역할별 컨테이너 px 확정, height_cost 제약, 블록 스펙 | | **컨테이너** | 코드 | 결정론적 | Kei 비중 → 역할별 컨테이너 px 확정, height_cost 제약 |
| **2 A-2** | Kei 실장 | Kei API (Opus) | 컨테이너 제약 보고 블록 확정 (FAISS 후보 기반) | | **블록 선택** | 코드 + Kei | 결정론적 + AI 1회 | relation_type → 카테고리 매핑 → 컨테이너 제약 필터 → Kei 최종 선택 (Phase Q) |
| **2 B** | 디자인 팀장 | Sonnet | zone 배치 + char_guide만 (블록 타입 변경 불가) | | **예산 계산** | 코드 | 결정론적 | 컨테이너 px → 글자수 예산 사전 계산 → AI 편집 시 하드 제약 (Phase Q) |
| **블록 스펙** | 코드 | — | 컨테이너 크기 → 항목수/글자수/폰트/패딩 확정 | | **3** | Kei 편집자 | Kei API (Opus) | 텍스트 편집 (글자수 예산 준수, 원본 보존) |
| **3** | Kei 편집자 | Kei API (Opus) | 텍스트 편집 (컨테이너 제약 준수, 원본 보존) | | **4** | 디자인 실무자 | Sonnet + Jinja2 | CSS 변수 override + HTML 조립 |
| **4** | 디자인 실무자 | Sonnet | CSS 변수 override + Jinja2 렌더링 | | **검증** | 코드 | Selenium 1회 | 검증 렌더링 — overflow 확인 (예산으로 사전 방지됨) |
| **Phase L** | 코드 | Selenium | 렌더링 측정 → overflow 감지 → 편집자 재호출 | | **품질 게이트** | 비전 모델 | Opus 멀티모달 | 스크린샷 기반 시각 품질 평가 (VASCAR식) — 미달 시 출력 차단 (Phase Q) |
| **5** | Kei 실장 | Opus | 스크린샷 보고 최종 검수 (멀티모달) |
### 핵심 원칙 ### 핵심 원칙
- **비중 → 컨테이너 → 블록 → 콘텐츠** 순서. 비중이 모든 것을 결정 - **비중 → 컨테이너 → 블록 → 콘텐츠** 순서. 비중이 모든 것을 결정
- **Kei API 필수.** fallback 없음. 기본값 없음. 성공할 때까지 무한 재시도 - **Kei API 필수.** fallback 없음. 기본값 없음. 성공할 때까지 무한 재시도
- **Sonnet은 zone 배치 + CSS 조정만.** 블록 선택/콘텐츠 판단 금지 - **계산 먼저, AI 판단 나중에, 렌더링은 검증만** (Phase Q 핵심 원칙)
- **블록 선택은 Kei가 확정 → 코드가 강제.** Sonnet이 변경 불가 - **블록 선택은 결정론적 필터링 → Kei 최종 선택.** AI에게 불가능한 선택지를 주지 않는다
- **콘텐츠가 구조를 결정, 블록 CSS는 참고만** (Phase R'). AI가 HTML 구조 직접 생성
- **글자수 예산은 하드 제약.** 컨테이너 px에서 수학적으로 도출, AI 편집 전에 전달
- **overflow 상태에서 출력 금지.** 비전 모델 품질 게이트 통과 필수
- **블록 = 시각 패턴(구조), 크기가 아님.** 같은 블록이 컨테이너에 따라 항목수/폰트/패딩 변동
- **텍스트가 기준.** 디자인이 텍스트에 맞춤. CSS로 사후 자르기 금지 - **텍스트가 기준.** 디자인이 텍스트에 맞춤. CSS로 사후 자르기 금지
--- ---
@@ -103,7 +108,8 @@ Kei가 판단한 비중이 시각적 레이아웃에 정확히 반영되는 구
| **L** | 렌더링 측정 에이전트 (Selenium headless) + 피드백 루프 | 완료 | | **L** | 렌더링 측정 에이전트 (Selenium headless) + 피드백 루프 | 완료 |
| **M** | Kei 비중 시스템 (page_structure weight) + 원본 보존 강화 | 완료 | | **M** | Kei 비중 시스템 (page_structure weight) + 원본 보존 강화 | 완료 |
| **N** | 4대 핵심 문제 해결 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 | 완료 | | **N** | 4대 핵심 문제 해결 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 | 완료 |
| **O** | 컨테이너 기반 레이아웃 시스템 — 비중→px→블록제약→콘텐츠제약 | **진행 중** | | **O** | 컨테이너 기반 레이아웃 시스템 — 비중→px→블록제약→콘텐츠제약 | 완료 |
| **P** | 블록 재구성 + 실제 렌더링 비교 선택 — 후보 3개 렌더링→Kei 스크린샷 판단 | **계획 확정** |
--- ---

617
scripts/test_3approaches.py Normal file
View File

@@ -0,0 +1,617 @@
"""3가지 접근법 비교: 같은 콘텐츠, 다른 생성 방식.
접근 A: Few-Shot 직접 생성 — Claude가 디자인 토큰 안에서 HTML 직접 작성
접근 B: 레이아웃 프리미티브 조합 — 15개 기본 요소를 조합
접근 C: 참조 기반 생성 — ideal_v2를 참조하여 구조 유지하되 콘텐츠만 교체
Kei API 불필요 — 순수 렌더링만.
"""
from __future__ import annotations
import asyncio, json, sys, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
# ═══════════════════════════════════════
# 공통 디자인 토큰 (3가지 접근 모두 이 토큰만 사용)
# ═══════════════════════════════════════
DESIGN_TOKENS_CSS = """
:root {
--color-primary: #1e293b;
--color-accent: #2563eb;
--color-accent-light: #93c5fd;
--color-bg: #ffffff;
--color-bg-subtle: #f8fafc;
--color-bg-dark: #1e293b;
--color-bg-dark-deep: #0f172a;
--color-border: #e2e8f0;
--color-danger: #dc2626;
--color-warning: #fbbf24;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-light: #94a3b8;
--color-text-on-dark: #e2e8f0;
--color-text-on-accent: #ffffff;
--font-title: 28px;
--font-section: 14px;
--font-body: 13px;
--font-small: 11px;
--font-caption: 10px;
--weight-normal: 400;
--weight-medium: 500;
--weight-bold: 700;
--weight-black: 900;
--spacing-page: 36px 40px 24px;
--spacing-section: 16px;
--spacing-block: 12px;
--spacing-inner: 10px;
--spacing-small: 6px;
--radius: 8px;
--radius-small: 6px;
--line-height: 1.6;
}
"""
SLIDE_BASE_CSS = """
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
.slide {
width: 1280px; height: 720px; overflow: hidden;
background: var(--color-bg);
font-family: 'Pretendard Variable', sans-serif;
color: var(--color-text);
font-size: var(--font-body);
line-height: var(--line-height);
word-break: keep-all;
display: grid;
grid-template-areas: 'header header' 'body sidebar' 'footer footer';
grid-template-columns: 65fr 35fr;
grid-template-rows: auto 1fr auto;
gap: var(--spacing-section);
padding: var(--spacing-page);
}
.header {
grid-area: header;
font-size: var(--font-title);
font-weight: var(--weight-black);
color: var(--color-primary);
border-bottom: 3px solid var(--color-accent);
padding-bottom: 8px;
}
.body { grid-area: body; display: flex; flex-direction: column; gap: var(--spacing-block); overflow: hidden; }
.sidebar { grid-area: sidebar; display: flex; flex-direction: column; gap: var(--spacing-block); border-left: 1px solid var(--color-border); padding-left: 20px; overflow: hidden; }
.footer { grid-area: footer; background: linear-gradient(135deg, #006aff, #00aaff); border-radius: var(--radius); padding: 14px 30px; text-align: center; color: var(--color-text-on-accent); }
.footer-text { font-size: 15px; font-weight: var(--weight-bold); }
.footer-sub { font-size: var(--font-small); opacity: 0.85; margin-top: 2px; }
"""
# ═══════════════════════════════════════
# 접근 A: Few-Shot 직접 생성
# Claude가 디자인 토큰만 보고 자유롭게 HTML 구성
# (여기서는 "Claude가 만들었을 법한" 결과를 시뮬레이션)
# ═══════════════════════════════════════
APPROACH_A_HTML = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 A</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
/* 접근 A: Claude가 콘텐츠에 맞게 자유 구성 */
.intro-bar {{
background: linear-gradient(135deg, var(--color-bg-dark), var(--color-bg-dark-deep));
border-radius: var(--radius);
padding: 14px 20px;
color: var(--color-text-on-dark);
}}
.intro-bar h3 {{
font-size: var(--font-body);
font-weight: var(--weight-bold);
color: var(--color-accent-light);
margin-bottom: var(--spacing-small);
}}
.intro-bar p {{
font-size: var(--font-body);
line-height: 1.7;
}}
.intro-bar strong {{ color: var(--color-warning); }}
.cases-row {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-inner);
margin-top: var(--spacing-inner);
}}
.case {{
background: rgba(255,255,255,0.07);
border-radius: var(--radius-small);
padding: 8px 12px;
border-left: 3px solid var(--color-accent-light);
}}
.case-title {{ font-size: var(--font-small); font-weight: var(--weight-bold); color: var(--color-accent-light); margin-bottom: 3px; }}
.case-text {{ font-size: var(--font-small); color: var(--color-text-light); line-height: 1.5; }}
.core-title {{ font-size: var(--font-section); font-weight: var(--weight-black); color: var(--color-accent); text-align: center; margin-bottom: var(--spacing-small); }}
.dx-container {{
border: 3px solid var(--color-accent);
border-radius: 14px;
padding: 14px 16px 12px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
position: relative;
flex: 1;
display: flex;
flex-direction: column;
}}
.dx-badge {{
position: absolute; top: -11px; left: 16px;
background: var(--color-accent); color: white;
font-size: var(--font-small); font-weight: var(--weight-black);
padding: 2px 14px; border-radius: var(--radius-small);
}}
.dx-desc {{
font-size: var(--font-small); color: #1e40af;
text-align: center; margin-bottom: var(--spacing-inner);
}}
.tech-grid {{
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-inner);
flex: 1;
}}
.tech {{
background: white; border: 2px solid var(--color-accent-light);
border-radius: var(--radius); padding: 8px; text-align: center;
display: flex; flex-direction: column; align-items: center;
}}
.tech-circle {{
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
color: white; font-size: 15px; font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px;
}}
.tech b {{ font-size: var(--font-body); color: var(--color-primary); }}
.tech span {{ font-size: var(--font-caption); color: var(--color-text-secondary); line-height: 1.4; margin-top: 2px; }}
.key-msg {{
background: #f0f9ff; border: 2px solid #bae6fd;
border-radius: var(--radius); padding: 8px 14px; text-align: center;
}}
.key-msg p {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: #0c4a6e; }}
.key-msg em {{ color: var(--color-danger); font-style: normal; font-weight: var(--weight-black); }}
.sidebar-label {{
display: flex; align-items: center; gap: 10px;
font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light);
}}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.def {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px 12px;
}}
.def-head {{ display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }}
.def-num {{
width: 22px; height: 22px; border-radius: 50%; background: var(--color-accent);
color: white; font-size: var(--font-small); font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}}
.def-title {{ font-size: var(--font-body); font-weight: var(--weight-bold); }}
.def-desc {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.6; }}
.def-src {{ font-size: var(--font-caption); color: var(--color-text-light); font-style: italic; margin-top: 3px; }}
</style></head><body>
<div class="slide">
<div class="header">건설산업 DX의 올바른 이해</div>
<div class="body">
<div class="intro-bar">
<h3>현실 — 용어의 혼용</h3>
<p>건설산업에서 <strong>DX와 BIM이 동일 개념으로 인식</strong>되고 있다. DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다.</p>
<div class="cases-row">
<div class="case">
<div class="case-title">스마트 건설 활성화 방안 (2022.07)</div>
<div class="case-text">추진과제: 건설산업 디지털화<br>실행과제: BIM 전면 도입, BIM 전문인력 양성</div>
</div>
<div class="case">
<div class="case-title">제7차 건설기술진흥 기본계획 (2023.12)</div>
<div class="case-text">추진방향: 디지털 전환을 통한 스마트 건설 확산<br>추진과제: BIM 도입으로 건설산업 디지털화</div>
</div>
</div>
</div>
<div class="core-title">DX와 핵심기술의 올바른 관계</div>
<div class="dx-container">
<div class="dx-badge">DX — 디지털 전환 (상위개념)</div>
<div class="dx-desc">BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능</div>
<div class="tech-grid">
<div class="tech">
<div class="tech-circle">G</div>
<b>GIS</b>
<span>지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</span>
</div>
<div class="tech">
<div class="tech-circle">B</div>
<b>BIM</b>
<span>시설물 생애주기 정보를 3차원 모델 기반으로 통합·관리하는 도구</span>
</div>
<div class="tech">
<div class="tech-circle">T</div>
<b>디지털 트윈</b>
<span>현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현</span>
</div>
</div>
</div>
<div class="key-msg">
<p><em>BIM ≠ DX</em> — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다</p>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">용어 정의</div>
<div class="def">
<div class="def-head"><span class="def-num">1</span><span class="def-title">건설산업</span></div>
<div class="def-desc">부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업</div>
</div>
<div class="def">
<div class="def-head"><span class="def-num">2</span><span class="def-title">BIM</span></div>
<div class="def-desc">형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="def-src">건설산업 BIM 기본지침, 국토교통부, 2020</div>
</div>
<div class="def">
<div class="def-head"><span class="def-num">3</span><span class="def-title">DX (디지털 전환)</span></div>
<div class="def-desc">디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 산업의 새로운 방향을 정립</div>
<div class="def-src">IBM Institute for Business Value, 2011</div>
</div>
</div>
<div class="footer">
<div class="footer-text">BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다</div>
<div class="footer-sub">각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요</div>
</div>
</div>
</body></html>"""
# ═══════════════════════════════════════
# 접근 B: 레이아웃 프리미티브 조합
# 15개 기본 요소 중 선택하여 조합
# ═══════════════════════════════════════
APPROACH_B_HTML = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 B</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
/* 접근 B: 프리미티브 조합 — callout + comparison-table + card-row + definition-list */
.prim-callout {{
background: linear-gradient(135deg, var(--color-bg-dark), var(--color-bg-dark-deep));
border-radius: var(--radius); padding: 12px 18px; color: var(--color-text-on-dark);
}}
.prim-callout-title {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: var(--color-accent-light); margin-bottom: 4px; }}
.prim-callout-text {{ font-size: var(--font-small); line-height: 1.6; }}
.prim-callout-text strong {{ color: var(--color-warning); }}
.prim-compare {{
display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
border-radius: var(--radius); overflow: hidden; margin-top: var(--spacing-small);
}}
.prim-compare-head {{
font-size: var(--font-small); font-weight: var(--weight-bold);
padding: 6px 10px; text-align: center;
}}
.prim-compare-head.left {{ background: var(--color-accent); color: white; }}
.prim-compare-head.right {{ background: #475569; color: white; }}
.prim-compare-row {{ display: contents; }}
.prim-compare-cell {{
font-size: var(--font-caption); padding: 5px 10px;
background: var(--color-bg-subtle); border-bottom: 1px solid var(--color-border);
line-height: 1.5;
}}
.prim-compare-cell.left {{ color: var(--color-accent); font-weight: var(--weight-medium); }}
.prim-section-title {{
font-size: var(--font-section); font-weight: var(--weight-black);
color: var(--color-accent); text-align: center; padding: 4px 0;
}}
.prim-card-row {{
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-inner);
flex: 1;
}}
.prim-card {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px; text-align: center;
display: flex; flex-direction: column; align-items: center;
}}
.prim-card-icon {{
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
color: white; font-size: 15px; font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center; margin-bottom: 4px;
}}
.prim-card b {{ font-size: var(--font-body); }}
.prim-card span {{ font-size: var(--font-caption); color: var(--color-text-secondary); line-height: 1.4; margin-top: 2px; }}
.prim-highlight {{
background: #fef2f2; border: 2px solid #fecaca; border-radius: var(--radius);
padding: 8px 14px; text-align: center;
}}
.prim-highlight p {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: var(--color-danger); }}
.sidebar-label {{ display: flex; align-items: center; gap: 10px; font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light); }}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.prim-deflist {{ display: flex; flex-direction: column; gap: var(--spacing-inner); }}
.prim-def {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px 12px;
}}
.prim-def-head {{ display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }}
.prim-def-num {{
width: 20px; height: 20px; border-radius: 50%; background: var(--color-accent);
color: white; font-size: 10px; font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}}
.prim-def-title {{ font-size: var(--font-body); font-weight: var(--weight-bold); }}
.prim-def-desc {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.5; }}
.prim-def-src {{ font-size: var(--font-caption); color: var(--color-text-light); font-style: italic; margin-top: 2px; }}
</style></head><body>
<div class="slide">
<div class="header">건설산업 DX의 올바른 이해</div>
<div class="body">
<!-- 프리미티브 1: callout (문제 제기) -->
<div class="prim-callout">
<div class="prim-callout-title">현실 — 용어의 혼용</div>
<div class="prim-callout-text">건설산업에서 <strong>DX와 BIM이 동일 개념으로 인식</strong>되고 있다. DX는 상위개념이며 BIM은 하위 기술에 해당한다.</div>
<!-- 프리미티브 2: compare (사례 비교) -->
<div class="prim-compare">
<div class="prim-compare-head left">스마트건설 활성화 방안 (2022.07)</div>
<div class="prim-compare-head right">제7차 건설기술진흥 기본계획 (2023.12)</div>
<div class="prim-compare-cell left">추진과제: 건설산업 디지털화</div>
<div class="prim-compare-cell">추진방향: 디지털 전환을 통한 스마트 건설 확산</div>
<div class="prim-compare-cell left">실행과제: BIM 전면 도입, 전문인력 양성</div>
<div class="prim-compare-cell">추진과제: BIM 도입으로 건설산업 디지털화</div>
</div>
</div>
<!-- 프리미티브 3: section-title -->
<div class="prim-section-title">DX와 핵심기술의 올바른 관계</div>
<!-- 프리미티브 4: card-row (기술 카드 3열) -->
<div class="prim-card-row">
<div class="prim-card">
<div class="prim-card-icon">G</div>
<b>GIS</b>
<span>지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</span>
</div>
<div class="prim-card">
<div class="prim-card-icon">B</div>
<b>BIM</b>
<span>시설물 생애주기 정보를 3차원 모델 기반으로 통합·관리하는 도구</span>
</div>
<div class="prim-card">
<div class="prim-card-icon">T</div>
<b>디지털 트윈</b>
<span>현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현</span>
</div>
</div>
<!-- 프리미티브 5: highlight (핵심 메시지) -->
<div class="prim-highlight">
<p>BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다</p>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">용어 정의</div>
<div class="prim-deflist">
<div class="prim-def">
<div class="prim-def-head"><span class="prim-def-num">1</span><span class="prim-def-title">건설산업</span></div>
<div class="prim-def-desc">부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업</div>
</div>
<div class="prim-def">
<div class="prim-def-head"><span class="prim-def-num">2</span><span class="prim-def-title">BIM</span></div>
<div class="prim-def-desc">형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="prim-def-src">건설산업 BIM 기본지침, 국토교통부, 2020</div>
</div>
<div class="prim-def">
<div class="prim-def-head"><span class="prim-def-num">3</span><span class="prim-def-title">DX (디지털 전환)</span></div>
<div class="prim-def-desc">디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 산업의 새로운 방향을 정립</div>
<div class="prim-def-src">IBM Institute for Business Value, 2011</div>
</div>
</div>
</div>
<div class="footer">
<div class="footer-text">BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다</div>
<div class="footer-sub">각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요</div>
</div>
</div>
</body></html>"""
# ═══════════════════════════════════════
# 접근 C: 참조 기반 생성
# ideal_v2의 구조를 참조하되, 디자인 토큰으로 스타일 통일
# + 포함관계를 더 명확하게 (DX 큰 원 안에 3개)
# ═══════════════════════════════════════
APPROACH_C_HTML = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 C</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
/* 접근 C: 참조(ideal_v2) 기반 + 디자인 토큰 통일 */
.ref-problem {{
background: linear-gradient(135deg, var(--color-bg-dark), var(--color-bg-dark-deep));
border-radius: var(--radius); padding: 14px 20px; color: var(--color-text-on-dark);
}}
.ref-problem h3 {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: var(--color-accent-light); margin-bottom: var(--spacing-small); }}
.ref-problem p {{ font-size: var(--font-body); line-height: 1.7; }}
.ref-problem strong {{ color: var(--color-warning); }}
.ref-cases {{ display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-inner); margin-top: var(--spacing-inner); }}
.ref-case {{ background: rgba(255,255,255,0.07); border-radius: var(--radius-small); padding: 8px 12px; border-left: 3px solid var(--color-accent-light); }}
.ref-case-title {{ font-size: var(--font-small); font-weight: var(--weight-bold); color: var(--color-accent-light); margin-bottom: 3px; }}
.ref-case-text {{ font-size: var(--font-small); color: var(--color-text-light); line-height: 1.5; }}
.ref-core-title {{ font-size: var(--font-section); font-weight: var(--weight-black); color: var(--color-accent); text-align: center; }}
/* 포함관계: SVG 기반 벤 다이어그램 스타일 */
.ref-hierarchy {{
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
position: relative;
}}
.ref-dx-ring {{
width: 100%; max-width: 600px;
border: 3px solid var(--color-accent); border-radius: 20px;
padding: 20px 16px 14px; position: relative;
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
}}
.ref-dx-tag {{
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
background: var(--color-accent); color: white;
font-size: 12px; font-weight: var(--weight-black);
padding: 3px 20px; border-radius: 12px; white-space: nowrap;
}}
.ref-dx-sub {{
text-align: center; font-size: var(--font-small); color: #1e40af;
margin-bottom: var(--spacing-inner);
}}
.ref-techs {{
display: flex; justify-content: center; gap: 16px;
}}
.ref-tech {{
width: 130px; text-align: center;
}}
.ref-tech-bubble {{
width: 50px; height: 50px; border-radius: 50%;
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
color: white; font-size: 20px; font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center;
margin: 0 auto 6px; box-shadow: 0 2px 8px rgba(37,99,235,0.3);
}}
.ref-tech b {{ display: block; font-size: var(--font-body); color: var(--color-primary); margin-bottom: 2px; }}
.ref-tech span {{ font-size: var(--font-caption); color: var(--color-text-secondary); line-height: 1.4; }}
.ref-arrow {{
text-align: center; font-size: 12px; color: var(--color-accent);
font-weight: var(--weight-bold); margin: 4px 0;
}}
.ref-msg {{
background: #f0f9ff; border: 2px solid #bae6fd;
border-radius: var(--radius); padding: 10px 16px; text-align: center;
margin-top: var(--spacing-small);
}}
.ref-msg p {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: #0c4a6e; }}
.ref-msg em {{ color: var(--color-danger); font-style: normal; font-weight: var(--weight-black); }}
.sidebar-label {{ display: flex; align-items: center; gap: 10px; font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light); }}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.ref-def {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px 12px;
}}
.ref-def-head {{ display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }}
.ref-def-num {{
width: 22px; height: 22px; border-radius: 50%; background: var(--color-accent);
color: white; font-size: var(--font-small); font-weight: var(--weight-black);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}}
.ref-def-title {{ font-size: var(--font-body); font-weight: var(--weight-bold); }}
.ref-def-desc {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.6; }}
.ref-def-src {{ font-size: var(--font-caption); color: var(--color-text-light); font-style: italic; margin-top: 3px; }}
</style></head><body>
<div class="slide">
<div class="header">건설산업 DX의 올바른 이해</div>
<div class="body">
<div class="ref-problem">
<h3>현실 — 용어의 혼용</h3>
<p>건설산업에서 <strong>DX와 BIM이 동일 개념으로 인식</strong>되고 있다. DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 하위 기술에 해당한다.</p>
<div class="ref-cases">
<div class="ref-case">
<div class="ref-case-title">스마트 건설 활성화 방안 (2022.07)</div>
<div class="ref-case-text">추진과제: 건설산업 디지털화<br>실행과제: BIM 전면 도입, BIM 전문인력 양성</div>
</div>
<div class="ref-case">
<div class="ref-case-title">제7차 건설기술진흥 기본계획 (2023.12)</div>
<div class="ref-case-text">추진방향: 디지털 전환을 통한 스마트 건설 확산<br>추진과제: BIM 도입으로 건설산업 디지털화</div>
</div>
</div>
</div>
<div class="ref-core-title">DX와 핵심기술의 올바른 관계</div>
<div class="ref-hierarchy">
<div class="ref-dx-ring">
<div class="ref-dx-tag">DX — 디지털 전환 (상위개념)</div>
<div class="ref-dx-sub">업무방식과 가치 창출 구조를 근본적으로 전환하는 과정</div>
<div class="ref-techs">
<div class="ref-tech">
<div class="ref-tech-bubble">G</div>
<b>GIS</b>
<span>지리적 데이터를 공간 분석, 위치기반 정보 제공</span>
</div>
<div class="ref-tech">
<div class="ref-tech-bubble">B</div>
<b>BIM</b>
<span>시설물 생애주기 정보를 3차원 모델로 통합·관리</span>
</div>
<div class="ref-tech">
<div class="ref-tech-bubble">T</div>
<b>디지털 트윈</b>
<span>현실 객체를 디지털 환경에 동일하게 구현</span>
</div>
</div>
</div>
</div>
<div class="ref-msg">
<p><em>BIM ≠ DX</em> — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다</p>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">용어 정의</div>
<div class="ref-def">
<div class="ref-def-head"><span class="ref-def-num">1</span><span class="ref-def-title">건설산업</span></div>
<div class="ref-def-desc">부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업</div>
</div>
<div class="ref-def">
<div class="ref-def-head"><span class="ref-def-num">2</span><span class="ref-def-title">BIM</span></div>
<div class="ref-def-desc">형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="ref-def-src">건설산업 BIM 기본지침, 국토교통부, 2020</div>
</div>
<div class="ref-def">
<div class="ref-def-head"><span class="ref-def-num">3</span><span class="ref-def-title">DX (디지털 전환)</span></div>
<div class="ref-def-desc">디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 산업의 새로운 방향을 정립</div>
<div class="ref-def-src">IBM Institute for Business Value, 2011</div>
</div>
</div>
<div class="footer">
<div class="footer-text">BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다</div>
<div class="footer-sub">각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요</div>
</div>
</div>
</body></html>"""
async def main():
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / "3approaches"
out_dir.mkdir(parents=True, exist_ok=True)
for name, html in [("A_fewshot", APPROACH_A_HTML), ("B_primitives", APPROACH_B_HTML), ("C_reference", APPROACH_C_HTML)]:
print(f"\n=== 접근 {name} ===")
m = await asyncio.to_thread(measure_rendered_heights, html)
s = await asyncio.to_thread(capture_slide_screenshot, html)
(out_dir / f"{name}.html").write_text(html, encoding="utf-8")
if s:
(out_dir / f"{name}.png").write_bytes(base64.b64decode(s))
slide = m.get("slide", {})
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px {'' if not slide.get('overflowed') else ''}")
print(f"\n결과물: {out_dir}")
print(" A_fewshot.png — 접근 A: Claude가 디자인 토큰 안에서 자유 생성")
print(" B_primitives.png — 접근 B: 15개 프리미티브 조합")
print(" C_reference.png — 접근 C: ideal_v2 참조 기반 + 더 큰 포함관계 시각화")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,474 @@
"""3가지 접근법 비교 — 콘텐츠 2: DX 시행 목표 및 기대 효과
이전 콘텐츠(포함 관계)와 성격이 다름:
- 목표 3가지 (안전/품질, 생산성, 소통/신뢰)
- 프로세스 변화 4가지 (생산방식, 인지검토, 협업구조, 검증대응)
- 주체별 기대효과 (DxEffect 컴포넌트 — 텍스트로 대체)
- 핵심 결론 1줄
"""
from __future__ import annotations
import asyncio, json, sys, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
DESIGN_TOKENS_CSS = """
:root {
--color-primary: #1e293b;
--color-accent: #2563eb;
--color-accent-light: #93c5fd;
--color-bg: #ffffff;
--color-bg-subtle: #f8fafc;
--color-bg-dark: #1e293b;
--color-bg-dark-deep: #0f172a;
--color-border: #e2e8f0;
--color-danger: #dc2626;
--color-success: #16a34a;
--color-warning: #f59e0b;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-light: #94a3b8;
--color-text-on-dark: #e2e8f0;
--color-text-on-accent: #ffffff;
--font-title: 28px;
--font-section: 14px;
--font-body: 13px;
--font-small: 11px;
--font-caption: 10px;
--weight-normal: 400;
--weight-medium: 500;
--weight-bold: 700;
--weight-black: 900;
--spacing-page: 36px 40px 24px;
--spacing-section: 16px;
--spacing-block: 12px;
--spacing-inner: 10px;
--spacing-small: 6px;
--radius: 8px;
--radius-small: 6px;
--line-height: 1.6;
}
"""
SLIDE_BASE_CSS = """
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
.slide {
width: 1280px; height: 720px; overflow: hidden;
background: var(--color-bg);
font-family: 'Pretendard Variable', sans-serif;
color: var(--color-text);
font-size: var(--font-body);
line-height: var(--line-height);
word-break: keep-all;
display: grid;
grid-template-areas: 'header header' 'body sidebar' 'footer footer';
grid-template-columns: 65fr 35fr;
grid-template-rows: auto 1fr auto;
gap: var(--spacing-section);
padding: var(--spacing-page);
}
.header { grid-area: header; font-size: var(--font-title); font-weight: var(--weight-black); color: var(--color-primary); border-bottom: 3px solid var(--color-accent); padding-bottom: 8px; }
.body { grid-area: body; display: flex; flex-direction: column; gap: var(--spacing-block); overflow: hidden; }
.sidebar { grid-area: sidebar; display: flex; flex-direction: column; gap: var(--spacing-block); border-left: 1px solid var(--color-border); padding-left: 20px; overflow: hidden; }
.footer { grid-area: footer; background: linear-gradient(135deg, #006aff, #00aaff); border-radius: var(--radius); padding: 14px 30px; text-align: center; color: var(--color-text-on-accent); }
.footer-text { font-size: 15px; font-weight: var(--weight-bold); }
.footer-sub { font-size: var(--font-small); opacity: 0.85; margin-top: 2px; }
"""
# ═══════════════════════════════════════
# 접근 A: Few-Shot 직접 생성
# ═══════════════════════════════════════
APPROACH_A = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 A — DX 목표</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
.goal-cards {{ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-inner); }}
.goal {{
border-radius: var(--radius); padding: 12px; text-align: center;
display: flex; flex-direction: column; align-items: center;
}}
.goal-1 {{ background: linear-gradient(135deg, #eff6ff, #dbeafe); border: 2px solid var(--color-accent-light); }}
.goal-2 {{ background: linear-gradient(135deg, #f0fdf4, #dcfce7); border: 2px solid #86efac; }}
.goal-3 {{ background: linear-gradient(135deg, #fefce8, #fef9c3); border: 2px solid #fde047; }}
.goal-icon {{ font-size: 24px; margin-bottom: 4px; }}
.goal-title {{ font-size: var(--font-body); font-weight: var(--weight-black); margin-bottom: 4px; }}
.goal-desc {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.5; }}
.section-title {{ font-size: var(--font-section); font-weight: var(--weight-black); color: var(--color-accent); margin-bottom: 2px; }}
.process-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; flex: 1; }}
.process-item {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px 12px;
display: flex; gap: 10px; align-items: flex-start;
}}
.process-arrow {{
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: var(--color-accent); font-weight: var(--weight-black);
width: 28px; flex-shrink: 0;
}}
.process-content {{ flex: 1; }}
.process-label {{ font-size: var(--font-small); font-weight: var(--weight-bold); color: var(--color-accent); margin-bottom: 2px; }}
.process-before {{ font-size: var(--font-caption); color: var(--color-text-light); text-decoration: line-through; }}
.process-after {{ font-size: var(--font-small); color: var(--color-text); font-weight: var(--weight-medium); margin-top: 2px; }}
.sidebar-label {{ display: flex; align-items: center; gap: 10px; font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light); }}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.effect-table {{ width: 100%; border-collapse: collapse; font-size: var(--font-small); flex: 1; }}
.effect-table th {{ background: var(--color-accent); color: white; padding: 6px 8px; text-align: left; font-weight: var(--weight-bold); font-size: var(--font-small); }}
.effect-table td {{ padding: 5px 8px; border-bottom: 1px solid var(--color-border); line-height: 1.5; font-size: var(--font-caption); }}
.effect-table tr:nth-child(even) {{ background: var(--color-bg-subtle); }}
.effect-role {{ font-weight: var(--weight-bold); color: var(--color-accent); white-space: nowrap; }}
</style></head><body>
<div class="slide">
<div class="header">DX 시행 목표 및 기대 효과</div>
<div class="body">
<div class="section-title">DX를 통한 궁극적 목표</div>
<div class="goal-cards">
<div class="goal goal-1">
<div class="goal-icon">🛡️</div>
<div class="goal-title">안전과 품질</div>
<div class="goal-desc">설계-시공-운영 전 과정에서 디지털로 검증하여 안전성 확보. 하자 최소화로 고품질 성과물 제공</div>
</div>
<div class="goal goal-2">
<div class="goal-icon">⚡</div>
<div class="goal-title">생산성 향상</div>
<div class="goal-desc">Analogue → Digital 프로세스 전환. 비용 절감, 기간 단축, 인력투입 최소화로 부가가치 제고</div>
</div>
<div class="goal goal-3">
<div class="goal-icon">🤝</div>
<div class="goal-title">소통과 신뢰</div>
<div class="goal-desc">협업 강화로 의사소통 효율 증진. 3D 모델·데이터 기반 검증으로 오류 최소화 및 Claim 예방</div>
</div>
</div>
<div class="section-title">업무 수행 과정(Process)의 변화</div>
<div class="process-grid">
<div class="process-item">
<div class="process-arrow">→</div>
<div class="process-content">
<div class="process-label">생산 방식</div>
<div class="process-before">수작업 의존의 반복 업무</div>
<div class="process-after">SW를 활용한 체계화된 방식으로 전환</div>
</div>
</div>
<div class="process-item">
<div class="process-arrow">→</div>
<div class="process-content">
<div class="process-label">인지·검토</div>
<div class="process-before">2D 도면 해석 중심</div>
<div class="process-after">3D 모델 기반의 직관적 인지·검토 체계</div>
</div>
</div>
<div class="process-item">
<div class="process-arrow">→</div>
<div class="process-content">
<div class="process-label">협업 구조</div>
<div class="process-before">개별 문서 중심 협업</div>
<div class="process-after">데이터 통합 기반의 정보 공유·관리 환경</div>
</div>
</div>
<div class="process-item">
<div class="process-arrow">→</div>
<div class="process-content">
<div class="process-label">검증·대응</div>
<div class="process-before">사후 대응 중심의 문제 처리</div>
<div class="process-after">사전 검증 중심의 예방적 업무 방식</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">주체별 기대효과</div>
<table class="effect-table">
<tr><th>주체</th><th>기대효과</th></tr>
<tr><td class="effect-role">발주처</td><td>품질 향상, 비용·기간 절감, 투명한 관리</td></tr>
<tr><td class="effect-role">설계사</td><td>오류 감소, 설계 품질 제고, 재작업 최소화</td></tr>
<tr><td class="effect-role">시공사</td><td>공정 최적화, 안전 강화, 현장 생산성 향상</td></tr>
<tr><td class="effect-role">감리·CM</td><td>실시간 모니터링, 데이터 기반 의사결정</td></tr>
<tr><td class="effect-role">유지관리</td><td>디지털 트윈 기반 예방 정비, 자산 관리 효율화</td></tr>
</table>
</div>
<div class="footer">
<div class="footer-text">고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다</div>
</div>
</div>
</body></html>"""
# ═══════════════════════════════════════
# 접근 B: 프리미티브 조합
# ═══════════════════════════════════════
APPROACH_B = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 B — DX 목표</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
/* 프리미티브: icon-card-row */
.p-icon-row {{ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-inner); }}
.p-icon-card {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 10px; text-align: center;
}}
.p-icon-card .icon {{ font-size: 22px; margin-bottom: 4px; }}
.p-icon-card b {{ font-size: var(--font-body); display: block; margin-bottom: 3px; }}
.p-icon-card span {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.5; }}
/* 프리미티브: section-title */
.p-sec {{ font-size: var(--font-section); font-weight: var(--weight-black); color: var(--color-accent); }}
/* 프리미티브: bullet-list */
.p-bullets {{ display: flex; flex-direction: column; gap: 4px; flex: 1; }}
.p-bullet {{
font-size: var(--font-small); line-height: 1.6; padding-left: 14px; position: relative;
}}
.p-bullet::before {{ content: ''; position: absolute; left: 0; color: var(--color-accent); font-weight: var(--weight-bold); }}
.p-bullet strong {{ color: var(--color-accent); }}
/* 프리미티브: sidebar table */
.sidebar-label {{ display: flex; align-items: center; gap: 10px; font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light); }}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.p-table {{ width: 100%; border-collapse: collapse; font-size: var(--font-small); }}
.p-table th {{ background: var(--color-accent); color: white; padding: 6px 8px; text-align: left; font-size: var(--font-small); }}
.p-table td {{ padding: 5px 8px; border-bottom: 1px solid var(--color-border); font-size: var(--font-caption); line-height: 1.5; }}
.p-table tr:nth-child(even) {{ background: var(--color-bg-subtle); }}
.p-table .role {{ font-weight: var(--weight-bold); color: var(--color-accent); }}
</style></head><body>
<div class="slide">
<div class="header">DX 시행 목표 및 기대 효과</div>
<div class="body">
<div class="p-sec">DX를 통한 궁극적 목표</div>
<div class="p-icon-row">
<div class="p-icon-card">
<div class="icon">🛡️</div>
<b>안전과 품질</b>
<span>디지털 검증으로 안전성 확보, 하자 최소화로 고품질 성과물</span>
</div>
<div class="p-icon-card">
<div class="icon">⚡</div>
<b>생산성 향상</b>
<span>Digital 프로세스 전환, 비용 절감·기간 단축·부가가치 제고</span>
</div>
<div class="p-icon-card">
<div class="icon">🤝</div>
<b>소통과 신뢰</b>
<span>협업 강화, 3D·데이터 기반 검증으로 오류 최소화·Claim 예방</span>
</div>
</div>
<div class="p-sec">업무 수행 과정(Process)의 변화</div>
<div class="p-bullets">
<div class="p-bullet"><strong>생산 방식</strong>: 수작업 의존 반복 업무 → SW를 활용한 체계화된 방식으로 전환</div>
<div class="p-bullet"><strong>인지·검토</strong>: 2D 도면 해석 중심 → 3D 모델 기반의 직관적 인지·검토 체계로 전환</div>
<div class="p-bullet"><strong>협업 구조</strong>: 개별 문서 중심 → 데이터 통합 기반의 정보 공유·관리 협업 환경으로 전환</div>
<div class="p-bullet"><strong>검증·대응</strong>: 사후 대응 중심 → 사전 검증 중심의 예방적 업무 방식으로 전환</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">주체별 기대효과</div>
<table class="p-table">
<tr><th>주체</th><th>기대효과</th></tr>
<tr><td class="role">발주처</td><td>품질 향상, 비용·기간 절감, 투명한 관리</td></tr>
<tr><td class="role">설계사</td><td>오류 감소, 설계 품질 제고, 재작업 최소화</td></tr>
<tr><td class="role">시공사</td><td>공정 최적화, 안전 강화, 현장 생산성 향상</td></tr>
<tr><td class="role">감리·CM</td><td>실시간 모니터링, 데이터 기반 의사결정</td></tr>
<tr><td class="role">유지관리</td><td>디지털 트윈 기반 예방 정비, 자산 관리 효율화</td></tr>
</table>
</div>
<div class="footer">
<div class="footer-text">고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다</div>
</div>
</div>
</body></html>"""
# ═══════════════════════════════════════
# 접근 C: 참조 기반 생성
# ideal_v2의 디자인 패턴(다크배경+포함박스+사이드바 정의) 참조하되
# 이 콘텐츠에 맞게 구조 변형
# ═══════════════════════════════════════
APPROACH_C = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>접근 C — DX 목표</title>
<style>{DESIGN_TOKENS_CSS}{SLIDE_BASE_CSS}
/* 참조 패턴: 상단 다크 배경 요약 */
.ref-summary {{
background: linear-gradient(135deg, var(--color-bg-dark), var(--color-bg-dark-deep));
border-radius: var(--radius); padding: 14px 20px; color: var(--color-text-on-dark);
}}
.ref-summary h3 {{ font-size: var(--font-body); font-weight: var(--weight-bold); color: var(--color-accent-light); margin-bottom: var(--spacing-small); }}
.ref-goals {{
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-inner); margin-top: var(--spacing-inner);
}}
.ref-goal {{
background: rgba(255,255,255,0.07); border-radius: var(--radius-small);
padding: 8px 10px; text-align: center;
}}
.ref-goal-icon {{ font-size: 20px; margin-bottom: 2px; }}
.ref-goal-title {{ font-size: var(--font-small); font-weight: var(--weight-bold); color: var(--color-accent-light); }}
.ref-goal-desc {{ font-size: var(--font-caption); color: var(--color-text-light); line-height: 1.5; margin-top: 2px; }}
/* 참조 패턴: 포함 박스 (DX 프레임 안에 4가지 변화) */
.ref-section-title {{ font-size: var(--font-section); font-weight: var(--weight-black); color: var(--color-accent); text-align: center; }}
.ref-dx-frame {{
flex: 1; border: 3px solid var(--color-accent); border-radius: 14px;
padding: 16px 14px 12px; background: linear-gradient(180deg, #eff6ff, #dbeafe);
position: relative; display: flex; flex-direction: column;
}}
.ref-dx-badge {{
position: absolute; top: -11px; left: 50%; transform: translateX(-50%);
background: var(--color-accent); color: white;
font-size: var(--font-small); font-weight: var(--weight-black);
padding: 2px 16px; border-radius: var(--radius-small); white-space: nowrap;
}}
.ref-dx-sub {{ text-align: center; font-size: var(--font-small); color: #1e40af; margin-bottom: var(--spacing-inner); }}
.ref-changes {{
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; flex: 1;
}}
.ref-change {{
background: white; border: 1px solid var(--color-accent-light);
border-radius: var(--radius); padding: 8px 10px;
}}
.ref-change-label {{ font-size: var(--font-small); font-weight: var(--weight-bold); color: var(--color-accent); margin-bottom: 3px; }}
.ref-change-from {{ font-size: var(--font-caption); color: var(--color-text-light); text-decoration: line-through; }}
.ref-change-to {{ font-size: var(--font-small); color: var(--color-text); font-weight: var(--weight-medium); margin-top: 2px; }}
/* 참조 패턴: 사이드바 테이블 */
.sidebar-label {{ display: flex; align-items: center; gap: 10px; font-size: var(--font-small); font-weight: var(--weight-medium); color: var(--color-text-light); }}
.sidebar-label::before, .sidebar-label::after {{ content: ''; flex: 1; height: 1px; background: var(--color-border); }}
.ref-effects {{ display: flex; flex-direction: column; gap: 6px; flex: 1; }}
.ref-effect {{
background: var(--color-bg-subtle); border: 1px solid var(--color-border);
border-radius: var(--radius); padding: 8px 10px;
display: flex; gap: 8px; align-items: flex-start;
}}
.ref-effect-role {{
background: var(--color-accent); color: white;
font-size: var(--font-caption); font-weight: var(--weight-bold);
padding: 2px 8px; border-radius: 4px; white-space: nowrap; flex-shrink: 0;
}}
.ref-effect-desc {{ font-size: var(--font-small); color: var(--color-text-secondary); line-height: 1.5; }}
</style></head><body>
<div class="slide">
<div class="header">DX 시행 목표 및 기대 효과</div>
<div class="body">
<!-- 참조 패턴 1: 다크 배경 요약 + 목표 3카드 -->
<div class="ref-summary">
<h3>DX를 통한 궁극적 목표</h3>
<div class="ref-goals">
<div class="ref-goal">
<div class="ref-goal-icon">🛡️</div>
<div class="ref-goal-title">안전과 품질</div>
<div class="ref-goal-desc">디지털 검증으로 안전성 확보<br>하자 최소화, 고품질 성과물</div>
</div>
<div class="ref-goal">
<div class="ref-goal-icon">⚡</div>
<div class="ref-goal-title">생산성 향상</div>
<div class="ref-goal-desc">Digital 프로세스 전환<br>비용 절감, 기간 단축, 부가가치 제고</div>
</div>
<div class="ref-goal">
<div class="ref-goal-icon">🤝</div>
<div class="ref-goal-title">소통과 신뢰</div>
<div class="ref-goal-desc">협업 강화, 의사소통 효율<br>데이터 검증으로 Claim 예방</div>
</div>
</div>
</div>
<!-- 참조 패턴 2: DX 프레임 안에 프로세스 변화 4가지 -->
<div class="ref-section-title">DX 기반 Process 혁신</div>
<div class="ref-dx-frame">
<div class="ref-dx-badge">업무 수행 과정의 변화</div>
<div class="ref-dx-sub">Analogue 기반 → Digital 기반 프로세스 전환</div>
<div class="ref-changes">
<div class="ref-change">
<div class="ref-change-label">생산 방식</div>
<div class="ref-change-from">수작업 의존의 반복 업무</div>
<div class="ref-change-to">SW를 활용한 체계화된 방식</div>
</div>
<div class="ref-change">
<div class="ref-change-label">인지·검토</div>
<div class="ref-change-from">2D 도면 해석 중심</div>
<div class="ref-change-to">3D 모델 기반의 직관적 인지·검토</div>
</div>
<div class="ref-change">
<div class="ref-change-label">협업 구조</div>
<div class="ref-change-from">개별 문서 중심 협업</div>
<div class="ref-change-to">데이터 통합 기반 정보 공유·관리</div>
</div>
<div class="ref-change">
<div class="ref-change-label">검증·대응</div>
<div class="ref-change-from">사후 대응 중심 문제 처리</div>
<div class="ref-change-to">사전 검증 중심 예방적 업무 방식</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">주체별 기대효과</div>
<div class="ref-effects">
<div class="ref-effect">
<span class="ref-effect-role">발주처</span>
<span class="ref-effect-desc">품질 향상, 비용·기간 절감, 투명한 관리</span>
</div>
<div class="ref-effect">
<span class="ref-effect-role">설계사</span>
<span class="ref-effect-desc">오류 감소, 설계 품질 제고, 재작업 최소화</span>
</div>
<div class="ref-effect">
<span class="ref-effect-role">시공사</span>
<span class="ref-effect-desc">공정 최적화, 안전 강화, 현장 생산성 향상</span>
</div>
<div class="ref-effect">
<span class="ref-effect-role">감리·CM</span>
<span class="ref-effect-desc">실시간 모니터링, 데이터 기반 의사결정</span>
</div>
<div class="ref-effect">
<span class="ref-effect-role">유지관리</span>
<span class="ref-effect-desc">디지털 트윈 기반 예방 정비, 자산 관리 효율화</span>
</div>
</div>
</div>
<div class="footer">
<div class="footer-text">고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다</div>
</div>
</div>
</body></html>"""
async def main():
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / "3approaches_dx2"
out_dir.mkdir(parents=True, exist_ok=True)
for name, html in [("A_fewshot", APPROACH_A), ("B_primitives", APPROACH_B), ("C_reference", APPROACH_C)]:
print(f"\n=== 접근 {name} ===")
m = await asyncio.to_thread(measure_rendered_heights, html)
s = await asyncio.to_thread(capture_slide_screenshot, html)
(out_dir / f"{name}.html").write_text(html, encoding="utf-8")
if s:
(out_dir / f"{name}.png").write_bytes(base64.b64decode(s))
slide = m.get("slide", {})
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px {'' if not slide.get('overflowed') else ''}")
print(f"\n결과물: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

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())

369
scripts/test_hybrid.py Normal file
View File

@@ -0,0 +1,369 @@
"""하이브리드 시뮬레이션: 기존 블록 활용 + 필요 시 변형/조합.
블록 사용 현황:
- card-icon-desc: 목표 3카드 ← 기존 블록 그대로
- dark-bullet-list: 변형 — 불릿 대신 Before→After 구조 (CSS만 추가)
- table-simple-striped: 주체별 효과 ← 기존 블록 그대로
- banner-gradient: 결론 ← 기존 블록 그대로
- 섹션 구분: divider-text 스타일 활용
블록 사용률: ~70% 기존 블록 + ~30% 변형/자유
"""
from __future__ import annotations
import asyncio, json, sys, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
HYBRID_HTML = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>하이브리드 — DX 시행 목표 및 기대 효과</title>
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
.slide {
width: 1280px; height: 720px; overflow: hidden;
background: #ffffff;
font-family: 'Pretendard Variable', sans-serif;
color: #1e293b;
font-size: 13px;
line-height: 1.6;
word-break: keep-all;
display: grid;
grid-template-areas: 'header header' 'body sidebar' 'footer footer';
grid-template-columns: 65fr 35fr;
grid-template-rows: auto 1fr auto;
gap: 16px;
padding: 36px 40px 24px;
}
/* ── 슬라이드 제목 (기존 base.css) ── */
.slide-title {
grid-area: header;
font-size: 28px;
font-weight: 900;
color: #1e293b;
border-bottom: 3px solid #2563eb;
padding-bottom: 8px;
}
/* ── Body ── */
.area-body {
grid-area: body;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
/* ── Sidebar ── */
.area-sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: 12px;
border-left: 1px solid #e2e8f0;
padding-left: 20px;
overflow: hidden;
}
/* ── Footer ── */
.area-footer {
grid-area: footer;
}
/* ════════════════════════════════════════
블록 1: card-icon-desc (기존 블록 100% 재사용)
목표 3카드
════════════════════════════════════════ */
.block-card-icon {
display: grid;
grid-template-columns: repeat(var(--ci-count, 3), 1fr);
gap: 16px;
}
.cid-card {
text-align: center;
padding: 14px 12px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.cid-icon {
font-size: 2rem;
margin-bottom: 6px;
}
.cid-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin-bottom: 4px;
}
.cid-desc {
font-size: 11px;
color: #475569;
line-height: 1.6;
white-space: pre-line;
}
/* ════════════════════════════════════════
블록 2: dark-bullet-list 변형 — Before→After 구조
기존 dark-bullet-list의 색상/배경/radius 그대로 사용
불릿 대신 label + before + after 구조로 변형
════════════════════════════════════════ */
.block-dark-bullets {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-radius: 8px;
padding: 14px 20px;
color: #ffffff;
}
.db-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
color: #93c5fd;
}
/* 변형: Before→After 그리드 */
.db-changes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.db-change {
background: rgba(255,255,255,0.06);
border-radius: 6px;
padding: 8px 10px;
border-left: 3px solid #60a5fa;
}
.db-change-label {
font-size: 11px;
font-weight: 700;
color: #93c5fd;
margin-bottom: 3px;
}
.db-change-before {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
}
.db-change-after {
font-size: 11px;
color: #e2e8f0;
font-weight: 500;
margin-top: 2px;
}
/* ════════════════════════════════════════
블록 3: divider-text (기존 블록 100% 재사용)
════════════════════════════════════════ */
.block-divider-text {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.dt-line {
flex: 1;
height: 1px;
background: #cbd5e1;
}
.dt-text {
font-size: 13px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
/* ════════════════════════════════════════
블록 4: table-simple-striped (기존 블록 100% 재사용)
주체별 기대효과
════════════════════════════════════════ */
.block-table-striped table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
line-height: 1.6;
}
.block-table-striped thead th {
background: #1e293b;
color: #ffffff;
font-weight: 700;
padding: 8px 12px;
text-align: left;
font-size: 12px;
}
.block-table-striped tbody td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
white-space: pre-line;
color: #334155;
font-size: 11px;
}
.block-table-striped tbody tr:nth-child(even) {
background: #f8fafc;
}
.block-table-striped tbody td:first-child {
font-weight: 600;
color: #1e293b;
}
/* ════════════════════════════════════════
블록 5: banner-gradient (기존 블록 100% 재사용)
결론
════════════════════════════════════════ */
.block-banner-grad {
background: linear-gradient(135deg, #006aff 0%, #00aaff 100%);
border-radius: 8px;
padding: 14px 30px;
text-align: center;
color: #ffffff;
}
.bg-text {
font-size: 15px;
font-weight: 700;
line-height: 1.5;
}
/* ════════════════════════════════════════
섹션 타이틀 (기존 디자인 토큰 활용)
════════════════════════════════════════ */
.section-label {
font-size: 13px;
font-weight: 900;
color: #2563eb;
}
</style>
</head>
<body>
<div class="slide">
<div class="slide-title">DX 시행 목표 및 기대 효과</div>
<div class="area-body">
<!-- 블록 1: card-icon-desc (기존 블록 그대로) → 목표 3카드 -->
<div class="section-label">DX를 통한 궁극적 목표</div>
<div class="block-card-icon" style="--ci-count: 3">
<div class="cid-card">
<div class="cid-icon">🛡️</div>
<div class="cid-title">안전과 품질</div>
<div class="cid-desc">설계-시공-운영 전 과정에서 디지털로 검증하여 안전성 확보
하자 최소화로 고품질 성과물 제공</div>
</div>
<div class="cid-card">
<div class="cid-icon">⚡</div>
<div class="cid-title">생산성 향상</div>
<div class="cid-desc">Analogue → Digital 프로세스 전환
비용 절감, 기간 단축, 인력투입 최소화로 부가가치 제고</div>
</div>
<div class="cid-card">
<div class="cid-icon">🤝</div>
<div class="cid-title">소통과 신뢰</div>
<div class="cid-desc">협업 강화로 의사소통 효율 증진
3D 모델·데이터 기반 검증으로 오류 최소화 및 Claim 예방</div>
</div>
</div>
<!-- 블록 2: dark-bullet-list 변형 → 프로세스 변화 4가지 (Before→After) -->
<div class="block-dark-bullets">
<div class="db-title">업무 수행 과정(Process)의 변화</div>
<div class="db-changes">
<div class="db-change">
<div class="db-change-label">생산 방식</div>
<div class="db-change-before">수작업 의존의 반복 업무</div>
<div class="db-change-after">→ SW를 활용한 체계화된 방식으로 전환</div>
</div>
<div class="db-change">
<div class="db-change-label">인지·검토</div>
<div class="db-change-before">2D 도면 해석 중심</div>
<div class="db-change-after">→ 3D 모델 기반의 직관적 인지·검토 체계</div>
</div>
<div class="db-change">
<div class="db-change-label">협업 구조</div>
<div class="db-change-before">개별 문서 중심 협업</div>
<div class="db-change-after">→ 데이터 통합 기반 정보 공유·관리 환경</div>
</div>
<div class="db-change">
<div class="db-change-label">검증·대응</div>
<div class="db-change-before">사후 대응 중심 문제 처리</div>
<div class="db-change-after">→ 사전 검증 중심의 예방적 업무 방식</div>
</div>
</div>
</div>
</div>
<div class="area-sidebar">
<!-- 블록 3: divider-text (기존 블록 그대로) -->
<div class="block-divider-text">
<div class="dt-line"></div>
<div class="dt-text">주체별 기대효과</div>
<div class="dt-line"></div>
</div>
<!-- 블록 4: table-simple-striped (기존 블록 그대로) -->
<div class="block-table-striped">
<table>
<thead>
<tr><th>주체</th><th>기대효과</th></tr>
</thead>
<tbody>
<tr><td>발주처</td><td>품질 향상, 비용·기간 절감, 투명한 관리</td></tr>
<tr><td>설계사</td><td>오류 감소, 설계 품질 제고, 재작업 최소화</td></tr>
<tr><td>시공사</td><td>공정 최적화, 안전 강화, 현장 생산성 향상</td></tr>
<tr><td>감리·CM</td><td>실시간 모니터링, 데이터 기반 의사결정</td></tr>
<tr><td>유지관리</td><td>디지털 트윈 기반 예방 정비, 자산 관리 효율화</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 블록 5: banner-gradient (기존 블록 그대로) → 결론 -->
<div class="area-footer">
<div class="block-banner-grad">
<div class="bg-text">고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다</div>
</div>
</div>
</div>
</body>
</html>"""
async def main():
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / "hybrid_simulation"
out_dir.mkdir(parents=True, exist_ok=True)
m = await asyncio.to_thread(measure_rendered_heights, HYBRID_HTML)
s = await asyncio.to_thread(capture_slide_screenshot, HYBRID_HTML)
(out_dir / "hybrid.html").write_text(HYBRID_HTML, encoding="utf-8")
if s:
(out_dir / "hybrid_screenshot.png").write_bytes(base64.b64decode(s))
slide = m.get("slide", {})
print(f"slide: {slide.get('scrollHeight', 0)}px / 720px {'' if not slide.get('overflowed') else ''}")
print(f"""
블록 사용 현황:
card-icon-desc → 목표 3카드 (기존 블록 100%)
dark-bullet-list → 프로세스 변화 (기존 색상/구조 + Before→After 변형)
divider-text → 섹션 구분 (기존 블록 100%)
table-simple-striped → 주체별 기대효과 (기존 블록 100%)
banner-gradient → 결론 (기존 블록 100%)
블록 활용률: 4/5 기존 블록 그대로 + 1/5 변형
결과: {out_dir}/hybrid_screenshot.png
""")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,201 @@
"""해법 방향 시뮬레이션: 콘텐츠 전달 의도에 맞는 블록 배치.
사용자가 지적한 문제:
- t1 (문제제기): 불릿 3줄 → 짧은 1-2줄이면 충분
- t2 (사례 비교): 세로 card-numbered → 가로 2열 비교
- t3 (핵심 DX≠BIM): 약어 원형 → DX와 BIM의 차이/관계를 보여주는 비교
- t4 (용어 정의): 태그 짧은 요약 → 풀 정의
시뮬레이션: 블록을 "전달 의도"에 맞게 수동 선택하여 렌더링.
Kei API 불필요 — 렌더링만.
"""
from __future__ import annotations
import asyncio
import json
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
import copy
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"))
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")
topics = analysis["topics"]
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS[preset_name]
out_dir = ROOT / "data" / "runs" / "ideal_simulation"
out_dir.mkdir(parents=True, exist_ok=True)
# ═══════════════════════════════════════
# 시뮬레이션 A: 전달 의도에 맞는 블록 선택
# 컨테이너 비중도 콘텐츠에 맞게 조정
# ═══════════════════════════════════════
print("=== 시뮬레이션 A: 전달 의도 기반 블록 배치 ===")
# 비중 조정: 본심(핵심 비교)이 가장 크고, 배경은 간결하게
adjusted_structure = copy.deepcopy(analysis["page_structure"])
adjusted_structure["본심"]["weight"] = 0.55
adjusted_structure["배경"]["weight"] = 0.25
adjusted_structure["결론"]["weight"] = 0.10
adjusted_structure["첨부"]["weight"] = 0.10
container_specs = calculate_container_specs(adjusted_structure, topics, preset)
blocks = []
# sidebar label
blocks.append({
"area": "sidebar", "type": "divider-text",
"topic_id": None, "purpose": "_label",
"data": {"text": "용어 정의"}, "size": "compact",
})
# t1 (배경 - 문제제기): 짧은 인용 한 줄 — quote-big-mark
blocks.append({
"type": "quote-big-mark",
"topic_id": 1,
"area": "body",
"purpose": "문제제기",
"data": {
"quote_text": "건설산업에서 DX와 BIM이 동일 개념으로 인식되고 있다",
"source": ""
}
})
# t2 (배경 - 사례 비교): 가로 2열 비교 — comparison-2col
blocks.append({
"type": "comparison-2col",
"topic_id": 2,
"area": "body",
"purpose": "근거사례",
"data": {
"left_title": "스마트건설 활성화 방안",
"left_subtitle": "2022.07",
"left_content": "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입, BIM 전문인력 양성",
"right_title": "제7차 건설기술진흥 기본계획",
"right_subtitle": "2023.12",
"right_content": "• 추진방향: 디지털 전환을 통한 스마트 건설 확산\n• 추진과제: BIM 도입으로 건설산업 디지털화"
}
})
# t3 (본심 - 핵심): DX vs BIM 차이 — comparison-2col (큰 비교)
blocks.append({
"type": "comparison-2col",
"topic_id": 3,
"area": "body",
"purpose": "핵심전달",
"data": {
"left_title": "DX (상위개념)",
"left_subtitle": "Digital Transformation",
"left_content": "• BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념\n• Engineering + Management 통합\n• 근본적 문제의식을 통한 개선\n• 전 생애주기 활용 시스템\n• 자체 수행 능력 — 지속가능성 확보",
"right_title": "BIM (하위기술)",
"right_subtitle": "Building Information Modeling",
"right_content": "• 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구\n• Only 3D (형상 구현 중심)\n• 기존 2D 설계 방식 유지\n• (설계/시공/운영) 분야별 단절\n• S/W 제작사 판매 정책에 의존"
}
})
# t4 (sidebar - 용어 정의): 풀 정의 — card-numbered
blocks.append({
"type": "card-numbered",
"topic_id": 4,
"area": "sidebar",
"purpose": "용어정의",
"data": {
"items": [
{
"title": "건설산업",
"description": "부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업"
},
{
"title": "BIM",
"description": "형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구"
},
{
"title": "DX",
"description": "디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. BIM, GIS, 디지털 트윈의 기술융합을 통해서만 실현 가능한 상위개념"
}
]
}
})
# t5 (footer - 결론): 원문 그대로 — banner-gradient
blocks.append({
"type": "banner-gradient",
"topic_id": 5,
"area": "footer",
"purpose": "결론강조",
"data": {
"text": "BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다",
"sub_text": "각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요"
}
})
layout = {
"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": {
"body": "--font-body: 0.85rem; --spacing-inner: 10px; --spacing-block: 10px;",
"sidebar": "--font-body: 0.82rem; --spacing-inner: 8px; --spacing-block: 10px;",
"footer": "",
},
}],
}
html = render_slide(layout)
m = await asyncio.to_thread(measure_rendered_heights, html)
s = await asyncio.to_thread(capture_slide_screenshot, html)
_save(out_dir, "sim_a.html", html)
if s:
import base64 as b64
(out_dir / "sim_a_screenshot.png").write_bytes(b64.b64decode(s))
_save(out_dir, "sim_a_measurement.json", m)
print("컨테이너:")
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", {})
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px {'' if not slide.get('overflowed') else ''}")
print(f"\n결과: {out_dir}/sim_a_screenshot.png")
def _save(out_dir, name, data):
path = out_dir / name
if isinstance(data, str):
path.write_text(data, encoding="utf-8")
else:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
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("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

426
scripts/test_ideal_v2.py Normal file
View File

@@ -0,0 +1,426 @@
"""이상적인 슬라이드 시뮬레이션 v2.
콘텐츠의 전달 의도를 정확히 반영한 블록 배치.
핵심: "DX와 BIM은 다르다. BIM은 DX의 일부다."를 독자가 이해하게 하는 것.
Kei API 불필요 — 순수 렌더링만.
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
# 직접 HTML을 작성하여 렌더링
SLIDE_HTML = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>건설산업 DX의 올바른 이해</title>
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
.slide {
width: 1280px;
height: 720px;
overflow: hidden;
background: #ffffff;
font-family: 'Pretendard Variable', sans-serif;
color: #1e293b;
font-size: 14px;
line-height: 1.6;
word-break: keep-all;
display: grid;
grid-template-areas:
'header header'
'body sidebar'
'footer footer';
grid-template-columns: 65fr 35fr;
grid-template-rows: auto 1fr auto;
gap: 16px;
padding: 36px 40px 24px;
}
/* ── 제목 ── */
.header {
grid-area: header;
font-size: 28px;
font-weight: 900;
color: #1e293b;
border-bottom: 3px solid #2563eb;
padding-bottom: 8px;
}
/* ── Body ── */
.body {
grid-area: body;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
/* ── 배경: 문제 제기 (간결한 1블록) ── */
.problem-box {
background: linear-gradient(135deg, #1e293b, #0f172a);
border-radius: 8px;
padding: 16px 24px;
color: #fff;
}
.problem-title {
font-size: 13px;
font-weight: 700;
color: #93c5fd;
margin-bottom: 6px;
}
.problem-text {
font-size: 13px;
line-height: 1.7;
color: #e2e8f0;
}
.problem-text strong {
color: #fbbf24;
font-weight: 700;
}
.problem-cases {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.case-card {
background: rgba(255,255,255,0.08);
border-radius: 6px;
padding: 10px 14px;
border-left: 3px solid #60a5fa;
}
.case-label {
font-size: 11px;
font-weight: 700;
color: #93c5fd;
margin-bottom: 4px;
}
.case-content {
font-size: 11px;
color: #cbd5e1;
line-height: 1.6;
}
/* ── 본심: 핵심 관계 시각화 ── */
.core-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.core-label {
font-size: 14px;
font-weight: 800;
color: #2563eb;
text-align: center;
}
/* 포함 관계 시각화 */
.hierarchy-visual {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.dx-outer {
width: 100%;
max-width: 620px;
border: 3px solid #2563eb;
border-radius: 16px;
padding: 16px 20px 14px;
position: relative;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
}
.dx-label {
position: absolute;
top: -12px;
left: 20px;
background: #2563eb;
color: white;
font-size: 13px;
font-weight: 800;
padding: 2px 16px;
border-radius: 10px;
}
.dx-desc {
font-size: 11px;
color: #1e40af;
margin-bottom: 10px;
text-align: center;
}
.tech-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.tech-card {
background: white;
border: 2px solid #93c5fd;
border-radius: 10px;
padding: 10px;
text-align: center;
}
.tech-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #93c5fd, #2563eb);
color: white;
font-size: 16px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
}
.tech-name {
font-size: 13px;
font-weight: 700;
color: #1e293b;
margin-bottom: 3px;
}
.tech-desc {
font-size: 10px;
color: #64748b;
line-height: 1.5;
}
/* 핵심 메시지 */
.core-message {
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 8px;
padding: 10px 16px;
text-align: center;
}
.core-message-text {
font-size: 13px;
font-weight: 700;
color: #0c4a6e;
line-height: 1.5;
}
.core-message-text em {
color: #dc2626;
font-style: normal;
font-weight: 800;
}
/* ── Sidebar ── */
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: 12px;
border-left: 1px solid #e2e8f0;
padding-left: 20px;
}
.sidebar-label {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
}
.sidebar-label::before, .sidebar-label::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.def-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
}
.def-num {
display: inline-flex;
width: 24px;
height: 24px;
border-radius: 50%;
background: #2563eb;
color: white;
font-size: 12px;
font-weight: 800;
align-items: center;
justify-content: center;
margin-right: 8px;
vertical-align: middle;
}
.def-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
display: inline;
vertical-align: middle;
}
.def-desc {
font-size: 12px;
color: #475569;
line-height: 1.6;
margin-top: 6px;
}
.def-source {
font-size: 10px;
color: #94a3b8;
font-style: italic;
margin-top: 4px;
}
/* ── Footer ── */
.footer {
grid-area: footer;
background: linear-gradient(135deg, #006aff, #00aaff);
border-radius: 8px;
padding: 14px 30px;
text-align: center;
color: white;
}
.footer-text {
font-size: 15px;
font-weight: 700;
}
.footer-sub {
font-size: 11px;
opacity: 0.85;
margin-top: 2px;
}
</style>
</head>
<body>
<div class="slide">
<div class="header">건설산업 DX의 올바른 이해</div>
<div class="body">
<!-- 배경: 문제 제기 + 사례 (1블록에 통합) -->
<div class="problem-box">
<div class="problem-title">현실 — 용어의 혼용</div>
<div class="problem-text">
건설산업에서 <strong>DX와 BIM이 동일 개념으로 인식</strong>되고 있다.
DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 하위 기술에 해당한다.
</div>
<div class="problem-cases">
<div class="case-card">
<div class="case-label">스마트 건설 활성화 방안 (2022.07)</div>
<div class="case-content">추진과제: 건설산업 디지털화<br>실행과제: BIM 전면 도입, BIM 전문인력 양성</div>
</div>
<div class="case-card">
<div class="case-label">제7차 건설기술진흥 기본계획 (2023.12)</div>
<div class="case-content">추진방향: 디지털 전환을 통한 스마트 건설 확산<br>추진과제: BIM 도입으로 건설산업 디지털화</div>
</div>
</div>
</div>
<!-- 본심: DX ⊃ BIM 포함 관계 시각화 -->
<div class="core-section">
<div class="core-label">DX와 핵심기술의 올바른 관계</div>
<div class="hierarchy-visual">
<div class="dx-outer">
<div class="dx-label">DX — 디지털 전환 (상위개념)</div>
<div class="dx-desc">BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능</div>
<div class="tech-row">
<div class="tech-card">
<div class="tech-icon">G</div>
<div class="tech-name">GIS</div>
<div class="tech-desc">지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
</div>
<div class="tech-card">
<div class="tech-icon">B</div>
<div class="tech-name">BIM</div>
<div class="tech-desc">시설물 생애주기 정보를 3차원 모델 기반으로 통합·관리하는 도구</div>
</div>
<div class="tech-card">
<div class="tech-icon">T</div>
<div class="tech-name">디지털 트윈</div>
<div class="tech-desc">현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현</div>
</div>
</div>
</div>
</div>
<div class="core-message">
<div class="core-message-text">
DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 근본적으로 전환하는 과정이다.<br>
<em>BIM ≠ DX</em> — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다.
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-label">용어 정의</div>
<div class="def-item">
<span class="def-num">1</span>
<span class="def-title">건설산업</span>
<div class="def-desc">부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업</div>
</div>
<div class="def-item">
<span class="def-num">2</span>
<span class="def-title">BIM</span>
<div class="def-desc">형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="def-source">건설산업 BIM 기본지침, 국토교통부, 2020</div>
</div>
<div class="def-item">
<span class="def-num">3</span>
<span class="def-title">DX (디지털 전환)</span>
<div class="def-desc">디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 산업의 새로운 방향을 정립</div>
<div class="def-source">IBM Institute for Business Value, 2011</div>
</div>
</div>
<div class="footer">
<div class="footer-text">BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다</div>
<div class="footer-sub">각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요</div>
</div>
</div>
</body>
</html>"""
async def main():
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
import base64
out_dir = ROOT / "data" / "runs" / "ideal_v2"
out_dir.mkdir(parents=True, exist_ok=True)
# 렌더링 + 측정
m = await asyncio.to_thread(measure_rendered_heights, SLIDE_HTML)
s = await asyncio.to_thread(capture_slide_screenshot, SLIDE_HTML)
(out_dir / "ideal_v2.html").write_text(SLIDE_HTML, encoding="utf-8")
if s:
(out_dir / "ideal_v2_screenshot.png").write_bytes(base64.b64decode(s))
print("=== 이상적인 슬라이드 v2 ===")
slide = m.get("slide", {})
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px {'' if not slide.get('overflowed') else ''}")
for name, data in m.get("zones", {}).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('clientHeight', 0)}px {status}")
print(f"\n결과: {out_dir}/ideal_v2_screenshot.png")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

346
scripts/test_phase_q.py Normal file
View File

@@ -0,0 +1,346 @@
"""Phase Q 단독 테스트 스크립트.
기존 run의 step1 결과물(analysis, concepts)을 재사용하여
블록 선택 → 콘텐츠 채우기 → 렌더링만 실행한다.
Kei 분석(~13분)을 건너뛰고 Phase Q 로직만 검증.
사용법:
python scripts/test_phase_q.py [run_id]
python scripts/test_phase_q.py 1774736083771
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
# 프로젝트 루트를 sys.path에 추가
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def run_phase_q_test(run_id: str):
"""기존 run의 step1 결과를 사용하여 Phase Q만 실행."""
from src.block_selector import select_block_candidates, select_fallback_candidates, load_catalog
from src.space_allocator import (
calculate_container_specs, finalize_block_specs, find_container_for_topic,
calculate_char_budget, calculate_budgets_for_candidates,
)
from src.design_director import select_preset, LAYOUT_PRESETS
from src.renderer import render_slide
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
run_dir = ROOT / "data" / "runs" / run_id
# 매 실행마다 새 폴더 생성 (타임스탬프)
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = ROOT / "data" / "runs" / f"{run_id}_q_{timestamp}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"[Phase Q 테스트] run={run_id}")
print(f" 입력: {run_dir}")
print(f" 출력: {out_dir}")
print()
# ── Step 1 결과 로딩 (기존 것 재사용) ──
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에서 relation_type을 analysis topics에 병합
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["expression_hint"] = concept_map[tid].get("expression_hint", "")
topic["source_data"] = concept_map[tid].get("source_data", "")
# 원본 콘텐츠 (step1에 저장 안 되어 있으면 직접 입력)
content_file = run_dir / "input_content.txt"
if content_file.exists():
content = content_file.read_text(encoding="utf-8")
else:
content = """# 건설산업 DX의 올바른 이해
## 용어의 혼용
건설산업에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 동일 개념으로 인식되고 있다.
실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다.
## 혼용 대표 사례
1. 스마트 건설 활성화 방안(2022.07): 추진과제를 건설산업 디지털화로 명시하면서 실행과제는 BIM 전면 도입에 국한
2. 제7차 건설기술진흥 기본계획(2023.12): 추진방향을 디지털 전환으로 제시하면서 추진과제는 BIM 도입으로 한정
## DX와 핵심기술의 올바른 관계
DX는 BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념이다.
- GIS: 지리적 데이터를 공간 분석하여 시각적으로 표현
- BIM: 시설물 생애주기 정보를 3차원 모델로 통합 관리
- 디지털 트윈: 현실 객체를 디지털로 동일하게 구현
## 용어별 정의
- 건설산업: 광범위한 기술을 통합 융합하여 만드는 종합산업
- BIM: 3차원 모델 기반으로 통합 관리하는 정보 관리 도구
- DX: 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
## 핵심 요약
BIM은 DX의 기초가 되는 일부분이다. 각 용어의 정의와 상호관계에 대한 체계적 정립이 필요하다.
"""
topics = analysis.get("topics", [])
page_structure = analysis.get("page_structure", {})
print(f" topics: {len(topics)}")
for t in topics:
print(f" t{t['id']}: {t['title']} (relation={t.get('relation_type', '?')}, purpose={t.get('purpose', '?')})")
print()
# ── 컨테이너 계산 ──
t0 = time.time()
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS.get(preset_name, {})
container_specs = calculate_container_specs(page_structure, topics, preset)
print(f"[{time.time()-t0:.1f}s] 컨테이너 계산 완료:")
for role, spec in container_specs.items():
print(f" {role}: {spec.height_px}px × {spec.width_px}px, topics={spec.topic_ids}")
_save(out_dir, "step1c_containers.json", {
role: {"height_px": s.height_px, "width_px": s.width_px, "topic_ids": s.topic_ids,
"max_height_cost": s.max_height_cost, "weight": s.weight}
for role, s in container_specs.items()
})
# ── Q-2: 블록 후보 필터링 (결정론적) ──
catalog = load_catalog()
used_blocks: set[str] = set()
candidates_per_topic: dict[int, list[dict]] = {}
budgets_per_topic: dict[int, dict[str, dict]] = {}
print(f"\n[{time.time()-t0:.1f}s] Q-2: 블록 후보 필터링")
for topic in topics:
tid = topic["id"]
spec = find_container_for_topic(tid, container_specs)
if not spec:
print(f" t{tid}: 컨테이너 없음!")
continue
candidates = select_block_candidates(topic, spec, used_blocks, catalog)
if not candidates:
candidates = select_fallback_candidates(spec, used_blocks, catalog)
print(f" t{tid}: fallback → {len(candidates)}")
candidates_per_topic[tid] = candidates
budgets_per_topic[tid] = calculate_budgets_for_candidates(candidates, spec)
per_topic_px = spec.height_px // max(1, len(spec.topic_ids))
print(f" t{tid} ({topic.get('relation_type', '?')}, {per_topic_px}px): "
f"{len(candidates)}개 → [{', '.join(c['id'] for c in candidates[:5])}]")
_save(out_dir, "step2_candidates.json", {
str(tid): [{"id": c["id"], "category": c.get("category")} for c in cs[:5]]
for tid, cs in candidates_per_topic.items()
})
# ── Q-4: Kei 블록 선택 (AI 1회) ──
print(f"\n[{time.time()-t0:.1f}s] Q-4: Kei 블록 선택 중... (AI 호출)")
from src.kei_client import select_block_for_topics
selections = None
for attempt in range(5):
selections = await select_block_for_topics(
topics, candidates_per_topic, budgets_per_topic,
container_specs, analysis
)
if selections:
break
print(f" 재시도 {attempt + 1}/5...")
await asyncio.sleep(10)
if not selections:
print(" ❌ Kei 블록 선택 실패")
return
print(f"[{time.time()-t0:.1f}s] 블록 선택 완료:")
selected_blocks: dict[int, dict] = {}
for topic in topics:
tid = topic["id"]
sel = selections.get(tid, {})
block_id = sel.get("block_id", "")
spec = find_container_for_topic(tid, container_specs)
if not block_id and candidates_per_topic.get(tid):
block_id = candidates_per_topic[tid][0]["id"]
used_blocks.add(block_id)
budget = budgets_per_topic.get(tid, {}).get(block_id, {})
variant = sel.get("variant", "default")
block = {
"type": block_id,
"_variant": variant,
"topic_id": tid,
"area": spec.zone if spec else "body",
"purpose": topic.get("purpose", ""),
"_char_budget": budget,
}
finalize_block_specs([block], container_specs)
selected_blocks[tid] = block
variant_label = f" [{variant}]" if variant != "default" else ""
print(f" t{tid}: {block_id}{variant_label} (예산: {budget.get('total_chars', '?')}자) — {sel.get('reason', '')[:50]}")
_save(out_dir, "step2_selection.json", {
str(tid): {"type": b["type"], "variant": b.get("_variant", "default"),
"area": b["area"], "budget": b.get("_char_budget", {}),
"reason": selections.get(tid, {}).get("reason", "")}
for tid, b in selected_blocks.items()
})
# ── layout_concept 조립 ──
final_blocks = []
# sidebar label
sidebar_tids = [tid for tid, b in selected_blocks.items() if b.get("area") == "sidebar"]
if sidebar_tids:
first_topic = next((t for t in topics if t["id"] == sidebar_tids[0]), {})
section_title = first_topic.get("section_title", "")
if not section_title:
purpose = first_topic.get("purpose", "")
section_title = {"용어정의": "용어 정의", "근거사례": "참고 자료"}.get(purpose, "")
if section_title:
final_blocks.append({
"area": "sidebar", "type": "divider-text",
"topic_id": None, "purpose": "_label",
"data": {"text": section_title}, "size": "compact",
})
role_order = ["배경", "본심", "첨부", "결론"]
for role in role_order:
spec = container_specs.get(role)
if not spec:
continue
for tid in spec.topic_ids:
block = selected_blocks.get(tid)
if block:
final_blocks.append(block)
layout_concept = {
"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": final_blocks,
}],
}
print(f"\n[{time.time()-t0:.1f}s] 레이아웃 조립: {len(final_blocks)}개 블록")
# ── Step 3: topic별 개별 호출 (Phase P fill_candidates 방식 복원) ──
print(f"[{time.time()-t0:.1f}s] Step 3: Kei 편집자 텍스트 채우기 중 (topic별 개별)...")
from src.content_editor import fill_candidates
for topic in topics:
tid = topic["id"]
block = selected_blocks.get(tid)
if not block:
continue
await fill_candidates(content, topic, [block], analysis)
has_data = bool(block.get("data"))
char_count = len(json.dumps(block.get("data", {}), ensure_ascii=False)) if has_data else 0
print(f" t{tid}: {block['type']}{'' if has_data else ''} ({char_count}자)")
blocks_with_data = [b for b in final_blocks if b.get("data") and b.get("topic_id") is not None]
blocks_without_data = [b for b in final_blocks if not b.get("data") and b.get("topic_id") is not None]
print(f"[{time.time()-t0:.1f}s] 텍스트 채우기 완료:")
print(f" 데이터 있음: {len(blocks_with_data)}개 — {[b['type'] for b in blocks_with_data]}")
if blocks_without_data:
print(f" 데이터 없음: {len(blocks_without_data)}개 — {[b['type'] for b in blocks_without_data]}")
_save(out_dir, "step3_fill_content.json", {
"filled": len(blocks_with_data),
"empty": len(blocks_without_data),
"blocks": [
{"type": b["type"], "topic_id": b.get("topic_id"),
"has_data": bool(b.get("data")),
"data_preview": str(b.get("data", {}))[:100]}
for b in final_blocks if b.get("topic_id") is not None
]
})
# ── Step 4: CSS 조정 + 렌더링 ──
print(f"\n[{time.time()-t0:.1f}s] Step 4: CSS 조정 + 렌더링...")
from src.pipeline import _adjust_design
layout_concept = await _adjust_design(layout_concept, analysis)
html = render_slide(layout_concept)
_save(out_dir, "step4_rendered.html", html)
print(f"[{time.time()-t0:.1f}s] HTML 생성: {len(html)}")
# ── 측정 ──
print(f"[{time.time()-t0:.1f}s] Selenium 측정 중...")
measurement = await asyncio.to_thread(measure_rendered_heights, html)
_save(out_dir, "step4_measurement.json", measurement)
has_overflow = False
for name, data in measurement.get("containers", {}).items():
status = "" if not data.get("overflowed") else ""
print(f" {name}: {data.get('scrollHeight', 0)}px / {data.get('allocatedHeight', 0)}px {status}")
if data.get("overflowed"):
has_overflow = True
slide_data = measurement.get("slide", {})
slide_status = "" if not slide_data.get("overflowed") else ""
print(f" slide: {slide_data.get('scrollHeight', 0)}px / 720px {slide_status}")
# ── 스크린샷 ──
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
if screenshot_b64:
import base64
png_path = out_dir / "screenshot.png"
png_path.write_bytes(base64.b64decode(screenshot_b64))
print(f"\n[{time.time()-t0:.1f}s] 스크린샷 저장: {png_path}")
# ── final.html 저장 ──
_save(out_dir, "final.html", html)
# ── 결과 요약 ──
total = time.time() - t0
print(f"\n{'='*50}")
print(f"Phase Q 테스트 완료: {total:.1f}")
print(f" 블록 다양성: {len(set(b['type'] for b in final_blocks))}종류")
print(f" 데이터 채움: {len(blocks_with_data)}/{len([b for b in final_blocks if b.get('topic_id') is not None])}")
print(f" overflow: {'없음 ✅' if not has_overflow else '있음 ❌'}")
print(f" 출력: {out_dir}")
print(f"{'='*50}")
def _save(out_dir: Path, filename: str, data):
path = out_dir / filename
if isinstance(data, str):
path.write_text(data, encoding="utf-8")
else:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
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("httpcore").setLevel(logging.WARNING)
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
run_id = sys.argv[1] if len(sys.argv) > 1 else "1774736083771"
asyncio.run(run_phase_q_test(run_id))

View File

@@ -0,0 +1,187 @@
"""Phase R' 테스트: 접근 C — 블록 CSS 참고 + AI 구조 결정.
기존 step1 결과를 재사용하여 html_generator로 HTML 직접 생성.
블록 선택(block_selector) 없음. 슬롯 채우기(fill_candidates) 없음.
AI가 콘텐츠에 맞는 HTML 구조를 직접 만든다.
사용법:
python scripts/test_phase_r_prime.py [run_id]
python scripts/test_phase_r_prime.py 1774736083771
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
import datetime
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main(run_id: str):
from src.html_generator import generate_slide_html
from src.html_validator import validate_and_clean_html
from src.renderer import render_slide_from_html
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" / run_id
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = ROOT / "data" / "runs" / f"{run_id}_rprime_{timestamp}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"[Phase R' 테스트] run={run_id}")
print(f" 입력: {run_dir}")
print(f" 출력: {out_dir}")
print()
# ── Step 1 결과 로딩 (기존 것 재사용) ──
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"))
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["expression_hint"] = concept_map[tid].get("expression_hint", "")
topic["source_data"] = concept_map[tid].get("source_data", "")
# 원본 콘텐츠
content = """# 건설산업 DX의 올바른 이해
## 용어의 혼용
건설산업에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 동일 개념으로 인식되고 있다.
실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다.
## 혼용 대표 사례
1. 스마트 건설 활성화 방안(2022.07): 추진과제를 건설산업 디지털화로 명시하면서 실행과제는 BIM 전면 도입에 국한
2. 제7차 건설기술진흥 기본계획(2023.12): 추진방향을 디지털 전환으로 제시하면서 추진과제는 BIM 도입으로 한정
## DX와 핵심기술의 올바른 관계
DX는 BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념이다.
- GIS: 지리적 데이터를 공간 분석하여 시각적으로 표현
- BIM: 시설물 생애주기 정보를 3차원 모델로 통합 관리
- 디지털 트윈: 현실 객체를 디지털로 동일하게 구현
## 용어별 정의
- 건설산업: 광범위한 기술을 통합 융합하여 만드는 종합산업
- BIM: 3차원 모델 기반으로 통합 관리하는 정보 관리 도구
- DX: 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
## 핵심 요약
BIM은 DX의 기초가 되는 일부분이다. 각 용어의 정의와 상호관계에 대한 체계적 정립이 필요하다.
"""
topics = analysis["topics"]
t0 = time.time()
# ── 컨테이너 계산 (유지) ──
preset_name = select_preset(analysis)
preset = LAYOUT_PRESETS[preset_name]
container_specs = calculate_container_specs(
analysis.get("page_structure", {}), topics, preset
)
print(f"[{time.time()-t0:.1f}s] 컨테이너 계산:")
for role, spec in container_specs.items():
print(f" {role}: {spec.height_px}px × {spec.width_px}px, topics={spec.topic_ids}")
_save(out_dir, "step1c_containers.json", {
role: {"height_px": s.height_px, "width_px": s.width_px, "topic_ids": s.topic_ids}
for role, s in container_specs.items()
})
# ══════════════════════════════════════
# ★ Phase R' 핵심: AI HTML 직접 생성
# block_selector 없음. fill_candidates 없음.
# ══════════════════════════════════════
print(f"\n[{time.time()-t0:.1f}s] ★ AI HTML 생성 중... (블록 선택 없음, AI가 구조 결정)")
generated = await generate_slide_html(
content=content,
analysis=analysis,
container_specs=container_specs,
preset=preset,
)
# HTML 정화 + 검증
generated = validate_and_clean_html(generated)
_save(out_dir, "step2_generated.json", {
"body_html_length": len(generated.get("body_html", "")),
"sidebar_html_length": len(generated.get("sidebar_html", "")),
"footer_html_length": len(generated.get("footer_html", "")),
"reasoning": generated.get("reasoning", ""),
})
print(f"[{time.time()-t0:.1f}s] HTML 생성 완료:")
print(f" body: {len(generated.get('body_html', ''))}")
print(f" sidebar: {len(generated.get('sidebar_html', ''))}")
print(f" footer: {len(generated.get('footer_html', ''))}")
print(f" 구조 결정 근거: {generated.get('reasoning', '')[:100]}")
# ── 렌더링 (AI HTML을 프레임에 삽입) ──
print(f"\n[{time.time()-t0:.1f}s] 렌더링...")
html = render_slide_from_html(generated, analysis, preset)
_save(out_dir, "step3_rendered.html", html)
_save(out_dir, "final.html", html)
# ── Selenium 측정 ──
print(f"[{time.time()-t0:.1f}s] Selenium 측정...")
measurement = await asyncio.to_thread(measure_rendered_heights, html)
_save(out_dir, "step4_measurement.json", measurement)
slide = measurement.get("slide", {})
print(f" slide: {slide.get('scrollHeight', 0)}px / 720px "
f"{'' if not slide.get('overflowed') else ''}")
for name, data in measurement.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}")
# ── 스크린샷 ──
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
if screenshot_b64:
import base64 as b64
(out_dir / "screenshot.png").write_bytes(b64.b64decode(screenshot_b64))
print(f"\n[{time.time()-t0:.1f}s] 스크린샷: {out_dir / 'screenshot.png'}")
total = time.time() - t0
print(f"\n{'='*50}")
print(f"Phase R' 테스트 완료: {total:.1f}")
print(f" 블록 선택: 없음 (AI가 HTML 구조 직접 생성)")
print(f" 슬롯 채우기: 없음 (AI가 텍스트 직접 포함)")
print(f" 결과: {out_dir}")
print(f"{'='*50}")
def _save(out_dir, name, data):
path = out_dir / name
if isinstance(data, str):
path.write_text(data, encoding="utf-8")
else:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
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("httpcore").setLevel(logging.WARNING)
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
run_id = sys.argv[1] if len(sys.argv) > 1 else "1774736083771"
asyncio.run(main(run_id))

248
scripts/verify_3issues.py Normal file
View File

@@ -0,0 +1,248 @@
"""Phase R' 검증: 3가지 문제를 각각 Kei API에 요청하여 가능 여부 확인.
검증 1: 배경 사례 2건이 박스 안에 온전히 들어가는 HTML
검증 2: DX/GIS/BIM/디지털트윈 상호 관계 시각화 HTML
검증 3: 용어 정의 풀 텍스트 + 출처 포함 HTML
각각 독립적으로 Kei API 호출 → 렌더링 → 스크린샷.
"""
from __future__ import annotations
import asyncio, json, sys, time, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.sse_utils import stream_sse_tokens
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings
import httpx
out_dir = ROOT / "data" / "runs" / f"verify_3issues_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"출력: {out_dir}\n")
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
t0 = time.time()
# ═══════════════════════════════════════
# 검증 1: 배경 — 문제 제기 + 사례 2건이 176px 안에 들어가는 HTML
# ═══════════════════════════════════════
print("=== 검증 1: 배경 사례 박스 ===")
prompt_1 = """다음 콘텐츠를 176px 높이 × 707px 너비의 다크 배경 박스 안에 HTML로 만들어라.
## 콘텐츠
- 제목: "현실 — 용어의 혼용"
- 본문: "건설산업에서 DX와 BIM이 동일 개념으로 인식되고 있다. 실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다."
- 사례 1: "스마트 건설 활성화 방안(2022.07) — 추진과제: 건설산업 디지털화, 실행과제: BIM 전면 도입, BIM 전문인력 양성"
- 사례 2: "제7차 건설기술진흥 기본계획(2023.12) — 추진방향: 디지털 전환을 통한 스마트 건설 확산, 추진과제: BIM 도입으로 건설산업 디지털화"
## 요구사항
1. 다크 배경(#1e293b → #0f172a 그라데이션), 흰 텍스트
2. 제목: #93c5fd 색상
3. 사례 2건을 가로 나란히 카드로 배치 (border-left: 3px solid #60a5fa)
4. 사례 제목: #fbbf24 (노란색)
5. **176px 높이 안에 모든 내용이 들어가야 한다. 넘치면 안 된다.**
6. 본문과 사례의 텍스트를 축약하지 마라. 위에 제공한 텍스트 그대로 사용.
7. 폰트 크기를 줄여서라도 176px 안에 맞춰라 (최소 10px까지 허용)
## 출력
HTML + inline <style>만 반환. 설명 없이 코드만.
```html
(여기에 HTML)
```"""
html_1 = await _call_kei(kei_url, prompt_1)
if html_1:
wrapped_1 = _wrap_in_slide(html_1, 707, 176)
m_1 = await asyncio.to_thread(measure_rendered_heights, wrapped_1)
s_1 = await asyncio.to_thread(capture_slide_screenshot, wrapped_1)
_save(out_dir, "verify1_background.html", wrapped_1)
if s_1:
(out_dir / "verify1_background.png").write_bytes(base64.b64decode(s_1))
slide = m_1.get("slide", {})
print(f" [{time.time()-t0:.0f}s] 결과: {slide.get('scrollHeight', 0)}px / 720px")
print(f" HTML: {len(html_1)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 생성 실패")
# ═══════════════════════════════════════
# 검증 2: DX/GIS/BIM/디지털트윈 상호 관계 시각화
# ═══════════════════════════════════════
print("\n=== 검증 2: DX 관계 시각화 ===")
prompt_2 = """다음 관계를 시각화하는 HTML을 만들어라. 크기: 707px 너비 × 293px 높이.
## 관계 구조
- DX(디지털 전환)는 상위개념이다.
- DX 안에 GIS, BIM, 디지털 트윈이 포함된다.
- GIS, BIM, 디지털 트윈은 서로 연결/융합되어 DX를 실현한다.
- "BIM ≠ DX" — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다.
## 각 기술 설명 (원본 그대로 사용)
- DX: "BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념"
- GIS: "지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공"
- BIM: "시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구"
- 디지털 트윈: "현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술"
## 시각화 요구사항
1. DX를 큰 원 또는 큰 박스로, 그 안에 GIS/BIM/디지털트윈을 포함
2. GIS, BIM, 디지털 트윈은 서로 겹치거나 연결되어 융합을 표현 (벤 다이어그램, 겹치는 원, 또는 연결선)
3. 하단에 "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다" 강조 박스
4. 색상: DX 파란(#2563eb), GIS/BIM/디지털트윈 각각 다른 색조의 파란
5. **293px 높이 안에 맞춰라**
6. SVG 또는 CSS로 시각화
## 출력
HTML + inline <style>만 반환. 설명 없이 코드만.
```html
(여기에 HTML)
```"""
html_2 = await _call_kei(kei_url, prompt_2)
if html_2:
wrapped_2 = _wrap_in_slide(html_2, 707, 293)
m_2 = await asyncio.to_thread(measure_rendered_heights, wrapped_2)
s_2 = await asyncio.to_thread(capture_slide_screenshot, wrapped_2)
_save(out_dir, "verify2_hierarchy.html", wrapped_2)
if s_2:
(out_dir / "verify2_hierarchy.png").write_bytes(base64.b64decode(s_2))
slide = m_2.get("slide", {})
print(f" [{time.time()-t0:.0f}s] 결과: {slide.get('scrollHeight', 0)}px / 720px")
print(f" HTML: {len(html_2)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 생성 실패")
# ═══════════════════════════════════════
# 검증 3: 용어 정의 풀 텍스트 + 출처
# ═══════════════════════════════════════
print("\n=== 검증 3: 용어 정의 (풀 텍스트 + 출처) ===")
prompt_3 = """다음 3개 용어의 정의를 sidebar 카드로 만들어라. 크기: 380px 너비 × 490px 높이.
## 용어 (원본 텍스트를 100% 그대로 사용. 한 글자도 바꾸지 마라.)
1. 건설산업
정의: "부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업"
2. BIM (Building Information Modeling)
정의: "형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구"
출처: "건설산업 BIM 기본지침, 국토교통부, 2020"
3. DX (Digital Transformation)
정의: "디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 산업의 새로운 방향을 정립"
출처: "IBM Institute for Business Value, 2011"
## 요구사항
1. 카드 스타일: 배경 #f8fafc, 테두리 1px solid #e2e8f0, border-radius 8px
2. 번호: 원형 #2563eb 배경 + 흰 숫자
3. 정의 텍스트를 축약하지 마라. 위에 제공한 텍스트를 한 글자도 빠짐없이 그대로 넣어라.
4. 출처가 있으면 이탤릭 작은 글씨(10px, #94a3b8)로 표시
5. 490px 높이 안에 여유 있게 배치 (공간이 충분함)
6. 상단에 "용어 정의" 구분선 라벨 (좌우 선 + 중앙 텍스트)
## 출력
HTML + inline <style>만 반환. 설명 없이 코드만.
```html
(여기에 HTML)
```"""
html_3 = await _call_kei(kei_url, prompt_3)
if html_3:
wrapped_3 = _wrap_in_slide(html_3, 380, 490)
m_3 = await asyncio.to_thread(measure_rendered_heights, wrapped_3)
s_3 = await asyncio.to_thread(capture_slide_screenshot, wrapped_3)
_save(out_dir, "verify3_definitions.html", wrapped_3)
if s_3:
(out_dir / "verify3_definitions.png").write_bytes(base64.b64decode(s_3))
slide = m_3.get("slide", {})
print(f" [{time.time()-t0:.0f}s] 결과: {slide.get('scrollHeight', 0)}px / 720px")
print(f" HTML: {len(html_3)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 생성 실패")
print(f"\n총 소요: {time.time()-t0:.0f}")
print(f"결과: {out_dir}")
async def _call_kei(kei_url: str, prompt: str) -> str | None:
"""Kei API 호출하여 HTML 코드 추출."""
import re
import httpx
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST", f"{kei_url}/api/message",
json={"message": prompt, "session_id": "verify-3issues", "mode_hint": "chat"},
timeout=None,
) as response:
if response.status_code != 200:
return None
from src.sse_utils import stream_sse_tokens
full_text = await stream_sse_tokens(response)
if not full_text:
return None
# ```html ... ``` 블록 추출
match = re.search(r"```html\s*(.*?)```", full_text, re.DOTALL)
if match:
return match.group(1).strip()
# <div나 <style로 시작하는 HTML 직접 추출
match = re.search(r"(<(?:div|style|section)[^>]*>.*)", full_text, re.DOTALL)
if match:
return match.group(1).strip()
return full_text.strip()
except Exception as e:
print(f" Kei API 오류: {e}")
return None
def _wrap_in_slide(inner_html: str, width: int, height: int) -> str:
"""HTML 조각을 측정 가능한 슬라이드 프레임으로 감싼다."""
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden;
background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
padding: 40px;
}}
.test-container {{
width: {width}px;
max-height: {height}px;
overflow: visible;
}}
</style>
</head><body>
<div class="slide">
<div class="test-container">
{inner_html}
</div>
</div>
</body></html>"""
def _save(out_dir, name, data):
(out_dir / name).write_text(data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,175 @@
"""검증 1, 2 재시도 — Claude API 직접 호출.
Kei는 콘텐츠 분석/판단. Claude가 HTML 코드 생성.
"""
from __future__ import annotations
import asyncio, json, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_claude_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"출력: {out_dir}\n")
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
# ═══════════════════════════════════════
# 검증 1: 배경 사례 박스
# ═══════════════════════════════════════
print("=== 검증 1: 배경 사례 박스 (Claude) ===")
prompt_1 = """다음 콘텐츠를 다크 배경 박스 HTML로 만들어라.
## 크기
- width: 100%, height: 176px (고정, overflow 금지)
## 콘텐츠 (축약 금지, 그대로 사용)
- 제목: "현실 — 용어의 혼용"
- 본문: "건설산업에서 DX와 BIM이 동일 개념으로 인식되고 있다. 실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다."
- 사례 1: "스마트 건설 활성화 방안(2022.07)" / "추진과제: 건설산업 디지털화 / 실행과제: BIM 전면 도입, BIM 전문인력 양성"
- 사례 2: "제7차 건설기술진흥 기본계획(2023.12)" / "추진방향: 디지털 전환을 통한 스마트 건설 확산 / 추진과제: BIM 도입으로 건설산업 디지털화"
## 디자인
- 배경: linear-gradient(135deg, #1e293b, #0f172a), border-radius: 8px
- width: 100%, height: 176px
- 제목: 13px bold #93c5fd
- 본문: 12px #e2e8f0, "DX와 BIM"을 <strong> 처리
- 사례 2개 가로 나란히 (flex/grid)
- 사례 카드: rgba(255,255,255,0.06), border-left: 3px solid #60a5fa
- 사례 제목: 11px bold #fbbf24
- 사례 내용: 10px #cbd5e1
HTML + inline <style>만 반환. 설명 없이 코드만."""
html_1 = await _call_claude(client, prompt_1)
if html_1:
wrapped = _wrap(html_1, 707)
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
_save(out_dir, "verify1.html", wrapped)
if s:
(out_dir / "verify1.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료. HTML {len(html_1)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 실패")
# ═══════════════════════════════════════
# 검증 2: DX 포함 관계 (카드 구조)
# ═══════════════════════════════════════
print("\n=== 검증 2: DX 포함 관계 (Claude) ===")
prompt_2 = """다음 포함 관계를 시각화하는 HTML을 만들어라.
## 크기
- width: 100%, max-height: 293px
## 구조 (정확히 이 구조를 따르라)
1. 제목: "DX와 핵심기술의 올바른 관계" (14px bold #2563eb 가운데)
2. DX 큰 박스:
- border: 3px solid #2563eb, border-radius: 14px
- background: linear-gradient(180deg, #eff6ff, #dbeafe)
- position: relative
- 라벨 배지 (absolute top:-11px left:50% transform:translateX(-50%)):
"DX — 디지털 전환 (상위개념)" background:#2563eb color:white font-size:12px font-weight:900 padding:3px 18px border-radius:10px
- 설명 (11px #1e40af 가운데):
"BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능"
- 카드 3개 가로 나란히 (gap:10px):
각 카드: background:white, border:2px solid #93c5fd, border-radius:8px, padding:10px, text-align:center
각 카드 상단 원형 아이콘: 36px, background:linear-gradient(135deg,#93c5fd,#2563eb), color:white, font-weight:900
- G | GIS | "지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공"
- B | BIM | "시설물 생애주기 정보를 3차원 모델 기반으로 통합·관리하는 도구"
- T | 디지털 트윈 | "현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현"
카드 설명: 10px #64748b
3. 핵심 메시지 박스 (DX 박스 아래):
- background:#f0f9ff, border:2px solid #bae6fd, border-radius:8px, padding:10px, text-align:center
- "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
- "BIM ≠ DX" 부분: color:#dc2626 font-weight:900
- 나머지: 13px bold #0c4a6e
HTML + inline <style>만 반환. 설명 없이 코드만."""
html_2 = await _call_claude(client, prompt_2)
if html_2:
wrapped = _wrap(html_2, 707)
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
_save(out_dir, "verify2.html", wrapped)
if s:
(out_dir / "verify2.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료. HTML {len(html_2)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 실패")
print(f"\n총 소요: {time.time()-t0:.0f}")
print(f"결과: {out_dir}")
async def _call_claude(client, prompt: str) -> str | None:
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
if not text:
return None
# ```html ... ``` 추출
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
if match:
return match.group(1).strip()
# HTML 직접 추출
match = re.search(r"(<(?:div|style)[^>]*>.*)", text, re.DOTALL)
if match:
return match.group(1).strip()
return text.strip()
except Exception as e:
print(f" Claude API 오류: {e}")
return None
def _wrap(inner_html: str, width: int) -> str:
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden;
background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: {width}px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{inner_html}
</div></div>
</body></html>"""
def _save(d, n, data):
(d / n).write_text(data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,173 @@
"""본심 C 수정: 캡션 이미지에 가까이 + 두 번째 불릿 한 줄로."""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_c_fix_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core {{
width: 767px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
.popup-link {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}}
.fi {{
float: right;
margin: 60px 0 8px 12px;
width: 250px;
}}
.fi img {{ width: 100%; }}
.fi .cap {{
font-size: 9px;
color: #94a3b8;
text-align: center;
margin-top: 1px;
line-height: 1.2;
}}
.core-text {{
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}}
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}}
.core-text b {{ font-weight: 700; color: #1e293b; }}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
</style>
<div class="core">
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
<span class="popup-link">📊 DX와 BIM의 상세 비교</span>
</div>
<div class="core-text">
<div class="fi">
<img src="{img_src}">
<div class="cap">건설산업의 DX</div>
</div>
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_c_fix.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_c_fix.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,227 @@
"""본심 최종 검증: 샘플 이미지 구조 정확히 반영.
구조: 왼쪽 텍스트(넓게) | 오른쪽 이미지(좁게) + 상단 팝업 링크
텍스트: 원본 MDX 거의 그대로, 축약 없음
"""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_final_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
# dx1.png base64
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core-section {{
width: 707px;
height: 293px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 20px;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}}
.core-title {{
background: #1e293b;
color: #ffffff;
font-size: 13px;
font-weight: 700;
padding: 4px 14px;
border-radius: 4px;
}}
.core-detail-link {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}}
.core-body {{
display: flex;
gap: 16px;
flex: 1;
}}
.core-text {{
flex: 62%;
font-size: 12px;
color: #1e293b;
line-height: 1.7;
}}
.core-text .main-point {{
margin-bottom: 8px;
}}
.core-text .main-point::before {{
content: '';
margin-right: 6px;
color: #1e293b;
font-weight: 700;
}}
.core-text .sub-point {{
padding-left: 16px;
font-size: 11px;
color: #475569;
margin-bottom: 4px;
}}
.core-text .sub-point::before {{
content: '';
margin-right: 6px;
color: #64748b;
}}
.core-text b {{
font-weight: 700;
color: #1e293b;
}}
.core-image {{
flex: 38%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}}
.core-image img {{
width: 100%;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}}
.core-image .caption {{
font-size: 9px;
color: #94a3b8;
margin-top: 4px;
text-align: center;
}}
/* 팝업 테이블 */
.core-detail-link details {{
position: relative;
}}
.core-detail-link summary {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
list-style: none;
}}
.core-detail-link summary::-webkit-details-marker {{
display: none;
}}
.popup-table {{
position: absolute;
right: 0;
top: 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 8px;
z-index: 10;
width: 500px;
}}
.popup-table table {{
width: 100%;
border-collapse: collapse;
font-size: 10px;
}}
.popup-table th {{
background: #1e293b;
color: white;
padding: 5px 8px;
text-align: left;
font-weight: 700;
}}
.popup-table td {{
padding: 4px 8px;
border-bottom: 1px solid #e2e8f0;
color: #334155;
}}
.popup-table tr:nth-child(even) {{
background: #f8fafc;
}}
</style>
<div class="core-section">
<div class="core-header">
<div class="core-title">DX와 BIM의 관계</div>
<div class="core-detail-link">
<details>
<summary>📊 DX와 BIM의 상세 비교</summary>
<div class="popup-table">
<table>
<tr><th>기준</th><th>DX</th><th>BIM</th></tr>
<tr><td>범위</td><td>BIM &lt;&lt; DX (Engineering + Management 통합)</td><td>Only 3D (형상 구현 중심)</td></tr>
<tr><td>프로세스</td><td>근본적 문제의식을 통한 개선</td><td>기존 2D 설계 방식 유지</td></tr>
<tr><td>성과품</td><td>공학 정보 및 콘텐츠 연계에 집중</td><td>3D 모델 중심</td></tr>
<tr><td>활용</td><td>설계/시공 생산성 혁신</td><td>3D 모델에 의한 일반적 이해 향상</td></tr>
<tr><td>확장성</td><td>전 생애주기 활용 시스템</td><td>(설계/시공/운영) 분야별 단절</td></tr>
<tr><td>주체</td><td>자체 수행 능력 — 지속가능성 확보</td><td>S/W 제작사 판매 정책에 의존</td></tr>
</table>
</div>
</details>
</div>
</div>
<div class="core-body">
<div class="core-text">
<div class="main-point">DX는 BIM과 같은 기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="main-point">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sub-point"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sub-point"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 <b>Process와 Product를 제공</b></div>
</div>
<div class="core-image">
<img src="{img_src}" alt="건설산업의 DX">
<div class="caption">건설산업의 DX</div>
</div>
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_final.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_final.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,216 @@
"""본심 최종 v2: 원본 MDX 85-95% 보존 + 들여쓰기 + 여백 최소화."""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_final2_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core {{
width: 707px;
height: 293px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
.detail-link {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}}
.detail-link details {{ position: relative; }}
.detail-link summary {{
font-size: 10px; color: #2563eb; font-weight: 700;
cursor: pointer; list-style: none;
}}
.detail-link summary::-webkit-details-marker {{ display: none; }}
.popup {{
position: absolute; right: 0; top: 18px;
background: white; border: 1px solid #e2e8f0;
border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 8px; z-index: 10; width: 480px;
}}
.popup table {{ width: 100%; border-collapse: collapse; font-size: 10px; }}
.popup th {{ background: #1e293b; color: white; padding: 4px 6px; text-align: left; }}
.popup td {{ padding: 3px 6px; border-bottom: 1px solid #e2e8f0; color: #334155; }}
.popup tr:nth-child(even) {{ background: #f8fafc; }}
.core-body {{
display: flex;
gap: 14px;
flex: 1;
}}
.text-area {{
flex: 60%;
font-size: 12px;
color: #1e293b;
line-height: 1.7;
word-break: keep-all;
}}
/* 불릿 들여쓰기: 점 다음 줄이 점 옆 글자 시작 위치에 맞춤 */
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
margin-right: 6px;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 3px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
margin-right: 6px;
color: #64748b;
}}
.text-area b {{ font-weight: 700; color: #1e293b; }}
.img-area {{
flex: 40%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}}
.img-area img {{
width: 100%;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}}
.img-caption {{
font-size: 9px;
color: #94a3b8;
margin-top: 3px;
}}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 6px;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
</style>
<div class="core">
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
<div class="detail-link">
<details>
<summary>📊 DX와 BIM의 상세 비교</summary>
<div class="popup">
<table>
<tr><th>기준</th><th>DX</th><th>BIM</th></tr>
<tr><td>범위</td><td>BIM &lt;&lt; DX (Engineering + Management 통합)</td><td>Only 3D (형상 구현 중심)</td></tr>
<tr><td>프로세스</td><td>근본적 문제의식을 통한 개선</td><td>기존 2D 설계 방식 유지</td></tr>
<tr><td>성과품</td><td>공학 정보 및 콘텐츠 연계에 집중</td><td>3D 모델 중심</td></tr>
<tr><td>활용</td><td>설계/시공 생산성 혁신(개념의 재정립)</td><td>3D 모델에 의한 일반적 이해 향상</td></tr>
<tr><td>확장성</td><td>전 생애주기 활용 시스템</td><td>(설계/시공/운영) 분야별 단절</td></tr>
<tr><td>주체</td><td>적극적, 주체적인 기술 접목/융합<br>자체 수행 능력 — 지속가능성 확보</td><td>소극적, 상용 기술에 의존<br>S/W 제작사 판매 정책에 의존</td></tr>
</table>
</div>
</details>
</div>
</div>
<div class="core-body">
<div class="text-area">
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
</div>
<div class="img-area">
<img src="{img_src}" alt="건설산업의 DX">
<div class="img-caption">건설산업의 DX</div>
</div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_final2.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_final2.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,211 @@
"""본심: 텍스트 감싸기(float) — 워드/HWP 스타일.
이미지를 오른쪽에 float, 텍스트가 이미지를 감싸며 흐름.
이미지 아래에도 텍스트가 이어짐. 빈 공간 없음.
"""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_float_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core {{
width: 767px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
.detail-link details {{ position: relative; }}
.detail-link summary {{
font-size: 10px; color: #2563eb; font-weight: 700;
cursor: pointer; list-style: none;
}}
.detail-link summary::-webkit-details-marker {{ display: none; }}
.popup {{
position: absolute; right: 0; top: 18px;
background: white; border: 1px solid #e2e8f0;
border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.12);
padding: 8px; z-index: 10; width: 500px;
}}
.popup table {{ width: 100%; border-collapse: collapse; font-size: 10px; }}
.popup th {{ background: #1e293b; color: white; padding: 4px 6px; text-align: left; }}
.popup td {{ padding: 3px 6px; border-bottom: 1px solid #e2e8f0; color: #334155; }}
.popup tr:nth-child(even) {{ background: #f8fafc; }}
/* 이미지 float: 텍스트가 이미지를 감싸며 흐름 */
.float-img {{
float: right;
margin: 0 0 10px 14px;
width: 280px;
}}
.float-img img {{
width: 100%;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}}
.float-img .caption {{
font-size: 9px;
color: #94a3b8;
text-align: center;
margin-top: 3px;
}}
.core-text {{
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}}
/* 불릿 들여쓰기: 점 다음 줄은 점 옆 글자 시작 위치에 맞춤 */
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}}
.core-text b {{ font-weight: 700; color: #1e293b; }}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
</style>
<div class="core">
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
<div class="detail-link">
<details>
<summary>📊 DX와 BIM의 상세 비교</summary>
<div class="popup">
<table>
<tr><th>기준</th><th>DX</th><th>BIM</th></tr>
<tr><td>범위</td><td>BIM &lt;&lt; DX (Engineering + Management 통합)</td><td>Only 3D (형상 구현 중심)</td></tr>
<tr><td>프로세스</td><td>근본적 문제의식을 통한 개선</td><td>기존 2D 설계 방식 유지</td></tr>
<tr><td>성과품</td><td>공학 정보 및 콘텐츠 연계에 집중</td><td>3D 모델 중심</td></tr>
<tr><td>활용</td><td>설계/시공 생산성 혁신(개념의 재정립)</td><td>3D 모델에 의한 일반적 이해 향상</td></tr>
<tr><td>확장성</td><td>전 생애주기 활용 시스템</td><td>(설계/시공/운영) 분야별 단절</td></tr>
<tr><td>주체</td><td>적극적, 주체적인 기술 접목/융합<br>자체 수행 능력 — 지속가능성 확보</td><td>소극적, 상용 기술에 의존<br>S/W 제작사 판매 정책에 의존</td></tr>
</table>
</div>
</details>
</div>
</div>
<div class="core-text">
<!-- 이미지를 오른쪽에 float -->
<div class="float-img">
<img src="{img_src}" alt="건설산업의 DX">
<div class="caption">건설산업의 DX</div>
</div>
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_float.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_float.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,231 @@
"""본심 float v2: 이미지 아래 빈 공간에 팝업 배치."""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_float2_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core {{
width: 767px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
/* 이미지 + 팝업을 하나의 float 블록으로 묶음 */
.float-block {{
float: right;
margin: 0 0 8px 14px;
width: 280px;
}}
.float-block img {{
width: 100%;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}}
.float-block .caption {{
font-size: 9px;
color: #94a3b8;
text-align: center;
margin-top: 3px;
margin-bottom: 6px;
}}
/* 팝업이 이미지 바로 아래에 위치 */
.float-block .detail-trigger {{
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-align: center;
}}
.float-block details {{ position: relative; }}
.float-block summary {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
list-style: none;
text-align: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
}}
.float-block summary::-webkit-details-marker {{ display: none; }}
.popup {{
position: absolute;
right: 0;
top: 32px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
padding: 8px;
z-index: 10;
width: 500px;
}}
.popup table {{ width: 100%; border-collapse: collapse; font-size: 10px; }}
.popup th {{ background: #1e293b; color: white; padding: 4px 6px; text-align: left; }}
.popup td {{ padding: 3px 6px; border-bottom: 1px solid #e2e8f0; color: #334155; }}
.popup tr:nth-child(even) {{ background: #f8fafc; }}
.core-text {{
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}}
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}}
.core-text b {{ font-weight: 700; color: #1e293b; }}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
</style>
<div class="core">
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
</div>
<div class="core-text">
<!-- 이미지 + 팝업을 하나의 float 블록으로 -->
<div class="float-block">
<img src="{img_src}" alt="건설산업의 DX">
<div class="caption">건설산업의 DX</div>
<details>
<summary>📊 DX와 BIM의 상세 비교</summary>
<div class="popup">
<table>
<tr><th>기준</th><th>DX</th><th>BIM</th></tr>
<tr><td>범위</td><td>BIM &lt;&lt; DX (Engineering + Management 통합)</td><td>Only 3D (형상 구현 중심)</td></tr>
<tr><td>프로세스</td><td>근본적 문제의식을 통한 개선</td><td>기존 2D 설계 방식 유지</td></tr>
<tr><td>성과품</td><td>공학 정보 및 콘텐츠 연계에 집중</td><td>3D 모델 중심</td></tr>
<tr><td>활용</td><td>설계/시공 생산성 혁신(개념의 재정립)</td><td>3D 모델에 의한 일반적 이해 향상</td></tr>
<tr><td>확장성</td><td>전 생애주기 활용 시스템</td><td>(설계/시공/운영) 분야별 단절</td></tr>
<tr><td>주체</td><td>적극적, 주체적인 기술 접목/융합<br>자체 수행 능력 — 지속가능성 확보</td><td>소극적, 상용 기술에 의존<br>S/W 제작사 판매 정책에 의존</td></tr>
</table>
</div>
</details>
</div>
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_float2.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_float2.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,218 @@
"""본심 float v3: 이미지를 아래로 내려서 GIS 역할 줄과 상단 맞춤. 팝업은 상단 오른쪽."""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_float3_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
# 상단 불릿 2줄(메인 포인트)의 대략적 높이를 계산
# 줄 높이 12px * 1.75 = 21px, 불릿 2개 + margin = ~52px
# GIS 역할 줄이 시작하는 위치와 이미지 상단을 맞춤
# margin-top으로 이미지를 아래로 내림
html = f"""<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
.core {{
width: 767px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}}
.core-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.core-label {{
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}}
.detail-link {{
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}}
.detail-link details {{ position: relative; }}
.detail-link summary {{
font-size: 10px; color: #2563eb; font-weight: 700;
cursor: pointer; list-style: none;
}}
.detail-link summary::-webkit-details-marker {{ display: none; }}
.popup {{
position: absolute; right: 0; top: 18px;
background: white; border: 1px solid #e2e8f0;
border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.12);
padding: 8px; z-index: 10; width: 500px;
}}
.popup table {{ width: 100%; border-collapse: collapse; font-size: 10px; }}
.popup th {{ background: #1e293b; color: white; padding: 4px 6px; text-align: left; }}
.popup td {{ padding: 3px 6px; border-bottom: 1px solid #e2e8f0; color: #334155; }}
.popup tr:nth-child(even) {{ background: #f8fafc; }}
/* 이미지를 아래로 내림: 상단 불릿 2줄 후 GIS 역할과 상단 맞춤 */
.float-img {{
float: right;
margin: 50px 0 8px 14px;
width: 280px;
}}
.float-img img {{
width: 100%;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}}
.float-img .caption {{
font-size: 9px;
color: #94a3b8;
text-align: center;
margin-top: 3px;
}}
.core-text {{
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}}
.bp {{
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}}
.bp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}}
.sp {{
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}}
.sp::before {{
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}}
.core-text b {{ font-weight: 700; color: #1e293b; }}
.key-msg {{
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}}
.key-msg em {{
color: #dc2626;
font-style: normal;
font-weight: 900;
}}
</style>
<div class="core">
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
<div class="detail-link">
<details>
<summary>📊 DX와 BIM의 상세 비교</summary>
<div class="popup">
<table>
<tr><th>기준</th><th>DX</th><th>BIM</th></tr>
<tr><td>범위</td><td>BIM &lt;&lt; DX (Engineering + Management 통합)</td><td>Only 3D (형상 구현 중심)</td></tr>
<tr><td>프로세스</td><td>근본적 문제의식을 통한 개선</td><td>기존 2D 설계 방식 유지</td></tr>
<tr><td>성과품</td><td>공학 정보 및 콘텐츠 연계에 집중</td><td>3D 모델 중심</td></tr>
<tr><td>활용</td><td>설계/시공 생산성 혁신(개념의 재정립)</td><td>3D 모델에 의한 일반적 이해 향상</td></tr>
<tr><td>확장성</td><td>전 생애주기 활용 시스템</td><td>(설계/시공/운영) 분야별 단절</td></tr>
<tr><td>주체</td><td>적극적, 주체적인 기술 접목/융합<br>자체 수행 능력 — 지속가능성 확보</td><td>소극적, 상용 기술에 의존<br>S/W 제작사 판매 정책에 의존</td></tr>
</table>
</div>
</details>
</div>
</div>
<div class="core-text">
<!-- 이미지: margin-top으로 아래로 내려서 GIS 역할 줄과 상단 맞춤 -->
<div class="float-img">
<img src="{img_src}" alt="건설산업의 DX">
<div class="caption">건설산업의 DX</div>
</div>
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
</div>
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
</div>"""
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / "core_float3.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "core_float3.png").write_bytes(base64.b64decode(s))
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,220 @@
"""본심 4가지 샘플: 이미지와 텍스트가 어우러지는 방식."""
from __future__ import annotations
import asyncio, sys, datetime, base64
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
out_dir = ROOT / "data" / "runs" / f"core_samples_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
img_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
img_b64 = base64.b64encode(img_path.read_bytes()).decode()
img_src = f"data:image/png;base64,{img_b64}"
common_css = """
* { margin:0; padding:0; box-sizing:border-box; }
.core {
width: 767px;
font-family: 'Pretendard Variable', sans-serif;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
overflow: hidden;
word-break: keep-all;
}
.core-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.core-label {
background: #1e293b;
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 4px;
}
.popup-link {
font-size: 10px;
color: #2563eb;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}
.core-text {
font-size: 12px;
color: #1e293b;
line-height: 1.75;
}
.bp {
padding-left: 14px;
text-indent: -14px;
margin-bottom: 5px;
}
.bp::before {
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #1e293b;
font-weight: 700;
}
.sp {
padding-left: 28px;
text-indent: -14px;
margin-bottom: 4px;
font-size: 11px;
color: #475569;
}
.sp::before {
content: '';
display: inline-block;
width: 14px;
text-indent: 0;
color: #64748b;
}
.core-text b { font-weight: 700; color: #1e293b; }
.key-msg {
background: #f0f9ff;
border: 2px solid #bae6fd;
border-radius: 6px;
padding: 5px 12px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #0c4a6e;
margin-top: 8px;
clear: both;
}
.key-msg em {
color: #dc2626;
font-style: normal;
font-weight: 900;
}
"""
text_content = """
<div class="bp">DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 <b>프로세스를 혁신하는 상위개념</b></div>
<div class="bp">건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 <b>기술융합을 통해서만 실현 또는 구현 가능</b></div>
<div class="sp"><b>GIS의 역할</b> : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공</div>
<div class="sp"><b>BIM의 역할</b> : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공. 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구</div>
<div class="sp"><b>디지털 트윈</b> : 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술</div>
<div class="bp">DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 <b>근본적으로 전환하는 과정 및 결과</b></div>
"""
key_msg = """
<div class="key-msg">
<em>BIM ≠ DX</em> — BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다
</div>
"""
header = """
<div class="core-header">
<div class="core-label">DX와 BIM의 관계</div>
<span class="popup-link">📊 DX와 BIM의 상세 비교</span>
</div>
"""
# 샘플 A: float right, 이미지 border/shadow 없이 자연스럽게
sample_a = f"""<style>{common_css}
.s-a .fi {{ float: right; margin: 45px 0 8px 12px; width: 260px; }}
.s-a .fi img {{ width: 100%; }}
.s-a .fi .cap {{ font-size: 9px; color: #94a3b8; text-align: center; margin-top: 2px; }}
</style>
<div class="core s-a">
{header}
<div class="core-text">
<div class="fi"><img src="{img_src}"><div class="cap">건설산업의 DX</div></div>
{text_content}
</div>
{key_msg}
</div>"""
# 샘플 B: float right, 살짝 큰 이미지, 연한 배경
sample_b = f"""<style>{common_css}
.s-b .fi {{ float: right; margin: 40px 0 8px 16px; width: 300px; background: #f8fafc; border-radius: 8px; padding: 8px; }}
.s-b .fi img {{ width: 100%; }}
.s-b .fi .cap {{ font-size: 9px; color: #94a3b8; text-align: center; margin-top: 3px; }}
</style>
<div class="core s-b">
{header}
<div class="core-text">
<div class="fi"><img src="{img_src}"><div class="cap">건설산업의 DX</div></div>
{text_content}
</div>
{key_msg}
</div>"""
# 샘플 C: float right, 이미지 더 아래로 (BIM 역할과 맞춤)
sample_c = f"""<style>{common_css}
.s-c .fi {{ float: right; margin: 65px 0 8px 12px; width: 250px; }}
.s-c .fi img {{ width: 100%; }}
.s-c .fi .cap {{ font-size: 9px; color: #94a3b8; text-align: center; margin-top: 2px; }}
</style>
<div class="core s-c">
{header}
<div class="core-text">
<div class="fi"><img src="{img_src}"><div class="cap">건설산업의 DX</div></div>
{text_content}
</div>
{key_msg}
</div>"""
# 샘플 D: float left (이미지가 왼쪽)
sample_d = f"""<style>{common_css}
.s-d .fi {{ float: left; margin: 45px 14px 8px 0; width: 260px; }}
.s-d .fi img {{ width: 100%; }}
.s-d .fi .cap {{ font-size: 9px; color: #94a3b8; text-align: center; margin-top: 2px; }}
</style>
<div class="core s-d">
{header}
<div class="core-text">
<div class="fi"><img src="{img_src}"><div class="cap">건설산업의 DX</div></div>
{text_content}
</div>
{key_msg}
</div>"""
samples = {"A_float_clean": sample_a, "B_float_bg": sample_b, "C_float_lower": sample_c, "D_float_left": sample_d}
for name, html in samples.items():
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin:0; padding:0; box-sizing:border-box; }}
.slide {{
width:1280px; height:720px; overflow:hidden; background:white;
font-family:'Pretendard Variable',sans-serif;
display:flex; align-items:center; justify-content:center;
}}
</style>
</head><body>
<div class="slide">
{html}
</div>
</body></html>"""
(out_dir / f"{name}.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / f"{name}.png").write_bytes(base64.b64decode(s))
print(f" {name} 완료")
print(f"\n결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

135
scripts/verify_core_v3.py Normal file
View File

@@ -0,0 +1,135 @@
"""검증 B 재시도: 본심 — 참고 이미지 구조 반영.
참고 이미지 구조:
- DX 박스(이미지+텍스트) | BIM 박스(이미지+텍스트) 좌우 나란히
- 각 박스 안에 관련 이미지 + 설명
- 비교표는 팝업(details)으로 오른쪽 상단
"""
from __future__ import annotations
import asyncio, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_core_v3_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
prompt = """다음 콘텐츠를 본심 영역 HTML로 만들어라. 707px × 293px.
## 참고 레이아웃 (이 구조를 따르라)
실제 기획서 슬라이드의 본심 영역 레이아웃:
- 좌우 2단으로 DX 영역과 BIM 영역이 나란히 배치
- 각 영역 안에 관련 이미지/다이어그램 + 핵심 설명 텍스트
- 상단 오른쪽에 "📊 상세 비교표 보기" 팝업 링크
- 하단에 핵심 메시지 강조
## 구조
1. 상단 바: 좌측에 섹션 소제목, 우측에 팝업 링크
- 좌: 빈 공간 또는 소제목
- 우: <details><summary>📊 DX vs BIM 상세 비교표</summary>
표 내용:
| 기준 | DX | BIM |
| 범위 | BIM << DX (Engineering + Management 통합) | Only 3D (형상 구현 중심) |
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
| 주체 | 자체 수행 능력 | S/W 제작사 판매 정책에 의존 |
</details>
2. 본문: 좌우 2단 (각 50%)
왼쪽 — DX (디지털 전환):
- 상단: 이미지 <img src="/assets/images/dx1.png" style="width:100%; border-radius:6px;">
(이미지가 없으면 placeholder: 연한 파란 배경 + "DX 기술융합 관계도" 텍스트)
- 하단 텍스트:
"DX (Digital Transformation) : 상위개념"
• BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능
• Engineering + Management 통합
• 전 생애주기 활용 시스템
오른쪽 — BIM:
- 상단: placeholder 이미지 (연한 초록 배경 + "BIM 3D 모델 기반" 텍스트, border-radius:6px)
- 하단 텍스트:
"BIM (Building Information Modeling) : 하위기술"
• Only 3D (형상 구현 중심)
• 기존 2D 설계 방식 유지
• (설계/시공/운영) 분야별 단절
3. 하단: 핵심 메시지
- background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px
- "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
- "BIM ≠ DX": color: #dc2626, font-weight: 900
## 디자인
- DX 영역: border-left: 3px solid #2563eb
- BIM 영역: border-left: 3px solid #10b981
- 이미지 placeholder: height: 100px, border-radius: 6px, display:flex, align-items:center, justify-content:center
- DX placeholder: background: #eff6ff, color: #2563eb
- BIM placeholder: background: #f0fdf4, color: #10b981
- 제목: 12px bold
- 불릿: 11px #475569, line-height: 1.5
- <summary>: 11px bold #2563eb, cursor: pointer, float: right 또는 text-align: right
- 표: font-size: 10px, 헤더 #1e293b/white
- 전체 293px 안에 맞출 것
HTML + inline <style>만 반환. 설명 없이 코드만."""
print("=== 검증 B v3: 본심 (참고 이미지 구조) ===")
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
html = match.group(1).strip() if match else text.strip()
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden; background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: 707px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{html}
</div></div>
</body></html>"""
(out_dir / "B_core_v3.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "B_core_v3.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
print(f" 결과: {out_dir}")
except Exception as e:
print(f" 오류: {e}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

116
scripts/verify_core_v4.py Normal file
View File

@@ -0,0 +1,116 @@
"""검증 B v4: dx1.png 중심 + 주변 텍스트 배치.
dx1.png가 DX/GIS/BIM/디지털트윈 전체 관계를 보여주는 중심 이미지.
이미지 주변에 원본 텍스트로 관계를 설명.
"""
from __future__ import annotations
import asyncio, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_core_v4_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
prompt = """다음 콘텐츠를 본심 영역 HTML로 만들어라. 707px × 293px.
## 핵심: dx1.png 이미지가 중심
이 이미지는 Digital Transformation, GIS, BIM, Metaverse(Digital Twin)의 관계를 보여주는 다이어그램이다.
이 이미지 하나가 전체 관계를 시각적으로 보여주므로, 이미지를 중심에 크게 배치하고 주변에 텍스트로 보충한다.
## 구조
1. 이미지를 중앙 또는 좌측에 크게 배치:
<img src="D:/ad-hoc/cel/public/assets/images/dx1.png" style="max-width:320px; border-radius:8px; border:1px solid #e2e8f0;">
2. 이미지 오른쪽 또는 아래에 텍스트 배치 (원본 그대로 사용):
"DX는 BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념이다."
• GIS: 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
• BIM: 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
• 디지털 트윈: 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술
3. 오른쪽 상단에 팝업:
<details><summary style="font-size:11px; color:#2563eb; cursor:pointer; font-weight:bold;">📊 DX vs BIM 상세 비교표</summary>
표:
| 기준 | DX | BIM |
| 범위 | BIM << DX (Engineering + Management 통합) | Only 3D (형상 구현 중심) |
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
| 주체 | 자체 수행 능력 — 지속가능성 확보 | S/W 제작사 판매 정책에 의존 |
</details>
4. 하단에 핵심 메시지:
background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px, padding: 8px
"BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
"BIM ≠ DX": color: #dc2626, font-weight: 900
## 디자인
- 이미지+텍스트를 flex로 가로 배치 (이미지 왼쪽, 텍스트 오른쪽)
- 텍스트: 11px #475569, line-height: 1.6
- 각 기술명(GIS, BIM, 디지털 트윈): bold #1e293b
- 전체 293px 안에 맞출 것
- "상위개념", "하위기술" 같은 단어 사용 금지
HTML + inline <style>만 반환. 설명 없이 코드만."""
print("=== 검증 B v4: dx1.png 중심 ===")
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
html = match.group(1).strip() if match else text.strip()
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden; background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: 707px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{html}
</div></div>
</body></html>"""
(out_dir / "B_core_v4.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "B_core_v4.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
print(f" 결과: {out_dir}")
except Exception as e:
print(f" 오류: {e}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

129
scripts/verify_core_v5.py Normal file
View File

@@ -0,0 +1,129 @@
"""검증 B v5: 텍스트 왼쪽 | dx1.png 이미지 오른쪽.
참고 이미지(스크린샷) 구조 정확히 반영.
dx1.png를 base64로 인라인 삽입하여 확실히 표시.
"""
from __future__ import annotations
import asyncio, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_core_v5_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
# dx1.png를 base64로 변환
dx1_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
dx1_b64 = ""
if dx1_path.exists():
dx1_b64 = base64.b64encode(dx1_path.read_bytes()).decode()
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
prompt = f"""다음 콘텐츠를 본심 영역 HTML로 만들어라. 707px × 293px.
## 레이아웃 (정확히 이 구조를 따르라)
왼쪽(55%): 텍스트 | 오른쪽(45%): 이미지
텍스트가 왼쪽, 이미지가 오른쪽이다. 반대로 하지 마라.
## 왼쪽 영역 (텍스트)
원본 텍스트를 그대로 사용:
"DX는 BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념이다."
• GIS: 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
• BIM: 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
• 디지털 트윈: 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술
"DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 근본적으로 전환하는 과정 및 결과이다."
## 오른쪽 영역 (이미지)
이미지를 아래 태그로 삽입 (base64 인라인):
<img src="data:image/png;base64,{dx1_b64}" style="width:100%; border-radius:8px; border:1px solid #e2e8f0;">
## 하단
오른쪽 상단에:
<details><summary style="font-size:11px; color:#2563eb; cursor:pointer; font-weight:bold; text-align:right;">📊 DX vs BIM 상세 비교표</summary>
표:
| 기준 | DX | BIM |
| 범위 | Engineering + Management 통합 | Only 3D (형상 구현 중심) |
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
</details>
맨 아래에 핵심 메시지:
background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px, padding: 8px, text-align: center
"BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
"BIM ≠ DX": color: #dc2626, font-weight: 900
## 디자인
- flex로 가로 배치 (왼쪽 텍스트 55%, 오른쪽 이미지 45%)
- 왼쪽 텍스트: 12px #1e293b, 불릿 11px #475569
- 기술명(GIS, BIM, 디지털 트윈): bold
- 전체 293px 안에 맞출 것
- "상위개념", "하위기술" 단어 사용 금지
HTML + inline <style>만 반환. 설명 없이 코드만."""
print("=== 검증 B v5: 텍스트 왼쪽 | 이미지 오른쪽 ===")
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=16384,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
html = match.group(1).strip() if match else text.strip()
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden; background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: 707px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{html}
</div></div>
</body></html>"""
(out_dir / "B_core_v5.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "B_core_v5.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
print(f" 결과: {out_dir}")
except Exception as e:
print(f" 오류: {e}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,175 @@
"""검증 A: 용어 정의 재검증 + 검증 B: 본심 (이미지+텍스트+팝업 표)
용어 정의: 참고 이미지 수준 — 부제 + 불릿 2개 + 원본 텍스트 거의 그대로
본심: dx1.png 이미지 + DX vs BIM 관계 텍스트 + 비교표는 details/summary 팝업
"""
from __future__ import annotations
import asyncio, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_v2_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
# ═══════════════════════════════════════
# 검증 A: 용어 정의 (참고 이미지 수준)
# ═══════════════════════════════════════
print("=== 검증 A: 용어 정의 (참고 이미지 수준) ===")
prompt_a = """다음 3개 용어 정의를 sidebar 카드로 만들어라. 380px × 490px.
## 용어 (원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용)
### BIM (Building Information Modeling) : 디지털 전환을 위한 핵심 기술
- 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
- 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
### 건설산업
- 다양한 시설물을 각 산업마다의 광범위한 기술을 통합 및 융합하여 만들어내는 종합산업
- 목적 시설물의 품질 욕구를 충족시키면서 최단기간 내에 최소 비용으로 편리하고 안전하며 우수한 성능의 시설물 완성을 목표로 함
### 디지털전환 (DX, Digital Transformation) : 산업 패러다임의 변화
- 디지털 기술을 기반으로 산업 전반의 업무 방식과 가치 창출 구조를 전환하는 과정 및 결과
- 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립하는 것을 의미
## 디자인 요구사항
1. 상단에 "용어 정의" 구분선 라벨 (좌우 선 + 중앙 텍스트, 13px #64748b)
2. 각 용어를 카드로:
- 배경: #f8fafc, 테두리: 1px solid #e2e8f0, border-radius: 8px, padding: 14px
- 용어명: 14px bold #1e293b (예: "BIM (Building Information Modeling)")
- 부제: 12px #2563eb (예: ": 디지털 전환을 위한 핵심 기술")
- 불릿: 12px #475569, line-height: 1.6, 불릿 마커 ""
- 각 불릿은 원본 텍스트 그대로
3. 카드 간 간격 10px
4. 490px 안에 여유 있게 배치
HTML + inline <style>만 반환. 설명 없이 코드만."""
html_a = await _call(client, prompt_a)
if html_a:
wrapped = _wrap(html_a, 380)
(out_dir / "A_definitions.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "A_definitions.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
# ═══════════════════════════════════════
# 검증 B: 본심 (이미지 + 텍스트 + 팝업 표)
# ═══════════════════════════════════════
print("\n=== 검증 B: 본심 (이미지+텍스트+팝업표) ===")
prompt_b = """다음 콘텐츠를 본심 영역 HTML로 만들어라. 707px × 293px.
## 구조 (정확히 이 구조를 따르라)
1. 제목: "DX와 핵심기술의 올바른 관계" (14px bold #2563eb 가운데)
2. 좌우 2단 레이아웃:
- 왼쪽 (50%): 이미지
<img src="/assets/images/dx1.png" style="width:100%; border-radius:8px; border:1px solid #e2e8f0;">
- 오른쪽 (50%): DX vs BIM 핵심 차이 텍스트
DX (상위개념):
• 기술융합을 통해서만 실현 가능한 상위개념
• Engineering + Management 통합
• 전 생애주기 활용 시스템
• 자체 수행 능력 — 지속가능성 확보
BIM (하위기술):
• Only 3D (형상 구현 중심)
• 기존 2D 설계 방식 유지
• (설계/시공/운영) 분야별 단절
• S/W 제작사 판매 정책에 의존
3. 이미지+텍스트 아래에 <details>/<summary> 팝업:
<summary>📊 DX vs BIM 상세 비교표 보기</summary>
펼치면 표가 보임:
| 기준 | DX | BIM |
| 범위 | BIM << DX (Engineering + Management 통합) | Only 3D (형상 구현 중심) |
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
| 주체 | 자체 수행 능력 — 지속가능성 확보 | S/W 제작사 판매 정책에 의존 |
4. 맨 아래에 핵심 메시지:
background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px, padding: 8px, text-align: center
"BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
"BIM ≠ DX" 부분: color: #dc2626, font-weight: 900
## 디자인
- DX 항목 제목: 13px bold #2563eb
- BIM 항목 제목: 13px bold #64748b
- 불릿: 11px #475569
- 표 헤더: background: #1e293b, color: white
- 표 셀: 10px, border-bottom: 1px solid #e2e8f0
- <summary>: cursor: pointer, 12px bold #2563eb
- 이미지가 안 보이면 placeholder 박스(회색 배경 + "DX 관계도" 텍스트)로 대체
HTML + inline <style>만 반환. 설명 없이 코드만."""
html_b = await _call(client, prompt_b)
if html_b:
wrapped = _wrap(html_b, 707)
(out_dir / "B_core.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "B_core.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
print(f"\n총 소요: {time.time()-t0:.0f}")
print(f"결과: {out_dir}")
async def _call(client, prompt):
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
return match.group(1).strip() if match else text.strip()
except Exception as e:
print(f" 오류: {e}")
return None
def _wrap(inner, width):
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden; background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: {width}px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{inner}
</div></div>
</body></html>"""
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -0,0 +1,129 @@
"""DX 포함 관계를 3가지 다른 시각화로 비교.
A: 벤 다이어그램 (원 안에 이름만, 설명은 하단 별도)
B: 동심원 (DX 큰 원 > 기술융합 중간 원 > GIS/BIM/DT 작은 원)
C: 계층 박스 (DX 박스 안에 3개 기술 + 겹치는 영역 표시)
"""
from __future__ import annotations
import asyncio, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
COMMON_INFO = """
## 관계 (반드시 반영)
- DX는 상위개념. GIS, BIM, 디지털 트윈을 포함.
- 3개 기술은 서로 융합되어 DX를 실현.
- "BIM ≠ DX"
## 텍스트
- DX: 상위개념 (디지털 전환)
- GIS: 공간 정보
- BIM: 3차원 모델
- 디지털 트윈: 디지털 구현
- 핵심 메시지: "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
## 공통 규칙
- 크기: 707px × 280px
- 원 안에는 이름만 (설명 텍스트를 원 안에 넣지 마라)
- 각 기술의 설명은 원 아래에 작은 텍스트로 별도 배치하거나 생략
- "BIM ≠ DX" 강조 박스는 하단에 배치
- 색상: GIS=#3b82f6, BIM=#10b981, 디지털트윈=#f59e0b, DX=#2563eb
- 폰트: Pretendard Variable
HTML + inline <style> 반환. 설명 없이 코드만.
"""
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"hierarchy_3ways_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
prompts = {
"A_venn": f"""DX 포함 관계를 **벤 다이어그램**으로 시각화하라.
- SVG로 3개 원을 서로 30% 겹치게 배치
- 각 원 안에 이름과 아이콘 글자(G, B, T)만 표시 (설명 넣지 마라)
- DX 큰 둥근 박스가 3개 원을 감싼다
- 3개가 겹치는 중심에 "융합" 텍스트
- 원 아래에 각 기술명 + 한 줄 설명을 가로로 나열
{COMMON_INFO}""",
"B_concentric": f"""DX 포함 관계를 **동심원 구조**로 시각화하라.
- 가장 큰 원: DX (연한 파란 배경)
- 중간 원: "기술 융합" (약간 진한 파란)
- 안쪽에 GIS, BIM, 디지털트윈 3개 작은 원이 삼각형으로 배치
- 각 원 안에 이름만 (G, B, T 아이콘 + 이름)
- 아래에 각 기술 한 줄 설명
{COMMON_INFO}""",
"C_nested_boxes": f"""DX 포함 관계를 **중첩 박스**로 시각화하라.
- DX 큰 박스 (border: 3px solid #2563eb, 둥근 모서리)
- 안에 3개 기술 카드가 가로로 배치
- 카드 사이에 겹치는 영역을 그라데이션 또는 점선으로 표시 (융합을 시각적으로)
- 각 카드: 원형 아이콘(G/B/T) + 이름 + 한 줄 설명
- DX 박스 상단에 라벨: "DX — 디지털 전환 (상위개념)"
- 카드들 아래에 "3개 기술이 융합되어 DX를 실현" 텍스트
{COMMON_INFO}""",
}
for name, prompt in prompts.items():
print(f"\n=== {name} ===")
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
html = match.group(1).strip() if match else text.strip()
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden; background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: 707px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{html}
</div></div>
</body></html>"""
(out_dir / f"{name}.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / f"{name}.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료")
except Exception as e:
print(f" 오류: {e}")
print(f"\n총 소요: {time.time()-t0:.0f}")
print(f"결과: {out_dir}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

146
scripts/verify_layout_3.py Normal file

File diff suppressed because one or more lines are too long

198
scripts/verify_retry_1_2.py Normal file
View File

@@ -0,0 +1,198 @@
"""검증 1, 2 재시도 — 프롬프트 개선.
검증 1: 배경 박스가 영역을 꽉 채우도록
검증 2: 벤 다이어그램이 아니라 포함 관계 박스 구조 (C_reference 방식)
"""
from __future__ import annotations
import asyncio, json, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.sse_utils import stream_sse_tokens
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings
import httpx
out_dir = ROOT / "data" / "runs" / f"verify_retry_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"출력: {out_dir}\n")
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
t0 = time.time()
# ═══════════════════════════════════════
# 검증 1 재시도: 배경 박스가 영역을 꽉 채움
# ═══════════════════════════════════════
print("=== 검증 1 재시도: 배경 사례 박스 ===")
prompt_1 = """다음 콘텐츠를 다크 배경 박스 HTML로 만들어라.
## 크기 제약
- 너비: 707px을 꽉 채운다 (width: 100%)
- 높이: 176px을 꽉 채운다 (height: 176px)
- overflow 금지 — 176px 안에 모든 내용이 보여야 한다
## 콘텐츠 (이 텍스트를 그대로 사용, 축약 금지)
- 제목: "현실 — 용어의 혼용"
- 본문: "건설산업에서 DX와 BIM이 동일 개념으로 인식되고 있다. 실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다."
- 사례 1: 제목 "스마트 건설 활성화 방안(2022.07)" / 내용 "추진과제: 건설산업 디지털화 / 실행과제: BIM 전면 도입, BIM 전문인력 양성"
- 사례 2: 제목 "제7차 건설기술진흥 기본계획(2023.12)" / 내용 "추진방향: 디지털 전환을 통한 스마트 건설 확산 / 추진과제: BIM 도입으로 건설산업 디지털화"
## 디자인
- 배경: linear-gradient(135deg, #1e293b, #0f172a)
- border-radius: 8px
- width: 100%, height: 176px (고정)
- 제목: 13px bold, color: #93c5fd
- 본문: 12px, color: #e2e8f0
- 사례 카드 2개를 가로 나란히 (flex 또는 grid)
- 사례 카드: background: rgba(255,255,255,0.06), border-left: 3px solid #60a5fa, padding: 8px 12px
- 사례 제목: 11px bold, color: #fbbf24
- 사례 내용: 10px, color: #cbd5e1
- DX와 BIM을 strong 태그로 강조
## 출력
HTML + inline <style>만 반환. 설명 없이.
```html
(여기)
```"""
html_1 = await _call_kei(kei_url, prompt_1)
if html_1:
wrapped_1 = _wrap_in_container(html_1, 707, 200)
m_1 = await asyncio.to_thread(measure_rendered_heights, wrapped_1)
s_1 = await asyncio.to_thread(capture_slide_screenshot, wrapped_1)
_save(out_dir, "verify1_retry.html", wrapped_1)
if s_1:
(out_dir / "verify1_retry.png").write_bytes(base64.b64decode(s_1))
print(f" [{time.time()-t0:.0f}s] 완료. HTML {len(html_1)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 실패")
# ═══════════════════════════════════════
# 검증 2 재시도: 포함 관계 박스 구조 (벤 다이어그램 아님)
# ═══════════════════════════════════════
print("\n=== 검증 2 재시도: DX 포함 관계 ===")
prompt_2 = """다음 포함 관계를 시각화하는 HTML을 만들어라.
## 크기 제약
- 너비: 707px을 꽉 채운다
- 높이: 293px 안에 맞춘다
## 관계 구조
DX는 상위개념이다. DX 안에 GIS, BIM, 디지털 트윈이 포함된다.
이 3개 기술이 융합되어야 DX가 실현된다.
## 시각화 구조 (이 구조를 정확히 따르라)
1. "DX와 핵심기술의 올바른 관계" 제목 (14px bold, #2563eb, 가운데 정렬)
2. DX 큰 박스:
- border: 3px solid #2563eb, border-radius: 14px
- background: linear-gradient(180deg, #eff6ff, #dbeafe)
- 상단에 라벨 배지: "DX — 디지털 전환 (상위개념)" (absolute, top: -11px, background: #2563eb, color: white, border-radius: 10px)
- 배지 아래에 설명: "BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능" (11px, #1e40af, 가운데)
- 내부에 카드 3개를 가로 나란히:
- 각 카드: background: white, border: 2px solid #93c5fd, border-radius: 8px, padding: 10px
- 각 카드 상단: 원형 아이콘 (36px, gradient #93c5fd→#2563eb, 흰 글자)
- GIS 카드: 아이콘 "G", 이름 "GIS", 설명 "지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공"
- BIM 카드: 아이콘 "B", 이름 "BIM", 설명 "시설물 생애주기 정보를 3차원 모델 기반으로 통합·관리하는 도구"
- 디지털트윈 카드: 아이콘 "T", 이름 "디지털 트윈", 설명 "현실 세계의 물리적 객체를 디지털 환경에 동일하게 구현"
3. DX 박스 아래에 핵심 메시지 박스:
- background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px
- 텍스트: "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다" (13px bold, #0c4a6e)
- "BIM ≠ DX" 부분만 color: #dc2626, font-weight: 900
## 출력
HTML + inline <style>만 반환. 설명 없이.
```html
(여기)
```"""
html_2 = await _call_kei(kei_url, prompt_2)
if html_2:
wrapped_2 = _wrap_in_container(html_2, 707, 310)
m_2 = await asyncio.to_thread(measure_rendered_heights, wrapped_2)
s_2 = await asyncio.to_thread(capture_slide_screenshot, wrapped_2)
_save(out_dir, "verify2_retry.html", wrapped_2)
if s_2:
(out_dir / "verify2_retry.png").write_bytes(base64.b64decode(s_2))
print(f" [{time.time()-t0:.0f}s] 완료. HTML {len(html_2)}")
else:
print(f" [{time.time()-t0:.0f}s] ❌ 실패")
print(f"\n총 소요: {time.time()-t0:.0f}")
print(f"결과: {out_dir}")
async def _call_kei(kei_url: str, prompt: str) -> str | None:
import httpx
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST", f"{kei_url}/api/message",
json={"message": prompt, "session_id": "verify-retry", "mode_hint": "chat"},
timeout=None,
) as response:
if response.status_code != 200:
return None
from src.sse_utils import stream_sse_tokens
full_text = await stream_sse_tokens(response)
if not full_text:
return None
match = re.search(r"```html\s*(.*?)```", full_text, re.DOTALL)
if match:
return match.group(1).strip()
match = re.search(r"(<(?:div|style|section)[^>]*>.*)", full_text, re.DOTALL)
if match:
return match.group(1).strip()
return full_text.strip()
except Exception as e:
print(f" Kei API 오류: {e}")
return None
def _wrap_in_container(inner_html: str, width: int, height: int) -> str:
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: white; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden;
background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{
width: {width}px;
}}
</style>
</head><body>
<div class="slide">
<div class="test-container">
{inner_html}
</div>
</div>
</body></html>"""
def _save(out_dir, name, data):
(out_dir / name).write_text(data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
asyncio.run(main())

108
scripts/verify_venn.py Normal file
View File

@@ -0,0 +1,108 @@
"""검증: DX 포함 관계를 겹치는 원(벤 다이어그램)으로 시각화.
3개 기술이 서로 겹쳐서 융합을 표현하고, DX가 전체를 감싸는 구조.
"""
from __future__ import annotations
import asyncio, json, sys, time, datetime, base64, re
from pathlib import Path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
async def main():
from src.slide_measurer import capture_slide_screenshot
from src.config import settings
import anthropic
out_dir = ROOT / "data" / "runs" / f"verify_venn_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
out_dir.mkdir(parents=True, exist_ok=True)
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
t0 = time.time()
prompt = """다음 포함 관계를 SVG 벤 다이어그램으로 시각화하는 HTML을 만들어라.
## 관계 구조
- DX(디지털 전환)는 상위개념이다 — 전체를 감싸는 가장 큰 원 또는 박스.
- DX 안에 GIS, BIM, 디지털 트윈 3개 기술이 있다.
- 이 3개 기술은 **서로 겹쳐서 융합**된다 — 벤 다이어그램처럼 원이 겹치는 부분이 있어야 한다.
- 3개가 겹치는 중심 영역 = "기술 융합" 또는 "DX 실현"
## 시각화 요구사항 (SVG 사용)
1. 전체 크기: 707px × 250px
2. DX 큰 원 또는 둥근 박스가 전체를 감싼다:
- fill: rgba(37,99,235,0.08), stroke: #2563eb, stroke-width: 2
- 상단 라벨: "DX (상위개념)"
3. 내부에 3개 원이 서로 겹쳐서 배치:
- GIS 원: cx=250, cy=120, r=80, fill: rgba(59,130,246,0.2), stroke: #3b82f6
- BIM 원: cx=350, cy=120, r=80, fill: rgba(16,185,129,0.2), stroke: #10b981
- 디지털트윈 원: cx=450, cy=120, r=80, fill: rgba(245,158,11,0.2), stroke: #f59e0b
- 각 원이 약 30-40px씩 겹쳐야 한다 (완전 분리 아님)
4. 각 원 안에 텍스트:
- 이름 (14px bold)
- 한 줄 설명 (10px)
5. 3개가 겹치는 중심 영역에 "융합" 또는 "DX 실현" 텍스트 (작게)
6. 아래에 핵심 메시지: "BIM ≠ DX — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다"
- background: #f0f9ff, border: 2px solid #bae6fd, border-radius: 8px
- "BIM ≠ DX" 부분: color: #dc2626, font-weight: 900
## 텍스트 (원본 그대로)
- GIS: "지리적 데이터를 공간 분석하여 시각적으로 표현"
- BIM: "시설물 생애주기 정보를 3차원 모델로 통합·관리"
- 디지털 트윈: "현실 객체를 디지털로 동일하게 구현"
HTML + inline <style> + <svg>를 포함하여 반환. 설명 없이 코드만."""
print("=== 벤 다이어그램 검증 (Claude Sonnet) ===")
try:
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
html = match.group(1).strip() if match else text.strip()
wrapped = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
.slide {{
width: 1280px; height: 720px; overflow: hidden;
background: white;
font-family: 'Pretendard Variable', sans-serif;
display: flex; align-items: center; justify-content: center;
}}
.test-container {{ width: 707px; }}
</style>
</head><body>
<div class="slide"><div class="test-container">
{html}
</div></div>
</body></html>"""
(out_dir / "venn.html").write_text(wrapped, encoding="utf-8")
s = await asyncio.to_thread(capture_slide_screenshot, wrapped)
if s:
(out_dir / "venn.png").write_bytes(base64.b64decode(s))
print(f" [{time.time()-t0:.0f}s] 완료. HTML {len(html)}")
print(f" 결과: {out_dir}")
except Exception as e:
print(f" 오류: {e}")
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("selenium").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
asyncio.run(main())

View File

@@ -175,6 +175,38 @@ def search_blocks_for_topics(
return _format_for_prompt(sorted_blocks) return _format_for_prompt(sorted_blocks)
def search_candidates_per_topic(
topics: list[dict],
top_k: int = 2,
) -> dict[int, list[dict]]:
"""Phase P: 각 topic별 FAISS 상위 후보를 반환한다.
Args:
topics: 1단계 꼭지 분석 결과
top_k: topic당 반환할 후보 수
Returns:
{topic_id: [블록 메타데이터 목록]} — 각 topic별 상위 top_k개
"""
if not _ensure_loaded():
return {}
result: dict[int, list[dict]] = {}
for topic in topics:
tid = topic.get("id")
if tid is None:
continue
query = _build_query(topic)
candidates = search_blocks(query, top_k=top_k + 2) # 여유분 확보 (중복 제거용)
result[tid] = candidates[:top_k]
logger.info(
f"[Phase P] topic별 FAISS 후보: "
+ ", ".join(f"t{tid}={[c['id'] for c in cs]}" for tid, cs in result.items())
)
return result
def _build_query(topic: dict) -> str: def _build_query(topic: dict) -> str:
"""꼭지 정보에서 검색 쿼리를 생성한다. (Phase M: 역할+관계+표현 추가)""" """꼭지 정보에서 검색 쿼리를 생성한다. (Phase M: 역할+관계+표현 추가)"""
parts = [ parts = [

View File

@@ -28,18 +28,18 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
## 핵심 원칙 ## 핵심 원칙
- **원본 텍스트를 최대한 보존한다.** 슬라이드 공간에 맞게 약간만 축약한다. - **원본 텍스트를 최대한 보존한다.** 슬라이드 공간에 맞게 약간만 축약한다.
- 의미를 바꾸거나 완전히 재작성하지 않는다. - 의미를 바꾸거나 완전히 재작성하지 않는다.
- 팀장이 제시한 글자 가이드는 참고. 의미를 살리려면 가이드를 초과해도 된다. - 글자수 예산(★ 표시)이 있으면 반드시 지킨다. 초과하면 overflow가 발생한다.
- 예산 내라면 원본을 최대한 보존. 예산 초과 시에만 뒤에서부터 축약.
- 디자인 실무자가 텍스트에 맞게 디자인을 조정할 것이므로, 텍스트를 억지로 자르지 않는다. - 디자인 실무자가 텍스트에 맞게 디자인을 조정할 것이므로, 텍스트를 억지로 자르지 않는다.
- **모든 슬롯을 빠짐없이 채운다. 빈 슬롯 금지.** - **모든 슬롯을 빠짐없이 채운다. 빈 슬롯 금지.**
## 편집 규칙 ## 편집 규칙
- 전체 컨텍스트와 핵심 용어를 보존한다 - 전체 컨텍스트와 핵심 용어를 보존한다
- 원본 표현을 살리되, 슬라이드에 맞게 약간만 다듬는다
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지. - 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
- 각 블록의 **목적(purpose)**을 보고 해당 목적에 맞는 텍스트를 원본에서 가져온다 - 각 블록의 **목적(purpose)**을 보고 해당 목적에 맞는 텍스트를 원본에서 가져온다
- **불릿 항목은 반드시 각각 별도 줄(\n)로 작성한다.** 한 줄에 여러 항목을 넣지 마라. - **불릿 항목은 반드시 각각 별도 줄(\n)로 작성한다.** 한 줄에 여러 항목을 넣지 마라.
- 올바른 예: "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입\n• 출처: 국토교통부" - 올바른 예: "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입"
- 잘못된 예: "• 추진과제: 건설산업 디지털화 • 실행과제: BIM 전면 도입 • 출처: 국토교통부" - 잘못된 예: "• 추진과제: 건설산업 디지털화 • 실행과제: BIM 전면 도입"
- 출처가 있는 내용은 출처를 반드시 보존한다 - 출처가 있는 내용은 출처를 반드시 보존한다
- 출처가 없는 수치나 통계를 만들지 않는다 - 출처가 없는 수치나 통계를 만들지 않는다
@@ -95,15 +95,31 @@ async def fill_content(
char_guide = block.get("char_guide", {}) char_guide = block.get("char_guide", {})
topic_id = block.get("topic_id", i + 1) topic_id = block.get("topic_id", i + 1)
# Phase Q: topic의 source_data를 찾아서 직접 전달
source_data_text = ""
if analysis:
for topic in analysis.get("topics", []):
if topic.get("id") == topic_id:
sd = topic.get("source_data", "")
if sd:
source_data_text = sd
break
req_text = ( req_text = (
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n" f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n"
f" 목적(purpose): {block.get('purpose', '미지정')}\n" f" 목적(purpose): {block.get('purpose', '미지정')}\n"
f" 용도: {block.get('reason', '미지정')}\n"
f" 크기: {block.get('size', 'medium')}\n"
f" 필수 슬롯: {slots.get('required', [])}\n" f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}" f" 선택 슬롯: {slots.get('optional', [])}"
) )
# source_data를 최우선으로 전달
if source_data_text:
req_text += (
f"\n ★★ source_data (이 텍스트를 그대로 슬롯에 배치하라):\n"
f" {source_data_text}"
)
# I-5: 슬롯 의미 설명 전달 (slot_desc가 있으면) # I-5: 슬롯 의미 설명 전달 (slot_desc가 있으면)
slot_desc = slots.get("slot_desc", {}) slot_desc = slots.get("slot_desc", {})
if slot_desc: if slot_desc:
@@ -114,9 +130,20 @@ async def fill_content(
guide_lines = [f" {k}: ~{v}" for k, v in char_guide.items()] guide_lines = [f" {k}: ~{v}" for k, v in char_guide.items()]
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines) req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
# Phase O-4: 컨테이너 기반 블록 스펙 전달 # Phase Q-3: 글자수 예산 전달 (char_budget 우선, 없으면 Phase O 스펙)
char_budget = block.get("_char_budget", {})
container_h = block.get("_container_height_px") container_h = block.get("_container_height_px")
if container_h:
if char_budget:
req_text += (
f"\n ★ 글자수 예산 (하드 제약 — 반드시 준수):"
f"\n - 최대 항목 수: {char_budget.get('max_items', '제한 없음')}"
f"\n - 항목당 최대 글자 수: {char_budget.get('chars_per_item', '제한 없음')}"
f"\n - 총 최대 글자 수: {char_budget.get('total_chars', '제한 없음')}"
f"\n - 폰트 크기: {char_budget.get('font_size_px', 15.2)}px"
f"\n 이 예산은 컨테이너 크기에서 수학적으로 도출됨. 초과 시 overflow 발생."
)
elif container_h:
max_items = block.get("_max_items", "제한 없음") max_items = block.get("_max_items", "제한 없음")
max_chars_item = block.get("_max_chars_per_item", "제한 없음") max_chars_item = block.get("_max_chars_per_item", "제한 없음")
max_chars_total = block.get("_max_chars_total", "제한 없음") max_chars_total = block.get("_max_chars_total", "제한 없음")
@@ -157,69 +184,102 @@ async def fill_content(
) )
user_prompt = ( user_prompt = (
f"## 원본 콘텐츠\n{content}\n\n" f"## 원본 콘텐츠 (참고용 — source_data가 있으면 source_data 우선)\n{content}\n\n"
f"## 블록 배치{page_label}\n" f"## 블록 배치{page_label}\n"
+ "\n".join(slot_requirements) + "\n".join(slot_requirements)
+ source_section + source_section
+ "\n\n## 요청\n" + "\n\n## 요청\n"
" 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n" "각 블록의 ★★ source_data를 해당 블록 슬롯에 그대로 배치하라.\n"
"원본에서 추출하라. 재작성하지 마라. 축약만 허용.\n" "source_data의 텍스트를 축약/요약/재작성하지 마라. 그대로 넣어라.\n"
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n" "글자수 예산 초과 시에만 뒤에서부터 잘라내라.\n"
"형식:\n" "형식:\n"
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}' '{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
) )
try: # Phase Q: 파싱 실패 시 재시도 (빈 data로 넘어가지 않는다)
# Kei API만 사용. fallback 없음. 성공할 때까지 무한 재시도. import asyncio
result_text = await _call_kei_editor_with_retry(user_prompt) MAX_FILL_RETRIES = 3
fill_success = False
filled = _parse_json(result_text) for fill_attempt in range(MAX_FILL_RETRIES):
try:
result_text = await _call_kei_editor_with_retry(user_prompt)
if filled and "blocks" in filled: filled = _parse_json(result_text)
for filled_block in filled["blocks"]:
matched = False
# 1차: topic_id로 정확 매칭
if filled_block.get("topic_id"):
for orig_block in blocks:
if orig_block.get("topic_id") == filled_block.get("topic_id"):
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
matched = True
break
# 2차: area + type으로 매칭 (topic_id 없을 때)
if not matched:
for orig_block in blocks:
if (
orig_block.get("area") == filled_block.get("area")
and orig_block.get("type") == filled_block.get("type")
and "data" not in orig_block
):
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
break
logger.info( if filled and "blocks" in filled:
f"텍스트 정리 완료 (페이지 {page_idx + 1}): " filled_count = 0
f"{len(filled['blocks'])}개 블록" for filled_block in filled["blocks"]:
) matched = False
else: # 1차: topic_id로 정확 매칭
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 재시도 필요하지만 텍스트는 받았으므로 진행.") if filled_block.get("topic_id"):
for orig_block in blocks:
if orig_block.get("topic_id") == filled_block.get("topic_id"):
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
matched = True
filled_count += 1
break
# 2차: area + type으로 매칭 (topic_id 없을 때)
if not matched:
for orig_block in blocks:
if (
orig_block.get("area") == filled_block.get("area")
and orig_block.get("type") == filled_block.get("type")
and "data" not in orig_block
):
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
filled_count += 1
break
except Exception as e: logger.info(
logger.error(f"텍스트 편집자 호출 실패: {e}", exc_info=True) f"텍스트 정리 완료 (페이지 {page_idx + 1}): "
raise f"{filled_count}/{len(filled['blocks'])}개 블록 매칭"
)
# 검증: data가 실제로 채워진 블록이 있는가?
blocks_with_data = [b for b in blocks if b.get("data") and b.get("topic_id") is not None]
if blocks_with_data:
fill_success = True
break
else:
logger.warning(
f"[fill_content] 파싱 성공했으나 매칭된 블록 0개 "
f"(시도 {fill_attempt + 1}/{MAX_FILL_RETRIES})"
)
else:
logger.warning(
f"[fill_content] JSON 파싱 실패 (시도 {fill_attempt + 1}/{MAX_FILL_RETRIES}). "
f"응답: {result_text[:200] if result_text else '(비어있음)'}"
)
except Exception as e:
logger.error(f"텍스트 편집자 호출 실패 (시도 {fill_attempt + 1}): {e}")
if fill_attempt == MAX_FILL_RETRIES - 1:
raise
# 재시도 전 대기
if fill_attempt < MAX_FILL_RETRIES - 1:
await asyncio.sleep(5)
if not fill_success:
# 최대 재시도 후에도 실패 — 에러 발생 (빈 data로 진행하지 않음)
empty_blocks = [b.get("type") for b in blocks if not b.get("data") and b.get("topic_id") is not None]
raise RuntimeError(
f"fill_content 최대 재시도({MAX_FILL_RETRIES}회) 후에도 "
f"데이터 채우기 실패. 빈 블록: {empty_blocks}"
)
return layout_concept return layout_concept
@@ -271,7 +331,115 @@ async def _call_kei_editor_with_retry(prompt: str) -> str:
# _apply_defaults 삭제됨 — Kei API 무한 재시도로 fallback 불필요. async def fill_candidates(
content: str,
topic: dict[str, Any],
candidates: list[dict[str, Any]],
analysis: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Phase P: 1개 topic의 후보 3개 블록을 한꺼번에 텍스트 편집한다.
Kei 편집자 1회 호출로 3개 블록 각각의 슬롯에 맞게 편집.
Args:
content: 원본 텍스트
topic: 해당 topic 정보 (id, title, purpose, source_hint 등)
candidates: 후보 블록 3개 (type, _container_height_px, _max_items 등 포함)
analysis: 1단계 분석 결과
Returns:
candidates 리스트에 data가 채워진 상태로 반환
"""
tid = topic.get("id", "?")
purpose = topic.get("purpose", "")
source_hint = topic.get("source_hint", "")
source_data = topic.get("source_data", "")
# 각 후보 블록의 슬롯 + 컨테이너 스펙 정리
block_sections = []
for i, block in enumerate(candidates):
block_type = block.get("type", "")
slots = BLOCK_SLOTS.get(block_type, {})
section = (
f"### 후보 {i+1}: {block_type}\n"
f" 필수 슬롯: {slots.get('required', [])}\n"
f" 선택 슬롯: {slots.get('optional', [])}"
)
slot_desc = slots.get("slot_desc", {})
if slot_desc:
desc_lines = [f" {k}: {v}" for k, v in slot_desc.items()]
section += "\n 슬롯 설명:\n" + "\n".join(desc_lines)
# Phase R: expression_hint + variant 전달
if topic.get("expression_hint"):
section += f"\n ★ 표현 의도: {topic['expression_hint']}"
variant = block.get("_variant", "default")
if variant != "default":
section += f"\n ★ 변형: {variant}"
# Phase Q: 글자수 예산 전달 (있으면 우선, 없으면 Phase O 스펙)
char_budget = block.get("_char_budget", {})
container_h = block.get("_container_height_px")
if char_budget:
section += (
f"\n ★ 글자수 예산 (하드 제약 — 초과 시 overflow):"
f"\n 총 글자: {char_budget.get('total_chars', '제한 없음')}"
f"\n 최대 항목: {char_budget.get('max_items', '제한 없음')}"
f"\n 항목당 글자: {char_budget.get('chars_per_item', '제한 없음')}"
)
elif container_h:
section += (
f"\n ★ 컨테이너 제약:"
f"\n 높이: {container_h}px"
f"\n 최대 항목: {block.get('_max_items', '제한 없음')}"
f"\n 항목당 글자: {block.get('_max_chars_per_item', '제한 없음')}"
f"\n 총 글자: {block.get('_max_chars_total', '제한 없음')}"
)
block_sections.append(section)
source_section = ""
if source_hint or source_data:
source_section = (
f"\n\n## 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)\n"
f" source_hint: {source_hint}\n"
f" source_data: {source_data}"
)
prompt = (
f"## 원본 콘텐츠\n{content}\n\n"
f"## 꼭지 {tid}: {topic.get('title', '')}\n"
f" 목적: {purpose}\n\n"
f"## 후보 블록 3개 — 각각의 슬롯에 맞게 텍스트를 편집하라\n\n"
+ "\n\n".join(block_sections)
+ source_section
+ "\n\n## 요청\n"
"위 3개 후보 블록 각각에 맞는 텍스트를 JSON으로 반환해줘.\n"
"원본에서 추출하라. 재작성 금지. 축약만 허용.\n"
"형식:\n"
'{"candidates": [\n'
' {"candidate_index": 0, "type": "블록타입", "data": {슬롯 키-값}},\n'
' {"candidate_index": 1, "type": "블록타입", "data": {슬롯 키-값}},\n'
' {"candidate_index": 2, "type": "블록타입", "data": {슬롯 키-값}}\n'
']}'
)
result_text = await _call_kei_editor_with_retry(prompt)
filled = _parse_json(result_text)
if filled and "candidates" in filled:
for filled_item in filled["candidates"]:
idx = filled_item.get("candidate_index", -1)
if 0 <= idx < len(candidates):
candidates[idx]["data"] = filled_item.get("data", {})
logger.info(f"[Phase P] 꼭지 {tid}: 후보 {len(filled['candidates'])}개 텍스트 편집 완료")
else:
logger.warning(f"[Phase P] 꼭지 {tid}: 텍스트 편집 파싱 실패")
return candidates
def _parse_json(text: str) -> dict[str, Any] | None: def _parse_json(text: str) -> dict[str, Any] | None:

View File

@@ -450,6 +450,99 @@ def _load_catalog() -> str:
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체. # Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
async def _opus_batch_recommend(
analysis: dict[str, Any],
faiss_candidates: dict[int, list[dict]],
container_specs: dict | None = None,
) -> dict[int, str]:
"""Phase P: 전체 topic을 한꺼번에 보여주고 topic별 Opus 추천 1개씩 받는다.
FAISS 후보 2개를 함께 보여주고, Opus가 도메인 지식으로 다른 1개를 추천.
1회 Kei API 호출로 전체 topic 처리.
Returns:
{topic_id: block_type} — 각 topic별 Opus 추천 블록
"""
import httpx
from src.sse_utils import stream_sse_tokens
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
# 각 topic의 정보 + FAISS 후보 정리
topic_sections = []
for topic in analysis.get("topics", []):
tid = topic.get("id")
faiss_blocks = faiss_candidates.get(tid, [])
faiss_ids = [b["id"] for b in faiss_blocks]
# 컨테이너 제약 정보
container_info = ""
if container_specs:
from src.space_allocator import find_container_for_topic
spec = find_container_for_topic(tid, container_specs)
if spec:
per_topic = spec.height_px // max(1, len(spec.topic_ids))
container_info = f"컨테이너: {per_topic}px, 허용 height_cost: {spec.max_height_cost} 이하"
topic_sections.append(
f"- 꼭지 {tid}: {topic.get('title', '')}\n"
f" purpose: {topic.get('purpose', '')}\n"
f" relation_type: {topic.get('relation_type', '')}\n"
f" expression_hint: {topic.get('expression_hint', '')}\n"
f" FAISS 후보: {faiss_ids}\n"
f" {container_info}"
)
prompt = (
"아래 각 꼭지에 대해 FAISS가 추천한 블록 2개를 참고하되,\n"
"도메인 지식을 활용하여 **FAISS 후보에 없는 다른 블록 1개**를 추천해줘.\n"
"FAISS 후보와 중복되면 안 된다.\n"
"각 꼭지의 purpose, relation_type, expression_hint를 보고\n"
"**콘텐츠의 의미와 목적에 가장 적합한** 블록을 추천하라.\n"
"컨테이너 크기 제약도 반드시 고려하라.\n\n"
f"## 꼭지 목록\n" + "\n".join(topic_sections) +
"\n\n## 출력 (JSON만)\n"
'{"recommendations": [{"topic_id": 1, "block_type": "...", "reason": "..."}]}'
)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
f"{kei_url}/api/message",
json={
"message": prompt,
"session_id": "design-agent-p-recommend",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[Phase P] Opus 배치 추천 HTTP {response.status_code}")
return {}
full_text = await stream_sse_tokens(response)
if not full_text:
return {}
result = _parse_json(full_text)
if result and "recommendations" in result:
mapping = {}
for rec in result["recommendations"]:
tid = rec.get("topic_id") or rec.get("id")
if tid is not None:
mapping[tid] = rec.get("block_type", "")
logger.info(f"[Phase P] Opus 배치 추천: {mapping}")
return mapping
logger.warning(f"[Phase P] Opus 배치 추천 JSON 파싱 실패: {full_text[:200]}")
return {}
except Exception as e:
logger.warning(f"[Phase P] Opus 배치 추천 실패: {e}")
return {}
async def _opus_block_recommendation( async def _opus_block_recommendation(
analysis: dict[str, Any], analysis: dict[str, Any],
block_candidates: str, block_candidates: str,

View File

@@ -217,6 +217,76 @@ def format_measurement_for_kei(
return "\n".join(lines) return "\n".join(lines)
def measure_candidate_block(html: str) -> dict[str, Any]:
"""Phase P: 단일 후보 블록을 렌더링하여 높이 측정 + 스크린샷 캡처.
Args:
html: render_block_in_container()로 생성된 완전한 HTML
Returns:
{
"scrollHeight": 실제 콘텐츠 높이,
"containerHeight": 컨테이너 높이,
"overflowed": 넘침 여부,
"excess_px": 초과 px,
"screenshot_b64": base64 PNG 문자열
}
"""
options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--force-device-scale-factor=1")
options.add_argument("--window-size=1400,900")
driver = None
try:
driver = webdriver.Chrome(options=options)
import urllib.parse
encoded = urllib.parse.quote(html)
driver.get(f"data:text/html;charset=utf-8,{encoded}")
try:
driver.execute_script("return document.fonts.ready")
except Exception:
pass
result = driver.execute_script("""
var container = document.querySelector('.candidate-container');
if (!container) return {error: 'container not found'};
return {
scrollHeight: container.scrollHeight,
containerHeight: parseInt(container.style.height) || container.clientHeight,
overflowed: container.scrollHeight > container.clientHeight + 2,
excess_px: Math.max(0, container.scrollHeight - container.clientHeight)
};
""")
if not result or "error" in result:
return {"scrollHeight": 0, "containerHeight": 0, "overflowed": False, "excess_px": 0, "screenshot_b64": None}
# 스크린샷 캡처
from selenium.webdriver.common.by import By
container = driver.find_element(By.CSS_SELECTOR, ".candidate-container")
screenshot_b64 = container.screenshot_as_base64
result["screenshot_b64"] = screenshot_b64
return result
except Exception as e:
logger.warning(f"[Phase P] 후보 블록 측정 실패: {e}")
return {"scrollHeight": 0, "containerHeight": 0, "overflowed": False, "excess_px": 0, "screenshot_b64": None}
finally:
if driver:
try:
driver.quit()
except Exception:
pass
def capture_slide_screenshot(html: str) -> str | None: def capture_slide_screenshot(html: str) -> str | None:
"""Phase N-4: 렌더링된 슬라이드의 스크린샷을 base64 PNG로 캡처한다. """Phase N-4: 렌더링된 슬라이드의 스크린샷을 base64 PNG로 캡처한다.

View File

@@ -0,0 +1,52 @@
<!-- card-icon-desc variant: compact -->
<!--
📋 card-icon-desc--compact
─────────────────
용도: 높이가 부족할 때 아이콘 카드를 축소 렌더링
슬롯: cards[] (기존과 동일 — icon, title, description)
기존 card-icon-desc의 색상/구조 유지, 패딩/아이콘 축소
-->
<div class="block-card-icon-compact" style="--ci-count: {{ column_override | default(cards|length) }}">
{% for card in cards %}
<div class="cid-card-c">
{% if card.icon %}<div class="cid-icon-c">{{ card.icon }}</div>{% endif %}
<div class="cid-title-c">{{ card.title }}</div>
{% if card.description %}<div class="cid-desc-c">{{ card.description }}</div>{% endif %}
</div>
{% endfor %}
</div>
<style>
.block-card-icon-compact {
display: grid;
grid-template-columns: repeat(var(--ci-count, 3), 1fr);
gap: 8px;
}
.cid-card-c {
text-align: center;
padding: 8px 8px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.cid-icon-c {
font-size: 1.4rem;
margin-bottom: 3px;
}
.cid-title-c {
font-size: 12px;
font-weight: 700;
color: #1e293b;
margin-bottom: 2px;
}
.cid-desc-c {
font-size: 10px;
color: #475569;
line-height: 1.5;
white-space: pre-line;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,60 @@
<!-- card-numbered variant: horizontal -->
<!--
📋 card-numbered--horizontal
─────────────────
용도: 같은 구조의 항목 2-3개를 가로로 나란히 비교
슬롯: items[] (기존과 동일 — title, description)
기존 card-numbered의 색상/스타일 유지, 배치만 가로
-->
<div class="block-card-num-h" style="--cn-count: {{ items|length }}">
{% for item in items %}
<div class="cn-item-h">
<div class="cn-number-h" style="background: {{ item.color | default('#2563eb') }}">{{ loop.index }}</div>
<div class="cn-body-h">
<div class="cn-title-h">{{ item.title }}</div>
<div class="cn-desc-h">{{ item.description }}</div>
</div>
</div>
{% endfor %}
</div>
<style>
.block-card-num-h {
display: grid;
grid-template-columns: repeat(var(--cn-count, 2), 1fr);
gap: 10px;
}
.cn-item-h {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.cn-number-h {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
.cn-title-h {
font-size: 13px;
font-weight: 700;
color: #1e293b;
margin-bottom: 2px;
}
.cn-desc-h {
font-size: 11px;
color: #475569;
line-height: 1.6;
white-space: pre-line;
}
</style>

View File

@@ -0,0 +1,97 @@
<!-- comparison-2col variant: cards-in-container -->
<!--
📋 comparison-2col--cards-in-container
─────────────────
용도: 포함 관계 시각화 (A 안에 B, C, D가 포함됨)
슬롯: container_label, container_desc, cards[] (각 카드에 letter, label, description)
기존 comparison-2col의 색상 계열 활용
-->
<div class="block-container-cards">
<div class="cc-outer">
<div class="cc-badge">{{ container_label }}</div>
{% if container_desc %}<div class="cc-desc">{{ container_desc }}</div>{% endif %}
<div class="cc-grid" style="--cc-count: {{ cards|length }}">
{% for card in cards %}
<div class="cc-card">
{% if card.letter %}
<div class="cc-icon">{{ card.letter }}</div>
{% endif %}
<div class="cc-label">{{ card.label }}</div>
{% if card.description %}<div class="cc-text">{{ card.description }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
<style>
.block-container-cards {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.cc-outer {
width: 100%;
border: 3px solid #2563eb;
border-radius: 14px;
padding: 16px 14px 12px;
background: linear-gradient(180deg, #eff6ff, #dbeafe);
position: relative;
}
.cc-badge {
position: absolute;
top: -11px;
left: 50%;
transform: translateX(-50%);
background: #2563eb;
color: #ffffff;
font-size: 12px;
font-weight: 900;
padding: 3px 18px;
border-radius: 10px;
white-space: nowrap;
}
.cc-desc {
text-align: center;
font-size: 11px;
color: #1e40af;
margin-bottom: 10px;
}
.cc-grid {
display: grid;
grid-template-columns: repeat(var(--cc-count, 3), 1fr);
gap: 10px;
}
.cc-card {
background: #ffffff;
border: 2px solid #93c5fd;
border-radius: 8px;
padding: 10px;
text-align: center;
}
.cc-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #93c5fd, #2563eb);
color: #ffffff;
font-size: 16px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
}
.cc-label {
font-size: 13px;
font-weight: 700;
color: #1e293b;
margin-bottom: 3px;
}
.cc-text {
font-size: 10px;
color: #64748b;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,65 @@
<!-- dark-bullet-list variant: before-after -->
<!--
📋 dark-bullet-list--before-after
─────────────────
용도: 기존 방식 → 새 방식 전환/변화를 보여줄 때
슬롯: title (선택), changes[] (각 항목에 label, before, after)
기존 dark-bullet-list의 색상/배경/radius 그대로 사용
-->
<div class="block-dark-bullets">
{% if title %}<div class="db-title">{{ title }}</div>{% endif %}
<div class="db-changes">
{% for item in changes %}
<div class="db-change">
<div class="db-change-label">{{ item.label }}</div>
<div class="db-change-before">{{ item.before }}</div>
<div class="db-change-after">→ {{ item.after }}</div>
</div>
{% endfor %}
</div>
</div>
<style>
/* 기존 dark-bullet-list CSS 재사용 */
.block-dark-bullets {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-radius: 8px;
padding: 14px 20px;
color: #ffffff;
}
.db-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
color: #93c5fd;
}
/* variant: before-after 2열 구조 */
.db-changes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 6px;
}
.db-change {
background: rgba(255,255,255,0.06);
border-radius: 6px;
padding: 8px 10px;
border-left: 3px solid #60a5fa;
}
.db-change-label {
font-size: 11px;
font-weight: 700;
color: #93c5fd;
margin-bottom: 3px;
}
.db-change-before {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
}
.db-change-after {
font-size: 11px;
color: #e2e8f0;
font-weight: 500;
margin-top: 2px;
}
</style>

View File

@@ -1,12 +1,21 @@
version: '2.0' version: '4.0'
# Phase Q: min_height_px, relation_types, category, min_items, max_items
# Phase R: variants[] — 블록 변형. 기존 CSS를 유지하면서 내부 구조만 변경.
# variants[].id: 변형 ID (default = 기존 블록 그대로)
# variants[].description: 변형 설명
# variants[].template: 변형 전용 템플릿 경로 (없으면 기존 template 사용)
# variants[].when: 이 변형이 적합한 상황
blocks: blocks:
# ═══════════════════════════════════════ # ═══════════════════════════════════════
# HEADERS (5개) — 꼭지/섹션 제목용 # HEADERS (5개) — 꼭지/섹션 제목용
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: section-title-with-bg - id: section-title-with-bg
name: 배경 이미지 타이틀 name: 배경 이미지 타이틀
category: headers
template: blocks/headers/section-title-with-bg.html template: blocks/headers/section-title-with-bg.html
height_cost: large height_cost: large
min_height_px: 300
relation_types: []
visual: 전체 너비 배경 이미지(파란 그라데이션+웨이브) 위에 흰색 영문 소제목(15px) + 한글 대제목(35px). 높이 약 500px. visual: 전체 너비 배경 이미지(파란 그라데이션+웨이브) 위에 흰색 영문 소제목(15px) + 한글 대제목(35px). 높이 약 500px.
when: '자세히보기(detail) 페이지의 맨 첫 화면 전용. 배경 이미지 위에 타이틀을 올려 페이지 주제를 시각적으로 강렬하게 선언할 때.' when: '자세히보기(detail) 페이지의 맨 첫 화면 전용. 배경 이미지 위에 타이틀을 올려 페이지 주제를 시각적으로 강렬하게 선언할 때.'
not_for: '일반 슬라이드 내부 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → topic-center. 높이 200px 이하 → section-header-bar.' not_for: '일반 슬라이드 내부 소제목 → topic-left-right 또는 topic-center 사용. 배경 이미지 없이 텍스트만 → topic-center. 높이 200px 이하 → section-header-bar.'
@@ -17,8 +26,11 @@ blocks:
- id: section-header-bar - id: section-header-bar
name: 섹션 헤더 바 name: 섹션 헤더 바
category: headers
template: blocks/headers/section-header-bar.html template: blocks/headers/section-header-bar.html
height_cost: compact height_cost: compact
min_height_px: 40
relation_types: []
visual: 전체 너비 파란 배경 바(~50px) + 중앙 흰색 제목. 섹션 구분용. 컴팩트. visual: 전체 너비 파란 배경 바(~50px) + 중앙 흰색 제목. 섹션 구분용. 컴팩트.
when: '같은 페이지 안에서 주제 전환이 필요할 때. 높이 예산이 적을 때 섹션 구분.' when: '같은 페이지 안에서 주제 전환이 필요할 때. 높이 예산이 적을 때 섹션 구분.'
not_for: '페이지 전체 타이틀 → section-title-with-bg. 꼭지별 소제목 → topic-left-right 또는 topic-numbered.' not_for: '페이지 전체 타이틀 → section-title-with-bg. 꼭지별 소제목 → topic-left-right 또는 topic-numbered.'
@@ -32,8 +44,11 @@ blocks:
- id: topic-left-right - id: topic-left-right
name: 좌우 꼭지 헤더 name: 좌우 꼭지 헤더
category: headers
template: blocks/headers/topic-left-right.html template: blocks/headers/topic-left-right.html
height_cost: compact height_cost: compact
min_height_px: 50
relation_types: []
visual: 좌측에 파란 굵은 제목(24px, 240px 고정) + 우측에 본문 설명(16px). 가로 2단. visual: 좌측에 파란 굵은 제목(24px, 240px 고정) + 우측에 본문 설명(16px). 가로 2단.
when: '좌측에 핵심 주장/질문, 우측에 근거/설명을 배치하는 구조. 문제 제기의 도입부로 적합. 예: "용어의 혼용" + "DX와 BIM이 혼용되고 있다..."' when: '좌측에 핵심 주장/질문, 우측에 근거/설명을 배치하는 구조. 문제 제기의 도입부로 적합. 예: "용어의 혼용" + "DX와 BIM이 혼용되고 있다..."'
not_for: '중앙 정렬 대제목 → topic-center. 번호가 붙은 순서형 → topic-numbered. 섹션 전체 타이틀 → section-title-with-bg.' not_for: '중앙 정렬 대제목 → topic-center. 번호가 붙은 순서형 → topic-numbered. 섹션 전체 타이틀 → section-title-with-bg.'
@@ -47,8 +62,11 @@ blocks:
- id: topic-center - id: topic-center
name: 중앙 정렬 꼭지 헤더 name: 중앙 정렬 꼭지 헤더
category: headers
template: blocks/headers/topic-center.html template: blocks/headers/topic-center.html
height_cost: medium height_cost: medium
min_height_px: 60
relation_types: []
visual: 중앙 정렬 대제목(26px 굵게) + 파란 서브타이틀 + 하단 설명. 단독 강조. visual: 중앙 정렬 대제목(26px 굵게) + 파란 서브타이틀 + 하단 설명. 단독 강조.
when: '하나의 주제를 페이지 중심에 크게 선언할 때. sidebar 영역의 섹션 라벨로도 사용 가능.' when: '하나의 주제를 페이지 중심에 크게 선언할 때. sidebar 영역의 섹션 라벨로도 사용 가능.'
not_for: '좌:제목 우:설명 구조 → topic-left-right. 번호 순서 → topic-numbered.' not_for: '좌:제목 우:설명 구조 → topic-left-right. 번호 순서 → topic-numbered.'
@@ -63,8 +81,11 @@ blocks:
- id: topic-numbered - id: topic-numbered
name: 번호 꼭지 헤더 name: 번호 꼭지 헤더
category: headers
template: blocks/headers/topic-numbered.html template: blocks/headers/topic-numbered.html
height_cost: compact height_cost: compact
min_height_px: 45
relation_types: []
visual: 파란 원형 번호(①②③) + 굵은 제목 + 파란 구분선 + 설명. 세로 배치. visual: 파란 원형 번호(①②③) + 굵은 제목 + 파란 구분선 + 설명. 세로 배치.
when: '순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션.' when: '순서가 있는 꼭지를 시작할 때. 1번, 2번, 3번 식의 단계별 섹션.'
not_for: '순서 없는 꼭지 → topic-left-right 또는 topic-center. 카드 안의 순서 → card-numbered.' not_for: '순서 없는 꼭지 → topic-left-right 또는 topic-center. 카드 안의 순서 → card-numbered.'
@@ -74,12 +95,17 @@ blocks:
optional: [description, color] optional: [description, color]
# ═══════════════════════════════════════ # ═══════════════════════════════════════
# CARDS (10개) — 항목 나열/비교용 # CARDS (9개) — 항목 나열/비교용
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: card-image-3col - id: card-image-3col
name: 이미지 카드 3열 name: 이미지 카드 3열
category: cards
template: blocks/cards/card-image-3col.html template: blocks/cards/card-image-3col.html
height_cost: large height_cost: large
min_height_px: 250
relation_types: []
min_items: 2
max_items: 3
visual: 3열 카드. 각 카드 상단에 이미지(160px) + 하단에 색상 밑줄 제목 + 불릿 목록. visual: 3열 카드. 각 카드 상단에 이미지(160px) + 하단에 색상 밑줄 제목 + 불릿 목록.
when: '이미지가 핵심인 항목 3개를 나란히. 예: 설계단계(3D모델) / 시공단계(현장) / 유지관리(자산).' when: '이미지가 핵심인 항목 3개를 나란히. 예: 설계단계(3D모델) / 시공단계(현장) / 유지관리(자산).'
not_for: '이미지 없이 텍스트만 → card-icon-desc. 키워드+짧은 설명만 → card-dark-overlay. 2개 비교 → compare-pill-pair.' not_for: '이미지 없이 텍스트만 → card-icon-desc. 키워드+짧은 설명만 → card-dark-overlay. 2개 비교 → compare-pill-pair.'
@@ -90,8 +116,13 @@ blocks:
- id: card-dark-overlay - id: card-dark-overlay
name: 다크 오버레이 카드 name: 다크 오버레이 카드
category: cards
template: blocks/cards/card-dark-overlay.html template: blocks/cards/card-dark-overlay.html
height_cost: medium height_cost: medium
min_height_px: 100
relation_types: []
min_items: 3
max_items: 5
visual: 3~5열 카드. 다크 배경 이미지 + 그라데이션 오버레이 + 흰색 굵은 제목 + 짧은 설명. visual: 3~5열 카드. 다크 배경 이미지 + 그라데이션 오버레이 + 흰색 굵은 제목 + 짧은 설명.
when: '키워드를 시각적으로 강조할 때. 짧은 설명(2줄 이내)과 함께. 예: 협업지원 / 오류감소 / 생산성향상.' when: '키워드를 시각적으로 강조할 때. 짧은 설명(2줄 이내)과 함께. 예: 협업지원 / 오류감소 / 생산성향상.'
not_for: '긴 설명(3줄+) → card-icon-desc. 이미지가 크게 보여야 함 → card-image-3col. 순서/단계 → process-horizontal.' not_for: '긴 설명(3줄+) → card-icon-desc. 이미지가 크게 보여야 함 → card-image-3col. 순서/단계 → process-horizontal.'
@@ -107,8 +138,13 @@ blocks:
- id: card-tag-image - id: card-tag-image
name: 태그 이미지 카드 name: 태그 이미지 카드
category: cards
template: blocks/cards/card-tag-image.html template: blocks/cards/card-tag-image.html
height_cost: large height_cost: large
min_height_px: 250
relation_types: []
min_items: 2
max_items: 3
visual: 3열 카드. 좌상단 색상 태그 라벨 + 이미지 + 제목 + 설명. visual: 3열 카드. 좌상단 색상 태그 라벨 + 이미지 + 제목 + 설명.
when: '카테고리 태그로 분류가 핵심일 때. 예: 제조업(파란) / 건축(초록) / 인프라(빨간).' when: '카테고리 태그로 분류가 핵심일 때. 예: 제조업(파란) / 건축(초록) / 인프라(빨간).'
not_for: '태그 불필요 → card-image-3col. 이미지 없음 → card-icon-desc.' not_for: '태그 불필요 → card-image-3col. 이미지 없음 → card-icon-desc.'
@@ -119,10 +155,22 @@ blocks:
- id: card-icon-desc - id: card-icon-desc
name: 아이콘 설명 카드 name: 아이콘 설명 카드
category: cards
template: blocks/cards/card-icon-desc.html template: blocks/cards/card-icon-desc.html
height_cost: medium height_cost: medium
min_height_px: 120
relation_types: [definition]
min_items: 2
max_items: 4
variants:
- id: default
description: 아이콘 + 제목 + 설명 (기본 그리드)
- id: compact
description: 아이콘 축소, 설명 2줄 제한, 패딩 축소 (높이 부족 시)
template: blocks/cards/card-icon-desc--compact.html
when: "컨테이너 높이가 150px 미만일 때"
visual: 2~4열. 중앙 큰 이모지 아이콘(2.5rem) + 굵은 제목 + 설명. 밝은 배경. visual: 2~4열. 중앙 큰 이모지 아이콘(2.5rem) + 굵은 제목 + 설명. 밝은 배경.
when: '독립적인 항목/개념/특성을 이모지 아이콘과 함께 나열. 순서 없는 개별 항목. 예: 🔧기술기반 / 💻S/W역량 / 🌏여건조성. 독립 사례를 각각 아이콘으로 구분하여 나열할 때도 적합.' when: '독립적인 항목/개념/특성을 이모지 아이콘과 함께 나열. 순서 없는 개별 항목. 예: 🔧기술기반 / 💻S/W역량 / 🌏여건조성.'
not_for: '이미지(사진) 필요 → card-image-3col. 순서 번호 필요 → card-numbered. 텍스트만(아이콘 불필요) → dark-bullet-list.' not_for: '이미지(사진) 필요 → card-image-3col. 순서 번호 필요 → card-numbered. 텍스트만(아이콘 불필요) → dark-bullet-list.'
purpose_fit: [핵심전달, 근거사례, 구조시각화] purpose_fit: [핵심전달, 근거사례, 구조시각화]
zone: full-width-only zone: full-width-only
@@ -136,8 +184,13 @@ blocks:
- id: card-compare-3col - id: card-compare-3col
name: 3단 비교 카드 name: 3단 비교 카드
category: cards
template: blocks/cards/card-compare-3col.html template: blocks/cards/card-compare-3col.html
height_cost: large height_cost: large
min_height_px: 200
relation_types: [comparison]
min_items: 3
max_items: 3
visual: 3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿 목록. visual: 3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿 목록.
when: '3개 카테고리를 비교할 때. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강).' when: '3개 카테고리를 비교할 때. 각 카테고리에 다른 색상 헤더. 예: 상용SW(회색) vs 3rd Party(파랑) vs 전문SW(빨강).'
not_for: '2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge.' not_for: '2개 비교 → compare-pill-pair + compare-2col-split. 다항목 표 → compare-3col-badge.'
@@ -153,8 +206,13 @@ blocks:
- id: card-step-vertical - id: card-step-vertical
name: 세로 단계 카드 name: 세로 단계 카드
category: cards
template: blocks/cards/card-step-vertical.html template: blocks/cards/card-step-vertical.html
height_cost: xlarge height_cost: xlarge
min_height_px: 250
relation_types: [sequence]
min_items: 2
max_items: 4
visual: 세로 나열. 좌측 색상 마커(단계명) + 우측 콘텐츠 박스(제목+이미지+설명). 연결선. visual: 세로 나열. 좌측 색상 마커(단계명) + 우측 콘텐츠 박스(제목+이미지+설명). 연결선.
when: '생애주기/프로세스 단계별 설명. 각 단계에 이미지+상세 설명. 예: 설계→시공→운영 단계.' when: '생애주기/프로세스 단계별 설명. 각 단계에 이미지+상세 설명. 예: 설계→시공→운영 단계.'
not_for: '가로 흐름(간단) → process-horizontal. 높이 예산 부족 → card-numbered. 독립 사례(순서 아님) → card-icon-desc.' not_for: '가로 흐름(간단) → process-horizontal. 높이 예산 부족 → card-numbered. 독립 사례(순서 아님) → card-icon-desc.'
@@ -169,8 +227,13 @@ blocks:
- id: card-image-round - id: card-image-round
name: 원형 이미지 카드 name: 원형 이미지 카드
category: cards
template: blocks/cards/card-image-round.html template: blocks/cards/card-image-round.html
height_cost: large height_cost: large
min_height_px: 200
relation_types: []
min_items: 2
max_items: 3
visual: 2~3열. 원형 이미지(140px, 테두리+그림자) + 제목 + 설명. 중앙 정렬. visual: 2~3열. 원형 이미지(140px, 테두리+그림자) + 제목 + 설명. 중앙 정렬.
when: '포트폴리오형 나열. 비전/가치 표현. 원형 이미지가 있는 경우.' when: '포트폴리오형 나열. 비전/가치 표현. 원형 이미지가 있는 경우.'
not_for: '사각형 이미지 → card-image-3col. 이미지 없음 → card-icon-desc.' not_for: '사각형 이미지 → card-image-3col. 이미지 없음 → card-icon-desc.'
@@ -181,8 +244,13 @@ blocks:
- id: card-stat-number - id: card-stat-number
name: 통계 숫자 카드 name: 통계 숫자 카드
category: cards
template: blocks/cards/card-stat-number.html template: blocks/cards/card-stat-number.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: []
min_items: 2
max_items: 4
visual: 2~4열. 매우 큰 숫자(36px, 색상) + 단위 + 라벨 + 설명. visual: 2~4열. 매우 큰 숫자(36px, 색상) + 단위 + 라벨 + 설명.
when: 'KPI, 성과 수치, 달성률, 비용 절감율 등 숫자가 핵심인 데이터. 예: 30% 절감 / 220명+.' when: 'KPI, 성과 수치, 달성률, 비용 절감율 등 숫자가 핵심인 데이터. 예: 30% 절감 / 220명+.'
not_for: '숫자가 아닌 텍스트 항목 → card-icon-desc. 비교 구조 → compare-3col-badge.' not_for: '숫자가 아닌 텍스트 항목 → card-icon-desc. 비교 구조 → compare-3col-badge.'
@@ -193,8 +261,20 @@ blocks:
- id: card-numbered - id: card-numbered
name: 번호 항목 카드 name: 번호 항목 카드
category: cards
template: blocks/cards/card-numbered.html template: blocks/cards/card-numbered.html
height_cost: medium height_cost: medium
min_height_px: 55
relation_types: [definition]
min_items: 1
max_items: 5
variants:
- id: default
description: 번호 + 제목 + 설명 (세로 나열)
- id: horizontal
description: 항목을 가로 2열로 배치 (사례 비교, 같은 구조 항목 나란히)
template: blocks/cards/card-numbered--horizontal.html
when: "같은 구조의 항목 2-3개를 나란히 비교할 때"
visual: 세로 나열. 색상 원형 번호(①②③) + 제목 + 설명. 밝은 배경 카드. visual: 세로 나열. 색상 원형 번호(①②③) + 제목 + 설명. 밝은 배경 카드.
when: '번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 2.BIM 3.DX). 조건/요구사항 나열.' when: '번호가 의미 있는 항목 나열. 순서가 있는 단계(1→2→3)이거나, 번호로 구분되는 정의 목록. sidebar 용어 정의에 적합(1.건설산업 2.BIM 3.DX). 조건/요구사항 나열.'
not_for: '순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal.' not_for: '순서 없는 독립 항목 → card-icon-desc. 이미지 포함 단계 → card-step-vertical. 가로 흐름 → process-horizontal.'
@@ -208,8 +288,11 @@ blocks:
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: compare-3col-badge - id: compare-3col-badge
name: VS 배지 비교표 name: VS 배지 비교표
category: tables
template: blocks/tables/compare-3col-badge.html template: blocks/tables/compare-3col-badge.html
height_cost: large height_cost: large
min_height_px: 150
relation_types: [comparison]
visual: 3단 테이블. 좌(하늘색 헤더) | 중앙(파란 VS 배지) | 우(파란 헤더). 행별 비교. visual: 3단 테이블. 좌(하늘색 헤더) | 중앙(파란 VS 배지) | 우(파란 헤더). 행별 비교.
when: '두 개념의 다항목 비교(5행 이상). 구분 기준(중앙)을 두고 좌우로 비교. 예: BIM vs DX — S/W, 프로세스, 성과물 비교.' when: '두 개념의 다항목 비교(5행 이상). 구분 기준(중앙)을 두고 좌우로 비교. 예: BIM vs DX — S/W, 프로세스, 성과물 비교.'
not_for: '시각적 대비(짧음) → compare-pill-pair. 2단 분할 → compare-2col-split. 범용 데이터 → table-simple-striped. A vs B 간단 비교(2~3행) → comparison-2col.' not_for: '시각적 대비(짧음) → compare-pill-pair. 2단 분할 → compare-2col-split. 범용 데이터 → table-simple-striped. A vs B 간단 비교(2~3행) → comparison-2col.'
@@ -223,8 +306,11 @@ blocks:
- id: compare-2col-split - id: compare-2col-split
name: 2단 분할 비교표 name: 2단 분할 비교표
category: tables
template: blocks/tables/compare-2col-split.html template: blocks/tables/compare-2col-split.html
height_cost: large height_cost: large
min_height_px: 150
relation_types: [comparison]
visual: 파란 헤더(좌/구분/우) + 행별 좌:항목 | 중앙:기준 라벨(파란) | 우:항목. 상세 비교. visual: 파란 헤더(좌/구분/우) + 행별 좌:항목 | 중앙:기준 라벨(파란) | 우:항목. 상세 비교.
when: '두 기술/개념의 항목별 상세 비교. 중앙에 비교 기준 라벨. 예: DX vs BIM — 정의/범위/역할 비교. 원본에 이미 비교표 데이터가 있을 때.' when: '두 기술/개념의 항목별 상세 비교. 중앙에 비교 기준 라벨. 예: DX vs BIM — 정의/범위/역할 비교. 원본에 이미 비교표 데이터가 있을 때.'
not_for: 'VS 배지 → compare-3col-badge. 범용 데이터 → table-simple-striped. 간단 A vs B(2~3항목) → comparison-2col.' not_for: 'VS 배지 → compare-3col-badge. 범용 데이터 → table-simple-striped. 간단 A vs B(2~3항목) → comparison-2col.'
@@ -239,8 +325,11 @@ blocks:
- id: table-simple-striped - id: table-simple-striped
name: 범용 줄무늬 테이블 name: 범용 줄무늬 테이블
category: tables
template: blocks/tables/table-simple-striped.html template: blocks/tables/table-simple-striped.html
height_cost: medium height_cost: medium
min_height_px: 100
relation_types: []
visual: 진한 남색 헤더 + 줄무늬 행 교차. 첫 열 굵은 글씨. 범용 데이터 표. visual: 진한 남색 헤더 + 줄무늬 행 교차. 첫 열 굵은 글씨. 범용 데이터 표.
when: '비교가 아닌 일반 데이터 표. 스펙표, 일정표, 항목 목록. 예: 구분/현재/목표/비고.' when: '비교가 아닌 일반 데이터 표. 스펙표, 일정표, 항목 목록. 예: 구분/현재/목표/비고.'
not_for: 'A vs B 비교 → compare-3col-badge 또는 compare-2col-split.' not_for: 'A vs B 비교 → compare-3col-badge 또는 compare-2col-split.'
@@ -254,8 +343,13 @@ blocks:
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: venn-diagram - id: venn-diagram
name: SVG 벤 다이어그램 name: SVG 벤 다이어그램
category: visuals
template: blocks/visuals/venn-diagram.html template: blocks/visuals/venn-diagram.html
height_cost: xlarge height_cost: xlarge
min_height_px: 300
relation_types: [hierarchy, inclusion]
min_items: 2
max_items: 5
visual: SVG. 진한 파란 큰 원(중심) + 3~5개 작은 원(주황/민트/골드 등). 그라데이션+글로우. 동적 N-item 지원. visual: SVG. 진한 파란 큰 원(중심) + 3~5개 작은 원(주황/민트/골드 등). 그라데이션+글로우. 동적 N-item 지원.
when: '상위-하위 포함 관계를 시각화. 기술 융합/포함 구조. 예: DX 안에 GIS/BIM/디지털트윈. relation_type=hierarchy 또는 inclusion일 때. ★ 반드시 단독 배치. 다른 블록과 같은 zone에 쌓으면 공간 부족.' when: '상위-하위 포함 관계를 시각화. 기술 융합/포함 구조. 예: DX 안에 GIS/BIM/디지털트윈. relation_type=hierarchy 또는 inclusion일 때. ★ 반드시 단독 배치. 다른 블록과 같은 zone에 쌓으면 공간 부족.'
not_for: '텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지. 순차 흐름(A→B→C) → process-horizontal. 대등 비교 → compare-pill-pair.' not_for: '텍스트로 관계 설명 가능하면 사용 금지. sidebar(35%) 배치 금지. 높이 300px 미만 금지. 순차 흐름(A→B→C) → process-horizontal. 대등 비교 → compare-pill-pair.'
@@ -266,8 +360,11 @@ blocks:
- id: circle-gradient - id: circle-gradient
name: 원형 라벨 name: 원형 라벨
category: visuals
template: blocks/visuals/circle-gradient.html template: blocks/visuals/circle-gradient.html
height_cost: compact height_cost: compact
min_height_px: 50
relation_types: []
visual: 파란 그라데이션 원(190px) + 이중 테두리 + 중앙 흰색 텍스트. visual: 파란 그라데이션 원(190px) + 이중 테두리 + 중앙 흰색 텍스트.
when: '섹션 전환점에서 키워드를 원형으로 강조. 아래에 카드/표가 올 때 주제 선언.' when: '섹션 전환점에서 키워드를 원형으로 강조. 아래에 카드/표가 올 때 주제 선언.'
not_for: '본문 텍스트 → topic-header 계열. 결론 한 줄 → banner-gradient. 단독 사용 비추.' not_for: '본문 텍스트 → topic-header 계열. 결론 한 줄 → banner-gradient. 단독 사용 비추.'
@@ -281,8 +378,11 @@ blocks:
- id: compare-pill-pair - id: compare-pill-pair
name: 둥근 박스 VS name: 둥근 박스 VS
category: visuals
template: blocks/visuals/compare-pill-pair.html template: blocks/visuals/compare-pill-pair.html
height_cost: compact height_cost: compact
min_height_px: 60
relation_types: [comparison]
visual: 이중 테두리 둥근 박스 2개 나란히 + 'VS'. 하늘색 테두리 + 시안 텍스트. visual: 이중 테두리 둥근 박스 2개 나란히 + 'VS'. 하늘색 테두리 + 시안 텍스트.
when: '2개 개념 시각적 대비. 비교 테이블 위 헤더로 사용. 예: "DX 협업 프로세스" VS "BIM 정보 관리".' when: '2개 개념 시각적 대비. 비교 테이블 위 헤더로 사용. 예: "DX 협업 프로세스" VS "BIM 정보 관리".'
not_for: '상세 비교(5행+) → compare-3col-badge. 3개 이상 → card-compare-3col.' not_for: '상세 비교(5행+) → compare-3col-badge. 3개 이상 → card-compare-3col.'
@@ -297,8 +397,13 @@ blocks:
- id: process-horizontal - id: process-horizontal
name: 가로 단계 흐름 name: 가로 단계 흐름
category: visuals
template: blocks/visuals/process-horizontal.html template: blocks/visuals/process-horizontal.html
height_cost: medium height_cost: medium
min_height_px: 100
relation_types: [sequence]
min_items: 2
max_items: 5
visual: 가로 방향. 파란 원형 번호 + 제목 + 설명(카드). → 화살표 연결. visual: 가로 방향. 파란 원형 번호 + 제목 + 설명(카드). → 화살표 연결.
when: '논리적 순서가 있는 단계를 가로로. A→B→C→D 프로세스 흐름. 각 단계에 제목+설명이 필요할 때.' when: '논리적 순서가 있는 단계를 가로로. A→B→C→D 프로세스 흐름. 각 단계에 제목+설명이 필요할 때.'
not_for: '독립 사례 나열(순서 없음) → card-icon-desc 또는 dark-bullet-list. 세로 나열 → card-numbered. 간결한 흐름(설명 불필요) → flow-arrow-horizontal.' not_for: '독립 사례 나열(순서 없음) → card-icon-desc 또는 dark-bullet-list. 세로 나열 → card-numbered. 간결한 흐름(설명 불필요) → flow-arrow-horizontal.'
@@ -309,8 +414,13 @@ blocks:
- id: flow-arrow-horizontal - id: flow-arrow-horizontal
name: 가로 흐름 화살표 name: 가로 흐름 화살표
category: visuals
template: blocks/visuals/flow-arrow-horizontal.html template: blocks/visuals/flow-arrow-horizontal.html
height_cost: compact height_cost: compact
min_height_px: 50
relation_types: [sequence]
min_items: 2
max_items: 6
visual: SVG. 색상 둥근 캡슐이 가로 나열 + 사이 화살표. 컴팩트. 각 캡슐 120px 폭. visual: SVG. 색상 둥근 캡슐이 가로 나열 + 사이 화살표. 컴팩트. 각 캡슐 120px 폭.
when: '명확한 시간 순서 또는 인과 흐름이 있을 때만 사용. A→B→C 순서가 핵심. 예: GIS→SPCC→토공→BIM (기술 발전 순서). ★ 각 라벨은 8자 이내로 짧아야 함(120px 캡슐 안에 들어가야 함).' when: '명확한 시간 순서 또는 인과 흐름이 있을 때만 사용. A→B→C 순서가 핵심. 예: GIS→SPCC→토공→BIM (기술 발전 순서). ★ 각 라벨은 8자 이내로 짧아야 함(120px 캡슐 안에 들어가야 함).'
not_for: '독립 사례/증거 나열(순서 없음) → dark-bullet-list 또는 card-icon-desc. 정책 문서 나열 → dark-bullet-list. 각 단계에 설명 필요 → process-horizontal. 라벨이 길면(8자 초과) → process-horizontal 또는 card-numbered.' not_for: '독립 사례/증거 나열(순서 없음) → dark-bullet-list 또는 card-icon-desc. 정책 문서 나열 → dark-bullet-list. 각 단계에 설명 필요 → process-horizontal. 라벨이 길면(8자 초과) → process-horizontal 또는 card-numbered.'
@@ -325,8 +435,13 @@ blocks:
- id: keyword-circle-row - id: keyword-circle-row
name: 키워드 원형 행 name: 키워드 원형 행
category: visuals
template: blocks/visuals/keyword-circle-row.html template: blocks/visuals/keyword-circle-row.html
height_cost: medium height_cost: medium
min_height_px: 120
relation_types: [hierarchy]
min_items: 2
max_items: 5
visual: SVG 그라데이션 원 안에 큰 글자(G,S,I,M 등 약어) + 아래 라벨 + 설명. visual: SVG 그라데이션 원 안에 큰 글자(G,S,I,M 등 약어) + 아래 라벨 + 설명.
when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information) + M(Model).' when: '약어 풀이. 핵심 키워드를 원형으로 시각 강조. 예: G(Geographic) + S(Structure) + I(Information) + M(Model).'
not_for: '아이콘+설명 → card-icon-desc. 용어 정의(문장형) → card-numbered. 약어가 아닌 일반 텍스트 → 사용 금지.' not_for: '아이콘+설명 → card-icon-desc. 용어 정의(문장형) → card-numbered. 약어가 아닌 일반 텍스트 → 사용 금지.'
@@ -345,8 +460,11 @@ blocks:
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: quote-big-mark - id: quote-big-mark
name: 큰따옴표 인용 name: 큰따옴표 인용
category: emphasis
template: blocks/emphasis/quote-big-mark.html template: blocks/emphasis/quote-big-mark.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: []
visual: 좌상단 ❝ + 우하단 ❞ 큰따옴표 장식. 연한 배경 박스 + 인용문 + 우측 출처. visual: 좌상단 ❝ + 우하단 ❞ 큰따옴표 장식. 연한 배경 박스 + 인용문 + 우측 출처.
when: '임팩트 있는 인용문. 문제 제기를 인용 형태로 강조. 출처가 있는 인용.' when: '임팩트 있는 인용문. 문제 제기를 인용 형태로 강조. 출처가 있는 인용.'
not_for: '짧은 질문(1~2줄) → quote-question. 결론 한 줄 강조 → banner-gradient. 불릿 나열 → dark-bullet-list.' not_for: '짧은 질문(1~2줄) → quote-question. 결론 한 줄 강조 → banner-gradient. 불릿 나열 → dark-bullet-list.'
@@ -360,8 +478,11 @@ blocks:
- id: quote-question - id: quote-question
name: 질문형 강조 name: 질문형 강조
category: emphasis
template: blocks/emphasis/quote-question.html template: blocks/emphasis/quote-question.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: []
visual: 밝은 파란 배경 + 파란 테두리 + 큰 질문 텍스트(22px) + 부연 설명. visual: 밝은 파란 배경 + 파란 테두리 + 큰 질문 텍스트(22px) + 부연 설명.
when: '독자에게 질문을 던져 문제 인식을 유도. 전환점. 예: "지금의 방식으로도 가능할까?"' when: '독자에게 질문을 던져 문제 인식을 유도. 전환점. 예: "지금의 방식으로도 가능할까?"'
not_for: '인용(출처 있음) → quote-big-mark. 결론 선언 → banner-gradient. 경고/문제 → callout-warning.' not_for: '인용(출처 있음) → quote-big-mark. 결론 선언 → banner-gradient. 경고/문제 → callout-warning.'
@@ -375,8 +496,18 @@ blocks:
- id: comparison-2col - id: comparison-2col
name: 2단 병렬 비교 name: 2단 병렬 비교
category: emphasis
template: blocks/emphasis/comparison-2col.html template: blocks/emphasis/comparison-2col.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: [comparison]
variants:
- id: default
description: 좌우 2단 텍스트 비교 (기본)
- id: cards-in-container
description: 큰 박스 안에 카드 N개 (포함 관계 시각화, DX⊃BIM)
template: blocks/emphasis/comparison-2col--cards-in-container.html
when: "hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때. 포함 관계 시각화"
visual: 좌우 2단. 좌 파란 헤더(밑줄) + 우 빨간 헤더(밑줄). 중앙 구분선. 서브타이틀+본문. visual: 좌우 2단. 좌 파란 헤더(밑줄) + 우 빨간 헤더(밑줄). 중앙 구분선. 서브타이틀+본문.
when: 'A vs B 간단 비교. 2~3개 항목을 좌우로 대비. 장단점, Before/After 등 대비 구조. 예: BIM(하위기술) vs DX(상위개념).' when: 'A vs B 간단 비교. 2~3개 항목을 좌우로 대비. 장단점, Before/After 등 대비 구조. 예: BIM(하위기술) vs DX(상위개념).'
not_for: '다항목 표(5행+) → compare-3col-badge. 결론 한 줄 강조 → banner-gradient. 핵심 메시지 선언 → banner-gradient. footer에서 결론 강조용으로 쓰지 마라.' not_for: '다항목 표(5행+) → compare-3col-badge. 결론 한 줄 강조 → banner-gradient. 핵심 메시지 선언 → banner-gradient. footer에서 결론 강조용으로 쓰지 마라.'
@@ -387,8 +518,11 @@ blocks:
- id: banner-gradient - id: banner-gradient
name: 그라데이션 배너 name: 그라데이션 배너
category: emphasis
template: blocks/emphasis/banner-gradient.html template: blocks/emphasis/banner-gradient.html
height_cost: compact height_cost: compact
min_height_px: 40
relation_types: []
visual: 전체 너비 파란 그라데이션 배경(둥근 모서리 8px) + 중앙 흰색 굵은 텍스트(16px) + 선택적 서브텍스트. visual: 전체 너비 파란 그라데이션 배경(둥근 모서리 8px) + 중앙 흰색 굵은 텍스트(16px) + 선택적 서브텍스트.
when: '★ 결론 강조에 가장 적합. 핵심 메시지 한 줄 선언. footer 배치에 최적(compact, 50~60px). 페이지의 "기억해야 할 단 하나의 문장". 예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"' when: '★ 결론 강조에 가장 적합. 핵심 메시지 한 줄 선언. footer 배치에 최적(compact, 50~60px). 페이지의 "기억해야 할 단 하나의 문장". 예: "BIM은 DX의 기초가 되는 일부분이다. DX ≠ BIM"'
not_for: '인용(출처) → quote-big-mark. 긴 설명(3줄+) → callout-solution. A vs B 비교 → comparison-2col.' not_for: '인용(출처) → quote-big-mark. 긴 설명(3줄+) → callout-solution. A vs B 비교 → comparison-2col.'
@@ -402,10 +536,22 @@ blocks:
- id: dark-bullet-list - id: dark-bullet-list
name: 다크 배경 불릿 name: 다크 배경 불릿
category: emphasis
template: blocks/emphasis/dark-bullet-list.html template: blocks/emphasis/dark-bullet-list.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: [cause_effect]
min_items: 2
max_items: 5
variants:
- id: default
description: 다크 배경 + 불릿 나열 (기본)
- id: before-after
description: Before→After 2열 구조 (프로세스 변화, 전환)
template: blocks/emphasis/dark-bullet-list--before-after.html
when: "기존 방식 → 새 방식으로의 전환/변화를 보여줄 때. 각 항목이 before/after 쌍일 때"
visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 파란 불릿 마커. 시각적 무게감. visual: 짙은 남색 배경 + 파란 제목 + 흰 텍스트 불릿. 파란 불릿 마커. 시각적 무게감.
when: '★ 독립적인 사례/증거/포인트를 나열할 때 적합. 순서 없는 항목을 강조하며 나열. 정책 문서 사례, 근거 자료 나열.: 혼용 사례 3건을 각각 독립적으로 제시. 핵심 포인트를 짙은 배경 위에 강조.' when: '★ 독립적인 사례/증거/포인트를 나열할 때 적합. 순서 없는 항목을 강조하며 나열. 정책 문서 사례, 근거 자료 나열.'
not_for: '밝은 배경 → card-icon-desc 또는 card-numbered. 순서가 있는 단계 → card-numbered 또는 process-horizontal. 시각화(다이어그램) → venn-diagram.' not_for: '밝은 배경 → card-icon-desc 또는 card-numbered. 순서가 있는 단계 → card-numbered 또는 process-horizontal. 시각화(다이어그램) → venn-diagram.'
purpose_fit: [근거사례, 문제제기, 핵심전달] purpose_fit: [근거사례, 문제제기, 핵심전달]
slots: slots:
@@ -418,8 +564,11 @@ blocks:
- id: highlight-strip - id: highlight-strip
name: 강조 분류 스트립 name: 강조 분류 스트립
category: emphasis
template: blocks/emphasis/highlight-strip.html template: blocks/emphasis/highlight-strip.html
height_cost: compact height_cost: compact
min_height_px: 35
relation_types: []
visual: 가로 색상 구간들. 각 구간에 흰 라벨. 카테고리 색상 분류 바. visual: 가로 색상 구간들. 각 구간에 흰 라벨. 카테고리 색상 분류 바.
when: '카테고리별 색상 분류를 한 줄로. 예: 상용(회색) | 3rd Party(파랑) | 전문SW(빨강).' when: '카테고리별 색상 분류를 한 줄로. 예: 상용(회색) | 3rd Party(파랑) | 전문SW(빨강).'
not_for: '탭 전환 → tab-label-row. 결론 강조 → banner-gradient. 독립 항목 나열 → dark-bullet-list.' not_for: '탭 전환 → tab-label-row. 결론 강조 → banner-gradient. 독립 항목 나열 → dark-bullet-list.'
@@ -433,8 +582,11 @@ blocks:
- id: callout-solution - id: callout-solution
name: 솔루션 콜아웃 name: 솔루션 콜아웃
category: emphasis
template: blocks/emphasis/callout-solution.html template: blocks/emphasis/callout-solution.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: [cause_effect]
visual: 밝은 파란 배경 + 파란 테두리 + 아이콘 + 파란 제목 + 설명 + 출처. visual: 밝은 파란 배경 + 파란 테두리 + 아이콘 + 파란 제목 + 설명 + 출처.
when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".' when: '핵심 해결책, 솔루션, 방향성을 강조. 예: "💡 Solution 제시 포인트".'
not_for: '경고/문제 → callout-warning. 인용 → quote-big-mark. 결론 한 줄 → banner-gradient.' not_for: '경고/문제 → callout-warning. 인용 → quote-big-mark. 결론 한 줄 → banner-gradient.'
@@ -448,10 +600,13 @@ blocks:
- id: callout-warning - id: callout-warning
name: 경고 콜아웃 name: 경고 콜아웃
category: emphasis
template: blocks/emphasis/callout-warning.html template: blocks/emphasis/callout-warning.html
height_cost: medium height_cost: medium
min_height_px: 80
relation_types: [cause_effect]
visual: 연한 빨간 배경 + 빨간 테두리 + 아이콘 + 빨간 제목 + 진한 빨간 설명. visual: 연한 빨간 배경 + 빨간 테두리 + 아이콘 + 빨간 제목 + 진한 빨간 설명.
when: '문제점 지적, 잘못된 인식 경고, 주의사항. 문제 제기 purpose에 적합. 예: "⚠️ 현재 접근 방식의 한계". 잘못된 관행/오해를 명확히 지적할 때.' when: '문제점 지적, 잘못된 인식 경고, 주의사항. 문제 제기 purpose에 적합. 예: "⚠️ 현재 접근 방식의 한계".'
not_for: '해결책 → callout-solution. 인용 → quote-big-mark. 결론 → banner-gradient.' not_for: '해결책 → callout-solution. 인용 → quote-big-mark. 결론 → banner-gradient.'
purpose_fit: [문제제기] purpose_fit: [문제제기]
slots: slots:
@@ -460,8 +615,11 @@ blocks:
- id: tab-label-row - id: tab-label-row
name: 탭 라벨 행 name: 탭 라벨 행
category: emphasis
template: blocks/emphasis/tab-label-row.html template: blocks/emphasis/tab-label-row.html
height_cost: compact height_cost: compact
min_height_px: 35
relation_types: []
visual: 가로 탭 버튼. 선택됨=색상 배경+흰 텍스트, 나머지=회색. 밝은 바탕. visual: 가로 탭 버튼. 선택됨=색상 배경+흰 텍스트, 나머지=회색. 밝은 바탕.
when: '카테고리 전환/분류 표시. 현재 선택된 항목 강조. 예: 제조 | 건축 | [인프라/토목].' when: '카테고리 전환/분류 표시. 현재 선택된 항목 강조. 예: 제조 | 건축 | [인프라/토목].'
not_for: '색상 바 → highlight-strip. 실제 클릭 전환 미지원.' not_for: '색상 바 → highlight-strip. 실제 클릭 전환 미지원.'
@@ -475,8 +633,11 @@ blocks:
- id: divider-text - id: divider-text
name: 텍스트 구분선 name: 텍스트 구분선
category: emphasis
template: blocks/emphasis/divider-text.html template: blocks/emphasis/divider-text.html
height_cost: compact height_cost: compact
min_height_px: 25
relation_types: []
visual: 좌우 가는 회색 선 + 중앙 작은 회색 텍스트(13px bold). 시각적 휴식점. visual: 좌우 가는 회색 선 + 중앙 작은 회색 텍스트(13px bold). 시각적 휴식점.
when: 'sidebar 영역의 섹션 라벨. 주제 전환점에 가벼운 구분. 예: ── 용어 정의 ──' when: 'sidebar 영역의 섹션 라벨. 주제 전환점에 가벼운 구분. 예: ── 용어 정의 ──'
not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient. body 영역 메인 제목 → topic 계열.' not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient. body 영역 메인 제목 → topic 계열.'
@@ -490,8 +651,11 @@ blocks:
# ═══════════════════════════════════════ # ═══════════════════════════════════════
- id: image-row-2col - id: image-row-2col
name: 이미지 2열 name: 이미지 2열
category: media
template: blocks/media/image-row-2col.html template: blocks/media/image-row-2col.html
height_cost: large height_cost: large
min_height_px: 200
relation_types: []
visual: 이미지 2장 나란히. 각 캡션 선택. visual: 이미지 2장 나란히. 각 캡션 선택.
when: '시공 사진 2장 나란히, 현장 비교.' when: '시공 사진 2장 나란히, 현장 비교.'
not_for: '4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption.' not_for: '4장 → image-grid-2x2. 이미지+텍스트 → image-side-text. 1장 → image-full-caption.'
@@ -502,8 +666,11 @@ blocks:
- id: image-grid-2x2 - id: image-grid-2x2
name: 이미지 2x2 그리드 name: 이미지 2x2 그리드
category: media
template: blocks/media/image-grid-2x2.html template: blocks/media/image-grid-2x2.html
height_cost: large height_cost: large
min_height_px: 350
relation_types: []
visual: 이미지 4장 2x2 격자. 각 캡션 선택. visual: 이미지 4장 2x2 격자. 각 캡션 선택.
when: '현장 사진 4장, 4개 관점 이미지.' when: '현장 사진 4장, 4개 관점 이미지.'
not_for: '2장 → image-row-2col. 이미지+텍스트 → image-side-text.' not_for: '2장 → image-row-2col. 이미지+텍스트 → image-side-text.'
@@ -514,8 +681,11 @@ blocks:
- id: image-side-text - id: image-side-text
name: 이미지+텍스트 가로 name: 이미지+텍스트 가로
category: media
template: blocks/media/image-side-text.html template: blocks/media/image-side-text.html
height_cost: medium height_cost: medium
min_height_px: 150
relation_types: []
visual: 좌측 이미지(320px 고정) + 우측 제목+설명+불릿. 가로 배치. visual: 좌측 이미지(320px 고정) + 우측 제목+설명+불릿. 가로 배치.
when: '이미지에 대한 설명. 제품/시스템 소개. 다이어그램+해설.' when: '이미지에 대한 설명. 제품/시스템 소개. 다이어그램+해설.'
not_for: '이미지만 → image-row-2col. 여러 장 → image-grid-2x2.' not_for: '이미지만 → image-row-2col. 여러 장 → image-grid-2x2.'
@@ -526,8 +696,11 @@ blocks:
- id: image-full-caption - id: image-full-caption
name: 전체 너비 이미지 name: 전체 너비 이미지
category: media
template: blocks/media/image-full-caption.html template: blocks/media/image-full-caption.html
height_cost: large height_cost: large
min_height_px: 200
relation_types: []
visual: 전체 너비 이미지 1장(둥근 모서리) + 하단 캡션. visual: 전체 너비 이미지 1장(둥근 모서리) + 하단 캡션.
when: '핵심 도표, 대형 다이어그램, 전경 사진을 크게.' when: '핵심 도표, 대형 다이어그램, 전경 사진을 크게.'
not_for: '2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text.' not_for: '2장+ → image-row-2col/image-grid-2x2. 이미지+텍스트 → image-side-text.'
@@ -538,8 +711,11 @@ blocks:
- id: image-before-after - id: image-before-after
name: Before/After 이미지 name: Before/After 이미지
category: media
template: blocks/media/image-before-after.html template: blocks/media/image-before-after.html
height_cost: large height_cost: large
min_height_px: 200
relation_types: [comparison]
visual: 좌 Before(회색 라벨) + → 화살표(파란) + 우 After(파란 라벨). 각 이미지 180px. visual: 좌 Before(회색 라벨) + → 화살표(파란) + 우 After(파란 라벨). 각 이미지 180px.
when: '변화 전후 비교. 디지털 전환 전후, 공정 개선 전후.' when: '변화 전후 비교. 디지털 전환 전후, 공정 개선 전후.'
not_for: '이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col.' not_for: '이미지 단순 나열 → image-row-2col. 텍스트 비교 → comparison-2col.'