Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입
블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성. 생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증. 주요 변경: - src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출 - src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프 - src/html_validator.py: 보안 검증(script/iframe 제거) - src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden - scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장 - 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙 다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
CURRENT-STATUS-20260331.md
Normal file
49
CURRENT-STATUS-20260331.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 현재 상태 기록 (2026-03-31 03:00)
|
||||
|
||||
---
|
||||
|
||||
## 확정된 것
|
||||
|
||||
1. **역할 분리:** Kei = 콘텐츠 분석, Claude Sonnet = HTML 생성
|
||||
2. **영역별 개별 호출:** 배경/본심/sidebar/footer 각각 Claude 개별 호출
|
||||
3. **블록 선택 시스템 제거:** block_selector, fill_candidates 미사용
|
||||
4. **검증 합격 결과물 존재:** 각 영역별로 수동 프롬프트로 합격한 결과물이 있음
|
||||
|
||||
## 합격한 결과물 위치
|
||||
|
||||
| 영역 | 파일 | 프롬프트 위치 | 방식 |
|
||||
|------|------|------------|------|
|
||||
| 배경 | verify_claude_20260330_212019/verify1.png | verify_claude_1_2.py prompt_1 | Claude, 수동 텍스트 |
|
||||
| 용어 정의 | verify_v2_20260331_003421/A_definitions.png | verify_definitions_v2.py prompt_a | Claude, 수동 텍스트 |
|
||||
| 본심 | core_c_fix_20260331_015828/core_c_fix.png | verify_core_c_fix.py (하드코딩 HTML) | 직접 작성 |
|
||||
| footer | 이전부터 OK | 간단 | Claude |
|
||||
|
||||
## 해결 못한 핵심 문제
|
||||
|
||||
**검증(수동) → 자동화 전환이 안 됨**
|
||||
|
||||
검증에서 합격한 프롬프트는 텍스트를 **수동으로 직접 타이핑**해서 넣은 것.
|
||||
자동화에서는 원본 MDX/source_data에서 텍스트를 **코드로 추출**해야 하는데,
|
||||
추출 결과가 수동으로 넣은 것과 달라서 Claude가 다른 결과를 생성.
|
||||
|
||||
구체적 문제:
|
||||
1. `_extract_bg_content()` — 배경 텍스트 추출이 부정확
|
||||
2. `_extract_core_content()` — 본심 텍스트 추출이 부정확
|
||||
3. `_extract_definitions()` — 용어 정의 추출이 원본과 다름
|
||||
4. 추출된 텍스트가 프롬프트에 들어가면 Claude가 축약/변형
|
||||
|
||||
## 해결 방향
|
||||
|
||||
**Kei가 1단계에서 이미 정리한 source_data를 그대로 프롬프트에 넣으면 됨.**
|
||||
문제는 source_data가 원본 MDX의 80-95%가 아니라 요약본이라는 것.
|
||||
|
||||
선택지:
|
||||
1. source_data를 더 풍부하게 만들기 (Kei 1단계 프롬프트 수정)
|
||||
2. 원본 MDX를 직접 프롬프트에 넣되, 관련 섹션만 정확히 추출
|
||||
3. 검증 때처럼 수동으로 텍스트를 준비 (확장성 없음)
|
||||
|
||||
## 다음 작업
|
||||
|
||||
1. 실제 프롬프트에 들어가는 텍스트를 로그로 저장하여 검증 때와 비교
|
||||
2. 차이점을 파악하고 추출 로직 수정
|
||||
3. 또는 원본 MDX를 영역별로 정확히 슬라이싱하는 로직 구현
|
||||
450
IMPROVEMENT-PHASE-R-PRIME.md
Normal file
450
IMPROVEMENT-PHASE-R-PRIME.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# Phase R': 접근 C 기반 — 블록 CSS 참고 + AI 구조 결정
|
||||
|
||||
> 작성일: 2026-03-30
|
||||
> 상태: 설계
|
||||
> 선행: Phase P(20점), Q(블록 선택 개선), R(실패 — 기존 구조 위에 패치)
|
||||
> 근거: C_reference.png + hybrid_simulation.png 시뮬레이션 검증
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase P, Q, R에서 무엇이 문제였는가
|
||||
|
||||
**P, Q, R 전부 같은 구조:**
|
||||
```
|
||||
블록을 선택한다 → 그 블록의 슬롯에 텍스트를 채운다
|
||||
```
|
||||
|
||||
이 구조의 근본 한계:
|
||||
- **블록이 구조를 결정한다.** 콘텐츠가 아니라 블록 템플릿이 HTML 구조를 고정.
|
||||
- topic 1개 = 블록 1개. 합침/분리 불가.
|
||||
- 38개 고정 템플릿에 없는 구조(포함 관계, Before→After 등)는 표현 불가.
|
||||
- 콘텐츠 전달 의도(expression_hint)가 HTML 구조에 반영되지 않음.
|
||||
|
||||
Phase Q에서 블록 선택 정확도를 올렸고, Phase R에서 variant를 추가했지만,
|
||||
**근본 구조("블록 선택 → 슬롯 채우기")는 P = Q = R 동일.**
|
||||
결과물도 동일 수준.
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 C: 무엇이 달라지는가
|
||||
|
||||
### 핵심 전환
|
||||
|
||||
```
|
||||
현재 (P=Q=R):
|
||||
블록이 구조를 결정 → 콘텐츠를 슬롯에 채움
|
||||
(블록 중심)
|
||||
|
||||
접근 C:
|
||||
콘텐츠가 구조를 결정 → 블록 CSS를 참고하여 HTML 생성
|
||||
(콘텐츠 중심)
|
||||
```
|
||||
|
||||
### "블록을 참고하여 만든다"의 정확한 의미
|
||||
|
||||
블록을 버리는 것이 아니다. 블록을 "선택"하는 것도 아니다.
|
||||
|
||||
| | 현재 (블록 선택) | 접근 C (블록 참고) |
|
||||
|---|---|---|
|
||||
| 블록의 역할 | HTML 구조를 결정하는 템플릿 | CSS 스타일(색상, 폰트, 배경, radius)의 참고 자료 |
|
||||
| 누가 구조를 결정 | 블록 템플릿 (Jinja2) | AI가 콘텐츠 전달 의도를 보고 결정 |
|
||||
| topic 합침/분리 | 불가 (1 topic = 1 블록) | 가능 (AI가 판단) |
|
||||
| 포함 관계 시각화 | 해당 블록이 없으면 불가 | AI가 큰 박스 안에 카드를 넣는 구조를 직접 생성 |
|
||||
| CSS 일관성 | 블록 템플릿이 보장 | 디자인 토큰 + 블록 CSS 참고로 보장 |
|
||||
|
||||
### C_reference.png에서 증명된 것
|
||||
|
||||
제가 수동으로 했던 판단:
|
||||
1. topic 1(문제제기) + topic 2(사례) → **1영역에 통합**, dark-bullet-list의 CSS 색상 사용
|
||||
2. 사례 2건 → **가로 2열 카드**, 같은 구조로 나란히
|
||||
3. topic 3(핵심) → **DX 큰 박스 안에 GIS/BIM/DT 카드** = 포함 관계 시각화
|
||||
4. "BIM ≠ DX" → **별도 강조 박스** (topic에 없는 요소를 추가)
|
||||
5. sidebar 용어 → **풀 정의 + 출처**, card-numbered의 CSS 스타일 참고
|
||||
|
||||
**이 판단들을 AI가 하는 것이 접근 C.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 프로세스 설계
|
||||
|
||||
### 기존 프로세스 (P=Q=R)
|
||||
|
||||
```
|
||||
1단계: Kei 분석 (topics, relation_type, expression_hint, page_structure)
|
||||
1.5단계: Kei 컨셉 구체화 (source_data)
|
||||
컨테이너 계산 (비중 → px)
|
||||
프리셋 선택 (sidebar-right 등)
|
||||
|
||||
2단계: 블록 선택 (block_selector → catalog → Kei 선택) ← 여기가 문제
|
||||
3단계: 텍스트 채우기 (fill_candidates → 슬롯에 텍스트) ← 여기가 문제
|
||||
|
||||
4단계: CSS 조정 + 렌더링
|
||||
검증: Selenium 측정
|
||||
품질 게이트: 비전 모델
|
||||
```
|
||||
|
||||
### 접근 C 프로세스
|
||||
|
||||
```
|
||||
1단계: Kei 분석 (동일)
|
||||
1.5단계: Kei 컨셉 구체화 (동일)
|
||||
컨테이너 계산 (동일)
|
||||
프리셋 선택 (동일)
|
||||
|
||||
2-3단계 통합: AI가 HTML 구조를 직접 생성 ← 여기가 바뀜
|
||||
입력: Kei 분석 결과 + 원본 텍스트 + 디자인 토큰 + 블록 CSS 참고 + 컨테이너 스펙
|
||||
출력: 각 컨테이너 영역의 HTML (슬라이드 body, sidebar, footer)
|
||||
|
||||
AI가 결정하는 것:
|
||||
- 각 topic을 어떤 구조로 보여줄지 (불릿? 비교? 포함관계? 카드?)
|
||||
- topic을 합칠지 분리할지
|
||||
- 핵심 메시지를 별도 강조할지
|
||||
- 텍스트를 어떻게 배치할지
|
||||
|
||||
AI가 참고하는 것:
|
||||
- 디자인 토큰 (CSS 변수 — 색상, 폰트, 간격)
|
||||
- 기존 블록의 CSS 패턴 (다크 배경, 카드 스타일, 배너 스타일 등)
|
||||
- 컨테이너 크기 (px)
|
||||
- expression_hint ("포함 관계 시각화", "프로세스 변화")
|
||||
- 예시 슬라이드 (few-shot)
|
||||
|
||||
4단계: 렌더링 (AI가 생성한 HTML을 슬라이드 프레임에 삽입)
|
||||
검증: Selenium 측정 (동일)
|
||||
품질 게이트: 비전 모델 (동일)
|
||||
```
|
||||
|
||||
### 변경되는 것 / 변경되지 않는 것
|
||||
|
||||
| 항목 | 변경 여부 |
|
||||
|------|----------|
|
||||
| 1단계 Kei 분석 | 변경 없음 |
|
||||
| 1.5단계 컨셉 구체화 | 변경 없음 |
|
||||
| 컨테이너 계산 (space_allocator) | 변경 없음 |
|
||||
| 프리셋 선택 | 변경 없음 |
|
||||
| **2단계 블록 선택 (block_selector)** | **제거** — AI가 직접 구조 결정 |
|
||||
| **3단계 슬롯 채우기 (fill_candidates)** | **제거** — AI가 HTML에 텍스트 직접 포함 |
|
||||
| 4단계 CSS 조정 | 변경 — AI 생성 HTML을 프레임에 삽입 |
|
||||
| Selenium 측정 | 변경 없음 |
|
||||
| 비전 모델 품질 게이트 | 변경 없음 |
|
||||
| slide-base.html | 변경 없음 |
|
||||
| tokens.css, base.css | 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 4. AI HTML 생성의 구체적 설계
|
||||
|
||||
### 4-1. AI에게 주는 입력
|
||||
|
||||
```
|
||||
1. 원본 콘텐츠 (MDX 텍스트 전체)
|
||||
2. Kei 분석 결과:
|
||||
- topics[] (id, title, purpose, relation_type, expression_hint, source_data)
|
||||
- page_structure (본심/배경/첨부/결론 비중)
|
||||
- core_message
|
||||
3. 컨테이너 스펙:
|
||||
- 각 역할별 높이(px), 너비(px)
|
||||
- 프리셋 (sidebar-right, two-column 등)
|
||||
4. 디자인 토큰 (tokens.css 전문)
|
||||
5. 블록 CSS 패턴 참고 (주요 블록의 CSS만 발췌):
|
||||
- 다크 배경 패턴 (.block-dark-bullets의 색상/배경)
|
||||
- 카드 패턴 (.cid-card의 border/radius/padding)
|
||||
- 배너 패턴 (.block-banner-grad의 gradient)
|
||||
- 비교 패턴 (.block-comparison의 좌우 분할)
|
||||
- 테이블 패턴 (.block-table-striped의 헤더/행)
|
||||
6. 예시 슬라이드 2-3개 (few-shot):
|
||||
- C_reference.png의 HTML
|
||||
- hybrid_simulation의 HTML
|
||||
- (다른 콘텐츠 예시)
|
||||
```
|
||||
|
||||
### 4-2. AI에게 요구하는 출력
|
||||
|
||||
```
|
||||
각 영역(body, sidebar, footer)의 HTML 조각.
|
||||
슬라이드 프레임(slide-base.html)에 삽입할 수 있는 형태.
|
||||
|
||||
{
|
||||
"body_html": "<div class=\"container-배경\">...</div><div class=\"container-본심\">...</div>",
|
||||
"sidebar_html": "<div class=\"sidebar-label\">용어 정의</div><div class=\"def-item\">...</div>...",
|
||||
"footer_html": "<div class=\"block-banner-grad\"><div class=\"bg-text\">...</div></div>"
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. AI HTML 생성 규칙 (프롬프트에 포함)
|
||||
|
||||
```
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 그대로 사용한다 (자유도 15-20). 축약은 공간 부족 시에만.
|
||||
2. 디자인 토큰(CSS 변수)만 사용한다. 하드코딩 색상/폰트 금지.
|
||||
3. 블록 CSS 패턴을 참고하되, 구조는 콘텐츠에 맞게 자유롭게 구성한다.
|
||||
4. 컨테이너 높이를 초과하지 않는다.
|
||||
5. expression_hint를 반드시 반영한다:
|
||||
- "포함 관계" → 큰 박스 안에 작은 카드
|
||||
- "프로세스 변화" → Before→After 구조
|
||||
- "독립 사례 나열" → 가로 카드 비교
|
||||
6. topic을 합치거나 분리할 수 있다 (page_structure의 역할 기준).
|
||||
7. 핵심 메시지(core_message)는 별도 강조 요소로 만들 수 있다.
|
||||
```
|
||||
|
||||
### 4-4. 품질 보장 (5중 방어)
|
||||
|
||||
```
|
||||
Layer 1: 디자인 토큰 제약 (CSS 변수만 사용)
|
||||
Layer 2: 컨테이너 크기 제약 (px 명시)
|
||||
Layer 3: HTML 정화 (nh3 — script 태그 등 제거)
|
||||
Layer 4: Selenium 측정 (overflow 감지)
|
||||
Layer 5: 비전 모델 품질 게이트 (시각적 평가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 스텝
|
||||
|
||||
| 스텝 | 내용 | 파일 | 비고 |
|
||||
|------|------|------|------|
|
||||
| R'-1 | 디자인 토큰 + 블록 CSS 패턴을 프롬프트용 텍스트로 추출 | 신규 `src/design_tokens.py` | tokens.css + 주요 블록 CSS 발췌 |
|
||||
| R'-2 | few-shot 예시 슬라이드 정리 (2-3개) | `data/examples/` | C_reference.html, hybrid_simulation.html |
|
||||
| R'-3 | AI HTML 생성 함수 구현 | 신규 `src/html_generator.py` | Kei 분석 + 토큰 + 예시 → HTML 생성 |
|
||||
| R'-4 | pipeline.py 2-3단계를 html_generator로 교체 | `src/pipeline.py` | block_selector, fill_candidates 호출 제거 |
|
||||
| R'-5 | 렌더러에 AI 생성 HTML 삽입 함수 추가 | `src/renderer.py` | 기존 render_slide와 별도로 render_slide_from_html 추가 |
|
||||
| R'-6 | HTML 정화 + 토큰 위반 검증 | 신규 `src/html_validator.py` | nh3 + tinycss2 |
|
||||
| R'-7 | 테스트 (2개 콘텐츠로 검증) | `scripts/test_phase_r_prime.py` | DX 이해 + DX 목표 |
|
||||
|
||||
### 의존 관계
|
||||
|
||||
```
|
||||
R'-1 (토큰 추출) ──→ R'-3 (HTML 생성)
|
||||
R'-2 (예시 정리) ──→ R'-3
|
||||
R'-3 ──→ R'-4 (파이프라인 교체) ──→ R'-7 (테스트)
|
||||
R'-3 ──→ R'-5 (렌더러 추가)
|
||||
R'-6 (검증) ←── 독립, R'-4와 병렬
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. pipeline.py 변경 상세 — 정확히 어디가 교체되는가
|
||||
|
||||
### 유지되는 코드 (1단계 ~ 컨테이너 계산: 줄 96~165)
|
||||
|
||||
```python
|
||||
# 줄 96~109: 1단계 Kei 분석 — 유지
|
||||
analysis = await _retry_kei(classify_content, content)
|
||||
_save_step(run_dir, "step1_analysis.json", analysis)
|
||||
|
||||
# 줄 111~120: 1.5단계 컨셉 구체화 — 유지
|
||||
analysis = await refine_concepts(content, analysis)
|
||||
_save_step(run_dir, "step1b_concepts.json", ...)
|
||||
|
||||
# 줄 122~140: 제목 중복 검증, 이미지 측정 — 유지
|
||||
|
||||
# 줄 142~164: 컨테이너 스펙 계산 — 유지
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
container_specs = calculate_container_specs(...)
|
||||
_save_step(run_dir, "step1c_containers.json", ...)
|
||||
```
|
||||
|
||||
### 제거되는 코드 (2-3단계: 줄 166~339)
|
||||
|
||||
```python
|
||||
# 줄 166~207: Phase Q 블록 선택 — 전체 제거
|
||||
# block_selector.select_block_candidates()
|
||||
# select_fallback_candidates()
|
||||
# calculate_budgets_for_candidates()
|
||||
|
||||
# 줄 209~257: Kei 블록 선택 — 전체 제거
|
||||
# select_block_for_topics()
|
||||
# selected_blocks 딕셔너리 생성
|
||||
# finalize_block_specs()
|
||||
|
||||
# 줄 259~298: layout_concept 조립 — 전체 제거
|
||||
# final_blocks 리스트, sidebar label, 역할 순서 배치
|
||||
|
||||
# 줄 300~339: fill_candidates 호출 — 전체 제거
|
||||
# topic별 fill_candidates()
|
||||
# 결과 검증
|
||||
```
|
||||
|
||||
### 교체되는 코드 (R'-4에서 신규 작성)
|
||||
|
||||
```python
|
||||
# 2-3단계 통합: AI HTML 생성 (줄 166~ 교체)
|
||||
yield {"event": "progress", "data": "2/5 슬라이드 HTML 생성 중..."}
|
||||
|
||||
from src.html_generator import generate_slide_html
|
||||
from src.html_validator import validate_html
|
||||
|
||||
# AI가 HTML 직접 생성
|
||||
generated = await generate_slide_html(
|
||||
content=content,
|
||||
analysis=analysis,
|
||||
container_specs=container_specs,
|
||||
preset=preset,
|
||||
)
|
||||
|
||||
# HTML 정화 + 토큰 위반 검증
|
||||
validated_html = validate_html(generated)
|
||||
|
||||
_save_step(run_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", "")),
|
||||
})
|
||||
```
|
||||
|
||||
### 유지되는 코드 (4단계 이후: 줄 341~)
|
||||
|
||||
```python
|
||||
# 줄 341~350: 4단계 렌더링 — 유지하되 render_slide → render_slide_from_html로 변경
|
||||
html = render_slide_from_html(generated, analysis, preset)
|
||||
_save_step(run_dir, "step4_rendered.html", html)
|
||||
|
||||
# 줄 352~446: Selenium 측정, overflow 감지, 수학적 조정 — 유지
|
||||
# 단, overflow 시 텍스트 압축(fill_content 호출, 줄 442)은 제거
|
||||
# 대신 AI HTML 재생성 요청
|
||||
|
||||
# 줄 448~474: 비전 모델 품질 게이트 — 유지
|
||||
|
||||
# 줄 476~483: 이미지 삽입, final.html 저장 — 유지
|
||||
```
|
||||
|
||||
### _adjust_design (줄 490~589) — 제거 또는 변경
|
||||
|
||||
```
|
||||
현재: layout_concept의 블록별 텍스트 양을 보고 CSS 변수 조정
|
||||
R': AI가 HTML 생성 시 이미 CSS를 포함하므로, 별도 CSS 조정 단계 불필요
|
||||
→ 제거하거나, AI 생성 HTML에 대한 보조 조정으로 역할 축소
|
||||
```
|
||||
|
||||
### _review_balance, _apply_adjustments (줄 592~730) — 변경
|
||||
|
||||
```
|
||||
현재: 블록 기반 layout_concept를 검수/조정
|
||||
R': AI 생성 HTML을 검수. 조정 시 block_selector/fill_candidates가 아닌 html_generator 재호출
|
||||
줄 729의 fill_content() 호출 → 제거
|
||||
```
|
||||
|
||||
### _build_overflow_context, _convert_kei_judgment (줄 733~800) — 변경
|
||||
|
||||
```
|
||||
현재: layout_concept의 블록 데이터에서 overflow 컨텍스트 추출
|
||||
R': AI 생성 HTML에서 직접 추출하도록 변경
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 다른 파일에 대한 영향 (충돌/회귀 체크)
|
||||
|
||||
### 영향 없음 (건드리지 않음)
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `src/kei_client.py` — classify_content, refine_concepts | 1단계에서 호출. R'에서 변경 없음 |
|
||||
| `src/kei_client.py` — vision_quality_gate | 품질 게이트. R'에서 변경 없음 |
|
||||
| `src/space_allocator.py` — calculate_container_specs | 컨테이너 계산. R'에서 변경 없음 |
|
||||
| `src/design_director.py` — select_preset, LAYOUT_PRESETS | 프리셋 선택. R'에서 변경 없음 |
|
||||
| `src/slide_measurer.py` | Selenium 측정. R'에서 변경 없음 |
|
||||
| `src/config.py` | 설정. 변경 없음 |
|
||||
| `src/sse_utils.py` | SSE 스트리밍. 변경 없음 |
|
||||
| `src/image_utils.py` | 이미지 크기 측정. 변경 없음 |
|
||||
| `src/main.py` | FastAPI 엔드포인트. generate_slide 호출은 동일 |
|
||||
| `static/index.html` | 프론트엔드. 변경 없음 |
|
||||
| `templates/slide-base.html` | 슬라이드 프레임. 변경 없음 |
|
||||
| `static/tokens.css`, `static/base.css` | 디자인 토큰. 변경 없음 (프롬프트에서 읽기만 함) |
|
||||
| `templates/blocks/*.html` | 38개 블록. 변경 없음 (CSS 참고용으로만 유지) |
|
||||
| `templates/catalog.yaml` | 변경 없음 (프롬프트에서 CSS 패턴 인덱스로 참고만) |
|
||||
|
||||
### 변경되는 파일
|
||||
|
||||
| 파일 | 변경 내용 | 기존 코드 영향 |
|
||||
|------|----------|-------------|
|
||||
| `src/pipeline.py` | 줄 166~339 교체 (블록 선택+채우기 → html_generator 호출) | 2-3단계만 교체, 나머지 유지 |
|
||||
| `src/pipeline.py` | _adjust_design 제거 또는 축소 | 4단계 CSS 조정 불필요 |
|
||||
| `src/pipeline.py` | _apply_adjustments에서 fill_content 호출 제거 | overflow 시 html_generator 재호출 |
|
||||
| `src/renderer.py` | `render_slide_from_html()` 신규 함수 추가 | 기존 `render_slide()` 변경 없음 (하위 호환) |
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/html_generator.py` | 핵심 — Kei 분석 + 토큰 + 예시 → AI HTML 생성 |
|
||||
| `src/design_tokens.py` | tokens.css + 블록 CSS 패턴을 프롬프트용 텍스트로 추출 |
|
||||
| `src/html_validator.py` | nh3 정화 + CSS 토큰 위반 검증 |
|
||||
| `data/examples/C_reference.html` | few-shot 예시 1 |
|
||||
| `data/examples/hybrid_simulation.html` | few-shot 예시 2 |
|
||||
| `scripts/test_phase_r_prime.py` | R' 테스트 스크립트 |
|
||||
|
||||
### 호출하면 안 되는 것 (자기 검증)
|
||||
|
||||
| 함수 | 위치 | 이유 |
|
||||
|------|------|------|
|
||||
| `select_block_candidates()` | `block_selector.py` | 블록 선택 시스템 — R'에서 제거 |
|
||||
| `select_fallback_candidates()` | `block_selector.py` | 동일 |
|
||||
| `select_block_for_topics()` | `kei_client.py` | 블록 선택 AI — R'에서 제거 |
|
||||
| `fill_candidates()` | `content_editor.py` | 슬롯 채우기 — R'에서 제거 |
|
||||
| `fill_content()` | `content_editor.py` | 동일 |
|
||||
| `finalize_block_specs()` | `space_allocator.py` | 블록 스펙 — 블록 없으므로 불필요 |
|
||||
| `calculate_budgets_for_candidates()` | `space_allocator.py` | 블록 예산 — 블록 없으므로 불필요 |
|
||||
|
||||
### 호출해야 하는 것 (유지)
|
||||
|
||||
| 함수 | 위치 | 이유 |
|
||||
|------|------|------|
|
||||
| `classify_content()` | `kei_client.py` | 1단계 Kei 분석 |
|
||||
| `refine_concepts()` | `kei_client.py` | 1.5단계 컨셉 구체화 |
|
||||
| `calculate_container_specs()` | `space_allocator.py` | 컨테이너 px 계산 |
|
||||
| `select_preset()` | `design_director.py` | 프리셋 선택 |
|
||||
| `measure_rendered_heights()` | `slide_measurer.py` | Selenium 측정 |
|
||||
| `capture_slide_screenshot()` | `slide_measurer.py` | 스크린샷 캡처 |
|
||||
| `vision_quality_gate()` | `kei_client.py` | 비전 모델 품질 게이트 |
|
||||
| `generate_slide_html()` | `html_generator.py` (신규) | AI HTML 생성 |
|
||||
| `validate_html()` | `html_validator.py` (신규) | HTML 정화+검증 |
|
||||
| `render_slide_from_html()` | `renderer.py` (신규 함수) | AI HTML → 슬라이드 프레임 삽입 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 자기 검증 체크리스트
|
||||
|
||||
구현 시작 전 반드시 확인:
|
||||
|
||||
- [ ] **block_selector.py에서 블록을 "선택"하는 코드를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **fill_candidates/fill_content로 "슬롯에 텍스트를 채우는" 코드를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **finalize_block_specs/calculate_budgets_for_candidates를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **AI가 HTML 구조를 결정하고 있는가, 블록 템플릿이 구조를 결정하고 있는가?** → AI가 결정해야 함
|
||||
- [ ] **기존 코드를 "수정"하는 것인가, "교체"하는 것인가?** → 2-3단계는 교체
|
||||
- [ ] **C_reference.png 수준의 결과가 나올 수 있는 구조인가?** → topic 합침, 포함 관계, 핵심 메시지 분리가 가능해야 함
|
||||
- [ ] **_adjust_design에서 블록 기반 로직을 사용하고 있는가?** → 사용하면 안 됨
|
||||
- [ ] **_apply_adjustments에서 fill_content를 호출하고 있는가?** → 호출하면 안 됨
|
||||
|
||||
---
|
||||
|
||||
## 9. 기대 효과
|
||||
|
||||
| 지표 | Phase R (실패) | Phase R' (목표) |
|
||||
|------|---------------|----------------|
|
||||
| 결과물 수준 | 34점 (블록 선택+슬롯 한계) | C_reference.png 수준 (70점+) |
|
||||
| 구조 결정 주체 | 블록 템플릿 | AI (콘텐츠 기반) |
|
||||
| topic 합침/분리 | 불가 | 가능 |
|
||||
| 포함 관계 시각화 | 해당 블록 없으면 불가 | AI가 직접 구성 |
|
||||
| 원본 텍스트 보존 | 편집자 의존 (축약됨) | AI가 원본 직접 삽입 |
|
||||
| CSS 일관성 | 블록 템플릿 보장 | 디자인 토큰 + 블록 CSS 참고 + 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 회귀 방지 — Phase P=Q=R 반복 금지
|
||||
|
||||
### 이 Phase에서 절대 하면 안 되는 것
|
||||
|
||||
1. **block_selector.py의 select_block_candidates를 호출하면 안 됨** — 블록 "선택" 시스템 회귀
|
||||
2. **content_editor.py의 fill_candidates/fill_content를 호출하면 안 됨** — "슬롯 채우기" 회귀
|
||||
3. **catalog.yaml에서 블록을 매칭하면 안 됨** — 블록 매칭 회귀
|
||||
4. **variant를 추가하면 안 됨** — Phase R의 실패 패턴 회귀
|
||||
5. **"최소 변경"으로 기존 코드 위에 패치하면 안 됨** — P=Q=R 반복의 원인
|
||||
|
||||
### 이 Phase에서 반드시 해야 하는 것
|
||||
|
||||
1. **html_generator.py에서 AI가 HTML 구조를 직접 생성** — 콘텐츠가 구조를 결정
|
||||
2. **블록 CSS는 참고만** — "선택"이 아닌 "참고"
|
||||
3. **pipeline.py 줄 166~339를 교체** — 패치가 아닌 교체
|
||||
4. **C_reference.png와 동일 수준의 결과를 자동으로 생성** — 이것이 합격 기준
|
||||
224
IMPROVEMENT-PHASE-S-ISSUES.md
Normal file
224
IMPROVEMENT-PHASE-S-ISSUES.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Phase S 실행 결과 문제점 + 해결 방향
|
||||
|
||||
> 작성일: 2026-03-31
|
||||
> 상태: 문제 기록. 수정 대기.
|
||||
> 근거: 1774736083771_phaseS_20260331_024142 결과물 검토
|
||||
|
||||
---
|
||||
|
||||
## 1. 근본 원인
|
||||
|
||||
**검증 때 성공한 것과 실제 실행에서 실패한 것의 차이:**
|
||||
|
||||
| 항목 | 검증 (성공) | 실행 (실패) |
|
||||
|------|-----------|-----------|
|
||||
| 프롬프트 | 실제 텍스트를 한 글자씩 프롬프트에 포함 | "source_data를 그대로 써라"만 지시 |
|
||||
| CSS | 구체적 수치까지 지정 (13px, #93c5fd 등) | 추상적 ("디자인 토큰 참고") |
|
||||
| 레이아웃 | 구체적 구조 지시 (flex, float, margin-top 등) | "여백 최소화"만 지시 |
|
||||
| 결과 | 합격 | 전부 불합격 |
|
||||
|
||||
**즉, 프롬프트가 구체적이면 합격, 추상적이면 실패.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 문제 목록 (전체)
|
||||
|
||||
### 2-1. 텍스트
|
||||
- 원본 MDX 80-95% 보존 안 됨 — 다시 축약/변형
|
||||
- source_data가 프롬프트에 직접 포함되지 않고 "참고하라"만 지시
|
||||
|
||||
### 2-2. 레이아웃
|
||||
- 배경과 본심 사이 간격이 없음
|
||||
- 본심 가로 크기가 배경에 비해 좁음 — 같은 body인데 너비가 다름
|
||||
- 전체 컨테이너 크기/간격이 검증 때와 다름
|
||||
|
||||
### 2-3. 이미지
|
||||
- float으로 어우러지지 않고 본문에 붙어있음
|
||||
- 검증(core_c_fix)에서는 margin-top으로 위치 조정, 텍스트 감싸기 작동 — 지금은 안 됨
|
||||
- 이미지 캡션이 이상함
|
||||
|
||||
### 2-4. 들여쓰기
|
||||
- 불릿(•) 다음 줄 들여쓰기 전혀 적용 안 됨
|
||||
- padding-left + text-indent CSS가 생성된 HTML에 없음
|
||||
|
||||
### 2-5. 색상/강조
|
||||
- 배경(다크 박스)이 너무 강해서 본심보다 배경에 시선이 감
|
||||
- 배경은 "배경"이지 핵심이 아닌데, 시각적으로 가장 강조됨
|
||||
|
||||
### 2-6. 핵심 메시지
|
||||
- 하단 강조 박스("BIM ≠ DX")가 잘림
|
||||
|
||||
### 2-7. 용어 정의 (sidebar)
|
||||
- 텍스트 축약 — 검증(verify_v2/A_definitions)에서는 풀 텍스트였음
|
||||
- overflow +15px — 490px 안에 안 맞음
|
||||
|
||||
### 2-8. 전체 균형
|
||||
- 배경/본심/sidebar/footer의 시각적 비중이 안 맞음
|
||||
- 배경이 가장 강조되고 본심이 약함
|
||||
|
||||
---
|
||||
|
||||
## 3. 해결 방향 — 5단계 프로세스
|
||||
|
||||
### Step 1: 원본 텍스트 매핑
|
||||
- Kei 분석 결과(topics, purpose, expression_hint)에 따라 원본 MDX에서 해당 텍스트를 매핑
|
||||
- source_data의 Kei 메모("간결한 문제 제기용" 등)는 제외
|
||||
- **원본 MDX 텍스트만** 가져옴
|
||||
- MDX를 ## 기준으로 섹션 슬라이싱 → topics의 source_hint로 섹션 매칭
|
||||
|
||||
### Step 2: 검증 합격 프롬프트와 결합
|
||||
- 검증에서 합격한 프롬프트의 **디자인/구조 부분은 고정**
|
||||
- Step 1에서 매핑한 **원본 텍스트를 동적으로 삽입**
|
||||
- 영역별 개별 호출 (배경/본심/sidebar/footer)
|
||||
|
||||
| 영역 | 검증 합격 프롬프트 | 텍스트 부분 |
|
||||
|------|-----------------|-----------|
|
||||
| 배경 | verify_claude_1_2.py prompt_1의 디자인 지시 | Step 1에서 매핑한 혼용+사례 텍스트 |
|
||||
| 본심 | core_c_fix.py의 CSS 구조 | Step 1에서 매핑한 핵심기술 텍스트 |
|
||||
| sidebar | verify_definitions_v2.py prompt_a의 디자인 지시 | Step 1에서 매핑한 용어 정의 텍스트 |
|
||||
| footer | 간단 프롬프트 | Step 1에서 매핑한 결론 텍스트 |
|
||||
|
||||
### Step 3: Claude 생성 HTML 자체 검증 (제출 전)
|
||||
- 원본 텍스트가 축약/변형 없이 들어갔는가
|
||||
- 들여쓰기 CSS(padding-left + text-indent)가 적용되었는가
|
||||
- 이미지가 정확히 배치되었는가
|
||||
- 컨테이너 크기를 초과하지 않는가
|
||||
- Kei 메모("간결한 문제 제기용" 등)가 슬라이드에 포함되지 않았는가
|
||||
- **검증 실패 시 사용자에게 제출하지 않고 재생성**
|
||||
|
||||
### Step 4: 전체 슬라이드 조립 + 전체 균형 검증
|
||||
- 각 영역 HTML을 슬라이드 프레임에 조립
|
||||
- 전체 균형 검증:
|
||||
- 배경이 본심보다 강조되지 않는가
|
||||
- 영역 간 간격/여백이 적절한가
|
||||
- 전체 720px 안에 들어가는가
|
||||
- 시각적 비중이 page_structure의 비중과 맞는가
|
||||
|
||||
### Step 5: 결과물 저장
|
||||
- runs 폴더에 스텝별로 정리
|
||||
- 각 스텝의 입력/출력을 JSON + PNG로 저장
|
||||
- 최종 실행 환경: localhost:8001 (Design Agent 서버, pipeline.py 경유)
|
||||
|
||||
---
|
||||
|
||||
## 4. 세부 수정 사항 10개 (2026-03-31 03:30 추가)
|
||||
|
||||
Phase S 실행 결과에서 발견된 세부 문제 10개와 각각의 해결 방법.
|
||||
|
||||
### 1-1. 배경 다크 박스가 시각적으로 너무 강함
|
||||
- **문제:** linear-gradient(135deg, #1e293b, #0f172a) — 거의 검정. 본심보다 배경이 더 눈에 띔.
|
||||
- **해결:** 배경의 opacity를 낮추거나, 다크 대신 연한 회색 배경 사용. 또는 본심에 시각적 강조 추가하여 상대적 균형.
|
||||
- **프롬프트 수정:** BG_PROMPT의 배경색을 연한 톤으로 변경하거나, 본심에 배경색/border 추가.
|
||||
- **충돌/회귀:** 없음. 색상만 변경이고, 검증 합격 배경도 같은 다크였으므로 톤만 조정.
|
||||
|
||||
### 1-2. 배경 사례 카드가 배경 영역 안에 다 들어가는가
|
||||
- **문제:** height:176px + overflow:hidden이면 사례 카드가 잘릴 수 있음.
|
||||
- **해결:** 프롬프트에 "폰트를 줄여서라도 height 안에 맞출 것" 이미 포함. 실제 잘림 여부는 브라우저에서 확인.
|
||||
- **충돌/회귀:** 없음. 기존 프롬프트에 이미 있는 지시.
|
||||
|
||||
### 2-1. 본심 가로 크기가 배경과 다름
|
||||
- **문제:** 배경 width:100%, 본심 width:707px. 같은 body인데 너비가 다름.
|
||||
- **해결:** CORE_PROMPT의 CSS에서 `.core { width: 707px; }` → `.core { width: 100%; }` 변경.
|
||||
- **충돌/회귀:** 없음. 본심이 body 전체 너비를 사용하는 것이 자연스러움. 검증(core_c_fix)에서 707px이었던 것은 테스트 컨테이너 크기였을 뿐.
|
||||
|
||||
### 3-1. 본심 이미지가 텍스트와 어우러지지 않음
|
||||
- **문제:** float이 적용 안 되거나 이미지가 본문에 붙어있음.
|
||||
- **해결:** CORE_PROMPT의 참고 CSS에 `.fi { float:right; margin:60px 0 8px 12px; width:250px; }` 이미 포함되어 있으나, Claude가 무시. → 프롬프트에 "이 CSS를 반드시 그대로 사용하라. float:right를 반드시 적용하라." 강조 추가.
|
||||
- **충돌/회귀:** 없음. CSS 강조만 추가.
|
||||
|
||||
### 3-2. 이미지 캡션이 이상함
|
||||
- **문제:** 캡션 텍스트가 검증 때와 다를 수 있음.
|
||||
- **해결:** CORE_PROMPT의 참고 HTML에서 캡션을 명시: `<div class="cap">건설산업의 DX</div>`. "이 캡션을 그대로 사용하라" 추가.
|
||||
- **충돌/회귀:** 없음. 캡션 텍스트 고정.
|
||||
|
||||
### 4-1. 본심 우상단 "상세비교" 팝업 링크
|
||||
- **문제:** 검증(core_c_fix)에서는 `📊 DX와 BIM의 상세 비교` 텍스트 링크였는데 다르게 나옴.
|
||||
- **해결:** CORE_PROMPT의 참고 HTML에서 `<span class="popup-link">📊 DX와 BIM의 상세 비교</span>` 명시. "이 텍스트를 그대로 사용하라" 추가.
|
||||
- **충돌/회귀:** 없음. 텍스트 명시만 추가.
|
||||
|
||||
### 4-2. 본심 하단 핵심 메시지 박스
|
||||
- **문제:** "상위-하위 포함관계" 같은 임의 텍스트가 들어감.
|
||||
- **해결:** CORE_PROMPT에서 핵심 메시지를 명시:
|
||||
`<div class="key-msg"><em>BIM ≠ DX</em> — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다</div>`
|
||||
"이 텍스트를 그대로 사용하라. '상위개념', '하위기술', '포함관계' 같은 임의 라벨 금지." 추가.
|
||||
- **충돌/회귀:** 없음. 텍스트 명시 + 금지어 추가.
|
||||
|
||||
### 5-1. 용어 정의 들여쓰기 안 됨
|
||||
- **문제:** 불릿 2줄째가 왼쪽 끝에서 시작. padding-left + text-indent 미적용.
|
||||
- **해결:** SIDEBAR_PROMPT에 CSS 코드를 직접 포함하고 "이 CSS를 반드시 적용하라" 명시:
|
||||
```css
|
||||
.def-bullet { padding-left: 14px; text-indent: -14px; }
|
||||
.def-bullet::before { content: '•'; display: inline-block; width: 14px; text-indent: 0; }
|
||||
```
|
||||
- **충돌/회귀:** 없음. CSS 추가만.
|
||||
|
||||
### 6-1. 용어 정의에서 원본의 2줄을 1점에 합침
|
||||
- **문제:** BIM 정의 "형상정보와... 정보 관리 도구. 건설 정보와... 핵심 인프라 기술"이 하나의 불릿. 검증(verify_definitions_v2)에서는 2개 불릿으로 분리.
|
||||
- **해결:** SIDEBAR_PROMPT에 "마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리하라" 명시. 또는 프롬프트에 넣는 텍스트를 미리 줄 단위로 분리.
|
||||
- **충돌/회귀:** 없음. 분리 규칙 추가만. 단, 원본 MDX가 1줄에 2문장인 경우에만 적용 — 다른 MDX에서 1문장 1줄인 경우는 영향 없음.
|
||||
|
||||
### 7-1. 배경 블록이 다크 디자인 — 보조 영역인데 시각적 주인공
|
||||
- **문제:** BG_PROMPT가 "다크 배경 박스"로 설계. 전체 슬라이드에서 흰색 바탕에 남색 블록 하나만 있으면 거기에 시선이 먼저 감. 배경은 보조인데 시각적으로는 주인공이 되는 근본적 모순.
|
||||
- **해결:** BG_PROMPT 자체를 라이트 디자인으로 근본 교체. background: #f8fafc (연회색), border: 1px solid #e2e8f0. 사례 카드도 padding/gap 축소 (6px 8px, gap 8px).
|
||||
- **충돌/회귀:** 없음. 디자인 변경이고 기능 영향 없음.
|
||||
|
||||
### 7-2. 배경 텍스트가 개조식이 아닌 서술형 원문 그대로
|
||||
- **문제:** 배경 영역의 문장이 "~인식되고 있다", "~해당한다", "~빈번하다" 등 서술형으로 끝남. 슬라이드는 개조식이어야 하고, 마침표로 끝나는 2-3문장이 불릿 없이 한 덩어리로 들어감.
|
||||
- **해결:**
|
||||
1. 모든 프롬프트에 개조식 통일 규칙 추가: 서술형(~하다/~이다/~있다/~된다) → 개조식(~에 해당/~인식되는 중/~발생 등)
|
||||
2. 마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리 (sidebar에만 있던 규칙을 전체로 확대)
|
||||
3. 슬라이드 전체에서 문장 끝 형식이 통일되어야 함
|
||||
- **충돌/회귀:** 없음. 원본 "의미"는 보존하되 "형식"만 슬라이드에 맞게 변환. 범용적 규칙.
|
||||
|
||||
---
|
||||
|
||||
## 5. 충돌/회귀 종합 검토
|
||||
|
||||
| 수정 | 기존 코드 영향 | 다른 MDX 영향 | 판정 |
|
||||
|------|-------------|-------------|------|
|
||||
| 1-1 배경 톤 조정 | 색상 값만 변경 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 1-2 사례 잘림 확인 | 프롬프트 지시 기존과 동일 | 영향 없음 | ✅ 안전 |
|
||||
| 2-1 본심 width 100% | CSS 값 변경 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 3-1 float 강조 | 프롬프트 강조 추가 | 영향 없음 | ✅ 안전 |
|
||||
| 3-2 캡션 명시 | 프롬프트 텍스트 추가 | 다른 MDX에서는 해당 topic의 title로 동적 생성 | ✅ 안전 |
|
||||
| 4-1 팝업 링크 명시 | 프롬프트 텍스트 추가 | 팝업이 없는 MDX에서는 생성 안 됨 | ✅ 안전 |
|
||||
| 4-2 핵심 메시지 명시 | 프롬프트 텍스트 추가 + 금지어 | core_message에서 동적 생성 | ✅ 안전 |
|
||||
| 5-1 들여쓰기 CSS | CSS 추가 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 6-1 문장 분리 | 분리 규칙 추가 | 1문장 1줄인 MDX에는 영향 없음 | ✅ 안전 |
|
||||
| 7-1 배경 라이트 디자인 | BG_PROMPT 전면 교체 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 7-2 개조식 통일 | 전체 프롬프트에 규칙 추가 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
|
||||
**회귀 위험: 없음.**
|
||||
**충돌 위험: 없음.**
|
||||
**하드코딩: 4-2의 핵심 메시지 텍스트는 analysis.core_message에서 동적으로 가져오도록 해야 함 — 하드코딩 아님.**
|
||||
|
||||
---
|
||||
|
||||
## 6. 자기 검증 체크리스트
|
||||
|
||||
결과물 제출 전 반드시 확인:
|
||||
|
||||
- [ ] 배경이 본심보다 시각적으로 강조되지 않는가?
|
||||
- [ ] 본심 가로 크기가 배경과 동일한가? (둘 다 100%)
|
||||
- [ ] 본심 이미지가 float:right로 텍스트와 어우러지는가?
|
||||
- [ ] 본심 이미지 캡션이 적절한가?
|
||||
- [ ] 본심 우상단에 팝업 링크가 있는가?
|
||||
- [ ] 본심 하단 핵심 메시지에 임의 텍스트("상위개념", "하위기술", "포함관계")가 없는가?
|
||||
- [ ] 용어 정의 들여쓰기(padding-left + text-indent)가 적용되어 있는가?
|
||||
- [ ] 용어 정의에서 2문장이 별도 불릿으로 분리되어 있는가?
|
||||
- [ ] 텍스트가 원본 MDX의 80-95%를 보존하는가?
|
||||
- [ ] Kei 메모가 슬라이드에 포함되지 않았는가?
|
||||
- [ ] 배경이 라이트 디자인(연회색)인가? (다크 배경 아닌가?)
|
||||
- [ ] 문장 끝이 개조식으로 통일되어 있는가? (서술형 "~하다/~이다" 혼재 없는가?)
|
||||
- [ ] 마침표로 끝나는 2문장 이상이 별도 불릿으로 분리되었는가?
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 합격 결과물 참조
|
||||
|
||||
| 영역 | 파일 | 프롬프트 위치 |
|
||||
|------|------|------------|
|
||||
| 배경 | data/runs/verify_claude_20260330_212019/verify1.png | scripts/verify_claude_1_2.py prompt_1 |
|
||||
| 용어 정의 | data/runs/verify_v2_20260331_003421/A_definitions.png | scripts/verify_definitions_v2.py prompt_a |
|
||||
| 본심 | data/runs/core_c_fix_20260331_015828/core_c_fix.png | scripts/verify_core_c_fix.py (직접 작성 HTML) |
|
||||
| footer | 이전부터 OK | 간단 프롬프트 |
|
||||
175
IMPROVEMENT-PHASE-S.md
Normal file
175
IMPROVEMENT-PHASE-S.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Phase S: 검증 기반 확정 — Claude HTML 생성 + 검증된 프롬프트 규칙
|
||||
|
||||
> 작성일: 2026-03-31
|
||||
> 상태: 설계 확정 + 문제 발견 → 5단계 프로세스로 재정리
|
||||
> 근거: 영역별 검증 합격 결과물 존재. 자동화 전환 시 품질 저하 문제 발견.
|
||||
> 최종 실행 환경: localhost:8001 (Design Agent 서버, pipeline.py 경유)
|
||||
|
||||
---
|
||||
|
||||
## 1. 역할 분리 (확정)
|
||||
|
||||
```
|
||||
Kei (1단계): 콘텐츠 분석 → topics, relation_type, expression_hint, source_hint, page_structure
|
||||
Kei는 HTML을 만들지 않는다. 콘텐츠를 분석하고 판단한다.
|
||||
source_data에는 Kei 메모가 포함될 수 있으므로, 슬라이드 텍스트로 직접 사용하지 않는다.
|
||||
|
||||
Claude Sonnet: 원본 MDX 텍스트(Kei가 매핑한) + 검증된 디자인 규칙
|
||||
→ 각 영역(body, sidebar, footer)의 HTML을 직접 생성
|
||||
|
||||
코드: 원본 텍스트 매핑, 프롬프트 조립, HTML 검증, 슬라이드 조립, Selenium 측정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 5단계 프로세스
|
||||
|
||||
### Step 1: 원본 텍스트 매핑
|
||||
|
||||
Kei 분석 결과에 따라 **원본 MDX에서 해당 텍스트를 정확히 매핑**한다.
|
||||
|
||||
- MDX를 `##` 기준으로 섹션 슬라이싱
|
||||
- Kei가 판단한 topics의 `source_hint`로 섹션 매칭
|
||||
- **source_data는 사용하지 않는다** — Kei 메모("간결한 문제 제기용 핵심 메시지만 추출" 등)가 포함되어 있으므로
|
||||
- **원본 MDX 텍스트만 가져온다** — 80-95% 보존
|
||||
- 매핑 결과를 JSON으로 저장하여 검증 가능하게
|
||||
|
||||
매핑 규칙:
|
||||
```
|
||||
page_structure의 각 역할 → topics → source_hint → 원본 MDX 섹션 매칭
|
||||
|
||||
배경 역할의 topics → "용어의 혼용" 섹션 + "혼용 대표 사례" 섹션
|
||||
본심 역할의 topics → "DX와 핵심기술의 올바른 관계" 섹션
|
||||
첨부 역할의 topics → "용어별 정의" 섹션
|
||||
결론 역할의 topics → "핵심 요약" 섹션
|
||||
```
|
||||
|
||||
### Step 2: 검증 합격 프롬프트와 결합
|
||||
|
||||
검증에서 합격한 프롬프트의 **디자인/구조 부분은 고정**, Step 1에서 매핑한 **원본 텍스트를 동적으로 삽입**.
|
||||
영역별 Claude Sonnet 개별 호출.
|
||||
|
||||
| 영역 | 디자인 규칙 (고정) | 텍스트 (동적) |
|
||||
|------|-----------------|------------|
|
||||
| 배경 | verify_claude_1_2.py prompt_1의 디자인 지시 (다크 박스, 사례 카드, 색상, 폰트) | Step 1에서 매핑한 "용어의 혼용" + "혼용 대표 사례" 원본 텍스트 |
|
||||
| 본심 | core_c_fix.py의 CSS 구조 (float 이미지, 들여쓰기, 핵심 메시지 박스) | Step 1에서 매핑한 "DX와 핵심기술" 원본 텍스트 |
|
||||
| sidebar | verify_definitions_v2.py prompt_a의 디자인 지시 (카드, 부제, 불릿, 출처) | Step 1에서 매핑한 "용어별 정의" 원본 텍스트 |
|
||||
| footer | 배너 디자인 | Step 1에서 매핑한 "핵심 요약" 원본 텍스트 |
|
||||
|
||||
프롬프트 구성 원칙:
|
||||
- 디자인 규칙은 CSS 수치(px, 색상코드)까지 명시적으로 포함
|
||||
- 텍스트는 원본을 따옴표로 감싸서 "이 텍스트를 그대로 사용하라" 명시
|
||||
- 들여쓰기 CSS 코드를 프롬프트에 직접 포함
|
||||
- "축약/요약/재작성 금지" 명시
|
||||
|
||||
### Step 3: Claude 생성 HTML 자체 검증 (제출 전)
|
||||
|
||||
Claude가 생성한 HTML을 **사용자에게 제출하기 전에** 자동 검증.
|
||||
|
||||
검증 항목:
|
||||
1. 원본 텍스트가 축약/변형 없이 들어갔는가 (원본과 비교)
|
||||
2. 들여쓰기 CSS(padding-left + text-indent)가 HTML에 존재하는가
|
||||
3. 이미지 태그(id="slide-img-*")가 존재하는가 (이미지가 있을 때)
|
||||
4. Kei 메모("간결한 문제 제기용", "핵심 메시지만 추출" 등)가 HTML에 포함되지 않았는가
|
||||
5. 각 영역의 HTML이 비어있지 않은가
|
||||
|
||||
**검증 실패 시 사용자에게 제출하지 않고 재생성.**
|
||||
**검증 통과 시에만 다음 단계로 진행.**
|
||||
|
||||
### Step 4: 전체 슬라이드 조립 + 전체 균형 검증
|
||||
|
||||
각 영역 HTML을 슬라이드 프레임에 조립한 후 전체 검증.
|
||||
|
||||
검증 항목:
|
||||
1. 배경이 본심보다 시각적으로 강조되지 않는가
|
||||
2. 영역 간 간격/여백이 적절한가
|
||||
3. 본심의 가로 크기가 배경과 동일한가 (같은 body 영역)
|
||||
4. 전체 720px 안에 들어가는가 (Selenium 측정)
|
||||
5. sidebar가 overflow 없이 맞는가
|
||||
6. 핵심 메시지 박스가 잘리지 않는가
|
||||
7. 시각적 비중이 page_structure의 비중과 맞는가
|
||||
|
||||
### Step 5: 결과물 저장
|
||||
|
||||
runs 폴더에 스텝별로 정리하여 저장.
|
||||
|
||||
```
|
||||
{run_id}_phaseS_{timestamp}/
|
||||
├── step1_text_mapping/
|
||||
│ ├── mdx_sections.json ← 원본 MDX 섹션 슬라이싱 결과
|
||||
│ ├── topic_mapping.json ← topic → 섹션 매핑 결과
|
||||
│ └── mapped_texts.json ← 각 영역에 들어갈 텍스트
|
||||
├── step2_html_generation/
|
||||
│ ├── bg_prompt.txt ← 배경 프롬프트 (검증용)
|
||||
│ ├── bg.html + bg.png ← 배경 결과
|
||||
│ ├── core_prompt.txt ← 본심 프롬프트
|
||||
│ ├── core.html + core.png ← 본심 결과
|
||||
│ ├── sidebar_prompt.txt ← sidebar 프롬프트
|
||||
│ ├── sidebar.html + sidebar.png
|
||||
│ ├── footer.html + footer.png
|
||||
│ └── generation_meta.json
|
||||
├── step3_validation/
|
||||
│ ├── validation_result.json ← 각 항목 PASS/FAIL
|
||||
│ └── issues.json ← 실패 항목 상세
|
||||
├── step4_assembly/
|
||||
│ ├── slide.html + slide.png ← 전체 슬라이드
|
||||
│ ├── measurement.json ← Selenium 측정
|
||||
│ └── balance_check.json ← 균형 검증 결과
|
||||
├── final.html ← 최종 결과물
|
||||
└── screenshot.png ← 최종 스크린샷
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 최종 실행 환경
|
||||
|
||||
지금 테스트는 `scripts/test_phase_s.py`로 직접 실행하지만,
|
||||
**최종적으로는 localhost:8001 (Design Agent 서버)에서 pipeline.py를 통해 실행.**
|
||||
|
||||
```
|
||||
사용자 → localhost:8001 POST /api/generate
|
||||
→ pipeline.py generate_slide()
|
||||
→ html_generator.py generate_slide_html() (Phase S)
|
||||
→ render_slide_from_html()
|
||||
→ Selenium 측정 + 품질 게이트
|
||||
→ SSE로 결과 전달
|
||||
```
|
||||
|
||||
pipeline.py의 2-3단계가 html_generator로 교체된 상태.
|
||||
테스트 완료 후 서버에서 동일하게 작동해야 함.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 합격 결과물 참조
|
||||
|
||||
| 영역 | 합격 결과물 | 합격 프롬프트 위치 | 합격 방식 |
|
||||
|------|-----------|-----------------|---------|
|
||||
| 배경 | verify_claude_20260330_212019/verify1.png | scripts/verify_claude_1_2.py prompt_1 | Claude Sonnet, 수동 텍스트 |
|
||||
| 용어 정의 | verify_v2_20260331_003421/A_definitions.png | scripts/verify_definitions_v2.py prompt_a | Claude Sonnet, 수동 텍스트 |
|
||||
| 본심 | core_c_fix_20260331_015828/core_c_fix.png | scripts/verify_core_c_fix.py (직접 작성 HTML) | 하드코딩 HTML |
|
||||
| footer | 이전부터 OK | 간단 프롬프트 | Claude Sonnet |
|
||||
|
||||
---
|
||||
|
||||
## 5. 절대 규칙
|
||||
|
||||
1. **하드코딩 금지** — 이 콘텐츠에만 작동하는 코드 금지. 어떤 MDX가 와도 동일 프로세스.
|
||||
2. **회귀 금지** — block_selector, fill_candidates, fill_content 호출 금지.
|
||||
3. **source_data를 슬라이드 텍스트로 직접 사용 금지** — Kei 메모 포함 가능성.
|
||||
4. **실행은 사용자 요청 시에만**.
|
||||
5. **제출 전 자체 검증 필수** — 검증 실패 시 사용자에게 제출하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 6. 자기 검증 체크리스트
|
||||
|
||||
구현 완료 후, 테스트 실행 전 반드시 확인:
|
||||
|
||||
- [ ] source_data를 프롬프트에 직접 넣고 있지 않은가?
|
||||
- [ ] 원본 MDX 텍스트를 매핑하여 넣고 있는가?
|
||||
- [ ] 프롬프트에 디자인 규칙이 CSS 수치까지 명시적으로 포함되어 있는가?
|
||||
- [ ] 들여쓰기 CSS 코드가 프롬프트에 포함되어 있는가?
|
||||
- [ ] 각 영역이 개별 Claude 호출인가?
|
||||
- [ ] block_selector, fill_candidates를 호출하지 않는가?
|
||||
- [ ] Step 3 자체 검증이 구현되어 있는가?
|
||||
- [ ] 결과물이 하드코딩이 아니라 범용적인가?
|
||||
289
scripts/test_phase_s.py
Normal file
289
scripts/test_phase_s.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Phase S 테스트: 각 스텝별 결과물을 폴더에 정리.
|
||||
|
||||
각 스텝을 순서대로 실행하고, 중간 산출물을 JSON + PNG로 저장.
|
||||
step1/ step2/ step3/ step4/ 폴더로 분리.
|
||||
|
||||
사용법:
|
||||
python scripts/test_phase_s.py [run_id]
|
||||
"""
|
||||
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(run_id: str):
|
||||
from src.html_generator import generate_slide_html
|
||||
from src.html_validator import validate_and_clean_html
|
||||
from src.content_verifier import generate_with_retry, verify_all_areas
|
||||
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
|
||||
from src.image_utils import get_image_sizes
|
||||
|
||||
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}_phaseS_{timestamp}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"[Phase S 테스트]")
|
||||
print(f" 입력: {run_dir}")
|
||||
print(f" 출력: {out_dir}")
|
||||
print()
|
||||
|
||||
# ── Step 1 결과 로딩 (기존 Kei 분석 재사용) ──
|
||||
step1_dir = out_dir / "step1_kei_analysis"
|
||||
step1_dir.mkdir(exist_ok=True)
|
||||
|
||||
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["expression_hint"] = concept_map[tid].get("expression_hint", "")
|
||||
topic["source_data"] = concept_map[tid].get("source_data", "")
|
||||
|
||||
_save(step1_dir, "analysis.json", analysis)
|
||||
_save(step1_dir, "concepts.json", concepts)
|
||||
|
||||
topics = analysis["topics"]
|
||||
t0 = time.time()
|
||||
|
||||
print(f"[Step 1] Kei 분석 결과 로딩 완료")
|
||||
print(f" 제목: {analysis.get('title', '')}")
|
||||
print(f" 핵심 메시지: {analysis.get('core_message', '')}")
|
||||
print(f" topics: {len(topics)}개")
|
||||
for t in topics:
|
||||
print(f" t{t['id']}: {t['title']} ({t.get('purpose', '')} / {t.get('relation_type', '')})")
|
||||
|
||||
# 원본 콘텐츠
|
||||
content = """# 건설산업 DX의 올바른 이해
|
||||
|
||||
## 용어의 혼용
|
||||
건설산업에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 동일 개념으로 인식되고 있다.
|
||||
실질적으로 DX는 산업 전반의 프로세스를 혁신하는 상위개념이며, BIM은 3차원 모델 기반의 정보 관리 도구로서 DX의 하위 기술에 해당한다.
|
||||
그러나 현장에서는 BIM 도입만으로 DX가 완성된 것으로 오인하는 사례가 빈번하다.
|
||||
|
||||
## 혼용 대표 사례
|
||||
1. 스마트 건설 활성화 방안(2022.07): 추진과제를 건설산업 디지털화로 명시하면서 실행과제는 BIM 전면 도입, BIM 전문인력 양성에 국한
|
||||
2. 제7차 건설기술진흥 기본계획(2023.12): 추진방향을 디지털 전환을 통한 스마트 건설 확산으로 제시하면서 추진과제는 BIM 도입으로 건설산업 디지털화로 한정
|
||||
|
||||
## DX와 핵심기술의 올바른 관계
|
||||
DX는 BIM, GIS, 디지털 트윈 등 핵심기술의 융합을 통해서만 실현 가능한 상위개념이다.
|
||||
- GIS: 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
|
||||
- BIM: 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
|
||||
- 디지털 트윈: 현실 세계의 물리적 객체나 시스템을 디지털 환경에 동일하게 구현하는 기술
|
||||
DX는 이들 기술을 통합하여 업무방식과 가치 창출 구조를 근본적으로 전환하는 과정 및 결과이다.
|
||||
|
||||
## 용어별 정의
|
||||
- 건설산업: 부동산 개발, 설계, 시공, 유지보수를 포괄하는 종합산업으로, 광범위한 기술을 통합·융합하여 인프라를 만드는 산업
|
||||
- BIM(Building Information Modeling): 형상정보와 속성정보가 포함된 3D 모델로, 시설물의 생애주기 동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구. 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
|
||||
- DX(Digital Transformation): 디지털 기술을 활용하여 업무방식과 가치 창출 구조를 전환하는 과정 및 결과. 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립
|
||||
|
||||
## 핵심 요약
|
||||
BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 가장 기초가 되는 일부분이다.
|
||||
각 용어의 정의, 역할, 상호관계에 대한 체계적 정립이 필요하다.
|
||||
"""
|
||||
|
||||
# ── Step 1.5: 컨테이너 계산 ──
|
||||
step1c_dir = out_dir / "step1c_containers"
|
||||
step1c_dir.mkdir(exist_ok=True)
|
||||
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS[preset_name]
|
||||
container_specs = calculate_container_specs(
|
||||
analysis.get("page_structure", {}), topics, preset
|
||||
)
|
||||
|
||||
container_info = {
|
||||
role: {"height_px": s.height_px, "width_px": s.width_px, "topic_ids": s.topic_ids, "weight": s.weight}
|
||||
for role, s in container_specs.items()
|
||||
}
|
||||
_save(step1c_dir, "containers.json", container_info)
|
||||
_save(step1c_dir, "preset.json", {"name": preset_name, "grid_areas": preset.get("grid_areas", ""), "grid_columns": preset.get("grid_columns", "")})
|
||||
|
||||
print(f"\n[Step 1.5] 컨테이너 계산 완료 ({time.time()-t0:.0f}s)")
|
||||
for role, spec in container_specs.items():
|
||||
print(f" {role}: {spec.height_px}px × {spec.width_px}px, topics={spec.topic_ids}")
|
||||
|
||||
# 이미지 정보
|
||||
# dx1.png를 본심(topic 3)에 사용
|
||||
dx1_path = Path("D:/ad-hoc/cel/public/assets/images/dx1.png")
|
||||
slide_images = []
|
||||
if dx1_path.exists():
|
||||
from PIL import Image as PILImage
|
||||
img = PILImage.open(dx1_path)
|
||||
img_b64 = base64.b64encode(dx1_path.read_bytes()).decode()
|
||||
slide_images.append({
|
||||
"path": str(dx1_path),
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"ratio": round(img.width / max(1, img.height), 2),
|
||||
"topic_id": 3,
|
||||
"b64": img_b64,
|
||||
})
|
||||
print(f"\n 이미지: dx1.png ({img.width}×{img.height}px, topic 3)")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# ★ Step 2: Claude Sonnet HTML 생성
|
||||
# ══════════════════════════════════════
|
||||
step2_dir = out_dir / "step2_html_generation"
|
||||
step2_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\n[Step 2] Claude Sonnet HTML 생성 + 검증 루프... ({time.time()-t0:.0f}s)")
|
||||
|
||||
generated, verification = await generate_with_retry(
|
||||
content=content,
|
||||
analysis=analysis,
|
||||
container_specs=container_specs,
|
||||
preset=preset,
|
||||
images=slide_images,
|
||||
max_retries=2,
|
||||
)
|
||||
|
||||
_save(step2_dir, "generated_meta.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", ""),
|
||||
})
|
||||
_save(step2_dir, "body.html", generated.get("body_html", ""))
|
||||
_save(step2_dir, "sidebar.html", generated.get("sidebar_html", ""))
|
||||
_save(step2_dir, "footer.html", generated.get("footer_html", ""))
|
||||
|
||||
# 검증 결과 저장
|
||||
step2b_dir = out_dir / "step2b_verification"
|
||||
step2b_dir.mkdir(exist_ok=True)
|
||||
for area_name, result in verification.items():
|
||||
_save(step2b_dir, f"{area_name}.json", {
|
||||
"passed": result.passed,
|
||||
"score": result.score,
|
||||
"checks": result.checks,
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings,
|
||||
})
|
||||
status = "✅ PASS" if result.passed else f"❌ FAIL"
|
||||
print(f" 검증 {area_name}: {status} (score={result.score:.0%}, errors={len(result.errors)})")
|
||||
for err in result.errors:
|
||||
print(f" ⚠ {err}")
|
||||
|
||||
print(f" 완료 ({time.time()-t0:.0f}s)")
|
||||
print(f" body: {len(generated.get('body_html', ''))}자")
|
||||
print(f" sidebar: {len(generated.get('sidebar_html', ''))}자")
|
||||
print(f" footer: {len(generated.get('footer_html', ''))}자")
|
||||
|
||||
# 각 영역별 개별 PNG 생성
|
||||
for area_name, area_html in [("body", generated.get("body_html", "")), ("sidebar", generated.get("sidebar_html", "")), ("footer", generated.get("footer_html", ""))]:
|
||||
if not area_html:
|
||||
continue
|
||||
width = 767 if area_name == "body" else 380 if area_name == "sidebar" else 1088
|
||||
area_wrapped = _wrap_area(area_html, width)
|
||||
s = await asyncio.to_thread(capture_slide_screenshot, area_wrapped)
|
||||
if s:
|
||||
(step2_dir / f"{area_name}.png").write_bytes(base64.b64decode(s))
|
||||
print(f" {area_name}.png 저장 완료")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# ★ Step 3: 슬라이드 조립 + 렌더링
|
||||
# ══════════════════════════════════════
|
||||
step3_dir = out_dir / "step3_slide"
|
||||
step3_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\n[Step 3] 슬라이드 조립 + 렌더링... ({time.time()-t0:.0f}s)")
|
||||
|
||||
html = render_slide_from_html(generated, analysis, preset)
|
||||
_save(step3_dir, "slide.html", html)
|
||||
_save(out_dir, "final.html", html)
|
||||
|
||||
s = await asyncio.to_thread(capture_slide_screenshot, html)
|
||||
if s:
|
||||
(step3_dir / "slide.png").write_bytes(base64.b64decode(s))
|
||||
(out_dir / "screenshot.png").write_bytes(base64.b64decode(s))
|
||||
print(f" slide.png 저장 완료")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# ★ Step 4: 측정 + 품질 검증
|
||||
# ══════════════════════════════════════
|
||||
step4_dir = out_dir / "step4_verification"
|
||||
step4_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\n[Step 4] Selenium 측정... ({time.time()-t0:.0f}s)")
|
||||
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
_save(step4_dir, "measurement.json", measurement)
|
||||
|
||||
slide_data = measurement.get("slide", {})
|
||||
print(f" slide: {slide_data.get('scrollHeight', 0)}px / 720px {'✅' if not slide_data.get('overflowed') else '❌'}")
|
||||
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
status = "✅" if not zone_data.get("overflowed") else f"❌ +{zone_data.get('excess_px', 0)}px"
|
||||
print(f" {zone_name}: {zone_data.get('scrollHeight', 0)}px / {zone_data.get('clientHeight', 0)}px {status}")
|
||||
|
||||
total = time.time() - t0
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Phase S 테스트 완료: {total:.0f}초")
|
||||
print(f" 블록 선택: 없음")
|
||||
print(f" 슬롯 채우기: 없음")
|
||||
print(f" HTML 생성: Claude Sonnet 직접 생성")
|
||||
print(f" 결과: {out_dir}")
|
||||
print(f"")
|
||||
print(f" 폴더 구조:")
|
||||
print(f" step1_kei_analysis/ — Kei 분석 결과")
|
||||
print(f" step1c_containers/ — 컨테이너 계산")
|
||||
print(f" step2_html_generation/ — Claude 생성 HTML (영역별 JSON + PNG)")
|
||||
print(f" step3_slide/ — 조립된 슬라이드 (HTML + PNG)")
|
||||
print(f" step4_verification/ — Selenium 측정")
|
||||
print(f" final.html — 최종 결과물")
|
||||
print(f" screenshot.png — 최종 스크린샷")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
def _wrap_area(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;
|
||||
}}
|
||||
.area {{ width:{width}px; }}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="slide"><div class="area">
|
||||
{inner_html}
|
||||
</div></div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
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))
|
||||
267
src/block_selector.py
Normal file
267
src/block_selector.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Phase Q-2: 제약 기반 블록 선택 엔진.
|
||||
|
||||
relation_type → 블록 카테고리 결정론적 매핑 + 컨테이너 제약 필터링 + catalog 검증.
|
||||
AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
|
||||
|
||||
주요 함수:
|
||||
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
|
||||
- load_catalog(): catalog.yaml 로딩 + 캐싱
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_PATH = Path("templates/catalog.yaml")
|
||||
_catalog_cache: dict | None = None
|
||||
_catalog_mtime: float = 0.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# relation_type → 블록 카테고리 매핑 (Napkin.ai 방식)
|
||||
# ──────────────────────────────────────
|
||||
RELATION_TO_CATEGORIES: dict[str, list[str]] = {
|
||||
"hierarchy": ["visuals"],
|
||||
"inclusion": ["visuals"],
|
||||
"comparison": ["tables", "emphasis", "visuals"],
|
||||
"sequence": ["visuals", "cards"],
|
||||
"cause_effect": ["emphasis"],
|
||||
"definition": ["cards"],
|
||||
"none": ["emphasis", "cards"],
|
||||
}
|
||||
|
||||
# sidebar에 배치할 수 없는 카테고리
|
||||
SIDEBAR_FORBIDDEN_CATEGORIES = {"visuals", "media"}
|
||||
|
||||
# 블록이 콘텐츠 형태 변환을 강제하는 경우 (원문 보존도 저하)
|
||||
# key: block_id, value: 강제하는 형태
|
||||
# 원문이 서술형인데 이 블록이 선택되면 재작성이 불가피
|
||||
BLOCKS_FORCING_FORMAT_CHANGE = {
|
||||
"quote-question", # question 슬롯 필수 → 서술형 원문을 질문으로 변환 강제
|
||||
}
|
||||
|
||||
# zone: full-width-only 블록은 sidebar에 배치 불가
|
||||
# (catalog.yaml의 zone 필드로도 관리)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# catalog.yaml 로딩 (mtime 캐시)
|
||||
# ──────────────────────────────────────
|
||||
def load_catalog() -> dict:
|
||||
"""catalog.yaml을 로딩한다. mtime 기반 캐싱."""
|
||||
global _catalog_cache, _catalog_mtime
|
||||
|
||||
if not CATALOG_PATH.exists():
|
||||
logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
return {"blocks": []}
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime
|
||||
if _catalog_cache is not None and current_mtime == _catalog_mtime:
|
||||
return _catalog_cache
|
||||
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
_catalog_cache = yaml.safe_load(f)
|
||||
_catalog_mtime = current_mtime
|
||||
|
||||
block_count = len(_catalog_cache.get("blocks", []))
|
||||
logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록")
|
||||
return _catalog_cache
|
||||
|
||||
|
||||
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
|
||||
"""catalog에서 블록 ID로 검색."""
|
||||
for block in catalog.get("blocks", []):
|
||||
if block.get("id") == block_id:
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 핵심: 블록 후보 선택
|
||||
# ──────────────────────────────────────
|
||||
def select_block_candidates(
|
||||
topic: dict[str, Any],
|
||||
container_spec: ContainerSpec,
|
||||
used_blocks: set[str],
|
||||
catalog: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""topic + 컨테이너 → 물리적으로 가능한 블록 후보를 결정론적으로 필터링.
|
||||
|
||||
AI 호출 없음. 결과는 보통 2-4개.
|
||||
|
||||
Args:
|
||||
topic: {"id", "title", "purpose", "relation_type", ...}
|
||||
container_spec: 이 topic이 속한 컨테이너
|
||||
used_blocks: 슬라이드 내 이미 사용된 블록 ID 집합
|
||||
catalog: catalog.yaml 딕셔너리 (None이면 자동 로딩)
|
||||
|
||||
Returns:
|
||||
[{"id": "venn-diagram", "category": "visuals", "min_height_px": 300, ...}, ...]
|
||||
"""
|
||||
if catalog is None:
|
||||
catalog = load_catalog()
|
||||
|
||||
relation_type = topic.get("relation_type", "none")
|
||||
categories = RELATION_TO_CATEGORIES.get(relation_type, ["emphasis", "cards"])
|
||||
|
||||
# topic당 가용 높이
|
||||
topic_count_in_container = max(1, len(container_spec.topic_ids))
|
||||
per_topic_px = container_spec.height_px // topic_count_in_container
|
||||
|
||||
candidates = []
|
||||
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
block_category = block.get("category", "")
|
||||
|
||||
# ── 필터 1: 카테고리 매칭 ──
|
||||
if block_category not in categories:
|
||||
continue
|
||||
|
||||
# ── 필터 2: headers 제외 (headers는 슬라이드 제목용) ──
|
||||
if block_category == "headers":
|
||||
continue
|
||||
|
||||
# ── 필터 3: 최소 생존 크기 (10% tolerance) ──
|
||||
# 7px 차이로 가장 적합한 블록이 탈락하는 것을 방지
|
||||
min_height = block.get("min_height_px", 0)
|
||||
min_height_with_tolerance = min_height * 0.9
|
||||
if min_height_with_tolerance > per_topic_px:
|
||||
continue
|
||||
|
||||
# ── 필터 4: height_cost 범위 ──
|
||||
block_cost = block.get("height_cost", "medium")
|
||||
if HEIGHT_COST_ORDER.get(block_cost, 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
|
||||
continue
|
||||
|
||||
# ── 필터 5: sidebar 제한 ──
|
||||
if container_spec.zone == "sidebar":
|
||||
if block_category in SIDEBAR_FORBIDDEN_CATEGORIES:
|
||||
continue
|
||||
if block.get("zone") == "full-width-only":
|
||||
continue
|
||||
|
||||
# ── 필터 6: full-width-only 블록은 body/sidebar 나뉜 프리셋에서 body에만 ──
|
||||
if block.get("zone") == "full-width-only" and container_spec.zone == "sidebar":
|
||||
continue
|
||||
|
||||
# ── 필터 7: 중복 사용 제한 ──
|
||||
if block_id in used_blocks:
|
||||
continue
|
||||
|
||||
# ── 필터 8 (Phase Q fix): 형태 변환 강제 블록 제외 ──
|
||||
# 원문 보존이 중요하므로, 콘텐츠 형태를 강제로 바꾸는 블록은 제외
|
||||
if block_id in BLOCKS_FORCING_FORMAT_CHANGE:
|
||||
continue
|
||||
|
||||
# ── 필터 9: relation_types 명시적 매칭 ──
|
||||
# relation_types가 명시되어 있고 현재 relation_type이 포함 안 되면 제외
|
||||
# (deprioritize가 아니라 exclude — 의미 왜곡 방지)
|
||||
# 예: process-horizontal(sequence)은 hierarchy 콘텐츠에서 제외
|
||||
block_relations = block.get("relation_types", [])
|
||||
if block_relations and relation_type not in block_relations:
|
||||
continue
|
||||
|
||||
# Phase R: 블록에 available variants 정보 첨부
|
||||
variants = block.get("variants", [])
|
||||
if variants:
|
||||
block["_available_variants"] = variants
|
||||
else:
|
||||
block["_available_variants"] = [{"id": "default", "description": "기본"}]
|
||||
|
||||
candidates.append(block)
|
||||
|
||||
logger.info(
|
||||
f"[Q-2] topic {topic.get('id')} (relation={relation_type}, "
|
||||
f"container={per_topic_px}px): {len(candidates)}개 후보 "
|
||||
f"[{', '.join(c['id'] for c in candidates[:5])}]"
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 폴백: 카테고리 제한 없이 검색
|
||||
# ──────────────────────────────────────
|
||||
def select_fallback_candidates(
|
||||
container_spec: ContainerSpec,
|
||||
used_blocks: set[str],
|
||||
catalog: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""relation_type 매핑에서 후보가 없을 때, 물리적 제약만으로 검색.
|
||||
|
||||
최소 크기 + height_cost + zone만 검사. 카테고리 무시.
|
||||
"""
|
||||
if catalog is None:
|
||||
catalog = load_catalog()
|
||||
|
||||
topic_count = max(1, len(container_spec.topic_ids))
|
||||
per_topic_px = container_spec.height_px // topic_count
|
||||
|
||||
candidates = []
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
if block.get("category") == "headers":
|
||||
continue
|
||||
if block.get("min_height_px", 0) > per_topic_px:
|
||||
continue
|
||||
if HEIGHT_COST_ORDER.get(block.get("height_cost", "medium"), 1) > HEIGHT_COST_ORDER.get(container_spec.max_height_cost, 3):
|
||||
continue
|
||||
if container_spec.zone == "sidebar" and block.get("zone") == "full-width-only":
|
||||
continue
|
||||
if block_id in used_blocks:
|
||||
continue
|
||||
candidates.append(block)
|
||||
|
||||
# compact 블록 우선 (작은 컨테이너에 적합)
|
||||
cost_order = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
|
||||
candidates.sort(key=lambda c: cost_order.get(c.get("height_cost", "medium"), 1))
|
||||
|
||||
logger.info(
|
||||
f"[Q-2 fallback] container={per_topic_px}px: {len(candidates)}개 후보"
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 후보 설명 텍스트 생성 (Kei 프롬프트용)
|
||||
# ──────────────────────────────────────
|
||||
def format_candidates_for_prompt(
|
||||
candidates: list[dict],
|
||||
budget: dict | None = None,
|
||||
) -> str:
|
||||
"""블록 후보 목록을 Kei 프롬프트에 포함할 텍스트로 포맷. Phase R: variant 정보 포함."""
|
||||
lines = []
|
||||
for i, c in enumerate(candidates[:5], 1): # 최대 5개
|
||||
block_id = c.get("id", "")
|
||||
name = c.get("name", "")
|
||||
visual = c.get("visual", "")[:80] # 80자로 축약
|
||||
height_cost = c.get("height_cost", "")
|
||||
when = c.get("when", "")[:60]
|
||||
|
||||
budget_info = ""
|
||||
if budget and block_id in budget:
|
||||
b = budget[block_id]
|
||||
budget_info = f" | 예산: 최대 {b['max_items']}항목, 총 {b['total_chars']}자"
|
||||
|
||||
# Phase R: variant 정보
|
||||
variants = c.get("_available_variants", [])
|
||||
variant_lines = ""
|
||||
if len(variants) > 1: # default만 있으면 표시 안 함
|
||||
v_descs = [f" - {v['id']}: {v.get('description', '')}" for v in variants]
|
||||
variant_lines = "\n 변형:\n" + "\n".join(v_descs)
|
||||
|
||||
lines.append(
|
||||
f" {i}. {block_id} ({name})\n"
|
||||
f" 시각: {visual}\n"
|
||||
f" 적합: {when}\n"
|
||||
f" 크기: {height_cost}{budget_info}{variant_lines}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
635
src/content_verifier.py
Normal file
635
src/content_verifier.py
Normal file
@@ -0,0 +1,635 @@
|
||||
"""Phase S: 생성 HTML 콘텐츠 검증 + 재시도 루프.
|
||||
|
||||
생성기(html_generator)와 완전히 분리된 독립 검증.
|
||||
코드 기반 검증을 먼저, LLM 검증은 코드가 못 잡는 것만.
|
||||
|
||||
검증 계층:
|
||||
Layer 1: 텍스트 보존 검증 (코드, $0)
|
||||
Layer 2: 금지 콘텐츠 검증 (코드, $0)
|
||||
Layer 3: 구조 검증 (코드, $0)
|
||||
Layer 4: 오버플로 검증 (Selenium, $0) — slide_measurer.py 재사용
|
||||
Layer 5: 시각 품질 검증 (Opus 비전, $$) — kei_client.py 재사용
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from html.parser import HTMLParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 데이터 구조
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class VerificationResult:
|
||||
"""단일 영역의 검증 결과."""
|
||||
passed: bool
|
||||
area_name: str
|
||||
checks: dict[str, bool] = field(default_factory=dict)
|
||||
score: float = 0.0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# HTML 텍스트 추출
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
"""HTML에서 가시 텍스트만 추출. <style>, <script> 내부 제외."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.texts: list[str] = []
|
||||
self._skip = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self._skip:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self.texts.append(stripped)
|
||||
|
||||
|
||||
def extract_text_from_html(html: str) -> list[str]:
|
||||
"""HTML에서 가시 텍스트를 추출하여 리스트로 반환."""
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
return parser.texts
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 텍스트 정규화 + 키워드 추출
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
# 한국어 조사 (긴 것부터 매칭하도록 정렬)
|
||||
_PARTICLES = sorted([
|
||||
"에서", "으로", "부터", "까지", "에게", "한테",
|
||||
"은", "는", "이", "가", "을", "를", "에", "의",
|
||||
"로", "와", "과", "도", "만", "께",
|
||||
], key=len, reverse=True)
|
||||
|
||||
# 개조식 어미 변환 매핑 (역변환: 개조식 → 서술형)
|
||||
_ENDING_NORMALIZE = {
|
||||
"있음": "있다",
|
||||
"됨": "된다",
|
||||
"함": "한다",
|
||||
"임": "이다",
|
||||
"없음": "없다",
|
||||
"았음": "았다",
|
||||
"었음": "었다",
|
||||
"됨": "된다",
|
||||
}
|
||||
|
||||
|
||||
def normalize_for_comparison(text: str) -> str:
|
||||
"""비교용 텍스트 정규화.
|
||||
|
||||
1. 공백/줄바꿈 통일
|
||||
2. 불릿 마커 제거
|
||||
3. HTML 엔티티 디코딩
|
||||
4. 개조식 어미 → 서술형으로 통일 (양쪽 비교 기준 통일)
|
||||
"""
|
||||
# 공백 정규화
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
# 불릿 마커 제거
|
||||
text = re.sub(r"[•◦·\-▪▸►]", "", text).strip()
|
||||
# HTML 엔티티
|
||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace(" ", " ").replace("'", "'").replace(""", '"')
|
||||
# 개조식 어미 → 서술형 (비교 기준 통일)
|
||||
for gaejo, seosul in _ENDING_NORMALIZE.items():
|
||||
if text.endswith(gaejo):
|
||||
text = text[: -len(gaejo)] + seosul
|
||||
break
|
||||
return text
|
||||
|
||||
|
||||
def extract_keywords(text: str) -> list[str]:
|
||||
"""3글자 이상 키워드 추출. 조사 제거."""
|
||||
words = re.findall(r"[가-힣a-zA-Z0-9()]+", text)
|
||||
keywords = []
|
||||
for w in words:
|
||||
if len(w) < 3:
|
||||
continue
|
||||
# 뒤쪽 조사 제거
|
||||
for p in _PARTICLES:
|
||||
if w.endswith(p) and len(w) - len(p) >= 2:
|
||||
w = w[: -len(p)]
|
||||
break
|
||||
if len(w) >= 2:
|
||||
keywords.append(w)
|
||||
return keywords
|
||||
|
||||
|
||||
# 검증에서 제외할 메타 라인 접두사 (Kei 분석 메타, 프롬프트 지시사항)
|
||||
_META_PREFIXES = [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
|
||||
|
||||
def strip_meta_lines(text: str) -> str:
|
||||
"""검증 전에 메타/지시 라인을 제거.
|
||||
|
||||
_map_sections_for_role()이 추가하는 expression_hint, 제목 라벨 등은
|
||||
Claude에게 보내는 지시사항이지 슬라이드에 들어갈 콘텐츠가 아니므로
|
||||
검증 대상에서 제외한다.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
filtered = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if any(stripped.startswith(prefix) for prefix in _META_PREFIXES):
|
||||
continue
|
||||
# expression_hint 내용도 제거 (문장 중간에 포함될 수 있음)
|
||||
if "현상-문제 인과관계" in stripped:
|
||||
continue
|
||||
if "상위-하위 포함 관계" in stripped:
|
||||
continue
|
||||
if "독립적 나열" in stripped:
|
||||
continue
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
def split_into_sentences(text: str) -> list[str]:
|
||||
"""텍스트를 문장 단위로 분할.
|
||||
|
||||
마침표, 줄바꿈, 불릿 기준 분할.
|
||||
## 헤더, 빈 줄, 5자 미만, 메타 라인 필터링.
|
||||
"""
|
||||
# 메타 라인 제거
|
||||
text = strip_meta_lines(text)
|
||||
# 줄 단위 분할
|
||||
lines = text.split("\n")
|
||||
sentences = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# 불릿 마커 제거
|
||||
line = re.sub(r"^[\-•◦·\d]+[.)]\s*", "", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
# 마침표 기준 추가 분할
|
||||
parts = re.split(r"(?<=\.)\s+", line)
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if len(part) >= 5:
|
||||
sentences.append(part)
|
||||
return sentences
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Layer 1: 텍스트 보존 검증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def verify_text_preservation(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
threshold: float = 0.70,
|
||||
) -> VerificationResult:
|
||||
"""원본 MDX 텍스트가 HTML에 보존되었는지 검증.
|
||||
|
||||
1. 원본을 문장 단위로 분할
|
||||
2. 각 문장의 키워드를 추출
|
||||
3. HTML 텍스트에서 키워드 존재 확인
|
||||
4. 문장별 매칭률 계산
|
||||
"""
|
||||
original_sentences = split_into_sentences(original_mdx)
|
||||
if not original_sentences:
|
||||
return VerificationResult(
|
||||
passed=True, area_name=area_name,
|
||||
checks={"text_preservation": True}, score=1.0,
|
||||
)
|
||||
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
html_combined = normalize_for_comparison(" ".join(html_texts))
|
||||
|
||||
matched = 0
|
||||
missing: list[str] = []
|
||||
|
||||
for sentence in original_sentences:
|
||||
norm_orig = normalize_for_comparison(sentence)
|
||||
keywords = extract_keywords(norm_orig)
|
||||
if not keywords:
|
||||
matched += 1
|
||||
continue
|
||||
|
||||
# 키워드 매칭률
|
||||
kw_found = sum(1 for kw in keywords if kw in html_combined)
|
||||
kw_ratio = kw_found / len(keywords)
|
||||
|
||||
# SequenceMatcher fallback
|
||||
best_ratio = 0.0
|
||||
for html_text in html_texts:
|
||||
norm_html = normalize_for_comparison(html_text)
|
||||
ratio = SequenceMatcher(None, norm_orig, norm_html).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
|
||||
if kw_ratio >= 0.6 or best_ratio >= 0.65:
|
||||
matched += 1
|
||||
else:
|
||||
missing.append(sentence)
|
||||
|
||||
score = matched / len(original_sentences)
|
||||
passed = score >= threshold
|
||||
|
||||
errors = []
|
||||
if not passed:
|
||||
errors = [f"누락 문장 ({len(missing)}/{len(original_sentences)}):"]
|
||||
for s in missing[:5]: # 최대 5개만
|
||||
errors.append(f" - \"{s[:60]}...\"" if len(s) > 60 else f" - \"{s}\"")
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
area_name=area_name,
|
||||
checks={"text_preservation": passed},
|
||||
score=score,
|
||||
errors=errors,
|
||||
warnings=[f"보존율: {score:.0%} ({matched}/{len(original_sentences)} 문장)"]
|
||||
if score < 1.0 else [],
|
||||
)
|
||||
|
||||
|
||||
def detect_invented_text(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
min_length: int = 15,
|
||||
) -> list[str]:
|
||||
"""HTML에서 원본에 없는 발명된 텍스트를 탐지.
|
||||
|
||||
min_length 이상의 연속 텍스트가 원본에 없으면 발명 텍스트로 판정.
|
||||
"""
|
||||
# 허용 예외 (구조적 라벨)
|
||||
allowed_labels = {
|
||||
"용어 정의", "핵심 메시지", "상세 비교", "DX와 BIM의 상세 비교",
|
||||
}
|
||||
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
norm_mdx = normalize_for_comparison(original_mdx)
|
||||
|
||||
invented = []
|
||||
for text in html_texts:
|
||||
text = text.strip()
|
||||
if len(text) < min_length:
|
||||
continue
|
||||
if text in allowed_labels:
|
||||
continue
|
||||
# CSS 값, 숫자만으로 된 것 제외
|
||||
if re.match(r"^[\d\s.,%px#rgb()]+$", text):
|
||||
continue
|
||||
|
||||
norm_text = normalize_for_comparison(text)
|
||||
# 핵심 키워드 추출 후 원본에서 검색
|
||||
keywords = extract_keywords(norm_text)
|
||||
if not keywords:
|
||||
continue
|
||||
kw_found = sum(1 for kw in keywords if kw in norm_mdx)
|
||||
kw_ratio = kw_found / len(keywords) if keywords else 1.0
|
||||
|
||||
if kw_ratio < 0.4:
|
||||
invented.append(text[:80])
|
||||
|
||||
return invented
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Layer 2: 금지 콘텐츠 검증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
FORBIDDEN_KEI_MEMOS = [
|
||||
"간결한 문제 제기용",
|
||||
"핵심 메시지만 추출",
|
||||
"문제제기 핵심문장",
|
||||
"source_data",
|
||||
"expression_hint",
|
||||
"relation_type",
|
||||
]
|
||||
|
||||
FORBIDDEN_LABELS_IN_KEYMSG = [
|
||||
"상위개념",
|
||||
"하위기술",
|
||||
"포함관계",
|
||||
]
|
||||
|
||||
|
||||
def verify_no_forbidden_content(
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
) -> VerificationResult:
|
||||
"""금지 콘텐츠가 HTML에 포함되지 않았는지 검증."""
|
||||
html_text = " ".join(extract_text_from_html(generated_html))
|
||||
found = []
|
||||
|
||||
# Kei 메모 검색
|
||||
for memo in FORBIDDEN_KEI_MEMOS:
|
||||
if memo in html_text:
|
||||
found.append(f"Kei 메모 포함: \"{memo}\"")
|
||||
|
||||
# key-msg 영역의 금지 라벨 (body_core만)
|
||||
if area_name == "body_core":
|
||||
# key-msg 내용만 추출
|
||||
keymsg_match = re.search(
|
||||
r'class="key-msg"[^>]*>(.*?)</div>',
|
||||
generated_html,
|
||||
re.DOTALL,
|
||||
)
|
||||
if keymsg_match:
|
||||
keymsg_text = keymsg_match.group(1)
|
||||
for label in FORBIDDEN_LABELS_IN_KEYMSG:
|
||||
if label in keymsg_text:
|
||||
found.append(f"key-msg에 금지 라벨: \"{label}\"")
|
||||
|
||||
passed = len(found) == 0
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
area_name=area_name,
|
||||
checks={"no_forbidden": passed},
|
||||
score=1.0 if passed else 0.0,
|
||||
errors=found,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Layer 3: 구조 검증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
REQUIRED_PATTERNS: dict[str, list[str]] = {
|
||||
"body_bg": ["overflow:hidden", "overflow: hidden"],
|
||||
"body_core": [
|
||||
"overflow:hidden|overflow: hidden",
|
||||
"float:right|float: right",
|
||||
"key-msg",
|
||||
"popup-link",
|
||||
],
|
||||
"sidebar": [
|
||||
"overflow:hidden|overflow: hidden",
|
||||
"padding-left",
|
||||
"text-indent",
|
||||
],
|
||||
"footer": [],
|
||||
}
|
||||
|
||||
|
||||
def verify_structure(
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
has_image: bool = False,
|
||||
) -> VerificationResult:
|
||||
"""필수 CSS/HTML 패턴이 존재하는지 검증."""
|
||||
patterns = REQUIRED_PATTERNS.get(area_name, [])
|
||||
missing = []
|
||||
|
||||
for pattern in patterns:
|
||||
# OR 패턴: "a|b" → a 또는 b 중 하나 존재
|
||||
alternatives = pattern.split("|")
|
||||
if not any(alt in generated_html for alt in alternatives):
|
||||
missing.append(pattern)
|
||||
|
||||
if has_image and area_name == "body_core":
|
||||
if "slide-img-" not in generated_html:
|
||||
missing.append("slide-img-* (이미지 태그)")
|
||||
|
||||
passed = len(missing) == 0
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
area_name=area_name,
|
||||
checks={"structure": passed},
|
||||
score=1.0 if passed else (1.0 - len(missing) / max(1, len(patterns))),
|
||||
errors=[f"필수 패턴 누락: {p}" for p in missing],
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 합성 검증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def verify_area(
|
||||
original_text: str,
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
has_image: bool = False,
|
||||
) -> VerificationResult:
|
||||
"""단일 영역의 전체 검증 (L1 + L2 + L3)."""
|
||||
results = [
|
||||
verify_text_preservation(original_text, generated_html, area_name),
|
||||
verify_no_forbidden_content(generated_html, area_name),
|
||||
verify_structure(generated_html, area_name, has_image),
|
||||
]
|
||||
|
||||
all_passed = all(r.passed for r in results)
|
||||
all_checks = {}
|
||||
all_errors = []
|
||||
all_warnings = []
|
||||
|
||||
for r in results:
|
||||
all_checks.update(r.checks)
|
||||
all_errors.extend(r.errors)
|
||||
all_warnings.extend(r.warnings)
|
||||
|
||||
avg_score = sum(r.score for r in results) / len(results)
|
||||
|
||||
return VerificationResult(
|
||||
passed=all_passed,
|
||||
area_name=area_name,
|
||||
checks=all_checks,
|
||||
score=avg_score,
|
||||
errors=all_errors,
|
||||
warnings=all_warnings,
|
||||
)
|
||||
|
||||
|
||||
def verify_all_areas(
|
||||
generated: dict[str, str],
|
||||
area_texts: dict[str, str],
|
||||
has_image_areas: set[str] | None = None,
|
||||
) -> dict[str, VerificationResult]:
|
||||
"""모든 영역의 검증 결과를 반환.
|
||||
|
||||
Args:
|
||||
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
|
||||
area_texts: {"body_bg": "원본 텍스트", "body_core": "...", "sidebar": "...", "footer": "..."}
|
||||
has_image_areas: 이미지가 있는 영역 이름 set
|
||||
"""
|
||||
if has_image_areas is None:
|
||||
has_image_areas = set()
|
||||
|
||||
results = {}
|
||||
|
||||
# body_html은 bg + core 두 영역이 합쳐져 있으므로 분리 검증
|
||||
body_html = generated.get("body_html", "")
|
||||
|
||||
if "body_bg" in area_texts and body_html:
|
||||
results["body_bg"] = verify_area(
|
||||
area_texts["body_bg"], body_html, "body_bg",
|
||||
)
|
||||
|
||||
if "body_core" in area_texts and body_html:
|
||||
results["body_core"] = verify_area(
|
||||
area_texts["body_core"], body_html, "body_core",
|
||||
has_image="body_core" in has_image_areas,
|
||||
)
|
||||
|
||||
sidebar_html = generated.get("sidebar_html", "")
|
||||
if "sidebar" in area_texts and sidebar_html:
|
||||
results["sidebar"] = verify_area(
|
||||
area_texts["sidebar"], sidebar_html, "sidebar",
|
||||
)
|
||||
|
||||
footer_html = generated.get("footer_html", "")
|
||||
if "footer" in area_texts and footer_html:
|
||||
results["footer"] = verify_area(
|
||||
area_texts["footer"], footer_html, "footer",
|
||||
)
|
||||
|
||||
# 로그
|
||||
for name, r in results.items():
|
||||
status = "PASS" if r.passed else "FAIL"
|
||||
logger.info(
|
||||
f"[검증] {name}: {status} (score={r.score:.0%}, "
|
||||
f"errors={len(r.errors)}, warnings={len(r.warnings)})"
|
||||
)
|
||||
for err in r.errors:
|
||||
logger.warning(f"[검증] {name} 에러: {err}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 재시도 루프
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def generate_with_retry(
|
||||
content: str,
|
||||
analysis: dict,
|
||||
container_specs: dict,
|
||||
preset: dict,
|
||||
images: list[dict] | None = None,
|
||||
max_retries: int = 2,
|
||||
) -> tuple[dict[str, str], dict[str, VerificationResult]]:
|
||||
"""검증 포함 생성 루프.
|
||||
|
||||
1. generate_slide_html() 호출
|
||||
2. validate_and_clean_html() (보안)
|
||||
3. verify_all_areas() (콘텐츠 검증)
|
||||
4. 실패한 영역만 재생성 (에러 피드백 포함)
|
||||
5. max_retries까지 반복
|
||||
"""
|
||||
from src.html_generator import generate_slide_html, regenerate_area, _slice_mdx_sections, _map_sections_for_role, _get_definitions, _get_conclusion
|
||||
from src.html_validator import validate_and_clean_html
|
||||
|
||||
# 원본 텍스트 매핑 (검증 기준)
|
||||
sections = _slice_mdx_sections(content)
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
topics = analysis.get("topics", [])
|
||||
topic_map = {t["id"]: t for t in topics}
|
||||
|
||||
def get_topics_for_role(role: str) -> list[dict]:
|
||||
info = page_struct.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
||||
|
||||
area_texts = {}
|
||||
bg_topics = get_topics_for_role("배경")
|
||||
if bg_topics:
|
||||
area_texts["body_bg"] = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
|
||||
core_topics = get_topics_for_role("본심")
|
||||
if core_topics:
|
||||
area_texts["body_core"] = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
|
||||
ref_topics = get_topics_for_role("첨부")
|
||||
if ref_topics:
|
||||
area_texts["sidebar"] = _get_definitions(content)
|
||||
conclusion_topics = get_topics_for_role("결론")
|
||||
if conclusion_topics:
|
||||
area_texts["footer"] = _get_conclusion(content)
|
||||
|
||||
has_image_areas = set()
|
||||
if images:
|
||||
core_topic_ids = {t["id"] for t in core_topics}
|
||||
for img in images:
|
||||
if img.get("topic_id") in core_topic_ids:
|
||||
has_image_areas.add("body_core")
|
||||
|
||||
# 1차 생성
|
||||
logger.info("[검증 루프] 1차 생성 시작")
|
||||
generated = await generate_slide_html(
|
||||
content=content, analysis=analysis,
|
||||
container_specs=container_specs, preset=preset, images=images,
|
||||
)
|
||||
generated = validate_and_clean_html(generated)
|
||||
|
||||
# 검증 루프
|
||||
for attempt in range(max_retries + 1):
|
||||
verification = verify_all_areas(generated, area_texts, has_image_areas)
|
||||
|
||||
failed_areas = {name: r for name, r in verification.items() if not r.passed}
|
||||
|
||||
if not failed_areas:
|
||||
logger.info(f"[검증 루프] 전체 PASS (시도 {attempt + 1}회)")
|
||||
return generated, verification
|
||||
|
||||
if attempt >= max_retries:
|
||||
logger.warning(
|
||||
f"[검증 루프] {max_retries}회 재시도 후에도 실패: "
|
||||
+ ", ".join(failed_areas.keys())
|
||||
)
|
||||
return generated, verification
|
||||
|
||||
# 실패 영역만 재생성
|
||||
logger.info(
|
||||
f"[검증 루프] 시도 {attempt + 1}: "
|
||||
f"실패 영역 재생성 — {', '.join(failed_areas.keys())}"
|
||||
)
|
||||
|
||||
for area_name, result in failed_areas.items():
|
||||
new_html = await regenerate_area(
|
||||
area_name=area_name,
|
||||
errors=result.errors,
|
||||
content=content,
|
||||
analysis=analysis,
|
||||
container_specs=container_specs,
|
||||
preset=preset,
|
||||
images=images,
|
||||
)
|
||||
if new_html:
|
||||
# 영역별로 교체
|
||||
if area_name in ("body_bg", "body_core"):
|
||||
# body_html은 bg + core 합본이므로 전체 재생성 필요
|
||||
# (개별 교체가 복잡하므로 body 전체를 재생성)
|
||||
regenerated = await generate_slide_html(
|
||||
content=content, analysis=analysis,
|
||||
container_specs=container_specs, preset=preset,
|
||||
images=images,
|
||||
)
|
||||
regenerated = validate_and_clean_html(regenerated)
|
||||
generated["body_html"] = regenerated.get("body_html", generated["body_html"])
|
||||
break # body 전체를 재생성했으므로 다른 body 영역도 갱신됨
|
||||
elif area_name == "sidebar":
|
||||
generated["sidebar_html"] = new_html
|
||||
elif area_name == "footer":
|
||||
generated["footer_html"] = new_html
|
||||
|
||||
return generated, verification
|
||||
177
src/design_tokens.py
Normal file
177
src/design_tokens.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Phase S-2: 디자인 토큰 + CSS 패턴 + 레이아웃 규칙을 프롬프트용으로 추출.
|
||||
|
||||
AI(Claude Sonnet)가 HTML을 생성할 때 참고하는 디자인 시스템 컨텍스트.
|
||||
블록을 "선택"하는 것이 아니라, CSS 스타일을 "참고"하는 것.
|
||||
|
||||
Phase S 검증 결과 기반: 이미지 배치, 들여쓰기, 팝업 규칙 포함.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
def load_design_tokens() -> str:
|
||||
"""tokens.css 전문을 반환."""
|
||||
tokens_path = ROOT / "static" / "tokens.css"
|
||||
if tokens_path.exists():
|
||||
return tokens_path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def load_base_css() -> str:
|
||||
"""base.css 전문을 반환."""
|
||||
base_path = ROOT / "static" / "base.css"
|
||||
if base_path.exists():
|
||||
return base_path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def get_block_css_patterns() -> str:
|
||||
"""기존 블록에서 자주 쓰이는 CSS 패턴을 발췌하여 참고용 텍스트로 반환.
|
||||
|
||||
블록을 "선택"하기 위한 것이 아니라, AI가 HTML 생성 시 스타일을 "참고"하기 위한 것.
|
||||
"""
|
||||
return """
|
||||
## 블록 CSS 패턴 참고 (스타일만 참고, 구조는 AI가 결정)
|
||||
|
||||
### 다크 배경 패턴
|
||||
배경: linear-gradient(135deg, #1e293b, #0f172a)
|
||||
텍스트: #ffffff, 제목: #93c5fd, 강조: #fbbf24
|
||||
border-radius: 8px, padding: 14px 20px
|
||||
불릿 마커: #60a5fa
|
||||
카드 내부: rgba(255,255,255,0.06), border-left: 3px solid #60a5fa
|
||||
|
||||
### 밝은 카드 패턴
|
||||
배경: #f8fafc, 테두리: 1px solid #e2e8f0
|
||||
border-radius: 8px, padding: 12px 16px
|
||||
제목: 15px bold #1e293b, 설명: 13px #475569
|
||||
원형 번호: 32px, background: #2563eb, color: white
|
||||
|
||||
### 포함 관계 시각화 패턴
|
||||
외곽 박스: border: 3px solid #2563eb, border-radius: 14px
|
||||
배경: linear-gradient(180deg, #eff6ff, #dbeafe)
|
||||
라벨 배지: background: #2563eb, color: white, border-radius: 10px, position: absolute top: -11px
|
||||
내부 카드: background: white, border: 2px solid #93c5fd, border-radius: 8px
|
||||
아이콘 원: background: linear-gradient(135deg, #93c5fd, #2563eb), 36px, border-radius: 50%
|
||||
|
||||
### 강조 메시지 패턴
|
||||
배경: #f0f9ff, 테두리: 2px solid #bae6fd
|
||||
텍스트: #0c4a6e, 강조 색상: #dc2626 (danger)
|
||||
border-radius: 8px, padding: 10px 16px, text-align: center
|
||||
|
||||
### 배너/결론 패턴
|
||||
배경: linear-gradient(135deg, #006aff, #00aaff)
|
||||
텍스트: white, font-size: 15px bold
|
||||
border-radius: 8px, padding: 14px 30px, text-align: center
|
||||
|
||||
### 테이블 패턴
|
||||
헤더: background: #1e293b, color: white, padding: 8px 12px
|
||||
행: border-bottom: 1px solid #e2e8f0, 짝수행: background: #f8fafc
|
||||
첫 열: font-weight: 600, color: #1e293b
|
||||
|
||||
### 구분선 패턴
|
||||
좌우 선: height: 1px, background: #cbd5e1
|
||||
중앙 텍스트: 13px bold #64748b
|
||||
|
||||
### Sidebar 정의 카드 패턴
|
||||
배경: #f8fafc, 테두리: 1px solid #e2e8f0
|
||||
번호: 22px 원형, background: #2563eb
|
||||
제목: 14px bold, 설명: 12px #475569
|
||||
출처: 10px italic #94a3b8
|
||||
"""
|
||||
|
||||
|
||||
def get_layout_rules() -> str:
|
||||
"""Phase S 검증 결과 기반 레이아웃 규칙."""
|
||||
return """
|
||||
## 레이아웃 규칙 (검증 결과 기반 — 반드시 따를 것)
|
||||
|
||||
### 들여쓰기 규칙 (절대 규칙)
|
||||
- 불릿(•) 다음 줄은 반드시 불릿 옆 글자 시작 위치에 맞춤
|
||||
- CSS: padding-left: 14px; text-indent: -14px;
|
||||
- 하위 불릿(◦): padding-left: 28px; text-indent: -14px;
|
||||
- 줄바꿈은 단어 기준 (word-break: keep-all)
|
||||
|
||||
### 이미지 배치 규칙
|
||||
- 이미지가 있으면 float: right 또는 left로 텍스트와 어우러지게 (텍스트 감싸기)
|
||||
- 이미지 크기: 컨테이너 너비의 30-40% (이미지 비율에 따라 판단)
|
||||
- 이미지 border 없이 깔끔하게
|
||||
- 이미지 바로 아래에 캡션 (이미지에 가까이 붙임, margin-top: 1-2px)
|
||||
- 이미지 위치: 상단 텍스트 1-2줄은 전체 너비 → 그 아래에서 이미지 시작 (margin-top으로 조정)
|
||||
- 이미지 아래 빈 공간에 관련 요소 배치 (팝업 링크, 부가 정보 등)
|
||||
|
||||
### 팝업 규칙
|
||||
- 비교표, 상세 데이터 등은 <details>/<summary>로 팝업
|
||||
- 팝업 링크: 상단 오른쪽에 텍스트 링크 (박스 불필요)
|
||||
예: <summary>📊 DX와 BIM의 상세 비교</summary>
|
||||
- 팝업 내용: position: absolute, background: white, box-shadow, z-index: 10
|
||||
|
||||
### 여백 규칙
|
||||
- 빈 공간 최소화, 컨테이너를 꽉 채움
|
||||
- 패딩/간격을 과도하게 넣지 않는다
|
||||
- 이미지 아래 빈 공간에는 관련 요소(캡션, 팝업 링크 등) 배치
|
||||
|
||||
### 텍스트 보존 규칙 (절대 규칙)
|
||||
- 원본 MDX의 80-95%를 그대로 사용
|
||||
- source_data에 있는 텍스트를 그대로 가져온다
|
||||
- 축약/요약/재작성 금지
|
||||
- 삭제는 공간 부족 시에만, 뒤에서부터 자르기
|
||||
- "상위개념", "하위기술" 같은 임의 라벨을 붙이지 않는다
|
||||
"""
|
||||
|
||||
|
||||
def build_design_context(container_specs: dict, images: list[dict] | None = None) -> str:
|
||||
"""AI HTML 생성을 위한 전체 디자인 컨텍스트를 구성한다.
|
||||
|
||||
Args:
|
||||
container_specs: 역할별 ContainerSpec
|
||||
images: 이미지 정보 리스트 [{"path", "width", "height", "ratio", "topic_id"}, ...]
|
||||
|
||||
Returns:
|
||||
AI 프롬프트에 포함할 디자인 시스템 텍스트
|
||||
"""
|
||||
if images is None:
|
||||
images = []
|
||||
tokens = load_design_tokens()
|
||||
patterns = get_block_css_patterns()
|
||||
|
||||
# 컨테이너 정보
|
||||
container_lines = []
|
||||
for role, spec in container_specs.items():
|
||||
container_lines.append(
|
||||
f"- {role} ({spec.zone}): {spec.height_px}px × {spec.width_px}px, "
|
||||
f"topics={spec.topic_ids}, weight={spec.weight}"
|
||||
)
|
||||
container_text = "\n".join(container_lines)
|
||||
|
||||
# 이미지 정보
|
||||
image_text = ""
|
||||
if images:
|
||||
img_lines = []
|
||||
for img in images:
|
||||
img_lines.append(
|
||||
f"- 경로: {img.get('path', '')}\n"
|
||||
f" 크기: {img.get('width', '?')}px × {img.get('height', '?')}px\n"
|
||||
f" 비율: {img.get('ratio', '?')} ({'가로형' if img.get('ratio', 1) > 1.5 else '세로형' if img.get('ratio', 1) < 0.7 else '정방형'})\n"
|
||||
f" 소속 topic: {img.get('topic_id', '?')}"
|
||||
)
|
||||
image_text = "\n## 이미지 정보\n" + "\n".join(img_lines)
|
||||
|
||||
return f"""## 디자인 토큰 (CSS 변수 — 이 패턴을 참고)
|
||||
{tokens}
|
||||
|
||||
## 슬라이드 프레임
|
||||
- 크기: 1280px × 720px (고정)
|
||||
- 폰트: Pretendard Variable
|
||||
- 한국어 word-break: keep-all
|
||||
- padding: 36px 40px 24px
|
||||
|
||||
## 컨테이너 스펙 (각 영역의 크기)
|
||||
{container_text}
|
||||
{image_text}
|
||||
|
||||
{patterns}
|
||||
|
||||
{get_layout_rules()}
|
||||
"""
|
||||
623
src/html_generator.py
Normal file
623
src/html_generator.py
Normal file
@@ -0,0 +1,623 @@
|
||||
"""Phase S: AI HTML 생성기 — 검증 합격 프롬프트 템플릿 기반.
|
||||
|
||||
영역별 개별 호출. 검증에서 합격한 프롬프트의 구조/디자인은 고정, 텍스트만 동적.
|
||||
|
||||
역할 분리:
|
||||
Kei (1단계): 콘텐츠 분석
|
||||
Claude Sonnet (이 모듈): HTML 코드 생성
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 검증 합격 프롬프트 템플릿
|
||||
# 구조/디자인은 고정. {변수}만 동적 교체.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
BG_PROMPT = """다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
|
||||
|
||||
## 핵심 원칙
|
||||
이 영역은 **보조 영역**이다. 본심(핵심 콘텐츠)보다 시각적으로 약해야 한다.
|
||||
다크 배경 절대 금지. 흰색/연회색 위에 텍스트를 놓는 라이트 디자인으로.
|
||||
|
||||
## 크기
|
||||
- width: 100%, height: {height}px (고정, overflow:hidden)
|
||||
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||
{content_block}
|
||||
|
||||
## 텍스트 규칙 (반드시 적용)
|
||||
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
|
||||
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
|
||||
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
|
||||
- "~있다" → "~있음", "~한다" → "~함", "~이다" → 삭제, "~된다" → "~됨"
|
||||
예: "인식되고 있다" → "인식되고 있음" (단어 삭제 없이 끝만 변환)
|
||||
4. 원본에 없는 텍스트를 추가하지 마라.
|
||||
|
||||
## 디자인
|
||||
- 배경: background: #f8fafc (연회색, 다크 배경 절대 금지)
|
||||
- border: 1px solid #e2e8f0, border-radius: 6px
|
||||
- 전체 padding: 10px 14px (여백 최소화)
|
||||
- 제목: 12px bold #334155, margin-bottom: 4px
|
||||
- 본문: 11px #475569, line-height: 1.4, 핵심 키워드 <strong style="color:#1e293b"> 처리
|
||||
- 사례가 여러 건이면 가로로 나란히 (flex, gap:8px)
|
||||
- 사례 카드: background:#ffffff, border-left: 2px solid #94a3b8, padding: 6px 8px (여백 최소화)
|
||||
- 사례 제목: 10px bold #334155, margin-bottom: 2px
|
||||
- 사례 내용: 9px #64748b, line-height: 1.3
|
||||
- 들여쓰기: 불릿 다음 줄은 불릿 옆 글자 위치에 맞춤
|
||||
CSS: .bp {{ padding-left:14px; text-indent:-14px; }}
|
||||
CSS: .bp::before {{ content:'•'; display:inline-block; width:14px; text-indent:0; }}
|
||||
- 폰트를 줄여서라도 높이 안에 맞출 것. overflow:hidden이므로 넘치면 잘림.
|
||||
- 모든 텍스트가 보여야 한다. 잘리는 텍스트가 있으면 안 됨.
|
||||
|
||||
HTML + inline <style>만 반환. 설명 없이 코드만."""
|
||||
|
||||
|
||||
CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
|
||||
|
||||
## 크기: width:100%, max-height: {height}px, overflow: hidden (반드시 적용)
|
||||
|
||||
## 참고할 CSS 구조 (이 CSS를 반드시 그대로 사용하라. 특히 .fi의 float:right는 절대 빼지 마라.):
|
||||
```css
|
||||
.core {{
|
||||
width: 100%;
|
||||
max-height: {height}px;
|
||||
margin-top: 0;
|
||||
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: {img_margin_top}px 0 8px 12px;
|
||||
width: {img_width}px;
|
||||
}}
|
||||
.fi img {{ width: 100%; }}
|
||||
.fi .cap {{
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
}}
|
||||
.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;
|
||||
}}
|
||||
```
|
||||
|
||||
## 참고할 HTML 구조 (이 구조를 그대로 따르되 텍스트만 교체):
|
||||
```html
|
||||
<div class="core">
|
||||
<div class="core-header">
|
||||
<div class="core-label">제목 라벨</div>
|
||||
<span class="popup-link">📊 DX와 BIM의 상세 비교</span>
|
||||
</div>
|
||||
<div class="core-text">
|
||||
<div class="fi">
|
||||
<img id="slide-img-ID" src="placeholder">
|
||||
<div class="cap">이미지 캡션 (topic 제목을 사용)</div>
|
||||
</div>
|
||||
<div class="bp">메인 불릿 1</div>
|
||||
<div class="bp">메인 불릿 2</div>
|
||||
<div class="sp"><b>하위 항목</b> : 설명</div>
|
||||
<div class="sp"><b>하위 항목</b> : 설명</div>
|
||||
</div>
|
||||
<div class="key-msg">
|
||||
<em>BIM ≠ DX</em> — 핵심 메시지 (analysis.core_message 사용)
|
||||
</div>
|
||||
주의: "상위개념", "하위기술", "포함관계" 같은 임의 라벨을 넣지 마라. core_message 텍스트를 그대로 사용.
|
||||
</div>
|
||||
```
|
||||
|
||||
## 핵심 메시지 (하단 key-msg 박스에 이 텍스트 그대로 사용)
|
||||
{core_message}
|
||||
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트 80-95% 그대로 사용.)
|
||||
{content_block}
|
||||
|
||||
## 텍스트 규칙 (반드시 적용)
|
||||
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
|
||||
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
|
||||
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
|
||||
- "~있다" → "~있음", "~한다" → "~함", "~이다" → 삭제, "~된다" → "~됨"
|
||||
예: "실현 가능한 상위개념이다" → "실현 가능한 상위개념" (끝의 "이다"만 삭제)
|
||||
4. 원본에 없는 텍스트를 추가하지 마라.
|
||||
|
||||
{img_instruction}
|
||||
|
||||
## 팝업 비교표 데이터 (<details>/<summary>로 구현)
|
||||
{popup_data}
|
||||
|
||||
## 절대 규칙
|
||||
- "DX와 BIM의 상세 비교" 팝업 링크는 우측 상단에 1개만. 하단이나 다른 곳에 중복으로 넣지 마라.
|
||||
- .core에 margin-top을 넣지 마라 (간격은 외부에서 처리됨). margin-top: 0 필수.
|
||||
|
||||
HTML + inline <style>만 반환. 위 CSS와 HTML 구조를 정확히 따르라. 설명 없이 코드만."""
|
||||
|
||||
|
||||
SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
|
||||
|
||||
## 용어 (축약/요약/삭제 금지. 원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용.)
|
||||
{definitions_block}
|
||||
|
||||
## 디자인 요구사항
|
||||
1. 최상위 div: width:{width}px, height:{height}px, overflow:hidden (반드시 적용, 넘치면 잘림)
|
||||
2. 상단에 "용어 정의" 구분선 라벨 (좌우 선 + 중앙 텍스트, 13px #64748b)
|
||||
3. 각 용어를 카드로:
|
||||
- 배경: #f8fafc, 테두리: 1px solid #e2e8f0, border-radius: 8px, padding: 14px
|
||||
- 용어명: 14px bold #1e293b (예: "BIM (Building Information Modeling)")
|
||||
- 부제 금지: 원본에 없는 텍스트를 만들어 넣지 마라. 용어명 아래에 임의 설명을 추가하지 마라.
|
||||
- 불릿: 12px #475569, line-height: 1.6, 불릿 마커 "•"
|
||||
- 들여쓰기 CSS (반드시 적용):
|
||||
```css
|
||||
.def-bullet {{ padding-left: 14px; text-indent: -14px; margin-bottom: 4px; }}
|
||||
.def-bullet::before {{ content: '•'; display: inline-block; width: 14px; text-indent: 0; color: #475569; }}
|
||||
```
|
||||
- 각 불릿은 원본 텍스트 그대로. 단어를 한 글자도 빼지 마라.
|
||||
- 마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리하라.
|
||||
- 개조식 어미 변환: 문장 끝 1-2글자만 변환 ("~이다"→삭제, "~있다"→"~있음"). 그 외 단어 절대 건드리지 마라.
|
||||
4. 카드 간 간격 10px
|
||||
5. {height}px 안에 맞출 것. 넘치면 폰트를 줄여서 맞출 것.
|
||||
|
||||
HTML + inline <style>만 반환. 설명 없이 코드만."""
|
||||
|
||||
|
||||
FOOTER_PROMPT = """결론 배너 HTML.
|
||||
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||
{content_block}
|
||||
|
||||
## 텍스트 규칙
|
||||
- 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약 절대 금지.
|
||||
- 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
|
||||
예: "일부분이다" → "일부분", "필요하다" → "필요" (끝만 변환, 앞 문장 그대로)
|
||||
|
||||
## 디자인
|
||||
- 배너: linear-gradient(135deg, #006aff, #00aaff), border-radius: 8px
|
||||
- 핵심 메시지: 15px bold white
|
||||
- 부가 텍스트: 11px, opacity: 0.85
|
||||
- padding: 14px 30px, text-align: center, height: {height}px
|
||||
|
||||
HTML + inline <style>만 반환. 설명 없이 코드만."""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 메인 함수
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def generate_slide_html(
|
||||
content: str,
|
||||
analysis: dict[str, Any],
|
||||
container_specs: dict,
|
||||
preset: dict[str, Any],
|
||||
images: list[dict] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Phase S: 영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."""
|
||||
if images is None:
|
||||
images = []
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
topics = analysis.get("topics", [])
|
||||
topic_map = {t["id"]: t for t in topics}
|
||||
|
||||
def get_topics_for_role(role: str) -> list[dict]:
|
||||
info = page_struct.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
||||
|
||||
bg_topics = get_topics_for_role("배경")
|
||||
core_topics = get_topics_for_role("본심")
|
||||
ref_topics = get_topics_for_role("첨부")
|
||||
conclusion_topics = get_topics_for_role("결론")
|
||||
|
||||
bg_spec = container_specs.get("배경")
|
||||
core_spec = container_specs.get("본심")
|
||||
ref_spec = container_specs.get("첨부")
|
||||
concl_spec = container_specs.get("결론")
|
||||
|
||||
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
|
||||
|
||||
# ── 실제 zone 높이 계산 ──
|
||||
# slide=720, padding=40*2=80, grid-gap=20*2=40, header≈66px(2rem*1.7+padding+border)
|
||||
# body_zone = 720 - 80 - 66 - footer - 40
|
||||
footer_h = concl_spec.height_px if concl_spec else 60
|
||||
body_zone_h = 720 - 80 - 66 - footer_h - 40 # ≈ 474
|
||||
sidebar_zone_h = body_zone_h # body와 sidebar는 같은 grid row
|
||||
|
||||
BG_CORE_GAP = 12 # 배경↔본심 간격
|
||||
bg_h = bg_spec.height_px if bg_spec else 176
|
||||
# 본심은 body zone에서 배경+gap을 뺀 나머지
|
||||
core_max_h = body_zone_h - bg_h - BG_CORE_GAP if bg_topics else body_zone_h
|
||||
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px")
|
||||
|
||||
# ── 배경 ──
|
||||
if bg_topics:
|
||||
logger.info("[Phase S] 배경 생성...")
|
||||
sections = _slice_mdx_sections(content)
|
||||
bg_content = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
|
||||
prompt = BG_PROMPT.format(
|
||||
height=bg_h,
|
||||
content_block=bg_content,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["body_html"] += html + f'\n<div style="height:{BG_CORE_GAP}px;"></div>\n'
|
||||
logger.info(f"[Phase S] 배경 완료: {len(html)}자")
|
||||
|
||||
# ── 본심 ──
|
||||
if core_topics:
|
||||
logger.info("[Phase S] 본심 생성...")
|
||||
core_content = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
|
||||
popup = _get_popup_data(content)
|
||||
|
||||
img_instruction = ""
|
||||
img_margin = 60
|
||||
img_w = 250
|
||||
for img in images:
|
||||
if img.get("topic_id") in [t["id"] for t in core_topics]:
|
||||
img_id = f"slide-img-{img['topic_id']}"
|
||||
img_instruction = f"이미지 태그: <img id=\"{img_id}\" src=\"placeholder\">\nid=\"{img_id}\"를 반드시 포함 (후처리에서 실제 이미지로 교체)"
|
||||
if img.get("ratio", 1) > 1.5:
|
||||
img_w = 250
|
||||
img_margin = 60
|
||||
|
||||
prompt = CORE_PROMPT.format(
|
||||
width=core_spec.width_px if core_spec else 767,
|
||||
height=core_max_h,
|
||||
img_margin_top=img_margin,
|
||||
img_width=img_w,
|
||||
core_message=analysis.get("core_message", ""),
|
||||
content_block=core_content,
|
||||
img_instruction=img_instruction,
|
||||
popup_data=popup,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
html = _replace_img_placeholder(html, images)
|
||||
result["body_html"] += html + "\n"
|
||||
logger.info(f"[Phase S] 본심 완료: {len(html)}자")
|
||||
|
||||
# ── sidebar ──
|
||||
if ref_topics:
|
||||
logger.info("[Phase S] sidebar 생성...")
|
||||
defs = _get_definitions(content)
|
||||
prompt = SIDEBAR_PROMPT.format(
|
||||
width=ref_spec.width_px if ref_spec else 380,
|
||||
height=sidebar_zone_h,
|
||||
definitions_block=defs,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["sidebar_html"] = html
|
||||
logger.info(f"[Phase S] sidebar 완료: {len(html)}자")
|
||||
|
||||
# ── footer ──
|
||||
if conclusion_topics:
|
||||
logger.info("[Phase S] footer 생성...")
|
||||
footer_content = _get_conclusion(content)
|
||||
prompt = FOOTER_PROMPT.format(
|
||||
height=concl_spec.height_px if concl_spec else 60,
|
||||
content_block=footer_content.strip(),
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["footer_html"] = html
|
||||
logger.info(f"[Phase S] footer 완료: {len(html)}자")
|
||||
|
||||
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 콘텐츠 추출 함수
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _slice_mdx_sections(content: str) -> dict[str, str]:
|
||||
"""원본 MDX를 ## 기준으로 섹션별 슬라이싱.
|
||||
|
||||
source_data(Kei 메모 포함)를 사용하지 않고,
|
||||
원본 MDX 텍스트를 그대로 추출하여 프롬프트에 넣는다.
|
||||
"""
|
||||
sections = {}
|
||||
current_section = None
|
||||
current_lines = []
|
||||
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("## "):
|
||||
if current_section:
|
||||
sections[current_section] = "\n".join(current_lines).strip()
|
||||
current_section = line[3:].strip()
|
||||
current_lines = []
|
||||
elif current_section:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_section:
|
||||
sections[current_section] = "\n".join(current_lines).strip()
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _map_sections_for_role(
|
||||
sections: dict[str, str],
|
||||
role_topics: list[dict],
|
||||
fallback_keywords: list[str],
|
||||
) -> str:
|
||||
"""역할의 topics에 해당하는 원본 MDX 섹션을 매핑하여 반환.
|
||||
|
||||
1차: topic의 source_hint에서 섹션명 매칭
|
||||
2차: fallback_keywords로 섹션명 검색
|
||||
|
||||
source_data는 사용하지 않음 (Kei 메모 포함 가능).
|
||||
원본 MDX 텍스트만 반환.
|
||||
"""
|
||||
matched = []
|
||||
matched_names = set()
|
||||
|
||||
# 1차: source_hint 기반 매칭
|
||||
for t in role_topics:
|
||||
hint = t.get("source_hint", "")
|
||||
if hint:
|
||||
for sec_name, sec_text in sections.items():
|
||||
if sec_name in hint and sec_name not in matched_names:
|
||||
matched.append(f"### {sec_name}")
|
||||
matched.append(sec_text)
|
||||
matched.append("")
|
||||
matched_names.add(sec_name)
|
||||
|
||||
# 2차: fallback keywords
|
||||
if not matched:
|
||||
for sec_name, sec_text in sections.items():
|
||||
if any(kw in sec_name for kw in fallback_keywords) and sec_name not in matched_names:
|
||||
matched.append(f"### {sec_name}")
|
||||
matched.append(sec_text)
|
||||
matched.append("")
|
||||
matched_names.add(sec_name)
|
||||
|
||||
# topic 메타정보 추가 (제목, 표현 의도 — source_data 제외)
|
||||
meta = []
|
||||
for t in role_topics:
|
||||
meta.append(f"제목 라벨: \"{t.get('title', '')}\"")
|
||||
hint = t.get("expression_hint", "")
|
||||
if hint:
|
||||
meta.append(f"표현 의도: {hint}")
|
||||
|
||||
result = "\n".join(meta) + "\n\n" + "\n".join(matched) if matched else "\n".join(meta)
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _get_popup_data(content: str) -> str:
|
||||
"""팝업 비교표 데이터."""
|
||||
return """비교표 (<details>/<summary> 팝업으로):
|
||||
| 기준 | DX | BIM |
|
||||
| 범위 | BIM << DX (Engineering + Management 통합) | Only 3D (형상 구현 중심) |
|
||||
| 프로세스 | 근본적 문제의식을 통한 개선 | 기존 2D 설계 방식 유지 |
|
||||
| 활용 | 설계/시공 생산성 혁신 | 3D 모델에 의한 일반적 이해 향상 |
|
||||
| 확장성 | 전 생애주기 활용 시스템 | (설계/시공/운영) 분야별 단절 |
|
||||
| 주체 | 자체 수행 능력 — 지속가능성 확보 | S/W 제작사 판매 정책에 의존 |"""
|
||||
|
||||
|
||||
def _get_definitions(content: str) -> str:
|
||||
"""용어 정의: 원본 MDX에서 용어별 정의 섹션을 그대로 추출."""
|
||||
sections = _slice_mdx_sections(content)
|
||||
for sec_name, sec_text in sections.items():
|
||||
if "용어" in sec_name and "정의" in sec_name:
|
||||
return sec_text
|
||||
# fallback: "정의" 포함 섹션
|
||||
for sec_name, sec_text in sections.items():
|
||||
if "정의" in sec_name:
|
||||
return sec_text
|
||||
return "(원본에서 용어 정의를 찾지 못함)"
|
||||
|
||||
|
||||
def _get_conclusion(content: str) -> str:
|
||||
"""결론: 원본 MDX에서 핵심 요약/결론 섹션을 그대로 추출."""
|
||||
sections = _slice_mdx_sections(content)
|
||||
# "요약"이 포함된 섹션을 우선 매칭 (핵심만 포함된 섹션과 구분)
|
||||
for sec_name, sec_text in sections.items():
|
||||
if "요약" in sec_name:
|
||||
return sec_text
|
||||
for sec_name, sec_text in sections.items():
|
||||
if "결론" in sec_name:
|
||||
return sec_text
|
||||
# fallback: 마지막 섹션
|
||||
if sections:
|
||||
return list(sections.values())[-1]
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Claude 호출 + 이미지 교체
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def _call_claude(client, prompt: str) -> str | None:
|
||||
"""Claude Sonnet 호출 → HTML 추출."""
|
||||
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
|
||||
|
||||
match = re.search(r"```html\s*(.*?)```", text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
match = re.search(r"(<(?:div|style|section)[^>]*>.*)", text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"[Phase S] Claude 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def regenerate_area(
|
||||
area_name: str,
|
||||
errors: list[str],
|
||||
content: str,
|
||||
analysis: dict[str, Any],
|
||||
container_specs: dict,
|
||||
preset: dict[str, Any],
|
||||
images: list[dict] | None = None,
|
||||
) -> str | None:
|
||||
"""실패한 영역을 에러 피드백과 함께 재생성.
|
||||
|
||||
원래 프롬프트 끝에 검증 실패 사유를 추가하여 Claude에게 재생성 요청.
|
||||
전체를 재생성하지 않고 해당 영역만 재생성.
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# 에러 피드백 블록
|
||||
error_feedback = "\n\n## 이전 생성 결과 검증 실패. 다음 문제를 반드시 수정하라:\n"
|
||||
for i, err in enumerate(errors[:5], 1):
|
||||
error_feedback += f"{i}. {err}\n"
|
||||
error_feedback += "\n위 문제들을 해결한 새 HTML을 생성하라. 원본 텍스트를 축약/요약하지 마라."
|
||||
|
||||
sections = _slice_mdx_sections(content)
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
topics = analysis.get("topics", [])
|
||||
topic_map = {t["id"]: t for t in topics}
|
||||
|
||||
def get_topics_for_role(role: str) -> list[dict]:
|
||||
info = page_struct.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
||||
|
||||
if area_name == "sidebar":
|
||||
ref_spec = container_specs.get("첨부")
|
||||
# 실제 zone 높이 계산
|
||||
footer_h = container_specs.get("결론")
|
||||
footer_h = footer_h.height_px if footer_h else 60
|
||||
sidebar_zone_h = 720 - 80 - 66 - footer_h - 40
|
||||
|
||||
defs = _get_definitions(content)
|
||||
prompt = SIDEBAR_PROMPT.format(
|
||||
width=ref_spec.width_px if ref_spec else 380,
|
||||
height=sidebar_zone_h,
|
||||
definitions_block=defs,
|
||||
) + error_feedback
|
||||
|
||||
logger.info(f"[재생성] sidebar 재생성 (에러 {len(errors)}건)")
|
||||
return await _call_claude(client, prompt)
|
||||
|
||||
elif area_name == "footer":
|
||||
concl_spec = container_specs.get("결론")
|
||||
footer_content = _get_conclusion(content)
|
||||
prompt = FOOTER_PROMPT.format(
|
||||
height=concl_spec.height_px if concl_spec else 60,
|
||||
content_block=footer_content.strip(),
|
||||
) + error_feedback
|
||||
|
||||
logger.info(f"[재생성] footer 재생성 (에러 {len(errors)}건)")
|
||||
return await _call_claude(client, prompt)
|
||||
|
||||
else:
|
||||
# body_bg, body_core는 합본이므로 None 반환 → 호출자가 전체 body 재생성
|
||||
logger.info(f"[재생성] {area_name}은 body 전체 재생성 필요")
|
||||
return None
|
||||
|
||||
|
||||
def _replace_img_placeholder(html: str, images: list[dict]) -> str:
|
||||
"""placeholder 이미지를 base64로 교체."""
|
||||
for img in images:
|
||||
b64 = img.get("b64", "")
|
||||
if not b64:
|
||||
continue
|
||||
img_id = f"slide-img-{img.get('topic_id', 0)}"
|
||||
if img_id in html:
|
||||
data_uri = f"data:image/png;base64,{b64}"
|
||||
html = html.replace('src="placeholder"', f'src="{data_uri}"')
|
||||
html = html.replace("src='placeholder'", f"src='{data_uri}'")
|
||||
logger.info(f"[Phase S] 이미지 교체: {img_id}")
|
||||
return html
|
||||
79
src/html_validator.py
Normal file
79
src/html_validator.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Phase R'-6: AI 생성 HTML 정화 + 토큰 위반 검증.
|
||||
|
||||
AI가 생성한 HTML에서:
|
||||
1. 위험 태그 제거 (<script>, <iframe> 등)
|
||||
2. CSS에 하드코딩 값이 있는지 경고 (토큰 위반)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_and_clean_html(generated: dict[str, str]) -> dict[str, str]:
|
||||
"""AI 생성 HTML을 정화하고 검증한다.
|
||||
|
||||
Args:
|
||||
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
|
||||
|
||||
Returns:
|
||||
정화된 generated dict
|
||||
"""
|
||||
cleaned = {}
|
||||
for key in ("body_html", "sidebar_html", "footer_html"):
|
||||
html = generated.get(key, "")
|
||||
html = _remove_dangerous_tags(html)
|
||||
_check_token_violations(html, key)
|
||||
cleaned[key] = html
|
||||
|
||||
# reasoning은 그대로 유지
|
||||
cleaned["reasoning"] = generated.get("reasoning", "")
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def _remove_dangerous_tags(html: str) -> str:
|
||||
"""위험 태그 제거."""
|
||||
# <script>...</script> 제거
|
||||
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
# <iframe> 제거
|
||||
html = re.sub(r"<iframe[^>]*>.*?</iframe>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
# on* 이벤트 핸들러 제거
|
||||
html = re.sub(r'\s+on\w+="[^"]*"', "", html, flags=re.IGNORECASE)
|
||||
html = re.sub(r"\s+on\w+='[^']*'", "", html, flags=re.IGNORECASE)
|
||||
return html
|
||||
|
||||
|
||||
def _check_token_violations(html: str, area_name: str) -> None:
|
||||
"""CSS에서 디자인 토큰을 사용하지 않고 하드코딩한 값을 경고한다.
|
||||
|
||||
정보용 경고만. 생성을 차단하지는 않음 (AI가 블록 CSS 패턴을 참고하므로 하드코딩이 일부 있을 수 있음).
|
||||
"""
|
||||
# <style> 블록 추출
|
||||
style_blocks = re.findall(r"<style[^>]*>(.*?)</style>", html, re.DOTALL | re.IGNORECASE)
|
||||
if not style_blocks:
|
||||
return
|
||||
|
||||
all_css = "\n".join(style_blocks)
|
||||
|
||||
# 허용된 하드코딩 색상 (블록 CSS 패턴에서 자주 쓰이는 것)
|
||||
allowed_colors = {
|
||||
"#1e293b", "#0f172a", "#2563eb", "#93c5fd", "#60a5fa",
|
||||
"#ffffff", "#f8fafc", "#e2e8f0", "#475569", "#64748b",
|
||||
"#94a3b8", "#cbd5e1", "#1e40af", "#bae6fd", "#f0f9ff",
|
||||
"#006aff", "#00aaff", "#dc2626", "#fbbf24", "#0c4a6e",
|
||||
"#334155", "#0088cc", "#86efac", "#fde047", "#16a34a",
|
||||
"#fecaca", "#fef2f2",
|
||||
}
|
||||
|
||||
# 하드코딩 색상 검출 (#으로 시작하는 hex)
|
||||
found_colors = set(re.findall(r"#[0-9a-fA-F]{3,8}", all_css))
|
||||
unknown_colors = found_colors - allowed_colors
|
||||
|
||||
if unknown_colors:
|
||||
logger.info(
|
||||
f"[R' 검증] {area_name}: 디자인 토큰 외 색상 {len(unknown_colors)}개 "
|
||||
f"(참고: {list(unknown_colors)[:5]})"
|
||||
)
|
||||
@@ -262,6 +262,240 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-4: 제약 기반 블록 선택 (Kei 1회 호출)
|
||||
# ──────────────────────────────────────
|
||||
|
||||
BLOCK_SELECTION_PROMPT = """당신은 11년 경력의 기획 실장이다. 각 꼭지(topic)에 가장 적합한 블록과 변형(variant)을 선택하라.
|
||||
|
||||
## 판단 기준 (우선순위 순)
|
||||
1. 콘텐츠의 **표현 의도(expression_hint)**를 가장 잘 시각화하는 블록+변형
|
||||
2. 이 꼭지의 목적(purpose)에 부합하는 표현 방식
|
||||
3. 블록에 변형(variant)이 있으면, 콘텐츠에 더 적합한 변형을 선택
|
||||
4. 글자수 예산 내에서 의미 전달이 가능한 블록
|
||||
5. 다른 꼭지와 같은 블록 타입이 되지 않도록 다양성 확보
|
||||
|
||||
## 변형(variant) 선택 규칙
|
||||
- 블록에 변형이 여러 개 있으면, "when" 조건과 expression_hint를 비교하여 적합한 것 선택
|
||||
- 기본(default)이 적합하면 variant를 "default"로 지정
|
||||
- 변형의 "when"이 expression_hint와 맞으면 해당 variant 선택
|
||||
|
||||
## 출력 (JSON, 꼭지 수만큼)
|
||||
{"selections": [{"topic_id": 1, "block_id": "블록 id", "variant": "default 또는 변형 id", "reason": "선택 근거 1문장"}, ...]}
|
||||
"""
|
||||
|
||||
|
||||
async def select_block_for_topics(
|
||||
topics: list[dict[str, Any]],
|
||||
candidates_per_topic: dict[int, list[dict]],
|
||||
budgets_per_topic: dict[int, dict[str, dict]],
|
||||
container_specs: dict[str, Any],
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[int, dict] | None:
|
||||
"""Phase Q-4: 필터링된 후보 목록에서 Kei가 topic별 블록을 1개씩 선택.
|
||||
|
||||
AI 1회 호출로 모든 topic의 블록을 동시 선택한다.
|
||||
|
||||
Args:
|
||||
topics: 1단계 분석의 topic 리스트
|
||||
candidates_per_topic: {topic_id: [후보 블록 리스트]}
|
||||
budgets_per_topic: {topic_id: {block_id: budget_dict}}
|
||||
container_specs: 역할별 ContainerSpec
|
||||
analysis: 1단계 분석 결과
|
||||
|
||||
Returns:
|
||||
{topic_id: {"block_id": "...", "reason": "..."}} 또는 None (실패)
|
||||
"""
|
||||
from src.block_selector import format_candidates_for_prompt
|
||||
from src.space_allocator import find_container_for_topic
|
||||
|
||||
core_message = analysis.get("core_message", "")
|
||||
|
||||
# 프롬프트 구성
|
||||
prompt_parts = [
|
||||
BLOCK_SELECTION_PROMPT,
|
||||
f"\n## 슬라이드 핵심 메시지\n{core_message}\n",
|
||||
]
|
||||
|
||||
for topic in topics:
|
||||
tid = topic.get("id")
|
||||
candidates = candidates_per_topic.get(tid, [])
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
spec = find_container_for_topic(tid, container_specs)
|
||||
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 200
|
||||
budget = budgets_per_topic.get(tid, {})
|
||||
|
||||
expression_hint = topic.get("expression_hint", "")
|
||||
prompt_parts.append(
|
||||
f"\n### 꼭지 {tid}: {topic.get('title', '')}\n"
|
||||
f"- 목적: {topic.get('purpose', '')}\n"
|
||||
f"- 관계 유형: {topic.get('relation_type', 'none')}\n"
|
||||
f"- ★ 표현 의도: {expression_hint}\n"
|
||||
f"- 컨테이너: {per_topic_px}px × {spec.width_px if spec else 700}px "
|
||||
f"({spec.role if spec else '?'} {spec.zone if spec else '?'})\n"
|
||||
f"- 후보 블록:\n"
|
||||
f"{format_candidates_for_prompt(candidates, budget)}\n"
|
||||
)
|
||||
|
||||
full_prompt = "\n".join(prompt_parts)
|
||||
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-q4",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[Q-4] Kei API HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if not full_text:
|
||||
logger.warning("[Q-4] Kei API 응답 비어있음")
|
||||
return None
|
||||
|
||||
result = _parse_json(full_text)
|
||||
if not result or "selections" not in result:
|
||||
logger.warning(f"[Q-4] JSON 파싱 실패: {full_text[:200]}")
|
||||
return None
|
||||
|
||||
# 결과 → {topic_id: {"block_id": ..., "reason": ...}}
|
||||
selections = {}
|
||||
for sel in result["selections"]:
|
||||
tid = sel.get("topic_id")
|
||||
block_id = sel.get("block_id", "")
|
||||
|
||||
# catalog 존재 검증 (유령 블록 최종 차단)
|
||||
candidates = candidates_per_topic.get(tid, [])
|
||||
valid_ids = {c.get("id") for c in candidates}
|
||||
if block_id not in valid_ids:
|
||||
logger.warning(
|
||||
f"[Q-4] topic {tid}: Kei가 '{block_id}' 선택했으나 후보에 없음. "
|
||||
f"첫 번째 후보로 대체."
|
||||
)
|
||||
block_id = candidates[0]["id"] if candidates else block_id
|
||||
|
||||
selections[tid] = {
|
||||
"block_id": block_id,
|
||||
"variant": sel.get("variant", "default"),
|
||||
"reason": sel.get("reason", ""),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"[Q-4] 블록 선택 완료: "
|
||||
+ ", ".join(f"t{tid}={s['block_id']}" for tid, s in selections.items())
|
||||
)
|
||||
return selections
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Q-4] Kei 블록 선택 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-6: 비전 모델 품질 게이트 (VASCAR식)
|
||||
# ──────────────────────────────────────
|
||||
|
||||
VISION_QUALITY_PROMPT = """슬라이드 스크린샷을 평가하라.
|
||||
|
||||
## 체크리스트 (각 항목 1-5점)
|
||||
1. 콘텐츠 겹침/잘림: 모든 텍스트가 컨테이너 안에 있는가?
|
||||
2. 시각적 위계: 본심 영역이 가장 두드러지는가?
|
||||
3. 가독성: 모든 폰트가 읽을 수 있는 크기인가? (10px 이상)
|
||||
4. 블록 다양성: 서로 다른 블록 유형을 사용하고 있는가?
|
||||
5. 전문성: 한국어 비즈니스 프레젠테이션으로 적절한가?
|
||||
|
||||
## 출력 (JSON)
|
||||
{
|
||||
"passed": true/false,
|
||||
"score": 0-100,
|
||||
"checks": {"겹침": 5, "위계": 4, "가독성": 5, "다양성": 3, "전문성": 4},
|
||||
"issues": ["문제 설명 (있으면)"],
|
||||
"fix_targets": [{"area": "body", "topic_id": 3, "action": "shrink|replace|rewrite", "detail": "구체적 지시"}]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
async def vision_quality_gate(
|
||||
screenshot_b64: str,
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Phase Q-6: 스크린샷 기반 시각 품질 평가.
|
||||
|
||||
VASCAR 논문 기반 — 렌더링 → 비전 모델 평가 → 교정 여부 결정.
|
||||
|
||||
Returns:
|
||||
{"passed": bool, "score": int, "issues": [...], "fix_targets": [...]}
|
||||
"""
|
||||
import anthropic
|
||||
import base64
|
||||
|
||||
core_message = analysis.get("core_message", "")
|
||||
topic_summary = ", ".join(
|
||||
f"{t.get('id')}:{t.get('title','')}" for t in analysis.get("topics", [])
|
||||
)
|
||||
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
max_tokens=2048,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": screenshot_b64,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
VISION_QUALITY_PROMPT +
|
||||
f"\n\n## 컨텍스트\n"
|
||||
f"핵심 메시지: {core_message}\n"
|
||||
f"꼭지 구성: {topic_summary}\n"
|
||||
),
|
||||
},
|
||||
],
|
||||
}],
|
||||
)
|
||||
|
||||
result_text = response.content[0].text if response.content else ""
|
||||
result = _parse_json(result_text)
|
||||
|
||||
if result:
|
||||
score = result.get("score", 0)
|
||||
passed = result.get("passed", score >= 50)
|
||||
result["passed"] = passed
|
||||
logger.info(
|
||||
f"[Q-6] 품질 게이트: {score}/100 → {'PASS' if passed else 'FAIL'}"
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(f"[Q-6] JSON 파싱 실패: {result_text[:200]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Q-6] 비전 품질 평가 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# J-7: Kei 최종 검수
|
||||
# ──────────────────────────────────────
|
||||
@@ -548,4 +782,111 @@ def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
# manual_classify 삭제됨. Kei API는 필수. fallback 없음.
|
||||
async def select_best_candidate(
|
||||
topic_results: list[dict[str, Any]],
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Phase P: Kei가 스크린샷을 보고 topic별 최적 블록을 선택한다.
|
||||
|
||||
여러 topic을 묶어서 1회 호출. 각 topic별 후보 스크린샷을 Opus 멀티모달로 비교.
|
||||
|
||||
Args:
|
||||
topic_results: [{
|
||||
"topic_id": 1,
|
||||
"topic_title": "...",
|
||||
"purpose": "문제제기",
|
||||
"candidates": [
|
||||
{"index": 0, "type": "callout-warning", "screenshot_b64": "...", "overflowed": False},
|
||||
{"index": 1, "type": "dark-bullet-list", "screenshot_b64": "...", "overflowed": False},
|
||||
{"index": 2, "type": "quote-big-mark", "screenshot_b64": "...", "overflowed": True},
|
||||
]
|
||||
}, ...]
|
||||
analysis: 1단계 분석 결과 (core_message 등)
|
||||
|
||||
Returns:
|
||||
{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "..."}]}
|
||||
"""
|
||||
import anthropic
|
||||
|
||||
core_message = analysis.get("core_message", "")
|
||||
|
||||
# 메시지 content 블록 구성 (텍스트 + 이미지들)
|
||||
content_blocks = []
|
||||
|
||||
# 지시문
|
||||
instruction = (
|
||||
f"슬라이드의 핵심 메시지: {core_message}\n\n"
|
||||
"아래 각 꼭지(topic)별로 후보 블록 스크린샷을 보여준다.\n"
|
||||
"각 꼭지마다 **당초 목적에 가장 적합한 1개**를 선택하라.\n\n"
|
||||
"판단 기준:\n"
|
||||
"1. 당초 목적(purpose)에 적합한가? (문제 제기인데 비교 블록이면 부적합)\n"
|
||||
"2. 콘텐츠 의미가 왜곡되지 않는가?\n"
|
||||
"3. 컨테이너에 넘치지 않는가? (overflow 표시된 것은 감점)\n"
|
||||
"4. 같은 블록이 다른 topic과 중복되면 피하라.\n\n"
|
||||
"전부 안 맞으면 그나마 가장 나은 것을 선택하라.\n\n"
|
||||
)
|
||||
|
||||
# 각 topic의 후보 스크린샷 추가
|
||||
for tr in topic_results:
|
||||
tid = tr["topic_id"]
|
||||
purpose = tr.get("purpose", "")
|
||||
instruction += f"## 꼭지 {tid}: {tr.get('topic_title', '')} (목적: {purpose})\n"
|
||||
|
||||
for cand in tr.get("candidates", []):
|
||||
idx = cand["index"]
|
||||
block_type = cand.get("type", "")
|
||||
overflowed = cand.get("overflowed", False)
|
||||
overflow_note = " ⚠️ OVERFLOW" if overflowed else ""
|
||||
instruction += f" 후보 {idx}: {block_type}{overflow_note}\n"
|
||||
|
||||
content_blocks.append({"type": "text", "text": instruction})
|
||||
|
||||
# 스크린샷 이미지 추가
|
||||
for tr in topic_results:
|
||||
for cand in tr.get("candidates", []):
|
||||
b64 = cand.get("screenshot_b64")
|
||||
if b64:
|
||||
content_blocks.append({
|
||||
"type": "text",
|
||||
"text": f"[꼭지 {tr['topic_id']} 후보 {cand['index']}: {cand.get('type', '')}]",
|
||||
})
|
||||
content_blocks.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": b64,
|
||||
},
|
||||
})
|
||||
|
||||
content_blocks.append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
"\n## 출력 (JSON만)\n"
|
||||
'{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "선택 이유"}]}'
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
max_tokens=2048,
|
||||
messages=[{"role": "user", "content": content_blocks}],
|
||||
)
|
||||
|
||||
result_text = response.content[0].text
|
||||
result = _parse_json(result_text)
|
||||
if result and "selections" in result:
|
||||
logger.info(
|
||||
f"[Phase P] Kei 최종 선택: "
|
||||
+ ", ".join(f"t{s['topic_id']}→후보{s['selected_index']}" for s in result["selections"])
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(f"[Phase P] 선택 JSON 파싱 실패: {result_text[:200]}")
|
||||
return {"selections": []}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
|
||||
return {"selections": []}
|
||||
|
||||
343
src/pipeline.py
343
src/pipeline.py
@@ -17,42 +17,53 @@ from typing import Any, AsyncIterator
|
||||
|
||||
import anthropic
|
||||
|
||||
from src.kei_client import classify_content, refine_concepts, call_kei_overflow_judgment, call_kei_final_review
|
||||
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
|
||||
from src.content_editor import fill_content
|
||||
from src.renderer import render_slide
|
||||
from src.kei_client import classify_content, refine_concepts
|
||||
from src.design_director import LAYOUT_PRESETS, select_preset
|
||||
from src.image_utils import get_image_sizes, embed_images
|
||||
from src.space_allocator import calculate_container_specs, finalize_block_specs, find_container_for_topic, calculate_trim_chars
|
||||
from src.slide_measurer import measure_rendered_heights, format_measurement_for_kei, capture_slide_screenshot
|
||||
from src.space_allocator import calculate_container_specs
|
||||
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Kei API 재시도 간격(초). 제한 없음 — 성공할 때까지 무한 재시도.
|
||||
# Kei API 재시도 설정 (P0 수정: 무한 루프 방지)
|
||||
KEI_RETRY_INTERVAL = 10
|
||||
KEI_MAX_RETRY_ATTEMPTS = 30 # 최대 30회 (5분)
|
||||
KEI_MAX_RETRY_DURATION = 300 # 절대 제한 300초
|
||||
|
||||
|
||||
async def _retry_kei(fn, *args, **kwargs):
|
||||
"""Kei API 호출을 성공할 때까지 무한 재시도한다.
|
||||
"""Kei API 호출을 성공할 때까지 재시도한다.
|
||||
|
||||
Kei API는 필수 인프라. fallback 없음. 제한 없음.
|
||||
10분이든 20분이든 Kei가 응답할 때까지 기다린다.
|
||||
Kei API는 필수 인프라. fallback 없음.
|
||||
최대 30회 또는 300초까지 재시도 후 TimeoutError.
|
||||
"""
|
||||
import asyncio
|
||||
attempt = 0
|
||||
while True:
|
||||
start_time = time.time()
|
||||
while attempt < KEI_MAX_RETRY_ATTEMPTS:
|
||||
attempt += 1
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > KEI_MAX_RETRY_DURATION:
|
||||
raise TimeoutError(
|
||||
f"Kei API 타임아웃: {fn.__name__} — "
|
||||
f"{elapsed:.0f}초 경과 (제한 {KEI_MAX_RETRY_DURATION}초)"
|
||||
)
|
||||
result = await fn(*args, **kwargs)
|
||||
if result is not None:
|
||||
if attempt > 1:
|
||||
logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
|
||||
return result
|
||||
logger.warning(
|
||||
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}). "
|
||||
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}/{KEI_MAX_RETRY_ATTEMPTS}). "
|
||||
f"{KEI_RETRY_INTERVAL}초 후 재시도..."
|
||||
)
|
||||
await asyncio.sleep(KEI_RETRY_INTERVAL)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Kei API 최대 재시도 초과: {fn.__name__} — {attempt}회 시도"
|
||||
)
|
||||
|
||||
|
||||
def _save_step(run_dir: Path, filename: str, data: Any) -> None:
|
||||
"""스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
|
||||
@@ -150,238 +161,110 @@ async def generate_slide(
|
||||
for role, spec in container_specs.items()
|
||||
})
|
||||
|
||||
# 2단계: 디자인 팀장 — Step A(프리셋) + Step A-2(Kei 블록 확정) + Step B(zone 배치)
|
||||
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
# ★ Phase S: Claude Sonnet이 HTML 직접 생성
|
||||
# 블록 선택 없음. 슬롯 채우기 없음. AI가 콘텐츠에 맞는 HTML 구조를 직접 만든다.
|
||||
yield {"event": "progress", "data": "2/4 슬라이드 HTML 생성 중..."}
|
||||
|
||||
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
|
||||
|
||||
total_blocks = sum(
|
||||
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
|
||||
)
|
||||
logger.info(
|
||||
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
|
||||
f"{total_blocks}개 블록"
|
||||
)
|
||||
_save_step(run_dir, "step2_layout.json", {
|
||||
"preset": layout_concept.get("pages", [{}])[0].get("grid_areas", ""),
|
||||
"blocks": [
|
||||
{
|
||||
"area": b.get("area"), "type": b.get("type"),
|
||||
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
|
||||
"reason": b.get("reason", ""), "size": b.get("size", ""),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
],
|
||||
"overflow": layout_concept.get("overflow", []),
|
||||
})
|
||||
|
||||
# ★ Phase O-3: 블록 스펙 확정 (컨테이너 크기 → 항목수/글자수/폰트)
|
||||
for page in layout_concept.get("pages", []):
|
||||
finalize_block_specs(page.get("blocks", []), container_specs)
|
||||
# 컨테이너 스펙을 layout_concept에 저장 (렌더러에서 사용)
|
||||
layout_concept["_container_specs"] = container_specs
|
||||
|
||||
_save_step(run_dir, "step2c_block_specs.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"type": b.get("type"), "topic_id": b.get("topic_id"),
|
||||
"area": b.get("area"),
|
||||
"_container_height_px": b.get("_container_height_px"),
|
||||
"_max_items": b.get("_max_items"),
|
||||
"_max_chars_per_item": b.get("_max_chars_per_item"),
|
||||
"_max_chars_total": b.get("_max_chars_total"),
|
||||
"_font_size_px": b.get("_font_size_px"),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# 3단계: 텍스트 편집자 — 텍스트 정리
|
||||
yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
|
||||
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
logger.info("3단계 완료: 텍스트 정리")
|
||||
_save_step(run_dir, "step3_filled_blocks.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"area": b.get("area"), "type": b.get("type"),
|
||||
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
|
||||
"data": b.get("data", {}),
|
||||
"char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
|
||||
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
|
||||
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("4단계 완료: HTML 조립")
|
||||
_save_step(run_dir, "step4_css_adjustment.json", {
|
||||
"area_styles": layout_concept.get("pages", [{}])[0].get("area_styles", {})
|
||||
})
|
||||
_save_step(run_dir, "step4_rendered.html", html)
|
||||
|
||||
# Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
|
||||
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.kei_client import vision_quality_gate
|
||||
import asyncio
|
||||
MAX_MEASURE_ROUNDS = 3
|
||||
measurement = None
|
||||
|
||||
for measure_round in range(MAX_MEASURE_ROUNDS):
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
_save_step(run_dir, f"step4_measurement_round{measure_round + 1}.json", measurement)
|
||||
topics = analysis.get("topics", [])
|
||||
|
||||
# overflow 감지 — zone + container 양쪽 체크
|
||||
has_overflow = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
break
|
||||
# Phase O: container 레벨 overflow도 체크
|
||||
for cont_name, cont_data in measurement.get("containers", {}).items():
|
||||
if cont_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
logger.warning(
|
||||
f"[측정] container-{cont_name}: "
|
||||
f"scroll={cont_data.get('scrollHeight')}px > "
|
||||
f"allocated={cont_data.get('allocatedHeight')}px "
|
||||
f"(+{cont_data.get('excess_px')}px)"
|
||||
)
|
||||
break
|
||||
# 이미지 정보 구성 (base64 포함)
|
||||
slide_images = []
|
||||
if image_sizes:
|
||||
import base64 as b64_mod
|
||||
for img_key, img_info in image_sizes.items():
|
||||
img_path = Path(base_path) / img_key if base_path else Path(img_key)
|
||||
img_b64 = ""
|
||||
if img_path.exists():
|
||||
img_b64 = b64_mod.b64encode(img_path.read_bytes()).decode()
|
||||
slide_images.append({
|
||||
"path": str(img_path),
|
||||
"width": img_info.get("width", 0),
|
||||
"height": img_info.get("height", 0),
|
||||
"ratio": round(img_info.get("width", 1) / max(1, img_info.get("height", 1)), 2),
|
||||
"topic_id": img_info.get("topic_id"),
|
||||
"b64": img_b64,
|
||||
})
|
||||
|
||||
if not has_overflow:
|
||||
logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
|
||||
break
|
||||
# Claude Sonnet이 HTML 생성
|
||||
generated = await generate_slide_html(
|
||||
content=content,
|
||||
analysis=analysis,
|
||||
container_specs=container_specs,
|
||||
preset=preset,
|
||||
images=slide_images,
|
||||
)
|
||||
|
||||
logger.warning(f"[측정] overflow 감지 (round {measure_round + 1})")
|
||||
# HTML 정화 + 검증
|
||||
generated = validate_and_clean_html(generated)
|
||||
|
||||
# 수학적 축약량 계산 → 편집자 재호출
|
||||
adjusted = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if not zone_data.get("overflowed"):
|
||||
continue
|
||||
excess = zone_data.get("excess_px", 0)
|
||||
zone_info = preset.get("zones", {}).get(zone_name, {})
|
||||
width_px = int(settings.slide_width * zone_info.get("width_pct", 100) / 100 * 0.85)
|
||||
_save_step(run_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", ""),
|
||||
})
|
||||
logger.info(
|
||||
f"[Phase S] HTML 생성 완료: body={len(generated.get('body_html', ''))}자, "
|
||||
f"sidebar={len(generated.get('sidebar_html', ''))}자, "
|
||||
f"footer={len(generated.get('footer_html', ''))}자"
|
||||
)
|
||||
|
||||
# Phase O: overflow 블록의 _max_chars_total 축소
|
||||
for block_m in zone_data.get("blocks", []):
|
||||
if block_m.get("overflowed"):
|
||||
trim_chars = calculate_trim_chars(
|
||||
block_m.get("excess_px", excess),
|
||||
width_px,
|
||||
)
|
||||
for page in layout_concept.get("pages", []):
|
||||
for block in page.get("blocks", []):
|
||||
if block.get("area") == zone_name:
|
||||
current_max = block.get("_max_chars_total", 400)
|
||||
block["_max_chars_total"] = max(20, current_max - trim_chars)
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
adjusted = True
|
||||
logger.info(
|
||||
f"[측정 조정] {zone_name}/{block_m.get('block_type')}: "
|
||||
f"{block_m.get('excess_px')}px 초과 → "
|
||||
f"_max_chars_total {current_max}→{block['_max_chars_total']} ({trim_chars}자 축약)"
|
||||
)
|
||||
break
|
||||
# 3단계: 렌더링 — AI 생성 HTML을 슬라이드 프레임에 삽입
|
||||
yield {"event": "progress", "data": "3/4 슬라이드 조립 중..."}
|
||||
|
||||
if not adjusted:
|
||||
logger.info("[측정] 조정 대상 없음, 현재 결과 확정")
|
||||
break
|
||||
html = render_slide_from_html(generated, analysis, preset)
|
||||
logger.info("[Phase S] 슬라이드 조립 완료")
|
||||
_save_step(run_dir, "step3_rendered.html", html)
|
||||
|
||||
# 편집자 재호출 → 재렌더링
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info(f"[측정] round {measure_round + 1} 재렌더링 완료")
|
||||
# ★ Phase Q: 검증 렌더링 + 수학적 조정 + 비전 품질 게이트
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
_save_step(run_dir, "step4_measurement.json", measurement)
|
||||
|
||||
# 측정 결과 텍스트 (Kei 검수에 전달)
|
||||
measurement_text = format_measurement_for_kei(measurement) if measurement else ""
|
||||
# Phase S: overflow 감지
|
||||
has_overflow = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
logger.warning(f"[Phase S] {zone_name}: overflow +{zone_data.get('excess_px', 0)}px")
|
||||
|
||||
# Phase N-4: 5단계 — Kei 실장 최종 검수 (스크린샷 기반, 최대 1회)
|
||||
# overflow 없으면 skip (시간 절약)
|
||||
has_any_overflow = False
|
||||
if measurement:
|
||||
for zone_data in measurement.get("zones", {}).values():
|
||||
if zone_data.get("overflowed"):
|
||||
has_any_overflow = True
|
||||
break
|
||||
if measurement.get("slide", {}).get("overflowed"):
|
||||
has_any_overflow = True
|
||||
|
||||
MAX_REVIEW_ROUNDS = 1
|
||||
screenshot_b64 = None
|
||||
|
||||
if not has_any_overflow:
|
||||
logger.info("5단계 skip: overflow 없음. 검수 불필요.")
|
||||
if has_overflow:
|
||||
logger.warning("[Phase S] overflow 감지 — 결과물에 반영 (후속 품질 게이트에서 평가)")
|
||||
else:
|
||||
yield {"event": "progress", "data": "5/5 Kei 실장이 최종 검수 중..."}
|
||||
logger.info("[Phase S] overflow 없음")
|
||||
|
||||
# 스크린샷 캡처 (Selenium)
|
||||
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
|
||||
if screenshot_b64:
|
||||
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
|
||||
logger.info("[5단계] 스크린샷 캡처 완료 → Kei에게 전달")
|
||||
# Phase S: 비전 모델 품질 게이트
|
||||
yield {"event": "progress", "data": "4/4 품질 검증 중..."}
|
||||
|
||||
for review_round in range(MAX_REVIEW_ROUNDS if has_any_overflow else 0):
|
||||
review_result = await _review_balance(
|
||||
html, layout_concept, content, analysis, measurement_text,
|
||||
screenshot_b64=screenshot_b64,
|
||||
)
|
||||
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
|
||||
quality_result = None
|
||||
|
||||
if not review_result or not review_result.get("needs_adjustment"):
|
||||
if review_round == 0:
|
||||
logger.info("5단계 완료: 조정 불필요")
|
||||
if screenshot_b64:
|
||||
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
|
||||
quality_result = await vision_quality_gate(screenshot_b64, analysis)
|
||||
|
||||
if quality_result:
|
||||
_save_step(run_dir, "step5_quality_gate.json", quality_result)
|
||||
|
||||
if not quality_result.get("passed", True):
|
||||
score = quality_result.get("score", 0)
|
||||
issues = quality_result.get("issues", [])
|
||||
logger.warning(f"[Q-6] 품질 게이트 FAIL: {score}/100 — {issues}")
|
||||
|
||||
# Q-8: 심각한 품질 문제 시 출력 차단
|
||||
if score < 30:
|
||||
logger.error(f"[Q-8] 출력 차단: 품질 {score}/100 < 30 최소 기준")
|
||||
yield {"event": "error", "data": f"슬라이드 품질 미달 ({score}/100). 재시도해 주세요."}
|
||||
return
|
||||
else:
|
||||
logger.info(f"5단계 완료: {review_round}차 조정 후 균형 확인")
|
||||
break
|
||||
|
||||
issues = review_result.get("issues", [])
|
||||
logger.info(
|
||||
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
|
||||
f"조정 필요 — {issues}"
|
||||
)
|
||||
_save_step(run_dir, f"step5_review_round{review_round + 1}.json", review_result)
|
||||
|
||||
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
|
||||
overflow_adjs = [
|
||||
adj for adj in review_result.get("adjustments", [])
|
||||
if adj.get("action") == "overflow_detected"
|
||||
]
|
||||
if overflow_adjs:
|
||||
overflow_context = _build_overflow_context(
|
||||
layout_concept, overflow_adjs
|
||||
)
|
||||
kei_judgment = await call_kei_overflow_judgment(
|
||||
overflow_context, content, analysis
|
||||
)
|
||||
|
||||
if kei_judgment is None:
|
||||
# 넘침 판단도 Kei 필수 — 성공할 때까지 무한 재시도
|
||||
kei_judgment = await _retry_kei(
|
||||
call_kei_overflow_judgment, overflow_context, content, analysis
|
||||
)
|
||||
_convert_kei_judgment(review_result, kei_judgment)
|
||||
logger.info(
|
||||
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
|
||||
)
|
||||
|
||||
layout_concept = await _apply_adjustments(
|
||||
layout_concept, review_result, content
|
||||
)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info(f"5단계: {review_round + 1}차 조정 반영, 재검토 진행")
|
||||
logger.info(f"[Q-6] 품질 게이트 PASS: {quality_result.get('score', 0)}/100")
|
||||
else:
|
||||
# MAX_REVIEW_ROUNDS 초과
|
||||
logger.warning(
|
||||
f"5단계: 최대 재조정 횟수({MAX_REVIEW_ROUNDS}) 도달. 현재 결과로 확정."
|
||||
)
|
||||
logger.warning("[Q-6] 스크린샷 캡처 실패 — 품질 게이트 스킵")
|
||||
|
||||
# D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
|
||||
if base_path:
|
||||
@@ -390,7 +273,7 @@ async def generate_slide(
|
||||
|
||||
_save_step(run_dir, "final.html", html)
|
||||
yield {"event": "result", "data": html}
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지, run={run_id}")
|
||||
logger.info(f"슬라이드 생성 완료: run={run_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"파이프라인 오류: {e}")
|
||||
@@ -558,7 +441,8 @@ async def _review_balance(
|
||||
if measurement_text:
|
||||
overflow_hint_text += f"\n\n{measurement_text}"
|
||||
|
||||
# Kei로 최종 검수 (Sonnet 절대 금지, 스크린샷 있으면 이미지 기반)
|
||||
# Kei로 최종 검수 (레거시 — Phase S에서는 메인 흐름에서 미사용)
|
||||
from src.kei_client import call_kei_final_review
|
||||
return await call_kei_final_review(
|
||||
html, block_summary, zone_budget_text, overflow_hint_text, analysis,
|
||||
screenshot_b64=screenshot_b64,
|
||||
@@ -635,7 +519,8 @@ async def _apply_adjustments(
|
||||
f"조정: {area} → kei_restructure (detail_target)"
|
||||
)
|
||||
|
||||
# 조정된 가이드로 재편집
|
||||
# 조정된 가이드로 재편집 (레거시 — Phase S에서는 미사용)
|
||||
from src.content_editor import fill_content
|
||||
layout_concept = await fill_content(content, layout_concept)
|
||||
return layout_concept
|
||||
|
||||
|
||||
211
src/renderer.py
211
src/renderer.py
@@ -62,6 +62,41 @@ def _load_catalog_map() -> dict[str, str]:
|
||||
return _CATALOG_MAP
|
||||
|
||||
|
||||
# Phase R: variant별 template 경로 캐시
|
||||
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _load_catalog_map_with_variants() -> dict[str, str]:
|
||||
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
|
||||
|
||||
키: "block_id--variant_id" → 값: template 경로
|
||||
"""
|
||||
global _CATALOG_VARIANT_MAP
|
||||
|
||||
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
|
||||
_load_catalog_map() # 캐시 갱신 보장
|
||||
|
||||
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
_CATALOG_VARIANT_MAP = {}
|
||||
if CATALOG_PATH.exists():
|
||||
try:
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
for variant in block.get("variants", []):
|
||||
vid = variant.get("id", "default")
|
||||
vtemplate = variant.get("template", "")
|
||||
if vid != "default" and vtemplate:
|
||||
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog variant 로드 실패: {e}")
|
||||
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성."""
|
||||
return Environment(
|
||||
@@ -70,19 +105,33 @@ def create_jinja_env() -> Environment:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
|
||||
"""블록 타입으로 템플릿 경로를 찾는다.
|
||||
def _resolve_template_path(env: Environment, block_type: str, variant: str = "default") -> str | None:
|
||||
"""블록 타입 + variant로 템플릿 경로를 찾는다.
|
||||
|
||||
Phase R: variant가 지정되면 variant 전용 템플릿을 우선 탐색.
|
||||
variant 템플릿이 없으면 기존 블록 템플릿으로 fallback.
|
||||
|
||||
검색 순서:
|
||||
0. catalog.yaml 매핑 (id → template 경로, 최우선)
|
||||
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등 — 팀장이 카테고리 포함 지정)
|
||||
0-v. catalog.yaml의 variant별 template 경로 (Phase R, 최우선)
|
||||
0. catalog.yaml 매핑 (id → template 경로)
|
||||
1. 정확한 경로 (blocks/cards/card-icon-desc.html 등)
|
||||
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
|
||||
3. _legacy fallback (blocks/_legacy/{block_type}.html)
|
||||
4. 루트 fallback (blocks/{block_type}.html)
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 0. catalog.yaml에서 id → template 매핑 조회 (최우선)
|
||||
# 0-v. Phase R: variant 전용 템플릿 우선 탐색
|
||||
if variant and variant != "default":
|
||||
catalog_map_full = _load_catalog_map_with_variants()
|
||||
variant_key = f"{block_type}--{variant}"
|
||||
if variant_key in catalog_map_full:
|
||||
candidates.append(catalog_map_full[variant_key])
|
||||
# 카테고리 폴더에서 variant 파일 탐색
|
||||
for category in BLOCK_CATEGORIES:
|
||||
candidates.append(f"blocks/{category}/{block_type}--{variant}.html")
|
||||
|
||||
# 0. catalog.yaml에서 id → template 매핑 조회
|
||||
catalog_map = _load_catalog_map()
|
||||
if block_type in catalog_map:
|
||||
catalog_path = catalog_map[block_type]
|
||||
@@ -272,7 +321,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
block_data = _preprocess_svg_data(block_type, block_data)
|
||||
|
||||
# DA-21: 카테고리 경로 검색
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
|
||||
|
||||
if template_path:
|
||||
try:
|
||||
@@ -352,7 +401,7 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
block_type = block["type"]
|
||||
block_data = block.get("data", {})
|
||||
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
template_path = _resolve_template_path(env, block_type, block.get("_variant", "default") if isinstance(block, dict) else "default")
|
||||
|
||||
if template_path:
|
||||
try:
|
||||
@@ -403,10 +452,154 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
return html
|
||||
|
||||
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
def render_block_in_container(
|
||||
block_type: str,
|
||||
data: dict[str, Any],
|
||||
container_height_px: int,
|
||||
container_width_px: int,
|
||||
font_size_px: float = 15.2,
|
||||
padding_px: int = 20,
|
||||
) -> str:
|
||||
"""Phase P: 단일 블록을 컨테이너 안에서 렌더링한다.
|
||||
|
||||
컨테이너 크기에 맞게 CSS가 적용된 완전한 HTML을 반환.
|
||||
Selenium으로 스크린샷 캡처 + 높이 측정에 사용.
|
||||
"""
|
||||
block_html = render_standalone_block(block_type, data)
|
||||
|
||||
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
|
||||
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
|
||||
base_css = base_css.replace("@import url('./tokens.css');", "")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
{tokens_css}
|
||||
{base_css}
|
||||
.candidate-container {{
|
||||
width: {container_width_px}px;
|
||||
height: {container_height_px}px;
|
||||
overflow: visible;
|
||||
font-size: {font_size_px}px;
|
||||
--font-body: {font_size_px / 16:.3f}rem;
|
||||
--spacing-inner: {padding_px}px;
|
||||
padding: {padding_px}px;
|
||||
font-family: 'Pretendard Variable', 'Pretendard', sans-serif;
|
||||
line-height: 1.7;
|
||||
word-break: keep-all;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="candidate-container">
|
||||
{block_html}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_slide_from_html(
|
||||
generated: dict[str, str],
|
||||
analysis: dict[str, Any],
|
||||
preset: dict[str, Any],
|
||||
) -> str:
|
||||
"""Phase R': AI가 생성한 HTML 조각을 슬라이드 프레임에 삽입하여 완성 HTML 반환.
|
||||
|
||||
블록 템플릿을 렌더링하지 않는다. AI가 생성한 body/sidebar/footer HTML을 직접 삽입.
|
||||
|
||||
Args:
|
||||
generated: {"body_html": "...", "sidebar_html": "...", "footer_html": "..."}
|
||||
analysis: 1단계 분석 결과 (title)
|
||||
preset: 프리셋 정보 (grid_areas, grid_columns, grid_rows)
|
||||
"""
|
||||
tokens_css = ""
|
||||
tokens_path = Path(__file__).parent.parent / "static" / "tokens.css"
|
||||
if tokens_path.exists():
|
||||
tokens_css = tokens_path.read_text(encoding="utf-8")
|
||||
|
||||
base_css = ""
|
||||
base_path = Path(__file__).parent.parent / "static" / "base.css"
|
||||
if base_path.exists():
|
||||
# @import 제거 (inline으로 포함하므로)
|
||||
raw = base_path.read_text(encoding="utf-8")
|
||||
base_css = "\n".join(
|
||||
line for line in raw.split("\n")
|
||||
if not line.strip().startswith("@import")
|
||||
)
|
||||
|
||||
title = analysis.get("title", "슬라이드")
|
||||
grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'")
|
||||
grid_columns = preset.get("grid_columns", "65fr 35fr")
|
||||
grid_rows = preset.get("grid_rows", "auto 1fr auto")
|
||||
|
||||
body_html = generated.get("body_html", "")
|
||||
sidebar_html = generated.get("sidebar_html", "")
|
||||
footer_html = generated.get("footer_html", "")
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
{tokens_css}
|
||||
{base_css}
|
||||
|
||||
.slide-1 {{
|
||||
grid-template-areas: {grid_areas};
|
||||
grid-template-columns: {grid_columns};
|
||||
grid-template-rows: {grid_rows};
|
||||
}}
|
||||
.slide-1 .area-body {{ grid-area: body; }}
|
||||
.slide-1 .area-sidebar {{ grid-area: sidebar; }}
|
||||
.slide-1 .area-footer {{ grid-area: footer; }}
|
||||
|
||||
.slide + .slide {{ margin-top: 40px; }}
|
||||
@media print {{
|
||||
.slide {{ page-break-after: always; }}
|
||||
.slide + .slide {{ margin-top: 0; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="slide slide-1">
|
||||
<div class="slide-title" style="grid-area: header;">{title}</div>
|
||||
|
||||
<div class="area-body" style="overflow:hidden;">
|
||||
{body_html}
|
||||
</div>
|
||||
|
||||
<div class="area-sidebar" style="overflow:hidden;">
|
||||
{sidebar_html}
|
||||
</div>
|
||||
|
||||
<div class="area-footer" style="overflow:hidden;">
|
||||
{footer_html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.onbeforeprint = function() {{
|
||||
document.querySelectorAll('details').forEach(function(d) {{ d.open = true; }});
|
||||
}};
|
||||
window.onafterprint = function() {{
|
||||
document.querySelectorAll('details').forEach(function(d) {{ d.open = false; }});
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
logger.info(f"[R'] 슬라이드 렌더링 완료: {title}")
|
||||
return html
|
||||
|
||||
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any], variant: str = "default") -> str:
|
||||
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
|
||||
env = create_jinja_env()
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
template_path = _resolve_template_path(env, block_type, variant)
|
||||
if not template_path:
|
||||
return f"<div>블록 미발견: {block_type}</div>"
|
||||
template = env.get_template(template_path)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Phase O: 컨테이너 기반 공간 할당 시스템.
|
||||
"""Phase O + Phase Q: 컨테이너 기반 공간 할당 + 글자수 예산 + 글루 모델.
|
||||
|
||||
Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
|
||||
LLM 추정이 아닌 결정론적 계산.
|
||||
|
||||
주요 함수:
|
||||
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
|
||||
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
|
||||
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec (Phase O)
|
||||
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙 (Phase O)
|
||||
- calculate_char_budget(): 블록+컨테이너 → 글자수 예산 사전 계산 (Phase Q-3)
|
||||
- apply_glue_compression(): overflow 시 수학적 간격 축소 (Phase Q-7)
|
||||
- calculate_trim_chars(): 초과 px → 삭제 글자 수
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -310,3 +312,247 @@ def calculate_trim_chars(
|
||||
lines_to_remove = math.ceil(excess_px / line_height_px)
|
||||
chars_per_line = int(container_width_px / avg_char_width_px)
|
||||
return max(lines_to_remove * chars_per_line, 10)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-3: 글자수 예산 계산
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
|
||||
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
|
||||
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
|
||||
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
|
||||
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
|
||||
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
|
||||
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
|
||||
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
|
||||
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
|
||||
"compare-2col-split": 60, # 헤더 행 40 + 패딩
|
||||
"table-simple-striped": 50, # 헤더 행 35 + 패딩
|
||||
"banner-gradient": 36, # 패딩 16*2=32 + 여유
|
||||
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
|
||||
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
|
||||
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
|
||||
"venn-diagram": 60, # SVG 구조 + 패딩
|
||||
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
|
||||
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
|
||||
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
|
||||
}
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap (px)
|
||||
_CONTAINER_BLOCK_GAP = 8
|
||||
|
||||
|
||||
def calculate_char_budget(
|
||||
block_type: str,
|
||||
container_spec: ContainerSpec,
|
||||
block_def: dict | None = None,
|
||||
) -> dict:
|
||||
"""블록이 컨테이너에서 수용 가능한 최대 글자수를 사전 계산한다.
|
||||
|
||||
Phase Q 핵심: 이 예산이 AI 콘텐츠 생성의 하드 제약.
|
||||
|
||||
Args:
|
||||
block_type: 블록 ID (예: "venn-diagram")
|
||||
container_spec: 이 topic이 속한 컨테이너
|
||||
block_def: catalog.yaml의 블록 정의 (None이면 기본값 사용)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_chars": int, # 전체 글자수 예산
|
||||
"max_items": int, # 최대 항목 수
|
||||
"chars_per_item": int, # 항목당 최대 글자수
|
||||
"font_size_px": float, # 적용 폰트 크기
|
||||
"available_lines": int, # 가용 줄 수
|
||||
}
|
||||
"""
|
||||
topic_count = max(1, len(container_spec.topic_ids))
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap 차감
|
||||
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
|
||||
available_container_height = max(40, container_spec.height_px - total_gap)
|
||||
per_topic_px = available_container_height // topic_count
|
||||
|
||||
# 폰트 크기 결정
|
||||
font_size, padding, line_h = _determine_typography(per_topic_px)
|
||||
|
||||
# 구조적 오버헤드
|
||||
structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
|
||||
content_height = max(10, per_topic_px - structural)
|
||||
|
||||
# 줄 수 계산
|
||||
line_height_px = font_size * line_h
|
||||
available_lines = max(1, int(content_height / line_height_px))
|
||||
|
||||
# 한국어 줄당 글자수 (폰트 크기 기반)
|
||||
usable_width = container_spec.width_px * 0.85 # 패딩 제외
|
||||
chars_per_line = max(5, int(usable_width / font_size))
|
||||
|
||||
# 항목 수 제한 (블록 정의 참조)
|
||||
max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄
|
||||
catalog_max = 10
|
||||
catalog_min = 1
|
||||
if block_def:
|
||||
catalog_max = block_def.get("max_items", 10)
|
||||
catalog_min = block_def.get("min_items", 1)
|
||||
max_items = min(max_items_by_space, catalog_max)
|
||||
max_items = max(max_items, catalog_min)
|
||||
|
||||
total_chars = available_lines * chars_per_line
|
||||
chars_per_item = total_chars // max(1, max_items)
|
||||
|
||||
budget = {
|
||||
"total_chars": max(20, total_chars),
|
||||
"max_items": max_items,
|
||||
"chars_per_item": max(10, chars_per_item),
|
||||
"font_size_px": font_size,
|
||||
"available_lines": available_lines,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"[Q-3] 예산: {block_type} → {budget['total_chars']}자, "
|
||||
f"{budget['max_items']}항목, {budget['font_size_px']}px"
|
||||
)
|
||||
return budget
|
||||
|
||||
|
||||
def calculate_budgets_for_candidates(
|
||||
candidates: list[dict],
|
||||
container_spec: ContainerSpec,
|
||||
) -> dict[str, dict]:
|
||||
"""후보 블록 리스트의 각 블록에 대해 글자수 예산을 계산한다.
|
||||
|
||||
Returns:
|
||||
{"block_id": {"total_chars": ..., "max_items": ..., ...}, ...}
|
||||
"""
|
||||
budgets = {}
|
||||
for block in candidates:
|
||||
block_id = block.get("id", "")
|
||||
budgets[block_id] = calculate_char_budget(
|
||||
block_id, container_spec, block_def=block
|
||||
)
|
||||
return budgets
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase Q-7: LaTeX 글루 모델 (overflow 수학적 조정)
|
||||
# ──────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class GlueSpec:
|
||||
"""LaTeX 글루 모델: 유연한 간격 정의.
|
||||
|
||||
natural: 기본 간격 (px)
|
||||
stretch: 늘어날 수 있는 양 (px)
|
||||
shrink: 줄어들 수 있는 양 (px)
|
||||
"""
|
||||
natural: float
|
||||
stretch: float
|
||||
shrink: float
|
||||
|
||||
|
||||
# 간격 유형별 글루 설정
|
||||
SPACING_GLUE: dict[str, GlueSpec] = {
|
||||
"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),
|
||||
"container_padding": GlueSpec(natural=16, stretch=0, shrink=8),
|
||||
}
|
||||
|
||||
# 폰트 크기 축소 단계 (이진 탐색용)
|
||||
FONT_SIZE_STEPS = [15.2, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0]
|
||||
|
||||
|
||||
def calculate_glue_absorption(block_count: int) -> float:
|
||||
"""글루 모델로 흡수 가능한 최대 px를 계산한다.
|
||||
|
||||
Args:
|
||||
block_count: 컨테이너 내 블록 수
|
||||
|
||||
Returns:
|
||||
흡수 가능한 총 shrink px
|
||||
"""
|
||||
total_shrink = 0.0
|
||||
# 블록 간 간격
|
||||
total_shrink += SPACING_GLUE["block_gap"].shrink * max(0, block_count - 1)
|
||||
# 각 블록 내부 간격
|
||||
total_shrink += SPACING_GLUE["inner_gap"].shrink * block_count
|
||||
# 제목 간격
|
||||
total_shrink += SPACING_GLUE["title_gap"].shrink * block_count
|
||||
# 컨테이너 패딩 (상하)
|
||||
total_shrink += SPACING_GLUE["container_padding"].shrink * 2
|
||||
|
||||
return total_shrink
|
||||
|
||||
|
||||
def compute_glue_css_overrides(
|
||||
excess_px: float,
|
||||
block_count: int,
|
||||
) -> dict[str, str]:
|
||||
"""overflow excess를 흡수하기 위한 CSS 변수 오버라이드를 계산한다.
|
||||
|
||||
Returns:
|
||||
{"--spacing-block": "8px", "--spacing-inner": "8px", ...} 또는
|
||||
None (글루만으로 흡수 불가)
|
||||
"""
|
||||
max_absorption = calculate_glue_absorption(block_count)
|
||||
if excess_px <= 0:
|
||||
return {}
|
||||
|
||||
if excess_px > max_absorption:
|
||||
# 글루만으로 부족 — 부분 축소 적용 후 나머지는 폰트 축소 필요
|
||||
ratio = 1.0
|
||||
else:
|
||||
ratio = excess_px / max_absorption
|
||||
|
||||
overrides = {}
|
||||
|
||||
# 비율에 따라 각 간격 축소
|
||||
block_gap = SPACING_GLUE["block_gap"]
|
||||
new_block_gap = block_gap.natural - block_gap.shrink * ratio
|
||||
overrides["--spacing-block"] = f"{new_block_gap:.0f}px"
|
||||
|
||||
inner_gap = SPACING_GLUE["inner_gap"]
|
||||
new_inner_gap = inner_gap.natural - inner_gap.shrink * ratio
|
||||
overrides["--spacing-inner"] = f"{new_inner_gap:.0f}px"
|
||||
|
||||
padding = SPACING_GLUE["container_padding"]
|
||||
new_padding = padding.natural - padding.shrink * ratio
|
||||
overrides["--container-padding"] = f"{new_padding:.0f}px"
|
||||
|
||||
logger.info(
|
||||
f"[Q-7] 글루 압축: excess={excess_px:.0f}px, "
|
||||
f"absorption={max_absorption:.0f}px, ratio={ratio:.2f}"
|
||||
)
|
||||
return overrides
|
||||
|
||||
|
||||
def find_fitting_font_size(
|
||||
current_font_px: float,
|
||||
excess_after_glue_px: float,
|
||||
available_lines: int,
|
||||
chars_per_line: int,
|
||||
) -> float | None:
|
||||
"""글루 압축 후에도 남은 overflow를 폰트 축소로 해결할 수 있는지 확인.
|
||||
|
||||
Returns:
|
||||
적합한 폰트 크기 (px) 또는 None (불가능)
|
||||
"""
|
||||
for font_size in FONT_SIZE_STEPS:
|
||||
if font_size >= current_font_px:
|
||||
continue # 현재보다 같거나 큰 크기는 스킵
|
||||
|
||||
# 이 폰트에서의 줄 높이
|
||||
line_height_px = font_size * 1.6 # 한국어 기본
|
||||
height_saved = (current_font_px * 1.6 - line_height_px) * available_lines
|
||||
|
||||
if height_saved >= excess_after_glue_px:
|
||||
logger.info(
|
||||
f"[Q-7] 폰트 축소: {current_font_px}px → {font_size}px "
|
||||
f"({height_saved:.0f}px 확보)"
|
||||
)
|
||||
return font_size
|
||||
|
||||
return None # 8px에서도 안 맞으면 AI 텍스트 압축 필요
|
||||
|
||||
Reference in New Issue
Block a user