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