Author SHA1 Message Date
KyeongminandClaude Opus 4.6 bc7c08e575 B' overflow 루프 동작: Kei popup 결정 → 상단 소제목만 유지 + 하위불릿 팝업
- kei_client: 에스컬레이션 prompt 개선 — 소제목 유지 필수, overflow 영역만 대상
- block_assembler B': 상단 popup_roles 체크 추가 — 소제목만 남기고 하위불릿 제거
- block_assembler: \x01 바이트 수정 (r-string 역참조)
- 결과: 03번 top overflow 358px → 143px (루프 2회차에서 popup 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:20:23 +09:00
KyeongminandClaude Opus 4.6 571b057f19 Selenium 실측 overflow 정보를 에스컬레이션 report에 추가
- build_escalation_report 결과에 Selenium overflow px 정보 추가
- Kei가 실측 overflow를 인식하고 popup 결정을 내림 (7건)
- 미완: Kei popup 결정이 상단/하단좌 조립에 미반영 + 결정 과격 (전부 팝업)
- 다음: popup 결정 조립 반영 + Kei 가이드 개선 (일부만 팝업, 핵심은 유지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:05:47 +09:00
KyeongminandClaude Opus 4.6 51f61012c3 WIP: overflow 루프 구현 + Selenium 기반 에스컬레이션 트리거
- pipeline: filled→측정→Kei 재판단→재조립 루프 (최대 3회)
- pipeline: Selenium overflow가 있으면 calculate_fit과 무관하게 에스컬레이션
- 문제: build_escalation_report가 Selenium 측정 결과를 포함하지 않아 Kei가 빈 결정 반환
- 다음: content family 기반 범용 파이프라인 설계 필요 (Phase X-C)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:59:28 +09:00
KyeongminandClaude Opus 4.6 b2a49f55ef Type B' 추가: 03번 MDX용 레이아웃 (표 렌더링 + 불릿 전용 하단)
- block_assembler: _assemble_slide_html_type_b_prime 추가
  - 하단 좌: normalized.tables를 표로 렌더링 (셀 중복 불릿 제거)
  - 하단 우: 불릿만 (table_summaries 미사용)
- pipeline: layout_template 체크를 in ("B", "B'")로 확장
- 결과: 03번 표가 표로 렌더링됨, 하단 우에 잘못된 표 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:28:10 +09:00
KyeongminandClaude Opus 4.6 f568e5c95d 이미지 마크다운 필터 추가: ![alt](path) 패턴도 content_lines에서 제거
- block_assembler + assemble_stage2: 기존 [이미지:] 패턴에 ![markdown image 패턴 추가
- 02번 상단 "소통과 신뢰" 카드에 이미지 경로가 불릿으로 표시되던 문제 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:45:32 +09:00
KyeongminandClaude Opus 4.6 095abdf9af XBX-2 완료: overflow 프로세스 정상 동작
- slide_measurer: data URI → 임시파일 방식 (대용량 HTML 측정 가능)
- pipeline: Type B zone 간 재배분 (top↔bottom 공간 이전)
- pipeline: overflow 분기에 top/bottom zone 추가
- kei_client: 에스컬레이션 prompt 개선
  - 텍스트 원문 보존 원칙 명시 (삭제/요약/압축 금지)
  - action을 popup만으로 제한
  - 실제 역할명 목록을 prompt에 전달
- block_assembler: Kei popup 결정 반영 (해당 역할 콘텐츠 → 팝업 링크)

결과: 02번 상단 카드 3개 모두 표시, 하단 우측 표 → 팝업 분리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:39:02 +09:00
KyeongminandClaude Opus 4.6 028f611070 XBX-2 수행 방향 상세화: overflow 프로세스 원인 3개 + 수행 순서
- Selenium 측정 실패 (data URI 크기 제한)
- overflow 분기에 Type B zone 없음
- calculate_fit에서 Type B 역할 인식 불가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:56:58 +09:00
KyeongminandClaude Opus 4.6 17e77e310f Phase X-BX' XBX-1,3,5,6 완료: 유형 B 파이프라인 정상 동작
- XBX-1: normalizer 불릿 depth 보존 (D1/D2 마커) + 조립 로직 계층 반영
- XBX-3: 하단 구조 개선 — 하나의 큰 박스 안에 중제목 헤더 + 세로 구분선 2분할
- XBX-5: before→filled→after 파이프라인 연결 확인 (filled 2.2MB, 측정/재배분 정상)
- XBX-6: Type B에서 Sonnet 재구성 + renderer 스킵 — code_assembled 직접 사용
- final.html: 4,934 bytes → 2.2MB (Type B 정상 출력)
- Type A 코드 한 글자도 안 건드림

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:00:18 +09:00
KyeongminandClaude Opus 4.6 82f25caa6e Phase X-BX' + X-C 계획 문서 정리
- PHASE-X-BX.md: 유형 B 미완료 6개 task 수행 방향 상세 (02번 먼저 → 03번 확장)
- PHASE-X-C.md: 서브존 프리셋 기반 범용 레이아웃 방향

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 05:16:12 +09:00
KyeongminandClaude Opus 4.6 d4eaec694c 유형 B 파이프라인 연결: block_assembler type B 조립 + zone 기반 전환 시작
- block_assembler: _assemble_slide_html_type_b 추가 (filled/after용 HTML 생성)
- fit_verifier: redistribute()가 ROLE_ZONE_MAP 대신 containers zone 사용
- renderer: render_slide_from_html()에 zone 기반 높이 탐색 추가
- pipeline: 팝업 HTML CSS를 콘텐츠 유형별(table/list/text) 분기
- run_from_stage1b: MDX 파일 하드코딩 제거 + layout_template 전달 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 04:39:02 +09:00
KyeongminandClaude Opus 4.6 ef9bae7711 03번 분석: 하단 좌/우 내용 풍부, 현재 조립 부족
03번 하단 구조:
- 좌(2.1): 3개 소주제(Digital화+표, GIS+BIM, Solution) 각각 불릿 있음
- 우(2.2): 3개 소주제(품질향상, 정보물추가, 효율화) 각각 불릿 있음
- 결론: 원본 그대로

문제: _assemble_type_b가 내용을 제대로 조립 못 함
다음 세션: 하단 좌/우 내용 정확히 조립 + 렌더링 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:40:37 +09:00
KyeongminandClaude Opus 4.6 4f0105926d 03번 MDX sections 매핑 수정: 상단 level=2 합침 + 하단 대목차 정확히 찾기
- _assemble_type_b: 상단에 해당하는 모든 level=2 section을 합침
  (03번처럼 기술/사람/자연이 별도 section으로 분리된 경우 대응)
- 하단 대목차: level=3 바로 앞의 level=2 section으로 정확히 찾기
- 03번 결과: 상단 카드(기술/사람/자연) + 하단(과정혁신/결과변화) 정상
- 02번 영향 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:33:05 +09:00
KyeongminandClaude Opus 4.6 42d60e44a5 문서 정리: PHASE-X-B, PHASE-X-PRIME, 메모리 업데이트
현재 상태:
- 유형 A: ✅ 동작
- 유형 B: code_assembled만 동작, 파이프라인(filled/after) 미연결
- 핵심 문제: block_assembler가 고정 4역할만 처리 → 유형 B 지원 필요

다음 세션:
1. block_assembler 유형 B 지원
2. 컨테이너 크기 맞춤 (Selenium 측정 기반)
3. 유형 A 깨지지 않는지 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:27:47 +09:00
KyeongminandClaude Opus 4.6 3719704d75 X' 핵심 수정: MDX sections에서 직접 텍스트 가져오기 + normalizer ### 지원
핵심 변경:
- mdx_normalizer: ### (h3) 소목차도 section으로 분리 (기존 ## 만)
- _assemble_type_b: Kei structured_text 대신 normalized.sections에서 직접 텍스트
- 대목차/소목차 계층 구조 그대로 반영

결과:
- 슬라이드 제목: 원본 MDX frontmatter 그대로
- 대목차: "DX 기반 Process 혁신에 따른 주체별 기대효과"
- 소목차 좌: "업무 수행 과정(Process)의 변화"
- 소목차 우: "DX 시행 주체별 기대효과" + 팝업 링크 + Kei 요약 표
- 캡션: normalized.images alt text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:22:09 +09:00
KyeongminandClaude Opus 4.6 6b17f448eb Phase X'-6 추가: 본문 표 요약 프로세스 (미완성)
- pipeline.py: normalized.tables에서 본문 표 감지 → Kei 요약 요청
- assemble_stage2: _assemble_type_b 하단 우측에 table_summaries 표출
- 검증: 4열x3행 표 생성 확인

미해결:
- 들여쓰기 계층이 PNG와 다름 (대제목→소제목→본문 indent)
- 상단 컨테이너 내용 잘림
- 하단 우측: 표를 불릿으로 풀지 말고 팝업 링크 + 요약 표로
- [DX 시행 주체별 기대효과 바로가기 →] 팝업 처리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:57:46 +09:00
KyeongminandClaude Opus 4.6 56fd9fa71e Phase X'-1~5 완료: 제목/들여쓰기/캡션/빈칸/카드 디자인
X'-1: 제목 원본 MDX frontmatter에서 가져오기 (Kei가 바꾸지 않음)
X'-2: 들여쓰기 계층 (소제목→불릿 indent 적용)
X'-3: 이미지 캡션 normalized.images alt text에서 추출
X'-4: 상단 컨테이너 justify-content:space-between
X'-5: 카드 디자인 다크 그라데이션 + 밝은 텍스트

X'-6 미완료: 본문 표(팝업 아닌)를 하단 우측에 Kei 요약 배치 → 다음 세션

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:40:53 +09:00
KyeongminandClaude Opus 4.6 c4d7212ff3 Phase X' 계획: 유형 B 파이프라인 개선 6건 정리
X'-1: 제목 원본에서 가져오기 (Kei가 바꾸지 않도록)
X'-2: 들여쓰기 계층 (대/중/소제목+본문)
X'-3: 이미지 캡션 원본 형식
X'-4: 상단 빈칸 균등 배분
X'-5: 카드 디자인 개선
X'-6: 표 요약 (하단 우측 Kei 요약)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:33:18 +09:00
KyeongminandClaude Opus 4.6 a8fe20e08e Phase X-B 진행중: 유형 B 조립 + 텍스트 보존 강화 + 원본 MDX 복구
X-B-3~5 완료:
- space_allocator: build_containers_type_b() 추가
- assemble_stage2: _assemble_type_b() 추가 (소제목 카드형)
- pipeline.py: layout_template 분기 (A/B)
- pipeline_context: Analysis.layout_template 필드
- validators: 유형 B 검증 완화

텍스트 보존 강화:
- KEI_PROMPT: 제목 원본 그대로, 텍스트 재작성 금지
- KEI_STRUCTURED_TEXT_PROMPT: 소제목 유지, 원본 문장 그대로

원본 MDX 복구:
- samples/mdx_batch/02.mdx: 표 데이터 누락 수정 (원본에서 재복사)

미해결 (다음 세션):
- 들여쓰기: 대제목→중제목→소제목→본문 계층 구조
- 이미지 캡션: [그림 제목] 형식 (대괄호 포함)
- 상단 컨테이너: 빈칸 위로 붙이기
- 카드 디자인: 안전과품질/생산성향상/소통과신뢰 디자인 개선
- 제목: Kei가 원본 제목 바꾸는 문제 잔존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:28:03 +09:00
KyeongminandClaude Opus 4.6 bc7829b08b Phase X-B-1,2 완료: Kei 유형 A/B 선택 + 검증기 완화
X-B-1: KEI_PROMPT에 유형 B 옵션 추가
- 유형 A: 기존 배경/본심/첨부/결론 (참조자료 있는 콘텐츠)
- 유형 B: 본심1(상단)+본심2(하단2분할)+결론 (본문만으로 구성)
- Kei가 콘텐츠 보고 A/B 선택, layout_template 필드로 반환
- 검증: 01번→A, 02번→B 정확히 선택

X-B-2: 검증기 완화
- 유형 A: 본심 필수 유지
- 유형 B: 결론(footer)만 필수, 자유 역할명 허용
- 섹션 수 차이 허용 확대 (유형 B: 4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:10:22 +09:00
KyeongminandClaude Opus 4.6 c9677a69f8 V'-2/V'-4 수정: 표 행 수 계산 + footer 최소 높이 + 이미지 비율
- V'-2: 표 공간 계산에 V'-4(결론 위까지 채움) 높이 반영
  → Kei에게 정확한 행 수 전달 (1행 → 5행)
- V'-2: 이미지 높이를 실제 비율로 계산 (sub_layout 고정값 대신)
  → 200/2.73 = 73px (기존 172px → 공간 100px 확보)
- footer 최소 높이: design tokens 기반 동적 계산
  → weight 0.05일 때 26px → 53px 보장
- assemble_stage2: 이미지 높이도 실제 비율 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:38:28 +09:00
23 changed files with 4257 additions and 1155 deletions
+101
View File
@@ -0,0 +1,101 @@
# Phase X-B: 유형 B 템플릿 추가
> 최종 업데이트: 2026-04-06
> 전제: 유형 A(배경+본심+첨부+결론) 기존 코드 건드리지 않음
---
## 유형 B 구조
02번 MDX (DX의 시행 목표 및 기대효과) 기준.
MDX 원본 구조:
```
title: DX의 시행 목표 및 기대효과 ← 슬라이드 제목 (frontmatter)
## 1. DX의 궁극적 목표 ← 상단 (level=2)
- 안전과 품질 / 생산성 향상 / 소통과 신뢰 ← 소제목 카드
![DX의 궁극적 목표](이미지) ← 상단 우측 이미지
## 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
View File
@@ -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
View File
@@ -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)
+111
View File
@@ -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
View File
@@ -1,189 +1,39 @@
# Phase X: 템플릿 기반 동적 레이아웃
> 작성일: 2026-04-06
> 상태: 계획 수립
---
# Phase X: 콘텐츠 기반 레이아웃 판단 프로세스
## 배경
현재 파이프라인은 모든 MDX를 "배경/본심/첨부/결론" 4칸에 억지로 끼워넣는다.
배경이 없는 콘텐츠도 배경을 만들어내고, 3분할이 적절한 콘텐츠도 2분할로 넣는다.
Kei가 내용을 잘못 파악한 게 아니라, **"4칸을 채워라"는 지시가 잘못**된 것이다.
현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다.
## 핵심 아이디어
**미리 정의된 레이아웃 템플릿 중 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 원본을 읽고 내용 분석
→ 핵심 메시지, 콘텐츠 구조 파악
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
Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분
```
---
- `reference` 있으면 → `sidebar-right` (무조건)
- 콘텐츠 양/특성 기반 판단 없음
- 레이아웃이 콘텐츠에 맞는지 검증 없음
## 작업 리스트
## 목표 프로세스
### X-1: 템플릿 정의
- 옵션 A~F의 구체적 컨테이너 구조 정의
- 각 옵션의 zone 이름, 비율 계산 공식, 좌표 계산 로직
- `src/design_director.py`에 `LAYOUT_TEMPLATES` 정의
- 하드코딩 아님: 템플릿은 구조만 정의, 크기는 weight와 슬라이드 크기에서 동적 계산
```
1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음)
2. FILLED: 콘텐츠 채움
3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등)
4. 판단2: 결정된 레이아웃에서 크기 재배분
5. AFTER: 최종 레이아웃 + 크기
```
### X-2: Kei 프롬프트 수정
- `KEI_PROMPT`에 템플릿 A~F 옵션 제시
- Kei가 콘텐츠를 보고 `layout: "C"` 선택
- page_structure의 역할명이 자유 (배경/본심 고정 아님)
- 각 역할에 zone 배정 (상단/하단좌/하단우 등)
- 하드코딩 아님: Kei가 콘텐츠마다 다른 선택을 함
- 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과**
- 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨
### X-3: space_allocator 템플릿 기반 컨테이너 생성
- 선택된 템플릿에 따라 컨테이너 좌표/크기 생성
- weight 비율로 각 영역 크기 결정
- `select_preset()` → `build_layout_from_template()`
- 하드코딩 아님: 템플릿 구조 + weight + 슬라이드 크기로 동적 계산
## 관련 코드
### X-4: block_assembler / assemble_stage2 동적 역할
- `["배경", "본심", "첨부", "결론"]` 고정 루프 → `page_structure.keys()` 동적 루프
- 좌표 계산은 X-3에서 생성한 컨테이너 정보 사용
- 색상/폰트: 역할 수에 맞게 동적 배분
- 하드코딩 아님: 역할 수가 3개든 5개든 동작
- `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()`
- `src/pipeline.py`: Stage 1.5a에서 preset 선택
- `src/kei_client.py`: Stage 1A에서 role 태그 부여
- `src/space_allocator.py`: zone 기반 컨테이너 배분
### X-5: 나머지 파일 동적화
- step_visualizer: before/after 시각화에서 동적 역할 루프
- fit_verifier: 4역할 고정 → 동적 역할
- html_generator: Sonnet에게 동적 영역 수만큼 생성 요청
- renderer: 동적 grid-template 생성
- 하드코딩 아님: 모두 ctx.containers.keys() 기반
## 상태
### X-6: 검증
- 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건** | |
Phase W (before→filled→after 파이프라인) 완료 후 착수.
+30
View File
@@ -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이 브라우저에서 정상 렌더링
---
## 의존 관계
```
+8
View File
@@ -33,6 +33,14 @@ import DxEffect from '../../../../components/dx.astro';
<br/>
### 2.2 DX 시행 주체별 기대효과
| 구분 | 발주자 | 시공자 | 설계자 |
|------|--------|--------|--------|
| **필요 역량** | 실행 의지와 합리적 판단 역량 | 기술 투자와 운영 역량 | 기술개발 투자에 의한 S/W 역량 |
| **수작업 의존 → S/W 기반 체계화** | - 행정서류 자동 생성 및 최소화로 업무 생산성 향상<br/>- 건설기간 단축, 건설비 및 유지관리비 총비용 최소화 | - 체계적 공정/자원 관리를 통한 신뢰성 확보 및 생산성 향상<br/>- Model에서의 도면 추출로 쉽고 정확한 시공상세도 작성 용이<br/>- 시스템 구축 시, 품질·안전·관리 등에 필요한 도서 작성 용이 | - SW기반 설계프로세스 체계화로 설계 생산성 향상<br/>- 프로젝트 정보의 일관 유지 및 관리를 통한 오류 최소화<br/>- 다양한 성과물과 정보물 활용으로 추가 부가가치 창출 |
| **2D → 3D 기반 인지·검토** | - 3D 모델을 통한 직관적 시각화로 품질 향상 및 안전성 제고<br/>- 건설단계별 수행상태에 대한 쉬운 이해로 관리 편의성 증대 | - 직관적 시각화로 계획시공 등을 관리하여 안전성 제고 및 품질 향상<br/>- 중간태, 완성태 측량을 통한 시·공간적 관리의 편리성 향상 | - 3D 모델을 통한 확인/검증으로 설계 오류 최소화 및 Claim 예방 |
| **문서 중심 → 데이터 통합 기반 협업** | - 현장 실무자와 발주자의 원활한 의사소통으로 오류 최소화<br/>- 디지털 환경 구축을 통한 건설 정보 통합관리 활용성 강화 | - 불필요한 행정서류 감소를 통한 협업 및 의사소통 효율 향상 | - 설계 신뢰도 확보 및 발주자 이익 기여로 상호신뢰 증진 |
| **사후 대응 → 사전 검증 중심 관리** | - 설계변경, 민원, 재작업, 소송 등의 사전 예방, 최소화 | - 설계 및 시공 오류 예방과 원활한 의사 소통으로 공사 Risk 최소화 | - 시공 전 설계검증 강화로 설계 책임 리스크 감소 |
<DxEffect />
<br/>
<br/>
+513 -132
View File
@@ -44,8 +44,13 @@ def assemble(run_dir: str):
popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "")
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):
"""V-10 bold 키워드 적용."""
for kw in bold_kw.get(role, []):
@@ -130,7 +135,7 @@ def assemble(run_dir: str):
return p
return None
def popup_to_compact_table(popup_content, font_size, role_name=""):
def popup_to_compact_table(popup_content, font_size):
"""팝업의 마크다운 표를 compact HTML 테이블로 변환."""
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
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")
weight = "600" if ci == 0 else "400"
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'
return (
@@ -189,8 +194,8 @@ def assemble(run_dir: str):
all_css = set()
role_htmls = {}
# ── 각 역할별 조립 (동적 — page_structure의 모든 역할) ──
for role in ps.keys():
# ── 각 역할별 조립 ──
for role in ["배경", "본심", "첨부", "결론"]:
info = ps.get(role, {})
if not isinstance(info, dict):
continue
@@ -209,14 +214,8 @@ def assemble(run_dir: str):
ci = containers.get(role, {})
h = int(redist.get(role, ci.get("height_px", 0)))
w = ci.get("width_px", 0)
# font_size: zone 기반 동적 결정
zone = ci.get("zone", "") if isinstance(ci, dict) else ""
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)
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
font_size = fh.get(font_key, 12)
block_css, block_body = extract_block_html(ref_html)
if block_css:
@@ -247,8 +246,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════
# 결론
# ════════════════════════════════════
# Phase X: zone 기반 분기 (역할명이 아닌 zone/블록타입으로)
if zone == "footer":
if role == "결론":
assembled = block_body
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
assembled = re.sub(r'>부연 설명<', '><', assembled)
@@ -257,7 +255,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
# ════════════════════════════════════
elif "block-card-num" in block_body: # 카드 넘버링 블록
elif role == "첨부":
st = get_text(primary_topic)
# 마크다운 bold → HTML
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
@@ -328,7 +326,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조
# ════════════════════════════════════
elif "block-callout-warn" in block_body or "block-callout-sol" in block_body: # callout 블록
elif role == "배경":
sub_html = ""
if is_hier and sup_tids:
for st_id in sup_tids:
@@ -378,7 +376,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════
# 본심 — 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)
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)
@@ -393,7 +391,10 @@ def assemble(run_dir: str):
break
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 텍스트를 합침
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:
content = popup.get("content", "")
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:
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>'
@@ -509,120 +510,23 @@ def assemble(run_dir: str):
f'{keymsg_html}</div>'
)
else:
# 범용 텍스트 조립 (블록 타입 무관)
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
bullets_html, _ = structured_to_bullets(all_text, role, font_size)
role_htmls[role] = (
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>'
)
# ── 슬라이드 좌표 ──
bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
# ── Phase X: 동적 좌표 계산 (block_assembler와 동일 로직) ──
# zone별로 역할 그룹핑
zone_roles = {}
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)
bg_top = pad + header_h + gap_block
core_top = bg_top + bg_h + gap_small
sb_top = bg_top
# 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)
# #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위
core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥
sb_h = column_bottom - sb_top # 첨부: column 바닥까지
# 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)
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">
<style>
@@ -632,10 +536,28 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
.bl-sub{{padding-left:1em;}}
{css_block}
</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="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>"""
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__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
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)")
+7 -10
View File
@@ -22,13 +22,8 @@ async def main(run_dir: str):
# Stage 1B context 로드
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
# MDX 원본: samples에서 직접 읽기 (최신 원본 사용)
samples_dir = Path(__file__).parent.parent / "samples"
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", "")
# MDX 원본: context에서 가져옴 (어떤 MDX든 대응)
raw_content = ctx_json.get("raw_content", "")
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
@@ -41,9 +36,11 @@ async def main(run_dir: str):
"page_structure": ps,
"core_message": ctx_json.get("analysis", {}).get("core_message", ""),
"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'])}개")
for t in ctx_json["topics"]:
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() 호출
from src.pipeline import generate_slide
# 이미지 base_path: samples/images/
base_path = str(samples_dir / "images")
# 이미지 base_path: context에서 가져옴
base_path = ctx_json.get("base_path", "")
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
ev_type = event.get("event", "")
ev_data = event.get("data", "")
+892 -163
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-92
View File
@@ -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: 프리셋 선택 (규칙 기반)
# ──────────────────────────────────────
+22 -20
View File
@@ -304,16 +304,8 @@ def calculate_fit(
if normalized is None:
normalized = {}
# Phase X: zone 기반 font/line_height 결정 (고정 역할명 불필요)
def _font_key_for_role(role_name):
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
role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3}
analysis = FitAnalysis()
@@ -335,9 +327,9 @@ def calculate_fit(
allocated_h = container.get("height_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)
line_h = _line_height_for_role(role)
line_h = role_line_height.get(role, 1.5)
# V-1 출력: 꼭지별 블록 리스트
ref_list = references.get(role, [])
@@ -383,8 +375,7 @@ def calculate_fit(
has_image = img_h > 0
# ── 4. key-msg (본심에만) ──
# keymsg: 이미지가 있는 역할에만 (zone 무관, 블록 기반)
has_keymsg = (has_image and core_message)
has_keymsg = (role == "본심" and core_message)
keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0
# ── 5. 블록 오버헤드 ──
@@ -494,7 +485,12 @@ def build_escalation_report(analysis: FitAnalysis) -> str:
return "\n".join(lines)
# Phase X: ROLE_ZONE_MAP 제거. containers에서 zone을 직접 읽음.
ROLE_ZONE_MAP = {
"본심": "body",
"배경": "body",
"첨부": "sidebar",
"결론": "footer",
}
def redistribute(
@@ -505,12 +501,18 @@ def redistribute(
"""부족 영역에 여유 영역의 공간을 재배분.
같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심).
유형 B: containers의 zone 속성에서 동적으로 매핑.
"""
zone_roles: dict[str, list[str]] = {}
for role in analysis.roles:
# Phase X: containers에서 zone 직접 읽기 (고정 맵 불필요)
ci = containers.get(role, {})
zone = ci.get("zone", "body") if isinstance(ci, dict) else "body"
# containers에 zone 정보가 있으면 그걸 사용, 없으면 ROLE_ZONE_MAP fallback
ci = containers.get(role)
if ci is not None:
zone = ci.get("zone") if isinstance(ci, dict) else getattr(ci, "zone", None)
else:
zone = None
if not zone:
zone = ROLE_ZONE_MAP.get(role, "body")
if zone not in zone_roles:
zone_roles[zone] = []
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)
# 제목 높이: font_size * line_height + margin
# Phase X: font_key는 font_hierarchy에서 가장 가까운 키 사용
font_key = "core" # 기본값. zone 정보가 없으면 core
role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
font_key = role_font_map.get(role, "core")
title_font = font_hierarchy.get(font_key, 12)
title_h = title_font * 1.5 + tokens["spacing_small"]
+109 -65
View File
@@ -82,14 +82,9 @@ def build_area_prompt(
refs = phase_t.get("references", {})
budgets = phase_t.get("design_budgets", {})
# Phase X: zone 기반 font 결정 (고정 역할명 불필요)
# phase_t에서 이 역할의 zone을 찾아 font 매핑
_ps = phase_t.get("fit_result", {}).get("redistribution", {})
# 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)
# 역할별 폰트 매핑
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
font_size = fh.get(role_font_map.get(role, "core"), 12)
# 들여쓰기 (폰트 크기 기반)
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;">• 첫줄 텍스트가 여기서 시작하고
둘째줄도 정확히 같은 위치에서 시작한다</div>"""
# ── Phase X: zone 기반 역할 지시 (고정 역할명 불필요) ──
if _zone in ("top",) and not images: # 상단 강조/도입 영역 (이미지 없음)
# ── 역할별 지시 ──
if role == "배경":
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
## 핵심 원칙
@@ -219,7 +214,7 @@ def build_area_prompt(
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
elif images or _zone in ("main", "body"): # 이미지 포함 또는 메인 콘텐츠
elif role == "본심":
img_instruction = ""
if images:
for img in images:
@@ -269,7 +264,7 @@ def build_area_prompt(
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
{img_instruction}""")
elif _zone == "sidebar": # 참조/보조 sidebar
elif role == "첨부":
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
## 크기 (TP-3: 잘림 방지)
@@ -291,7 +286,7 @@ def build_area_prompt(
- 카드 간 간격 8px.
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
elif _zone == "footer": # 결론/footer
elif role == "결론":
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
## 크기
@@ -301,21 +296,6 @@ def build_area_prompt(
- 핵심 메시지: {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"""
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
@@ -564,10 +544,8 @@ def _build_phase_t_supplement(role: str, analysis: dict) -> str:
# 1. 폰트 위계 (역할별 확정 폰트)
fh = phase_t.get("font_hierarchy", {})
# Phase X: zone 기반 font 결정
_regen_containers = phase_t.get("_containers", {})
_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")
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"}
font_key = role_font_map.get(role, "core")
font_size = fh.get(font_key, 12)
parts.append(
f"\n[폰트 위계 — 반드시 준수]\n"
@@ -636,66 +614,132 @@ async def generate_slide_html(
return []
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": ""}
# ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ──
from src.fit_verifier import _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"]
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", {})
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)
# ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ──
def _get_role_content(role_topics):
"""structured_text를 우선 사용. 없으면 기존 sections 매칭."""
texts = []
for t in role_topics:
st = t.get("structured_text", "")
if st:
texts.append(st)
else:
# fallback: source_hint 키워드로 sections에서 매칭
keywords = _extract_keywords_from_hints([t])
matched = _map_sections_for_role(sections, [t], keywords)
if matched:
texts.append(matched)
return "\n\n".join(texts) if texts else ""
for role_name, role_info in page_struct.items():
if not isinstance(role_info, dict):
continue
role_topics = get_topics_for_role(role_name)
if not role_topics:
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}) 생성...")
# ── 배경 ──
if bg_topics:
logger.info("[Phase T] 배경 생성...")
bg_content = _get_role_content(bg_topics)
body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0)
prompt = build_area_prompt(
role=role_name,
content_block=role_content,
role="배경",
content_block=bg_content,
phase_t=phase_t,
height_px=spec.height_px,
width_px=spec.width_px,
images=role_images if role_images else None,
core_message=analysis.get("core_message", "") if zone != "footer" else "",
height_px=bg_h,
width_px=body_width,
)
html = await _call_claude(client, prompt)
if html:
if role_images:
html = _replace_img_placeholder(html, images)
# 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)}자")
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
logger.info(f"[Phase T] 배경 완료: {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"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
return result
+121 -77
View File
@@ -23,69 +23,97 @@ KEI_PROMPT = (
"- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
"- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
"- core_message 필드에 기록.\n\n"
"## 2단계: 콘텐츠 분석 및 꼭지 설계\n"
"콘텐츠를 분석하여 꼭지(topic)를 나눠라.\n"
"- 꼭지 수와 역할명은 **콘텐츠에 맞게 자유롭게** 결정하라.\n"
"- '배경/본심/첨부/결론' 같은 고정 틀에 억지로 끼우지 마라.\n"
"- 배경이 없으면 배경을 만들지 마라. 3분할이 적절하면 3분할하라.\n"
"- 결론 꼭지는 반드시 1개 있어야 한다 (슬라이드 하단 핵심 한 줄).\n"
"## 2단계: 정보 구조 파악\n"
"- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n"
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
"- info_structure 필드에 기술.\n\n"
"## 3단계: 슬라이드 스토리라인 설계\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"
"## 3단계: 레이아웃 템플릿 선택\n"
"꼭지 구조를 보고, 아래 템플릿 중 **가장 적합한 것을 선택**하라.\n\n"
"### 템플릿 옵션:\n"
"**A. body-sidebar** — 좌측 본문 + 우측 참조\n"
" zones: body(65%), sidebar(35%) — 1행\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"
"## 배치 규칙\n"
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
"- 결론은 layer: 'conclusion' → 하단 배치\n"
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
"- 1페이지 적정 꼭지: 3~6개. 분량에 맞게.\n"
"- **슬라이드 제목(title)과 꼭지 제목은 달라야 한다.**\n\n"
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
"## 출력 형식 (JSON만)\n"
"layout_template에 따라 page_structure가 달라진다.\n\n"
"유형 A 예시:\n"
"```json\n"
'{"title": "슬라이드 제목", '
'"core_message": "핵심 메시지 한 줄", '
'{"title": "제목", '
'"core_message": "핵심 메시지", '
'"total_pages": 1, '
'"layout_template": "B", '
'"layout_template": "A", '
'"page_structure": {'
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.25}, '
'"발주처_기대효과": {"zone": "bottom_1", "topic_ids": [2], "weight": 0.20}, '
'"설계사_기대효과": {"zone": "bottom_2", "topic_ids": [3], "weight": 0.20}, '
'"시공사_기대효과": {"zone": "bottom_3", "topic_ids": [4], "weight": 0.20}, '
'"결론": {"zone": "footer", "topic_ids": [5], "weight": 0.15}}, '
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
'"배경": {"topic_ids": [1], "weight": 0.15}, '
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
'"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
'"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", '
'"page": 1}], '
'"images": [{"topic_id": 1, "description": "이미지 설명"}], '
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "description": "표 설명"}]}\n'
'"detail_target": false, "page": 1}], '
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
"```\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"
)
@@ -235,14 +263,20 @@ async def refine_concepts(
KEI_STRUCTURED_TEXT_PROMPT = (
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
"## 규칙\n"
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"2. 각 문장을 불릿(•)으로 구분하라.\n"
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 절대 규칙\n"
"1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n"
" 원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
" → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
" ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n"
"2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n"
" 원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n"
"4. 각 문장을 불릿(•)으로 구분하라.\n"
"5. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"6. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n"
"```json\n"
'{"structured_texts": ['
@@ -1271,8 +1305,8 @@ async def call_kei_bold_keywords(
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
JSON으로 응답하라. 키는 아래 역할명을 그대로 사용:
{{{", ".join(f'"{r}": ["키워드", ...]' for r in role_texts.keys())}}}
JSON으로 응답하라:
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
빈 역할은 빈 리스트로.
{role_section}"""
@@ -1321,25 +1355,32 @@ JSON으로 응답하라. 키는 아래 역할명을 그대로 사용:
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.
## 핵심 원칙
- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.**
- 공간이 부족하면 **하위 불릿(상세 설명)만 팝업으로 분리.**
- **소제목(카드 제목)은 반드시 슬라이드에 유지.** 절대 팝업으로 빼지 않는다.
- 슬라이드에는 소제목 + "바로가기 →" 링크. 팝업에 하위 불릿 원문 전체.
- overflow가 없는 영역은 건드리지 않는다.
## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장
- 배경은 보조 역할 — 간결화 가능
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
- 용어 정의는 sidebar에 맞게 조정 가능
- overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
- 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
- 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
- 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
## 출력 (JSON만. 설명 없이.)
- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라.
- overflow가 발생한 역할만 포함. overflow 없는 역할은 포함하지 마라.
```json
{
"decisions": [
{
"role": "배경",
"action": "merge|inline|popup|trim|restructure",
"detail": "구체적 지시 (어떤 꼭지를 어떻게)",
"role": "역할 목록에 있는 정확한 역할명",
"action": "popup",
"detail": "팝업으로 분리할 구체적 내용 (하위 불릿만. 소제목은 유지)",
"reason": "판단 근거 1문장"
}
]
@@ -1347,11 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
```
action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
- popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지.
"""
@@ -1359,6 +1396,7 @@ async def call_kei_fit_escalation(
fit_report: str,
topics: list[dict],
content_summary: str,
role_names: list[str] | None = None,
) -> dict[str, Any] | None:
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
@@ -1380,10 +1418,16 @@ async def call_kei_fit_escalation(
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 = (
KEI_FIT_ESCALATION_PROMPT + "\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]}"
)
+79 -13
View File
@@ -73,6 +73,44 @@ class _CodeBlockProtector:
# 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:
"""마크다운 테이블(| 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:]]
# HTML 생성
# HTML 생성 — 셀 내 <br/> → <br> 유지 (줄바꿈 역할)
header_html = "".join(f"<th>{h}</th>" for h in headers)
rows_html = ""
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"
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 = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
content = content.replace("</div>", "")
content = re.sub(r"<br\s*/?>", "\n", content)
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
# 마크다운 테이블 → HTML 테이블
# 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내 <br/>로 행이 쪼개지는 것 방지)
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})
return f"[팝업: {title}]"
@@ -229,15 +273,20 @@ def _extract_structure(text: str) -> dict[str, Any]:
current_section_title = ""
current_section_lines = []
current_section_level = 2
bullet_depth = 0 # 불릿 중첩 깊이 추적 (bullet_list_open/close)
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:
sections.append({
"level": 2,
"level": current_section_level,
"title": current_section_title,
"content": "\n".join(current_section_lines).strip(),
})
current_section_lines = []
current_section_level = 2
bullet_depth = 0
for i, token in enumerate(tokens):
# 이미지 추출 (inline children)
@@ -283,12 +332,24 @@ def _extract_structure(text: str) -> dict[str, Any]:
if table["headers"] or table["rows"]:
tables.append(table)
# 섹션 추출 (## 기준)
if token.type == "heading_open" and token.tag == "h2":
_flush_section()
# 다음 토큰이 inline (제목 텍스트)
# 불릿 depth 추적 (섹션 내용 수집 시 계층 보존)
if current_section_title:
if token.type == "bullet_list_open":
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":
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",
"ordered_list_open", "fence"):
# 섹션 내용 수집 — inline 토큰의 content만
@@ -297,7 +358,12 @@ def _extract_structure(text: str) -> dict[str, Any]:
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
parent_type = tokens[i - 1].type if i > 0 else ""
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()
+285 -166
View File
@@ -172,11 +172,13 @@ async def generate_slide(
page_struct_raw = analysis_raw.get("page_structure", {})
page_structure = PageStructure(roles=page_struct_raw)
# X'-1: 제목은 원본 MDX frontmatter에서 가져옴 (Kei가 바꾸지 않음)
original_title = context.normalized.title or analysis_raw.get("title", "")
analysis = Analysis(
core_message=analysis_raw.get("core_message", ""),
title=analysis_raw.get("title", ""),
title=original_title,
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: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
@@ -249,6 +251,7 @@ async def generate_slide(
[t.model_dump() for t in updated_topics],
context.normalized.clean_text,
raw_content=context.raw_content,
layout_template=context.analysis.layout_template,
)
if validation_errors:
return {"_errors": validation_errors}
@@ -300,58 +303,56 @@ async def generate_slide(
# T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식)
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(
key_msg=14.0,
core=core_font,
bg=bg_font,
sidebar=sidebar_font,
key_msg=font_hierarchy_dict.get("핵심", 14.0),
core=font_hierarchy_dict.get("본심", 12.0),
bg=font_hierarchy_dict.get("배경", 11.0),
sidebar=font_hierarchy_dict.get("첨부", 10.0),
)
# Phase X: 템플릿 기반 컨테이너 생성
from src.design_director import LAYOUT_TEMPLATES, get_template
from src.space_allocator import build_containers_from_template
# 프리셋 선택 (비율 계산보다 먼저 — 프리셋의 기본 비율을 fallback으로 사용)
analysis_dict = {
"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에서 저장됨)
layout_template_id = context.analysis.layout_template or "E"
template = get_template(layout_template_id) or LAYOUT_TEMPLATES.get("E", {})
preset_name = layout_template_id
preset = template # 하위 호환
# 컨테이너 스펙 계산 (Phase X 템플릿 기반)
container_specs = build_containers_from_template(
page_structure=context.page_structure.roles,
template=template,
# T-5: 동적 비율 역산 (sidebar 텍스트 양 기반 + 프리셋 기본 비율)
container_ratio = calculate_dynamic_ratio(
role_text_lengths, font_hierarchy_dict,
slide_width=settings.slide_width,
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(
f"[Phase X] 템플릿={layout_template_id}, "
f"폰트: core={font_hierarchy.core}, bg={font_hierarchy.bg}, sidebar={font_hierarchy.sidebar}"
f"[T-5] 폰트 위계: 핵심={font_hierarchy.key_msg}, 본심={font_hierarchy.core}, "
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 변환
containers = {}
for role, spec in container_specs.items():
@@ -516,93 +517,138 @@ async def generate_slide(
# context에 sub_layouts 반영 후 filled 생성
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)
(steps_dir / "stage_1_8_filled.html").write_text(
filled_html.replace('</head><body>', '</head><body>\n'
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
'Stage 1.8: filled (블록+텍스트 채운 상태)</div>\n'
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
encoding="utf-8",
)
for fit_round in range(MAX_FIT_RETRIES):
# context에 현재 kei_decisions 반영 (2회차부터 popup 결정이 반영됨)
if kei_decisions:
context = context.model_copy(update={
"enhancement_result": {
**(context.enhancement_result or {}),
"kei_decisions": kei_decisions,
},
"containers": updated_containers,
})
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
logger.info(f"[Stage 1.8] filled 측정 완료")
# ── 판단: 넘치는 영역 처리 ──
updated_containers = dict(context.containers) # 복사
for zone_name, zone_data in filled_measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
excess = zone_data.get("excess_px", 0)
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],
# ── 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'
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'
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
encoding="utf-8",
)
kei_decisions = []
if kei_result:
kei_decisions = kei_result.get("decisions", [])
logger.info(f"[Stage 1.8] Kei 결정: {len(kei_decisions)}건")
for d in kei_decisions:
action = d.get("action", "")
target_role = d.get("role", "")
detail = d.get("detail", "")
logger.info(f"[V-4] {target_role} → {action}: {detail}")
if action == "restructure" and target_role in fit_analysis.roles:
fit_analysis = redistribute(fit_analysis, containers_dict)
else:
kei_decisions = []
# ── Selenium 측정 ──
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
logger.info(f"[Stage 1.8] round {fit_round+1} 측정 완료")
# ── overflow 확인 ──
has_overflow = False
for zone_name, zone_data in filled_measurement.get("zones", {}).items():
if zone_data.get("overflowed"):
excess = zone_data.get("excess_px", 0)
scroll_h = zone_data.get("scrollHeight", 0)
has_overflow = True
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: 보강 제안 분석
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)
if not text_sc:
continue
# 텍스트 줄 수 계산 (zone 기반 font_size)
ci = updated_containers.get(role)
zone = ci.zone if ci else ""
fk = "key_msg" if zone == "footer" else ("sidebar" if zone == "sidebar" else "core")
# 텍스트 줄 수 계산
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
fk = role_font_map.get(role, "core")
fs = font_h.get(fk, 12)
# structured_text에서 팝업 마커 찾기
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)
if not popup:
continue
# 공란 계산: after(updated_containers) 기준
after_ci = updated_containers.get(role)
after_h = after_ci.height_px if after_ci else float(text_sc["height_px"])
# after 높이에서 제목+keymsg+padding 제외 → 텍스트 영역
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
from src.fit_verifier import _load_design_tokens as _ldt_v2
_v2_tokens = _ldt_v2()
_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
for sc in role_scs:
if sc.get("name") == "keymsg":
keymsg_h = float(sc.get("height_px", 0))
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_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"])
if available_h < fs * 3:
continue # 공간 부족하면 건너뜀
@@ -760,6 +825,41 @@ async def generate_slide(
popup_summaries[pr] = summary
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에서 사용)
return {
"containers": updated_containers,
@@ -791,6 +891,7 @@ async def generate_slide(
"emphasis_blocks": enhancements.emphasis_blocks,
"bold_keywords": enhancements.bold_keywords,
"popup_summaries": popup_summaries,
"table_summaries": table_summaries,
},
}
@@ -806,10 +907,9 @@ async def generate_slide(
# V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용
ref_list = context.references.get(role, [])
schema_info = ref_list[0].schema_info if ref_list else {}
# Phase X: zone 기반 font_size
_zone = ci.zone if ci else ""
_fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core")
font_size = getattr(context.font_hierarchy, _fk, 12.0)
font_size = getattr(context.font_hierarchy, {
"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "core"
}.get(role, "core"), 12.0)
budget = calculate_design_budget(
container_height_px=ci.height_px,
@@ -844,6 +944,14 @@ async def generate_slide(
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
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
# PipelineContext → 기존 함수 인터페이스로 변환
@@ -905,6 +1013,12 @@ async def generate_slide(
yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."}
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
analysis_dict = {
@@ -1033,6 +1147,34 @@ async def generate_slide(
# markdown bold → HTML bold
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>
<html lang="ko">
<head>
@@ -1070,30 +1212,7 @@ h1 {{
color: #64748b;
margin-bottom: 20px;
}}
table {{
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; }}
{content_css}
strong {{ color: var(--color-primary); }}
.source {{
font-size: 11px;
+2 -2
View File
@@ -63,7 +63,7 @@ class Analysis(BaseModel):
core_message: str = ""
title: str = ""
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)
# topics와 page_structure는 PipelineContext 최상위에 위치
@@ -112,7 +112,7 @@ class FontHierarchy(BaseModel):
@model_validator(mode="after")
def check_hierarchy(self):
"""폰트 위계 유지 검증: 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(
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
+22 -14
View File
@@ -231,7 +231,7 @@ def _group_blocks_by_area(
container_htmls = []
assigned_ids = set()
role_order = [r for r, s in container_specs.items() if s.zone == area]
role_order = ["배경", "본심"]
for role in role_order:
spec = container_specs.get(role)
if not spec or spec.zone != area:
@@ -547,19 +547,27 @@ def render_slide_from_html(
_tokens = _ldt()
_header_h = _tokens.get("header_height", 66)
_gap_small = _tokens["spacing_small"]
# Phase X: 동적 grid-template-rows (역할명 무관)
_footer_h = 0
_content_h = 0
for rn, ci_data in containers.items():
h = int(redist.get(rn, ci_data.get("height_px", 0)))
zone = ci_data.get("zone", "")
if zone == "footer":
_footer_h = h
else:
_content_h += h
_content_h += _gap_small * max(0, len([c for c in containers.values() if c.get("zone") != "footer"]) - 1)
if _content_h > 0 and _footer_h > 0:
grid_rows = f"auto {_content_h}px {_footer_h}px"
# zone 기반으로 body/footer 높이를 동적 탐색 (유형 A: 배경+본심, 유형 B: zone별)
def _find_h(role_name, zone_name=None):
"""redist → containers 순으로 높이 탐색. role_name 없으면 zone으로 fallback."""
h = redist.get(role_name, 0)
if h:
return int(h)
ci = containers.get(role_name, {})
if ci:
return int(ci.get("height_px", 0))
if zone_name:
for _r, _c in containers.items():
if isinstance(_c, dict) and _c.get("zone") == zone_name:
return int(redist.get(_r, _c.get("height_px", 0)))
return 0
_bg_h = _find_h("배경")
_core_h = _find_h("본심")
_footer_h = _find_h("결론", "footer")
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
if _body_row_h > 0 and _footer_h > 0:
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
else:
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
+13 -4
View File
@@ -135,13 +135,16 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
)
driver = None
tmp_file = None
try:
driver = webdriver.Chrome(options=options)
# HTML을 data URI로 로드
import urllib.parse
encoded = urllib.parse.quote(html)
driver.get(f"data:text/html;charset=utf-8,{encoded}")
# HTML을 임시 파일로 저장 후 file:// URI로 로드 (data URI는 대용량 HTML에서 실패)
import tempfile
tmp_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8")
tmp_file.write(html)
tmp_file.close()
driver.get(f"file:///{tmp_file.name}")
# 폰트 로딩 대기 (Pretendard CDN)
try:
@@ -169,6 +172,12 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
driver.quit()
except Exception:
pass
if tmp_file:
import os
try:
os.unlink(tmp_file.name)
except Exception:
pass
def format_measurement_for_kei(
+95 -110
View File
@@ -400,6 +400,12 @@ def calculate_container_specs(
# 비중 비율로 높이 할당
ratio = weight / total_weight
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_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],
template: dict[str, Any],
slide_width: int = 1280,
slide_height: int = 720,
image_sizes: list[dict] | None = None,
) -> dict[str, ContainerSpec]:
"""템플릿 + Kei page_structure → 역할별 ContainerSpec.
"""유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).
템플릿의 rows/zones 구조와 Kei의 weight로 동적 계산.
하드코딩 없음. 모든 크기는 슬라이드 크기 + weight + 템플릿 splits에서 계산.
기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.
Args:
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.3}, ...}
template: LAYOUT_TEMPLATES["B"] 등
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
slide_width: 슬라이드 너비
slide_height: 슬라이드 높이
Returns:
{"핵심목표": ContainerSpec(...), "Process_혁신": ContainerSpec(...), ...}
image_sizes: 이미지 정보 (비율 계산용)
"""
from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens()
@@ -464,120 +467,102 @@ def build_containers_from_template(
gap_small = tokens["spacing_small"]
inner_w = slide_width - pad * 2
# 결론(footer) weight와 나머지 분리
footer_role = None
footer_weight = 0
content_roles = {} # zone → [(role_name, info)]
# 역할을 zone별로 분류
top_roles = [] # zone=top
bottom_roles = [] # zone=bottom_left, bottom_right
footer_role = None # zone=footer
for role_name, info in page_structure.items():
if not isinstance(info, dict):
continue
zone = info.get("zone", "")
if zone == "footer":
footer_role = role_name
footer_weight = info.get("weight", 0.1)
else:
if zone not in content_roles:
content_roles[zone] = []
content_roles[zone].append((role_name, info))
if zone == "top":
top_roles.append((role_name, info))
elif zone in ("bottom_left", "bottom_right"):
bottom_roles.append((role_name, info))
elif zone == "footer":
footer_role = (role_name, info)
# 전체 가용 높이 (header + footer 제외)
total_content_h = slide_height - pad * 2 - header_h - gap_block # header 아래 ~ 슬라이드 바닥-pad
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
total_available = slide_height - pad * 2 - header_h - gap_block
# footer 높이
footer_h = max(40, int(total_content_h * footer_weight)) if footer_weight > 0 else 53
# 중간 영역 높이 (footer와 gap 제외)
middle_h = total_content_h - footer_h - gap_block
# footer 높이: weight 비율 (최소 보장)
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
footer_h_raw = int(total_available * footer_weight)
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
footer_h = max(_footer_min, footer_h_raw)
# 템플릿 rows에서 각 row의 높이 계산
rows = template.get("rows", [])
num_rows = len(rows)
row_gap_total = gap_small * max(0, num_rows - 1)
available_row_h = middle_h - row_gap_total
# 중간 영역: footer + gap 제외
middle_h = total_available - footer_h - gap_block
# 각 row의 높이: row에 속한 역할들의 weight 합 비율로
row_weights = {}
for row_def in rows:
row_name = row_def["name"]
zones_in_row = row_def["zones"]
# 이 row에 속한 역할들의 weight 합
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
# 상단/하단 높이: weight 비율로
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
total_mid_weight = top_weight + bottom_weight
if total_mid_weight <= 0:
total_mid_weight = 1
total_row_weight = sum(row_weights.values())
if total_row_weight <= 0:
total_row_weight = 1
top_h = int(middle_h * top_weight / total_mid_weight)
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
img_ratio = 0
if image_sizes:
for img in image_sizes:
r = img.get("ratio", 0)
if r > 0:
img_ratio = r
break
if img_ratio > 0:
# 이미지 높이 = top_h, 이미지 폭 = top_h * ratio
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45%
text_w = inner_w - img_w - gap_block
else:
text_w = inner_w
img_w = 0
specs = {}
for row_def in rows:
row_name = row_def["name"]
zones_in_row = row_def["zones"]
splits = row_def["splits"]
# 상단 역할
for role_name, info in top_roles:
specs[role_name] = ContainerSpec(
role=role_name,
zone="top",
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=top_h,
width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만
max_height_cost=_max_allowed_height_cost(top_h),
block_constraints={
"img_width_px": img_w,
"img_height_px": top_h if img_w > 0 else 0,
"has_image": img_w > 0,
},
)
# row 높이
row_h = int(available_row_h * row_weights.get(row_name, 1) / total_row_weight)
# 하단 역할: 2분할
bottom_col_w = (inner_w - gap_block) // 2
for role_name, info in bottom_roles:
specs[role_name] = ContainerSpec(
role=role_name,
zone=info.get("zone", "bottom_left"),
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=bottom_h,
width_px=bottom_col_w,
max_height_cost=_max_allowed_height_cost(bottom_h),
block_constraints={},
)
# 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:
footer_info = page_structure[footer_role]
specs[footer_role] = ContainerSpec(
role=footer_role,
rn, info = footer_role
specs[rn] = ContainerSpec(
role=rn,
zone="footer",
topic_ids=footer_info.get("topic_ids", []),
weight=footer_weight,
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=footer_h,
width_px=inner_w,
max_height_cost="low",
@@ -585,7 +570,7 @@ def build_containers_from_template(
)
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())
)
return specs
+36 -100
View File
@@ -29,15 +29,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Phase X: 동적 색상 팔레트 (역할 수에 관계없이 순환)
_COLOR_PALETTE = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"]
# 하위호환: 기존 코드가 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]
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
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:
"""Phase X: 동적 역할 좌표 계산. containers의 모든 역할을 zone 기반으로 배치."""
t = _tokens()
pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8)
header_h = t.get("header_height", 66)
slide_w = t.get("slide_width", 1280)
slide_h = t.get("slide_height", 720)
inner_w = slide_w - pad * 2
header_h = 66
inner_w = 1280 - 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):
if hasattr(c, "height_px"): return c.height_px
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별 그룹핑
zone_roles = {}
footer_role = None
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)
bg_top = pad + header_h + gap
core_top = bg_top + bg_h + small
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
# footer
footer_h = gh(containers[footer_role]) if footer_role else 0
ft_top = slide_h - pad - footer_h
content_top = pad + header_h + gap
content_bottom = ft_top - gap
# 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
return {
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
}
def _wrap(title, subtitle, slide_body):
@@ -332,9 +268,9 @@ def _gen_stage_1_5a(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
fk = FONT_MAP[role]
font = getattr(fh, fk, "?")
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
topic_map = {t.id: t for t in ctx.topics}
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
info = ps.get(role, {})
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 "슬라이드"
body = _hdr(coords["header"], title)
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
ci = ctx.containers.get(role)
if not ci:
continue
@@ -432,9 +368,9 @@ def _gen_stage_1_7(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[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>']
@@ -480,9 +416,9 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title)
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
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", {})
sups = enh.get("supplement_blocks", [])
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
rf = roles_fit.get(role, {})
status = rf.get("fit_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)
legend_lines = []
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role]
cl = _get_color(role, list(ctx.containers.keys()))
cl = COLORS[role]
ref_list = ctx.references.get(role, [])
info = ps.get(role, {})
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", {})
sections = []
for role in ctx.containers.keys():
for role in ["배경", "본심", "첨부", "결론"]:
rhtml = role_htmls.get(role, "")
if not rhtml:
continue
+45 -13
View File
@@ -171,19 +171,37 @@ def validate_stage_1a(
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
})
# Phase X: 결론(footer) 역할 존재 검증 (역할명은 자유, zone="footer"가 1개 있어야 함)
has_footer = any(
isinstance(info, dict) and info.get("zone") == "footer"
for info in page_struct.values()
)
if not has_footer:
# 하위호환: "결론" 키가 있으면 OK
if "결론" not in page_struct:
# 유형에 따른 구조 검증
layout_template = analysis.get("layout_template", "A")
if layout_template == "A":
# 유형 A: 본심 필수
core_info = page_struct.get("본심", {})
if not core_info or not isinstance(core_info, dict):
errors.append({
"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({
"severity": "RETRYABLE",
"field": "page_structure.footer",
"localization": "결론(footer) 역할이 page_structure에 없음",
"instruction": "page_structure에 결론 역할(zone: footer)을 추가하라.",
"localization": "결론(footer) 역할이 없음",
"instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
})
# 필수 필드 검증
@@ -224,7 +242,9 @@ def validate_stage_1a(
if clean_text:
# 원본 ## 섹션 수 vs topic 수 비교
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({
"severity": "RETRYABLE",
"field": "topics",
@@ -271,6 +291,7 @@ def validate_stage_1b(
topics: list[dict[str, Any]],
clean_text: str,
raw_content: str = "",
layout_template: str = "A",
) -> list[dict]:
"""Stage 1B(컨셉 구체화) 결과 검증.
@@ -364,7 +385,18 @@ def validate_stage_1b(
claimed_count = evidence.get(relation_type, 0)
if claimed_count == 0:
# Phase X: relation_type 증거 0개는 warning으로 처리 (역할 구조가 자유이므로)
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — warning으로 처리")
if layout_template == "B":
# 유형 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