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:
93
ACTION_PLAN.md
Normal file
93
ACTION_PLAN.md
Normal 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
137
CLAUDE.md
@@ -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 상태에서 출력 금지** — 비전 모델 품질 게이트 통과 필수
|
||||||
- 텍스트 편집자가 정리한 텍스트가 기준. 디자인이 텍스트에 맞춘다.
|
- 텍스트 편집자가 정리한 텍스트가 기준. 디자인이 텍스트에 맞춘다.
|
||||||
- 실무자는 텍스트를 자르지 않고, 디자인을 조정한다.
|
- 실무자는 텍스트를 자르지 않고, 디자인을 조정한다.
|
||||||
- 이미지는 원본 그대로 사용, 크기만 조절.
|
- 이미지는 원본 그대로 사용, 크기만 조절.
|
||||||
|
|||||||
1011
COMPREHENSIVE_AUDIT_REPORT.md
Normal file
1011
COMPREHENSIVE_AUDIT_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
189
IMPROVEMENT-PHASE-Q-FIX.md
Normal 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
531
IMPROVEMENT-PHASE-Q.md
Normal 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
297
IMPROVEMENT-PHASE-R.md
Normal 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 기반 조합 생성 |
|
||||||
@@ -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' 설계 확정. |
|
||||||
|
|||||||
189
PROGRESS.md
189
PROGRESS.md
@@ -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' 반영 |
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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
617
scripts/test_3approaches.py
Normal 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())
|
||||||
474
scripts/test_3approaches_dx2.py
Normal file
474
scripts/test_3approaches_dx2.py
Normal 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
325
scripts/test_3directions.py
Normal 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
369
scripts/test_hybrid.py
Normal 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())
|
||||||
201
scripts/test_ideal_layout.py
Normal file
201
scripts/test_ideal_layout.py
Normal 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
426
scripts/test_ideal_v2.py
Normal 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
346
scripts/test_phase_q.py
Normal 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))
|
||||||
187
scripts/test_phase_r_prime.py
Normal file
187
scripts/test_phase_r_prime.py
Normal 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
248
scripts/verify_3issues.py
Normal 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())
|
||||||
175
scripts/verify_claude_1_2.py
Normal file
175
scripts/verify_claude_1_2.py
Normal 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())
|
||||||
173
scripts/verify_core_c_fix.py
Normal file
173
scripts/verify_core_c_fix.py
Normal 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())
|
||||||
227
scripts/verify_core_final.py
Normal file
227
scripts/verify_core_final.py
Normal 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 << 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())
|
||||||
216
scripts/verify_core_final2.py
Normal file
216
scripts/verify_core_final2.py
Normal 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 << 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())
|
||||||
211
scripts/verify_core_float.py
Normal file
211
scripts/verify_core_float.py
Normal 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 << 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())
|
||||||
231
scripts/verify_core_float2.py
Normal file
231
scripts/verify_core_float2.py
Normal 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 << 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())
|
||||||
218
scripts/verify_core_float3.py
Normal file
218
scripts/verify_core_float3.py
Normal 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 << 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())
|
||||||
220
scripts/verify_core_samples.py
Normal file
220
scripts/verify_core_samples.py
Normal 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
135
scripts/verify_core_v3.py
Normal 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
116
scripts/verify_core_v4.py
Normal 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
129
scripts/verify_core_v5.py
Normal 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())
|
||||||
175
scripts/verify_definitions_v2.py
Normal file
175
scripts/verify_definitions_v2.py
Normal 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())
|
||||||
129
scripts/verify_hierarchy_3ways.py
Normal file
129
scripts/verify_hierarchy_3ways.py
Normal 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
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
198
scripts/verify_retry_1_2.py
Normal 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
108
scripts/verify_venn.py
Normal 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())
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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로 캡처한다.
|
||||||
|
|
||||||
|
|||||||
52
templates/blocks/cards/card-icon-desc--compact.html
Normal file
52
templates/blocks/cards/card-icon-desc--compact.html
Normal 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>
|
||||||
60
templates/blocks/cards/card-numbered--horizontal.html
Normal file
60
templates/blocks/cards/card-numbered--horizontal.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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.'
|
||||||
|
|||||||
Reference in New Issue
Block a user