Compare commits
20
Commits
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# Phase X-B: 유형 B 템플릿 추가
|
||||||
|
|
||||||
|
> 최종 업데이트: 2026-04-06
|
||||||
|
> 전제: 유형 A(배경+본심+첨부+결론) 기존 코드 건드리지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 유형 B 구조
|
||||||
|
|
||||||
|
02번 MDX (DX의 시행 목표 및 기대효과) 기준.
|
||||||
|
MDX 원본 구조:
|
||||||
|
```
|
||||||
|
title: DX의 시행 목표 및 기대효과 ← 슬라이드 제목 (frontmatter)
|
||||||
|
## 1. DX의 궁극적 목표 ← 상단 (level=2)
|
||||||
|
- 안전과 품질 / 생산성 향상 / 소통과 신뢰 ← 소제목 카드
|
||||||
|
 ← 상단 우측 이미지
|
||||||
|
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과 ← 하단 대목차 (level=2)
|
||||||
|
### 2.1 업무 수행 과정(Process)의 변화 ← 하단 좌측 (level=3)
|
||||||
|
### 2.2 DX 시행 주체별 기대효과 ← 하단 우측 (level=3) — 표 데이터
|
||||||
|
:::note[핵심 요약]
|
||||||
|
* 고품질의 성과품, 비용 절감... ← 결론 (원본 그대로)
|
||||||
|
:::
|
||||||
|
```
|
||||||
|
|
||||||
|
슬라이드 레이아웃:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ DX의 시행 목표 및 기대효과 (원본 title) │
|
||||||
|
├───────────────────────┬──────────────────┤
|
||||||
|
│ DX의 궁극적 목표 │ │
|
||||||
|
│ ┌안전과 품질──────────┐│ [이미지] │
|
||||||
|
│ │• 불릿 ││ DX의 궁극적 │
|
||||||
|
│ ├생산성 향상──────────┤││ 목표 │
|
||||||
|
│ │• 불릿 ││ │
|
||||||
|
│ ├소통과 신뢰──────────┤│ │
|
||||||
|
│ │• 불릿 ││ │
|
||||||
|
│ └────────────────────┘│ │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ DX 기반 Process 혁신에 따른 주체별 기대효과 │ ← 대목차
|
||||||
|
├───────────┬──────────────────────────────┤
|
||||||
|
│ 2.1 업무 │ 2.2 DX 시행 주체별 기대효과 │
|
||||||
|
│ 수행 과정 │ [바로가기 →] (팝업 링크) │
|
||||||
|
│ 변화 │ ┌ Kei 요약 표 ──────────┐ │
|
||||||
|
│ • 생산방식 │ │ 구분│발주자│시공자│설계자│ │
|
||||||
|
│ • 인지검토 │ │ ...│ ...│ ...│ ...│ │
|
||||||
|
│ • 협업구조 │ └──────────────────────┘ │
|
||||||
|
│ • 검증대응 │ │
|
||||||
|
├───────────┴──────────────────────────────┤
|
||||||
|
│ 결론: 고품질의 성과품, 비용 절감... (원본) │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 진행 현황
|
||||||
|
|
||||||
|
### X-B-1: KEI_PROMPT 유형 B 옵션 추가 — ✅ 완료
|
||||||
|
### X-B-2: 검증기 완화 — ✅ 완료
|
||||||
|
### X-B-3: space_allocator 유형 B 컨테이너 생성 — ✅ 완료
|
||||||
|
### X-B-4: assemble_stage2 유형 B 조립 — ✅ 완료 (code_assembled)
|
||||||
|
### X-B-5: pipeline.py 분기 — ✅ 완료
|
||||||
|
|
||||||
|
### X-B-6: 검증 — ❌ 미완료
|
||||||
|
|
||||||
|
**code_assembled(assemble_stage2):**
|
||||||
|
- 제목/대목차/소목차/텍스트: MDX 원본에서 직접 가져옴 ✅
|
||||||
|
- 팝업 링크 + Kei 요약 표 ✅
|
||||||
|
- 이미지 + 캡션 ✅
|
||||||
|
- 카드형 소제목 ✅
|
||||||
|
- **하지만 렌더링에서 잘림** — 컨테이너 크기 vs 내용 크기 불일치
|
||||||
|
|
||||||
|
**파이프라인(before→filled→after):**
|
||||||
|
- **유형 B에서 동작 안 함** — block_assembler가 고정 4역할만 처리
|
||||||
|
- filled가 거의 빈 HTML (2997bytes)
|
||||||
|
- 이걸 해결해야 Selenium 측정 → 재배분이 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 세션 핵심 작업
|
||||||
|
|
||||||
|
**1. block_assembler 유형 B 지원**
|
||||||
|
- `assemble_slide_html()`이 유형 B 역할도 처리
|
||||||
|
- 또는 유형 B 전용 함수 추가
|
||||||
|
- filled/after가 제대로 생성되어야 Selenium 측정 가능
|
||||||
|
|
||||||
|
**2. 컨테이너 크기 맞춤**
|
||||||
|
- 현재 렌더링 잘림 → Selenium 측정 후 재배분으로 해결
|
||||||
|
- 이건 1번이 해결되면 자동으로 동작
|
||||||
|
|
||||||
|
**3. 01번(유형 A) 깨지지 않는지 확인**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
- 하드코딩 절대 금지
|
||||||
|
- HTML 결과물 고치지 말고 파이프라인 프로세스 고칠 것
|
||||||
|
- 제목/텍스트는 원본 MDX에서 그대로 (Kei가 바꾸지 않음)
|
||||||
|
- Kei가 재구성하는 건 빈 공간 채우기(표 요약)만
|
||||||
|
- 유형 A 코드 건드리지 않고 유형 B 추가
|
||||||
|
- normalized.sections에서 직접 텍스트 가져옴 (Kei structured_text 대신)
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
# Phase X-BX': 유형 B 미완료 사항 정리
|
||||||
|
|
||||||
|
> 최종 업데이트: 2026-04-07
|
||||||
|
> 전제: **유형 A 코드 절대 건드리지 않음.** A는 완벽하게 동작 중. 수정도 재검증도 하지 않음.
|
||||||
|
> 유형 B의 code_assembled + 파이프라인만 수정.
|
||||||
|
> **02번 MDX 먼저 → 03번 확장** 순서로 진행.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MDX 원본 위치
|
||||||
|
|
||||||
|
`D:\ad-hoc\cel\src\content\docs\Civil DX\BIM과 DX의 이해\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 근본 원인
|
||||||
|
|
||||||
|
Type A는 Kei가 역할명을 `"배경"`, `"본심"`, `"첨부"`, `"결론"`으로 내려주고,
|
||||||
|
하류 코드가 `containers["배경"]` 처럼 **역할명 글자**로 매칭한다. → 동작함.
|
||||||
|
|
||||||
|
Type B는 Kei가 역할명을 `"필수요건"`, `"과정혁신"` 등으로 내려주는데,
|
||||||
|
하류 코드가 여전히 `containers["배경"]`을 찾는다. → **키가 없어서 빈 것.**
|
||||||
|
|
||||||
|
**해결:** Type B일 때는 역할명 글자가 아니라 `containers`에 있는 키를 순회하고,
|
||||||
|
zone 정보(`top`, `bottom_left` 등)로 위치를 결정한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Type A (기존 그대로):
|
||||||
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
|
container = containers[role]
|
||||||
|
|
||||||
|
# Type B (분기 추가):
|
||||||
|
for role in containers:
|
||||||
|
zone = containers[role].zone # top, bottom_left, bottom_right, footer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-1: 들여쓰기 계층
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
MDX의 2단 계층(`* > *`)이 동일 레벨로 평탄화됨.
|
||||||
|
|
||||||
|
```
|
||||||
|
MDX 원본: 현재 HTML:
|
||||||
|
- 안전과 품질 (소제목) → • 안전과 품질 ← 소제목인데 불릿과 동일
|
||||||
|
- 시설물의 요구 성능을... → • 시설물의 요구 성능을... ← 구분 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
```
|
||||||
|
■ 안전과 품질 ← 소제목 (bold, 색상 구분)
|
||||||
|
• 시설물의 요구 성능... ← 본문 불릿 (들여쓰기)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수행 방향
|
||||||
|
|
||||||
|
**1단계: normalizer에서 불릿 depth 보존**
|
||||||
|
|
||||||
|
현재 `src/mdx_normalizer.py`의 section content:
|
||||||
|
```
|
||||||
|
"**안전과 품질**\n시설물의 요구 성능을..." ← flat, depth 정보 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 후:
|
||||||
|
```
|
||||||
|
"- **안전과 품질**\n - 시설물의 요구 성능을..." ← depth 마커 보존
|
||||||
|
```
|
||||||
|
|
||||||
|
markdown-it의 `list_item_open` 토큰에 이미 indent 정보 있음 (88번째 줄).
|
||||||
|
section content 수집 시 indent level을 보존하면 됨.
|
||||||
|
|
||||||
|
**2단계: 조립 로직에서 depth별 스타일 분기**
|
||||||
|
|
||||||
|
`scripts/assemble_stage2.py` `_assemble_type_b` + `src/block_assembler.py` `_assemble_slide_html_type_b`:
|
||||||
|
- depth 1 (`- `) → 소제목 스타일 (bold, 색상 구분, 카드)
|
||||||
|
- depth 2 (` - `) → 본문 불릿 (들여쓰기, normal weight)
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
02번 상단 "안전과 품질/생산성 향상/소통과 신뢰" 3개 소제목이 카드로 분리,
|
||||||
|
각각의 하위 불릿 2줄이 들여쓰기되어 보임.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-2: overflow → 콘텐츠 맞춤 프로세스
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
상단 zone(255px)에 소제목 3개 + 불릿 6줄 + 이미지 → overflow.
|
||||||
|
하단 우측에 표 데이터가 너무 많아서 overflow.
|
||||||
|
|
||||||
|
### 프로세스 (네가 말한 것)
|
||||||
|
```
|
||||||
|
넘침 감지 → 최대 몇 줄까지 가능? → 몇 자 이내로 정리 → Kei에게 요약 요청 → 재수취 후 정리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 왜 안 되는가 (원인 3개)
|
||||||
|
|
||||||
|
**원인 1: Selenium 측정 실패**
|
||||||
|
- `slide_measurer.py` 144줄: 2.2MB HTML을 `data:` URI로 로드 → 브라우저 크기 제한
|
||||||
|
- Type A는 ~214KB라 동작, Type B는 이미지 base64 포함 2.2MB라 실패
|
||||||
|
- **수정:** 임시 파일로 저장 후 `file://` URI로 로드 (크기 제한 없음)
|
||||||
|
|
||||||
|
**원인 2: overflow 분기에 Type B zone 없음**
|
||||||
|
- `pipeline.py` 538-553줄: `sidebar`와 `body`만 처리
|
||||||
|
- Type B zone(`top`, `bottom`)은 분기 없음 → overflow 감지돼도 무시됨
|
||||||
|
- **수정:** `if layout_template == "B":` 분기 추가. top/bottom overflow 시 처리
|
||||||
|
|
||||||
|
**원인 3: calculate_fit에서 Type B 역할 인식 불가**
|
||||||
|
- `fit_verifier.py` 307줄: `role_font_map = {"본심": "core", "배경": "bg", ...}`
|
||||||
|
- Type B 역할명이 이 dict에 없어서 항상 `"core"` fallback
|
||||||
|
- overflow 계산이 부정확 → `needs_escalation`이 항상 `False`
|
||||||
|
- **수정:** `if layout_template == "B":` 분기. zone 기반 font 매핑
|
||||||
|
|
||||||
|
### 수행 순서
|
||||||
|
|
||||||
|
1. **Selenium 측정 수정** — data URI → 임시파일 방식 (slide_measurer.py)
|
||||||
|
2. **overflow 분기 추가** — Type B zone 처리 (pipeline.py)
|
||||||
|
3. **calculate_fit Type B 지원** — zone 기반 font 매핑 (fit_verifier.py)
|
||||||
|
4. **에스컬레이션 → Kei 요약 요청** — 이미 있는 코드 활용 (pipeline.py 584-606)
|
||||||
|
5. **검증** — 02번 파이프라인 돌려서 상단 overflow 해소 확인
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
- Selenium 측정에서 상단/하단 zone overflow 감지
|
||||||
|
- overflow 시 Kei에게 요약 요청 → 줄어든 콘텐츠로 재조립
|
||||||
|
- 결과 스크린샷에서 overflow 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-3: 하단 구조 — 중제목 별도 행
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
"DX 기반 Process 혁신에 따른 주체별 기대효과"가 별도 행 → 공간 낭비.
|
||||||
|
|
||||||
|
```
|
||||||
|
현재: 목표:
|
||||||
|
┌──────────────────────────────┐ ┌─────────────┬──────────────┐
|
||||||
|
│ DX 기반 Process 혁신에 따른...│ ← 별도행 │ 2.1 업무 수행│ 2.2 DX 시행 │
|
||||||
|
├──────────────┬───────────────┤ │ 과정의 변화 │ 주체별 기대효과│
|
||||||
|
│ 2.1 업무 수행 │ 2.2 DX 시행 │ │ (불릿) │ (표) │
|
||||||
|
│ 과정의 변화 │ 주체별 기대효과│ └─────────────┴──────────────┘
|
||||||
|
└──────────────┴───────────────┘ 중제목은 2분할 상단에 작게 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수행 방향
|
||||||
|
|
||||||
|
`scripts/assemble_stage2.py` `_assemble_type_b` + `src/block_assembler.py` `_assemble_slide_html_type_b`:
|
||||||
|
- 하단 대목차(level=2)를 별도 행으로 배치하지 않음
|
||||||
|
- 2분할 각 칸의 상단에 작은 라벨로 표시하거나, 2분할 위에 한 줄 라벨로 통합
|
||||||
|
- 절약된 높이를 2분할 콘텐츠에 할당
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
하단 영역 전체가 2분할 콘텐츠로 사용됨. 중제목이 별도 행을 차지하지 않음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-4: 하단 좌/우 높이 불균형
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
02번 컨테이너:
|
||||||
|
- bottom_left (업무 프로세스 변화): **124px**
|
||||||
|
- bottom_right (주체별 기대효과): **321px**
|
||||||
|
|
||||||
|
높이가 2.5배 차이.
|
||||||
|
|
||||||
|
### 수행 방향
|
||||||
|
|
||||||
|
`src/space_allocator.py`의 `build_containers_type_b` (544-556줄):
|
||||||
|
- 현재 코드에서 `height_px=bottom_h`로 동일하게 주고 있음
|
||||||
|
- **문제는 Kei가 준 weight가 다른 것** → weight에 의해 top/bottom 비율이 달라지고,
|
||||||
|
그 결과 bottom_h 자체가 줄어드는 구조인지 추적 필요
|
||||||
|
- 하단 좌/우는 무조건 **동일 높이**(`bottom_h`)로 고정
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
하단 좌/우 컨테이너가 동일 높이로 나옴.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-5: before→filled→after 파이프라인 연결
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
Type B의 filled HTML이 2,742 bytes (거의 빈 HTML). Type A는 214KB.
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
하류 코드가 `containers["배경"]`, `containers["본심"]` 처럼 **Type A 역할명 글자**로 매칭.
|
||||||
|
Type B의 역할명(`"필수요건"`, `"과정혁신"` 등)은 이 키에 없어서 빈 것.
|
||||||
|
|
||||||
|
### 수행 방향
|
||||||
|
|
||||||
|
**원칙: Type A 코드 그대로 두고, `if layout_template == "B":` 분기만 추가.**
|
||||||
|
|
||||||
|
#### 5-1. `src/step_visualizer.py` (9곳+)
|
||||||
|
|
||||||
|
현재:
|
||||||
|
```python
|
||||||
|
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||||
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
|
container = containers[role]
|
||||||
|
```
|
||||||
|
|
||||||
|
수정: Type B 분기 추가. 기존 Type A 코드는 **한 글자도 안 건드림.**
|
||||||
|
```python
|
||||||
|
if layout_template == "B":
|
||||||
|
for role in containers:
|
||||||
|
zone = containers[role].zone
|
||||||
|
color = ZONE_COLORS.get(zone, "#333") # zone 기반 색상
|
||||||
|
# ... Type B 시각화
|
||||||
|
else:
|
||||||
|
# 기존 Type A 코드 그대로
|
||||||
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 대상 함수 (9곳):
|
||||||
|
- `_gen_stage_1_5a` (271줄)
|
||||||
|
- `_gen_stage_1_5a_content` (297줄)
|
||||||
|
- `_gen_stage_1_5b` (334줄)
|
||||||
|
- `_gen_stage_1_7` (371줄)
|
||||||
|
- `_gen_stage_1_8_fit_before` (419줄)
|
||||||
|
- `_gen_stage_1_8_fit_after` (465줄)
|
||||||
|
- `_gen_stage_1_8_blocks` (534줄)
|
||||||
|
- `_gen_stage_2` (650줄, 683줄)
|
||||||
|
|
||||||
|
#### 5-2. `src/fit_verifier.py`
|
||||||
|
|
||||||
|
- `ROLE_ZONE_MAP` (488-493줄) — 이미 부분 수정됨. containers에 zone 있으면 그걸 사용.
|
||||||
|
- `role_font_map` (307줄) `{"본심": "core", ...}` — Type B 분기 추가:
|
||||||
|
zone 기반 매핑 (`"top" → "core"`, `"bottom_left" → "core"` 등)
|
||||||
|
- `role_line_height` (308줄) — 동일하게 분기
|
||||||
|
- **Type A 코드 안 건드림.** `ROLE_ZONE_MAP`, `role_font_map`은 그대로 두고 fallback으로만 사용.
|
||||||
|
|
||||||
|
#### 5-3. `src/renderer.py`
|
||||||
|
|
||||||
|
- `_find_h` fallback 이미 추가됨. Type A는 `_find_h("배경")` 그대로 동작.
|
||||||
|
- Type B에서 `body_row_h` 계산이 맞는지 확인 필요 — Type B는 body_row가 없고 top+bottom 구조.
|
||||||
|
- 필요시 `if layout_template == "B":` 분기 추가.
|
||||||
|
|
||||||
|
#### 5-4. `src/slide_measurer.py`
|
||||||
|
|
||||||
|
- CSS 클래스 `area-*`로 zone 탐색 → **역할명 하드코딩 없음. 수정 불필요.**
|
||||||
|
- `_assemble_slide_html_type_b`가 `area-top`, `area-bottom`, `area-footer` 클래스를 생성하므로
|
||||||
|
Selenium 측정이 그대로 동작.
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
02번 MDX로 파이프라인 실행 → filled HTML이 10KB+ → Selenium 측정 정상 → after HTML 생성.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XBX-6: Sonnet HTML 재구성 프로세스 분리
|
||||||
|
|
||||||
|
### 현상
|
||||||
|
Stage 2(`src/pipeline.py` 901-957줄)에서 Sonnet(`generate_with_retry`)이 HTML 재구성.
|
||||||
|
Type B에서는 품질 불안정.
|
||||||
|
|
||||||
|
### 수행 방향
|
||||||
|
|
||||||
|
`src/pipeline.py` stage_2 함수에 Type B 분기 추가:
|
||||||
|
```python
|
||||||
|
async def stage_2(context: PipelineContext) -> dict:
|
||||||
|
if context.analysis.layout_template == "B":
|
||||||
|
# Type B: code_assembled 결과를 직접 사용, Sonnet 재구성 스킵
|
||||||
|
from src.block_assembler import assemble_slide_html
|
||||||
|
generated = assemble_slide_html(context)
|
||||||
|
return {"generated_html": generated}
|
||||||
|
|
||||||
|
# Type A: 기존 Sonnet 재구성 코드 그대로
|
||||||
|
from src.content_verifier import generate_with_retry
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sonnet 코드 삭제하지 않음
|
||||||
|
- Type B일 때만 스킵
|
||||||
|
- code_assembled HTML은 `assemble_slide_html(context)`로 생성 (이미 동작 확인됨)
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
- Type A: 기존대로 Sonnet 재구성 → 결과 동일
|
||||||
|
- Type B: code_assembled 직접 사용 → 결과가 스크린샷에서 정상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 순서
|
||||||
|
|
||||||
|
**02번 먼저 완성 → 03번 확장:**
|
||||||
|
|
||||||
|
1. **XBX-1** (들여쓰기) — normalizer depth 보존 + 조립 로직 분기
|
||||||
|
2. **XBX-3** (하단 구조) — 중제목 별도행 → 라벨로 통합
|
||||||
|
3. **XBX-4** (하단 높이) — 좌/우 균등 확인 + 수정
|
||||||
|
4. **XBX-5** (파이프라인 연결) — step_visualizer/fit_verifier/renderer Type B 분기
|
||||||
|
5. **XBX-2** (overflow) — 파이프라인 연결 후 Selenium으로 자동 확인
|
||||||
|
6. **XBX-6** (Sonnet 분리) — pipeline.py Type B 분기
|
||||||
|
7. **03번 확장** — 03번 MDX에서도 동작 확인 (표 보존, 3단 계층 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 02번 run 정보
|
||||||
|
|
||||||
|
- 최신 run: `data/runs/20260406_121405`
|
||||||
|
- 스크린샷: `steps/code_assembled_02_2x.png`
|
||||||
|
- containers: top=255px(w=847px), bottom_left=124px, bottom_right=321px, footer=83px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
- 하드코딩 절대 금지
|
||||||
|
- HTML 결과물 고치지 말고 파이프라인 프로세스 고칠 것
|
||||||
|
- 제목/텍스트는 원본 MDX에서 그대로
|
||||||
|
- **유형 A 코드 절대 건드리지 않음** — A는 완벽하게 동작 중. 수정도 재검증도 하지 않음.
|
||||||
|
- Type B 코드는 기존 코드에 분기(`if layout_template == "B"`) 추가로만 구현
|
||||||
|
- 검증은 반드시 렌더링(스크린샷)으로
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# Phase X-C: 서브존 프리셋 기반 범용 레이아웃
|
||||||
|
|
||||||
|
> 최종 업데이트: 2026-04-07
|
||||||
|
> 전제: Type A, Type B 건드리지 않음. Type C로 새 접근.
|
||||||
|
> 의존: Phase X-BX' 완료 후 시작 (zone 기반 코드 전환 완료 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 아이디어
|
||||||
|
|
||||||
|
**AI는 "고르는 것"에만 쓰고, "만드는 것"은 코드가 한다.**
|
||||||
|
|
||||||
|
### 고정 구조
|
||||||
|
|
||||||
|
모든 슬라이드는 3개 zone:
|
||||||
|
```
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ header (제목) │ ← 고정
|
||||||
|
├───────────────────────────┤
|
||||||
|
│ body (본문) │ ← 서브존 프리셋 적용
|
||||||
|
├───────────────────────────┤
|
||||||
|
│ footer (핵심 요약) │ ← 고정
|
||||||
|
└───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서브존 프리셋
|
||||||
|
|
||||||
|
body 안의 배치를 row 조합으로 정의:
|
||||||
|
|
||||||
|
```
|
||||||
|
row 유형:
|
||||||
|
F = 전체폭 (1칸)
|
||||||
|
H = 2분할
|
||||||
|
T = 3분할
|
||||||
|
Q = 4분할
|
||||||
|
|
||||||
|
프리셋 예시:
|
||||||
|
S1: F → 1단 전체폭
|
||||||
|
S2: H → 2단 (좌/우)
|
||||||
|
S3: T → 3단 균등
|
||||||
|
S4: F+H → 상단 전체폭 + 하단 2분할 (현재 Type B에 해당)
|
||||||
|
S5: H+F → 상단 2분할 + 하단 전체폭
|
||||||
|
S6: H+H → 상하 각 2분할 (2x2)
|
||||||
|
S7: F+T → 상단 전체폭 + 하단 3분할
|
||||||
|
S8: T+F → 상단 3분할 + 하단 전체폭
|
||||||
|
S9: F+F → 전체폭 2단 (현재 Type A에 가까움)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kei 역할 (최소화)
|
||||||
|
|
||||||
|
1. **꼭지 추출** — 콘텐츠를 몇 개 덩어리로 나눌지
|
||||||
|
2. **꼭지 간 관계** — 비교/나열/종속/독립
|
||||||
|
3. **프리셋 선택** — 번호로 고르기 (또는 코드가 자동 매핑)
|
||||||
|
|
||||||
|
### 코드 역할
|
||||||
|
|
||||||
|
1. 프리셋 → zone/sub-zone px 계산 (사칙연산)
|
||||||
|
2. 텍스트량 기반 비율 계산
|
||||||
|
3. before→filled→after 파이프라인 (Selenium 측정 → 재배분)
|
||||||
|
4. MDX 원본 텍스트 배치
|
||||||
|
5. 블록 선택 (프리셋별 적합 블록 매핑)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 질문: Kei가 프리셋을 고를 수 있는가?
|
||||||
|
|
||||||
|
### 자동 매핑 (코드가 결정) — 안정적이지만 제한적
|
||||||
|
```python
|
||||||
|
if len(topics) == 1: preset = "S1" # 1단
|
||||||
|
if len(topics) == 2: preset = "S2" # 2분할
|
||||||
|
if len(topics) == 3: preset = "S3" # 3단
|
||||||
|
if len(topics) == 4: preset = "S6" # 2x2
|
||||||
|
```
|
||||||
|
→ 꼭지 관계 무시. 비교 3개 + 정리 1개 같은 경우 대응 못 함.
|
||||||
|
|
||||||
|
### Kei 선택 — 유연하지만 불안정 위험
|
||||||
|
```json
|
||||||
|
{"topics": [...], "preset": "S4", "reason": "상단 3개 비교 + 하단 종합"}
|
||||||
|
```
|
||||||
|
→ 블록 선택도 불안한데 프리셋 선택이 안정적일지 미지수.
|
||||||
|
|
||||||
|
### 하이브리드 (유력) — 코드가 후보 제시, Kei가 선택
|
||||||
|
```
|
||||||
|
코드: "꼭지 4개이므로 후보: S4, S6, S7"
|
||||||
|
Kei: "비교 관계이므로 S4"
|
||||||
|
```
|
||||||
|
→ 선택지를 3개 이하로 좁히면 Kei가 잘 고를 가능성 높음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술적 구현 방향
|
||||||
|
|
||||||
|
### sub-zone px 계산
|
||||||
|
|
||||||
|
```python
|
||||||
|
def calculate_sub_zones(preset: str, body_width: int, body_height: int, gap: int):
|
||||||
|
rows = parse_preset(preset) # "F+H" → [F, H]
|
||||||
|
row_count = len(rows)
|
||||||
|
row_height = (body_height - gap * (row_count - 1)) // row_count
|
||||||
|
|
||||||
|
zones = []
|
||||||
|
for i, row_type in enumerate(rows):
|
||||||
|
col_count = {"F": 1, "H": 2, "T": 3, "Q": 4}[row_type]
|
||||||
|
col_width = (body_width - gap * (col_count - 1)) // col_count
|
||||||
|
for j in range(col_count):
|
||||||
|
zones.append({
|
||||||
|
"row": i, "col": j,
|
||||||
|
"width": col_width, "height": row_height,
|
||||||
|
})
|
||||||
|
return zones
|
||||||
|
```
|
||||||
|
|
||||||
|
### 비율 조정
|
||||||
|
```python
|
||||||
|
# 텍스트량 기반 비율
|
||||||
|
text_lengths = [len(topic.text) for topic in row_topics]
|
||||||
|
total = sum(text_lengths)
|
||||||
|
ratios = [l / total for l in text_lengths]
|
||||||
|
# 최소 20%, 최대 60% 제한
|
||||||
|
ratios = [max(0.2, min(0.6, r)) for r in ratios]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 단계별 진행 계획
|
||||||
|
|
||||||
|
1. **X-C-1: 프리셋 정의** — S1~S9 구조 + px 계산 함수
|
||||||
|
2. **X-C-2: 자동 매핑 먼저** — 꼭지 수 → 프리셋 (코드만으로)
|
||||||
|
3. **X-C-3: 조립 범용화** — zone 기반으로 어떤 프리셋이든 조립
|
||||||
|
4. **X-C-4: Kei 선택 실험** — 하이브리드 방식 테스트
|
||||||
|
5. **X-C-5: before→filled→after 연결** — X-BX'의 zone 기반 코드 활용
|
||||||
|
6. **X-C-6: 검증** — 01/02/03번 + 새 MDX로 범용성 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type A/B와의 관계
|
||||||
|
|
||||||
|
- Type A, B는 기존 코드 그대로 유지
|
||||||
|
- Type C는 별도 경로로 동작
|
||||||
|
- 추후 Type C가 안정화되면 A/B를 C의 프리셋으로 흡수 가능:
|
||||||
|
- Type A ≈ S9 (F+F) + sidebar
|
||||||
|
- Type B ≈ S4 (F+H)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Phase X': 유형 B 파이프라인 개선
|
||||||
|
|
||||||
|
> 최종 업데이트: 2026-04-06
|
||||||
|
> 전제: 유형 A 건드리지 않음. 유형 B 파이프라인 프로세스 수정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
- 유형 A (배경+본심+첨부+결론): ✅ 동작 (01번 MDX)
|
||||||
|
- 유형 B (상단+하단2분할+결론): **code_assembled만 동작, 파이프라인(before→filled→after) 미연결**
|
||||||
|
|
||||||
|
## 완료된 것
|
||||||
|
|
||||||
|
### X'-1: 제목 원본 MDX에서 가져오기 ✅
|
||||||
|
- `context.normalized.title` 사용 (Kei title 대신)
|
||||||
|
- 파일: `src/pipeline.py` Stage 1A
|
||||||
|
|
||||||
|
### X'-2: 들여쓰기 계층 ✅ (code_assembled에서만)
|
||||||
|
- `###` 소제목 → 카드형 분리
|
||||||
|
- 본문 불릿 indent 적용
|
||||||
|
- 파일: `scripts/assemble_stage2.py`
|
||||||
|
|
||||||
|
### X'-3: 이미지 캡션 ✅
|
||||||
|
- `normalized.images` alt text에서 추출
|
||||||
|
- 파일: `scripts/assemble_stage2.py`
|
||||||
|
|
||||||
|
### X'-4: 상단 균등배분 ✅
|
||||||
|
- `justify-content:space-between`
|
||||||
|
- 파일: `scripts/assemble_stage2.py`
|
||||||
|
|
||||||
|
### X'-5: 카드 디자인 ✅
|
||||||
|
- 다크 그라데이션 + 밝은 텍스트
|
||||||
|
- 파일: `scripts/assemble_stage2.py`
|
||||||
|
|
||||||
|
### X'-6: 표 요약 ✅ (code_assembled에서만)
|
||||||
|
- `normalized.tables` → pipeline V'-2에서 Kei 요약 → context 저장
|
||||||
|
- `_assemble_type_b` 하단 우측에 표출
|
||||||
|
- 파일: `src/pipeline.py`, `scripts/assemble_stage2.py`
|
||||||
|
|
||||||
|
### MDX sections 계층 ✅
|
||||||
|
- `mdx_normalizer`: `###` (h3) 소목차도 section으로 분리
|
||||||
|
- `_assemble_type_b`: `normalized.sections`에서 직접 텍스트 가져오기
|
||||||
|
- 대목차/소목차 계층 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 미해결 문제
|
||||||
|
|
||||||
|
### 유형 B의 before→filled→after 파이프라인이 연결 안 됨
|
||||||
|
|
||||||
|
**증거:**
|
||||||
|
- FILLED: 2997bytes, 한글 80자 (유형 A는 214KB)
|
||||||
|
- `block_assembler.assemble_slide_html()`이 고정 4역할(배경/본심/첨부/결론)만 처리
|
||||||
|
- 유형 B의 자유 역할명(DX_궁극적_목표, 프로세스_변화 등)을 처리 못 함
|
||||||
|
- 결과: filled/after가 거의 빈 HTML
|
||||||
|
|
||||||
|
**해결 방향:**
|
||||||
|
- `block_assembler.assemble_slide_html()`이 유형 B 역할도 처리하도록
|
||||||
|
- 또는 유형 B 전용 filled/after 함수 추가
|
||||||
|
- `_assemble_type_b`(assemble_stage2)는 code_assembled 전용이므로, 파이프라인의 filled/after에는 별도 로직 필요
|
||||||
|
|
||||||
|
### 렌더링에서 잘림
|
||||||
|
|
||||||
|
**증거:**
|
||||||
|
- code_assembled에 모든 내용이 HTML로 있지만 브라우저에서 보면 잘림
|
||||||
|
- overflow:hidden + 컨테이너 크기 < 내용 크기
|
||||||
|
- 상단 카드가 잘림, 결론이 안 보임
|
||||||
|
|
||||||
|
**해결 방향:**
|
||||||
|
- 컨테이너 크기 계산에서 내용 크기를 고려
|
||||||
|
- 또는 Selenium 측정 후 재배분 (이건 filled→after 파이프라인이 동작해야 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kei가 하는 일 (명확히 정리)
|
||||||
|
|
||||||
|
1. **꼭지 찾기 + 그루핑** — MDX 구조 분석
|
||||||
|
2. **유형 선택 (A/B)** — 콘텐츠에 맞는 레이아웃
|
||||||
|
3. **블록 선택** — 컨테이너에 맞는 블록 타입
|
||||||
|
4. **공란에 표/팝업 요약** — 원문 최대 유지
|
||||||
|
5. **bold 키워드 판단** — 문맥 기반
|
||||||
|
|
||||||
|
**나머지는 전부 MDX 원본에서 가져옴:**
|
||||||
|
- 제목, 대목차, 중목차, 소목차, 텍스트 — 원본 그대로
|
||||||
|
- 핵심 요약 — 원본 그대로
|
||||||
|
- Kei가 텍스트를 재작성하지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 세션 작업 순서
|
||||||
|
|
||||||
|
1. **유형 B filled/after 파이프라인 연결** — block_assembler 또는 별도 함수
|
||||||
|
2. **컨테이너 크기 vs 내용 크기 맞춤** — Selenium 측정 기반 재배분
|
||||||
|
3. **렌더링 잘림 해결** — overflow 처리
|
||||||
|
4. **01번(유형 A) 깨지지 않는지 확인**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 | 유형 B 상태 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `src/kei_client.py` | KEI_PROMPT (유형 A/B 선택) | ✅ |
|
||||||
|
| `src/validators.py` | 검증기 (유형 B 완화) | ✅ |
|
||||||
|
| `src/space_allocator.py` | 컨테이너 생성 (build_containers_type_b) | ✅ |
|
||||||
|
| `src/pipeline.py` | 파이프라인 분기 (layout_template) | ✅ 분기만 |
|
||||||
|
| `src/pipeline_context.py` | Analysis.layout_template | ✅ |
|
||||||
|
| `src/mdx_normalizer.py` | ### 소목차 section 분리 | ✅ |
|
||||||
|
| `scripts/assemble_stage2.py` | _assemble_type_b (code_assembled) | ✅ |
|
||||||
|
| `src/block_assembler.py` | assemble_slide_html (filled/after) | ❌ 유형 B 미지원 |
|
||||||
@@ -393,6 +393,36 @@ P2-E (누락기능) ── 병렬 │
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase Y: MDX 외부 컴포넌트 인라인 삽입
|
||||||
|
|
||||||
|
> 근거: MDX에서 `import ... from '*.astro'`로 불러오는 외부 컴포넌트(표, 다이어그램 등)가 파이프라인에서 누락됨. import문은 제거되고 `<DxEffect />` 같은 태그는 사라져서 콘텐츠 손실 발생.
|
||||||
|
|
||||||
|
### Y-1: import문 파싱 — 컴포넌트명:파일경로 매핑
|
||||||
|
- **파일:** `src/mdx_normalizer.py`
|
||||||
|
- **내용:** `import Foo from '../../components/foo.astro'` → `{"Foo": 절대경로}` 매핑 추출
|
||||||
|
- **의존성:** base_path (MDX 원본 파일 위치, pipeline.py에서 전달)
|
||||||
|
- **완료 기준:** import문에서 컴포넌트명→절대경로 dict 반환
|
||||||
|
|
||||||
|
### Y-2: .astro 파일 파싱 — HTML + CSS 추출
|
||||||
|
- **파일:** `src/mdx_normalizer.py`
|
||||||
|
- **내용:** .astro 파일에서 `---` frontmatter 제거, HTML 본문 + `<style>` 블록 추출
|
||||||
|
- **의존성:** Y-1
|
||||||
|
- **완료 기준:** dx.astro → `<div class="table-wrapper">...</div>` + `<style>...</style>` 반환
|
||||||
|
|
||||||
|
### Y-3: 셀프클로징 태그 교체 — 인라인 삽입
|
||||||
|
- **파일:** `src/mdx_normalizer.py`
|
||||||
|
- **내용:** `<DxEffect />` 태그를 Y-2에서 추출한 HTML+CSS로 교체
|
||||||
|
- **의존성:** Y-1, Y-2
|
||||||
|
- **완료 기준:** MDX 정규화 결과에 외부 컴포넌트 HTML이 인라인으로 포함
|
||||||
|
|
||||||
|
### Y-4: Astro 특수 문법 정리
|
||||||
|
- **파일:** `src/mdx_normalizer.py`
|
||||||
|
- **내용:** Astro의 멀티라인 태그(`<td class="category-cell">텍스트</td>` 줄바꿈 패턴), `style="letter-spacing: -0.9px"` 등 인라인 스타일 정리
|
||||||
|
- **의존성:** Y-2
|
||||||
|
- **완료 기준:** 추출된 HTML이 브라우저에서 정상 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 의존 관계
|
## 의존 관계
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ import DxEffect from '../../../../components/dx.astro';
|
|||||||
<br/>
|
<br/>
|
||||||
### 2.2 DX 시행 주체별 기대효과
|
### 2.2 DX 시행 주체별 기대효과
|
||||||
|
|
||||||
|
| 구분 | 발주자 | 시공자 | 설계자 |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| **필요 역량** | 실행 의지와 합리적 판단 역량 | 기술 투자와 운영 역량 | 기술개발 투자에 의한 S/W 역량 |
|
||||||
|
| **수작업 의존 → S/W 기반 체계화** | - 행정서류 자동 생성 및 최소화로 업무 생산성 향상<br/>- 건설기간 단축, 건설비 및 유지관리비 총비용 최소화 | - 체계적 공정/자원 관리를 통한 신뢰성 확보 및 생산성 향상<br/>- Model에서의 도면 추출로 쉽고 정확한 시공상세도 작성 용이<br/>- 시스템 구축 시, 품질·안전·관리 등에 필요한 도서 작성 용이 | - SW기반 설계프로세스 체계화로 설계 생산성 향상<br/>- 프로젝트 정보의 일관 유지 및 관리를 통한 오류 최소화<br/>- 다양한 성과물과 정보물 활용으로 추가 부가가치 창출 |
|
||||||
|
| **2D → 3D 기반 인지·검토** | - 3D 모델을 통한 직관적 시각화로 품질 향상 및 안전성 제고<br/>- 건설단계별 수행상태에 대한 쉬운 이해로 관리 편의성 증대 | - 직관적 시각화로 계획시공 등을 관리하여 안전성 제고 및 품질 향상<br/>- 중간태, 완성태 측량을 통한 시·공간적 관리의 편리성 향상 | - 3D 모델을 통한 확인/검증으로 설계 오류 최소화 및 Claim 예방 |
|
||||||
|
| **문서 중심 → 데이터 통합 기반 협업** | - 현장 실무자와 발주자의 원활한 의사소통으로 오류 최소화<br/>- 디지털 환경 구축을 통한 건설 정보 통합관리 활용성 강화 | - 불필요한 행정서류 감소를 통한 협업 및 의사소통 효율 향상 | - 설계 신뢰도 확보 및 발주자 이익 기여로 상호신뢰 증진 |
|
||||||
|
| **사후 대응 → 사전 검증 중심 관리** | - 설계변경, 민원, 재작업, 소송 등의 사전 예방, 최소화 | - 설계 및 시공 오류 예방과 원활한 의사 소통으로 공사 Risk 최소화 | - 시공 전 설계검증 강화로 설계 책임 리스크 감소 |
|
||||||
|
|
||||||
<DxEffect />
|
<DxEffect />
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
+469
-2
@@ -44,8 +44,13 @@ def assemble(run_dir: str):
|
|||||||
popups = ctx.get("normalized", {}).get("popups", [])
|
popups = ctx.get("normalized", {}).get("popups", [])
|
||||||
title = ctx.get("analysis", {}).get("title", "")
|
title = ctx.get("analysis", {}).get("title", "")
|
||||||
ratio = ctx.get("container_ratio", [71, 29])
|
ratio = ctx.get("container_ratio", [71, 29])
|
||||||
|
layout_template = ctx.get("analysis", {}).get("layout_template", "A")
|
||||||
|
|
||||||
# ── 유틸 ──
|
# Phase X-B: 유형 B면 별도 함수로 분기
|
||||||
|
if layout_template == "B":
|
||||||
|
return _assemble_type_b(run, ctx)
|
||||||
|
|
||||||
|
# ── 유틸 (유형 A) ──
|
||||||
def bold(text, role):
|
def bold(text, role):
|
||||||
"""V-10 bold 키워드 적용."""
|
"""V-10 bold 키워드 적용."""
|
||||||
for kw in bold_kw.get(role, []):
|
for kw in bold_kw.get(role, []):
|
||||||
@@ -386,7 +391,10 @@ def assemble(run_dir: str):
|
|||||||
break
|
break
|
||||||
|
|
||||||
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
|
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
|
||||||
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
|
# 이미지 높이: 실제 비율로 계산 (sub_layout 고정값 대신)
|
||||||
|
slide_images = ctx.get("slide_images", [])
|
||||||
|
img_ratio = next((img.get("ratio", 1) for img in slide_images if img.get("b64")), 1)
|
||||||
|
svg_h = int(svg_w / img_ratio) if img_ratio > 0 else int(svg_sc["height_px"]) if svg_sc else 265
|
||||||
# 본심의 모든 topic 텍스트를 합침
|
# 본심의 모든 topic 텍스트를 합침
|
||||||
all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
|
all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
|
||||||
|
|
||||||
@@ -560,3 +568,462 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
|
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
|
||||||
assemble(run_dir)
|
assemble(run_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
# Phase X-B: 유형 B 조립
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
def _assemble_type_b(run: Path, ctx: dict):
|
||||||
|
"""유형 B: 상단(top+이미지) + 하단 2분할 + 결론.
|
||||||
|
|
||||||
|
기존 유형 A 코드를 건드리지 않는 별도 함수.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from src.fit_verifier import _load_design_tokens
|
||||||
|
|
||||||
|
topics = ctx["topics"]
|
||||||
|
topic_map = {t["id"]: t for t in topics}
|
||||||
|
ps = ctx["page_structure"]
|
||||||
|
if "roles" in ps:
|
||||||
|
ps = ps["roles"]
|
||||||
|
containers = ctx["containers"]
|
||||||
|
fh = ctx.get("font_hierarchy", {})
|
||||||
|
enh = ctx.get("enhancement_result", {})
|
||||||
|
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||||
|
popups = ctx.get("normalized", {}).get("popups", [])
|
||||||
|
title = ctx.get("analysis", {}).get("title", "")
|
||||||
|
core_message = ctx.get("analysis", {}).get("core_message", "")
|
||||||
|
slide_images = ctx.get("slide_images", [])
|
||||||
|
|
||||||
|
tokens = _load_design_tokens()
|
||||||
|
pad = tokens["spacing_page"]
|
||||||
|
header_h = tokens.get("header_height", 66)
|
||||||
|
gap_block = tokens["spacing_block"]
|
||||||
|
gap_small = tokens["spacing_small"]
|
||||||
|
slide_w = tokens.get("slide_width", 1280)
|
||||||
|
slide_h = tokens.get("slide_height", 720)
|
||||||
|
inner_w = slide_w - pad * 2
|
||||||
|
|
||||||
|
# ── 유틸 ──
|
||||||
|
def get_text(topic):
|
||||||
|
if isinstance(topic, dict):
|
||||||
|
return topic.get("structured_text", "") or topic.get("source_data", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def bold(text, role):
|
||||||
|
for kw in bold_kw.get(role, []):
|
||||||
|
if kw in text:
|
||||||
|
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def find_popup(title_keyword):
|
||||||
|
for p in popups:
|
||||||
|
if title_keyword in p.get("title", ""):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── zone별 역할 분류 ──
|
||||||
|
top_role = None
|
||||||
|
bottom_left_role = None
|
||||||
|
bottom_right_role = None
|
||||||
|
footer_role = None
|
||||||
|
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
zone = info.get("zone", "")
|
||||||
|
if zone == "top":
|
||||||
|
top_role = (role_name, info)
|
||||||
|
elif zone == "bottom_left":
|
||||||
|
bottom_left_role = (role_name, info)
|
||||||
|
elif zone == "bottom_right":
|
||||||
|
bottom_right_role = (role_name, info)
|
||||||
|
elif zone == "footer":
|
||||||
|
footer_role = (role_name, info)
|
||||||
|
|
||||||
|
# ── 좌표 계산 (containers에서 동적으로) ──
|
||||||
|
# footer
|
||||||
|
footer_ci = containers.get(footer_role[0], {}) if footer_role else {}
|
||||||
|
footer_h = footer_ci.get("height_px", 53) if isinstance(footer_ci, dict) else 53
|
||||||
|
ft_top = slide_h - pad - footer_h
|
||||||
|
|
||||||
|
# 상단
|
||||||
|
top_ci = containers.get(top_role[0], {}) if top_role else {}
|
||||||
|
top_h = top_ci.get("height_px", 200) if isinstance(top_ci, dict) else 200
|
||||||
|
top_w = top_ci.get("width_px", inner_w) if isinstance(top_ci, dict) else inner_w
|
||||||
|
top_top = pad + header_h + gap_block
|
||||||
|
|
||||||
|
# 이미지 크기
|
||||||
|
img_constraints = top_ci.get("block_constraints", {}) if isinstance(top_ci, dict) else {}
|
||||||
|
img_w = img_constraints.get("img_width_px", 0)
|
||||||
|
has_image = img_constraints.get("has_image", False)
|
||||||
|
|
||||||
|
# 이미지 높이: 실제 비율로
|
||||||
|
img_h = 0
|
||||||
|
img_html = ""
|
||||||
|
if has_image and slide_images:
|
||||||
|
for img in slide_images:
|
||||||
|
b64 = img.get("b64", "")
|
||||||
|
if b64:
|
||||||
|
img_ratio = img.get("ratio", 1)
|
||||||
|
img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
|
||||||
|
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||||
|
break
|
||||||
|
|
||||||
|
# 하단
|
||||||
|
bottom_top = top_top + top_h + gap_small
|
||||||
|
# V'-4: 결론 바로 위까지 채움
|
||||||
|
column_bottom = ft_top - gap_block
|
||||||
|
bottom_h = column_bottom - bottom_top
|
||||||
|
bottom_col_w = (inner_w - gap_block) // 2
|
||||||
|
|
||||||
|
# ── normalized.sections에서 직접 텍스트 가져오기 ──
|
||||||
|
norm_sections = ctx.get("normalized", {}).get("sections", [])
|
||||||
|
font_size = fh.get("core", 12)
|
||||||
|
|
||||||
|
# 상단 (텍스트 + 이미지 나란히) — sections[0] 사용
|
||||||
|
top_html = ""
|
||||||
|
if top_role:
|
||||||
|
rn, info = top_role
|
||||||
|
tids = info.get("topic_ids", [])
|
||||||
|
# MDX 원본 sections에서 직접 가져오기
|
||||||
|
# 상단: 첫 번째 level=2 section + 하단 대목차 전까지의 level=2 sections를 합침
|
||||||
|
# (03번처럼 기술/사람/자연이 별도 section으로 분리된 경우 대응)
|
||||||
|
topic_title_from_section = ""
|
||||||
|
top_contents = []
|
||||||
|
for s in norm_sections:
|
||||||
|
if s["level"] == 3:
|
||||||
|
break # level=3(소목차) 나오면 상단 끝
|
||||||
|
if not topic_title_from_section and s.get("title"):
|
||||||
|
topic_title_from_section = s["title"]
|
||||||
|
content = s.get("content", "")
|
||||||
|
if content:
|
||||||
|
# section title도 소제목으로 포함 (첫 번째 제외)
|
||||||
|
if s["title"] and s["title"] != topic_title_from_section:
|
||||||
|
top_contents.append(f"### {s['title']}")
|
||||||
|
top_contents.append(content)
|
||||||
|
all_text = "\n".join(top_contents)
|
||||||
|
# 마크다운 bold → HTML
|
||||||
|
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
|
||||||
|
|
||||||
|
# 팝업 분리
|
||||||
|
popup_titles = []
|
||||||
|
content_lines = []
|
||||||
|
for line in all_text_clean.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||||
|
if popup_match:
|
||||||
|
popup_titles.append(popup_match.group(1))
|
||||||
|
continue
|
||||||
|
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
|
||||||
|
continue
|
||||||
|
content_lines.append(stripped)
|
||||||
|
|
||||||
|
# 팝업 링크 우측상단
|
||||||
|
popup_html = ""
|
||||||
|
if popup_titles:
|
||||||
|
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles)
|
||||||
|
popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
|
||||||
|
|
||||||
|
# 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
|
||||||
|
sections = [] # [(소제목, [불릿들])]
|
||||||
|
current_section = ("", [])
|
||||||
|
for line in content_lines:
|
||||||
|
if line.startswith("### ") or line.startswith("###"):
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (line.lstrip("# ").strip(), [])
|
||||||
|
elif re.match(r'^D1:\s*', line):
|
||||||
|
# D1 = 1단 불릿 = 소제목 (카드 제목)
|
||||||
|
title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (bold(title_text, rn), [])
|
||||||
|
elif re.match(r'^D[2-9]:\s*', line):
|
||||||
|
# D2+ = 하위 불릿 = 본문
|
||||||
|
clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(bold(clean, rn))
|
||||||
|
else:
|
||||||
|
clean = line.lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(bold(clean, rn))
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
|
||||||
|
# X'-2: 카드형 HTML — 소제목별 들여쓰기 계층
|
||||||
|
# X'-5: 카드 디자인 — 다크 그라데이션 배경, 밝은 텍스트
|
||||||
|
_card_colors = [
|
||||||
|
("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
|
||||||
|
]
|
||||||
|
card_pad = int(font_size * 0.6)
|
||||||
|
card_gap = max(3, int(font_size * 0.4))
|
||||||
|
indent_body = int(font_size * 1.2) # 본문 들여쓰기
|
||||||
|
|
||||||
|
bullets = ""
|
||||||
|
if len(sections) > 1 and sections[0][0]:
|
||||||
|
for ci, (sec_title, sec_items) in enumerate(sections):
|
||||||
|
bg, text_color = _card_colors[ci % len(_card_colors)]
|
||||||
|
items_html = "".join(
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
|
||||||
|
for item in sec_items
|
||||||
|
)
|
||||||
|
if sec_title:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
|
||||||
|
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
|
||||||
|
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
|
||||||
|
f'margin-bottom:{int(font_size*0.3)}px;">{bold(sec_title, rn)}</div>'
|
||||||
|
f'{items_html}</div>\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bullets += items_html
|
||||||
|
else:
|
||||||
|
for sec_title, sec_items in sections:
|
||||||
|
for item in sec_items:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="font-size:{font_size}px;">• {item}</span></div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# X'-3: 이미지 캡션 — normalized.images alt → 출처 → [이미지:] 마커 순
|
||||||
|
img_caption = ""
|
||||||
|
norm_images = ctx.get("normalized", {}).get("images", [])
|
||||||
|
if norm_images:
|
||||||
|
img_caption = norm_images[0].get("alt", "")
|
||||||
|
if not img_caption:
|
||||||
|
for line in all_text.split("\n"):
|
||||||
|
stripped = line.strip().lstrip("• ")
|
||||||
|
if stripped.startswith("출처:"):
|
||||||
|
img_caption = re.sub(r'^출처:\s*', '', stripped)
|
||||||
|
break
|
||||||
|
if not img_caption:
|
||||||
|
img_marker = re.search(r'\[이미지:\s*([^\]]+)\]', all_text)
|
||||||
|
if img_marker:
|
||||||
|
img_caption = img_marker.group(1)
|
||||||
|
|
||||||
|
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
||||||
|
|
||||||
|
# 이미지 블록
|
||||||
|
img_block = ""
|
||||||
|
if has_image and img_html:
|
||||||
|
img_block = (
|
||||||
|
f'<div style="width:{img_w}px;flex-shrink:0;">'
|
||||||
|
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
||||||
|
f'{caption_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 제목 — MDX 원본 section 제목 사용
|
||||||
|
topic_title = bold(topic_title_from_section or rn, rn)
|
||||||
|
|
||||||
|
# X'-4: 상단 컨테이너 — 내용을 전체 높이에 균등 배분
|
||||||
|
top_html = (
|
||||||
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||||
|
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
||||||
|
f'{popup_html}'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
||||||
|
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
||||||
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||||
|
f'{img_block}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 하단: normalized.sections에서 직접 매핑
|
||||||
|
# sections 구조: [level=2 상단, level=2 하단대목차, level=3 하단좌, level=3 하단우, ...]
|
||||||
|
# 하단 대목차 = level=2 두 번째
|
||||||
|
# 하단 소목차들 = level=3
|
||||||
|
# 하단: level=3이 존재하는 구역의 level=2가 대목차
|
||||||
|
bottom_title = ""
|
||||||
|
sub_sections_from_norm = [] # [(제목, content)]
|
||||||
|
found_level3 = False
|
||||||
|
for s in norm_sections:
|
||||||
|
if s["level"] == 3:
|
||||||
|
found_level3 = True
|
||||||
|
sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
|
||||||
|
elif s["level"] == 2 and not found_level3:
|
||||||
|
# level=3 전의 level=2는 상단에 속함 → 건너뜀
|
||||||
|
continue
|
||||||
|
elif s["level"] == 2 and found_level3:
|
||||||
|
# level=3 이후의 level=2 → 하단 대목차 후보 (이미 잡혔으면 무시)
|
||||||
|
pass
|
||||||
|
# 하단 대목차: level=3 바로 앞의 level=2
|
||||||
|
for s in norm_sections:
|
||||||
|
if s["level"] == 2:
|
||||||
|
# 이 section 다음에 level=3이 오면 이게 대목차
|
||||||
|
idx = norm_sections.index(s)
|
||||||
|
if idx + 1 < len(norm_sections) and norm_sections[idx + 1]["level"] == 3:
|
||||||
|
bottom_title = s.get("title", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
bl_indent = int(font_size * 1.2)
|
||||||
|
|
||||||
|
# 하단 좌측 = 첫 번째 소목차 (level=3)
|
||||||
|
bl_html = ""
|
||||||
|
if sub_sections_from_norm and bottom_left_role:
|
||||||
|
rn = bottom_left_role[0]
|
||||||
|
sub_title, sub_content = sub_sections_from_norm[0]
|
||||||
|
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
|
||||||
|
|
||||||
|
bullets = ""
|
||||||
|
for line in sub_content.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
# D마커 제거 + depth별 스타일
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
clean = bold(clean, rn)
|
||||||
|
pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bullets += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
bl_html = (
|
||||||
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title, rn)}</div>'
|
||||||
|
f'<div style="line-height:1.55;color:#333;">{bullets}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 하단 우측 = 두 번째 소목차 (level=3) + 표 요약
|
||||||
|
br_html = ""
|
||||||
|
if bottom_right_role and len(sub_sections_from_norm) > 1:
|
||||||
|
rn = bottom_right_role[0]
|
||||||
|
sub_title_br, sub_content_br = sub_sections_from_norm[1]
|
||||||
|
sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)
|
||||||
|
|
||||||
|
content_lines_br = [l.strip() for l in sub_content_br.split("\n") if l.strip()]
|
||||||
|
|
||||||
|
# 팝업 링크 — 소목차 제목으로 팝업 링크 생성
|
||||||
|
popup_html_br = ""
|
||||||
|
popup_link_title = f"{sub_title_br} 바로가기"
|
||||||
|
popup_html_br = (
|
||||||
|
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
||||||
|
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 불릿 — table_summaries가 있으면 표 데이터는 Kei 요약으로 대체되므로 불릿은 간략하게
|
||||||
|
table_summaries = enh.get("table_summaries", {})
|
||||||
|
bullets = ""
|
||||||
|
if not table_summaries:
|
||||||
|
# 표 요약 없으면 content 그대로
|
||||||
|
for line in content_lines_br:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
if clean:
|
||||||
|
clean = bold(clean, rn)
|
||||||
|
pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bullets += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
# X'-6: 본문 표 요약이 있으면 하단 우측에 추가
|
||||||
|
table_summaries = enh.get("table_summaries", {})
|
||||||
|
table_html_br = ""
|
||||||
|
for ts_key, ts_data in table_summaries.items():
|
||||||
|
fmt = ts_data.get("format", "text")
|
||||||
|
if fmt == "table":
|
||||||
|
cols = ts_data.get("columns", [])
|
||||||
|
data = ts_data.get("data", [])
|
||||||
|
col_count = len(cols)
|
||||||
|
if col_count > 0 and data:
|
||||||
|
header_cells = "".join(
|
||||||
|
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.5)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
||||||
|
for c in cols
|
||||||
|
)
|
||||||
|
rows_html = ""
|
||||||
|
for ri, row in enumerate(data):
|
||||||
|
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
|
||||||
|
cells = ""
|
||||||
|
for ci, cell in enumerate(row):
|
||||||
|
c_color = "#1e40af" if ci == 0 else "#475569"
|
||||||
|
c_weight = "600" if ci == 0 else "400"
|
||||||
|
cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{bold(str(cell), rn)}</div>'
|
||||||
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
|
||||||
|
table_html_br = (
|
||||||
|
f'<div style="margin-top:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
|
||||||
|
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
||||||
|
f'{rows_html}</div>'
|
||||||
|
)
|
||||||
|
elif fmt == "bullets":
|
||||||
|
items = ts_data.get("items", [])
|
||||||
|
table_html_br = "".join(
|
||||||
|
f'<div style="padding-left:{int(font_size*1.2)}px;font-size:{font_size-1}px;margin-bottom:1px;">• {bold(str(item), rn)}</div>'
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
elif fmt == "text":
|
||||||
|
table_html_br = f'<div style="font-size:{font_size-1}px;color:#475569;margin-top:{int(font_size*0.5)}px;">{bold(str(ts_data.get("summary", "")), rn)}</div>'
|
||||||
|
|
||||||
|
br_html = (
|
||||||
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||||
|
f'display:flex;flex-direction:column;">'
|
||||||
|
f'{popup_html_br}'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title_br, rn)}</div>'
|
||||||
|
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
|
||||||
|
f'{table_html_br}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 결론
|
||||||
|
footer_html = ""
|
||||||
|
if footer_role:
|
||||||
|
rn, info = footer_role
|
||||||
|
footer_html = (
|
||||||
|
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
||||||
|
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
|
||||||
|
f'display:flex;align-items:center;justify-content:center;">'
|
||||||
|
f'<div style="font-size:{fh.get("key_msg",14)}px;font-weight:700;">{bold(core_message, rn)}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HTML 조립 ──
|
||||||
|
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||||
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||||
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
||||||
|
</style></head><body>
|
||||||
|
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 (유형 B)</div>
|
||||||
|
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
|
||||||
|
{top_html}</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
|
||||||
|
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{bold(bottom_title, "")}</div>
|
||||||
|
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
|
||||||
|
<div style="flex:1;overflow:hidden;">
|
||||||
|
{bl_html}</div>
|
||||||
|
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
|
||||||
|
<div style="flex:1;overflow:hidden;">
|
||||||
|
{br_html}</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;">
|
||||||
|
{footer_html}</div>
|
||||||
|
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
out = run / "steps" / "stage_2_code_assembled.html"
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(html, encoding="utf-8")
|
||||||
|
print(f"저장: {out} ({len(html)} bytes)")
|
||||||
|
|||||||
@@ -22,13 +22,8 @@ async def main(run_dir: str):
|
|||||||
|
|
||||||
# Stage 1B context 로드
|
# Stage 1B context 로드
|
||||||
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
|
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
|
||||||
# MDX 원본: samples에서 직접 읽기 (최신 원본 사용)
|
# MDX 원본: context에서 가져옴 (어떤 MDX든 대응)
|
||||||
samples_dir = Path(__file__).parent.parent / "samples"
|
raw_content = ctx_json.get("raw_content", "")
|
||||||
mdx_file = samples_dir / "mdx" / "01. 건설산업 DX의 올바른 이해(0127).mdx"
|
|
||||||
if mdx_file.exists():
|
|
||||||
raw_content = mdx_file.read_text(encoding="utf-8")
|
|
||||||
else:
|
|
||||||
raw_content = ctx_json.get("raw_content", "")
|
|
||||||
|
|
||||||
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
|
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
|
||||||
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
|
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
|
||||||
@@ -41,9 +36,11 @@ async def main(run_dir: str):
|
|||||||
"page_structure": ps,
|
"page_structure": ps,
|
||||||
"core_message": ctx_json.get("analysis", {}).get("core_message", ""),
|
"core_message": ctx_json.get("analysis", {}).get("core_message", ""),
|
||||||
"title": ctx_json.get("analysis", {}).get("title", ""),
|
"title": ctx_json.get("analysis", {}).get("title", ""),
|
||||||
|
"layout_template": ctx_json.get("analysis", {}).get("layout_template", "A"),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"=== Stage 1B 데이터 고정: {run.name} ===")
|
layout = manual_layout.get("layout_template", "A")
|
||||||
|
print(f"=== Stage 1B 데이터 고정: {run.name} (유형 {layout}) ===")
|
||||||
print(f" topics: {len(ctx_json['topics'])}개")
|
print(f" topics: {len(ctx_json['topics'])}개")
|
||||||
for t in ctx_json["topics"]:
|
for t in ctx_json["topics"]:
|
||||||
print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)")
|
print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)")
|
||||||
@@ -51,8 +48,8 @@ async def main(run_dir: str):
|
|||||||
# pipeline.py의 generate_slide() 호출
|
# pipeline.py의 generate_slide() 호출
|
||||||
from src.pipeline import generate_slide
|
from src.pipeline import generate_slide
|
||||||
|
|
||||||
# 이미지 base_path: samples/images/
|
# 이미지 base_path: context에서 가져옴
|
||||||
base_path = str(samples_dir / "images")
|
base_path = ctx_json.get("base_path", "")
|
||||||
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
|
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
|
||||||
ev_type = event.get("event", "")
|
ev_type = event.get("event", "")
|
||||||
ev_data = event.get("data", "")
|
ev_data = event.get("data", "")
|
||||||
|
|||||||
@@ -367,7 +367,17 @@ def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
|
|||||||
"""전체 슬라이드를 조립하여 HTML 반환.
|
"""전체 슬라이드를 조립하여 HTML 반환.
|
||||||
|
|
||||||
filled, assembled, stage_2 모두 이 함수를 호출.
|
filled, assembled, stage_2 모두 이 함수를 호출.
|
||||||
|
layout_template에 따라 유형 A/B 분기.
|
||||||
"""
|
"""
|
||||||
|
if ctx.analysis.layout_template == "B":
|
||||||
|
return _assemble_slide_html_type_b(ctx, title_text)
|
||||||
|
if ctx.analysis.layout_template == "B'":
|
||||||
|
return _assemble_slide_html_type_b_prime(ctx, title_text)
|
||||||
|
return _assemble_slide_html_type_a(ctx, title_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_slide_html_type_a(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||||
|
"""유형 A 전체 슬라이드 조립 (기존 코드 그대로)."""
|
||||||
from src.fit_verifier import _load_design_tokens
|
from src.fit_verifier import _load_design_tokens
|
||||||
tokens = _load_design_tokens()
|
tokens = _load_design_tokens()
|
||||||
pad = tokens["spacing_page"]
|
pad = tokens["spacing_page"]
|
||||||
@@ -443,3 +453,839 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
|
|||||||
{role_htmls.get("결론", "")}</div>
|
{role_htmls.get("결론", "")}</div>
|
||||||
|
|
||||||
</div></body></html>"""
|
</div></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||||
|
"""유형 B 전체 슬라이드 조립: 상단(top+이미지) + 하단 2분할 + 결론.
|
||||||
|
|
||||||
|
assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합.
|
||||||
|
filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성.
|
||||||
|
"""
|
||||||
|
from src.fit_verifier import _load_design_tokens
|
||||||
|
tokens = _load_design_tokens()
|
||||||
|
pad = tokens["spacing_page"]
|
||||||
|
header_h = tokens.get("header_height", 66)
|
||||||
|
gap_block = tokens["spacing_block"]
|
||||||
|
gap_small = tokens["spacing_small"]
|
||||||
|
slide_w = tokens.get("slide_width", 1280)
|
||||||
|
slide_h = tokens.get("slide_height", 720)
|
||||||
|
inner_w = slide_w - pad * 2
|
||||||
|
|
||||||
|
ps = ctx.page_structure.roles
|
||||||
|
enh = ctx.enhancement_result or {}
|
||||||
|
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||||
|
font_h = ctx.font_hierarchy
|
||||||
|
font_size = font_h.core
|
||||||
|
title = title_text or ctx.analysis.title or ""
|
||||||
|
core_message = ctx.analysis.core_message or ""
|
||||||
|
slide_images = ctx.slide_images or []
|
||||||
|
norm_sections = ctx.normalized.sections or []
|
||||||
|
|
||||||
|
# Kei 에스컬레이션 결정: popup 대상 역할 수집
|
||||||
|
kei_decisions = enh.get("kei_decisions", [])
|
||||||
|
popup_roles = set()
|
||||||
|
for d in kei_decisions:
|
||||||
|
if d.get("action") == "popup":
|
||||||
|
popup_roles.add(d.get("role", ""))
|
||||||
|
|
||||||
|
# ── zone별 역할 분류 ──
|
||||||
|
top_role = None
|
||||||
|
bottom_left_role = None
|
||||||
|
bottom_right_role = None
|
||||||
|
footer_role = None
|
||||||
|
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
zone = info.get("zone", "")
|
||||||
|
if zone == "top":
|
||||||
|
top_role = (role_name, info)
|
||||||
|
elif zone == "bottom_left":
|
||||||
|
bottom_left_role = (role_name, info)
|
||||||
|
elif zone == "bottom_right":
|
||||||
|
bottom_right_role = (role_name, info)
|
||||||
|
elif zone == "footer":
|
||||||
|
footer_role = (role_name, info)
|
||||||
|
|
||||||
|
# ── 좌표 계산 (containers에서 동적으로) ──
|
||||||
|
footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
|
||||||
|
footer_h_px = footer_ci.height_px if footer_ci else 53
|
||||||
|
ft_top = slide_h - pad - footer_h_px
|
||||||
|
|
||||||
|
top_ci = ctx.containers.get(top_role[0]) if top_role else None
|
||||||
|
top_h = top_ci.height_px if top_ci else 200
|
||||||
|
top_top = pad + header_h + gap_block
|
||||||
|
|
||||||
|
# 이미지: block_constraints 또는 slide_images에서 판단
|
||||||
|
img_constraints = top_ci.block_constraints if top_ci else {}
|
||||||
|
img_w = img_constraints.get("img_width_px", 0)
|
||||||
|
has_image = img_constraints.get("has_image", False)
|
||||||
|
# block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용
|
||||||
|
if not has_image and slide_images:
|
||||||
|
has_image = any(img.get("b64") for img in slide_images)
|
||||||
|
if has_image and img_w <= 0:
|
||||||
|
# 이미지 폭: top_h * ratio, 최대 45%
|
||||||
|
first_img = next((img for img in slide_images if img.get("b64")), None)
|
||||||
|
if first_img:
|
||||||
|
img_ratio = first_img.get("ratio", 1)
|
||||||
|
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))
|
||||||
|
|
||||||
|
img_h = 0
|
||||||
|
img_html = ""
|
||||||
|
if has_image and slide_images:
|
||||||
|
for img in slide_images:
|
||||||
|
b64 = img.get("b64", "")
|
||||||
|
if b64:
|
||||||
|
img_ratio = img.get("ratio", 1)
|
||||||
|
img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
|
||||||
|
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||||
|
break
|
||||||
|
|
||||||
|
# 하단
|
||||||
|
bottom_top = top_top + top_h + gap_small
|
||||||
|
|
||||||
|
# V'-4: 결론 바로 위까지 채움
|
||||||
|
fit = ctx.fit_result or {}
|
||||||
|
redist = fit.get("redistribution", {})
|
||||||
|
column_bottom = ft_top - gap_block
|
||||||
|
bottom_h = column_bottom - bottom_top
|
||||||
|
bottom_col_w = (inner_w - gap_block) // 2
|
||||||
|
|
||||||
|
# ── 유틸 ──
|
||||||
|
def _bold(text: str, role: str) -> str:
|
||||||
|
for kw in bold_kw.get(role, []):
|
||||||
|
if kw in text:
|
||||||
|
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# ── 상단 조립: normalized.sections에서 직접 가져오기 ──
|
||||||
|
top_html = ""
|
||||||
|
if top_role:
|
||||||
|
rn = top_role[0]
|
||||||
|
topic_title_from_section = ""
|
||||||
|
top_contents = []
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 3:
|
||||||
|
break # level=3(소목차) 나오면 상단 끝
|
||||||
|
if not topic_title_from_section and s.get("title"):
|
||||||
|
topic_title_from_section = s["title"]
|
||||||
|
content = s.get("content", "")
|
||||||
|
if content:
|
||||||
|
if s.get("title") and s["title"] != topic_title_from_section:
|
||||||
|
top_contents.append(f"### {s['title']}")
|
||||||
|
top_contents.append(content)
|
||||||
|
all_text = "\n".join(top_contents)
|
||||||
|
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
|
||||||
|
|
||||||
|
# 팝업 분리
|
||||||
|
popup_titles = []
|
||||||
|
content_lines = []
|
||||||
|
for line in all_text_clean.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||||
|
if popup_match:
|
||||||
|
popup_titles.append(popup_match.group(1))
|
||||||
|
continue
|
||||||
|
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
|
||||||
|
continue
|
||||||
|
content_lines.append(stripped)
|
||||||
|
|
||||||
|
popup_html = _popup_links_html(popup_titles, font_size)
|
||||||
|
|
||||||
|
# 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
|
||||||
|
sections = []
|
||||||
|
current_section = ("", [])
|
||||||
|
for line in content_lines:
|
||||||
|
if line.startswith("### ") or line.startswith("###"):
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (line.lstrip("# ").strip(), [])
|
||||||
|
elif re.match(r'^D1:\s*', line):
|
||||||
|
# D1 = 1단 불릿 = 소제목 (카드 제목)
|
||||||
|
title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (_bold(title_text, rn), [])
|
||||||
|
elif re.match(r'^D[2-9]:\s*', line):
|
||||||
|
# D2+ = 하위 불릿 = 본문
|
||||||
|
clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(_bold(clean, rn))
|
||||||
|
else:
|
||||||
|
clean = line.lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(_bold(clean, rn))
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
|
||||||
|
# 카드형 HTML
|
||||||
|
_card_colors = [
|
||||||
|
("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
|
||||||
|
]
|
||||||
|
card_pad = int(font_size * 0.6)
|
||||||
|
card_gap = max(3, int(font_size * 0.4))
|
||||||
|
indent_body = int(font_size * 1.2)
|
||||||
|
|
||||||
|
bullets = ""
|
||||||
|
if len(sections) > 1 and sections[0][0]:
|
||||||
|
for ci, (sec_title, sec_items) in enumerate(sections):
|
||||||
|
bg, text_color = _card_colors[ci % len(_card_colors)]
|
||||||
|
items_html = "".join(
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
|
||||||
|
for item in sec_items
|
||||||
|
)
|
||||||
|
if sec_title:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
|
||||||
|
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
|
||||||
|
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
|
||||||
|
f'margin-bottom:{int(font_size*0.3)}px;">{_bold(sec_title, rn)}</div>'
|
||||||
|
f'{items_html}</div>\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bullets += items_html
|
||||||
|
else:
|
||||||
|
for _, sec_items in sections:
|
||||||
|
for item in sec_items:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="font-size:{font_size}px;">• {item}</span></div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 이미지 캡션
|
||||||
|
img_caption = ""
|
||||||
|
norm_images = ctx.normalized.images or []
|
||||||
|
if norm_images:
|
||||||
|
img_caption = norm_images[0].get("alt", "")
|
||||||
|
if not img_caption:
|
||||||
|
for line in all_text.split("\n"):
|
||||||
|
stripped = line.strip().lstrip("• ")
|
||||||
|
if stripped.startswith("출처:"):
|
||||||
|
img_caption = re.sub(r'^출처:\s*', '', stripped)
|
||||||
|
break
|
||||||
|
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
||||||
|
|
||||||
|
# 이미지 블록
|
||||||
|
img_block = ""
|
||||||
|
if has_image and img_html:
|
||||||
|
img_block = (
|
||||||
|
f'<div style="width:{img_w}px;flex-shrink:0;">'
|
||||||
|
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
||||||
|
f'{caption_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_title = _bold(topic_title_from_section or rn, rn)
|
||||||
|
|
||||||
|
top_html = (
|
||||||
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||||
|
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
||||||
|
f'{popup_html}'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
||||||
|
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
||||||
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||||
|
f'{img_block}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 하단: normalized.sections에서 직접 매핑 ──
|
||||||
|
bottom_title = ""
|
||||||
|
sub_sections_from_norm = []
|
||||||
|
found_level3 = False
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 3:
|
||||||
|
found_level3 = True
|
||||||
|
sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
|
||||||
|
# 하단 대목차: level=3 바로 앞의 level=2
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 2:
|
||||||
|
idx = norm_sections.index(s)
|
||||||
|
if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
|
||||||
|
bottom_title = s.get("title", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
bl_indent = int(font_size * 1.2)
|
||||||
|
|
||||||
|
# 하단 좌측
|
||||||
|
bl_html = ""
|
||||||
|
if sub_sections_from_norm and bottom_left_role:
|
||||||
|
rn = bottom_left_role[0]
|
||||||
|
sub_title, sub_content = sub_sections_from_norm[0]
|
||||||
|
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
|
||||||
|
|
||||||
|
bul = ""
|
||||||
|
for line in sub_content.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
# D마커 제거 + depth별 스타일
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
clean = _bold(clean, rn)
|
||||||
|
pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bul += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
bl_html = (
|
||||||
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
|
||||||
|
f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 하단 우측 + 표 요약
|
||||||
|
br_html = ""
|
||||||
|
if bottom_right_role and len(sub_sections_from_norm) > 1:
|
||||||
|
rn = bottom_right_role[0]
|
||||||
|
sub_title_br, sub_content_br = sub_sections_from_norm[1]
|
||||||
|
sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)
|
||||||
|
|
||||||
|
# 팝업 링크
|
||||||
|
popup_link_title = f"{sub_title_br} 바로가기"
|
||||||
|
popup_html_br = (
|
||||||
|
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
||||||
|
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kei가 이 역할을 popup 대상으로 결정했으면 → 콘텐츠 대신 팝업 링크만
|
||||||
|
if rn in popup_roles:
|
||||||
|
bul = (
|
||||||
|
f'<div style="padding:{gap_small}px;text-align:center;color:#64748b;'
|
||||||
|
f'font-size:{font_size}px;margin-top:{gap_small*2}px;">'
|
||||||
|
f'상세 내용은 팝업에서 확인</div>'
|
||||||
|
)
|
||||||
|
table_summaries = {} # 표도 팝업으로 이동
|
||||||
|
else:
|
||||||
|
# 불릿
|
||||||
|
table_summaries = enh.get("table_summaries", {})
|
||||||
|
bul = ""
|
||||||
|
if not table_summaries:
|
||||||
|
for line in sub_content_br.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
if clean:
|
||||||
|
clean = _bold(clean, rn)
|
||||||
|
_pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bul += f'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
# 표 요약 HTML
|
||||||
|
table_html_br = ""
|
||||||
|
for ts_key, ts_data in table_summaries.items():
|
||||||
|
fmt = ts_data.get("format", "text")
|
||||||
|
if fmt == "table":
|
||||||
|
cols = ts_data.get("columns", [])
|
||||||
|
data = ts_data.get("data", [])
|
||||||
|
col_count = len(cols)
|
||||||
|
if col_count > 0 and data:
|
||||||
|
header_cells = "".join(
|
||||||
|
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.5)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
||||||
|
for c in cols
|
||||||
|
)
|
||||||
|
rows_html = ""
|
||||||
|
for ri, row in enumerate(data):
|
||||||
|
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
|
||||||
|
cells = ""
|
||||||
|
for ci_idx, cell in enumerate(row):
|
||||||
|
c_color = "#1e40af" if ci_idx == 0 else "#475569"
|
||||||
|
c_weight = "600" if ci_idx == 0 else "400"
|
||||||
|
cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{_bold(str(cell), rn)}</div>'
|
||||||
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
|
||||||
|
table_html_br = (
|
||||||
|
f'<div style="margin-top:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
|
||||||
|
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
||||||
|
f'{rows_html}</div>'
|
||||||
|
)
|
||||||
|
elif fmt == "bullets":
|
||||||
|
items = ts_data.get("items", [])
|
||||||
|
table_html_br = "".join(
|
||||||
|
f'<div style="padding-left:{int(font_size*1.2)}px;font-size:{font_size-1}px;margin-bottom:1px;">• {_bold(str(item), rn)}</div>'
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
elif fmt == "text":
|
||||||
|
table_html_br = f'<div style="font-size:{font_size-1}px;color:#475569;margin-top:{int(font_size*0.5)}px;">{_bold(str(ts_data.get("summary", "")), rn)}</div>'
|
||||||
|
|
||||||
|
br_html = (
|
||||||
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||||
|
f'display:flex;flex-direction:column;">'
|
||||||
|
f'{popup_html_br}'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title_br, rn)}</div>'
|
||||||
|
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bul}</div>'
|
||||||
|
f'{table_html_br}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 결론 ──
|
||||||
|
footer_html = ""
|
||||||
|
if footer_role:
|
||||||
|
rn = footer_role[0]
|
||||||
|
footer_html = (
|
||||||
|
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
||||||
|
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
|
||||||
|
f'display:flex;align-items:center;justify-content:center;">'
|
||||||
|
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HTML 조립 ──
|
||||||
|
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||||
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||||
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
||||||
|
|
||||||
|
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
|
||||||
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[0]};opacity:0.5;">상단 ({inner_w}x{top_h}px)</span>
|
||||||
|
{top_html}</div>
|
||||||
|
|
||||||
|
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
|
||||||
|
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{_bold(bottom_title, "")}</div>
|
||||||
|
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
|
||||||
|
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
|
||||||
|
{bl_html}</div>
|
||||||
|
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
|
||||||
|
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
|
||||||
|
{br_html}</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
|
||||||
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[3]};opacity:0.5;">결론 ({inner_w}x{footer_h_px}px)</span>
|
||||||
|
{footer_html}</div>
|
||||||
|
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_slide_html_type_b_prime(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||||
|
"""유형 B' 전체 슬라이드 조립: 상단(세로 카드) + 하단 2분할 + 결론. (03번용)
|
||||||
|
|
||||||
|
assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합.
|
||||||
|
filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성.
|
||||||
|
"""
|
||||||
|
from src.fit_verifier import _load_design_tokens
|
||||||
|
tokens = _load_design_tokens()
|
||||||
|
pad = tokens["spacing_page"]
|
||||||
|
header_h = tokens.get("header_height", 66)
|
||||||
|
gap_block = tokens["spacing_block"]
|
||||||
|
gap_small = tokens["spacing_small"]
|
||||||
|
slide_w = tokens.get("slide_width", 1280)
|
||||||
|
slide_h = tokens.get("slide_height", 720)
|
||||||
|
inner_w = slide_w - pad * 2
|
||||||
|
|
||||||
|
ps = ctx.page_structure.roles
|
||||||
|
enh = ctx.enhancement_result or {}
|
||||||
|
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||||
|
font_h = ctx.font_hierarchy
|
||||||
|
font_size = font_h.core
|
||||||
|
title = title_text or ctx.analysis.title or ""
|
||||||
|
core_message = ctx.analysis.core_message or ""
|
||||||
|
slide_images = ctx.slide_images or []
|
||||||
|
norm_sections = ctx.normalized.sections or []
|
||||||
|
|
||||||
|
# Kei 에스컬레이션 결정: popup 대상 역할 수집
|
||||||
|
kei_decisions = enh.get("kei_decisions", [])
|
||||||
|
popup_roles = set()
|
||||||
|
for d in kei_decisions:
|
||||||
|
if d.get("action") == "popup":
|
||||||
|
popup_roles.add(d.get("role", ""))
|
||||||
|
|
||||||
|
# ── zone별 역할 분류 ──
|
||||||
|
top_role = None
|
||||||
|
bottom_left_role = None
|
||||||
|
bottom_right_role = None
|
||||||
|
footer_role = None
|
||||||
|
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
zone = info.get("zone", "")
|
||||||
|
if zone == "top":
|
||||||
|
top_role = (role_name, info)
|
||||||
|
elif zone == "bottom_left":
|
||||||
|
bottom_left_role = (role_name, info)
|
||||||
|
elif zone == "bottom_right":
|
||||||
|
bottom_right_role = (role_name, info)
|
||||||
|
elif zone == "footer":
|
||||||
|
footer_role = (role_name, info)
|
||||||
|
|
||||||
|
# ── 좌표 계산 (containers에서 동적으로) ──
|
||||||
|
footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
|
||||||
|
footer_h_px = footer_ci.height_px if footer_ci else 53
|
||||||
|
ft_top = slide_h - pad - footer_h_px
|
||||||
|
|
||||||
|
top_ci = ctx.containers.get(top_role[0]) if top_role else None
|
||||||
|
top_h = top_ci.height_px if top_ci else 200
|
||||||
|
top_top = pad + header_h + gap_block
|
||||||
|
|
||||||
|
# 이미지: block_constraints 또는 slide_images에서 판단
|
||||||
|
img_constraints = top_ci.block_constraints if top_ci else {}
|
||||||
|
img_w = img_constraints.get("img_width_px", 0)
|
||||||
|
has_image = img_constraints.get("has_image", False)
|
||||||
|
# block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용
|
||||||
|
if not has_image and slide_images:
|
||||||
|
has_image = any(img.get("b64") for img in slide_images)
|
||||||
|
if has_image and img_w <= 0:
|
||||||
|
# 이미지 폭: top_h * ratio, 최대 45%
|
||||||
|
first_img = next((img for img in slide_images if img.get("b64")), None)
|
||||||
|
if first_img:
|
||||||
|
img_ratio = first_img.get("ratio", 1)
|
||||||
|
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))
|
||||||
|
|
||||||
|
img_h = 0
|
||||||
|
img_html = ""
|
||||||
|
if has_image and slide_images:
|
||||||
|
for img in slide_images:
|
||||||
|
b64 = img.get("b64", "")
|
||||||
|
if b64:
|
||||||
|
img_ratio = img.get("ratio", 1)
|
||||||
|
img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
|
||||||
|
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||||
|
break
|
||||||
|
|
||||||
|
# 하단
|
||||||
|
bottom_top = top_top + top_h + gap_small
|
||||||
|
|
||||||
|
# V'-4: 결론 바로 위까지 채움
|
||||||
|
fit = ctx.fit_result or {}
|
||||||
|
redist = fit.get("redistribution", {})
|
||||||
|
column_bottom = ft_top - gap_block
|
||||||
|
bottom_h = column_bottom - bottom_top
|
||||||
|
bottom_col_w = (inner_w - gap_block) // 2
|
||||||
|
|
||||||
|
# ── 유틸 ──
|
||||||
|
def _bold(text: str, role: str) -> str:
|
||||||
|
for kw in bold_kw.get(role, []):
|
||||||
|
if kw in text:
|
||||||
|
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# ── 상단 조립: normalized.sections에서 직접 가져오기 ──
|
||||||
|
top_html = ""
|
||||||
|
if top_role:
|
||||||
|
rn = top_role[0]
|
||||||
|
topic_title_from_section = ""
|
||||||
|
top_contents = []
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 3:
|
||||||
|
break # level=3(소목차) 나오면 상단 끝
|
||||||
|
if not topic_title_from_section and s.get("title"):
|
||||||
|
topic_title_from_section = s["title"]
|
||||||
|
content = s.get("content", "")
|
||||||
|
if content:
|
||||||
|
if s.get("title") and s["title"] != topic_title_from_section:
|
||||||
|
top_contents.append(f"### {s['title']}")
|
||||||
|
top_contents.append(content)
|
||||||
|
all_text = "\n".join(top_contents)
|
||||||
|
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
|
||||||
|
|
||||||
|
# 팝업 분리
|
||||||
|
popup_titles = []
|
||||||
|
content_lines = []
|
||||||
|
for line in all_text_clean.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||||
|
if popup_match:
|
||||||
|
popup_titles.append(popup_match.group(1))
|
||||||
|
continue
|
||||||
|
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
|
||||||
|
continue
|
||||||
|
content_lines.append(stripped)
|
||||||
|
|
||||||
|
popup_html = _popup_links_html(popup_titles, font_size)
|
||||||
|
|
||||||
|
# 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
|
||||||
|
sections = []
|
||||||
|
current_section = ("", [])
|
||||||
|
for line in content_lines:
|
||||||
|
if line.startswith("### ") or line.startswith("###"):
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (line.lstrip("# ").strip(), [])
|
||||||
|
elif re.match(r'^D1:\s*', line):
|
||||||
|
# D1 = 1단 불릿 = 소제목 (카드 제목)
|
||||||
|
title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
current_section = (_bold(title_text, rn), [])
|
||||||
|
elif re.match(r'^D[2-9]:\s*', line):
|
||||||
|
# D2+ = 하위 불릿 = 본문
|
||||||
|
clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(_bold(clean, rn))
|
||||||
|
else:
|
||||||
|
clean = line.lstrip("• ")
|
||||||
|
if clean.startswith("출처:"):
|
||||||
|
continue
|
||||||
|
current_section[1].append(_bold(clean, rn))
|
||||||
|
if current_section[0] or current_section[1]:
|
||||||
|
sections.append(current_section)
|
||||||
|
|
||||||
|
# 카드형 HTML
|
||||||
|
_card_colors = [
|
||||||
|
("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
|
||||||
|
("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
|
||||||
|
]
|
||||||
|
card_pad = int(font_size * 0.6)
|
||||||
|
card_gap = max(3, int(font_size * 0.4))
|
||||||
|
indent_body = int(font_size * 1.2)
|
||||||
|
|
||||||
|
# B': 상단이 popup 대상이면 소제목만 유지, 하위 불릿 제거
|
||||||
|
top_is_popup = rn in popup_roles
|
||||||
|
|
||||||
|
bullets = ""
|
||||||
|
if len(sections) > 1 and sections[0][0]:
|
||||||
|
for ci, (sec_title, sec_items) in enumerate(sections):
|
||||||
|
bg, text_color = _card_colors[ci % len(_card_colors)]
|
||||||
|
if top_is_popup:
|
||||||
|
items_html = ""
|
||||||
|
else:
|
||||||
|
items_html = "".join(
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
|
||||||
|
for item in sec_items
|
||||||
|
)
|
||||||
|
if sec_title:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
|
||||||
|
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
|
||||||
|
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
|
||||||
|
f'margin-bottom:{int(font_size*0.3)}px;">{_bold(sec_title, rn)}</div>'
|
||||||
|
f'{items_html}</div>\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bullets += items_html
|
||||||
|
else:
|
||||||
|
for _, sec_items in sections:
|
||||||
|
for item in sec_items:
|
||||||
|
bullets += (
|
||||||
|
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
|
||||||
|
f'<span style="font-size:{font_size}px;">• {item}</span></div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 이미지 캡션
|
||||||
|
img_caption = ""
|
||||||
|
norm_images = ctx.normalized.images or []
|
||||||
|
if norm_images:
|
||||||
|
img_caption = norm_images[0].get("alt", "")
|
||||||
|
if not img_caption:
|
||||||
|
for line in all_text.split("\n"):
|
||||||
|
stripped = line.strip().lstrip("• ")
|
||||||
|
if stripped.startswith("출처:"):
|
||||||
|
img_caption = re.sub(r'^출처:\s*', '', stripped)
|
||||||
|
break
|
||||||
|
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
||||||
|
|
||||||
|
# 이미지 블록
|
||||||
|
img_block = ""
|
||||||
|
if has_image and img_html:
|
||||||
|
img_block = (
|
||||||
|
f'<div style="width:{img_w}px;flex-shrink:0;">'
|
||||||
|
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
||||||
|
f'{caption_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_title = _bold(topic_title_from_section or rn, rn)
|
||||||
|
|
||||||
|
top_html = (
|
||||||
|
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
|
||||||
|
f'display:flex;flex-direction:column;justify-content:space-between;">'
|
||||||
|
f'{popup_html}'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
|
||||||
|
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
|
||||||
|
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||||
|
f'{img_block}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 하단: normalized.sections에서 직접 매핑 ──
|
||||||
|
bottom_title = ""
|
||||||
|
sub_sections_from_norm = []
|
||||||
|
found_level3 = False
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 3:
|
||||||
|
found_level3 = True
|
||||||
|
sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
|
||||||
|
# 하단 대목차: level=3 바로 앞의 level=2
|
||||||
|
for s in norm_sections:
|
||||||
|
if s.get("level") == 2:
|
||||||
|
idx = norm_sections.index(s)
|
||||||
|
if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
|
||||||
|
bottom_title = s.get("title", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
bl_indent = int(font_size * 1.2)
|
||||||
|
|
||||||
|
# 하단 좌측 — B': normalized.tables가 있으면 표로 렌더링
|
||||||
|
norm_tables = ctx.normalized.tables or []
|
||||||
|
bl_html = ""
|
||||||
|
if sub_sections_from_norm and bottom_left_role:
|
||||||
|
rn = bottom_left_role[0]
|
||||||
|
sub_title, sub_content = sub_sections_from_norm[0]
|
||||||
|
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
|
||||||
|
|
||||||
|
# 표 렌더링 (normalized.tables에서)
|
||||||
|
table_html_bl = ""
|
||||||
|
if norm_tables:
|
||||||
|
for table_data in norm_tables:
|
||||||
|
headers = table_data.get("headers", [])
|
||||||
|
rows = table_data.get("rows", [])
|
||||||
|
col_count = len(headers)
|
||||||
|
if col_count > 0 and rows:
|
||||||
|
header_cells = "".join(
|
||||||
|
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
||||||
|
for c in headers
|
||||||
|
)
|
||||||
|
rows_html = ""
|
||||||
|
for ri, row in enumerate(rows):
|
||||||
|
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
|
||||||
|
cells = ""
|
||||||
|
for ci_idx, cell in enumerate(row):
|
||||||
|
cell_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', str(cell))
|
||||||
|
c_color = "#1e40af" if ci_idx == 0 else "#475569"
|
||||||
|
c_weight = "600" if ci_idx == 0 else "400"
|
||||||
|
cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.3)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{cell_clean}</div>'
|
||||||
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
|
||||||
|
|
||||||
|
table_html_bl = (
|
||||||
|
f'<div style="margin-bottom:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
|
||||||
|
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
||||||
|
f'{rows_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 불릿: 표 셀과 중복되는 텍스트 제외
|
||||||
|
table_cell_texts = set()
|
||||||
|
for td in norm_tables:
|
||||||
|
for h in td.get("headers", []):
|
||||||
|
table_cell_texts.add(h.strip().lstrip("*").rstrip("*"))
|
||||||
|
for row in td.get("rows", []):
|
||||||
|
for cell in row:
|
||||||
|
table_cell_texts.add(str(cell).strip().lstrip("*").rstrip("*"))
|
||||||
|
|
||||||
|
bul = ""
|
||||||
|
for line in sub_content.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
|
||||||
|
if clean_plain in table_cell_texts or clean_plain == "➠":
|
||||||
|
continue
|
||||||
|
if clean:
|
||||||
|
clean = _bold(clean, rn)
|
||||||
|
_pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bul += f'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
bl_html = (
|
||||||
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;overflow-y:auto;">'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
|
||||||
|
f'{table_html_bl}'
|
||||||
|
f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 하단 우측 — B': 불릿만 (table_summaries 사용 안 함)
|
||||||
|
br_html = ""
|
||||||
|
if bottom_right_role and len(sub_sections_from_norm) > 1:
|
||||||
|
rn = bottom_right_role[0]
|
||||||
|
sub_title_br, sub_content_br = sub_sections_from_norm[1]
|
||||||
|
sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)
|
||||||
|
|
||||||
|
bul = ""
|
||||||
|
for line in sub_content_br.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
depth = 1
|
||||||
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
|
if dm:
|
||||||
|
depth = int(dm.group(1))
|
||||||
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
clean = stripped.lstrip("- ").lstrip("• ")
|
||||||
|
if clean:
|
||||||
|
clean = _bold(clean, rn)
|
||||||
|
_pad = bl_indent * depth
|
||||||
|
fs = font_size if depth == 1 else font_size - 1
|
||||||
|
weight = "font-weight:600;" if depth == 1 else ""
|
||||||
|
bul += f'<div style="padding-left:{_pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
|
||||||
|
|
||||||
|
br_html = (
|
||||||
|
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
|
||||||
|
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title_br, rn)}</div>'
|
||||||
|
f'<div style="line-height:1.55;color:#333;flex:1;">{bul}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 결론 ──
|
||||||
|
footer_html = ""
|
||||||
|
if footer_role:
|
||||||
|
rn = footer_role[0]
|
||||||
|
footer_html = (
|
||||||
|
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
||||||
|
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
|
||||||
|
f'display:flex;align-items:center;justify-content:center;">'
|
||||||
|
f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HTML 조립 ──
|
||||||
|
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||||
|
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||||
|
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
||||||
|
|
||||||
|
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
|
||||||
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[0]};opacity:0.5;">상단 ({inner_w}x{top_h}px)</span>
|
||||||
|
{top_html}</div>
|
||||||
|
|
||||||
|
<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
|
||||||
|
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{_bold(bottom_title, "")}</div>
|
||||||
|
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
|
||||||
|
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
|
||||||
|
{bl_html}</div>
|
||||||
|
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
|
||||||
|
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
|
||||||
|
{br_html}</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
|
||||||
|
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[3]};opacity:0.5;">결론 ({inner_w}x{footer_h_px}px)</span>
|
||||||
|
{footer_html}</div>
|
||||||
|
|
||||||
|
</div></body></html>"""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -501,10 +501,18 @@ def redistribute(
|
|||||||
"""부족 영역에 여유 영역의 공간을 재배분.
|
"""부족 영역에 여유 영역의 공간을 재배분.
|
||||||
|
|
||||||
같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심).
|
같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심).
|
||||||
|
유형 B: containers의 zone 속성에서 동적으로 매핑.
|
||||||
"""
|
"""
|
||||||
zone_roles: dict[str, list[str]] = {}
|
zone_roles: dict[str, list[str]] = {}
|
||||||
for role in analysis.roles:
|
for role in analysis.roles:
|
||||||
zone = ROLE_ZONE_MAP.get(role, "body")
|
# containers에 zone 정보가 있으면 그걸 사용, 없으면 ROLE_ZONE_MAP fallback
|
||||||
|
ci = containers.get(role)
|
||||||
|
if ci is not None:
|
||||||
|
zone = ci.get("zone") if isinstance(ci, dict) else getattr(ci, "zone", None)
|
||||||
|
else:
|
||||||
|
zone = None
|
||||||
|
if not zone:
|
||||||
|
zone = ROLE_ZONE_MAP.get(role, "body")
|
||||||
if zone not in zone_roles:
|
if zone not in zone_roles:
|
||||||
zone_roles[zone] = []
|
zone_roles[zone] = []
|
||||||
zone_roles[zone].append(role)
|
zone_roles[zone].append(role)
|
||||||
|
|||||||
+91
-39
@@ -30,23 +30,43 @@ KEI_PROMPT = (
|
|||||||
"## 3단계: 슬라이드 스토리라인 설계\n"
|
"## 3단계: 슬라이드 스토리라인 설계\n"
|
||||||
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
||||||
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
||||||
"## 4단계: 페이지 구조 판단 (비중 시스템)\n"
|
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
|
||||||
"콘텐츠를 분석하여 이 페이지의 **구조와 비중**을 판단하라:\n\n"
|
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
|
||||||
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.\n"
|
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
|
||||||
" 비교라면 비교표, 관계라면 관계도, 프로세스라면 흐름도로 구조화.\n"
|
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
|
||||||
" 비교 구조일 때 비교 목적(왜 비교하는가)을 summary에 명시.\n"
|
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
|
||||||
"- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게. 2-3줄이면 충분.\n"
|
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
|
||||||
"- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.\n"
|
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
|
||||||
" role: 'reference'로 표시. 본문 흐름을 방해하지 않도록.\n"
|
"- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
|
||||||
"- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.\n\n"
|
"- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
|
||||||
|
"- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
|
||||||
|
"- 하단: 세부 내용 2분할 (좌/우)\n"
|
||||||
|
"- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
|
||||||
|
"- 결론 키는 반드시 '결론'\n\n"
|
||||||
|
"선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
|
||||||
|
"### 역할별 규칙 (유형 A)\n"
|
||||||
|
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
|
||||||
|
"- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
|
||||||
|
"- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
|
||||||
|
"- **결론**: 핵심 한 줄. footer.\n\n"
|
||||||
|
"### 역할별 규칙 (유형 B)\n"
|
||||||
|
"- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
|
||||||
|
"- 하단 좌측: zone: 'bottom_left'\n"
|
||||||
|
"- 하단 우측: zone: 'bottom_right'\n"
|
||||||
|
"- 결론: zone: 'footer'\n\n"
|
||||||
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
|
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
|
||||||
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
||||||
"page_structure 필드에 기록.\n\n"
|
"page_structure 필드에 기록.\n\n"
|
||||||
"## 원본 텍스트 보존 원칙\n"
|
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
|
||||||
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
|
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
|
||||||
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
|
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
|
||||||
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n"
|
" 임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n"
|
||||||
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시\n\n"
|
"- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n"
|
||||||
|
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
|
||||||
|
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
|
||||||
|
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
|
||||||
|
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
|
||||||
|
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
|
||||||
"## 배치 규칙\n"
|
"## 배치 규칙\n"
|
||||||
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
||||||
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
||||||
@@ -56,11 +76,13 @@ KEI_PROMPT = (
|
|||||||
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
|
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
|
||||||
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
|
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
|
||||||
"## 출력 형식 (JSON만)\n"
|
"## 출력 형식 (JSON만)\n"
|
||||||
|
"layout_template에 따라 page_structure가 달라진다.\n\n"
|
||||||
|
"유형 A 예시:\n"
|
||||||
"```json\n"
|
"```json\n"
|
||||||
'{"title": "제목", '
|
'{"title": "제목", '
|
||||||
'"core_message": "이 슬라이드의 핵심 메시지 한 줄", '
|
'"core_message": "핵심 메시지", '
|
||||||
'"total_pages": 1, '
|
'"total_pages": 1, '
|
||||||
'"info_structure": "정보 구조 설명", '
|
'"layout_template": "A", '
|
||||||
'"page_structure": {'
|
'"page_structure": {'
|
||||||
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
|
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
|
||||||
'"배경": {"topic_ids": [1], "weight": 0.15}, '
|
'"배경": {"topic_ids": [1], "weight": 0.15}, '
|
||||||
@@ -79,6 +101,20 @@ KEI_PROMPT = (
|
|||||||
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
|
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
|
||||||
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
|
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
|
||||||
"```\n\n"
|
"```\n\n"
|
||||||
|
"유형 B 예시:\n"
|
||||||
|
"```json\n"
|
||||||
|
'{"title": "제목", '
|
||||||
|
'"core_message": "핵심 메시지", '
|
||||||
|
'"total_pages": 1, '
|
||||||
|
'"layout_template": "B", '
|
||||||
|
'"page_structure": {'
|
||||||
|
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, '
|
||||||
|
'"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, '
|
||||||
|
'"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, '
|
||||||
|
'"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, '
|
||||||
|
'"topics": [...],'
|
||||||
|
'"images": [...]}\n'
|
||||||
|
"```\n\n"
|
||||||
"## 콘텐츠:\n"
|
"## 콘텐츠:\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,14 +263,20 @@ async def refine_concepts(
|
|||||||
KEI_STRUCTURED_TEXT_PROMPT = (
|
KEI_STRUCTURED_TEXT_PROMPT = (
|
||||||
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
|
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
|
||||||
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
|
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
|
||||||
"## 규칙\n"
|
"## 절대 규칙\n"
|
||||||
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
|
"1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n"
|
||||||
"2. 각 문장을 불릿(•)으로 구분하라.\n"
|
" 원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
|
||||||
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
|
" → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
|
||||||
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
|
" ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n"
|
||||||
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
|
"2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
|
||||||
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
|
"3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n"
|
||||||
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
|
" 원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n"
|
||||||
|
"4. 각 문장을 불릿(•)으로 구분하라.\n"
|
||||||
|
"5. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
|
||||||
|
"6. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
|
||||||
|
"7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
|
||||||
|
"8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
|
||||||
|
"9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
|
||||||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||||||
"```json\n"
|
"```json\n"
|
||||||
'{"structured_texts": ['
|
'{"structured_texts": ['
|
||||||
@@ -1313,25 +1355,32 @@ JSON으로 응답하라:
|
|||||||
|
|
||||||
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
||||||
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
|
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
|
||||||
재배분을 시도했지만 해결되지 않은 영역이 있다.
|
|
||||||
|
|
||||||
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.
|
## 핵심 원칙
|
||||||
|
- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.**
|
||||||
|
- 공간이 부족하면 **하위 불릿(상세 설명)만 팝업으로 분리.**
|
||||||
|
- **소제목(카드 제목)은 반드시 슬라이드에 유지.** 절대 팝업으로 빼지 않는다.
|
||||||
|
- 슬라이드에는 소제목 + "바로가기 →" 링크. 팝업에 하위 불릿 원문 전체.
|
||||||
|
- overflow가 없는 영역은 건드리지 않는다.
|
||||||
|
|
||||||
## 판단 기준
|
## 판단 기준
|
||||||
- 핵심 메시지(본심)의 공간은 최대한 보장
|
- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
|
||||||
- 배경은 보조 역할 — 간결화 가능
|
- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
|
||||||
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
|
- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
|
||||||
- 용어 정의는 sidebar에 맞게 조정 가능
|
- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
|
||||||
|
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
|
||||||
|
|
||||||
## 출력 (JSON만. 설명 없이.)
|
## 출력 (JSON만. 설명 없이.)
|
||||||
|
- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라.
|
||||||
|
- overflow가 발생한 역할만 포함. overflow 없는 역할은 포함하지 마라.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"decisions": [
|
"decisions": [
|
||||||
{
|
{
|
||||||
"role": "배경",
|
"role": "역할 목록에 있는 정확한 역할명",
|
||||||
"action": "merge|inline|popup|trim|restructure",
|
"action": "popup",
|
||||||
"detail": "구체적 지시 (어떤 꼭지를 어떻게)",
|
"detail": "팝업으로 분리할 구체적 내용 (하위 불릿만. 소제목은 유지)",
|
||||||
"reason": "판단 근거 1문장"
|
"reason": "판단 근거 1문장"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1339,11 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
|||||||
```
|
```
|
||||||
|
|
||||||
action 종류:
|
action 종류:
|
||||||
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
|
- popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지.
|
||||||
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
|
|
||||||
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
|
|
||||||
- trim: 텍스트 분량을 줄임 (max_chars 지정)
|
|
||||||
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -1351,6 +1396,7 @@ async def call_kei_fit_escalation(
|
|||||||
fit_report: str,
|
fit_report: str,
|
||||||
topics: list[dict],
|
topics: list[dict],
|
||||||
content_summary: str,
|
content_summary: str,
|
||||||
|
role_names: list[str] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
|
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
|
||||||
|
|
||||||
@@ -1372,10 +1418,16 @@ async def call_kei_fit_escalation(
|
|||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 실제 역할명 목록을 prompt에 명시 (Kei가 정확한 역할명을 사용하도록)
|
||||||
|
role_list_text = ""
|
||||||
|
if role_names:
|
||||||
|
role_list_text = f"\n## 역할 목록 (role에 반드시 아래 이름을 사용)\n" + "\n".join(f"- {r}" for r in role_names)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
KEI_FIT_ESCALATION_PROMPT + "\n\n"
|
KEI_FIT_ESCALATION_PROMPT + "\n\n"
|
||||||
f"## 적합성 검증 결과\n{fit_report}\n\n"
|
f"## 적합성 검증 결과\n{fit_report}\n\n"
|
||||||
f"## 꼭지 목록\n{topics_desc}\n\n"
|
f"## 꼭지 목록\n{topics_desc}"
|
||||||
|
f"{role_list_text}\n\n"
|
||||||
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
|
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+79
-13
@@ -73,6 +73,44 @@ class _CodeBlockProtector:
|
|||||||
# Layer 2: MDX 전용 패턴 처리
|
# Layer 2: MDX 전용 패턴 처리
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
|
def _convert_md_list_to_html(text: str) -> str:
|
||||||
|
"""마크다운 리스트(* item, - item)를 HTML <ul><li>로 변환.
|
||||||
|
|
||||||
|
들여쓰기 수준(2-4칸)을 감지하여 중첩 <ul>을 생성한다.
|
||||||
|
"""
|
||||||
|
lines = text.split("\n")
|
||||||
|
result = []
|
||||||
|
list_stack: list[int] = [] # 현재 열린 리스트의 들여쓰기 레벨들
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^(\s*)[*\-]\s+(.+)$", line)
|
||||||
|
if m:
|
||||||
|
indent = len(m.group(1))
|
||||||
|
content = m.group(2)
|
||||||
|
if not list_stack:
|
||||||
|
result.append("<ul>")
|
||||||
|
list_stack.append(indent)
|
||||||
|
elif indent > list_stack[-1]:
|
||||||
|
result.append("<ul>")
|
||||||
|
list_stack.append(indent)
|
||||||
|
else:
|
||||||
|
while len(list_stack) > 1 and indent < list_stack[-1]:
|
||||||
|
result.append("</ul></li>")
|
||||||
|
list_stack.pop()
|
||||||
|
result.append(f"<li>{content}")
|
||||||
|
else:
|
||||||
|
while list_stack:
|
||||||
|
result.append("</li></ul>")
|
||||||
|
list_stack.pop()
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
while list_stack:
|
||||||
|
result.append("</li></ul>")
|
||||||
|
list_stack.pop()
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
def _convert_md_table_to_html(text: str) -> str:
|
def _convert_md_table_to_html(text: str) -> str:
|
||||||
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
|
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
|
||||||
|
|
||||||
@@ -120,11 +158,14 @@ def _render_md_table(table_lines: list[str]) -> str:
|
|||||||
|
|
||||||
rows = [_parse_row(line) for line in table_lines[data_start:]]
|
rows = [_parse_row(line) for line in table_lines[data_start:]]
|
||||||
|
|
||||||
# HTML 생성
|
# HTML 생성 — 셀 내 <br/> → <br> 유지 (줄바꿈 역할)
|
||||||
header_html = "".join(f"<th>{h}</th>" for h in headers)
|
header_html = "".join(f"<th>{h}</th>" for h in headers)
|
||||||
rows_html = ""
|
rows_html = ""
|
||||||
for row in rows:
|
for row in rows:
|
||||||
cells = "".join(f"<td>{c}</td>" for c in row)
|
cells = ""
|
||||||
|
for c in row:
|
||||||
|
c = re.sub(r"<br\s*/?>", "<br>", c)
|
||||||
|
cells += f"<td>{c}</td>"
|
||||||
rows_html += f"<tr>{cells}</tr>\n"
|
rows_html += f"<tr>{cells}</tr>\n"
|
||||||
|
|
||||||
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
|
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
|
||||||
@@ -145,10 +186,13 @@ def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
|
|||||||
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
|
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
|
||||||
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
|
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
|
||||||
content = content.replace("</div>", "")
|
content = content.replace("</div>", "")
|
||||||
content = re.sub(r"<br\s*/?>", "\n", content)
|
# 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내 <br/>로 행이 쪼개지는 것 방지)
|
||||||
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
|
|
||||||
# 마크다운 테이블 → HTML 테이블
|
|
||||||
content = _convert_md_table_to_html(content)
|
content = _convert_md_table_to_html(content)
|
||||||
|
# 테이블 밖 <br/> → \n (테이블 안은 이미 <br>로 변환 완료)
|
||||||
|
content = re.sub(r"<br\s*/>", "\n", content)
|
||||||
|
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
|
||||||
|
# 마크다운 리스트(* item) → HTML <ul><li>
|
||||||
|
content = _convert_md_list_to_html(content)
|
||||||
popups.append({"title": title, "content": content})
|
popups.append({"title": title, "content": content})
|
||||||
return f"[팝업: {title}]"
|
return f"[팝업: {title}]"
|
||||||
|
|
||||||
@@ -229,15 +273,20 @@ def _extract_structure(text: str) -> dict[str, Any]:
|
|||||||
current_section_title = ""
|
current_section_title = ""
|
||||||
current_section_lines = []
|
current_section_lines = []
|
||||||
|
|
||||||
|
current_section_level = 2
|
||||||
|
bullet_depth = 0 # 불릿 중첩 깊이 추적 (bullet_list_open/close)
|
||||||
|
|
||||||
def _flush_section():
|
def _flush_section():
|
||||||
nonlocal current_section_title, current_section_lines
|
nonlocal current_section_title, current_section_lines, current_section_level, bullet_depth
|
||||||
if current_section_title:
|
if current_section_title:
|
||||||
sections.append({
|
sections.append({
|
||||||
"level": 2,
|
"level": current_section_level,
|
||||||
"title": current_section_title,
|
"title": current_section_title,
|
||||||
"content": "\n".join(current_section_lines).strip(),
|
"content": "\n".join(current_section_lines).strip(),
|
||||||
})
|
})
|
||||||
current_section_lines = []
|
current_section_lines = []
|
||||||
|
current_section_level = 2
|
||||||
|
bullet_depth = 0
|
||||||
|
|
||||||
for i, token in enumerate(tokens):
|
for i, token in enumerate(tokens):
|
||||||
# 이미지 추출 (inline children)
|
# 이미지 추출 (inline children)
|
||||||
@@ -283,12 +332,24 @@ def _extract_structure(text: str) -> dict[str, Any]:
|
|||||||
if table["headers"] or table["rows"]:
|
if table["headers"] or table["rows"]:
|
||||||
tables.append(table)
|
tables.append(table)
|
||||||
|
|
||||||
# 섹션 추출 (## 기준)
|
# 불릿 depth 추적 (섹션 내용 수집 시 계층 보존)
|
||||||
if token.type == "heading_open" and token.tag == "h2":
|
if current_section_title:
|
||||||
_flush_section()
|
if token.type == "bullet_list_open":
|
||||||
# 다음 토큰이 inline (제목 텍스트)
|
bullet_depth += 1
|
||||||
|
elif token.type == "bullet_list_close":
|
||||||
|
bullet_depth = max(0, bullet_depth - 1)
|
||||||
|
|
||||||
|
# 섹션 추출 (## 및 ### 기준 — 대목차/소목차 모두)
|
||||||
|
if token.type == "heading_open" and token.tag in ("h2", "h3"):
|
||||||
|
# 다음 토큰이 inline (제목 텍스트) — 무의미한 제목(<br/> 등)은 건너뜀
|
||||||
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
||||||
current_section_title = tokens[i + 1].content
|
heading_text = tokens[i + 1].content.strip()
|
||||||
|
# <br/>, 빈 문자열, 숫자만 등은 section 제목으로 부적합
|
||||||
|
clean_heading = re.sub(r'<br\s*/?>', '', heading_text).strip()
|
||||||
|
if clean_heading and len(clean_heading) > 1:
|
||||||
|
_flush_section()
|
||||||
|
current_section_title = clean_heading
|
||||||
|
current_section_level = 2 if token.tag == "h2" else 3
|
||||||
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
|
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
|
||||||
"ordered_list_open", "fence"):
|
"ordered_list_open", "fence"):
|
||||||
# 섹션 내용 수집 — inline 토큰의 content만
|
# 섹션 내용 수집 — inline 토큰의 content만
|
||||||
@@ -297,7 +358,12 @@ def _extract_structure(text: str) -> dict[str, Any]:
|
|||||||
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
|
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
|
||||||
parent_type = tokens[i - 1].type if i > 0 else ""
|
parent_type = tokens[i - 1].type if i > 0 else ""
|
||||||
if parent_type != "heading_open":
|
if parent_type != "heading_open":
|
||||||
current_section_lines.append(token.content)
|
# depth prefix 추가: D1=1단 불릿, D2=2단 불릿, D3=3단 불릿
|
||||||
|
depth = max(1, bullet_depth) if bullet_depth > 0 else 0
|
||||||
|
if depth > 0:
|
||||||
|
current_section_lines.append(f"D{depth}: {token.content}")
|
||||||
|
else:
|
||||||
|
current_section_lines.append(token.content)
|
||||||
|
|
||||||
_flush_section()
|
_flush_section()
|
||||||
|
|
||||||
|
|||||||
+260
-123
@@ -172,10 +172,13 @@ async def generate_slide(
|
|||||||
page_struct_raw = analysis_raw.get("page_structure", {})
|
page_struct_raw = analysis_raw.get("page_structure", {})
|
||||||
page_structure = PageStructure(roles=page_struct_raw)
|
page_structure = PageStructure(roles=page_struct_raw)
|
||||||
|
|
||||||
|
# X'-1: 제목은 원본 MDX frontmatter에서 가져옴 (Kei가 바꾸지 않음)
|
||||||
|
original_title = context.normalized.title or analysis_raw.get("title", "")
|
||||||
analysis = Analysis(
|
analysis = Analysis(
|
||||||
core_message=analysis_raw.get("core_message", ""),
|
core_message=analysis_raw.get("core_message", ""),
|
||||||
title=analysis_raw.get("title", ""),
|
title=original_title,
|
||||||
total_pages=analysis_raw.get("total_pages", 1),
|
total_pages=analysis_raw.get("total_pages", 1),
|
||||||
|
layout_template=analysis_raw.get("layout_template", "A"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
|
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
|
||||||
@@ -248,6 +251,7 @@ async def generate_slide(
|
|||||||
[t.model_dump() for t in updated_topics],
|
[t.model_dump() for t in updated_topics],
|
||||||
context.normalized.clean_text,
|
context.normalized.clean_text,
|
||||||
raw_content=context.raw_content,
|
raw_content=context.raw_content,
|
||||||
|
layout_template=context.analysis.layout_template,
|
||||||
)
|
)
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
return {"_errors": validation_errors}
|
return {"_errors": validation_errors}
|
||||||
@@ -327,14 +331,27 @@ async def generate_slide(
|
|||||||
f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
|
f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 컨테이너 스펙 계산 (기존 space_allocator 활용)
|
# Phase X-B: 유형에 따라 컨테이너 생성 분기
|
||||||
container_specs = calculate_container_specs(
|
if context.analysis.layout_template in ("B", "B'"):
|
||||||
page_structure=context.page_structure.roles,
|
from src.space_allocator import build_containers_type_b
|
||||||
topics=[t.model_dump() for t in context.topics],
|
container_specs = build_containers_type_b(
|
||||||
preset=preset,
|
page_structure=context.page_structure.roles,
|
||||||
slide_width=settings.slide_width,
|
slide_width=settings.slide_width,
|
||||||
slide_height=settings.slide_height,
|
slide_height=settings.slide_height,
|
||||||
)
|
image_sizes=image_sizes if isinstance(image_sizes, list) else (
|
||||||
|
[{**v, "key": k} for k, v in image_sizes.items()] if image_sizes else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(f"[X-B] 유형 B 컨테이너 생성")
|
||||||
|
else:
|
||||||
|
# 유형 A: 기존 코드 그대로
|
||||||
|
container_specs = calculate_container_specs(
|
||||||
|
page_structure=context.page_structure.roles,
|
||||||
|
topics=[t.model_dump() for t in context.topics],
|
||||||
|
preset=preset,
|
||||||
|
slide_width=settings.slide_width,
|
||||||
|
slide_height=settings.slide_height,
|
||||||
|
)
|
||||||
|
|
||||||
# ContainerSpec → ContainerInfo 변환
|
# ContainerSpec → ContainerInfo 변환
|
||||||
containers = {}
|
containers = {}
|
||||||
@@ -500,93 +517,138 @@ async def generate_slide(
|
|||||||
# context에 sub_layouts 반영 후 filled 생성
|
# context에 sub_layouts 반영 후 filled 생성
|
||||||
context = context.model_copy(update={"sub_layouts": pre_sub_layouts})
|
context = context.model_copy(update={"sub_layouts": pre_sub_layouts})
|
||||||
|
|
||||||
# ── filled: before 컨테이너에 블록+텍스트 채움 → Selenium 측정 ──
|
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
|
||||||
|
kei_decisions = []
|
||||||
|
updated_containers = dict(context.containers)
|
||||||
|
MAX_FIT_RETRIES = 3
|
||||||
|
|
||||||
filled_html = assemble_slide_html(context)
|
for fit_round in range(MAX_FIT_RETRIES):
|
||||||
(steps_dir / "stage_1_8_filled.html").write_text(
|
# context에 현재 kei_decisions 반영 (2회차부터 popup 결정이 반영됨)
|
||||||
filled_html.replace('</head><body>', '</head><body>\n'
|
if kei_decisions:
|
||||||
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
context = context.model_copy(update={
|
||||||
'Stage 1.8: filled (블록+텍스트 채운 상태)</div>\n'
|
"enhancement_result": {
|
||||||
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
|
**(context.enhancement_result or {}),
|
||||||
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
|
"kei_decisions": kei_decisions,
|
||||||
encoding="utf-8",
|
},
|
||||||
)
|
"containers": updated_containers,
|
||||||
|
})
|
||||||
|
|
||||||
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
|
# ── filled: 컨테이너에 블록+텍스트 채움 ──
|
||||||
logger.info(f"[Stage 1.8] filled 측정 완료")
|
filled_html = assemble_slide_html(context)
|
||||||
|
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
|
||||||
# ── 판단: 넘치는 영역 처리 ──
|
filled_html.replace('</head><body>', '</head><body>\n'
|
||||||
updated_containers = dict(context.containers) # 복사
|
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
||||||
|
f'Stage 1.8: filled (round {fit_round+1}/{MAX_FIT_RETRIES})</div>\n'
|
||||||
for zone_name, zone_data in filled_measurement.get("zones", {}).items():
|
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
|
||||||
if zone_data.get("overflowed"):
|
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
|
||||||
excess = zone_data.get("excess_px", 0)
|
encoding="utf-8",
|
||||||
scroll_h = zone_data.get("scrollHeight", 0)
|
|
||||||
|
|
||||||
if zone_name == "sidebar":
|
|
||||||
# sidebar 예외: 세로 확장 허용
|
|
||||||
for role, ci in updated_containers.items():
|
|
||||||
if ci.zone == "sidebar":
|
|
||||||
new_h = max(ci.height_px, scroll_h + 10) # 여유 10px
|
|
||||||
updated_containers[role] = ci.model_copy(update={"height_px": new_h})
|
|
||||||
logger.info(f"[Stage 1.8] sidebar 예외 확장: {role} {ci.height_px}px → {new_h}px")
|
|
||||||
|
|
||||||
elif zone_name == "body":
|
|
||||||
# body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
|
|
||||||
logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요")
|
|
||||||
|
|
||||||
# containers_dict 업데이트 (sidebar 확장 반영)
|
|
||||||
for role, ci in updated_containers.items():
|
|
||||||
containers_dict[role] = {
|
|
||||||
"height_px": ci.height_px,
|
|
||||||
"width_px": ci.width_px,
|
|
||||||
"zone": ci.zone,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── fit 계산 + 재배분 (업데이트된 컨테이너 기준) ──
|
|
||||||
fit_analysis = calculate_fit(
|
|
||||||
topics=[t.model_dump() for t in context.topics],
|
|
||||||
page_structure=context.page_structure.roles,
|
|
||||||
containers=containers_dict,
|
|
||||||
references=refs_dict,
|
|
||||||
font_hierarchy=font_h,
|
|
||||||
normalized=normalized,
|
|
||||||
core_message=core_message,
|
|
||||||
)
|
|
||||||
fit_analysis = redistribute(fit_analysis, containers_dict)
|
|
||||||
|
|
||||||
# ── after: 조정된 컨테이너 ──
|
|
||||||
for role, ci in updated_containers.items():
|
|
||||||
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
|
|
||||||
updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})
|
|
||||||
|
|
||||||
logger.info(f"[Stage 1.8] after: " + ", ".join(
|
|
||||||
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
|
|
||||||
))
|
|
||||||
|
|
||||||
# Step 3: Kei 에스컬레이션 (필요 시)
|
|
||||||
if fit_analysis.needs_escalation:
|
|
||||||
from src.kei_client import call_kei_fit_escalation
|
|
||||||
report = build_escalation_report(fit_analysis)
|
|
||||||
logger.info(f"[Stage 1.8] 에스컬레이션 필요:\n{report}")
|
|
||||||
kei_result = await call_kei_fit_escalation(
|
|
||||||
fit_report=report,
|
|
||||||
topics=[t.model_dump() for t in context.topics],
|
|
||||||
content_summary=context.raw_content[:1500],
|
|
||||||
)
|
)
|
||||||
kei_decisions = []
|
|
||||||
if kei_result:
|
# ── Selenium 측정 ──
|
||||||
kei_decisions = kei_result.get("decisions", [])
|
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
|
||||||
logger.info(f"[Stage 1.8] Kei 결정: {len(kei_decisions)}건")
|
logger.info(f"[Stage 1.8] round {fit_round+1} 측정 완료")
|
||||||
for d in kei_decisions:
|
|
||||||
action = d.get("action", "")
|
# ── overflow 확인 ──
|
||||||
target_role = d.get("role", "")
|
has_overflow = False
|
||||||
detail = d.get("detail", "")
|
for zone_name, zone_data in filled_measurement.get("zones", {}).items():
|
||||||
logger.info(f"[V-4] {target_role} → {action}: {detail}")
|
if zone_data.get("overflowed"):
|
||||||
if action == "restructure" and target_role in fit_analysis.roles:
|
excess = zone_data.get("excess_px", 0)
|
||||||
fit_analysis = redistribute(fit_analysis, containers_dict)
|
scroll_h = zone_data.get("scrollHeight", 0)
|
||||||
else:
|
has_overflow = True
|
||||||
kei_decisions = []
|
|
||||||
|
if zone_name == "sidebar":
|
||||||
|
for role, ci in updated_containers.items():
|
||||||
|
if ci.zone == "sidebar":
|
||||||
|
new_h = max(ci.height_px, scroll_h + 10)
|
||||||
|
updated_containers[role] = ci.model_copy(update={"height_px": new_h})
|
||||||
|
logger.info(f"[Stage 1.8] sidebar 확장: {role} → {new_h}px")
|
||||||
|
else:
|
||||||
|
logger.info(f"[Stage 1.8] {zone_name} overflow +{excess}px")
|
||||||
|
|
||||||
|
if not has_overflow:
|
||||||
|
logger.info(f"[Stage 1.8] round {fit_round+1}: overflow 없음 — 완료")
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── fit 계산 + 재배분 ──
|
||||||
|
for role, ci in updated_containers.items():
|
||||||
|
containers_dict[role] = {
|
||||||
|
"height_px": ci.height_px,
|
||||||
|
"width_px": ci.width_px,
|
||||||
|
"zone": ci.zone,
|
||||||
|
}
|
||||||
|
|
||||||
|
fit_analysis = calculate_fit(
|
||||||
|
topics=[t.model_dump() for t in context.topics],
|
||||||
|
page_structure=context.page_structure.roles,
|
||||||
|
containers=containers_dict,
|
||||||
|
references=refs_dict,
|
||||||
|
font_hierarchy=font_h,
|
||||||
|
normalized=normalized,
|
||||||
|
core_message=core_message,
|
||||||
|
)
|
||||||
|
fit_analysis = redistribute(fit_analysis, containers_dict)
|
||||||
|
|
||||||
|
# Type B: zone 간 재배분
|
||||||
|
if context.analysis.layout_template in ("B", "B'"):
|
||||||
|
deficit_roles = [(r, rf.shortfall_px) for r, rf in fit_analysis.roles.items() if rf.shortfall_px > 0]
|
||||||
|
surplus_roles = [(r, abs(rf.shortfall_px)) for r, rf in fit_analysis.roles.items() if rf.shortfall_px < -8]
|
||||||
|
if deficit_roles and surplus_roles:
|
||||||
|
total_deficit = sum(d for _, d in deficit_roles)
|
||||||
|
total_surplus = sum(s for _, s in surplus_roles)
|
||||||
|
transferable = min(total_deficit, total_surplus)
|
||||||
|
if transferable > 0:
|
||||||
|
for role, deficit in deficit_roles:
|
||||||
|
share = transferable * (deficit / total_deficit)
|
||||||
|
old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
|
||||||
|
fit_analysis.redistribution[role] = old + share
|
||||||
|
for role, surplus in surplus_roles:
|
||||||
|
share = transferable * (surplus / total_surplus)
|
||||||
|
old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
|
||||||
|
fit_analysis.redistribution[role] = old - share
|
||||||
|
logger.info(f"[Stage 1.8] zone 간 재배분: {transferable:.0f}px 이전")
|
||||||
|
|
||||||
|
# 재배분된 컨테이너 크기 적용
|
||||||
|
for role, ci in updated_containers.items():
|
||||||
|
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
|
||||||
|
updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})
|
||||||
|
|
||||||
|
logger.info(f"[Stage 1.8] round {fit_round+1} after: " + ", ".join(
|
||||||
|
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
|
||||||
|
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
|
||||||
|
if fit_analysis.needs_escalation or has_overflow:
|
||||||
|
from src.kei_client import call_kei_fit_escalation
|
||||||
|
report = build_escalation_report(fit_analysis)
|
||||||
|
# Selenium 실측 overflow 정보를 report에 추가 (calculate_fit과 실측이 다를 수 있음)
|
||||||
|
selenium_overflow_lines = []
|
||||||
|
for zn, zd in filled_measurement.get("zones", {}).items():
|
||||||
|
if zd.get("overflowed"):
|
||||||
|
selenium_overflow_lines.append(
|
||||||
|
f" ❌ {zn} zone: 실측 {zd.get('scrollHeight')}px / 가용 {zd.get('clientHeight')}px → +{zd.get('excess_px', 0)}px 초과"
|
||||||
|
)
|
||||||
|
if selenium_overflow_lines:
|
||||||
|
report += "\n\nSelenium 실측 overflow:\n" + "\n".join(selenium_overflow_lines)
|
||||||
|
logger.info(f"[Stage 1.8] round {fit_round+1} 에스컬레이션 필요")
|
||||||
|
kei_result = await call_kei_fit_escalation(
|
||||||
|
fit_report=report,
|
||||||
|
topics=[t.model_dump() for t in context.topics],
|
||||||
|
content_summary=context.raw_content[:1500],
|
||||||
|
role_names=list(context.page_structure.roles.keys()),
|
||||||
|
)
|
||||||
|
if kei_result:
|
||||||
|
new_decisions = kei_result.get("decisions", [])
|
||||||
|
kei_decisions.extend(new_decisions)
|
||||||
|
logger.info(f"[Stage 1.8] Kei 결정 {len(new_decisions)}건 추가 (누적 {len(kei_decisions)}건)")
|
||||||
|
for d in new_decisions:
|
||||||
|
logger.info(f"[V-4] {d.get('role','')} → {d.get('action','')}: {d.get('detail','')[:60]}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Stage 1.8] round {fit_round+1} Kei 응답 없음 — 루프 종료")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
|
||||||
|
break
|
||||||
|
|
||||||
# Step 4: 보강 제안 분석
|
# Step 4: 보강 제안 분석
|
||||||
enhancements = analyze_enhancements(
|
enhancements = analyze_enhancements(
|
||||||
@@ -716,19 +778,39 @@ async def generate_slide(
|
|||||||
popup = next((p for p in popups if pr in p.get("title", "")), None)
|
popup = next((p for p in popups if pr in p.get("title", "")), None)
|
||||||
if not popup:
|
if not popup:
|
||||||
continue
|
continue
|
||||||
# 공란 계산: after(updated_containers) 기준
|
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
|
||||||
after_ci = updated_containers.get(role)
|
from src.fit_verifier import _load_design_tokens as _ldt_v2
|
||||||
after_h = after_ci.height_px if after_ci else float(text_sc["height_px"])
|
_v2_tokens = _ldt_v2()
|
||||||
# after 높이에서 제목+keymsg+padding 제외 → 텍스트 영역
|
_v2_slide_h = _v2_tokens.get("slide_height", 720)
|
||||||
|
_v2_pad = _v2_tokens["spacing_page"]
|
||||||
|
_v2_header_h = _v2_tokens.get("header_height", 66)
|
||||||
|
_v2_gap = _v2_tokens["spacing_block"]
|
||||||
|
_v2_gap_small = _v2_tokens["spacing_small"]
|
||||||
|
_v2_concl_ci = updated_containers.get("결론") or next((ci for r, ci in updated_containers.items() if ci.zone == "footer"), None)
|
||||||
|
_v2_concl_h = _v2_concl_ci.height_px if _v2_concl_ci else 53
|
||||||
|
_v2_ft_top = _v2_slide_h - _v2_pad - _v2_concl_h - _v2_gap
|
||||||
|
_v2_column_bottom = _v2_ft_top - _v2_gap
|
||||||
|
_v2_bg_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "body" and r != role), None)
|
||||||
|
_v2_bg_h = _v2_bg_ci.height_px if _v2_bg_ci else 0
|
||||||
|
_v2_core_top = _v2_pad + _v2_header_h + _v2_gap + _v2_bg_h + _v2_gap_small
|
||||||
|
after_h = _v2_column_bottom - _v2_core_top # 결론 위까지의 실제 본심 높이
|
||||||
keymsg_h = 0
|
keymsg_h = 0
|
||||||
for sc in role_scs:
|
for sc in role_scs:
|
||||||
if sc.get("name") == "keymsg":
|
if sc.get("name") == "keymsg":
|
||||||
keymsg_h = float(sc.get("height_px", 0))
|
keymsg_h = float(sc.get("height_px", 0))
|
||||||
title_h = (fs + 1) * 1.5 + 4
|
title_h = (fs + 1) * 1.5 + 4
|
||||||
content_area_h = after_h - 16 - title_h - keymsg_h - 8 # pad*2 + gap
|
# 이미지 높이: 실제 비율로 계산
|
||||||
|
svg_sc = next((sc for sc in role_scs if sc.get("name") == "svg"), None)
|
||||||
|
img_h = 0
|
||||||
|
if svg_sc:
|
||||||
|
svg_w = float(svg_sc.get("width_px", 200))
|
||||||
|
img_ratio = next((img.get("ratio", 1) for img in (context.slide_images or []) if img.get("b64")), 1)
|
||||||
|
img_h = svg_w / img_ratio if img_ratio > 0 else float(svg_sc.get("height_px", 0))
|
||||||
text_lines = len([l for l in st_text.split("\n") if l.strip() and not l.strip().startswith("[팝업:") and not l.strip().startswith("[이미지:") and not l.strip().lstrip("• ").startswith("출처:")])
|
text_lines = len([l for l in st_text.split("\n") if l.strip() and not l.strip().startswith("[팝업:") and not l.strip().startswith("[이미지:") and not l.strip().lstrip("• ").startswith("출처:")])
|
||||||
text_h_used = text_lines * fs * 1.55
|
text_h_used = text_lines * fs * 1.55
|
||||||
available_h = content_area_h - text_h_used
|
# 표 공간 = 전체 - 제목 - max(이미지,텍스트) - keymsg - padding
|
||||||
|
upper_h = max(img_h, text_h_used)
|
||||||
|
available_h = after_h - 16 - title_h - upper_h - keymsg_h - 8
|
||||||
available_w = float(text_sc["width_px"])
|
available_w = float(text_sc["width_px"])
|
||||||
if available_h < fs * 3:
|
if available_h < fs * 3:
|
||||||
continue # 공간 부족하면 건너뜀
|
continue # 공간 부족하면 건너뜀
|
||||||
@@ -743,6 +825,41 @@ async def generate_slide(
|
|||||||
popup_summaries[pr] = summary
|
popup_summaries[pr] = summary
|
||||||
logger.info(f"[V'-2] {pr}: format={summary.get('format')}")
|
logger.info(f"[V'-2] {pr}: format={summary.get('format')}")
|
||||||
|
|
||||||
|
# X'-6: 본문 표 요약 (유형 B — normalized.tables가 있으면)
|
||||||
|
table_summaries = {}
|
||||||
|
norm_tables = context.normalized.tables or []
|
||||||
|
if norm_tables and context.analysis.layout_template in ("B", "B'"):
|
||||||
|
from src.kei_client import call_kei_summarize_popup
|
||||||
|
for ti, table_data in enumerate(norm_tables):
|
||||||
|
headers = table_data.get("headers", [])
|
||||||
|
rows = table_data.get("rows", [])
|
||||||
|
if not headers or not rows:
|
||||||
|
continue
|
||||||
|
# 표를 마크다운 형태로 변환하여 Kei에게 전달
|
||||||
|
md_table = "| " + " | ".join(headers) + " |\n"
|
||||||
|
md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
|
||||||
|
for row in rows:
|
||||||
|
md_table += "| " + " | ".join(str(c) for c in row) + " |\n"
|
||||||
|
|
||||||
|
# 하단 우측 공간 계산
|
||||||
|
bottom_roles = [r for r, ci in updated_containers.items() if ci.zone in ("bottom_left", "bottom_right")]
|
||||||
|
if bottom_roles:
|
||||||
|
br_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "bottom_right"), None)
|
||||||
|
if br_ci:
|
||||||
|
available_h = br_ci.height_px - 30 # 제목 + padding
|
||||||
|
available_w = br_ci.width_px
|
||||||
|
fs = font_h.get("core", 12)
|
||||||
|
summary = await call_kei_summarize_popup(
|
||||||
|
popup_title=f"본문표{ti+1}",
|
||||||
|
popup_content=md_table,
|
||||||
|
available_width_px=available_w,
|
||||||
|
available_height_px=available_h,
|
||||||
|
font_size=fs,
|
||||||
|
)
|
||||||
|
if summary:
|
||||||
|
table_summaries[f"table_{ti}"] = summary
|
||||||
|
logger.info(f"[X'-6] 본문표{ti+1}: format={summary.get('format')}")
|
||||||
|
|
||||||
# 결과를 context에 저장 (Stage 2에서 사용)
|
# 결과를 context에 저장 (Stage 2에서 사용)
|
||||||
return {
|
return {
|
||||||
"containers": updated_containers,
|
"containers": updated_containers,
|
||||||
@@ -774,6 +891,7 @@ async def generate_slide(
|
|||||||
"emphasis_blocks": enhancements.emphasis_blocks,
|
"emphasis_blocks": enhancements.emphasis_blocks,
|
||||||
"bold_keywords": enhancements.bold_keywords,
|
"bold_keywords": enhancements.bold_keywords,
|
||||||
"popup_summaries": popup_summaries,
|
"popup_summaries": popup_summaries,
|
||||||
|
"table_summaries": table_summaries,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,6 +944,14 @@ async def generate_slide(
|
|||||||
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
|
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
|
||||||
|
|
||||||
async def stage_2(context: PipelineContext) -> dict:
|
async def stage_2(context: PipelineContext) -> dict:
|
||||||
|
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
|
||||||
|
if context.analysis.layout_template in ("B", "B'"):
|
||||||
|
from src.block_assembler import assemble_slide_html
|
||||||
|
generated = assemble_slide_html(context)
|
||||||
|
logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
|
||||||
|
return {"generated_html": generated}
|
||||||
|
|
||||||
|
# Type A: 기존 Sonnet 재구성 코드 그대로
|
||||||
from src.content_verifier import generate_with_retry
|
from src.content_verifier import generate_with_retry
|
||||||
|
|
||||||
# PipelineContext → 기존 함수 인터페이스로 변환
|
# PipelineContext → 기존 함수 인터페이스로 변환
|
||||||
@@ -887,6 +1013,12 @@ async def generate_slide(
|
|||||||
yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."}
|
yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."}
|
||||||
|
|
||||||
async def stage_3(context: PipelineContext) -> dict:
|
async def stage_3(context: PipelineContext) -> dict:
|
||||||
|
# Phase X-BX': Type B는 Stage 2에서 이미 완전한 HTML → renderer 스킵
|
||||||
|
if context.analysis.layout_template in ("B", "B'"):
|
||||||
|
logger.info("[Stage 3] Type B: renderer 스킵 (generated_html 직접 사용)")
|
||||||
|
return {"rendered_html": context.generated_html}
|
||||||
|
|
||||||
|
# Type A: 기존 renderer 코드 그대로
|
||||||
from src.renderer import render_slide_from_html
|
from src.renderer import render_slide_from_html
|
||||||
|
|
||||||
analysis_dict = {
|
analysis_dict = {
|
||||||
@@ -1015,6 +1147,34 @@ async def generate_slide(
|
|||||||
# markdown bold → HTML bold
|
# markdown bold → HTML bold
|
||||||
clean_content = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', clean_content)
|
clean_content = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', clean_content)
|
||||||
|
|
||||||
|
# 콘텐츠 유형 감지: 테이블 vs 리스트
|
||||||
|
has_table = "<table" in clean_content
|
||||||
|
has_list = "<ul" in clean_content or "<li" in clean_content
|
||||||
|
|
||||||
|
# 콘텐츠 유형별 CSS
|
||||||
|
if has_table:
|
||||||
|
# 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
|
||||||
|
content_css = """
|
||||||
|
table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
|
||||||
|
th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
|
||||||
|
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
|
||||||
|
th:nth-child(2) {{ width: 16%; }}
|
||||||
|
td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
|
||||||
|
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
|
||||||
|
elif has_list:
|
||||||
|
# 카드형 리스트: 항목별 박스, 하위 항목은 인라인
|
||||||
|
content_css = """
|
||||||
|
ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
|
||||||
|
li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
|
||||||
|
li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
|
||||||
|
li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
|
||||||
|
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
|
||||||
|
else:
|
||||||
|
# 기본 (텍스트)
|
||||||
|
content_css = """
|
||||||
|
ul {{ padding-left: 20px; margin: 8px 0; }}
|
||||||
|
li {{ margin-bottom: 4px; font-size: 13px; }}"""
|
||||||
|
|
||||||
popup_html = f"""<!DOCTYPE html>
|
popup_html = f"""<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -1052,30 +1212,7 @@ h1 {{
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}}
|
}}
|
||||||
table {{
|
{content_css}
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}}
|
|
||||||
th {{
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 10px 14px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
}}
|
|
||||||
td {{
|
|
||||||
padding: 8px 14px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
vertical-align: top;
|
|
||||||
line-height: 1.5;
|
|
||||||
}}
|
|
||||||
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}
|
|
||||||
td:first-child {{ font-weight: 600; background: #f1f5f9; }}
|
|
||||||
ul {{ padding-left: 20px; margin: 8px 0; }}
|
|
||||||
li {{ margin-bottom: 4px; font-size: 13px; }}
|
|
||||||
strong {{ color: var(--color-primary); }}
|
strong {{ color: var(--color-primary); }}
|
||||||
.source {{
|
.source {{
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class Analysis(BaseModel):
|
|||||||
core_message: str = ""
|
core_message: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
total_pages: int = 1
|
total_pages: int = 1
|
||||||
|
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
|
||||||
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||||
# topics와 page_structure는 PipelineContext 최상위에 위치
|
# topics와 page_structure는 PipelineContext 최상위에 위치
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -547,9 +547,24 @@ def render_slide_from_html(
|
|||||||
_tokens = _ldt()
|
_tokens = _ldt()
|
||||||
_header_h = _tokens.get("header_height", 66)
|
_header_h = _tokens.get("header_height", 66)
|
||||||
_gap_small = _tokens["spacing_small"]
|
_gap_small = _tokens["spacing_small"]
|
||||||
_bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
|
# zone 기반으로 body/footer 높이를 동적 탐색 (유형 A: 배경+본심, 유형 B: zone별)
|
||||||
_core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
|
def _find_h(role_name, zone_name=None):
|
||||||
_footer_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
|
"""redist → containers 순으로 높이 탐색. role_name 없으면 zone으로 fallback."""
|
||||||
|
h = redist.get(role_name, 0)
|
||||||
|
if h:
|
||||||
|
return int(h)
|
||||||
|
ci = containers.get(role_name, {})
|
||||||
|
if ci:
|
||||||
|
return int(ci.get("height_px", 0))
|
||||||
|
if zone_name:
|
||||||
|
for _r, _c in containers.items():
|
||||||
|
if isinstance(_c, dict) and _c.get("zone") == zone_name:
|
||||||
|
return int(redist.get(_r, _c.get("height_px", 0)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
_bg_h = _find_h("배경")
|
||||||
|
_core_h = _find_h("본심")
|
||||||
|
_footer_h = _find_h("결론", "footer")
|
||||||
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
|
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
|
||||||
if _body_row_h > 0 and _footer_h > 0:
|
if _body_row_h > 0 and _footer_h > 0:
|
||||||
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
|
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
|
||||||
|
|||||||
+13
-4
@@ -135,13 +135,16 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
driver = None
|
driver = None
|
||||||
|
tmp_file = None
|
||||||
try:
|
try:
|
||||||
driver = webdriver.Chrome(options=options)
|
driver = webdriver.Chrome(options=options)
|
||||||
|
|
||||||
# HTML을 data URI로 로드
|
# HTML을 임시 파일로 저장 후 file:// URI로 로드 (data URI는 대용량 HTML에서 실패)
|
||||||
import urllib.parse
|
import tempfile
|
||||||
encoded = urllib.parse.quote(html)
|
tmp_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8")
|
||||||
driver.get(f"data:text/html;charset=utf-8,{encoded}")
|
tmp_file.write(html)
|
||||||
|
tmp_file.close()
|
||||||
|
driver.get(f"file:///{tmp_file.name}")
|
||||||
|
|
||||||
# 폰트 로딩 대기 (Pretendard CDN)
|
# 폰트 로딩 대기 (Pretendard CDN)
|
||||||
try:
|
try:
|
||||||
@@ -169,6 +172,12 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
|
|||||||
driver.quit()
|
driver.quit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if tmp_file:
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_file.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def format_measurement_for_kei(
|
def format_measurement_for_kei(
|
||||||
|
|||||||
@@ -400,6 +400,12 @@ def calculate_container_specs(
|
|||||||
# 비중 비율로 높이 할당
|
# 비중 비율로 높이 할당
|
||||||
ratio = weight / total_weight
|
ratio = weight / total_weight
|
||||||
height_px = max(min_block_h, int(available * ratio))
|
height_px = max(min_block_h, int(available * ratio))
|
||||||
|
# footer는 최소 높이 보장 (font_size * line_height + padding)
|
||||||
|
if zone_name == "footer":
|
||||||
|
from src.fit_verifier import _load_design_tokens as _ldt_footer
|
||||||
|
_ft = _ldt_footer()
|
||||||
|
_footer_min = int(14 * _ft.get("line_height_ko", 1.7) + _ft["spacing_page"])
|
||||||
|
height_px = max(_footer_min, height_px)
|
||||||
|
|
||||||
# 블록 내부 제약 계산 — topic당 높이로 판단
|
# 블록 내부 제약 계산 — topic당 높이로 판단
|
||||||
topic_count = max(1, len(topic_ids))
|
topic_count = max(1, len(topic_ids))
|
||||||
@@ -433,6 +439,143 @@ def calculate_container_specs(
|
|||||||
return specs
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
# Phase X-B: 유형 B 컨테이너 생성
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
def build_containers_type_b(
|
||||||
|
page_structure: dict[str, Any],
|
||||||
|
slide_width: int = 1280,
|
||||||
|
slide_height: int = 720,
|
||||||
|
image_sizes: list[dict] | None = None,
|
||||||
|
) -> dict[str, ContainerSpec]:
|
||||||
|
"""유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).
|
||||||
|
|
||||||
|
기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
|
||||||
|
모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
|
||||||
|
slide_width: 슬라이드 너비
|
||||||
|
slide_height: 슬라이드 높이
|
||||||
|
image_sizes: 이미지 정보 (비율 계산용)
|
||||||
|
"""
|
||||||
|
from src.fit_verifier import _load_design_tokens
|
||||||
|
tokens = _load_design_tokens()
|
||||||
|
pad = tokens["spacing_page"]
|
||||||
|
header_h = tokens.get("header_height", 66)
|
||||||
|
gap_block = tokens["spacing_block"]
|
||||||
|
gap_small = tokens["spacing_small"]
|
||||||
|
inner_w = slide_width - pad * 2
|
||||||
|
|
||||||
|
# 역할을 zone별로 분류
|
||||||
|
top_roles = [] # zone=top
|
||||||
|
bottom_roles = [] # zone=bottom_left, bottom_right
|
||||||
|
footer_role = None # zone=footer
|
||||||
|
|
||||||
|
for role_name, info in page_structure.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
zone = info.get("zone", "")
|
||||||
|
if zone == "top":
|
||||||
|
top_roles.append((role_name, info))
|
||||||
|
elif zone in ("bottom_left", "bottom_right"):
|
||||||
|
bottom_roles.append((role_name, info))
|
||||||
|
elif zone == "footer":
|
||||||
|
footer_role = (role_name, info)
|
||||||
|
|
||||||
|
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
|
||||||
|
total_available = slide_height - pad * 2 - header_h - gap_block
|
||||||
|
|
||||||
|
# footer 높이: weight 비율 (최소 보장)
|
||||||
|
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
|
||||||
|
footer_h_raw = int(total_available * footer_weight)
|
||||||
|
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
|
||||||
|
footer_h = max(_footer_min, footer_h_raw)
|
||||||
|
|
||||||
|
# 중간 영역: footer + gap 제외
|
||||||
|
middle_h = total_available - footer_h - gap_block
|
||||||
|
|
||||||
|
# 상단/하단 높이: weight 비율로
|
||||||
|
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
|
||||||
|
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
|
||||||
|
total_mid_weight = top_weight + bottom_weight
|
||||||
|
if total_mid_weight <= 0:
|
||||||
|
total_mid_weight = 1
|
||||||
|
|
||||||
|
top_h = int(middle_h * top_weight / total_mid_weight)
|
||||||
|
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
|
||||||
|
|
||||||
|
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
|
||||||
|
img_ratio = 0
|
||||||
|
if image_sizes:
|
||||||
|
for img in image_sizes:
|
||||||
|
r = img.get("ratio", 0)
|
||||||
|
if r > 0:
|
||||||
|
img_ratio = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if img_ratio > 0:
|
||||||
|
# 이미지 높이 = top_h, 이미지 폭 = top_h * ratio
|
||||||
|
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45%
|
||||||
|
text_w = inner_w - img_w - gap_block
|
||||||
|
else:
|
||||||
|
text_w = inner_w
|
||||||
|
img_w = 0
|
||||||
|
|
||||||
|
specs = {}
|
||||||
|
|
||||||
|
# 상단 역할
|
||||||
|
for role_name, info in top_roles:
|
||||||
|
specs[role_name] = ContainerSpec(
|
||||||
|
role=role_name,
|
||||||
|
zone="top",
|
||||||
|
topic_ids=info.get("topic_ids", []),
|
||||||
|
weight=info.get("weight", 0),
|
||||||
|
height_px=top_h,
|
||||||
|
width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만
|
||||||
|
max_height_cost=_max_allowed_height_cost(top_h),
|
||||||
|
block_constraints={
|
||||||
|
"img_width_px": img_w,
|
||||||
|
"img_height_px": top_h if img_w > 0 else 0,
|
||||||
|
"has_image": img_w > 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 하단 역할: 2분할
|
||||||
|
bottom_col_w = (inner_w - gap_block) // 2
|
||||||
|
for role_name, info in bottom_roles:
|
||||||
|
specs[role_name] = ContainerSpec(
|
||||||
|
role=role_name,
|
||||||
|
zone=info.get("zone", "bottom_left"),
|
||||||
|
topic_ids=info.get("topic_ids", []),
|
||||||
|
weight=info.get("weight", 0),
|
||||||
|
height_px=bottom_h,
|
||||||
|
width_px=bottom_col_w,
|
||||||
|
max_height_cost=_max_allowed_height_cost(bottom_h),
|
||||||
|
block_constraints={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 결론
|
||||||
|
if footer_role:
|
||||||
|
rn, info = footer_role
|
||||||
|
specs[rn] = ContainerSpec(
|
||||||
|
role=rn,
|
||||||
|
zone="footer",
|
||||||
|
topic_ids=info.get("topic_ids", []),
|
||||||
|
weight=info.get("weight", 0),
|
||||||
|
height_px=footer_h,
|
||||||
|
width_px=inner_w,
|
||||||
|
max_height_cost="low",
|
||||||
|
block_constraints={},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[X-B-3] 유형 B 컨테이너: "
|
||||||
|
+ ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items())
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
def _max_allowed_height_cost(container_height_px: int) -> str:
|
def _max_allowed_height_cost(container_height_px: int) -> str:
|
||||||
"""컨테이너 높이에서 허용되는 최대 height_cost.
|
"""컨테이너 높이에서 허용되는 최대 height_cost.
|
||||||
|
|
||||||
|
|||||||
+49
-27
@@ -171,22 +171,38 @@ def validate_stage_1a(
|
|||||||
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
||||||
})
|
})
|
||||||
|
|
||||||
# 본심 존재 + 본심 weight ≥ 0.3
|
# 유형에 따른 구조 검증
|
||||||
core_info = page_struct.get("본심", {})
|
layout_template = analysis.get("layout_template", "A")
|
||||||
if not core_info or not isinstance(core_info, dict):
|
if layout_template == "A":
|
||||||
errors.append({
|
# 유형 A: 본심 필수
|
||||||
"severity": "RETRYABLE",
|
core_info = page_struct.get("본심", {})
|
||||||
"field": "page_structure.본심",
|
if not core_info or not isinstance(core_info, dict):
|
||||||
"localization": "본심 역할이 page_structure에 없음",
|
errors.append({
|
||||||
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
"severity": "RETRYABLE",
|
||||||
})
|
"field": "page_structure.본심",
|
||||||
elif core_info.get("weight", 0) < 0.3:
|
"localization": "본심 역할이 page_structure에 없음",
|
||||||
errors.append({
|
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
||||||
"severity": "RETRYABLE",
|
})
|
||||||
"field": "page_structure.본심.weight",
|
elif core_info.get("weight", 0) < 0.3:
|
||||||
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
|
errors.append({
|
||||||
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
|
"severity": "RETRYABLE",
|
||||||
})
|
"field": "page_structure.본심.weight",
|
||||||
|
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
|
||||||
|
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
|
||||||
|
})
|
||||||
|
elif layout_template == "B":
|
||||||
|
# 유형 B: 결론(footer) 필수, 나머지 자유
|
||||||
|
has_footer = any(
|
||||||
|
isinstance(info, dict) and info.get("zone") == "footer"
|
||||||
|
for info in page_struct.values()
|
||||||
|
)
|
||||||
|
if not has_footer and "결론" not in page_struct:
|
||||||
|
errors.append({
|
||||||
|
"severity": "RETRYABLE",
|
||||||
|
"field": "page_structure.footer",
|
||||||
|
"localization": "결론(footer) 역할이 없음",
|
||||||
|
"instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
|
||||||
|
})
|
||||||
|
|
||||||
# 필수 필드 검증
|
# 필수 필드 검증
|
||||||
for t in topics:
|
for t in topics:
|
||||||
@@ -226,7 +242,9 @@ def validate_stage_1a(
|
|||||||
if clean_text:
|
if clean_text:
|
||||||
# 원본 ## 섹션 수 vs topic 수 비교
|
# 원본 ## 섹션 수 vs topic 수 비교
|
||||||
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
|
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
|
||||||
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 2:
|
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
|
||||||
|
max_diff = 4 if layout_template == "B" else 2
|
||||||
|
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
|
||||||
errors.append({
|
errors.append({
|
||||||
"severity": "RETRYABLE",
|
"severity": "RETRYABLE",
|
||||||
"field": "topics",
|
"field": "topics",
|
||||||
@@ -273,6 +291,7 @@ def validate_stage_1b(
|
|||||||
topics: list[dict[str, Any]],
|
topics: list[dict[str, Any]],
|
||||||
clean_text: str,
|
clean_text: str,
|
||||||
raw_content: str = "",
|
raw_content: str = "",
|
||||||
|
layout_template: str = "A",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Stage 1B(컨셉 구체화) 결과 검증.
|
"""Stage 1B(컨셉 구체화) 결과 검증.
|
||||||
|
|
||||||
@@ -366,15 +385,18 @@ def validate_stage_1b(
|
|||||||
claimed_count = evidence.get(relation_type, 0)
|
claimed_count = evidence.get(relation_type, 0)
|
||||||
|
|
||||||
if claimed_count == 0:
|
if claimed_count == 0:
|
||||||
# 주장한 관계의 증거가 0개
|
if layout_template == "B":
|
||||||
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
|
# 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유)
|
||||||
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
|
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning")
|
||||||
errors.append({
|
else:
|
||||||
"severity": "RETRYABLE",
|
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
|
||||||
"field": f"topics[{tid}].relation_type",
|
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
|
||||||
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
|
errors.append({
|
||||||
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
|
"severity": "RETRYABLE",
|
||||||
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
|
"field": f"topics[{tid}].relation_type",
|
||||||
})
|
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
|
||||||
|
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
|
||||||
|
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
|
||||||
|
})
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
Reference in New Issue
Block a user