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: 템플릿 기반 동적 레이아웃 # Phase X: 콘텐츠 기반 레이아웃 판단 프로세스
> 작성일: 2026-04-06
> 상태: 계획 수립
---
## 배경 ## 배경
현재 파이프라인은 모든 MDX를 "배경/본심/첨부/결론" 4칸에 억지로 끼워넣는다. 현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다.
배경이 없는 콘텐츠도 배경을 만들어내고, 3분할이 적절한 콘텐츠도 2분할로 넣는다.
Kei가 내용을 잘못 파악한 게 아니라, **"4칸을 채워라"는 지시가 잘못**된 것이다.
## 핵심 아이디어 ## 현재 프로세스 (문제)
**미리 정의된 레이아웃 템플릿 중 Kei가 콘텐츠에 맞는 것을 선택한다.**
- Kei가 자유롭게 구조를 만드는 것이 아님 (불안정)
- 옵션을 주고 고르게 함 (안정적)
- 하드코딩 아님 — 어떤 MDX가 와도 적절한 템플릿이 선택됨
## 고정 영역
모든 템플릿 공통:
- **상단**: 슬라이드 제목 헤더 (항상 존재)
- **하단**: 결론 footer (항상 존재)
- **중간**: 템플릿에 따라 달라지는 영역
## 중간 영역 템플릿 옵션
### A. body + sidebar
```
┌──────────┬─────┐
│ 본심1 │참조 │
│ 본심2 │ │
└──────────┴─────┘
```
적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠
예시: 01번 MDX (DX/BIM 용어 정립)
### B. 상단 wide + 하단 2분할
```
┌─────────────────┐
│ 강조/핵심 │
├────────┬────────┤
│ 항목1 │ 항목2 │
└────────┴────────┘
```
적합: 핵심 1개 + 두 가지 측면 비교/설명
예시: 03번 MDX (필수요건 + 과정혁신/결과변화)
### C. 상단 wide + 하단 3분할
```
┌─────────────────┐
│ 강조/핵심 │
├─────┬─────┬─────┤
│항목1│항목2│항목3 │
└─────┴─────┴─────┘
```
적합: 핵심 1개 + 세 가지 항목 병렬
예시: 02번 MDX (궁극적 목표 + 발주처/설계사/시공사)
### D. 2분할 (좌우 대등)
```
┌────────┬────────┐
│ 항목1 │ 항목2 │
│ │ │
└────────┴────────┘
```
적합: 두 가지 비교/대비
### E. 단일 전체
```
┌─────────────────┐
│ │
│ 본심 (전체) │
│ │
└─────────────────┘
```
적합: 하나의 흐름, 분할 불필요
### F. 상단 wide + 하단 2분할 + 보조 sidebar
```
┌─────────────┬───┐
│ 강조/핵심 │참조│
├──────┬──────┤ │
│항목1 │항목2 │ │
└──────┴──────┴───┘
```
적합: B + sidebar 조합
---
## 프로세스
``` ```
1. Kei가 MDX 원본을 읽고 내용 분석 Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분
→ 핵심 메시지, 콘텐츠 구조 파악
2. Kei가 꼭지를 나눔 (개수와 역할명 자유)
→ "핵심목표 1개, 주체별 기대효과 3개, 결론 1개"
→ 역할명: 고정 4칸 아님. 콘텐츠에 맞는 이름 사용
3. Kei가 템플릿 선택
→ "꼭지 구조를 보니 C템플릿이 맞다"
→ 옵션 A~F 중 하나
4. Kei가 각 영역에 꼭지 배정 + weight
→ 상단: 핵심목표(0.3)
→ 하단좌: 발주처(0.2), 하단중: 설계사(0.2), 하단우: 시공사(0.2)
→ footer: 결론(0.1)
5. 파이프라인이 템플릿대로 컨테이너 생성 → BEFORE
6. 콘텐츠 채움 → 측정 → 재배분 → FILLED → AFTER
7. 조립 → code_assembled / final
``` ```
--- - `reference` 있으면 → `sidebar-right` (무조건)
- 콘텐츠 양/특성 기반 판단 없음
- 레이아웃이 콘텐츠에 맞는지 검증 없음
## 작업 리스트 ## 목표 프로세스
### X-1: 템플릿 정의 ```
- 옵션 A~F의 구체적 컨테이너 구조 정의 1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음)
- 각 옵션의 zone 이름, 비율 계산 공식, 좌표 계산 로직 2. FILLED: 콘텐츠 채움
- `src/design_director.py`에 `LAYOUT_TEMPLATES` 정의 3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등)
- 하드코딩 아님: 템플릿은 구조만 정의, 크기는 weight와 슬라이드 크기에서 동적 계산 4. 판단2: 결정된 레이아웃에서 크기 재배분
5. AFTER: 최종 레이아웃 + 크기
```
### X-2: Kei 프롬프트 수정 - 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과**
- `KEI_PROMPT`에 템플릿 A~F 옵션 제시 - 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨
- Kei가 콘텐츠를 보고 `layout: "C"` 선택
- page_structure의 역할명이 자유 (배경/본심 고정 아님)
- 각 역할에 zone 배정 (상단/하단좌/하단우 등)
- 하드코딩 아님: Kei가 콘텐츠마다 다른 선택을 함
### X-3: space_allocator 템플릿 기반 컨테이너 생성 ## 관련 코드
- 선택된 템플릿에 따라 컨테이너 좌표/크기 생성
- weight 비율로 각 영역 크기 결정
- `select_preset()` → `build_layout_from_template()`
- 하드코딩 아님: 템플릿 구조 + weight + 슬라이드 크기로 동적 계산
### X-4: block_assembler / assemble_stage2 동적 역할 - `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()`
- `["배경", "본심", "첨부", "결론"]` 고정 루프 → `page_structure.keys()` 동적 루프 - `src/pipeline.py`: Stage 1.5a에서 preset 선택
- 좌표 계산은 X-3에서 생성한 컨테이너 정보 사용 - `src/kei_client.py`: Stage 1A에서 role 태그 부여
- 색상/폰트: 역할 수에 맞게 동적 배분 - `src/space_allocator.py`: zone 기반 컨테이너 배분
- 하드코딩 아님: 역할 수가 3개든 5개든 동작
### X-5: 나머지 파일 동적화 ## 상태
- step_visualizer: before/after 시각화에서 동적 역할 루프
- fit_verifier: 4역할 고정 → 동적 역할
- html_generator: Sonnet에게 동적 영역 수만큼 생성 요청
- renderer: 동적 grid-template 생성
- 하드코딩 아님: 모두 ctx.containers.keys() 기반
### X-6: 검증 Phase W (before→filled→after 파이프라인) 완료 후 착수.
- 01번 MDX → A템플릿 → 기존과 동일하거나 더 나은 결과
- 02번 MDX → C템플릿 → 상단 강조 + 하단 3분할
- 03번 MDX → B템플릿 → 상단 요건 + 하단 2분할
- 텍스트가 컨테이너 안에 있음
- 공란 최소
- 01번이 깨지면 롤백
---
## 주의사항
- 하드코딩 절대 금지: 특정 MDX에만 동작하는 코드 없음
- 01번 보호: Phase X 전에 git commit 완료 (1f7579c). 깨지면 롤백
- 점진적 진행: X-1 → X-2 후 Kei 응답 확인 → X-3~X-5 순차 진행
- 각 단계마다 검증
---
## 관련 코드 (고정 역할 참조 현황)
| 파일 | 참조 수 | 수정 범위 |
|------|---------|----------|
| src/html_generator.py | 54건 | X-5 |
| src/step_visualizer.py | 32건 | X-5 |
| src/space_allocator.py | 26건 | X-3 |
| scripts/assemble_stage2.py | 26건 | X-4 |
| src/kei_client.py | 18건 | X-2 |
| src/block_assembler.py | 17건 | X-4 |
| src/fit_verifier.py | 16건 | X-5 |
| src/pipeline.py | 15건 | X-3~X-5 |
| src/renderer.py | 7건 | X-5 |
| src/pipeline_context.py | 4건 | 필요 시 |
| **합계** | **215건** | |
+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/> <br/>
### 2.2 DX 시행 주체별 기대효과 ### 2.2 DX 시행 주체별 기대효과
| 구분 | 발주자 | 시공자 | 설계자 |
|------|--------|--------|--------|
| **필요 역량** | 실행 의지와 합리적 판단 역량 | 기술 투자와 운영 역량 | 기술개발 투자에 의한 S/W 역량 |
| **수작업 의존 → S/W 기반 체계화** | - 행정서류 자동 생성 및 최소화로 업무 생산성 향상<br/>- 건설기간 단축, 건설비 및 유지관리비 총비용 최소화 | - 체계적 공정/자원 관리를 통한 신뢰성 확보 및 생산성 향상<br/>- Model에서의 도면 추출로 쉽고 정확한 시공상세도 작성 용이<br/>- 시스템 구축 시, 품질·안전·관리 등에 필요한 도서 작성 용이 | - SW기반 설계프로세스 체계화로 설계 생산성 향상<br/>- 프로젝트 정보의 일관 유지 및 관리를 통한 오류 최소화<br/>- 다양한 성과물과 정보물 활용으로 추가 부가가치 창출 |
| **2D → 3D 기반 인지·검토** | - 3D 모델을 통한 직관적 시각화로 품질 향상 및 안전성 제고<br/>- 건설단계별 수행상태에 대한 쉬운 이해로 관리 편의성 증대 | - 직관적 시각화로 계획시공 등을 관리하여 안전성 제고 및 품질 향상<br/>- 중간태, 완성태 측량을 통한 시·공간적 관리의 편리성 향상 | - 3D 모델을 통한 확인/검증으로 설계 오류 최소화 및 Claim 예방 |
| **문서 중심 → 데이터 통합 기반 협업** | - 현장 실무자와 발주자의 원활한 의사소통으로 오류 최소화<br/>- 디지털 환경 구축을 통한 건설 정보 통합관리 활용성 강화 | - 불필요한 행정서류 감소를 통한 협업 및 의사소통 효율 향상 | - 설계 신뢰도 확보 및 발주자 이익 기여로 상호신뢰 증진 |
| **사후 대응 → 사전 검증 중심 관리** | - 설계변경, 민원, 재작업, 소송 등의 사전 예방, 최소화 | - 설계 및 시공 오류 예방과 원활한 의사 소통으로 공사 Risk 최소화 | - 시공 전 설계검증 강화로 설계 책임 리스크 감소 |
<DxEffect /> <DxEffect />
<br/> <br/>
<br/> <br/>
+513 -132
View File
@@ -44,8 +44,13 @@ def assemble(run_dir: str):
popups = ctx.get("normalized", {}).get("popups", []) popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "") title = ctx.get("analysis", {}).get("title", "")
ratio = ctx.get("container_ratio", [71, 29]) ratio = ctx.get("container_ratio", [71, 29])
layout_template = ctx.get("analysis", {}).get("layout_template", "A")
# ── 유틸 ── # Phase X-B: 유형 B면 별도 함수로 분기
if layout_template == "B":
return _assemble_type_b(run, ctx)
# ── 유틸 (유형 A) ──
def bold(text, role): def bold(text, role):
"""V-10 bold 키워드 적용.""" """V-10 bold 키워드 적용."""
for kw in bold_kw.get(role, []): for kw in bold_kw.get(role, []):
@@ -130,7 +135,7 @@ def assemble(run_dir: str):
return p return p
return None return None
def popup_to_compact_table(popup_content, font_size, role_name=""): def popup_to_compact_table(popup_content, font_size):
"""팝업의 마크다운 표를 compact HTML 테이블로 변환.""" """팝업의 마크다운 표를 compact HTML 테이블로 변환."""
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응) # 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content) popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
@@ -164,7 +169,7 @@ def assemble(run_dir: str):
align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right") align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
weight = "600" if ci == 0 else "400" weight = "600" if ci == 0 else "400"
color = "#1e40af" if ci == 0 else "#64748b" color = "#1e40af" if ci == 0 else "#64748b"
cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, role_name)}</div>' cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, "본심")}</div>'
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n' rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'
return ( return (
@@ -189,8 +194,8 @@ def assemble(run_dir: str):
all_css = set() all_css = set()
role_htmls = {} role_htmls = {}
# ── 각 역할별 조립 (동적 — page_structure의 모든 역할) ── # ── 각 역할별 조립 ──
for role in ps.keys(): for role in ["배경", "본심", "첨부", "결론"]:
info = ps.get(role, {}) info = ps.get(role, {})
if not isinstance(info, dict): if not isinstance(info, dict):
continue continue
@@ -209,14 +214,8 @@ def assemble(run_dir: str):
ci = containers.get(role, {}) ci = containers.get(role, {})
h = int(redist.get(role, ci.get("height_px", 0))) h = int(redist.get(role, ci.get("height_px", 0)))
w = ci.get("width_px", 0) w = ci.get("width_px", 0)
# font_size: zone 기반 동적 결정 font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
zone = ci.get("zone", "") if isinstance(ci, dict) else "" font_size = fh.get(font_key, 12)
if zone == "footer":
font_size = fh.get("key_msg", 14)
elif zone == "sidebar":
font_size = fh.get("sidebar", 10)
else:
font_size = fh.get("core", 12)
block_css, block_body = extract_block_html(ref_html) block_css, block_body = extract_block_html(ref_html)
if block_css: if block_css:
@@ -247,8 +246,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════ # ════════════════════════════════════
# 결론 # 결론
# ════════════════════════════════════ # ════════════════════════════════════
# Phase X: zone 기반 분기 (역할명이 아닌 zone/블록타입으로) if role == "결론":
if zone == "footer":
assembled = block_body assembled = block_body
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled) assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
assembled = re.sub(r'>부연 설명<', '><', assembled) assembled = re.sub(r'>부연 설명<', '><', assembled)
@@ -257,7 +255,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════ # ════════════════════════════════════
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명 # 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
# ════════════════════════════════════ # ════════════════════════════════════
elif "block-card-num" in block_body: # 카드 넘버링 블록 elif role == "첨부":
st = get_text(primary_topic) st = get_text(primary_topic)
# 마크다운 bold → HTML # 마크다운 bold → HTML
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st) st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
@@ -328,7 +326,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════ # ════════════════════════════════════
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조 # 배경 — callout 구조 + 종속꼭지 인라인 + 강조
# ════════════════════════════════════ # ════════════════════════════════════
elif "block-callout-warn" in block_body or "block-callout-sol" in block_body: # callout 블록 elif role == "배경":
sub_html = "" sub_html = ""
if is_hier and sup_tids: if is_hier and sup_tids:
for st_id in sup_tids: for st_id in sup_tids:
@@ -378,7 +376,7 @@ def assemble(run_dir: str):
# ════════════════════════════════════ # ════════════════════════════════════
# 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단) # 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
# ════════════════════════════════════ # ════════════════════════════════════
elif any(sc.get("name") == "svg" for sc in scs): # 이미지 포함 블록 elif role == "본심":
svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None) svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None) text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None) keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)
@@ -393,7 +391,10 @@ def assemble(run_dir: str):
break break
svg_w = int(svg_sc["width_px"]) if svg_sc else 200 svg_w = int(svg_sc["width_px"]) if svg_sc else 200
svg_h = int(svg_sc["height_px"]) if svg_sc else 265 # 이미지 높이: 실제 비율로 계산 (sub_layout 고정값 대신)
slide_images = ctx.get("slide_images", [])
img_ratio = next((img.get("ratio", 1) for img in slide_images if img.get("b64")), 1)
svg_h = int(svg_w / img_ratio) if img_ratio > 0 else int(svg_sc["height_px"]) if svg_sc else 265
# 본심의 모든 topic 텍스트를 합침 # 본심의 모든 topic 텍스트를 합침
all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
@@ -468,7 +469,7 @@ def assemble(run_dir: str):
if popup: if popup:
content = popup.get("content", "") content = popup.get("content", "")
if content.count("|") > 3: if content.count("|") > 3:
compact = popup_to_compact_table(content, font_size, role_name=role) compact = popup_to_compact_table(content, font_size)
if compact: if compact:
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>' popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>' table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
@@ -509,120 +510,23 @@ def assemble(run_dir: str):
f'{keymsg_html}</div>' f'{keymsg_html}</div>'
) )
else: # ── 슬라이드 좌표 ──
# 범용 텍스트 조립 (블록 타입 무관) bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
all_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid)) core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
bullets_html, _ = structured_to_bullets(all_text, role, font_size) sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
role_htmls[role] = ( concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
f'<div style="height:100%;padding:{int(font_size * 0.5)}px;font-size:{font_size}px;line-height:1.4;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{topic_title}</div>'
f'{bullets_html}</div>'
)
# ── Phase X: 동적 좌표 계산 (block_assembler와 동일 로직) ── bg_top = pad + header_h + gap_block
# zone별로 역할 그룹핑 core_top = bg_top + bg_h + gap_small
zone_roles = {} sb_top = bg_top
footer_role = None
for role_name in ps.keys():
ci = containers.get(role_name, {})
z = ci.get("zone", "") if isinstance(ci, dict) else ""
if z == "footer":
footer_role = role_name
else:
if z not in zone_roles:
zone_roles[z] = []
zone_roles[z].append(role_name)
# row 그룹핑 # #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
row_map = {} ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
for zone_name in zone_roles: column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위
if zone_name.startswith("top"): core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥
row = "top" sb_h = column_bottom - sb_top # 첨부: column 바닥까지
elif zone_name.startswith("bottom"):
row = "bottom"
else:
row = "main"
if row not in row_map:
row_map[row] = []
if zone_name not in row_map[row]:
row_map[row].append(zone_name)
# footer 높이
footer_h = 0
if footer_role:
ci = containers.get(footer_role, {})
footer_h = int(redist.get(footer_role, ci.get("height_px", 0)))
ft_top = slide_h - pad - footer_h
content_top = pad + header_h + gap_block
content_bottom = ft_top - gap_block
# row별 높이
row_weights = {}
for row_name, zone_list in row_map.items():
w = 0
for zn in zone_list:
for rn in zone_roles.get(zn, []):
w += containers.get(rn, {}).get("weight", 0) if isinstance(containers.get(rn), dict) else 0
row_weights[row_name] = w
total_rw = sum(row_weights.values()) or 1
num_rows = len(row_map)
row_gap_total = gap_small * max(0, num_rows - 1)
available_h = content_bottom - content_top - row_gap_total
row_layout = {}
current_top = content_top
for row_name in sorted(row_map.keys()):
rw = row_weights.get(row_name, 1)
rh = int(available_h * rw / total_rw)
row_layout[row_name] = {"top": current_top, "height": rh}
current_top += rh + gap_small
# 컨테이너 좌표
container_boxes = {}
_color_palette = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"]
all_role_names = list(ps.keys())
for row_name, zone_list in row_map.items():
rl = row_layout[row_name]
num_cols = len(zone_list)
col_gap_total = gap_block * max(0, num_cols - 1)
col_available = inner_w - col_gap_total
zone_widths = []
for zn in zone_list:
roles_in_zone = zone_roles.get(zn, [])
if roles_in_zone:
w = containers.get(roles_in_zone[0], {}).get("width_px", col_available // num_cols) if isinstance(containers.get(roles_in_zone[0]), dict) else col_available // num_cols
else:
w = col_available // num_cols
zone_widths.append(w)
total_zw = sum(zone_widths) or 1
zone_widths = [int(col_available * w / total_zw) for w in zone_widths]
current_left = pad
for i, zn in enumerate(zone_list):
zw = zone_widths[i]
for rn in zone_roles.get(zn, []):
container_boxes[rn] = {"left": current_left, "top": rl["top"], "width": zw, "height": rl["height"]}
current_left += zw + gap_block
if footer_role:
container_boxes[footer_role] = {"left": pad, "top": ft_top, "width": inner_w, "height": footer_h}
# HTML 조립
css_block = "\n".join(all_css) css_block = "\n".join(all_css)
containers_html = ""
for rn in all_role_names:
box = container_boxes.get(rn)
if not box:
continue
idx = all_role_names.index(rn) % len(_color_palette)
color = _color_palette[idx]
containers_html += (
f'\n<div style="position:absolute;left:{box["left"]}px;top:{box["top"]}px;'
f'width:{box["width"]}px;height:{box["height"]}px;border-radius:6px;overflow:hidden;">'
f'{role_htmls.get(rn, "")}</div>\n'
)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"> html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style> <style>
@@ -632,10 +536,28 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
.bl-sub{{padding-left:1em;}} .bl-sub{{padding-left:1em;}}
{css_block} {css_block}
</style></head><body> </style></head><body>
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과</div> <div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)</div>
<div style="font-size:11px;color:#666;margin-bottom:10px;">sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups</div>
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;"> <div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div> <div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>
{containers_html}
<div style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("배경", "")}
</div>
<div style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("본심", "")}
</div>
<div style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("첨부", "")}
</div>
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border-radius:8px;overflow:hidden;">
{role_htmls.get("결론", "")}
</div>
</div></body></html>""" </div></body></html>"""
out = run / "steps" / "stage_2_code_assembled.html" out = run / "steps" / "stage_2_code_assembled.html"
@@ -646,3 +568,462 @@ body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sa
if __name__ == "__main__": if __name__ == "__main__":
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051" run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
assemble(run_dir) assemble(run_dir)
# ══════════════════════════════════════
# Phase X-B: 유형 B 조립
# ══════════════════════════════════════
def _assemble_type_b(run: Path, ctx: dict):
"""유형 B: 상단(top+이미지) + 하단 2분할 + 결론.
기존 유형 A 코드를 건드리지 않는 별도 함수.
"""
import re
from src.fit_verifier import _load_design_tokens
topics = ctx["topics"]
topic_map = {t["id"]: t for t in topics}
ps = ctx["page_structure"]
if "roles" in ps:
ps = ps["roles"]
containers = ctx["containers"]
fh = ctx.get("font_hierarchy", {})
enh = ctx.get("enhancement_result", {})
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
popups = ctx.get("normalized", {}).get("popups", [])
title = ctx.get("analysis", {}).get("title", "")
core_message = ctx.get("analysis", {}).get("core_message", "")
slide_images = ctx.get("slide_images", [])
tokens = _load_design_tokens()
pad = tokens["spacing_page"]
header_h = tokens.get("header_height", 66)
gap_block = tokens["spacing_block"]
gap_small = tokens["spacing_small"]
slide_w = tokens.get("slide_width", 1280)
slide_h = tokens.get("slide_height", 720)
inner_w = slide_w - pad * 2
# ── 유틸 ──
def get_text(topic):
if isinstance(topic, dict):
return topic.get("structured_text", "") or topic.get("source_data", "")
return ""
def bold(text, role):
for kw in bold_kw.get(role, []):
if kw in text:
text = text.replace(kw, f"<strong>{kw}</strong>")
return text
def find_popup(title_keyword):
for p in popups:
if title_keyword in p.get("title", ""):
return p
return None
# ── zone별 역할 분류 ──
top_role = None
bottom_left_role = None
bottom_right_role = None
footer_role = None
for role_name, info in ps.items():
if not isinstance(info, dict):
continue
zone = info.get("zone", "")
if zone == "top":
top_role = (role_name, info)
elif zone == "bottom_left":
bottom_left_role = (role_name, info)
elif zone == "bottom_right":
bottom_right_role = (role_name, info)
elif zone == "footer":
footer_role = (role_name, info)
# ── 좌표 계산 (containers에서 동적으로) ──
# footer
footer_ci = containers.get(footer_role[0], {}) if footer_role else {}
footer_h = footer_ci.get("height_px", 53) if isinstance(footer_ci, dict) else 53
ft_top = slide_h - pad - footer_h
# 상단
top_ci = containers.get(top_role[0], {}) if top_role else {}
top_h = top_ci.get("height_px", 200) if isinstance(top_ci, dict) else 200
top_w = top_ci.get("width_px", inner_w) if isinstance(top_ci, dict) else inner_w
top_top = pad + header_h + gap_block
# 이미지 크기
img_constraints = top_ci.get("block_constraints", {}) if isinstance(top_ci, dict) else {}
img_w = img_constraints.get("img_width_px", 0)
has_image = img_constraints.get("has_image", False)
# 이미지 높이: 실제 비율로
img_h = 0
img_html = ""
if has_image and slide_images:
for img in slide_images:
b64 = img.get("b64", "")
if b64:
img_ratio = img.get("ratio", 1)
img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
break
# 하단
bottom_top = top_top + top_h + gap_small
# V'-4: 결론 바로 위까지 채움
column_bottom = ft_top - gap_block
bottom_h = column_bottom - bottom_top
bottom_col_w = (inner_w - gap_block) // 2
# ── normalized.sections에서 직접 텍스트 가져오기 ──
norm_sections = ctx.get("normalized", {}).get("sections", [])
font_size = fh.get("core", 12)
# 상단 (텍스트 + 이미지 나란히) — sections[0] 사용
top_html = ""
if top_role:
rn, info = top_role
tids = info.get("topic_ids", [])
# MDX 원본 sections에서 직접 가져오기
# 상단: 첫 번째 level=2 section + 하단 대목차 전까지의 level=2 sections를 합침
# (03번처럼 기술/사람/자연이 별도 section으로 분리된 경우 대응)
topic_title_from_section = ""
top_contents = []
for s in norm_sections:
if s["level"] == 3:
break # level=3(소목차) 나오면 상단 끝
if not topic_title_from_section and s.get("title"):
topic_title_from_section = s["title"]
content = s.get("content", "")
if content:
# section title도 소제목으로 포함 (첫 번째 제외)
if s["title"] and s["title"] != topic_title_from_section:
top_contents.append(f"### {s['title']}")
top_contents.append(content)
all_text = "\n".join(top_contents)
# 마크다운 bold → HTML
all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)
# 팝업 분리
popup_titles = []
content_lines = []
for line in all_text_clean.split("\n"):
stripped = line.strip()
if not stripped:
continue
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
if popup_match:
popup_titles.append(popup_match.group(1))
continue
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
continue
content_lines.append(stripped)
# 팝업 링크 우측상단
popup_html = ""
if popup_titles:
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles)
popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
# 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
sections = [] # [(소제목, [불릿들])]
current_section = ("", [])
for line in content_lines:
if line.startswith("### ") or line.startswith("###"):
if current_section[0] or current_section[1]:
sections.append(current_section)
current_section = (line.lstrip("# ").strip(), [])
elif re.match(r'^D1:\s*', line):
# D1 = 1단 불릿 = 소제목 (카드 제목)
title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
if current_section[0] or current_section[1]:
sections.append(current_section)
current_section = (bold(title_text, rn), [])
elif re.match(r'^D[2-9]:\s*', line):
# D2+ = 하위 불릿 = 본문
clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
if clean.startswith("출처:"):
continue
current_section[1].append(bold(clean, rn))
else:
clean = line.lstrip("• ")
if clean.startswith("출처:"):
continue
current_section[1].append(bold(clean, rn))
if current_section[0] or current_section[1]:
sections.append(current_section)
# X'-2: 카드형 HTML — 소제목별 들여쓰기 계층
# X'-5: 카드 디자인 — 다크 그라데이션 배경, 밝은 텍스트
_card_colors = [
("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
]
card_pad = int(font_size * 0.6)
card_gap = max(3, int(font_size * 0.4))
indent_body = int(font_size * 1.2) # 본문 들여쓰기
bullets = ""
if len(sections) > 1 and sections[0][0]:
for ci, (sec_title, sec_items) in enumerate(sections):
bg, text_color = _card_colors[ci % len(_card_colors)]
items_html = "".join(
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
for item in sec_items
)
if sec_title:
bullets += (
f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
f'margin-bottom:{int(font_size*0.3)}px;">{bold(sec_title, rn)}</div>'
f'{items_html}</div>\n'
)
else:
bullets += items_html
else:
for sec_title, sec_items in sections:
for item in sec_items:
bullets += (
f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
f'<span style="font-size:{font_size}px;">• {item}</span></div>\n'
)
# X'-3: 이미지 캡션 — normalized.images alt → 출처 → [이미지:] 마커 순
img_caption = ""
norm_images = ctx.get("normalized", {}).get("images", [])
if norm_images:
img_caption = norm_images[0].get("alt", "")
if not img_caption:
for line in all_text.split("\n"):
stripped = line.strip().lstrip("• ")
if stripped.startswith("출처:"):
img_caption = re.sub(r'^출처:\s*', '', stripped)
break
if not img_caption:
img_marker = re.search(r'\[이미지:\s*([^\]]+)\]', all_text)
if img_marker:
img_caption = img_marker.group(1)
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
# 이미지 블록
img_block = ""
if has_image and img_html:
img_block = (
f'<div style="width:{img_w}px;flex-shrink:0;">'
f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
f'{caption_html}</div>'
)
# 제목 — MDX 원본 section 제목 사용
topic_title = bold(topic_title_from_section or rn, rn)
# X'-4: 상단 컨테이너 — 내용을 전체 높이에 균등 배분
top_html = (
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;justify-content:space-between;">'
f'{popup_html}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
f'{img_block}</div></div>'
)
# 하단: normalized.sections에서 직접 매핑
# sections 구조: [level=2 상단, level=2 하단대목차, level=3 하단좌, level=3 하단우, ...]
# 하단 대목차 = level=2 두 번째
# 하단 소목차들 = level=3
# 하단: level=3이 존재하는 구역의 level=2가 대목차
bottom_title = ""
sub_sections_from_norm = [] # [(제목, content)]
found_level3 = False
for s in norm_sections:
if s["level"] == 3:
found_level3 = True
sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
elif s["level"] == 2 and not found_level3:
# level=3 전의 level=2는 상단에 속함 → 건너뜀
continue
elif s["level"] == 2 and found_level3:
# level=3 이후의 level=2 → 하단 대목차 후보 (이미 잡혔으면 무시)
pass
# 하단 대목차: level=3 바로 앞의 level=2
for s in norm_sections:
if s["level"] == 2:
# 이 section 다음에 level=3이 오면 이게 대목차
idx = norm_sections.index(s)
if idx + 1 < len(norm_sections) and norm_sections[idx + 1]["level"] == 3:
bottom_title = s.get("title", "")
break
bl_indent = int(font_size * 1.2)
# 하단 좌측 = 첫 번째 소목차 (level=3)
bl_html = ""
if sub_sections_from_norm and bottom_left_role:
rn = bottom_left_role[0]
sub_title, sub_content = sub_sections_from_norm[0]
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
bullets = ""
for line in sub_content.split("\n"):
stripped = line.strip()
if not stripped:
continue
# D마커 제거 + depth별 스타일
depth = 1
dm = re.match(r'^D(\d+):\s*', stripped)
if dm:
depth = int(dm.group(1))
stripped = re.sub(r'^D\d+:\s*', '', stripped)
clean = stripped.lstrip("- ").lstrip("• ")
clean = bold(clean, rn)
pad = bl_indent * depth
fs = font_size if depth == 1 else font_size - 1
weight = "font-weight:600;" if depth == 1 else ""
bullets += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
bl_html = (
f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title, rn)}</div>'
f'<div style="line-height:1.55;color:#333;">{bullets}</div></div>'
)
# 하단 우측 = 두 번째 소목차 (level=3) + 표 요약
br_html = ""
if bottom_right_role and len(sub_sections_from_norm) > 1:
rn = bottom_right_role[0]
sub_title_br, sub_content_br = sub_sections_from_norm[1]
sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)
content_lines_br = [l.strip() for l in sub_content_br.split("\n") if l.strip()]
# 팝업 링크 — 소목차 제목으로 팝업 링크 생성
popup_html_br = ""
popup_link_title = f"{sub_title_br} 바로가기"
popup_html_br = (
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
)
# 불릿 — table_summaries가 있으면 표 데이터는 Kei 요약으로 대체되므로 불릿은 간략하게
table_summaries = enh.get("table_summaries", {})
bullets = ""
if not table_summaries:
# 표 요약 없으면 content 그대로
for line in content_lines_br:
stripped = line.strip()
if not stripped:
continue
depth = 1
dm = re.match(r'^D(\d+):\s*', stripped)
if dm:
depth = int(dm.group(1))
stripped = re.sub(r'^D\d+:\s*', '', stripped)
clean = stripped.lstrip("- ").lstrip("• ")
if clean:
clean = bold(clean, rn)
pad = bl_indent * depth
fs = font_size if depth == 1 else font_size - 1
weight = "font-weight:600;" if depth == 1 else ""
bullets += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'
# X'-6: 본문 표 요약이 있으면 하단 우측에 추가
table_summaries = enh.get("table_summaries", {})
table_html_br = ""
for ts_key, ts_data in table_summaries.items():
fmt = ts_data.get("format", "text")
if fmt == "table":
cols = ts_data.get("columns", [])
data = ts_data.get("data", [])
col_count = len(cols)
if col_count > 0 and data:
header_cells = "".join(
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.5)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
for c in cols
)
rows_html = ""
for ri, row in enumerate(data):
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
cells = ""
for ci, cell in enumerate(row):
c_color = "#1e40af" if ci == 0 else "#475569"
c_weight = "600" if ci == 0 else "400"
cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{bold(str(cell), rn)}</div>'
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
table_html_br = (
f'<div style="margin-top:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
f'{rows_html}</div>'
)
elif fmt == "bullets":
items = ts_data.get("items", [])
table_html_br = "".join(
f'<div style="padding-left:{int(font_size*1.2)}px;font-size:{font_size-1}px;margin-bottom:1px;">• {bold(str(item), rn)}</div>'
for item in items
)
elif fmt == "text":
table_html_br = f'<div style="font-size:{font_size-1}px;color:#475569;margin-top:{int(font_size*0.5)}px;">{bold(str(ts_data.get("summary", "")), rn)}</div>'
br_html = (
f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
f'display:flex;flex-direction:column;">'
f'{popup_html_br}'
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{bold(sub_title_br, rn)}</div>'
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
f'{table_html_br}</div>'
)
# 결론
footer_html = ""
if footer_role:
rn, info = footer_role
footer_html = (
f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
f'display:flex;align-items:center;justify-content:center;">'
f'<div style="font-size:{fh.get("key_msg",14)}px;font-weight:700;">{bold(core_message, rn)}</div></div>'
)
# ── HTML 조립 ──
_color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
</style></head><body>
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 (유형 B)</div>
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>
<div style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
{top_html}</div>
<div style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{bold(bottom_title, "")}</div>
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
<div style="flex:1;overflow:hidden;">
{bl_html}</div>
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
<div style="flex:1;overflow:hidden;">
{br_html}</div>
</div></div>
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;">
{footer_html}</div>
</div></body></html>"""
out = run / "steps" / "stage_2_code_assembled.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html, encoding="utf-8")
print(f"저장: {out} ({len(html)} bytes)")
+7 -10
View File
@@ -22,13 +22,8 @@ async def main(run_dir: str):
# Stage 1B context 로드 # Stage 1B context 로드
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8")) ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
# MDX 원본: samples에서 직접 읽기 (최신 원본 사용) # MDX 원본: context에서 가져옴 (어떤 MDX든 대응)
samples_dir = Path(__file__).parent.parent / "samples" raw_content = ctx_json.get("raw_content", "")
mdx_file = samples_dir / "mdx" / "01. 건설산업 DX의 올바른 이해(0127).mdx"
if mdx_file.exists():
raw_content = mdx_file.read_text(encoding="utf-8")
else:
raw_content = ctx_json.get("raw_content", "")
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵) # Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달 # page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
@@ -41,9 +36,11 @@ async def main(run_dir: str):
"page_structure": ps, "page_structure": ps,
"core_message": ctx_json.get("analysis", {}).get("core_message", ""), "core_message": ctx_json.get("analysis", {}).get("core_message", ""),
"title": ctx_json.get("analysis", {}).get("title", ""), "title": ctx_json.get("analysis", {}).get("title", ""),
"layout_template": ctx_json.get("analysis", {}).get("layout_template", "A"),
} }
print(f"=== Stage 1B 데이터 고정: {run.name} ===") layout = manual_layout.get("layout_template", "A")
print(f"=== Stage 1B 데이터 고정: {run.name} (유형 {layout}) ===")
print(f" topics: {len(ctx_json['topics'])}개") print(f" topics: {len(ctx_json['topics'])}개")
for t in ctx_json["topics"]: for t in ctx_json["topics"]:
print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)") print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)")
@@ -51,8 +48,8 @@ async def main(run_dir: str):
# pipeline.py의 generate_slide() 호출 # pipeline.py의 generate_slide() 호출
from src.pipeline import generate_slide from src.pipeline import generate_slide
# 이미지 base_path: samples/images/ # 이미지 base_path: context에서 가져옴
base_path = str(samples_dir / "images") base_path = ctx_json.get("base_path", "")
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path): async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
ev_type = event.get("event", "") ev_type = event.get("event", "")
ev_data = event.get("data", "") ev_data = event.get("data", "")
+892 -163
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: 프리셋 선택 (규칙 기반) # Step A: 프리셋 선택 (규칙 기반)
# ────────────────────────────────────── # ──────────────────────────────────────
+22 -20
View File
@@ -304,16 +304,8 @@ def calculate_fit(
if normalized is None: if normalized is None:
normalized = {} normalized = {}
# Phase X: zone 기반 font/line_height 결정 (고정 역할명 불필요) role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
def _font_key_for_role(role_name): role_line_height = {"본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3}
zone = containers.get(role_name, {}).get("zone", "")
if zone == "footer": return "key_msg"
if zone == "sidebar": return "sidebar"
return "core"
def _line_height_for_role(role_name):
zone = containers.get(role_name, {}).get("zone", "")
if zone == "footer": return 1.3
return 1.5
analysis = FitAnalysis() analysis = FitAnalysis()
@@ -335,9 +327,9 @@ def calculate_fit(
allocated_h = container.get("height_px", 0) allocated_h = container.get("height_px", 0)
width_px = container.get("width_px", 0) width_px = container.get("width_px", 0)
font_key = _font_key_for_role(role) font_key = role_font_map.get(role, "core")
font_size = font_hierarchy.get(font_key, 12) font_size = font_hierarchy.get(font_key, 12)
line_h = _line_height_for_role(role) line_h = role_line_height.get(role, 1.5)
# V-1 출력: 꼭지별 블록 리스트 # V-1 출력: 꼭지별 블록 리스트
ref_list = references.get(role, []) ref_list = references.get(role, [])
@@ -383,8 +375,7 @@ def calculate_fit(
has_image = img_h > 0 has_image = img_h > 0
# ── 4. key-msg (본심에만) ── # ── 4. key-msg (본심에만) ──
# keymsg: 이미지가 있는 역할에만 (zone 무관, 블록 기반) has_keymsg = (role == "본심" and core_message)
has_keymsg = (has_image and core_message)
keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0 keymsg_h = estimate_keymsg_height(core_message, font_hierarchy.get("key_msg", 14)) if has_keymsg else 0
# ── 5. 블록 오버헤드 ── # ── 5. 블록 오버헤드 ──
@@ -494,7 +485,12 @@ def build_escalation_report(analysis: FitAnalysis) -> str:
return "\n".join(lines) return "\n".join(lines)
# Phase X: ROLE_ZONE_MAP 제거. containers에서 zone을 직접 읽음. ROLE_ZONE_MAP = {
"본심": "body",
"배경": "body",
"첨부": "sidebar",
"결론": "footer",
}
def redistribute( def redistribute(
@@ -505,12 +501,18 @@ def redistribute(
"""부족 영역에 여유 영역의 공간을 재배분. """부족 영역에 여유 영역의 공간을 재배분.
같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심). 같은 zone 내에서만 재배분 가능 (body 안의 배경↔본심).
유형 B: containers의 zone 속성에서 동적으로 매핑.
""" """
zone_roles: dict[str, list[str]] = {} zone_roles: dict[str, list[str]] = {}
for role in analysis.roles: for role in analysis.roles:
# Phase X: containers에서 zone 직접 읽기 (고정 맵 불필요) # containers에 zone 정보가 있으면 그걸 사용, 없으면 ROLE_ZONE_MAP fallback
ci = containers.get(role, {}) ci = containers.get(role)
zone = ci.get("zone", "body") if isinstance(ci, dict) else "body" if ci is not None:
zone = ci.get("zone") if isinstance(ci, dict) else getattr(ci, "zone", None)
else:
zone = None
if not zone:
zone = ROLE_ZONE_MAP.get(role, "body")
if zone not in zone_roles: if zone not in zone_roles:
zone_roles[zone] = [] zone_roles[zone] = []
zone_roles[zone].append(role) zone_roles[zone].append(role)
@@ -945,8 +947,8 @@ def calculate_sub_layout(
layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px) layout = ContainerLayout(role=role, main_height_px=main_height_px, main_width_px=main_width_px)
# 제목 높이: font_size * line_height + margin # 제목 높이: font_size * line_height + margin
# Phase X: font_key는 font_hierarchy에서 가장 가까운 키 사용 role_font_map = {"본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "key_msg"}
font_key = "core" # 기본값. zone 정보가 없으면 core font_key = role_font_map.get(role, "core")
title_font = font_hierarchy.get(font_key, 12) title_font = font_hierarchy.get(font_key, 12)
title_h = title_font * 1.5 + tokens["spacing_small"] title_h = title_font * 1.5 + tokens["spacing_small"]
+109 -65
View File
@@ -82,14 +82,9 @@ def build_area_prompt(
refs = phase_t.get("references", {}) refs = phase_t.get("references", {})
budgets = phase_t.get("design_budgets", {}) budgets = phase_t.get("design_budgets", {})
# Phase X: zone 기반 font 결정 (고정 역할명 불필요) # 역할별 폰트 매핑
# phase_t에서 이 역할의 zone을 찾아 font 매핑 role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
_ps = phase_t.get("fit_result", {}).get("redistribution", {}) font_size = fh.get(role_font_map.get(role, "core"), 12)
# containers 정보가 있으면 zone 사용, 없으면 core 기본
_containers = phase_t.get("_containers", {})
_zone = _containers.get(role, {}).get("zone", "") if _containers else ""
_fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core")
font_size = fh.get(_fk, 12)
# 들여쓰기 (폰트 크기 기반) # 들여쓰기 (폰트 크기 기반)
indent_pl, indent_ti = _calc_indent(font_size) indent_pl, indent_ti = _calc_indent(font_size)
@@ -197,8 +192,8 @@ def build_area_prompt(
indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고 indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고
둘째줄도 정확히 같은 위치에서 시작한다</div>""" 둘째줄도 정확히 같은 위치에서 시작한다</div>"""
# ── Phase X: zone 기반 역할 지시 (고정 역할명 불필요) ── # ── 역할별 지시 ──
if _zone in ("top",) and not images: # 상단 강조/도입 영역 (이미지 없음) if role == "배경":
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라. parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
## 핵심 원칙 ## 핵심 원칙
@@ -219,7 +214,7 @@ def build_area_prompt(
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""") 불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
elif images or _zone in ("main", "body"): # 이미지 포함 또는 메인 콘텐츠 elif role == "본심":
img_instruction = "" img_instruction = ""
if images: if images:
for img in images: for img in images:
@@ -269,7 +264,7 @@ def build_area_prompt(
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라. - 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
{img_instruction}""") {img_instruction}""")
elif _zone == "sidebar": # 참조/보조 sidebar elif role == "첨부":
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라. parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
## 크기 (TP-3: 잘림 방지) ## 크기 (TP-3: 잘림 방지)
@@ -291,7 +286,7 @@ def build_area_prompt(
- 카드 간 간격 8px. - 카드 간 간격 8px.
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""") - 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
elif _zone == "footer": # 결론/footer elif role == "결론":
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라. parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
## 크기 ## 크기
@@ -301,21 +296,6 @@ def build_area_prompt(
- 핵심 메시지: {font_size}px bold white - 핵심 메시지: {font_size}px bold white
- 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""") - 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""")
else: # bottom_left, bottom_right, bottom_1~3 등 범용 콘텐츠 영역
parts.append(f"""다음 콘텐츠를 "{role}" 영역 HTML로 만들어라.
## 크기
- width: 100%, height: {height_px}px
- 이 크기 안에 모든 내용이 들어가야 한다.
## 폰트
- 본문: {font_size}px. 제목: {font_size + 1}px bold.
## 들여쓰기
{indent_example}
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
# ── 공통: 콘텐츠 ── # ── 공통: 콘텐츠 ──
parts.append(f""" parts.append(f"""
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.) ## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
@@ -564,10 +544,8 @@ def _build_phase_t_supplement(role: str, analysis: dict) -> str:
# 1. 폰트 위계 (역할별 확정 폰트) # 1. 폰트 위계 (역할별 확정 폰트)
fh = phase_t.get("font_hierarchy", {}) fh = phase_t.get("font_hierarchy", {})
# Phase X: zone 기반 font 결정 role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"}
_regen_containers = phase_t.get("_containers", {}) font_key = role_font_map.get(role, "core")
_regen_zone = _regen_containers.get(role, {}).get("zone", "") if _regen_containers else ""
font_key = "key_msg" if _regen_zone == "footer" else ("sidebar" if _regen_zone == "sidebar" else "core")
font_size = fh.get(font_key, 12) font_size = fh.get(font_key, 12)
parts.append( parts.append(
f"\n[폰트 위계 — 반드시 준수]\n" f"\n[폰트 위계 — 반드시 준수]\n"
@@ -636,66 +614,132 @@ async def generate_slide_html(
return [] return []
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map] return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
# Phase X: 동적 역할 루프 — page_structure의 모든 역할을 순회 bg_topics = get_topics_for_role("배경")
core_topics = get_topics_for_role("본심")
ref_topics = get_topics_for_role("첨부")
conclusion_topics = get_topics_for_role("결론")
bg_spec = container_specs.get("배경")
core_spec = container_specs.get("본심")
ref_spec = container_specs.get("첨부")
concl_spec = container_specs.get("결론")
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""} result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
# ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ──
from src.fit_verifier import _load_design_tokens from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens() tokens = _load_design_tokens()
bg_h = bg_spec.height_px if bg_spec else 0
core_h = core_spec.height_px if core_spec else 0
footer_h = concl_spec.height_px if concl_spec else 0
sidebar_h = ref_spec.height_px if ref_spec else 0
# body zone = 배경 + 본심 + gap
bg_core_gap = tokens["spacing_small"] bg_core_gap = tokens["spacing_small"]
body_zone_h = bg_h + core_h + (bg_core_gap if bg_topics and core_topics else 0)
sidebar_zone_h = sidebar_h if sidebar_h > 0 else body_zone_h
# core_max_h: 본심 컨테이너 높이에서 key-msg 높이를 빼야 Sonnet이 넘치지 않음
phase_t = analysis.get("phase_t", {}) phase_t = analysis.get("phase_t", {})
core_sub = phase_t.get("sub_layouts", {}).get("본심", {})
keymsg_sub_h = 0
for sc in core_sub.get("sub_containers", []):
if sc.get("name") == "keymsg":
keymsg_sub_h = sc.get("height_px", 0)
core_max_h = core_h - keymsg_sub_h if core_h > 0 else (body_zone_h - bg_h - bg_core_gap if bg_topics else body_zone_h)
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px (keymsg={keymsg_sub_h}px 제외)")
# Phase T context
phase_t = analysis.get("phase_t", {})
# 원본 텍스트 매핑
sections = _slice_mdx_sections(content) sections = _slice_mdx_sections(content)
# ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ──
def _get_role_content(role_topics): def _get_role_content(role_topics):
"""structured_text를 우선 사용. 없으면 기존 sections 매칭."""
texts = [] texts = []
for t in role_topics: for t in role_topics:
st = t.get("structured_text", "") st = t.get("structured_text", "")
if st: if st:
texts.append(st) texts.append(st)
else: else:
# fallback: source_hint 키워드로 sections에서 매칭
keywords = _extract_keywords_from_hints([t]) keywords = _extract_keywords_from_hints([t])
matched = _map_sections_for_role(sections, [t], keywords) matched = _map_sections_for_role(sections, [t], keywords)
if matched: if matched:
texts.append(matched) texts.append(matched)
return "\n\n".join(texts) if texts else "" return "\n\n".join(texts) if texts else ""
for role_name, role_info in page_struct.items(): # ── 배경 ──
if not isinstance(role_info, dict): if bg_topics:
continue logger.info("[Phase T] 배경 생성...")
role_topics = get_topics_for_role(role_name) bg_content = _get_role_content(bg_topics)
if not role_topics: body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0)
continue
spec = container_specs.get(role_name)
if not spec:
continue
zone = spec.zone
role_content = _get_role_content(role_topics)
role_images = [img for img in images if img.get("topic_id") in [t["id"] for t in role_topics]]
logger.info(f"[Phase X] {role_name}({zone}) 생성...")
prompt = build_area_prompt( prompt = build_area_prompt(
role=role_name, role="배경",
content_block=role_content, content_block=bg_content,
phase_t=phase_t, phase_t=phase_t,
height_px=spec.height_px, height_px=bg_h,
width_px=spec.width_px, width_px=body_width,
images=role_images if role_images else None,
core_message=analysis.get("core_message", "") if zone != "footer" else "",
) )
html = await _call_claude(client, prompt) html = await _call_claude(client, prompt)
if html: if html:
if role_images: result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
html = _replace_img_placeholder(html, images) logger.info(f"[Phase T] 배경 완료: {len(html)}자")
# zone에 따라 적절한 result 키에 추가
if zone == "footer":
result["footer_html"] += html + "\n"
elif zone == "sidebar":
result["sidebar_html"] += html + "\n"
else:
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
logger.info(f"[Phase X] {role_name} 완료: {len(html)}자")
# Phase X: 동적 루프에서 모든 역할 처리 완료. 기존 sidebar/footer 개별 블록 제거. # ── 본심 ──
if core_topics:
logger.info("[Phase T] 본심 생성...")
core_content = _get_role_content(core_topics)
core_images = [img for img in images if img.get("topic_id") in [t["id"] for t in core_topics]]
body_width = core_spec.width_px if core_spec else (bg_spec.width_px if bg_spec else 0)
prompt = build_area_prompt(
role="본심",
content_block=core_content,
phase_t=phase_t,
height_px=core_max_h,
width_px=body_width,
images=core_images,
core_message=analysis.get("core_message", ""),
)
html = await _call_claude(client, prompt)
if html:
html = _replace_img_placeholder(html, images)
result["body_html"] += html + "\n"
logger.info(f"[Phase T] 본심 완료: {len(html)}자")
# ── sidebar ──
if ref_topics:
logger.info("[Phase T] sidebar 생성...")
sidebar_content = _get_role_content(ref_topics)
sidebar_width = ref_spec.width_px if ref_spec else 0
prompt = build_area_prompt(
role="첨부",
content_block=sidebar_content,
phase_t=phase_t,
height_px=sidebar_zone_h,
width_px=sidebar_width,
)
html = await _call_claude(client, prompt)
if html:
result["sidebar_html"] = html
logger.info(f"[Phase T] sidebar 완료: {len(html)}자")
# ── footer ──
if conclusion_topics:
logger.info("[Phase T] footer 생성...")
footer_content = _get_role_content(conclusion_topics) or _get_conclusion(content)
footer_width = concl_spec.width_px if concl_spec else 0
prompt = build_area_prompt(
role="결론",
content_block=footer_content.strip(),
phase_t=phase_t,
height_px=concl_spec.height_px if concl_spec else 0,
width_px=footer_width,
)
html = await _call_claude(client, prompt)
if html:
result["footer_html"] = html
logger.info(f"[Phase T] footer 완료: {len(html)}자")
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용." result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
return result return result
+121 -77
View File
@@ -23,69 +23,97 @@ KEI_PROMPT = (
"- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n" "- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
"- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n" "- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
"- core_message 필드에 기록.\n\n" "- core_message 필드에 기록.\n\n"
"## 2단계: 콘텐츠 분석 및 꼭지 설계\n" "## 2단계: 정보 구조 파악\n"
"콘텐츠를 분석하여 꼭지(topic)를 나눠라.\n" "- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n"
"- 꼭지 수와 역할명은 **콘텐츠에 맞게 자유롭게** 결정하라.\n" "- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
"- '배경/본심/첨부/결론' 같은 고정 틀에 억지로 끼우지 마라.\n" "- info_structure 필드에 기술.\n\n"
"- 배경이 없으면 배경을 만들지 마라. 3분할이 적절하면 3분할하라.\n" "## 3단계: 슬라이드 스토리라인 설계\n"
"- 결론 꼭지는 반드시 1개 있어야 한다 (슬라이드 하단 핵심 한 줄).\n" "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
"- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
"- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
"- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
"- 하단: 세부 내용 2분할 (좌/우)\n"
"- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
"- 결론 키는 반드시 '결론'\n\n"
"선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
"### 역할별 규칙 (유형 A)\n"
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
"- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
"- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
"- **결론**: 핵심 한 줄. footer.\n\n"
"### 역할별 규칙 (유형 B)\n"
"- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
"- 하단 좌측: zone: 'bottom_left'\n"
"- 하단 우측: zone: 'bottom_right'\n"
"- 결론: zone: 'footer'\n\n"
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
"page_structure 필드에 기록.\n\n"
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
" 임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n"
"- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n"
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n" "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
"## 3단계: 레이아웃 템플릿 선택\n" "## 배치 규칙\n"
"꼭지 구조를 보고, 아래 템플릿 중 **가장 적합한 것을 선택**하라.\n\n" "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
"### 템플릿 옵션:\n" "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
"**A. body-sidebar** — 좌측 본문 + 우측 참조\n" "- 결론은 layer: 'conclusion' → 하단 배치\n"
" zones: body(65%), sidebar(35%) — 1행\n" "- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
" 적합: 참조자료(용어 정의 등)가 별도로 있는 콘텐츠\n\n"
"**B. top-wide + bottom-2col** — 상단 강조 + 하단 2분할\n"
" zones: top(100%) / bottom_left(50%), bottom_right(50%) — 2행\n"
" 적합: 핵심 1개 + 두 가지 측면 비교/설명\n\n"
"**C. top-wide + bottom-3col** — 상단 강조 + 하단 3분할\n"
" zones: top(100%) / bottom_1(33%), bottom_2(34%), bottom_3(33%) — 2행\n"
" 적합: 핵심 1개 + 세 가지 항목 병렬\n\n"
"**D. 2col-equal** — 좌우 대등 2분할\n"
" zones: left(50%), right(50%) — 1행\n"
" 적합: 두 가지 비교/대비\n\n"
"**E. single-full** — 단일 전체\n"
" zones: main(100%) — 1행\n"
" 적합: 하나의 흐름, 분할 불필요\n\n"
"**F. top-wide + bottom-2col + sidebar** — 상단 강조 + 하단 2분할 + 참조\n"
" zones: top(65%)+sidebar(35%) / bottom_left(32%)+bottom_right(33%)+sidebar(35%) — 2행\n"
" 적합: B + 참조자료 조합\n\n"
"## 4단계: page_structure 작성\n"
"선택한 템플릿의 zone에 꼭지를 배정하고 weight(공간 비중, 합계 1.0)를 결정하라.\n"
"- page_structure의 키 = zone 이름 + 결론(footer)\n"
"- 결론은 항상 \"결론\" 키로, zone은 항상 \"footer\"\n"
"- 나머지 키 이름은 콘텐츠에 맞게 자유롭게 (예: \"핵심목표\", \"발주처_기대효과\" 등)\n"
"- 각 항목에 zone, topic_ids, weight를 기록\n"
"- **weight는 콘텐츠에 따라 매번 달라진다. 고정값이 아니다.**\n\n"
"## 원본 텍스트 보존 원칙\n"
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n\n"
"## 규칙\n"
"- 이미지/표가 있으면 images[], tables[]에 기록\n" "- 이미지/표가 있으면 images[], tables[]에 기록\n"
"- 1페이지 적정 꼭지: 3~6개. 분량에 맞게.\n" "- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
"- **슬라이드 제목(title)과 꼭지 제목은 달라야 한다.**\n\n" "- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
"## 출력 형식 (JSON만)\n" "## 출력 형식 (JSON만)\n"
"layout_template에 따라 page_structure가 달라진다.\n\n"
"유형 A 예시:\n"
"```json\n" "```json\n"
'{"title": "슬라이드 제목", ' '{"title": "제목", '
'"core_message": "핵심 메시지 한 줄", ' '"core_message": "핵심 메시지", '
'"total_pages": 1, ' '"total_pages": 1, '
'"layout_template": "B", ' '"layout_template": "A", '
'"page_structure": {' '"page_structure": {'
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.25}, ' '"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
'"발주처_기대효과": {"zone": "bottom_1", "topic_ids": [2], "weight": 0.20}, ' '"배경": {"topic_ids": [1], "weight": 0.15}, '
'"설계사_기대효과": {"zone": "bottom_2", "topic_ids": [3], "weight": 0.20}, ' '"첨부": {"topic_ids": [4], "weight": 0.15}, '
'"시공사_기대효과": {"zone": "bottom_3", "topic_ids": [4], "weight": 0.20}, ' '"결론": {"topic_ids": [5], "weight": 0.10}}, '
'"결론": {"zone": "footer", "topic_ids": [5], "weight": 0.15}}, '
'"topics": [' '"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", ' '{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", ' '"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", ' '"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
'"layer": "intro|core|supporting|conclusion", '
'"role": "flow|reference", '
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
'"content_type": "text|image|table|mixed", ' '"content_type": "text|image|table|mixed", '
'"page": 1}], ' '"detail_target": false, "page": 1}], '
'"images": [{"topic_id": 1, "description": "이미지 설명"}], ' '"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "description": "표 설명"}]}\n' '"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
"```\n\n"
"유형 B 예시:\n"
"```json\n"
'{"title": "제목", '
'"core_message": "핵심 메시지", '
'"total_pages": 1, '
'"layout_template": "B", '
'"page_structure": {'
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, '
'"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, '
'"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, '
'"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, '
'"topics": [...],'
'"images": [...]}\n'
"```\n\n" "```\n\n"
"## 콘텐츠:\n" "## 콘텐츠:\n"
) )
@@ -235,14 +263,20 @@ async def refine_concepts(
KEI_STRUCTURED_TEXT_PROMPT = ( KEI_STRUCTURED_TEXT_PROMPT = (
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n" "아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n" "각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
"## 규칙\n" "## 절대 규칙\n"
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n" "1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n"
"2. 각 문장을 불릿(•)으로 구분하라.\n" " 원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n" " → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n" " ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n"
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n" "2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n" "3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n"
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n" " 원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n"
"4. 각 문장을 불릿(•)으로 구분하라.\n"
"5. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
"6. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
"7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
"8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
"9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
"## 출력 형식 (JSON만. 설명 없이.)\n" "## 출력 형식 (JSON만. 설명 없이.)\n"
"```json\n" "```json\n"
'{"structured_texts": [' '{"structured_texts": ['
@@ -1271,8 +1305,8 @@ async def call_kei_bold_keywords(
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다. - 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다. - 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
JSON으로 응답하라. 키는 아래 역할명을 그대로 사용: JSON으로 응답하라:
{{{", ".join(f'"{r}": ["키워드", ...]' for r in role_texts.keys())}}} {{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
빈 역할은 빈 리스트로. 빈 역할은 빈 리스트로.
{role_section}""" {role_section}"""
@@ -1321,25 +1355,32 @@ JSON으로 응답하라. 키는 아래 역할명을 그대로 사용:
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다. KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다. 콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라. ## 핵심 원칙
- **텍스트 원문은 절대 수정/삭제/요약하지 않는다.**
- 공간이 부족하면 **하위 불릿(상세 설명)만 팝업으로 분리.**
- **소제목(카드 제목)은 반드시 슬라이드에 유지.** 절대 팝업으로 빼지 않는다.
- 슬라이드에는 소제목 + "바로가기 →" 링크. 팝업에 하위 불릿 원문 전체.
- overflow가 없는 영역은 건드리지 않는다.
## 판단 기준 ## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장 - overflow가 발생한 영역만 대상. 다른 영역은 결정하지 않는다.
- 배경은 보조 역할 — 간결화 가능 - 해당 영역 내에서 **하위 불릿(상세 설명)만** 팝업 대상.
- 사례/근거는 인라인 축약 또는 팝업 분리 가능 - 소제목/카드 제목은 슬라이드에 남겨서 구조를 유지.
- 용어 정의는 sidebar에 맞게 조정 가능 - 표 데이터가 큰 경우 → 표를 팝업으로 분리하고 요약만 남김.
- 한 번에 1~2개 역할만 결정. 전부 다 팝업으로 빼지 않는다.
## 출력 (JSON만. 설명 없이.) ## 출력 (JSON만. 설명 없이.)
- role에는 반드시 아래 "역할 목록"에 있는 **정확한 역할명**을 사용하라.
- overflow가 발생한 역할만 포함. overflow 없는 역할은 포함하지 마라.
```json ```json
{ {
"decisions": [ "decisions": [
{ {
"role": "배경", "role": "역할 목록에 있는 정확한 역할명",
"action": "merge|inline|popup|trim|restructure", "action": "popup",
"detail": "구체적 지시 (어떤 꼭지를 어떻게)", "detail": "팝업으로 분리할 구체적 내용 (하위 불릿만. 소제목은 유지)",
"reason": "판단 근거 1문장" "reason": "판단 근거 1문장"
} }
] ]
@@ -1347,11 +1388,7 @@ KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
``` ```
action 종류: action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침 - popup: 하위 불릿(상세 설명)을 팝업으로 분리. 소제목은 슬라이드에 유지.
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
""" """
@@ -1359,6 +1396,7 @@ async def call_kei_fit_escalation(
fit_report: str, fit_report: str,
topics: list[dict], topics: list[dict],
content_summary: str, content_summary: str,
role_names: list[str] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청. """Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
@@ -1380,10 +1418,16 @@ async def call_kei_fit_escalation(
indent=2, indent=2,
) )
# 실제 역할명 목록을 prompt에 명시 (Kei가 정확한 역할명을 사용하도록)
role_list_text = ""
if role_names:
role_list_text = f"\n## 역할 목록 (role에 반드시 아래 이름을 사용)\n" + "\n".join(f"- {r}" for r in role_names)
prompt = ( prompt = (
KEI_FIT_ESCALATION_PROMPT + "\n\n" KEI_FIT_ESCALATION_PROMPT + "\n\n"
f"## 적합성 검증 결과\n{fit_report}\n\n" f"## 적합성 검증 결과\n{fit_report}\n\n"
f"## 꼭지 목록\n{topics_desc}\n\n" f"## 꼭지 목록\n{topics_desc}"
f"{role_list_text}\n\n"
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}" f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
) )
+79 -13
View File
@@ -73,6 +73,44 @@ class _CodeBlockProtector:
# Layer 2: MDX 전용 패턴 처리 # Layer 2: MDX 전용 패턴 처리
# ══════════════════════════════════════ # ══════════════════════════════════════
def _convert_md_list_to_html(text: str) -> str:
"""마크다운 리스트(* item, - item)를 HTML <ul><li>로 변환.
들여쓰기 수준(2-4칸)을 감지하여 중첩 <ul>을 생성한다.
"""
lines = text.split("\n")
result = []
list_stack: list[int] = [] # 현재 열린 리스트의 들여쓰기 레벨들
for line in lines:
m = re.match(r"^(\s*)[*\-]\s+(.+)$", line)
if m:
indent = len(m.group(1))
content = m.group(2)
if not list_stack:
result.append("<ul>")
list_stack.append(indent)
elif indent > list_stack[-1]:
result.append("<ul>")
list_stack.append(indent)
else:
while len(list_stack) > 1 and indent < list_stack[-1]:
result.append("</ul></li>")
list_stack.pop()
result.append(f"<li>{content}")
else:
while list_stack:
result.append("</li></ul>")
list_stack.pop()
result.append(line)
while list_stack:
result.append("</li></ul>")
list_stack.pop()
return "\n".join(result)
def _convert_md_table_to_html(text: str) -> str: def _convert_md_table_to_html(text: str) -> str:
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환. """마크다운 테이블(| col | col |)을 HTML <table>로 변환.
@@ -120,11 +158,14 @@ def _render_md_table(table_lines: list[str]) -> str:
rows = [_parse_row(line) for line in table_lines[data_start:]] rows = [_parse_row(line) for line in table_lines[data_start:]]
# HTML 생성 # HTML 생성 — 셀 내 <br/> → <br> 유지 (줄바꿈 역할)
header_html = "".join(f"<th>{h}</th>" for h in headers) header_html = "".join(f"<th>{h}</th>" for h in headers)
rows_html = "" rows_html = ""
for row in rows: for row in rows:
cells = "".join(f"<td>{c}</td>" for c in row) cells = ""
for c in row:
c = re.sub(r"<br\s*/?>", "<br>", c)
cells += f"<td>{c}</td>"
rows_html += f"<tr>{cells}</tr>\n" rows_html += f"<tr>{cells}</tr>\n"
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>" return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
@@ -145,10 +186,13 @@ def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML # 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content) content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
content = content.replace("</div>", "") content = content.replace("</div>", "")
content = re.sub(r"<br\s*/?>", "\n", content) # 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내 <br/>로 행이 쪼개지는 것 방지)
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
# 마크다운 테이블 → HTML 테이블
content = _convert_md_table_to_html(content) content = _convert_md_table_to_html(content)
# 테이블 밖 <br/> → \n (테이블 안은 이미 <br>로 변환 완료)
content = re.sub(r"<br\s*/>", "\n", content)
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
# 마크다운 리스트(* item) → HTML <ul><li>
content = _convert_md_list_to_html(content)
popups.append({"title": title, "content": content}) popups.append({"title": title, "content": content})
return f"[팝업: {title}]" return f"[팝업: {title}]"
@@ -229,15 +273,20 @@ def _extract_structure(text: str) -> dict[str, Any]:
current_section_title = "" current_section_title = ""
current_section_lines = [] current_section_lines = []
current_section_level = 2
bullet_depth = 0 # 불릿 중첩 깊이 추적 (bullet_list_open/close)
def _flush_section(): def _flush_section():
nonlocal current_section_title, current_section_lines nonlocal current_section_title, current_section_lines, current_section_level, bullet_depth
if current_section_title: if current_section_title:
sections.append({ sections.append({
"level": 2, "level": current_section_level,
"title": current_section_title, "title": current_section_title,
"content": "\n".join(current_section_lines).strip(), "content": "\n".join(current_section_lines).strip(),
}) })
current_section_lines = [] current_section_lines = []
current_section_level = 2
bullet_depth = 0
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
# 이미지 추출 (inline children) # 이미지 추출 (inline children)
@@ -283,12 +332,24 @@ def _extract_structure(text: str) -> dict[str, Any]:
if table["headers"] or table["rows"]: if table["headers"] or table["rows"]:
tables.append(table) tables.append(table)
# 섹션 추출 (## 기준) # 불릿 depth 추적 (섹션 내용 수집 시 계층 보존)
if token.type == "heading_open" and token.tag == "h2": if current_section_title:
_flush_section() if token.type == "bullet_list_open":
# 다음 토큰이 inline (제목 텍스트) bullet_depth += 1
elif token.type == "bullet_list_close":
bullet_depth = max(0, bullet_depth - 1)
# 섹션 추출 (## 및 ### 기준 — 대목차/소목차 모두)
if token.type == "heading_open" and token.tag in ("h2", "h3"):
# 다음 토큰이 inline (제목 텍스트) — 무의미한 제목(<br/> 등)은 건너뜀
if i + 1 < len(tokens) and tokens[i + 1].type == "inline": if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
current_section_title = tokens[i + 1].content heading_text = tokens[i + 1].content.strip()
# <br/>, 빈 문자열, 숫자만 등은 section 제목으로 부적합
clean_heading = re.sub(r'<br\s*/?>', '', heading_text).strip()
if clean_heading and len(clean_heading) > 1:
_flush_section()
current_section_title = clean_heading
current_section_level = 2 if token.tag == "h2" else 3
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open", elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
"ordered_list_open", "fence"): "ordered_list_open", "fence"):
# 섹션 내용 수집 — inline 토큰의 content만 # 섹션 내용 수집 — inline 토큰의 content만
@@ -297,7 +358,12 @@ def _extract_structure(text: str) -> dict[str, Any]:
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장) # heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
parent_type = tokens[i - 1].type if i > 0 else "" parent_type = tokens[i - 1].type if i > 0 else ""
if parent_type != "heading_open": if parent_type != "heading_open":
current_section_lines.append(token.content) # depth prefix 추가: D1=1단 불릿, D2=2단 불릿, D3=3단 불릿
depth = max(1, bullet_depth) if bullet_depth > 0 else 0
if depth > 0:
current_section_lines.append(f"D{depth}: {token.content}")
else:
current_section_lines.append(token.content)
_flush_section() _flush_section()
+285 -166
View File
@@ -172,11 +172,13 @@ async def generate_slide(
page_struct_raw = analysis_raw.get("page_structure", {}) page_struct_raw = analysis_raw.get("page_structure", {})
page_structure = PageStructure(roles=page_struct_raw) page_structure = PageStructure(roles=page_struct_raw)
# X'-1: 제목은 원본 MDX frontmatter에서 가져옴 (Kei가 바꾸지 않음)
original_title = context.normalized.title or analysis_raw.get("title", "")
analysis = Analysis( analysis = Analysis(
core_message=analysis_raw.get("core_message", ""), core_message=analysis_raw.get("core_message", ""),
title=analysis_raw.get("title", ""), title=original_title,
total_pages=analysis_raw.get("total_pages", 1), total_pages=analysis_raw.get("total_pages", 1),
layout_template=analysis_raw.get("layout_template", "E"), layout_template=analysis_raw.get("layout_template", "A"),
) )
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
@@ -249,6 +251,7 @@ async def generate_slide(
[t.model_dump() for t in updated_topics], [t.model_dump() for t in updated_topics],
context.normalized.clean_text, context.normalized.clean_text,
raw_content=context.raw_content, raw_content=context.raw_content,
layout_template=context.analysis.layout_template,
) )
if validation_errors: if validation_errors:
return {"_errors": validation_errors} return {"_errors": validation_errors}
@@ -300,58 +303,56 @@ async def generate_slide(
# T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식) # T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식)
font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths) font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
# Phase X: zone 기반 font_hierarchy (역할명 자유)
# footer → key_msg, sidebar → sidebar, 나머지 → core/bg
# 텍스트 양이 가장 많은 역할 = core, 적은 역할 = bg
ps_roles = context.page_structure.roles
non_footer = {r: l for r, l in role_text_lengths.items()
if ps_roles.get(r, {}).get("zone") != "footer"}
sidebar_roles = {r: l for r, l in role_text_lengths.items()
if ps_roles.get(r, {}).get("zone") == "sidebar"}
core_roles = {r: l for r, l in non_footer.items() if r not in sidebar_roles}
# 가장 긴 텍스트 역할의 font → core, 나머지 → bg
core_font = max(font_hierarchy_dict.values()) if font_hierarchy_dict else 12.0
bg_font = min(v for v in font_hierarchy_dict.values() if v < core_font) if len(font_hierarchy_dict) > 1 else core_font - 1
sidebar_font = min(font_hierarchy_dict.values()) if sidebar_roles else bg_font
font_hierarchy = FontHierarchy( font_hierarchy = FontHierarchy(
key_msg=14.0, key_msg=font_hierarchy_dict.get("핵심", 14.0),
core=core_font, core=font_hierarchy_dict.get("본심", 12.0),
bg=bg_font, bg=font_hierarchy_dict.get("배경", 11.0),
sidebar=sidebar_font, sidebar=font_hierarchy_dict.get("첨부", 10.0),
) )
# Phase X: 템플릿 기반 컨테이너 생성 # 프리셋 선택 (비율 계산보다 먼저 — 프리셋의 기본 비율을 fallback으로 사용)
from src.design_director import LAYOUT_TEMPLATES, get_template analysis_dict = {
from src.space_allocator import build_containers_from_template "topics": [t.model_dump() for t in context.topics],
"page_structure": context.page_structure.roles,
}
preset_name = select_preset(analysis_dict)
preset = LAYOUT_PRESETS.get(preset_name, {})
# Kei가 선택한 layout_template (Stage 1A에서 저장됨) # T-5: 동적 비율 역산 (sidebar 텍스트 양 기반 + 프리셋 기본 비율)
layout_template_id = context.analysis.layout_template or "E" container_ratio = calculate_dynamic_ratio(
template = get_template(layout_template_id) or LAYOUT_TEMPLATES.get("E", {}) role_text_lengths, font_hierarchy_dict,
preset_name = layout_template_id
preset = template # 하위 호환
# 컨테이너 스펙 계산 (Phase X 템플릿 기반)
container_specs = build_containers_from_template(
page_structure=context.page_structure.roles,
template=template,
slide_width=settings.slide_width, slide_width=settings.slide_width,
slide_height=settings.slide_height, slide_height=settings.slide_height,
preset=preset,
) )
# container_ratio: 템플릿에서 추론 (하위 호환)
rows = template.get("rows", [])
if rows and len(rows[0].get("zones", [])) == 2:
splits = rows[0].get("splits", [50, 50])
container_ratio = splits
else:
container_ratio = [100, 0]
logger.info( logger.info(
f"[Phase X] 템플릿={layout_template_id}, " f"[T-5] 폰트 위계: 핵심={font_hierarchy.key_msg}, 본심={font_hierarchy.core}, "
f"폰트: core={font_hierarchy.core}, bg={font_hierarchy.bg}, sidebar={font_hierarchy.sidebar}" f"배경={font_hierarchy.bg}, 첨부={font_hierarchy.sidebar} / "
f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
) )
# Phase X-B: 유형에 따라 컨테이너 생성 분기
if context.analysis.layout_template in ("B", "B'"):
from src.space_allocator import build_containers_type_b
container_specs = build_containers_type_b(
page_structure=context.page_structure.roles,
slide_width=settings.slide_width,
slide_height=settings.slide_height,
image_sizes=image_sizes if isinstance(image_sizes, list) else (
[{**v, "key": k} for k, v in image_sizes.items()] if image_sizes else None
),
)
logger.info(f"[X-B] 유형 B 컨테이너 생성")
else:
# 유형 A: 기존 코드 그대로
container_specs = calculate_container_specs(
page_structure=context.page_structure.roles,
topics=[t.model_dump() for t in context.topics],
preset=preset,
slide_width=settings.slide_width,
slide_height=settings.slide_height,
)
# ContainerSpec → ContainerInfo 변환 # ContainerSpec → ContainerInfo 변환
containers = {} containers = {}
for role, spec in container_specs.items(): for role, spec in container_specs.items():
@@ -516,93 +517,138 @@ async def generate_slide(
# context에 sub_layouts 반영 후 filled 생성 # context에 sub_layouts 반영 후 filled 생성
context = context.model_copy(update={"sub_layouts": pre_sub_layouts}) context = context.model_copy(update={"sub_layouts": pre_sub_layouts})
# ── filled: before 컨테이너에 블록+텍스트 채움 → Selenium 측정 ── # ── filled→측정→Kei 재판단 루프 (최대 3회) ──
kei_decisions = []
updated_containers = dict(context.containers)
MAX_FIT_RETRIES = 3
filled_html = assemble_slide_html(context) for fit_round in range(MAX_FIT_RETRIES):
(steps_dir / "stage_1_8_filled.html").write_text( # context에 현재 kei_decisions 반영 (2회차부터 popup 결정이 반영됨)
filled_html.replace('</head><body>', '</head><body>\n' if kei_decisions:
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">' context = context.model_copy(update={
'Stage 1.8: filled (블록+텍스트 채운 상태)</div>\n' "enhancement_result": {
'<div style="font-size:11px;color:#666;margin-bottom:8px;">' **(context.enhancement_result or {}),
'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1), "kei_decisions": kei_decisions,
encoding="utf-8", },
) "containers": updated_containers,
})
filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html) # ── filled: 컨테이너에 블록+텍스트 채움 ──
logger.info(f"[Stage 1.8] filled 측정 완료") filled_html = assemble_slide_html(context)
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
# ── 판단: 넘치는 영역 처리 ── filled_html.replace('</head><body>', '</head><body>\n'
updated_containers = dict(context.containers) # 복사 f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
f'Stage 1.8: filled (round {fit_round+1}/{MAX_FIT_RETRIES})</div>\n'
for zone_name, zone_data in filled_measurement.get("zones", {}).items(): '<div style="font-size:11px;color:#666;margin-bottom:8px;">'
if zone_data.get("overflowed"): 'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
excess = zone_data.get("excess_px", 0) encoding="utf-8",
scroll_h = zone_data.get("scrollHeight", 0)
if zone_name == "sidebar":
# sidebar 예외: 세로 확장 허용
for role, ci in updated_containers.items():
if ci.zone == "sidebar":
new_h = max(ci.height_px, scroll_h + 10) # 여유 10px
updated_containers[role] = ci.model_copy(update={"height_px": new_h})
logger.info(f"[Stage 1.8] sidebar 예외 확장: {role} {ci.height_px}px → {new_h}px")
elif zone_name == "body":
# body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요")
# containers_dict 업데이트 (sidebar 확장 반영)
for role, ci in updated_containers.items():
containers_dict[role] = {
"height_px": ci.height_px,
"width_px": ci.width_px,
"zone": ci.zone,
}
# ── fit 계산 + 재배분 (업데이트된 컨테이너 기준) ──
fit_analysis = calculate_fit(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
containers=containers_dict,
references=refs_dict,
font_hierarchy=font_h,
normalized=normalized,
core_message=core_message,
)
fit_analysis = redistribute(fit_analysis, containers_dict)
# ── after: 조정된 컨테이너 ──
for role, ci in updated_containers.items():
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})
logger.info(f"[Stage 1.8] after: " + ", ".join(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
# Step 3: Kei 에스컬레이션 (필요 시)
if fit_analysis.needs_escalation:
from src.kei_client import call_kei_fit_escalation
report = build_escalation_report(fit_analysis)
logger.info(f"[Stage 1.8] 에스컬레이션 필요:\n{report}")
kei_result = await call_kei_fit_escalation(
fit_report=report,
topics=[t.model_dump() for t in context.topics],
content_summary=context.raw_content[:1500],
) )
kei_decisions = []
if kei_result: # ── Selenium 측정 ──
kei_decisions = kei_result.get("decisions", []) filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
logger.info(f"[Stage 1.8] Kei 결정: {len(kei_decisions)}건") logger.info(f"[Stage 1.8] round {fit_round+1} 측정 완료")
for d in kei_decisions:
action = d.get("action", "") # ── overflow 확인 ──
target_role = d.get("role", "") has_overflow = False
detail = d.get("detail", "") for zone_name, zone_data in filled_measurement.get("zones", {}).items():
logger.info(f"[V-4] {target_role} → {action}: {detail}") if zone_data.get("overflowed"):
if action == "restructure" and target_role in fit_analysis.roles: excess = zone_data.get("excess_px", 0)
fit_analysis = redistribute(fit_analysis, containers_dict) scroll_h = zone_data.get("scrollHeight", 0)
else: has_overflow = True
kei_decisions = []
if zone_name == "sidebar":
for role, ci in updated_containers.items():
if ci.zone == "sidebar":
new_h = max(ci.height_px, scroll_h + 10)
updated_containers[role] = ci.model_copy(update={"height_px": new_h})
logger.info(f"[Stage 1.8] sidebar 확장: {role} → {new_h}px")
else:
logger.info(f"[Stage 1.8] {zone_name} overflow +{excess}px")
if not has_overflow:
logger.info(f"[Stage 1.8] round {fit_round+1}: overflow 없음 — 완료")
break
# ── fit 계산 + 재배분 ──
for role, ci in updated_containers.items():
containers_dict[role] = {
"height_px": ci.height_px,
"width_px": ci.width_px,
"zone": ci.zone,
}
fit_analysis = calculate_fit(
topics=[t.model_dump() for t in context.topics],
page_structure=context.page_structure.roles,
containers=containers_dict,
references=refs_dict,
font_hierarchy=font_h,
normalized=normalized,
core_message=core_message,
)
fit_analysis = redistribute(fit_analysis, containers_dict)
# Type B: zone 간 재배분
if context.analysis.layout_template in ("B", "B'"):
deficit_roles = [(r, rf.shortfall_px) for r, rf in fit_analysis.roles.items() if rf.shortfall_px > 0]
surplus_roles = [(r, abs(rf.shortfall_px)) for r, rf in fit_analysis.roles.items() if rf.shortfall_px < -8]
if deficit_roles and surplus_roles:
total_deficit = sum(d for _, d in deficit_roles)
total_surplus = sum(s for _, s in surplus_roles)
transferable = min(total_deficit, total_surplus)
if transferable > 0:
for role, deficit in deficit_roles:
share = transferable * (deficit / total_deficit)
old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
fit_analysis.redistribution[role] = old + share
for role, surplus in surplus_roles:
share = transferable * (surplus / total_surplus)
old = fit_analysis.redistribution.get(role, fit_analysis.roles[role].allocated_px)
fit_analysis.redistribution[role] = old - share
logger.info(f"[Stage 1.8] zone 간 재배분: {transferable:.0f}px 이전")
# 재배분된 컨테이너 크기 적용
for role, ci in updated_containers.items():
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})
logger.info(f"[Stage 1.8] round {fit_round+1} after: " + ", ".join(
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
))
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
if fit_analysis.needs_escalation or has_overflow:
from src.kei_client import call_kei_fit_escalation
report = build_escalation_report(fit_analysis)
# Selenium 실측 overflow 정보를 report에 추가 (calculate_fit과 실측이 다를 수 있음)
selenium_overflow_lines = []
for zn, zd in filled_measurement.get("zones", {}).items():
if zd.get("overflowed"):
selenium_overflow_lines.append(
f" ❌ {zn} zone: 실측 {zd.get('scrollHeight')}px / 가용 {zd.get('clientHeight')}px → +{zd.get('excess_px', 0)}px 초과"
)
if selenium_overflow_lines:
report += "\n\nSelenium 실측 overflow:\n" + "\n".join(selenium_overflow_lines)
logger.info(f"[Stage 1.8] round {fit_round+1} 에스컬레이션 필요")
kei_result = await call_kei_fit_escalation(
fit_report=report,
topics=[t.model_dump() for t in context.topics],
content_summary=context.raw_content[:1500],
role_names=list(context.page_structure.roles.keys()),
)
if kei_result:
new_decisions = kei_result.get("decisions", [])
kei_decisions.extend(new_decisions)
logger.info(f"[Stage 1.8] Kei 결정 {len(new_decisions)}건 추가 (누적 {len(kei_decisions)}건)")
for d in new_decisions:
logger.info(f"[V-4] {d.get('role','')} → {d.get('action','')}: {d.get('detail','')[:60]}")
else:
logger.warning(f"[Stage 1.8] round {fit_round+1} Kei 응답 없음 — 루프 종료")
break
else:
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
break
# Step 4: 보강 제안 분석 # Step 4: 보강 제안 분석
enhancements = analyze_enhancements( enhancements = analyze_enhancements(
@@ -712,10 +758,9 @@ async def generate_slide(
text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None) text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None)
if not text_sc: if not text_sc:
continue continue
# 텍스트 줄 수 계산 (zone 기반 font_size) # 텍스트 줄 수 계산
ci = updated_containers.get(role) role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
zone = ci.zone if ci else "" fk = role_font_map.get(role, "core")
fk = "key_msg" if zone == "footer" else ("sidebar" if zone == "sidebar" else "core")
fs = font_h.get(fk, 12) fs = font_h.get(fk, 12)
# structured_text에서 팝업 마커 찾기 # structured_text에서 팝업 마커 찾기
ps_info = context.page_structure.roles.get(role, {}) ps_info = context.page_structure.roles.get(role, {})
@@ -733,19 +778,39 @@ async def generate_slide(
popup = next((p for p in popups if pr in p.get("title", "")), None) popup = next((p for p in popups if pr in p.get("title", "")), None)
if not popup: if not popup:
continue continue
# 공란 계산: after(updated_containers) 기준 # 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
after_ci = updated_containers.get(role) from src.fit_verifier import _load_design_tokens as _ldt_v2
after_h = after_ci.height_px if after_ci else float(text_sc["height_px"]) _v2_tokens = _ldt_v2()
# after 높이에서 제목+keymsg+padding 제외 → 텍스트 영역 _v2_slide_h = _v2_tokens.get("slide_height", 720)
_v2_pad = _v2_tokens["spacing_page"]
_v2_header_h = _v2_tokens.get("header_height", 66)
_v2_gap = _v2_tokens["spacing_block"]
_v2_gap_small = _v2_tokens["spacing_small"]
_v2_concl_ci = updated_containers.get("결론") or next((ci for r, ci in updated_containers.items() if ci.zone == "footer"), None)
_v2_concl_h = _v2_concl_ci.height_px if _v2_concl_ci else 53
_v2_ft_top = _v2_slide_h - _v2_pad - _v2_concl_h - _v2_gap
_v2_column_bottom = _v2_ft_top - _v2_gap
_v2_bg_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "body" and r != role), None)
_v2_bg_h = _v2_bg_ci.height_px if _v2_bg_ci else 0
_v2_core_top = _v2_pad + _v2_header_h + _v2_gap + _v2_bg_h + _v2_gap_small
after_h = _v2_column_bottom - _v2_core_top # 결론 위까지의 실제 본심 높이
keymsg_h = 0 keymsg_h = 0
for sc in role_scs: for sc in role_scs:
if sc.get("name") == "keymsg": if sc.get("name") == "keymsg":
keymsg_h = float(sc.get("height_px", 0)) keymsg_h = float(sc.get("height_px", 0))
title_h = (fs + 1) * 1.5 + 4 title_h = (fs + 1) * 1.5 + 4
content_area_h = after_h - 16 - title_h - keymsg_h - 8 # pad*2 + gap # 이미지 높이: 실제 비율로 계산
svg_sc = next((sc for sc in role_scs if sc.get("name") == "svg"), None)
img_h = 0
if svg_sc:
svg_w = float(svg_sc.get("width_px", 200))
img_ratio = next((img.get("ratio", 1) for img in (context.slide_images or []) if img.get("b64")), 1)
img_h = svg_w / img_ratio if img_ratio > 0 else float(svg_sc.get("height_px", 0))
text_lines = len([l for l in st_text.split("\n") if l.strip() and not l.strip().startswith("[팝업:") and not l.strip().startswith("[이미지:") and not l.strip().lstrip("• ").startswith("출처:")]) text_lines = len([l for l in st_text.split("\n") if l.strip() and not l.strip().startswith("[팝업:") and not l.strip().startswith("[이미지:") and not l.strip().lstrip("• ").startswith("출처:")])
text_h_used = text_lines * fs * 1.55 text_h_used = text_lines * fs * 1.55
available_h = content_area_h - text_h_used # 표 공간 = 전체 - 제목 - max(이미지,텍스트) - keymsg - padding
upper_h = max(img_h, text_h_used)
available_h = after_h - 16 - title_h - upper_h - keymsg_h - 8
available_w = float(text_sc["width_px"]) available_w = float(text_sc["width_px"])
if available_h < fs * 3: if available_h < fs * 3:
continue # 공간 부족하면 건너뜀 continue # 공간 부족하면 건너뜀
@@ -760,6 +825,41 @@ async def generate_slide(
popup_summaries[pr] = summary popup_summaries[pr] = summary
logger.info(f"[V'-2] {pr}: format={summary.get('format')}") logger.info(f"[V'-2] {pr}: format={summary.get('format')}")
# X'-6: 본문 표 요약 (유형 B — normalized.tables가 있으면)
table_summaries = {}
norm_tables = context.normalized.tables or []
if norm_tables and context.analysis.layout_template in ("B", "B'"):
from src.kei_client import call_kei_summarize_popup
for ti, table_data in enumerate(norm_tables):
headers = table_data.get("headers", [])
rows = table_data.get("rows", [])
if not headers or not rows:
continue
# 표를 마크다운 형태로 변환하여 Kei에게 전달
md_table = "| " + " | ".join(headers) + " |\n"
md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in rows:
md_table += "| " + " | ".join(str(c) for c in row) + " |\n"
# 하단 우측 공간 계산
bottom_roles = [r for r, ci in updated_containers.items() if ci.zone in ("bottom_left", "bottom_right")]
if bottom_roles:
br_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "bottom_right"), None)
if br_ci:
available_h = br_ci.height_px - 30 # 제목 + padding
available_w = br_ci.width_px
fs = font_h.get("core", 12)
summary = await call_kei_summarize_popup(
popup_title=f"본문표{ti+1}",
popup_content=md_table,
available_width_px=available_w,
available_height_px=available_h,
font_size=fs,
)
if summary:
table_summaries[f"table_{ti}"] = summary
logger.info(f"[X'-6] 본문표{ti+1}: format={summary.get('format')}")
# 결과를 context에 저장 (Stage 2에서 사용) # 결과를 context에 저장 (Stage 2에서 사용)
return { return {
"containers": updated_containers, "containers": updated_containers,
@@ -791,6 +891,7 @@ async def generate_slide(
"emphasis_blocks": enhancements.emphasis_blocks, "emphasis_blocks": enhancements.emphasis_blocks,
"bold_keywords": enhancements.bold_keywords, "bold_keywords": enhancements.bold_keywords,
"popup_summaries": popup_summaries, "popup_summaries": popup_summaries,
"table_summaries": table_summaries,
}, },
} }
@@ -806,10 +907,9 @@ async def generate_slide(
# V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용 # V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용
ref_list = context.references.get(role, []) ref_list = context.references.get(role, [])
schema_info = ref_list[0].schema_info if ref_list else {} schema_info = ref_list[0].schema_info if ref_list else {}
# Phase X: zone 기반 font_size font_size = getattr(context.font_hierarchy, {
_zone = ci.zone if ci else "" "본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "core"
_fk = "key_msg" if _zone == "footer" else ("sidebar" if _zone == "sidebar" else "core") }.get(role, "core"), 12.0)
font_size = getattr(context.font_hierarchy, _fk, 12.0)
budget = calculate_design_budget( budget = calculate_design_budget(
container_height_px=ci.height_px, container_height_px=ci.height_px,
@@ -844,6 +944,14 @@ async def generate_slide(
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."} yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
async def stage_2(context: PipelineContext) -> dict: async def stage_2(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
if context.analysis.layout_template in ("B", "B'"):
from src.block_assembler import assemble_slide_html
generated = assemble_slide_html(context)
logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
return {"generated_html": generated}
# Type A: 기존 Sonnet 재구성 코드 그대로
from src.content_verifier import generate_with_retry from src.content_verifier import generate_with_retry
# PipelineContext → 기존 함수 인터페이스로 변환 # PipelineContext → 기존 함수 인터페이스로 변환
@@ -905,6 +1013,12 @@ async def generate_slide(
yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."} yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."}
async def stage_3(context: PipelineContext) -> dict: async def stage_3(context: PipelineContext) -> dict:
# Phase X-BX': Type B는 Stage 2에서 이미 완전한 HTML → renderer 스킵
if context.analysis.layout_template in ("B", "B'"):
logger.info("[Stage 3] Type B: renderer 스킵 (generated_html 직접 사용)")
return {"rendered_html": context.generated_html}
# Type A: 기존 renderer 코드 그대로
from src.renderer import render_slide_from_html from src.renderer import render_slide_from_html
analysis_dict = { analysis_dict = {
@@ -1033,6 +1147,34 @@ async def generate_slide(
# markdown bold → HTML bold # markdown bold → HTML bold
clean_content = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', clean_content) clean_content = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', clean_content)
# 콘텐츠 유형 감지: 테이블 vs 리스트
has_table = "<table" in clean_content
has_list = "<ul" in clean_content or "<li" in clean_content
# 콘텐츠 유형별 CSS
if has_table:
# 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
content_css = """
table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
th:nth-child(2) {{ width: 16%; }}
td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
elif has_list:
# 카드형 리스트: 항목별 박스, 하위 항목은 인라인
content_css = """
ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
else:
# 기본 (텍스트)
content_css = """
ul {{ padding-left: 20px; margin: 8px 0; }}
li {{ margin-bottom: 4px; font-size: 13px; }}"""
popup_html = f"""<!DOCTYPE html> popup_html = f"""<!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
@@ -1070,30 +1212,7 @@ h1 {{
color: #64748b; color: #64748b;
margin-bottom: 20px; margin-bottom: 20px;
}} }}
table {{ {content_css}
border-collapse: collapse;
width: 100%;
margin: 16px 0;
font-size: 13px;
}}
th {{
background: var(--color-primary);
color: #ffffff;
font-weight: 700;
padding: 10px 14px;
text-align: center;
border: 1px solid #334155;
}}
td {{
padding: 8px 14px;
border: 1px solid var(--color-border);
vertical-align: top;
line-height: 1.5;
}}
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}
td:first-child {{ font-weight: 600; background: #f1f5f9; }}
ul {{ padding-left: 20px; margin: 8px 0; }}
li {{ margin-bottom: 4px; font-size: 13px; }}
strong {{ color: var(--color-primary); }} strong {{ color: var(--color-primary); }}
.source {{ .source {{
font-size: 11px; font-size: 11px;
+2 -2
View File
@@ -63,7 +63,7 @@ class Analysis(BaseModel):
core_message: str = "" core_message: str = ""
title: str = "" title: str = ""
total_pages: int = 1 total_pages: int = 1
layout_template: str = "E" # Phase X: Kei가 선택한 템플릿 ID (A~F) layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict) image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
# topics와 page_structure는 PipelineContext 최상위에 위치 # topics와 page_structure는 PipelineContext 최상위에 위치
@@ -112,7 +112,7 @@ class FontHierarchy(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def check_hierarchy(self): def check_hierarchy(self):
"""폰트 위계 유지 검증: key_msg > core >= bg > sidebar.""" """폰트 위계 유지 검증: key_msg > core >= bg > sidebar."""
if not (self.key_msg >= self.core >= self.bg >= self.sidebar): if not (self.key_msg > self.core >= self.bg > self.sidebar):
raise ValueError( raise ValueError(
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) " f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함" f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
+22 -14
View File
@@ -231,7 +231,7 @@ def _group_blocks_by_area(
container_htmls = [] container_htmls = []
assigned_ids = set() assigned_ids = set()
role_order = [r for r, s in container_specs.items() if s.zone == area] role_order = ["배경", "본심"]
for role in role_order: for role in role_order:
spec = container_specs.get(role) spec = container_specs.get(role)
if not spec or spec.zone != area: if not spec or spec.zone != area:
@@ -547,19 +547,27 @@ def render_slide_from_html(
_tokens = _ldt() _tokens = _ldt()
_header_h = _tokens.get("header_height", 66) _header_h = _tokens.get("header_height", 66)
_gap_small = _tokens["spacing_small"] _gap_small = _tokens["spacing_small"]
# Phase X: 동적 grid-template-rows (역할명 무관) # zone 기반으로 body/footer 높이를 동적 탐색 (유형 A: 배경+본심, 유형 B: zone별)
_footer_h = 0 def _find_h(role_name, zone_name=None):
_content_h = 0 """redist → containers 순으로 높이 탐색. role_name 없으면 zone으로 fallback."""
for rn, ci_data in containers.items(): h = redist.get(role_name, 0)
h = int(redist.get(rn, ci_data.get("height_px", 0))) if h:
zone = ci_data.get("zone", "") return int(h)
if zone == "footer": ci = containers.get(role_name, {})
_footer_h = h if ci:
else: return int(ci.get("height_px", 0))
_content_h += h if zone_name:
_content_h += _gap_small * max(0, len([c for c in containers.values() if c.get("zone") != "footer"]) - 1) for _r, _c in containers.items():
if _content_h > 0 and _footer_h > 0: if isinstance(_c, dict) and _c.get("zone") == zone_name:
grid_rows = f"auto {_content_h}px {_footer_h}px" return int(redist.get(_r, _c.get("height_px", 0)))
return 0
_bg_h = _find_h("배경")
_core_h = _find_h("본심")
_footer_h = _find_h("결론", "footer")
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
if _body_row_h > 0 and _footer_h > 0:
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
else: else:
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto") grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
+13 -4
View File
@@ -135,13 +135,16 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
) )
driver = None driver = None
tmp_file = None
try: try:
driver = webdriver.Chrome(options=options) driver = webdriver.Chrome(options=options)
# HTML을 data URI로 로드 # HTML을 임시 파일로 저장 후 file:// URI로 로드 (data URI는 대용량 HTML에서 실패)
import urllib.parse import tempfile
encoded = urllib.parse.quote(html) tmp_file = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8")
driver.get(f"data:text/html;charset=utf-8,{encoded}") tmp_file.write(html)
tmp_file.close()
driver.get(f"file:///{tmp_file.name}")
# 폰트 로딩 대기 (Pretendard CDN) # 폰트 로딩 대기 (Pretendard CDN)
try: try:
@@ -169,6 +172,12 @@ def measure_rendered_heights(html: str) -> dict[str, Any]:
driver.quit() driver.quit()
except Exception: except Exception:
pass pass
if tmp_file:
import os
try:
os.unlink(tmp_file.name)
except Exception:
pass
def format_measurement_for_kei( def format_measurement_for_kei(
+95 -110
View File
@@ -400,6 +400,12 @@ def calculate_container_specs(
# 비중 비율로 높이 할당 # 비중 비율로 높이 할당
ratio = weight / total_weight ratio = weight / total_weight
height_px = max(min_block_h, int(available * ratio)) height_px = max(min_block_h, int(available * ratio))
# footer는 최소 높이 보장 (font_size * line_height + padding)
if zone_name == "footer":
from src.fit_verifier import _load_design_tokens as _ldt_footer
_ft = _ldt_footer()
_footer_min = int(14 * _ft.get("line_height_ko", 1.7) + _ft["spacing_page"])
height_px = max(_footer_min, height_px)
# 블록 내부 제약 계산 — topic당 높이로 판단 # 블록 내부 제약 계산 — topic당 높이로 판단
topic_count = max(1, len(topic_ids)) topic_count = max(1, len(topic_ids))
@@ -434,27 +440,24 @@ def calculate_container_specs(
# ══════════════════════════════════════ # ══════════════════════════════════════
# Phase X: 템플릿 기반 컨테이너 생성 # Phase X-B: 유형 B 컨테이너 생성
# ══════════════════════════════════════ # ══════════════════════════════════════
def build_containers_from_template( def build_containers_type_b(
page_structure: dict[str, Any], page_structure: dict[str, Any],
template: dict[str, Any],
slide_width: int = 1280, slide_width: int = 1280,
slide_height: int = 720, slide_height: int = 720,
image_sizes: list[dict] | None = None,
) -> dict[str, ContainerSpec]: ) -> dict[str, ContainerSpec]:
"""템플릿 + Kei page_structure → 역할별 ContainerSpec. """유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).
템플릿의 rows/zones 구조와 Kei의 weight로 동적 계산. 기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
하드코딩 없음. 모든 크기는 슬라이드 크기 + weight + 템플릿 splits에서 계산. 모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.
Args: Args:
page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.3}, ...} page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
template: LAYOUT_TEMPLATES["B"] 등
slide_width: 슬라이드 너비 slide_width: 슬라이드 너비
slide_height: 슬라이드 높이 slide_height: 슬라이드 높이
image_sizes: 이미지 정보 (비율 계산용)
Returns:
{"핵심목표": ContainerSpec(...), "Process_혁신": ContainerSpec(...), ...}
""" """
from src.fit_verifier import _load_design_tokens from src.fit_verifier import _load_design_tokens
tokens = _load_design_tokens() tokens = _load_design_tokens()
@@ -464,120 +467,102 @@ def build_containers_from_template(
gap_small = tokens["spacing_small"] gap_small = tokens["spacing_small"]
inner_w = slide_width - pad * 2 inner_w = slide_width - pad * 2
# 결론(footer) weight와 나머지 분리 # 역할을 zone별로 분류
footer_role = None top_roles = [] # zone=top
footer_weight = 0 bottom_roles = [] # zone=bottom_left, bottom_right
content_roles = {} # zone → [(role_name, info)] footer_role = None # zone=footer
for role_name, info in page_structure.items(): for role_name, info in page_structure.items():
if not isinstance(info, dict): if not isinstance(info, dict):
continue continue
zone = info.get("zone", "") zone = info.get("zone", "")
if zone == "footer": if zone == "top":
footer_role = role_name top_roles.append((role_name, info))
footer_weight = info.get("weight", 0.1) elif zone in ("bottom_left", "bottom_right"):
else: bottom_roles.append((role_name, info))
if zone not in content_roles: elif zone == "footer":
content_roles[zone] = [] footer_role = (role_name, info)
content_roles[zone].append((role_name, info))
# 전체 가용 높이 (header + footer 제외) # 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
total_content_h = slide_height - pad * 2 - header_h - gap_block # header 아래 ~ 슬라이드 바닥-pad total_available = slide_height - pad * 2 - header_h - gap_block
# footer 높이 # footer 높이: weight 비율 (최소 보장)
footer_h = max(40, int(total_content_h * footer_weight)) if footer_weight > 0 else 53 footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
# 중간 영역 높이 (footer와 gap 제외) footer_h_raw = int(total_available * footer_weight)
middle_h = total_content_h - footer_h - gap_block _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
footer_h = max(_footer_min, footer_h_raw)
# 템플릿 rows에서 각 row의 높이 계산 # 중간 영역: footer + gap 제외
rows = template.get("rows", []) middle_h = total_available - footer_h - gap_block
num_rows = len(rows)
row_gap_total = gap_small * max(0, num_rows - 1)
available_row_h = middle_h - row_gap_total
# 각 row의 높이: row에 속한 역할들의 weight 합 비율로 # 상단/하단 높이: weight 비율로
row_weights = {} top_weight = sum(info.get("weight", 0) for _, info in top_roles)
for row_def in rows: bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
row_name = row_def["name"] total_mid_weight = top_weight + bottom_weight
zones_in_row = row_def["zones"] if total_mid_weight <= 0:
# 이 row에 속한 역할들의 weight 합 total_mid_weight = 1
w_sum = 0
for zone_name in zones_in_row:
for rn, ri in content_roles.get(zone_name, []):
w_sum += ri.get("weight", 0)
row_weights[row_name] = w_sum
total_row_weight = sum(row_weights.values()) top_h = int(middle_h * top_weight / total_mid_weight)
if total_row_weight <= 0: bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
total_row_weight = 1
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
img_ratio = 0
if image_sizes:
for img in image_sizes:
r = img.get("ratio", 0)
if r > 0:
img_ratio = r
break
if img_ratio > 0:
# 이미지 높이 = top_h, 이미지 폭 = top_h * ratio
img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45%
text_w = inner_w - img_w - gap_block
else:
text_w = inner_w
img_w = 0
specs = {} specs = {}
for row_def in rows: # 상단 역할
row_name = row_def["name"] for role_name, info in top_roles:
zones_in_row = row_def["zones"] specs[role_name] = ContainerSpec(
splits = row_def["splits"] role=role_name,
zone="top",
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=top_h,
width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만
max_height_cost=_max_allowed_height_cost(top_h),
block_constraints={
"img_width_px": img_w,
"img_height_px": top_h if img_w > 0 else 0,
"has_image": img_w > 0,
},
)
# row 높이 # 하단 역할: 2분할
row_h = int(available_row_h * row_weights.get(row_name, 1) / total_row_weight) bottom_col_w = (inner_w - gap_block) // 2
for role_name, info in bottom_roles:
specs[role_name] = ContainerSpec(
role=role_name,
zone=info.get("zone", "bottom_left"),
topic_ids=info.get("topic_ids", []),
weight=info.get("weight", 0),
height_px=bottom_h,
width_px=bottom_col_w,
max_height_cost=_max_allowed_height_cost(bottom_h),
block_constraints={},
)
# row 내 각 zone의 폭 # 결론
for i, zone_name in enumerate(zones_in_row):
# sidebar가 여러 row에 걸치는 경우 (F 템플릿) — 첫 번째에서만 생성
if zone_name in specs:
continue
width_pct = splits[i]
# gap 제외 폭 계산
num_cols = len(zones_in_row)
col_gap_total = gap_block * max(0, num_cols - 1)
zone_w = int((inner_w - col_gap_total) * width_pct / 100)
# sidebar가 여러 row에 걸치면 높이 = middle_h 전체
zone_zones = template.get("zones", {})
zone_def = zone_zones.get(zone_name, {})
if "+" in zone_def.get("row", ""):
zone_h = middle_h # top+bottom 관통
else:
zone_h = row_h
# 이 zone에 배정된 역할들
roles_here = content_roles.get(zone_name, [])
if not roles_here:
continue
for rn, ri in roles_here:
topic_ids = ri.get("topic_ids", [])
weight = ri.get("weight", 0)
topic_count = max(1, len(topic_ids))
per_topic_px = zone_h // topic_count
font_size, padding_px, line_h = _determine_typography(per_topic_px)
max_cost = _max_allowed_height_cost(per_topic_px)
constraints = _calculate_block_constraints(
zone_h, zone_w, topic_count, font_size, line_h, padding_px
)
constraints["font_size_px"] = font_size
constraints["padding_px"] = padding_px
constraints["line_height"] = line_h
specs[rn] = ContainerSpec(
role=rn,
zone=zone_name,
topic_ids=topic_ids,
weight=weight,
height_px=zone_h,
width_px=zone_w,
max_height_cost=max_cost,
block_constraints=constraints,
)
# footer
if footer_role: if footer_role:
footer_info = page_structure[footer_role] rn, info = footer_role
specs[footer_role] = ContainerSpec( specs[rn] = ContainerSpec(
role=footer_role, role=rn,
zone="footer", zone="footer",
topic_ids=footer_info.get("topic_ids", []), topic_ids=info.get("topic_ids", []),
weight=footer_weight, weight=info.get("weight", 0),
height_px=footer_h, height_px=footer_h,
width_px=inner_w, width_px=inner_w,
max_height_cost="low", max_height_cost="low",
@@ -585,7 +570,7 @@ def build_containers_from_template(
) )
logger.info( logger.info(
f"[X-3] 템플릿 컨테이너: " f"[X-B-3] 유형 B 컨테이너: "
+ ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items()) + ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items())
) )
return specs return specs
+36 -100
View File
@@ -29,15 +29,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Phase X: 동적 색상 팔레트 (역할 수에 관계없이 순환) COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
_COLOR_PALETTE = ["#dc2626", "#2563eb", "#16a34a", "#7c3aed", "#d97706", "#0891b2", "#be185d", "#4f46e5"] FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
# 하위호환: 기존 코드가 COLORS를 참조할 경우 fallback
COLORS = {}
def _get_color(role: str, all_roles: list[str]) -> str:
idx = all_roles.index(role) % len(_COLOR_PALETTE) if role in all_roles else 0
return _COLOR_PALETTE[idx]
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None: def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
@@ -82,93 +75,36 @@ def _tokens():
def _calc_coords(containers: dict, ratio: tuple) -> dict: def _calc_coords(containers: dict, ratio: tuple) -> dict:
"""Phase X: 동적 역할 좌표 계산. containers의 모든 역할을 zone 기반으로 배치."""
t = _tokens() t = _tokens()
pad = t.get("spacing_page", 40) pad = t.get("spacing_page", 40)
gap = t.get("spacing_block", 20) gap = t.get("spacing_block", 20)
small = t.get("spacing_small", 8) small = t.get("spacing_small", 8)
header_h = t.get("header_height", 66) header_h = 66
slide_w = t.get("slide_width", 1280)
slide_h = t.get("slide_height", 720) inner_w = 1280 - pad * 2
inner_w = slide_w - pad * 2 body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
def gh(c): def gh(c):
if hasattr(c, "height_px"): return c.height_px if hasattr(c, "height_px"): return c.height_px
return c.get("height_px", 0) if isinstance(c, dict) else 0 return c.get("height_px", 0) if isinstance(c, dict) else 0
def gz(c):
if hasattr(c, "zone"): return c.zone
return c.get("zone", "main") if isinstance(c, dict) else "main"
def gw(c):
if hasattr(c, "width_px"): return c.width_px
return c.get("width_px", 0) if isinstance(c, dict) else 0
result = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}} bg_h = gh(containers.get("배경", {}))
core_h = gh(containers.get("본심", {}))
sb_h = gh(containers.get("첨부", {}))
ft_h = gh(containers.get("결론", {}))
# zone별 그룹핑 bg_top = pad + header_h + gap
zone_roles = {} core_top = bg_top + bg_h + small
footer_role = None ft_top = max(core_top + core_h, bg_top + sb_h) + gap
for role_name, ci in containers.items():
zone = gz(ci)
if zone == "footer":
footer_role = role_name
else:
if zone not in zone_roles:
zone_roles[zone] = []
zone_roles[zone].append(role_name)
# footer return {
footer_h = gh(containers[footer_role]) if footer_role else 0 "header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
ft_top = slide_h - pad - footer_h "배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
content_top = pad + header_h + gap "첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
content_bottom = ft_top - gap "결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
}
# row 그룹핑
row_map = {}
for zone_name in zone_roles:
if zone_name.startswith("top"):
row = "top"
elif zone_name.startswith("bottom"):
row = "bottom"
else:
row = "main"
if row not in row_map:
row_map[row] = []
if zone_name not in row_map[row]:
row_map[row].append(zone_name)
# row 높이
row_weights = {}
for row_name, zone_list in row_map.items():
w = sum(gh(containers.get(rn, {})) for zn in zone_list for rn in zone_roles.get(zn, []))
row_weights[row_name] = max(1, w)
total_rw = sum(row_weights.values()) or 1
num_rows = len(row_map)
available_h = content_bottom - content_top - small * max(0, num_rows - 1)
current_top = content_top
for row_name in sorted(row_map.keys()):
rh = int(available_h * row_weights[row_name] / total_rw)
zone_list = row_map[row_name]
num_cols = len(zone_list)
col_gap_total = gap * max(0, num_cols - 1)
col_available = inner_w - col_gap_total
# zone 폭
zone_widths = [gw(containers.get(zone_roles.get(zn, [""])[0], {})) or (col_available // num_cols) for zn in zone_list]
total_zw = sum(zone_widths) or 1
zone_widths = [int(col_available * w / total_zw) for w in zone_widths]
current_left = pad
for i, zn in enumerate(zone_list):
for rn in zone_roles.get(zn, []):
result[rn] = {"l": current_left, "t": current_top, "w": zone_widths[i], "h": rh}
current_left += zone_widths[i] + gap
current_top += rh + small
if footer_role:
result[footer_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": footer_h}
return result
def _wrap(title, subtitle, slide_body): def _wrap(title, subtitle, slide_body):
@@ -332,9 +268,9 @@ def _gen_stage_1_5a(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드" title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title) body = _hdr(coords["header"], title)
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
fk = FONT_MAP[role] fk = FONT_MAP[role]
font = getattr(fh, fk, "?") font = getattr(fh, fk, "?")
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">' inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
@@ -358,9 +294,9 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
ps = ctx.page_structure.roles ps = ctx.page_structure.roles
topic_map = {t.id: t for t in ctx.topics} topic_map = {t.id: t for t in ctx.topics}
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
info = ps.get(role, {}) info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else [] tids = info.get("topic_ids", []) if isinstance(info, dict) else []
@@ -395,9 +331,9 @@ def _gen_stage_1_5b(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드" title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title) body = _hdr(coords["header"], title)
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
ci = ctx.containers.get(role) ci = ctx.containers.get(role)
if not ci: if not ci:
continue continue
@@ -432,9 +368,9 @@ def _gen_stage_1_7(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드" title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title) body = _hdr(coords["header"], title)
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
ref_list = ctx.references.get(role, []) ref_list = ctx.references.get(role, [])
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>'] lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
@@ -480,9 +416,9 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
title = ctx.analysis.title or "슬라이드" title = ctx.analysis.title or "슬라이드"
body = _hdr(coords["header"], title) body = _hdr(coords["header"], title)
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
ref_list = ctx.references.get(role, []) ref_list = ctx.references.get(role, [])
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택" blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
@@ -526,9 +462,9 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
bolds = enh.get("bold_keywords", {}) bolds = enh.get("bold_keywords", {})
sups = enh.get("supplement_blocks", []) sups = enh.get("supplement_blocks", [])
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
rf = roles_fit.get(role, {}) rf = roles_fit.get(role, {})
status = rf.get("fit_status", "?") status = rf.get("fit_status", "?")
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?") icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
@@ -595,9 +531,9 @@ def _gen_stage_1_8_blocks(ctx, steps_dir):
slide_body = _hdr(coords["header"], title) slide_body = _hdr(coords["header"], title)
legend_lines = [] legend_lines = []
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
c = coords[role] c = coords[role]
cl = _get_color(role, list(ctx.containers.keys())) cl = COLORS[role]
ref_list = ctx.references.get(role, []) ref_list = ctx.references.get(role, [])
info = ps.get(role, {}) info = ps.get(role, {})
tids = info.get("topic_ids", []) if isinstance(info, dict) else [] tids = info.get("topic_ids", []) if isinstance(info, dict) else []
@@ -744,7 +680,7 @@ def _gen_stage_2(ctx, steps_dir):
redist = fit.get("redistribution", {}) redist = fit.get("redistribution", {})
sections = [] sections = []
for role in ctx.containers.keys(): for role in ["배경", "본심", "첨부", "결론"]:
rhtml = role_htmls.get(role, "") rhtml = role_htmls.get(role, "")
if not rhtml: if not rhtml:
continue continue
+45 -13
View File
@@ -171,19 +171,37 @@ def validate_stage_1a(
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}", "instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
}) })
# Phase X: 결론(footer) 역할 존재 검증 (역할명은 자유, zone="footer"가 1개 있어야 함) # 유형에 따른 구조 검증
has_footer = any( layout_template = analysis.get("layout_template", "A")
isinstance(info, dict) and info.get("zone") == "footer" if layout_template == "A":
for info in page_struct.values() # 유형 A: 본심 필수
) core_info = page_struct.get("본심", {})
if not has_footer: if not core_info or not isinstance(core_info, dict):
# 하위호환: "결론" 키가 있으면 OK errors.append({
if "결론" not in page_struct: "severity": "RETRYABLE",
"field": "page_structure.본심",
"localization": "본심 역할이 page_structure에 없음",
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
})
elif core_info.get("weight", 0) < 0.3:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.본심.weight",
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
})
elif layout_template == "B":
# 유형 B: 결론(footer) 필수, 나머지 자유
has_footer = any(
isinstance(info, dict) and info.get("zone") == "footer"
for info in page_struct.values()
)
if not has_footer and "결론" not in page_struct:
errors.append({ errors.append({
"severity": "RETRYABLE", "severity": "RETRYABLE",
"field": "page_structure.footer", "field": "page_structure.footer",
"localization": "결론(footer) 역할이 page_structure에 없음", "localization": "결론(footer) 역할이 없음",
"instruction": "page_structure에 결론 역할(zone: footer)을 추가하라.", "instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
}) })
# 필수 필드 검증 # 필수 필드 검증
@@ -224,7 +242,9 @@ def validate_stage_1a(
if clean_text: if clean_text:
# 원본 ## 섹션 수 vs topic 수 비교 # 원본 ## 섹션 수 vs topic 수 비교
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE) original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 4: # 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
max_diff = 4 if layout_template == "B" else 2
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
errors.append({ errors.append({
"severity": "RETRYABLE", "severity": "RETRYABLE",
"field": "topics", "field": "topics",
@@ -271,6 +291,7 @@ def validate_stage_1b(
topics: list[dict[str, Any]], topics: list[dict[str, Any]],
clean_text: str, clean_text: str,
raw_content: str = "", raw_content: str = "",
layout_template: str = "A",
) -> list[dict]: ) -> list[dict]:
"""Stage 1B(컨셉 구체화) 결과 검증. """Stage 1B(컨셉 구체화) 결과 검증.
@@ -364,7 +385,18 @@ def validate_stage_1b(
claimed_count = evidence.get(relation_type, 0) claimed_count = evidence.get(relation_type, 0)
if claimed_count == 0: if claimed_count == 0:
# Phase X: relation_type 증거 0개는 warning으로 처리 (역할 구조가 자유이므로) if layout_template == "B":
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — warning으로 처리") # 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유)
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning")
else:
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
})
return errors return errors