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