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 미지원 |
|
||||||
+24
-174
@@ -1,189 +1,39 @@
|
|||||||
# Phase X: 템플릿 기반 동적 레이아웃
|
# Phase X: 콘텐츠 기반 레이아웃 판단 프로세스
|
||||||
|
|
||||||
> 작성일: 2026-04-06
|
|
||||||
> 상태: 계획 수립
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 배경
|
## 배경
|
||||||
|
|
||||||
현재 파이프라인은 모든 MDX를 "배경/본심/첨부/결론" 4칸에 억지로 끼워넣는다.
|
현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다.
|
||||||
배경이 없는 콘텐츠도 배경을 만들어내고, 3분할이 적절한 콘텐츠도 2분할로 넣는다.
|
|
||||||
Kei가 내용을 잘못 파악한 게 아니라, **"4칸을 채워라"는 지시가 잘못**된 것이다.
|
|
||||||
|
|
||||||
## 핵심 아이디어
|
## 현재 프로세스 (문제)
|
||||||
|
|
||||||
**미리 정의된 레이아웃 템플릿 중 Kei가 콘텐츠에 맞는 것을 선택한다.**
|
|
||||||
|
|
||||||
- Kei가 자유롭게 구조를 만드는 것이 아님 (불안정)
|
|
||||||
- 옵션을 주고 고르게 함 (안정적)
|
|
||||||
- 하드코딩 아님 — 어떤 MDX가 와도 적절한 템플릿이 선택됨
|
|
||||||
|
|
||||||
## 고정 영역
|
|
||||||
|
|
||||||
모든 템플릿 공통:
|
|
||||||
- **상단**: 슬라이드 제목 헤더 (항상 존재)
|
|
||||||
- **하단**: 결론 footer (항상 존재)
|
|
||||||
- **중간**: 템플릿에 따라 달라지는 영역
|
|
||||||
|
|
||||||
## 중간 영역 템플릿 옵션
|
|
||||||
|
|
||||||
### A. body + sidebar
|
|
||||||
```
|
|
||||||
┌──────────┬─────┐
|
|
||||||
│ 본심1 │참조 │
|
|
||||||
│ 본심2 │ │
|
|
||||||
└──────────┴─────┘
|
|
||||||
```
|
|
||||||
적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠
|
|
||||||
예시: 01번 MDX (DX/BIM 용어 정립)
|
|
||||||
|
|
||||||
### B. 상단 wide + 하단 2분할
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ 강조/핵심 │
|
|
||||||
├────────┬────────┤
|
|
||||||
│ 항목1 │ 항목2 │
|
|
||||||
└────────┴────────┘
|
|
||||||
```
|
|
||||||
적합: 핵심 1개 + 두 가지 측면 비교/설명
|
|
||||||
예시: 03번 MDX (필수요건 + 과정혁신/결과변화)
|
|
||||||
|
|
||||||
### C. 상단 wide + 하단 3분할
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ 강조/핵심 │
|
|
||||||
├─────┬─────┬─────┤
|
|
||||||
│항목1│항목2│항목3 │
|
|
||||||
└─────┴─────┴─────┘
|
|
||||||
```
|
|
||||||
적합: 핵심 1개 + 세 가지 항목 병렬
|
|
||||||
예시: 02번 MDX (궁극적 목표 + 발주처/설계사/시공사)
|
|
||||||
|
|
||||||
### D. 2분할 (좌우 대등)
|
|
||||||
```
|
|
||||||
┌────────┬────────┐
|
|
||||||
│ 항목1 │ 항목2 │
|
|
||||||
│ │ │
|
|
||||||
└────────┴────────┘
|
|
||||||
```
|
|
||||||
적합: 두 가지 비교/대비
|
|
||||||
|
|
||||||
### E. 단일 전체
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ │
|
|
||||||
│ 본심 (전체) │
|
|
||||||
│ │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
적합: 하나의 흐름, 분할 불필요
|
|
||||||
|
|
||||||
### F. 상단 wide + 하단 2분할 + 보조 sidebar
|
|
||||||
```
|
|
||||||
┌─────────────┬───┐
|
|
||||||
│ 강조/핵심 │참조│
|
|
||||||
├──────┬──────┤ │
|
|
||||||
│항목1 │항목2 │ │
|
|
||||||
└──────┴──────┴───┘
|
|
||||||
```
|
|
||||||
적합: B + sidebar 조합
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 프로세스
|
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Kei가 MDX 원본을 읽고 내용 분석
|
Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분
|
||||||
→ 핵심 메시지, 콘텐츠 구조 파악
|
|
||||||
|
|
||||||
2. Kei가 꼭지를 나눔 (개수와 역할명 자유)
|
|
||||||
→ "핵심목표 1개, 주체별 기대효과 3개, 결론 1개"
|
|
||||||
→ 역할명: 고정 4칸 아님. 콘텐츠에 맞는 이름 사용
|
|
||||||
|
|
||||||
3. Kei가 템플릿 선택
|
|
||||||
→ "꼭지 구조를 보니 C템플릿이 맞다"
|
|
||||||
→ 옵션 A~F 중 하나
|
|
||||||
|
|
||||||
4. Kei가 각 영역에 꼭지 배정 + weight
|
|
||||||
→ 상단: 핵심목표(0.3)
|
|
||||||
→ 하단좌: 발주처(0.2), 하단중: 설계사(0.2), 하단우: 시공사(0.2)
|
|
||||||
→ footer: 결론(0.1)
|
|
||||||
|
|
||||||
5. 파이프라인이 템플릿대로 컨테이너 생성 → BEFORE
|
|
||||||
|
|
||||||
6. 콘텐츠 채움 → 측정 → 재배분 → FILLED → AFTER
|
|
||||||
|
|
||||||
7. 조립 → code_assembled / final
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
- `reference` 있으면 → `sidebar-right` (무조건)
|
||||||
|
- 콘텐츠 양/특성 기반 판단 없음
|
||||||
|
- 레이아웃이 콘텐츠에 맞는지 검증 없음
|
||||||
|
|
||||||
## 작업 리스트
|
## 목표 프로세스
|
||||||
|
|
||||||
### X-1: 템플릿 정의
|
```
|
||||||
- 옵션 A~F의 구체적 컨테이너 구조 정의
|
1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음)
|
||||||
- 각 옵션의 zone 이름, 비율 계산 공식, 좌표 계산 로직
|
2. FILLED: 콘텐츠 채움
|
||||||
- `src/design_director.py`에 `LAYOUT_TEMPLATES` 정의
|
3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등)
|
||||||
- 하드코딩 아님: 템플릿은 구조만 정의, 크기는 weight와 슬라이드 크기에서 동적 계산
|
4. 판단2: 결정된 레이아웃에서 크기 재배분
|
||||||
|
5. AFTER: 최종 레이아웃 + 크기
|
||||||
|
```
|
||||||
|
|
||||||
### X-2: Kei 프롬프트 수정
|
- 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과**
|
||||||
- `KEI_PROMPT`에 템플릿 A~F 옵션 제시
|
- 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨
|
||||||
- Kei가 콘텐츠를 보고 `layout: "C"` 선택
|
|
||||||
- page_structure의 역할명이 자유 (배경/본심 고정 아님)
|
|
||||||
- 각 역할에 zone 배정 (상단/하단좌/하단우 등)
|
|
||||||
- 하드코딩 아님: Kei가 콘텐츠마다 다른 선택을 함
|
|
||||||
|
|
||||||
### X-3: space_allocator 템플릿 기반 컨테이너 생성
|
## 관련 코드
|
||||||
- 선택된 템플릿에 따라 컨테이너 좌표/크기 생성
|
|
||||||
- weight 비율로 각 영역 크기 결정
|
|
||||||
- `select_preset()` → `build_layout_from_template()`
|
|
||||||
- 하드코딩 아님: 템플릿 구조 + weight + 슬라이드 크기로 동적 계산
|
|
||||||
|
|
||||||
### X-4: block_assembler / assemble_stage2 동적 역할
|
- `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()`
|
||||||
- `["배경", "본심", "첨부", "결론"]` 고정 루프 → `page_structure.keys()` 동적 루프
|
- `src/pipeline.py`: Stage 1.5a에서 preset 선택
|
||||||
- 좌표 계산은 X-3에서 생성한 컨테이너 정보 사용
|
- `src/kei_client.py`: Stage 1A에서 role 태그 부여
|
||||||
- 색상/폰트: 역할 수에 맞게 동적 배분
|
- `src/space_allocator.py`: zone 기반 컨테이너 배분
|
||||||
- 하드코딩 아님: 역할 수가 3개든 5개든 동작
|
|
||||||
|
|
||||||
### X-5: 나머지 파일 동적화
|
## 상태
|
||||||
- step_visualizer: before/after 시각화에서 동적 역할 루프
|
|
||||||
- fit_verifier: 4역할 고정 → 동적 역할
|
|
||||||
- html_generator: Sonnet에게 동적 영역 수만큼 생성 요청
|
|
||||||
- renderer: 동적 grid-template 생성
|
|
||||||
- 하드코딩 아님: 모두 ctx.containers.keys() 기반
|
|
||||||
|
|
||||||
### X-6: 검증
|
Phase W (before→filled→after 파이프라인) 완료 후 착수.
|
||||||
- 01번 MDX → A템플릿 → 기존과 동일하거나 더 나은 결과
|
|
||||||
- 02번 MDX → C템플릿 → 상단 강조 + 하단 3분할
|
|
||||||
- 03번 MDX → B템플릿 → 상단 요건 + 하단 2분할
|
|
||||||
- 텍스트가 컨테이너 안에 있음
|
|
||||||
- 공란 최소
|
|
||||||
- 01번이 깨지면 롤백
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주의사항
|
|
||||||
|
|
||||||
- 하드코딩 절대 금지: 특정 MDX에만 동작하는 코드 없음
|
|
||||||
- 01번 보호: Phase X 전에 git commit 완료 (1f7579c). 깨지면 롤백
|
|
||||||
- 점진적 진행: X-1 → X-2 후 Kei 응답 확인 → X-3~X-5 순차 진행
|
|
||||||
- 각 단계마다 검증
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 관련 코드 (고정 역할 참조 현황)
|
|
||||||
|
|
||||||
| 파일 | 참조 수 | 수정 범위 |
|
|
||||||
|------|---------|----------|
|
|
||||||
| src/html_generator.py | 54건 | X-5 |
|
|
||||||
| src/step_visualizer.py | 32건 | X-5 |
|
|
||||||
| src/space_allocator.py | 26건 | X-3 |
|
|
||||||
| scripts/assemble_stage2.py | 26건 | X-4 |
|
|
||||||
| src/kei_client.py | 18건 | X-2 |
|
|
||||||
| src/block_assembler.py | 17건 | X-4 |
|
|
||||||
| src/fit_verifier.py | 16건 | X-5 |
|
|
||||||
| src/pipeline.py | 15건 | X-3~X-5 |
|
|
||||||
| src/renderer.py | 7건 | X-5 |
|
|
||||||
| src/pipeline_context.py | 4건 | 필요 시 |
|
|
||||||
| **합계** | **215건** | |
|
|
||||||
|
|||||||
@@ -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/>
|
||||||
|
|||||||
+513
-132
@@ -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, []):
|
||||||
@@ -130,7 +135,7 @@ def assemble(run_dir: str):
|
|||||||
return p
|
return p
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def popup_to_compact_table(popup_content, font_size, role_name=""):
|
def popup_to_compact_table(popup_content, font_size):
|
||||||
"""팝업의 마크다운 표를 compact HTML 테이블로 변환."""
|
"""팝업의 마크다운 표를 compact HTML 테이블로 변환."""
|
||||||
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
|
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
|
||||||
popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
|
popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
|
||||||
@@ -164,7 +169,7 @@ def assemble(run_dir: str):
|
|||||||
align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
|
align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
|
||||||
weight = "600" if ci == 0 else "400"
|
weight = "600" if ci == 0 else "400"
|
||||||
color = "#1e40af" if ci == 0 else "#64748b"
|
color = "#1e40af" if ci == 0 else "#64748b"
|
||||||
cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, role_name)}</div>'
|
cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, "본심")}</div>'
|
||||||
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'
|
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -189,8 +194,8 @@ def assemble(run_dir: str):
|
|||||||
all_css = set()
|
all_css = set()
|
||||||
role_htmls = {}
|
role_htmls = {}
|
||||||
|
|
||||||
# ── 각 역할별 조립 (동적 — page_structure의 모든 역할) ──
|
# ── 각 역할별 조립 ──
|
||||||
for role in ps.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
info = ps.get(role, {})
|
info = ps.get(role, {})
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
continue
|
continue
|
||||||
@@ -209,14 +214,8 @@ def assemble(run_dir: str):
|
|||||||
ci = containers.get(role, {})
|
ci = containers.get(role, {})
|
||||||
h = int(redist.get(role, ci.get("height_px", 0)))
|
h = int(redist.get(role, ci.get("height_px", 0)))
|
||||||
w = ci.get("width_px", 0)
|
w = ci.get("width_px", 0)
|
||||||
# font_size: zone 기반 동적 결정
|
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
|
||||||
zone = ci.get("zone", "") if isinstance(ci, dict) else ""
|
font_size = fh.get(font_key, 12)
|
||||||
if zone == "footer":
|
|
||||||
font_size = fh.get("key_msg", 14)
|
|
||||||
elif zone == "sidebar":
|
|
||||||
font_size = fh.get("sidebar", 10)
|
|
||||||
else:
|
|
||||||
font_size = fh.get("core", 12)
|
|
||||||
|
|
||||||
block_css, block_body = extract_block_html(ref_html)
|
block_css, block_body = extract_block_html(ref_html)
|
||||||
if block_css:
|
if block_css:
|
||||||
@@ -247,8 +246,7 @@ def assemble(run_dir: str):
|
|||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
# 결론
|
# 결론
|
||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
# Phase X: zone 기반 분기 (역할명이 아닌 zone/블록타입으로)
|
if role == "결론":
|
||||||
if zone == "footer":
|
|
||||||
assembled = block_body
|
assembled = block_body
|
||||||
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
|
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
|
||||||
assembled = re.sub(r'>부연 설명<', '><', assembled)
|
assembled = re.sub(r'>부연 설명<', '><', assembled)
|
||||||
@@ -257,7 +255,7 @@ def assemble(run_dir: str):
|
|||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
|
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
|
||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
elif "block-card-num" in block_body: # 카드 넘버링 블록
|
elif role == "첨부":
|
||||||
st = get_text(primary_topic)
|
st = get_text(primary_topic)
|
||||||
# 마크다운 bold → HTML
|
# 마크다운 bold → HTML
|
||||||
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
|
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
|
||||||
@@ -328,7 +326,7 @@ def assemble(run_dir: str):
|
|||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조
|
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조
|
||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
elif "block-callout-warn" in block_body or "block-callout-sol" in block_body: # callout 블록
|
elif role == "배경":
|
||||||
sub_html = ""
|
sub_html = ""
|
||||||
if is_hier and sup_tids:
|
if is_hier and sup_tids:
|
||||||
for st_id in sup_tids:
|
for st_id in sup_tids:
|
||||||
@@ -378,7 +376,7 @@ def assemble(run_dir: str):
|
|||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
# 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
|
# 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
|
||||||
# ════════════════════════════════════
|
# ════════════════════════════════════
|
||||||
elif any(sc.get("name") == "svg" for sc in scs): # 이미지 포함 블록
|
elif role == "본심":
|
||||||
svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
|
svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
|
||||||
text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
|
text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
|
||||||
keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)
|
keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)
|
||||||
@@ -393,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))
|
||||||
|
|
||||||
@@ -468,7 +469,7 @@ def assemble(run_dir: str):
|
|||||||
if popup:
|
if popup:
|
||||||
content = popup.get("content", "")
|
content = popup.get("content", "")
|
||||||
if content.count("|") > 3:
|
if content.count("|") > 3:
|
||||||
compact = popup_to_compact_table(content, font_size, role_name=role)
|
compact = popup_to_compact_table(content, font_size)
|
||||||
if compact:
|
if compact:
|
||||||
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
|
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
|
||||||
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
|
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
|
||||||
@@ -509,120 +510,23 @@ def assemble(run_dir: str):
|
|||||||
f'{keymsg_html}</div>'
|
f'{keymsg_html}</div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
# ── 슬라이드 좌표 ──
|
||||||
# 범용 텍스트 조립 (블록 타입 무관)
|
bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
|
||||||
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
|
core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
|
||||||
bullets_html, _ = structured_to_bullets(all_text, role, font_size)
|
sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
|
||||||
role_htmls[role] = (
|
concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
|
||||||
f'<div style="height:100%;padding:{int(font_size * 0.5)}px;font-size:{font_size}px;line-height:1.4;">'
|
|
||||||
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{topic_title}</div>'
|
|
||||||
f'{bullets_html}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Phase X: 동적 좌표 계산 (block_assembler와 동일 로직) ──
|
bg_top = pad + header_h + gap_block
|
||||||
# zone별로 역할 그룹핑
|
core_top = bg_top + bg_h + gap_small
|
||||||
zone_roles = {}
|
sb_top = bg_top
|
||||||
footer_role = None
|
|
||||||
for role_name in ps.keys():
|
|
||||||
ci = containers.get(role_name, {})
|
|
||||||
z = ci.get("zone", "") if isinstance(ci, dict) else ""
|
|
||||||
if z == "footer":
|
|
||||||
footer_role = role_name
|
|
||||||
else:
|
|
||||||
if z not in zone_roles:
|
|
||||||
zone_roles[z] = []
|
|
||||||
zone_roles[z].append(role_name)
|
|
||||||
|
|
||||||
# row 그룹핑
|
# #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
|
||||||
row_map = {}
|
ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
|
||||||
for zone_name in zone_roles:
|
column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위
|
||||||
if zone_name.startswith("top"):
|
core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥
|
||||||
row = "top"
|
sb_h = column_bottom - sb_top # 첨부: column 바닥까지
|
||||||
elif zone_name.startswith("bottom"):
|
|
||||||
row = "bottom"
|
|
||||||
else:
|
|
||||||
row = "main"
|
|
||||||
if row not in row_map:
|
|
||||||
row_map[row] = []
|
|
||||||
if zone_name not in row_map[row]:
|
|
||||||
row_map[row].append(zone_name)
|
|
||||||
|
|
||||||
# footer 높이
|
|
||||||
footer_h = 0
|
|
||||||
if footer_role:
|
|
||||||
ci = containers.get(footer_role, {})
|
|
||||||
footer_h = int(redist.get(footer_role, ci.get("height_px", 0)))
|
|
||||||
ft_top = slide_h - pad - footer_h
|
|
||||||
|
|
||||||
content_top = pad + header_h + gap_block
|
|
||||||
content_bottom = ft_top - gap_block
|
|
||||||
|
|
||||||
# row별 높이
|
|
||||||
row_weights = {}
|
|
||||||
for row_name, zone_list in row_map.items():
|
|
||||||
w = 0
|
|
||||||
for zn in zone_list:
|
|
||||||
for rn in zone_roles.get(zn, []):
|
|
||||||
w += containers.get(rn, {}).get("weight", 0) if isinstance(containers.get(rn), dict) else 0
|
|
||||||
row_weights[row_name] = w
|
|
||||||
total_rw = sum(row_weights.values()) or 1
|
|
||||||
num_rows = len(row_map)
|
|
||||||
row_gap_total = gap_small * max(0, num_rows - 1)
|
|
||||||
available_h = content_bottom - content_top - row_gap_total
|
|
||||||
|
|
||||||
row_layout = {}
|
|
||||||
current_top = content_top
|
|
||||||
for row_name in sorted(row_map.keys()):
|
|
||||||
rw = row_weights.get(row_name, 1)
|
|
||||||
rh = int(available_h * rw / total_rw)
|
|
||||||
row_layout[row_name] = {"top": current_top, "height": rh}
|
|
||||||
current_top += rh + gap_small
|
|
||||||
|
|
||||||
# 컨테이너 좌표
|
|
||||||
container_boxes = {}
|
|
||||||
_color_palette = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"]
|
|
||||||
all_role_names = list(ps.keys())
|
|
||||||
|
|
||||||
for row_name, zone_list in row_map.items():
|
|
||||||
rl = row_layout[row_name]
|
|
||||||
num_cols = len(zone_list)
|
|
||||||
col_gap_total = gap_block * max(0, num_cols - 1)
|
|
||||||
col_available = inner_w - col_gap_total
|
|
||||||
zone_widths = []
|
|
||||||
for zn in zone_list:
|
|
||||||
roles_in_zone = zone_roles.get(zn, [])
|
|
||||||
if roles_in_zone:
|
|
||||||
w = containers.get(roles_in_zone[0], {}).get("width_px", col_available // num_cols) if isinstance(containers.get(roles_in_zone[0]), dict) else col_available // num_cols
|
|
||||||
else:
|
|
||||||
w = col_available // num_cols
|
|
||||||
zone_widths.append(w)
|
|
||||||
total_zw = sum(zone_widths) or 1
|
|
||||||
zone_widths = [int(col_available * w / total_zw) for w in zone_widths]
|
|
||||||
|
|
||||||
current_left = pad
|
|
||||||
for i, zn in enumerate(zone_list):
|
|
||||||
zw = zone_widths[i]
|
|
||||||
for rn in zone_roles.get(zn, []):
|
|
||||||
container_boxes[rn] = {"left": current_left, "top": rl["top"], "width": zw, "height": rl["height"]}
|
|
||||||
current_left += zw + gap_block
|
|
||||||
|
|
||||||
if footer_role:
|
|
||||||
container_boxes[footer_role] = {"left": pad, "top": ft_top, "width": inner_w, "height": footer_h}
|
|
||||||
|
|
||||||
# HTML 조립
|
|
||||||
css_block = "\n".join(all_css)
|
css_block = "\n".join(all_css)
|
||||||
containers_html = ""
|
|
||||||
for rn in all_role_names:
|
|
||||||
box = container_boxes.get(rn)
|
|
||||||
if not box:
|
|
||||||
continue
|
|
||||||
idx = all_role_names.index(rn) % len(_color_palette)
|
|
||||||
color = _color_palette[idx]
|
|
||||||
containers_html += (
|
|
||||||
f'\n<div style="position:absolute;left:{box["left"]}px;top:{box["top"]}px;'
|
|
||||||
f'width:{box["width"]}px;height:{box["height"]}px;border-radius:6px;overflow:hidden;">'
|
|
||||||
f'{role_htmls.get(rn, "")}</div>\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
<style>
|
<style>
|
||||||
@@ -632,10 +536,28 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
|
|||||||
.bl-sub{{padding-left:1em;}}
|
.bl-sub{{padding-left:1em;}}
|
||||||
{css_block}
|
{css_block}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과</div>
|
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)</div>
|
||||||
|
<div style="font-size:11px;color:#666;margin-bottom:10px;">sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups</div>
|
||||||
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
<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:{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>
|
||||||
{containers_html}
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border-radius:6px;overflow:hidden;">
|
||||||
|
{role_htmls.get("배경", "")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border-radius:6px;overflow:hidden;">
|
||||||
|
{role_htmls.get("본심", "")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border-radius:6px;overflow:hidden;">
|
||||||
|
{role_htmls.get("첨부", "")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border-radius:8px;overflow:hidden;">
|
||||||
|
{role_htmls.get("결론", "")}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div></body></html>"""
|
</div></body></html>"""
|
||||||
|
|
||||||
out = run / "steps" / "stage_2_code_assembled.html"
|
out = run / "steps" / "stage_2_code_assembled.html"
|
||||||
@@ -646,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", "")
|
||||||
|
|||||||
+892
-163
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -369,98 +369,6 @@ LAYOUT_PRESETS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Phase X: 레이아웃 템플릿 (Kei가 선택)
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# 중간 영역의 컨테이너 구조 정의.
|
|
||||||
# 상단(header)과 하단(footer/결론)은 모든 템플릿 공통.
|
|
||||||
# zones의 각 영역:
|
|
||||||
# - width_pct: 슬라이드 내부 폭 대비 % (gap 제외 전)
|
|
||||||
# - row: 어느 행에 속하는지 ("top", "bottom", "full")
|
|
||||||
# - col_span: 해당 행에서 몇 칸 중 하나인지 (행 내 등분)
|
|
||||||
# 크기는 weight와 슬라이드 크기에서 동적 계산. budget_px 없음.
|
|
||||||
LAYOUT_TEMPLATES = {
|
|
||||||
"A": {
|
|
||||||
"name": "body-sidebar",
|
|
||||||
"description": "좌측 본문 흐름 + 우측 참조 사이드바",
|
|
||||||
"rows": [
|
|
||||||
{"name": "main", "zones": ["body", "sidebar"], "splits": [65, 35]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"body": {"row": "main", "desc": "본문 꼭지 (위→아래 순서)"},
|
|
||||||
"sidebar": {"row": "main", "desc": "참조/보조 정보"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"B": {
|
|
||||||
"name": "top-wide-bottom-2col",
|
|
||||||
"description": "상단 강조 + 하단 2분할",
|
|
||||||
"rows": [
|
|
||||||
{"name": "top", "zones": ["top"], "splits": [100]},
|
|
||||||
{"name": "bottom", "zones": ["bottom_left", "bottom_right"], "splits": [50, 50]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"top": {"row": "top", "desc": "강조/핵심 콘텐츠 (전체 폭)"},
|
|
||||||
"bottom_left": {"row": "bottom", "desc": "하단 좌측 항목"},
|
|
||||||
"bottom_right": {"row": "bottom", "desc": "하단 우측 항목"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"C": {
|
|
||||||
"name": "top-wide-bottom-3col",
|
|
||||||
"description": "상단 강조 + 하단 3분할",
|
|
||||||
"rows": [
|
|
||||||
{"name": "top", "zones": ["top"], "splits": [100]},
|
|
||||||
{"name": "bottom", "zones": ["bottom_1", "bottom_2", "bottom_3"], "splits": [33, 34, 33]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"top": {"row": "top", "desc": "강조/핵심 콘텐츠 (전체 폭)"},
|
|
||||||
"bottom_1": {"row": "bottom", "desc": "하단 첫째 항목"},
|
|
||||||
"bottom_2": {"row": "bottom", "desc": "하단 둘째 항목"},
|
|
||||||
"bottom_3": {"row": "bottom", "desc": "하단 셋째 항목"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"D": {
|
|
||||||
"name": "2col-equal",
|
|
||||||
"description": "좌우 대등 2분할",
|
|
||||||
"rows": [
|
|
||||||
{"name": "main", "zones": ["left", "right"], "splits": [50, 50]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"left": {"row": "main", "desc": "좌측 항목"},
|
|
||||||
"right": {"row": "main", "desc": "우측 항목"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"E": {
|
|
||||||
"name": "single-full",
|
|
||||||
"description": "단일 전체 (분할 불필요)",
|
|
||||||
"rows": [
|
|
||||||
{"name": "main", "zones": ["main"], "splits": [100]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"main": {"row": "main", "desc": "본심 전체"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"F": {
|
|
||||||
"name": "top-wide-bottom-2col-sidebar",
|
|
||||||
"description": "상단 강조 + 하단 2분할 + 우측 참조",
|
|
||||||
"rows": [
|
|
||||||
{"name": "top", "zones": ["top", "sidebar"], "splits": [65, 35]},
|
|
||||||
{"name": "bottom", "zones": ["bottom_left", "bottom_right", "sidebar"], "splits": [32, 33, 35]},
|
|
||||||
],
|
|
||||||
"zones": {
|
|
||||||
"top": {"row": "top", "desc": "강조/핵심 콘텐츠"},
|
|
||||||
"sidebar": {"row": "top+bottom", "desc": "참조/보조 정보 (세로 관통)"},
|
|
||||||
"bottom_left": {"row": "bottom", "desc": "하단 좌측 항목"},
|
|
||||||
"bottom_right": {"row": "bottom", "desc": "하단 우측 항목"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_template(template_id: str) -> dict[str, Any] | None:
|
|
||||||
"""템플릿 ID(A~F)로 템플릿 정의를 반환한다."""
|
|
||||||
return LAYOUT_TEMPLATES.get(template_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────
|
# ──────────────────────────────────────
|
||||||
# Step A: 프리셋 선택 (규칙 기반)
|
# Step A: 프리셋 선택 (규칙 기반)
|
||||||
# ──────────────────────────────────────
|
# ──────────────────────────────────────
|
||||||
|
|||||||
+22
-20
@@ -304,16 +304,8 @@ def calculate_fit(
|
|||||||
if normalized is None:
|
if normalized is None:
|
||||||
normalized = {}
|
normalized = {}
|
||||||
|
|
||||||
# Phase X: zone 기반 font/line_height 결정 (고정 역할명 불필요)
|
role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
def _font_key_for_role(role_name):
|
role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3}
|
||||||
zone = containers.get(role_name, {}).get("zone", "")
|
|
||||||
if zone == "footer": return "key_msg"
|
|
||||||
if zone == "sidebar": return "sidebar"
|
|
||||||
return "core"
|
|
||||||
def _line_height_for_role(role_name):
|
|
||||||
zone = containers.get(role_name, {}).get("zone", "")
|
|
||||||
if zone == "footer": return 1.3
|
|
||||||
return 1.5
|
|
||||||
|
|
||||||
analysis = FitAnalysis()
|
analysis = FitAnalysis()
|
||||||
|
|
||||||
@@ -335,9 +327,9 @@ def calculate_fit(
|
|||||||
allocated_h = container.get("height_px", 0)
|
allocated_h = container.get("height_px", 0)
|
||||||
width_px = container.get("width_px", 0)
|
width_px = container.get("width_px", 0)
|
||||||
|
|
||||||
font_key = _font_key_for_role(role)
|
font_key = role_font_map.get(role, "core")
|
||||||
font_size = font_hierarchy.get(font_key, 12)
|
font_size = font_hierarchy.get(font_key, 12)
|
||||||
line_h = _line_height_for_role(role)
|
line_h = role_line_height.get(role, 1.5)
|
||||||
|
|
||||||
# V-1 출력: 꼭지별 블록 리스트
|
# V-1 출력: 꼭지별 블록 리스트
|
||||||
ref_list = references.get(role, [])
|
ref_list = references.get(role, [])
|
||||||
@@ -383,8 +375,7 @@ def calculate_fit(
|
|||||||
has_image = img_h > 0
|
has_image = img_h > 0
|
||||||
|
|
||||||
# ── 4. key-msg (본심에만) ──
|
# ── 4. key-msg (본심에만) ──
|
||||||
# keymsg: 이미지가 있는 역할에만 (zone 무관, 블록 기반)
|
has_keymsg = (role == "본심" and core_message)
|
||||||
has_keymsg = (has_image and core_message)
|
|
||||||
keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0
|
keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0
|
||||||
|
|
||||||
# ── 5. 블록 오버헤드 ──
|
# ── 5. 블록 오버헤드 ──
|
||||||
@@ -494,7 +485,12 @@ def build_escalation_report(analysis: FitAnalysis) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# Phase X: ROLE_ZONE_MAP 제거. containers에서 zone을 직접 읽음.
|
ROLE_ZONE_MAP = {
|
||||||
|
"본심": "body",
|
||||||
|
"배경": "body",
|
||||||
|
"첨부": "sidebar",
|
||||||
|
"결론": "footer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def redistribute(
|
def redistribute(
|
||||||
@@ -505,12 +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:
|
||||||
# Phase X: containers에서 zone 직접 읽기 (고정 맵 불필요)
|
# containers에 zone 정보가 있으면 그걸 사용, 없으면 ROLE_ZONE_MAP fallback
|
||||||
ci = containers.get(role, {})
|
ci = containers.get(role)
|
||||||
zone = ci.get("zone", "body") if isinstance(ci, dict) else "body"
|
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)
|
||||||
@@ -945,8 +947,8 @@ def calculate_sub_layout(
|
|||||||
layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px)
|
layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px)
|
||||||
|
|
||||||
# 제목 높이: font_size * line_height + margin
|
# 제목 높이: font_size * line_height + margin
|
||||||
# Phase X: font_key는 font_hierarchy에서 가장 가까운 키 사용
|
role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
font_key = "core" # 기본값. zone 정보가 없으면 core
|
font_key = role_font_map.get(role, "core")
|
||||||
title_font = font_hierarchy.get(font_key, 12)
|
title_font = font_hierarchy.get(font_key, 12)
|
||||||
title_h = title_font * 1.5 + tokens["spacing_small"]
|
title_h = title_font * 1.5 + tokens["spacing_small"]
|
||||||
|
|
||||||
|
|||||||
+109
-65
@@ -82,14 +82,9 @@ def build_area_prompt(
|
|||||||
refs = phase_t.get("references", {})
|
refs = phase_t.get("references", {})
|
||||||
budgets = phase_t.get("design_budgets", {})
|
budgets = phase_t.get("design_budgets", {})
|
||||||
|
|
||||||
# Phase X: zone 기반 font 결정 (고정 역할명 불필요)
|
# 역할별 폰트 매핑
|
||||||
# phase_t에서 이 역할의 zone을 찾아 font 매핑
|
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
_ps = phase_t.get("fit_result", {}).get("redistribution", {})
|
font_size = fh.get(role_font_map.get(role, "core"), 12)
|
||||||
# containers 정보가 있으면 zone 사용, 없으면 core 기본
|
|
||||||
_containers = phase_t.get("_containers", {})
|
|
||||||
_zone = _containers.get(role, {}).get("zone", "") if _containers else ""
|
|
||||||
_fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core")
|
|
||||||
font_size = fh.get(_fk, 12)
|
|
||||||
|
|
||||||
# 들여쓰기 (폰트 크기 기반)
|
# 들여쓰기 (폰트 크기 기반)
|
||||||
indent_pl, indent_ti = _calc_indent(font_size)
|
indent_pl, indent_ti = _calc_indent(font_size)
|
||||||
@@ -197,8 +192,8 @@ def build_area_prompt(
|
|||||||
indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고
|
indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고
|
||||||
둘째줄도 정확히 같은 위치에서 시작한다</div>"""
|
둘째줄도 정확히 같은 위치에서 시작한다</div>"""
|
||||||
|
|
||||||
# ── Phase X: zone 기반 역할 지시 (고정 역할명 불필요) ──
|
# ── 역할별 지시 ──
|
||||||
if _zone in ("top",) and not images: # 상단 강조/도입 영역 (이미지 없음)
|
if role == "배경":
|
||||||
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
|
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
|
||||||
|
|
||||||
## 핵심 원칙
|
## 핵심 원칙
|
||||||
@@ -219,7 +214,7 @@ def build_area_prompt(
|
|||||||
|
|
||||||
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
|
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
|
||||||
|
|
||||||
elif images or _zone in ("main", "body"): # 이미지 포함 또는 메인 콘텐츠
|
elif role == "본심":
|
||||||
img_instruction = ""
|
img_instruction = ""
|
||||||
if images:
|
if images:
|
||||||
for img in images:
|
for img in images:
|
||||||
@@ -269,7 +264,7 @@ def build_area_prompt(
|
|||||||
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
|
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
|
||||||
{img_instruction}""")
|
{img_instruction}""")
|
||||||
|
|
||||||
elif _zone == "sidebar": # 참조/보조 sidebar
|
elif role == "첨부":
|
||||||
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
|
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
|
||||||
|
|
||||||
## 크기 (TP-3: 잘림 방지)
|
## 크기 (TP-3: 잘림 방지)
|
||||||
@@ -291,7 +286,7 @@ def build_area_prompt(
|
|||||||
- 카드 간 간격 8px.
|
- 카드 간 간격 8px.
|
||||||
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
|
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
|
||||||
|
|
||||||
elif _zone == "footer": # 결론/footer
|
elif role == "결론":
|
||||||
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
|
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
|
||||||
|
|
||||||
## 크기
|
## 크기
|
||||||
@@ -301,21 +296,6 @@ def build_area_prompt(
|
|||||||
- 핵심 메시지: {font_size}px bold white
|
- 핵심 메시지: {font_size}px bold white
|
||||||
- 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""")
|
- 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""")
|
||||||
|
|
||||||
else: # bottom_left, bottom_right, bottom_1~3 등 범용 콘텐츠 영역
|
|
||||||
parts.append(f"""다음 콘텐츠를 "{role}" 영역 HTML로 만들어라.
|
|
||||||
|
|
||||||
## 크기
|
|
||||||
- width: 100%, height: {height_px}px
|
|
||||||
- 이 크기 안에 모든 내용이 들어가야 한다.
|
|
||||||
|
|
||||||
## 폰트
|
|
||||||
- 본문: {font_size}px. 제목: {font_size + 1}px bold.
|
|
||||||
|
|
||||||
## 들여쓰기
|
|
||||||
{indent_example}
|
|
||||||
|
|
||||||
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
|
|
||||||
|
|
||||||
# ── 공통: 콘텐츠 ──
|
# ── 공통: 콘텐츠 ──
|
||||||
parts.append(f"""
|
parts.append(f"""
|
||||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||||
@@ -564,10 +544,8 @@ def _build_phase_t_supplement(role: str, analysis: dict) -> str:
|
|||||||
|
|
||||||
# 1. 폰트 위계 (역할별 확정 폰트)
|
# 1. 폰트 위계 (역할별 확정 폰트)
|
||||||
fh = phase_t.get("font_hierarchy", {})
|
fh = phase_t.get("font_hierarchy", {})
|
||||||
# Phase X: zone 기반 font 결정
|
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"}
|
||||||
_regen_containers = phase_t.get("_containers", {})
|
font_key = role_font_map.get(role, "core")
|
||||||
_regen_zone = _regen_containers.get(role, {}).get("zone", "") if _regen_containers else ""
|
|
||||||
font_key = "key_msg" if _regen_zone == "footer" else ("sidebar" if _regen_zone == "sidebar" else "core")
|
|
||||||
font_size = fh.get(font_key, 12)
|
font_size = fh.get(font_key, 12)
|
||||||
parts.append(
|
parts.append(
|
||||||
f"\n[폰트 위계 — 반드시 준수]\n"
|
f"\n[폰트 위계 — 반드시 준수]\n"
|
||||||
@@ -636,66 +614,132 @@ async def generate_slide_html(
|
|||||||
return []
|
return []
|
||||||
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
||||||
|
|
||||||
# Phase X: 동적 역할 루프 — page_structure의 모든 역할을 순회
|
bg_topics = get_topics_for_role("배경")
|
||||||
|
core_topics = get_topics_for_role("본심")
|
||||||
|
ref_topics = get_topics_for_role("첨부")
|
||||||
|
conclusion_topics = get_topics_for_role("결론")
|
||||||
|
|
||||||
|
bg_spec = container_specs.get("배경")
|
||||||
|
core_spec = container_specs.get("본심")
|
||||||
|
ref_spec = container_specs.get("첨부")
|
||||||
|
concl_spec = container_specs.get("결론")
|
||||||
|
|
||||||
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
|
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
|
||||||
|
|
||||||
|
# ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ──
|
||||||
from src.fit_verifier import _load_design_tokens
|
from src.fit_verifier import _load_design_tokens
|
||||||
tokens = _load_design_tokens()
|
tokens = _load_design_tokens()
|
||||||
|
bg_h = bg_spec.height_px if bg_spec else 0
|
||||||
|
core_h = core_spec.height_px if core_spec else 0
|
||||||
|
footer_h = concl_spec.height_px if concl_spec else 0
|
||||||
|
sidebar_h = ref_spec.height_px if ref_spec else 0
|
||||||
|
# body zone = 배경 + 본심 + gap
|
||||||
bg_core_gap = tokens["spacing_small"]
|
bg_core_gap = tokens["spacing_small"]
|
||||||
|
body_zone_h = bg_h + core_h + (bg_core_gap if bg_topics and core_topics else 0)
|
||||||
|
sidebar_zone_h = sidebar_h if sidebar_h > 0 else body_zone_h
|
||||||
|
# core_max_h: 본심 컨테이너 높이에서 key-msg 높이를 빼야 Sonnet이 넘치지 않음
|
||||||
phase_t = analysis.get("phase_t", {})
|
phase_t = analysis.get("phase_t", {})
|
||||||
|
core_sub = phase_t.get("sub_layouts", {}).get("본심", {})
|
||||||
|
keymsg_sub_h = 0
|
||||||
|
for sc in core_sub.get("sub_containers", []):
|
||||||
|
if sc.get("name") == "keymsg":
|
||||||
|
keymsg_sub_h = sc.get("height_px", 0)
|
||||||
|
core_max_h = core_h - keymsg_sub_h if core_h > 0 else (body_zone_h - bg_h - bg_core_gap if bg_topics else body_zone_h)
|
||||||
|
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px (keymsg={keymsg_sub_h}px 제외)")
|
||||||
|
|
||||||
|
# Phase T context
|
||||||
|
phase_t = analysis.get("phase_t", {})
|
||||||
|
|
||||||
|
# 원본 텍스트 매핑
|
||||||
sections = _slice_mdx_sections(content)
|
sections = _slice_mdx_sections(content)
|
||||||
|
|
||||||
|
# ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ──
|
||||||
def _get_role_content(role_topics):
|
def _get_role_content(role_topics):
|
||||||
|
"""structured_text를 우선 사용. 없으면 기존 sections 매칭."""
|
||||||
texts = []
|
texts = []
|
||||||
for t in role_topics:
|
for t in role_topics:
|
||||||
st = t.get("structured_text", "")
|
st = t.get("structured_text", "")
|
||||||
if st:
|
if st:
|
||||||
texts.append(st)
|
texts.append(st)
|
||||||
else:
|
else:
|
||||||
|
# fallback: source_hint 키워드로 sections에서 매칭
|
||||||
keywords = _extract_keywords_from_hints([t])
|
keywords = _extract_keywords_from_hints([t])
|
||||||
matched = _map_sections_for_role(sections, [t], keywords)
|
matched = _map_sections_for_role(sections, [t], keywords)
|
||||||
if matched:
|
if matched:
|
||||||
texts.append(matched)
|
texts.append(matched)
|
||||||
return "\n\n".join(texts) if texts else ""
|
return "\n\n".join(texts) if texts else ""
|
||||||
|
|
||||||
for role_name, role_info in page_struct.items():
|
# ── 배경 ──
|
||||||
if not isinstance(role_info, dict):
|
if bg_topics:
|
||||||
continue
|
logger.info("[Phase T] 배경 생성...")
|
||||||
role_topics = get_topics_for_role(role_name)
|
bg_content = _get_role_content(bg_topics)
|
||||||
if not role_topics:
|
body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0)
|
||||||
continue
|
|
||||||
spec = container_specs.get(role_name)
|
|
||||||
if not spec:
|
|
||||||
continue
|
|
||||||
|
|
||||||
zone = spec.zone
|
|
||||||
role_content = _get_role_content(role_topics)
|
|
||||||
role_images = [img for img in images if img.get("topic_id") in [t["id"] for t in role_topics]]
|
|
||||||
|
|
||||||
logger.info(f"[Phase X] {role_name}({zone}) 생성...")
|
|
||||||
prompt = build_area_prompt(
|
prompt = build_area_prompt(
|
||||||
role=role_name,
|
role="배경",
|
||||||
content_block=role_content,
|
content_block=bg_content,
|
||||||
phase_t=phase_t,
|
phase_t=phase_t,
|
||||||
height_px=spec.height_px,
|
height_px=bg_h,
|
||||||
width_px=spec.width_px,
|
width_px=body_width,
|
||||||
images=role_images if role_images else None,
|
|
||||||
core_message=analysis.get("core_message", "") if zone != "footer" else "",
|
|
||||||
)
|
)
|
||||||
html = await _call_claude(client, prompt)
|
html = await _call_claude(client, prompt)
|
||||||
if html:
|
if html:
|
||||||
if role_images:
|
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
|
||||||
html = _replace_img_placeholder(html, images)
|
logger.info(f"[Phase T] 배경 완료: {len(html)}자")
|
||||||
# zone에 따라 적절한 result 키에 추가
|
|
||||||
if zone == "footer":
|
|
||||||
result["footer_html"] += html + "\n"
|
|
||||||
elif zone == "sidebar":
|
|
||||||
result["sidebar_html"] += html + "\n"
|
|
||||||
else:
|
|
||||||
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
|
|
||||||
logger.info(f"[Phase X] {role_name} 완료: {len(html)}자")
|
|
||||||
|
|
||||||
# Phase X: 동적 루프에서 모든 역할 처리 완료. 기존 sidebar/footer 개별 블록 제거.
|
# ── 본심 ──
|
||||||
|
if core_topics:
|
||||||
|
logger.info("[Phase T] 본심 생성...")
|
||||||
|
core_content = _get_role_content(core_topics)
|
||||||
|
core_images = [img for img in images if img.get("topic_id") in [t["id"] for t in core_topics]]
|
||||||
|
body_width = core_spec.width_px if core_spec else (bg_spec.width_px if bg_spec else 0)
|
||||||
|
prompt = build_area_prompt(
|
||||||
|
role="본심",
|
||||||
|
content_block=core_content,
|
||||||
|
phase_t=phase_t,
|
||||||
|
height_px=core_max_h,
|
||||||
|
width_px=body_width,
|
||||||
|
images=core_images,
|
||||||
|
core_message=analysis.get("core_message", ""),
|
||||||
|
)
|
||||||
|
html = await _call_claude(client, prompt)
|
||||||
|
if html:
|
||||||
|
html = _replace_img_placeholder(html, images)
|
||||||
|
result["body_html"] += html + "\n"
|
||||||
|
logger.info(f"[Phase T] 본심 완료: {len(html)}자")
|
||||||
|
|
||||||
|
# ── sidebar ──
|
||||||
|
if ref_topics:
|
||||||
|
logger.info("[Phase T] sidebar 생성...")
|
||||||
|
sidebar_content = _get_role_content(ref_topics)
|
||||||
|
sidebar_width = ref_spec.width_px if ref_spec else 0
|
||||||
|
prompt = build_area_prompt(
|
||||||
|
role="첨부",
|
||||||
|
content_block=sidebar_content,
|
||||||
|
phase_t=phase_t,
|
||||||
|
height_px=sidebar_zone_h,
|
||||||
|
width_px=sidebar_width,
|
||||||
|
)
|
||||||
|
html = await _call_claude(client, prompt)
|
||||||
|
if html:
|
||||||
|
result["sidebar_html"] = html
|
||||||
|
logger.info(f"[Phase T] sidebar 완료: {len(html)}자")
|
||||||
|
|
||||||
|
# ── footer ──
|
||||||
|
if conclusion_topics:
|
||||||
|
logger.info("[Phase T] footer 생성...")
|
||||||
|
footer_content = _get_role_content(conclusion_topics) or _get_conclusion(content)
|
||||||
|
footer_width = concl_spec.width_px if concl_spec else 0
|
||||||
|
prompt = build_area_prompt(
|
||||||
|
role="결론",
|
||||||
|
content_block=footer_content.strip(),
|
||||||
|
phase_t=phase_t,
|
||||||
|
height_px=concl_spec.height_px if concl_spec else 0,
|
||||||
|
width_px=footer_width,
|
||||||
|
)
|
||||||
|
html = await _call_claude(client, prompt)
|
||||||
|
if html:
|
||||||
|
result["footer_html"] = html
|
||||||
|
logger.info(f"[Phase T] footer 완료: {len(html)}자")
|
||||||
|
|
||||||
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
|
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
|
||||||
return result
|
return result
|
||||||
|
|||||||
+121
-77
@@ -23,69 +23,97 @@ KEI_PROMPT = (
|
|||||||
"- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
|
"- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
|
||||||
"- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
|
"- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
|
||||||
"- core_message 필드에 기록.\n\n"
|
"- core_message 필드에 기록.\n\n"
|
||||||
"## 2단계: 콘텐츠 분석 및 꼭지 설계\n"
|
"## 2단계: 정보 구조 파악\n"
|
||||||
"콘텐츠를 분석하여 꼭지(topic)를 나눠라.\n"
|
"- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n"
|
||||||
"- 꼭지 수와 역할명은 **콘텐츠에 맞게 자유롭게** 결정하라.\n"
|
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
|
||||||
"- '배경/본심/첨부/결론' 같은 고정 틀에 억지로 끼우지 마라.\n"
|
"- info_structure 필드에 기술.\n\n"
|
||||||
"- 배경이 없으면 배경을 만들지 마라. 3분할이 적절하면 3분할하라.\n"
|
"## 3단계: 슬라이드 스토리라인 설계\n"
|
||||||
"- 결론 꼭지는 반드시 1개 있어야 한다 (슬라이드 하단 핵심 한 줄).\n"
|
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
||||||
|
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
||||||
|
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
|
||||||
|
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
|
||||||
|
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
|
||||||
|
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
|
||||||
|
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
|
||||||
|
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
|
||||||
|
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\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"
|
||||||
|
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
||||||
|
"page_structure 필드에 기록.\n\n"
|
||||||
|
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
|
||||||
|
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
|
||||||
|
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
|
||||||
|
" 임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n"
|
||||||
|
"- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n"
|
||||||
|
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
|
||||||
|
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
|
||||||
|
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
|
||||||
|
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
|
||||||
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
|
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
|
||||||
"## 3단계: 레이아웃 템플릿 선택\n"
|
"## 배치 규칙\n"
|
||||||
"꼭지 구조를 보고, 아래 템플릿 중 **가장 적합한 것을 선택**하라.\n\n"
|
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
||||||
"### 템플릿 옵션:\n"
|
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
||||||
"**A. body-sidebar** — 좌측 본문 + 우측 참조\n"
|
"- 결론은 layer: 'conclusion' → 하단 배치\n"
|
||||||
" zones: body(65%), sidebar(35%) — 1행\n"
|
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
|
||||||
" 적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠\n\n"
|
|
||||||
"**B. top-wide + bottom-2col** — 상단 강조 + 하단 2분할\n"
|
|
||||||
" zones: top(100%) / bottom_left(50%), bottom_right(50%) — 2행\n"
|
|
||||||
" 적합: 핵심 1개 + 두 가지 측면 비교/설명\n\n"
|
|
||||||
"**C. top-wide + bottom-3col** — 상단 강조 + 하단 3분할\n"
|
|
||||||
" zones: top(100%) / bottom_1(33%), bottom_2(34%), bottom_3(33%) — 2행\n"
|
|
||||||
" 적합: 핵심 1개 + 세 가지 항목 병렬\n\n"
|
|
||||||
"**D. 2col-equal** — 좌우 대등 2분할\n"
|
|
||||||
" zones: left(50%), right(50%) — 1행\n"
|
|
||||||
" 적합: 두 가지 비교/대비\n\n"
|
|
||||||
"**E. single-full** — 단일 전체\n"
|
|
||||||
" zones: main(100%) — 1행\n"
|
|
||||||
" 적합: 하나의 흐름, 분할 불필요\n\n"
|
|
||||||
"**F. top-wide + bottom-2col + sidebar** — 상단 강조 + 하단 2분할 + 참조\n"
|
|
||||||
" zones: top(65%)+sidebar(35%) / bottom_left(32%)+bottom_right(33%)+sidebar(35%) — 2행\n"
|
|
||||||
" 적합: B + 참조자료 조합\n\n"
|
|
||||||
"## 4단계: page_structure 작성\n"
|
|
||||||
"선택한 템플릿의 zone에 꼭지를 배정하고 weight(공간 비중, 합계 1.0)를 결정하라.\n"
|
|
||||||
"- page_structure의 키 = zone 이름 + 결론(footer)\n"
|
|
||||||
"- 결론은 항상 \"결론\" 키로, zone은 항상 \"footer\"\n"
|
|
||||||
"- 나머지 키 이름은 콘텐츠에 맞게 자유롭게 (예: \"핵심목표\", \"발주처_기대효과\" 등)\n"
|
|
||||||
"- 각 항목에 zone, topic_ids, weight를 기록\n"
|
|
||||||
"- **weight는 콘텐츠에 따라 매번 달라진다. 고정값이 아니다.**\n\n"
|
|
||||||
"## 원본 텍스트 보존 원칙\n"
|
|
||||||
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
|
|
||||||
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
|
|
||||||
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n\n"
|
|
||||||
"## 규칙\n"
|
|
||||||
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
|
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
|
||||||
"- 1페이지 적정 꼭지: 3~6개. 분량에 맞게.\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, '
|
||||||
'"layout_template": "B", '
|
'"layout_template": "A", '
|
||||||
'"page_structure": {'
|
'"page_structure": {'
|
||||||
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.25}, '
|
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
|
||||||
'"발주처_기대효과": {"zone": "bottom_1", "topic_ids": [2], "weight": 0.20}, '
|
'"배경": {"topic_ids": [1], "weight": 0.15}, '
|
||||||
'"설계사_기대효과": {"zone": "bottom_2", "topic_ids": [3], "weight": 0.20}, '
|
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
|
||||||
'"시공사_기대효과": {"zone": "bottom_3", "topic_ids": [4], "weight": 0.20}, '
|
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
|
||||||
'"결론": {"zone": "footer", "topic_ids": [5], "weight": 0.15}}, '
|
|
||||||
'"topics": ['
|
'"topics": ['
|
||||||
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
||||||
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
|
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
|
||||||
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
|
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
|
||||||
|
'"layer": "intro|core|supporting|conclusion", '
|
||||||
|
'"role": "flow|reference", '
|
||||||
|
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
|
||||||
|
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||||
'"content_type": "text|image|table|mixed", '
|
'"content_type": "text|image|table|mixed", '
|
||||||
'"page": 1}], '
|
'"detail_target": false, "page": 1}], '
|
||||||
'"images": [{"topic_id": 1, "description": "이미지 설명"}], '
|
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
|
||||||
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "description": "표 설명"}]}\n'
|
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\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"
|
||||||
"## 콘텐츠:\n"
|
"## 콘텐츠:\n"
|
||||||
)
|
)
|
||||||
@@ -235,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": ['
|
||||||
@@ -1271,8 +1305,8 @@ async def call_kei_bold_keywords(
|
|||||||
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
|
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
|
||||||
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
|
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
|
||||||
|
|
||||||
JSON으로 응답하라. 키는 아래 역할명을 그대로 사용:
|
JSON으로 응답하라:
|
||||||
{{{", ".join(f'"{r}": ["키워드", ...]' for r in role_texts.keys())}}}
|
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
|
||||||
빈 역할은 빈 리스트로.
|
빈 역할은 빈 리스트로.
|
||||||
|
|
||||||
{role_section}"""
|
{role_section}"""
|
||||||
@@ -1321,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문장"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1347,11 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
|||||||
```
|
```
|
||||||
|
|
||||||
action 종류:
|
action 종류:
|
||||||
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
|
- popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지.
|
||||||
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
|
|
||||||
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
|
|
||||||
- trim: 텍스트 분량을 줄임 (max_chars 지정)
|
|
||||||
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -1359,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에게 판단 요청.
|
||||||
|
|
||||||
@@ -1380,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()
|
||||||
|
|
||||||
|
|||||||
+285
-166
@@ -172,11 +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", "E"),
|
layout_template=analysis_raw.get("layout_template", "A"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
|
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
|
||||||
@@ -249,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}
|
||||||
@@ -300,58 +303,56 @@ async def generate_slide(
|
|||||||
|
|
||||||
# T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식)
|
# T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식)
|
||||||
font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
|
font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
|
||||||
# Phase X: zone 기반 font_hierarchy (역할명 자유)
|
|
||||||
# footer → key_msg, sidebar → sidebar, 나머지 → core/bg
|
|
||||||
# 텍스트 양이 가장 많은 역할 = core, 적은 역할 = bg
|
|
||||||
ps_roles = context.page_structure.roles
|
|
||||||
non_footer = {r: l for r, l in role_text_lengths.items()
|
|
||||||
if ps_roles.get(r, {}).get("zone") != "footer"}
|
|
||||||
sidebar_roles = {r: l for r, l in role_text_lengths.items()
|
|
||||||
if ps_roles.get(r, {}).get("zone") == "sidebar"}
|
|
||||||
core_roles = {r: l for r, l in non_footer.items() if r not in sidebar_roles}
|
|
||||||
|
|
||||||
# 가장 긴 텍스트 역할의 font → core, 나머지 → bg
|
|
||||||
core_font = max(font_hierarchy_dict.values()) if font_hierarchy_dict else 12.0
|
|
||||||
bg_font = min(v for v in font_hierarchy_dict.values() if v < core_font) if len(font_hierarchy_dict) > 1 else core_font - 1
|
|
||||||
sidebar_font = min(font_hierarchy_dict.values()) if sidebar_roles else bg_font
|
|
||||||
font_hierarchy = FontHierarchy(
|
font_hierarchy = FontHierarchy(
|
||||||
key_msg=14.0,
|
key_msg=font_hierarchy_dict.get("핵심", 14.0),
|
||||||
core=core_font,
|
core=font_hierarchy_dict.get("본심", 12.0),
|
||||||
bg=bg_font,
|
bg=font_hierarchy_dict.get("배경", 11.0),
|
||||||
sidebar=sidebar_font,
|
sidebar=font_hierarchy_dict.get("첨부", 10.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase X: 템플릿 기반 컨테이너 생성
|
# 프리셋 선택 (비율 계산보다 먼저 — 프리셋의 기본 비율을 fallback으로 사용)
|
||||||
from src.design_director import LAYOUT_TEMPLATES, get_template
|
analysis_dict = {
|
||||||
from src.space_allocator import build_containers_from_template
|
"topics": [t.model_dump() for t in context.topics],
|
||||||
|
"page_structure": context.page_structure.roles,
|
||||||
|
}
|
||||||
|
preset_name = select_preset(analysis_dict)
|
||||||
|
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||||
|
|
||||||
# Kei가 선택한 layout_template (Stage 1A에서 저장됨)
|
# T-5: 동적 비율 역산 (sidebar 텍스트 양 기반 + 프리셋 기본 비율)
|
||||||
layout_template_id = context.analysis.layout_template or "E"
|
container_ratio = calculate_dynamic_ratio(
|
||||||
template = get_template(layout_template_id) or LAYOUT_TEMPLATES.get("E", {})
|
role_text_lengths, font_hierarchy_dict,
|
||||||
preset_name = layout_template_id
|
|
||||||
preset = template # 하위 호환
|
|
||||||
|
|
||||||
# 컨테이너 스펙 계산 (Phase X 템플릿 기반)
|
|
||||||
container_specs = build_containers_from_template(
|
|
||||||
page_structure=context.page_structure.roles,
|
|
||||||
template=template,
|
|
||||||
slide_width=settings.slide_width,
|
slide_width=settings.slide_width,
|
||||||
slide_height=settings.slide_height,
|
slide_height=settings.slide_height,
|
||||||
|
preset=preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
# container_ratio: 템플릿에서 추론 (하위 호환)
|
|
||||||
rows = template.get("rows", [])
|
|
||||||
if rows and len(rows[0].get("zones", [])) == 2:
|
|
||||||
splits = rows[0].get("splits", [50, 50])
|
|
||||||
container_ratio = splits
|
|
||||||
else:
|
|
||||||
container_ratio = [100, 0]
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Phase X] 템플릿={layout_template_id}, "
|
f"[T-5] 폰트 위계: 핵심={font_hierarchy.key_msg}, 본심={font_hierarchy.core}, "
|
||||||
f"폰트: core={font_hierarchy.core}, bg={font_hierarchy.bg}, sidebar={font_hierarchy.sidebar}"
|
f"배경={font_hierarchy.bg}, 첨부={font_hierarchy.sidebar} / "
|
||||||
|
f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase X-B: 유형에 따라 컨테이너 생성 분기
|
||||||
|
if context.analysis.layout_template in ("B", "B'"):
|
||||||
|
from src.space_allocator import build_containers_type_b
|
||||||
|
container_specs = build_containers_type_b(
|
||||||
|
page_structure=context.page_structure.roles,
|
||||||
|
slide_width=settings.slide_width,
|
||||||
|
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 = {}
|
||||||
for role, spec in container_specs.items():
|
for role, spec in container_specs.items():
|
||||||
@@ -516,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(
|
||||||
@@ -712,10 +758,9 @@ async def generate_slide(
|
|||||||
text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None)
|
text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None)
|
||||||
if not text_sc:
|
if not text_sc:
|
||||||
continue
|
continue
|
||||||
# 텍스트 줄 수 계산 (zone 기반 font_size)
|
# 텍스트 줄 수 계산
|
||||||
ci = updated_containers.get(role)
|
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
zone = ci.zone if ci else ""
|
fk = role_font_map.get(role, "core")
|
||||||
fk = "key_msg" if zone == "footer" else ("sidebar" if zone == "sidebar" else "core")
|
|
||||||
fs = font_h.get(fk, 12)
|
fs = font_h.get(fk, 12)
|
||||||
# structured_text에서 팝업 마커 찾기
|
# structured_text에서 팝업 마커 찾기
|
||||||
ps_info = context.page_structure.roles.get(role, {})
|
ps_info = context.page_structure.roles.get(role, {})
|
||||||
@@ -733,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 # 공간 부족하면 건너뜀
|
||||||
@@ -760,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,
|
||||||
@@ -791,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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,10 +907,9 @@ async def generate_slide(
|
|||||||
# V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용
|
# V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용
|
||||||
ref_list = context.references.get(role, [])
|
ref_list = context.references.get(role, [])
|
||||||
schema_info = ref_list[0].schema_info if ref_list else {}
|
schema_info = ref_list[0].schema_info if ref_list else {}
|
||||||
# Phase X: zone 기반 font_size
|
font_size = getattr(context.font_hierarchy, {
|
||||||
_zone = ci.zone if ci else ""
|
"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "core"
|
||||||
_fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core")
|
}.get(role, "core"), 12.0)
|
||||||
font_size = getattr(context.font_hierarchy, _fk, 12.0)
|
|
||||||
|
|
||||||
budget = calculate_design_budget(
|
budget = calculate_design_budget(
|
||||||
container_height_px=ci.height_px,
|
container_height_px=ci.height_px,
|
||||||
@@ -844,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 → 기존 함수 인터페이스로 변환
|
||||||
@@ -905,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 = {
|
||||||
@@ -1033,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>
|
||||||
@@ -1070,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,7 +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 = "E" # Phase X: Kei가 선택한 템플릿 ID (A~F)
|
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 최상위에 위치
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ class FontHierarchy(BaseModel):
|
|||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_hierarchy(self):
|
def check_hierarchy(self):
|
||||||
"""폰트 위계 유지 검증: key_msg > core >= bg > sidebar."""
|
"""폰트 위계 유지 검증: key_msg > core >= bg > sidebar."""
|
||||||
if not (self.key_msg >= self.core >= self.bg >= self.sidebar):
|
if not (self.key_msg > self.core >= self.bg > self.sidebar):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
|
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
|
||||||
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
|
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
|
||||||
|
|||||||
+22
-14
@@ -231,7 +231,7 @@ def _group_blocks_by_area(
|
|||||||
container_htmls = []
|
container_htmls = []
|
||||||
assigned_ids = set()
|
assigned_ids = set()
|
||||||
|
|
||||||
role_order = [r for r, s in container_specs.items() if s.zone == area]
|
role_order = ["배경", "본심"]
|
||||||
for role in role_order:
|
for role in role_order:
|
||||||
spec = container_specs.get(role)
|
spec = container_specs.get(role)
|
||||||
if not spec or spec.zone != area:
|
if not spec or spec.zone != area:
|
||||||
@@ -547,19 +547,27 @@ 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"]
|
||||||
# Phase X: 동적 grid-template-rows (역할명 무관)
|
# zone 기반으로 body/footer 높이를 동적 탐색 (유형 A: 배경+본심, 유형 B: zone별)
|
||||||
_footer_h = 0
|
def _find_h(role_name, zone_name=None):
|
||||||
_content_h = 0
|
"""redist → containers 순으로 높이 탐색. role_name 없으면 zone으로 fallback."""
|
||||||
for rn, ci_data in containers.items():
|
h = redist.get(role_name, 0)
|
||||||
h = int(redist.get(rn, ci_data.get("height_px", 0)))
|
if h:
|
||||||
zone = ci_data.get("zone", "")
|
return int(h)
|
||||||
if zone == "footer":
|
ci = containers.get(role_name, {})
|
||||||
_footer_h = h
|
if ci:
|
||||||
else:
|
return int(ci.get("height_px", 0))
|
||||||
_content_h += h
|
if zone_name:
|
||||||
_content_h += _gap_small * max(0, len([c for c in containers.values() if c.get("zone") != "footer"]) - 1)
|
for _r, _c in containers.items():
|
||||||
if _content_h > 0 and _footer_h > 0:
|
if isinstance(_c, dict) and _c.get("zone") == zone_name:
|
||||||
grid_rows = f"auto {_content_h}px {_footer_h}px"
|
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
|
||||||
|
if _body_row_h > 0 and _footer_h > 0:
|
||||||
|
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
|
||||||
else:
|
else:
|
||||||
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
|
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
|
||||||
|
|
||||||
|
|||||||
+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(
|
||||||
|
|||||||
+95
-110
@@ -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))
|
||||||
@@ -434,27 +440,24 @@ def calculate_container_specs(
|
|||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
# Phase X: 템플릿 기반 컨테이너 생성
|
# Phase X-B: 유형 B 컨테이너 생성
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
def build_containers_from_template(
|
def build_containers_type_b(
|
||||||
page_structure: dict[str, Any],
|
page_structure: dict[str, Any],
|
||||||
template: dict[str, Any],
|
|
||||||
slide_width: int = 1280,
|
slide_width: int = 1280,
|
||||||
slide_height: int = 720,
|
slide_height: int = 720,
|
||||||
|
image_sizes: list[dict] | None = None,
|
||||||
) -> dict[str, ContainerSpec]:
|
) -> dict[str, ContainerSpec]:
|
||||||
"""템플릿 + Kei page_structure → 역할별 ContainerSpec.
|
"""유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).
|
||||||
|
|
||||||
템플릿의 rows/zones 구조와 Kei의 weight로 동적 계산.
|
기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
|
||||||
하드코딩 없음. 모든 크기는 슬라이드 크기 + weight + 템플릿 splits에서 계산.
|
모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.3}, ...}
|
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
|
||||||
template: LAYOUT_TEMPLATES["B"] 등
|
|
||||||
slide_width: 슬라이드 너비
|
slide_width: 슬라이드 너비
|
||||||
slide_height: 슬라이드 높이
|
slide_height: 슬라이드 높이
|
||||||
|
image_sizes: 이미지 정보 (비율 계산용)
|
||||||
Returns:
|
|
||||||
{"핵심목표": ContainerSpec(...), "Process_혁신": ContainerSpec(...), ...}
|
|
||||||
"""
|
"""
|
||||||
from src.fit_verifier import _load_design_tokens
|
from src.fit_verifier import _load_design_tokens
|
||||||
tokens = _load_design_tokens()
|
tokens = _load_design_tokens()
|
||||||
@@ -464,120 +467,102 @@ def build_containers_from_template(
|
|||||||
gap_small = tokens["spacing_small"]
|
gap_small = tokens["spacing_small"]
|
||||||
inner_w = slide_width - pad * 2
|
inner_w = slide_width - pad * 2
|
||||||
|
|
||||||
# 결론(footer) weight와 나머지 분리
|
# 역할을 zone별로 분류
|
||||||
footer_role = None
|
top_roles = [] # zone=top
|
||||||
footer_weight = 0
|
bottom_roles = [] # zone=bottom_left, bottom_right
|
||||||
content_roles = {} # zone → [(role_name, info)]
|
footer_role = None # zone=footer
|
||||||
|
|
||||||
for role_name, info in page_structure.items():
|
for role_name, info in page_structure.items():
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
continue
|
continue
|
||||||
zone = info.get("zone", "")
|
zone = info.get("zone", "")
|
||||||
if zone == "footer":
|
if zone == "top":
|
||||||
footer_role = role_name
|
top_roles.append((role_name, info))
|
||||||
footer_weight = info.get("weight", 0.1)
|
elif zone in ("bottom_left", "bottom_right"):
|
||||||
else:
|
bottom_roles.append((role_name, info))
|
||||||
if zone not in content_roles:
|
elif zone == "footer":
|
||||||
content_roles[zone] = []
|
footer_role = (role_name, info)
|
||||||
content_roles[zone].append((role_name, info))
|
|
||||||
|
|
||||||
# 전체 가용 높이 (header + footer 제외)
|
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
|
||||||
total_content_h = slide_height - pad * 2 - header_h - gap_block # header 아래 ~ 슬라이드 바닥-pad
|
total_available = slide_height - pad * 2 - header_h - gap_block
|
||||||
|
|
||||||
# footer 높이
|
# footer 높이: weight 비율 (최소 보장)
|
||||||
footer_h = max(40, int(total_content_h * footer_weight)) if footer_weight > 0 else 53
|
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
|
||||||
# 중간 영역 높이 (footer와 gap 제외)
|
footer_h_raw = int(total_available * footer_weight)
|
||||||
middle_h = total_content_h - footer_h - gap_block
|
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
|
||||||
|
footer_h = max(_footer_min, footer_h_raw)
|
||||||
|
|
||||||
# 템플릿 rows에서 각 row의 높이 계산
|
# 중간 영역: footer + gap 제외
|
||||||
rows = template.get("rows", [])
|
middle_h = total_available - footer_h - gap_block
|
||||||
num_rows = len(rows)
|
|
||||||
row_gap_total = gap_small * max(0, num_rows - 1)
|
|
||||||
available_row_h = middle_h - row_gap_total
|
|
||||||
|
|
||||||
# 각 row의 높이: row에 속한 역할들의 weight 합 비율로
|
# 상단/하단 높이: weight 비율로
|
||||||
row_weights = {}
|
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
|
||||||
for row_def in rows:
|
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
|
||||||
row_name = row_def["name"]
|
total_mid_weight = top_weight + bottom_weight
|
||||||
zones_in_row = row_def["zones"]
|
if total_mid_weight <= 0:
|
||||||
# 이 row에 속한 역할들의 weight 합
|
total_mid_weight = 1
|
||||||
w_sum = 0
|
|
||||||
for zone_name in zones_in_row:
|
|
||||||
for rn, ri in content_roles.get(zone_name, []):
|
|
||||||
w_sum += ri.get("weight", 0)
|
|
||||||
row_weights[row_name] = w_sum
|
|
||||||
|
|
||||||
total_row_weight = sum(row_weights.values())
|
top_h = int(middle_h * top_weight / total_mid_weight)
|
||||||
if total_row_weight <= 0:
|
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
|
||||||
total_row_weight = 1
|
|
||||||
|
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
|
||||||
|
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 = {}
|
specs = {}
|
||||||
|
|
||||||
for row_def in rows:
|
# 상단 역할
|
||||||
row_name = row_def["name"]
|
for role_name, info in top_roles:
|
||||||
zones_in_row = row_def["zones"]
|
specs[role_name] = ContainerSpec(
|
||||||
splits = row_def["splits"]
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# row 높이
|
# 하단 역할: 2분할
|
||||||
row_h = int(available_row_h * row_weights.get(row_name, 1) / total_row_weight)
|
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={},
|
||||||
|
)
|
||||||
|
|
||||||
# row 내 각 zone의 폭
|
# 결론
|
||||||
for i, zone_name in enumerate(zones_in_row):
|
|
||||||
# sidebar가 여러 row에 걸치는 경우 (F 템플릿) — 첫 번째에서만 생성
|
|
||||||
if zone_name in specs:
|
|
||||||
continue
|
|
||||||
|
|
||||||
width_pct = splits[i]
|
|
||||||
# gap 제외 폭 계산
|
|
||||||
num_cols = len(zones_in_row)
|
|
||||||
col_gap_total = gap_block * max(0, num_cols - 1)
|
|
||||||
zone_w = int((inner_w - col_gap_total) * width_pct / 100)
|
|
||||||
|
|
||||||
# sidebar가 여러 row에 걸치면 높이 = middle_h 전체
|
|
||||||
zone_zones = template.get("zones", {})
|
|
||||||
zone_def = zone_zones.get(zone_name, {})
|
|
||||||
if "+" in zone_def.get("row", ""):
|
|
||||||
zone_h = middle_h # top+bottom 관통
|
|
||||||
else:
|
|
||||||
zone_h = row_h
|
|
||||||
|
|
||||||
# 이 zone에 배정된 역할들
|
|
||||||
roles_here = content_roles.get(zone_name, [])
|
|
||||||
if not roles_here:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for rn, ri in roles_here:
|
|
||||||
topic_ids = ri.get("topic_ids", [])
|
|
||||||
weight = ri.get("weight", 0)
|
|
||||||
topic_count = max(1, len(topic_ids))
|
|
||||||
per_topic_px = zone_h // topic_count
|
|
||||||
font_size, padding_px, line_h = _determine_typography(per_topic_px)
|
|
||||||
max_cost = _max_allowed_height_cost(per_topic_px)
|
|
||||||
constraints = _calculate_block_constraints(
|
|
||||||
zone_h, zone_w, topic_count, font_size, line_h, padding_px
|
|
||||||
)
|
|
||||||
constraints["font_size_px"] = font_size
|
|
||||||
constraints["padding_px"] = padding_px
|
|
||||||
constraints["line_height"] = line_h
|
|
||||||
|
|
||||||
specs[rn] = ContainerSpec(
|
|
||||||
role=rn,
|
|
||||||
zone=zone_name,
|
|
||||||
topic_ids=topic_ids,
|
|
||||||
weight=weight,
|
|
||||||
height_px=zone_h,
|
|
||||||
width_px=zone_w,
|
|
||||||
max_height_cost=max_cost,
|
|
||||||
block_constraints=constraints,
|
|
||||||
)
|
|
||||||
|
|
||||||
# footer
|
|
||||||
if footer_role:
|
if footer_role:
|
||||||
footer_info = page_structure[footer_role]
|
rn, info = footer_role
|
||||||
specs[footer_role] = ContainerSpec(
|
specs[rn] = ContainerSpec(
|
||||||
role=footer_role,
|
role=rn,
|
||||||
zone="footer",
|
zone="footer",
|
||||||
topic_ids=footer_info.get("topic_ids", []),
|
topic_ids=info.get("topic_ids", []),
|
||||||
weight=footer_weight,
|
weight=info.get("weight", 0),
|
||||||
height_px=footer_h,
|
height_px=footer_h,
|
||||||
width_px=inner_w,
|
width_px=inner_w,
|
||||||
max_height_cost="low",
|
max_height_cost="low",
|
||||||
@@ -585,7 +570,7 @@ def build_containers_from_template(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[X-3] 템플릿 컨테이너: "
|
f"[X-B-3] 유형 B 컨테이너: "
|
||||||
+ ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items())
|
+ ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items())
|
||||||
)
|
)
|
||||||
return specs
|
return specs
|
||||||
|
|||||||
+36
-100
@@ -29,15 +29,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Phase X: 동적 색상 팔레트 (역할 수에 관계없이 순환)
|
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||||
_COLOR_PALETTE = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"]
|
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
# 하위호환: 기존 코드가 COLORS를 참조할 경우 fallback
|
|
||||||
COLORS = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_color(role: str, all_roles: list[str]) -> str:
|
|
||||||
idx = all_roles.index(role) % len(_COLOR_PALETTE) if role in all_roles else 0
|
|
||||||
return _COLOR_PALETTE[idx]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
|
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
|
||||||
@@ -82,93 +75,36 @@ def _tokens():
|
|||||||
|
|
||||||
|
|
||||||
def _calc_coords(containers: dict, ratio: tuple) -> dict:
|
def _calc_coords(containers: dict, ratio: tuple) -> dict:
|
||||||
"""Phase X: 동적 역할 좌표 계산. containers의 모든 역할을 zone 기반으로 배치."""
|
|
||||||
t = _tokens()
|
t = _tokens()
|
||||||
pad = t.get("spacing_page", 40)
|
pad = t.get("spacing_page", 40)
|
||||||
gap = t.get("spacing_block", 20)
|
gap = t.get("spacing_block", 20)
|
||||||
small = t.get("spacing_small", 8)
|
small = t.get("spacing_small", 8)
|
||||||
header_h = t.get("header_height", 66)
|
header_h = 66
|
||||||
slide_w = t.get("slide_width", 1280)
|
|
||||||
slide_h = t.get("slide_height", 720)
|
inner_w = 1280 - pad * 2
|
||||||
inner_w = slide_w - pad * 2
|
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
|
||||||
|
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
|
||||||
|
|
||||||
def gh(c):
|
def gh(c):
|
||||||
if hasattr(c, "height_px"): return c.height_px
|
if hasattr(c, "height_px"): return c.height_px
|
||||||
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
||||||
def gz(c):
|
|
||||||
if hasattr(c, "zone"): return c.zone
|
|
||||||
return c.get("zone", "main") if isinstance(c, dict) else "main"
|
|
||||||
def gw(c):
|
|
||||||
if hasattr(c, "width_px"): return c.width_px
|
|
||||||
return c.get("width_px", 0) if isinstance(c, dict) else 0
|
|
||||||
|
|
||||||
result = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
|
bg_h = gh(containers.get("배경", {}))
|
||||||
|
core_h = gh(containers.get("본심", {}))
|
||||||
|
sb_h = gh(containers.get("첨부", {}))
|
||||||
|
ft_h = gh(containers.get("결론", {}))
|
||||||
|
|
||||||
# zone별 그룹핑
|
bg_top = pad + header_h + gap
|
||||||
zone_roles = {}
|
core_top = bg_top + bg_h + small
|
||||||
footer_role = None
|
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
|
||||||
for role_name, ci in containers.items():
|
|
||||||
zone = gz(ci)
|
|
||||||
if zone == "footer":
|
|
||||||
footer_role = role_name
|
|
||||||
else:
|
|
||||||
if zone not in zone_roles:
|
|
||||||
zone_roles[zone] = []
|
|
||||||
zone_roles[zone].append(role_name)
|
|
||||||
|
|
||||||
# footer
|
return {
|
||||||
footer_h = gh(containers[footer_role]) if footer_role else 0
|
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
|
||||||
ft_top = slide_h - pad - footer_h
|
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
|
||||||
|
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
|
||||||
content_top = pad + header_h + gap
|
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
|
||||||
content_bottom = ft_top - gap
|
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
|
||||||
|
}
|
||||||
# row 그룹핑
|
|
||||||
row_map = {}
|
|
||||||
for zone_name in zone_roles:
|
|
||||||
if zone_name.startswith("top"):
|
|
||||||
row = "top"
|
|
||||||
elif zone_name.startswith("bottom"):
|
|
||||||
row = "bottom"
|
|
||||||
else:
|
|
||||||
row = "main"
|
|
||||||
if row not in row_map:
|
|
||||||
row_map[row] = []
|
|
||||||
if zone_name not in row_map[row]:
|
|
||||||
row_map[row].append(zone_name)
|
|
||||||
|
|
||||||
# row 높이
|
|
||||||
row_weights = {}
|
|
||||||
for row_name, zone_list in row_map.items():
|
|
||||||
w = sum(gh(containers.get(rn, {})) for zn in zone_list for rn in zone_roles.get(zn, []))
|
|
||||||
row_weights[row_name] = max(1, w)
|
|
||||||
total_rw = sum(row_weights.values()) or 1
|
|
||||||
num_rows = len(row_map)
|
|
||||||
available_h = content_bottom - content_top - small * max(0, num_rows - 1)
|
|
||||||
|
|
||||||
current_top = content_top
|
|
||||||
for row_name in sorted(row_map.keys()):
|
|
||||||
rh = int(available_h * row_weights[row_name] / total_rw)
|
|
||||||
zone_list = row_map[row_name]
|
|
||||||
num_cols = len(zone_list)
|
|
||||||
col_gap_total = gap * max(0, num_cols - 1)
|
|
||||||
col_available = inner_w - col_gap_total
|
|
||||||
# zone 폭
|
|
||||||
zone_widths = [gw(containers.get(zone_roles.get(zn, [""])[0], {})) or (col_available // num_cols) for zn in zone_list]
|
|
||||||
total_zw = sum(zone_widths) or 1
|
|
||||||
zone_widths = [int(col_available * w / total_zw) for w in zone_widths]
|
|
||||||
|
|
||||||
current_left = pad
|
|
||||||
for i, zn in enumerate(zone_list):
|
|
||||||
for rn in zone_roles.get(zn, []):
|
|
||||||
result[rn] = {"l": current_left, "t": current_top, "w": zone_widths[i], "h": rh}
|
|
||||||
current_left += zone_widths[i] + gap
|
|
||||||
current_top += rh + small
|
|
||||||
|
|
||||||
if footer_role:
|
|
||||||
result[footer_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": footer_h}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap(title, subtitle, slide_body):
|
def _wrap(title, subtitle, slide_body):
|
||||||
@@ -332,9 +268,9 @@ def _gen_stage_1_5a(ctx, steps_dir):
|
|||||||
title = ctx.analysis.title or "슬라이드"
|
title = ctx.analysis.title or "슬라이드"
|
||||||
body = _hdr(coords["header"], title)
|
body = _hdr(coords["header"], title)
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
fk = FONT_MAP[role]
|
fk = FONT_MAP[role]
|
||||||
font = getattr(fh, fk, "?")
|
font = getattr(fh, fk, "?")
|
||||||
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
|
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
|
||||||
@@ -358,9 +294,9 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
|
|||||||
ps = ctx.page_structure.roles
|
ps = ctx.page_structure.roles
|
||||||
topic_map = {t.id: t for t in ctx.topics}
|
topic_map = {t.id: t for t in ctx.topics}
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
info = ps.get(role, {})
|
info = ps.get(role, {})
|
||||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
||||||
|
|
||||||
@@ -395,9 +331,9 @@ def _gen_stage_1_5b(ctx, steps_dir):
|
|||||||
title = ctx.analysis.title or "슬라이드"
|
title = ctx.analysis.title or "슬라이드"
|
||||||
body = _hdr(coords["header"], title)
|
body = _hdr(coords["header"], title)
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
ci = ctx.containers.get(role)
|
ci = ctx.containers.get(role)
|
||||||
if not ci:
|
if not ci:
|
||||||
continue
|
continue
|
||||||
@@ -432,9 +368,9 @@ def _gen_stage_1_7(ctx, steps_dir):
|
|||||||
title = ctx.analysis.title or "슬라이드"
|
title = ctx.analysis.title or "슬라이드"
|
||||||
body = _hdr(coords["header"], title)
|
body = _hdr(coords["header"], title)
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
|
|
||||||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
|
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
|
||||||
@@ -480,9 +416,9 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
|
|||||||
title = ctx.analysis.title or "슬라이드"
|
title = ctx.analysis.title or "슬라이드"
|
||||||
body = _hdr(coords["header"], title)
|
body = _hdr(coords["header"], title)
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
|
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
||||||
@@ -526,9 +462,9 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
|
|||||||
bolds = enh.get("bold_keywords", {})
|
bolds = enh.get("bold_keywords", {})
|
||||||
sups = enh.get("supplement_blocks", [])
|
sups = enh.get("supplement_blocks", [])
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
rf = roles_fit.get(role, {})
|
rf = roles_fit.get(role, {})
|
||||||
status = rf.get("fit_status", "?")
|
status = rf.get("fit_status", "?")
|
||||||
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
|
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
|
||||||
@@ -595,9 +531,9 @@ def _gen_stage_1_8_blocks(ctx, steps_dir):
|
|||||||
slide_body = _hdr(coords["header"], title)
|
slide_body = _hdr(coords["header"], title)
|
||||||
legend_lines = []
|
legend_lines = []
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
c = coords[role]
|
c = coords[role]
|
||||||
cl = _get_color(role, list(ctx.containers.keys()))
|
cl = COLORS[role]
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
info = ps.get(role, {})
|
info = ps.get(role, {})
|
||||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
||||||
@@ -744,7 +680,7 @@ def _gen_stage_2(ctx, steps_dir):
|
|||||||
redist = fit.get("redistribution", {})
|
redist = fit.get("redistribution", {})
|
||||||
sections = []
|
sections = []
|
||||||
|
|
||||||
for role in ctx.containers.keys():
|
for role in ["배경", "본심", "첨부", "결론"]:
|
||||||
rhtml = role_htmls.get(role, "")
|
rhtml = role_htmls.get(role, "")
|
||||||
if not rhtml:
|
if not rhtml:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+45
-13
@@ -171,19 +171,37 @@ def validate_stage_1a(
|
|||||||
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Phase X: 결론(footer) 역할 존재 검증 (역할명은 자유, zone="footer"가 1개 있어야 함)
|
# 유형에 따른 구조 검증
|
||||||
has_footer = any(
|
layout_template = analysis.get("layout_template", "A")
|
||||||
isinstance(info, dict) and info.get("zone") == "footer"
|
if layout_template == "A":
|
||||||
for info in page_struct.values()
|
# 유형 A: 본심 필수
|
||||||
)
|
core_info = page_struct.get("본심", {})
|
||||||
if not has_footer:
|
if not core_info or not isinstance(core_info, dict):
|
||||||
# 하위호환: "결론" 키가 있으면 OK
|
errors.append({
|
||||||
if "결론" not in page_struct:
|
"severity": "RETRYABLE",
|
||||||
|
"field": "page_structure.본심",
|
||||||
|
"localization": "본심 역할이 page_structure에 없음",
|
||||||
|
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
||||||
|
})
|
||||||
|
elif core_info.get("weight", 0) < 0.3:
|
||||||
|
errors.append({
|
||||||
|
"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({
|
errors.append({
|
||||||
"severity": "RETRYABLE",
|
"severity": "RETRYABLE",
|
||||||
"field": "page_structure.footer",
|
"field": "page_structure.footer",
|
||||||
"localization": "결론(footer) 역할이 page_structure에 없음",
|
"localization": "결론(footer) 역할이 없음",
|
||||||
"instruction": "page_structure에 결론 역할(zone: footer)을 추가하라.",
|
"instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
|
||||||
})
|
})
|
||||||
|
|
||||||
# 필수 필드 검증
|
# 필수 필드 검증
|
||||||
@@ -224,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)) > 4:
|
# 유형 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",
|
||||||
@@ -271,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(컨셉 구체화) 결과 검증.
|
||||||
|
|
||||||
@@ -364,7 +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:
|
||||||
# Phase X: relation_type 증거 0개는 warning으로 처리 (역할 구조가 자유이므로)
|
if layout_template == "B":
|
||||||
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — warning으로 처리")
|
# 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유)
|
||||||
|
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning")
|
||||||
|
else:
|
||||||
|
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
|
||||||
|
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
|
||||||
|
errors.append({
|
||||||
|
"severity": "RETRYABLE",
|
||||||
|
"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