Add Type B slide pipeline and recipe rendering updates
This commit is contained in:
61
PIPELINE.md
61
PIPELINE.md
@@ -241,28 +241,49 @@ PipelineContext:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 현재 구현 상태
|
## 7. 현재 구현 상태 (Phase Y-11~13, 2026-04-15)
|
||||||
|
|
||||||
| 항목 | 상태 | 비고 |
|
> Phase Y: slide-base 기반 파이프라인 재설계. 상세: `docs/history/PHASE-Y-PLAN.md`
|
||||||
|------|------|------|
|
|
||||||
| Stage 0 (MDX 정규화) | ✅ | |
|
|
||||||
| Stage 1A (꼭지 추출) | ✅ | A/B 선택 포함 |
|
|
||||||
| Stage 1B (컨셉 구체화) | ✅ | |
|
|
||||||
| Stage 1B-ST (구조화 텍스트) | ✅ | |
|
|
||||||
| Stage 1.5a (컨테이너 계산) | ✅ | Type A/B 분기 |
|
|
||||||
| Stage 1.7 (블록 선택) | ✅ | catalog.yaml 기반 |
|
|
||||||
| Stage 1.8 (적합성 검증) | ✅ | Selenium 3회 루프 |
|
|
||||||
| **Stage 2 — Type A** | ⚠ 미완성 | Sonnet 의존, 검증 불완전 |
|
|
||||||
| **Stage 2 — Type B** | ✅ | block_assembler 코드 조립 |
|
|
||||||
| **Stage 2 — Type B'** | ✅ | 03번 전용 하드코딩 |
|
|
||||||
| **Stage 2 — Type B''** | ✅ | B' 스타일 변형 |
|
|
||||||
| Stage 3 (렌더링) | ⚠ | Type A만 사용 |
|
|
||||||
| Stage 4 (검증) | ✅ | Selenium + Opus Vision |
|
|
||||||
|
|
||||||
### 미해결 과제
|
### 파이프라인 흐름 (현재)
|
||||||
1. **Type A HTML 생성** — Sonnet 의존 구조가 불안정. block_assembler처럼 코드 기반으로 전환 필요?
|
|
||||||
2. **B'/B'' 범용화** — 03번 전용 → 다양한 콘텐츠 구조 커버
|
```
|
||||||
3. **블록 템플릿 업데이트 반영** — 최근 추가/수정된 블록이 파이프라인에 제대로 연결되는지 검증 필요
|
[Stage 0] MDX → normalized.sections (source of truth)
|
||||||
|
[Stage 1A] Kei 꼭지 추출 (영역/zone 판단 안 함)
|
||||||
|
[Phase Y] 코드: normalized → 대목차 추출 → group schema 분류 → 블록 매칭 → 영역 확정
|
||||||
|
[Stage 1.5a] space_allocator: weight → zone px (% 기반)
|
||||||
|
[Stage 1.7] block_reference: tag_match → schema_match → fallback 순서
|
||||||
|
[Stage 1.8] assembler(measure_mode) → Selenium 측정 → fit 루프
|
||||||
|
[Stage 2] assembler(slide-base + 블록) → final HTML
|
||||||
|
[Stage 4] Selenium overflow + 비전 (-1 미평가)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MDX별 상태
|
||||||
|
|
||||||
|
| MDX | 상태 | 비고 |
|
||||||
|
|-----|------|------|
|
||||||
|
| **03** | ✅ 동작 | prerequisites-3col + pp2. 텍스트 누락 없음. 회귀 기준. |
|
||||||
|
| **02** | ⚠ schema 1차 | top: parallel_3_with_image. bottom: 분류 정교화 필요. |
|
||||||
|
| **01** | ⬜ 미착수 | Type A. 별도 작업. |
|
||||||
|
|
||||||
|
### 핵심 원칙 (확립됨)
|
||||||
|
|
||||||
|
- source of truth = normalized.sections (Stage 0)
|
||||||
|
- 영역 = 코드가 결정 (Kei 아님). sub_titles 기반 + group schema.
|
||||||
|
- 블록 CSS에 최종 고정값. slide_font_css는 공통 레이아웃 계약만.
|
||||||
|
- zone = % 기반, block = height:100%.
|
||||||
|
- 글씨 크기 고정. fit은 padding → 내용량 → font 1단계(responsive tier).
|
||||||
|
- 기존 경로 삭제 금지. 새 schema 점진적 추가. MDX 03 회귀 기준.
|
||||||
|
- 하드코딩 금지. 프로세스가 결과를 만드는 구조.
|
||||||
|
|
||||||
|
3. **블록 글씨 크기 하드코딩 (px 고정)**
|
||||||
|
- 블록 CSS에 font-size가 Figma 원본 px로 고정
|
||||||
|
- 컨테이너 크기에 따라 조정 불가 → overflow 원인
|
||||||
|
- CSS 변수(`var(--block-font-heading)`)로 전환 → assembler가 zone 크기에 따라 계산
|
||||||
|
|
||||||
|
### 미해결 프로세스
|
||||||
|
1. **overflow 시 font 조정 루프** — 재배분만으로 부족할 때 font/padding 줄이기 (Y-5)
|
||||||
|
2. **Sonnet redesign 경로** — tag 매칭 실패 시 블록 단위 redesign → 저장 (Y-6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
431
README.md
431
README.md
@@ -1,303 +1,248 @@
|
|||||||
# Kei Design Agent
|
# Kei Design Agent
|
||||||
|
|
||||||
콘텐츠를 시각적으로 구조화된 슬라이드 HTML(1280×720px, 16:9)로 변환하는 AI 파이프라인.
|
MDX 콘텐츠를 분석해 1280x720 슬라이드 HTML로 변환하는 파이프라인입니다.
|
||||||
|
|
||||||
## 개요
|
이 문서는 "지금 실제 코드 기준으로 파이프라인이 어떻게 동작하는지"를 빠르게 파악하기 위한 개요 문서입니다. 과거 Phase 문서와 일부 legacy 경로는 남아 있지만, 아래 설명은 현재 메인 경로를 기준으로 정리했습니다.
|
||||||
|
|
||||||
텍스트/MDX 콘텐츠를 입력하면:
|
## 한눈에 보기
|
||||||
1. Kei 실장(Opus)이 정보 구조와 비중을 판단하고
|
|
||||||
2. 코드가 컨테이너 크기를 계산하고
|
|
||||||
3. 블록을 선택하고
|
|
||||||
4. 콘텐츠-컨테이너 적합성을 검증하고
|
|
||||||
5. AI(Sonnet)가 블록 디자인을 참고하여 HTML을 생성하고
|
|
||||||
6. 코드가 슬라이드 프레임에 조립하고
|
|
||||||
7. 측정+비전 모델로 검증합니다
|
|
||||||
|
|
||||||
---
|
파이프라인의 큰 흐름은 아래와 같습니다.
|
||||||
|
|
||||||
## 파이프라인 (10단계)
|
1. MDX를 코드가 정규화한다.
|
||||||
|
2. Kei가 문서의 의미와 topic을 읽는다.
|
||||||
|
3. 코드는 `normalized.sections`를 기준으로 실제 슬라이드 구조를 다시 만든다.
|
||||||
|
4. 코드는 schema, recipe, tag를 바탕으로 블록을 고른다.
|
||||||
|
5. Type B는 코드가 템플릿을 조립해 `final.html`을 만든다.
|
||||||
|
6. Type A는 아직 AI/renderer 비중이 더 크다.
|
||||||
|
7. Selenium과 vision gate로 측정/검증한 뒤 run 산출물을 저장한다.
|
||||||
|
|
||||||
```
|
핵심 원칙은 다음과 같습니다.
|
||||||
MDX 원본
|
|
||||||
↓
|
|
||||||
[Stage 0] MDX 정규화 (코드)
|
|
||||||
↓
|
|
||||||
[Stage 1A] 꼭지 추출 + 영역 배정 (Kei API / Opus)
|
|
||||||
↓
|
|
||||||
[Stage 1B] 컨셉 구체화 (Kei API / Opus)
|
|
||||||
↓
|
|
||||||
[Stage 1.5a] 컨테이너 초기 계산 (코드)
|
|
||||||
↓
|
|
||||||
[Stage 1.7] 블록 선택 (코드)
|
|
||||||
↓
|
|
||||||
[Stage 1.8] 적합성 검증 + 재배분 + 보강 (코드 + Kei 에스컬레이션)
|
|
||||||
↓
|
|
||||||
[Stage 1.5b] 디자인 예산 재계산 (코드)
|
|
||||||
↓
|
|
||||||
[Stage 2] HTML 생성 (영역별 개별 호출) (Claude Sonnet)
|
|
||||||
↓
|
|
||||||
[Stage 3] 렌더링 조립 + 후처리 (코드)
|
|
||||||
↓
|
|
||||||
[Stage 4] 측정 + 품질 검증 (Selenium + Opus Vision)
|
|
||||||
↓
|
|
||||||
검증 통과 시 → final.html 저장 + 팝업 분리 (파일 출력)
|
|
||||||
```
|
|
||||||
|
|
||||||
※ Stage 4 이후의 파일 저장은 별도 Stage가 아닌 후처리입니다.
|
- source of truth는 `normalized.sections`
|
||||||
|
- block 선택은 문서명 하드코딩이 아니라 shape, schema, tag 기반
|
||||||
|
- 흐름의 우선순위는 `구조 -> payload -> layout -> fit`
|
||||||
|
- popup/detail은 overflow를 덮는 임시 장치가 아니라 `메인 요약 + 상세 보기`의 2단 표현 계약
|
||||||
|
|
||||||
---
|
## 타입 구조
|
||||||
|
|
||||||
## 단계별 상세
|
현재 메인 타입 선택은 사실상 `A`와 `B`입니다.
|
||||||
|
|
||||||
### Stage 0: MDX 정규화
|
### Type A
|
||||||
|
|
||||||
| 항목 | 내용 |
|
- 본문 외에 sidebar, reference, 부록성 영역이 함께 필요한 슬라이드
|
||||||
|------|------|
|
- 현재는 Type B보다 덜 닫혀 있고, AI 생성 + renderer 경로 비중이 큽니다
|
||||||
| **목적** | 원본 MDX에서 JSX/frontmatter를 제거하고, 섹션/팝업/이미지/테이블로 분리 |
|
|
||||||
| **적용기술** | 코드 (`normalize_mdx_content()`) |
|
|
||||||
| **인풋** | 원본 MDX 문자열 |
|
|
||||||
| **아웃풋** | `normalized` — clean_text, title, sections[], popups[], images[], tables[] |
|
|
||||||
| **연계** | → Stage 1A가 clean_text를 Kei에게 전달 |
|
|
||||||
|
|
||||||
### Stage 1A: 꼭지 추출 + 영역 배정
|
### Type B
|
||||||
|
|
||||||
| 항목 | 내용 |
|
- top, bottom 같은 본문 zone 조합으로 해결되는 슬라이드
|
||||||
|------|------|
|
- 현재 가장 안정적인 메인 경로입니다
|
||||||
| **목적** | 콘텐츠에서 핵심 파트(꼭지)를 식별하고, 슬라이드의 어떤 영역(배경/본심/첨부/결론)에 배치할지 결정 |
|
- 최근 구조화 작업은 대부분 이 Type B 경로를 중심으로 진행되었습니다
|
||||||
| **적용기술** | Kei API (`classify_content()`) |
|
|
||||||
| **인풋** | normalized.clean_text |
|
|
||||||
| **아웃풋** | `topics[]` (id, title, purpose, layer, relation_type, expression_hint), `page_structure` (role별 topic_ids, weight) |
|
|
||||||
| **연계** | → Stage 1B가 각 꼭지를 구체화 |
|
|
||||||
|
|
||||||
### Stage 1B: 컨셉 구체화
|
### Type B' / B''
|
||||||
|
|
||||||
| 항목 | 내용 |
|
- 역사적으로 실험/호환 경로에서 나온 변형입니다
|
||||||
|------|------|
|
- 문서와 일부 legacy 코드에 흔적이 남아 있습니다
|
||||||
| **목적** | 각 꼭지에 실제 원본 텍스트(source_data)와 요약(summary)을 매핑 |
|
- 현재 메인 개념의 1급 타입으로 보기보다, 과거 흐름과 호환 레이어로 이해하는 편이 맞습니다
|
||||||
| **적용기술** | Kei API (`refine_concepts()`) |
|
|
||||||
| **인풋** | topics + clean_text |
|
|
||||||
| **아웃풋** | `topics` 업데이트 — source_data, summary 추가 |
|
|
||||||
| **연계** | → Stage 1.5a가 텍스트 양을 기반으로 컨테이너 비율 계산 |
|
|
||||||
|
|
||||||
### Stage 1.5a: 컨테이너 초기 계산
|
## 단계별 파이프라인
|
||||||
|
|
||||||
| 항목 | 내용 |
|
아래는 현재 기준의 실질적인 단계입니다.
|
||||||
|------|------|
|
|
||||||
| **목적** | 폰트 위계 확정 + 슬라이드 내 영역별 컨테이너 크기(px) 계산 + 프리셋 선택 |
|
|
||||||
| **적용기술** | 코드 (`calculate_font_hierarchy()`, `calculate_dynamic_ratio()`, `calculate_container_specs()`) |
|
|
||||||
| **인풋** | topics, page_structure (weight), preset |
|
|
||||||
| **아웃풋** | `font_hierarchy` (key_msg/core/bg/sidebar px), `container_ratio` (71:29 등), `containers` (role별 width_px, height_px), `preset` |
|
|
||||||
| **연계** | → Stage 1.7이 컨테이너 크기를 보고 블록 선택 |
|
|
||||||
|
|
||||||
### Stage 1.7: 블록 선택
|
| 단계 | 담당 | 주요 파일 | 하는 일 | 주요 산출물 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Stage 0 | 코드 | `src/mdx_normalizer.py` | MDX를 정규화하고 sections, tables, images, popups로 분리 | `NormalizedContent` |
|
||||||
|
| Stage 1A | AI (Kei/Opus) | `src/kei_client.py` | title, core message, topic, 초기 layout 힌트 추출 | `Analysis`, `Topic[]` |
|
||||||
|
| Phase Y | 코드 | `src/pipeline.py`, `src/section_parser.py` | `normalized.sections` 기반으로 group schema, recipe, zone/page_structure 결정 | `PageStructure`, `mdx_sections` |
|
||||||
|
| Stage 1B | AI (Kei/Opus) | `src/kei_client.py` | topic별 relation, source_data, 표현 힌트 보강 | 보강된 `Topic[]` |
|
||||||
|
| Stage 1B-ST | AI (Kei/Opus) | `src/kei_client.py` | structured_text 생성 | `Topic.structured_text` |
|
||||||
|
| Stage 1.5a | 코드 | `src/space_allocator.py` | zone/container 크기, preset, font hierarchy 계산 | `containers`, `font_hierarchy` |
|
||||||
|
| Stage 1.7 | 코드 | `src/block_reference.py`, `templates/catalog.yaml` | tag_match, schema_match, fallback 기준으로 block 선택 | `references` |
|
||||||
|
| Stage 1.8 | 코드 + Selenium + 일부 AI | `src/pipeline.py`, `src/slide_measurer.py` | fit 측정, overflow 확인, 재배분, 보정 | `fit_result`, `measurement` |
|
||||||
|
| Stage 2 | 코드 중심 | `src/block_assembler.py` | Type B 기준 slide-base + block template + payload 조립 | `generated_html` |
|
||||||
|
| Stage 3 | 코드 | `src/renderer.py` | Type A 쪽 Jinja/renderer 조립, Type B는 대체로 생략 | `rendered_html` |
|
||||||
|
| Stage 4 | 코드 + Vision AI | `src/slide_measurer.py`, `src/kei_client.py` | Selenium overflow 측정, screenshot, vision quality 평가 | `measurement`, `quality_score` |
|
||||||
|
| Stage 5 | 코드 | `src/pipeline.py` | `final.html`, `final_context.json`, popup/detail html 저장 | run 산출물 |
|
||||||
|
|
||||||
| 항목 | 내용 |
|
## 현재 메인 실행 경로
|
||||||
|------|------|
|
|
||||||
| **목적** | 각 꼭지의 relation_type + expression_hint + 컨테이너 크기로 적합한 블록 결정. 같은 영역 꼭지들의 layer가 다르면 주종관계 판단 (블록 1개로 합침) |
|
|
||||||
| **적용기술** | 코드 (`select_and_generate_references()`) — catalog.yaml 기반 결정론적 매칭 |
|
|
||||||
| **인풋** | topics, containers, page_structure |
|
|
||||||
| **아웃풋** | `references` — role별 block_id, variant, design_reference_html, topic_id, is_hierarchical, supporting_topic_ids |
|
|
||||||
| **연계** | → Stage 1.8이 선택된 블록+콘텐츠가 컨테이너에 맞는지 검증 |
|
|
||||||
|
|
||||||
### Stage 1.8: 적합성 검증 + 재배분 + 보강 + 서브 컨테이너
|
### Type B 메인 경로
|
||||||
|
|
||||||
| 항목 | 내용 |
|
지금 실전에서 가장 중요한 경로는 아래입니다.
|
||||||
|------|------|
|
|
||||||
| **목적** | 콘텐츠가 컨테이너에 들어가는지 검증 → 안 맞으면 재배분 → 여전히 안 되면 Kei 에스컬레이션 → 여유 공간에 보충 콘텐츠 → 서브 컨테이너 배치 계산 |
|
|
||||||
| **적용기술** | 코드 (`calculate_fit()`, `redistribute()`, `analyze_enhancements()`, `apply_enhancements()`, `calculate_sub_layout()`) + Kei API (에스컬레이션 시 `call_kei_fit_escalation()`) |
|
|
||||||
| **인풋** | topics, containers, references, font_hierarchy, normalized, core_message |
|
|
||||||
| **아웃풋** | `containers` (재배분된 height_px), `fit_result` (role별 fit_status, redistribution), `enhancement_result` (V-7 subordinate_treatments, V-8 supplement_blocks, V-9 emphasis_blocks, V-10 bold_keywords, V-4 kei_decisions), `sub_layouts` (role별 서브 컨테이너 name/width/height, table_rows) |
|
|
||||||
| **내부 흐름** | Step 1: 필요 높이 계산 → Step 2: 재배분 → Step 3: Kei 에스컬레이션 → Step 4-5: 보강 분석+적용 → Step 6: fit 재검증 → Step 7: 서브 컨테이너 배치 → Step 8: 확정 |
|
|
||||||
| **연계** | → Stage 1.5b가 재배분된 크기로 디자인 예산 재계산, → Stage 2가 sub_layouts + enhancements를 프롬프트에 반영 |
|
|
||||||
|
|
||||||
### Stage 1.5b: 디자인 예산 재계산
|
1. Stage 0에서 MDX를 정규화
|
||||||
|
2. Stage 1A/1B에서 Kei가 의미와 topic 추출
|
||||||
|
3. Phase Y에서 코드가 `normalized.sections`를 읽고 page_structure를 다시 생성
|
||||||
|
4. Stage 1.7에서 block 선택
|
||||||
|
5. Stage 1.8에서 fit/overflow 검증
|
||||||
|
6. Stage 2에서 `assemble_slide_html_final()`로 최종 HTML 조립
|
||||||
|
7. Stage 4/5에서 측정과 산출물 저장
|
||||||
|
|
||||||
| 항목 | 내용 |
|
Type B의 핵심 파일은 아래입니다.
|
||||||
|------|------|
|
|
||||||
| **목적** | 재배분된 컨테이너 크기 + 선택된 블록 schema 기준으로 영역별 가용 공간 계산 |
|
|
||||||
| **적용기술** | 코드 (`calculate_design_budget()`) |
|
|
||||||
| **인풋** | containers (재배분 후), references (블록 schema) |
|
|
||||||
| **아웃풋** | `containers` 업데이트 — design_budget (available_height_px, available_width_px, fits) |
|
|
||||||
| **연계** | → Stage 2가 design_budgets를 프롬프트에 포함 |
|
|
||||||
|
|
||||||
### Stage 2: HTML 생성 (영역별 개별 호출)
|
- `src/pipeline.py`
|
||||||
|
- `src/section_parser.py`
|
||||||
|
- `src/block_reference.py`
|
||||||
|
- `src/block_assembler.py`
|
||||||
|
- `src/space_allocator.py`
|
||||||
|
- `templates/catalog.yaml`
|
||||||
|
|
||||||
| 항목 | 내용 |
|
### Type A 경로
|
||||||
|------|------|
|
|
||||||
| **목적** | page_structure에 존재하는 각 역할(배경/본심/첨부/결론)의 HTML을 **영역별 개별 Sonnet 호출**로 생성. 블록 디자인을 참고하되 콘텐츠가 구조를 결정 (Phase R' 방식) |
|
|
||||||
| **적용기술** | Claude Sonnet API — 영역당 1회 호출 (`build_area_prompt()` → `_call_claude()`) |
|
|
||||||
| **인풋** | raw_content, topics, containers, font_hierarchy, references (design_reference_html), sub_layouts (서브 컨테이너 치수), enhancements (V-4~V-10 지시), design_budgets |
|
|
||||||
| **호출 흐름** | Sonnet(배경) → bg_html, Sonnet(본심) → core_html, Sonnet(첨부) → sidebar_html, Sonnet(결론) → footer_html. 해당 역할에 꼭지가 없으면 스킵. body_html = bg_html + spacer + core_html |
|
|
||||||
| **아웃풋** | `generated_html` — body_html, sidebar_html, footer_html |
|
|
||||||
| **프롬프트에 포함되는 것** | 서브 컨테이너 레이아웃 제약, 디자인 레퍼런스 HTML (블록 CSS 참고), Kei 에스컬레이션 결정, 종속 꼭지 처리 지시, 보충 블록 지시, 강조 문장, bold 키워드, 폰트/컨테이너 크기 제약 |
|
|
||||||
| **연계** | → Stage 3이 영역별 HTML을 슬라이드 프레임에 배치 |
|
|
||||||
|
|
||||||
### Stage 3: 렌더링 조립 + 후처리
|
Type A는 현재도 살아 있지만, Type B만큼 단단하게 닫힌 상태는 아닙니다.
|
||||||
|
|
||||||
| 항목 | 내용 |
|
- AI 생성 비중이 더 큼
|
||||||
|------|------|
|
- `src/renderer.py` 의존도가 더 큼
|
||||||
| **목적** | 생성된 HTML 조각을 CSS Grid 슬라이드 프레임에 삽입 + 후처리 (폰트 캡핑, overflow 제거, sidebar width 조정, bold 변환) |
|
- sidebar/reference 구조를 포함하는 쪽에서 의미가 큼
|
||||||
| **적용기술** | 코드 (`render_slide_from_html()`) |
|
|
||||||
| **인풋** | generated_html, preset (grid_areas, grid_columns), font_hierarchy, container_ratio |
|
|
||||||
| **아웃풋** | `rendered_html` → `final.html` 파일 저장 |
|
|
||||||
| **연계** | → Stage 4가 렌더링 결과를 측정+검증 |
|
|
||||||
|
|
||||||
### Stage 4: 품질 검증
|
## schema -> recipe -> block
|
||||||
|
|
||||||
| 항목 | 내용 |
|
최근 구조화에서 가장 중요한 변화 중 하나는 `schema -> recipe -> block` 레이어입니다.
|
||||||
|------|------|
|
|
||||||
| **목적** | Selenium으로 실제 브라우저 렌더링 후 overflow 측정 + Opus Vision으로 시각적 품질 평가 |
|
|
||||||
| **적용기술** | Selenium (`measure_rendered_heights()`) + Claude Opus Vision (`vision_quality_gate()`) |
|
|
||||||
| **인풋** | rendered_html |
|
|
||||||
| **아웃풋** | `measurement` (zone별 clientHeight, scrollHeight, overflow, excess_px), `quality_score` |
|
|
||||||
| **연계** | 파이프라인 완료. overflow 시 경고 포함하여 진행 |
|
|
||||||
|
|
||||||
---
|
### schema
|
||||||
|
|
||||||
## 중간 산출물
|
콘텐츠의 의미 구조입니다.
|
||||||
|
|
||||||
파이프라인 실행마다 `data/runs/{timestamp}/`에 단계별 결과가 저장된다.
|
예:
|
||||||
|
|
||||||
### JSON Context (Stage별 누적 상태)
|
- `parallel_cluster`
|
||||||
| 파일 | Stage | 내용 |
|
- `parallel_cluster_plus_visual`
|
||||||
|------|-------|------|
|
- `compare_asymmetric_paired`
|
||||||
| `stage_0_context.json` | 0 | normalized (섹션, 팝업, 이미지) |
|
- `sequence_plus_visual`
|
||||||
| `stage_1a_context.json` | 1A | topics, page_structure |
|
- `single_block`
|
||||||
| `stage_1b_context.json` | 1B | topics (source_data 추가) |
|
|
||||||
| `stage_1_5a_context.json` | 1.5a | font_hierarchy, containers, ratio |
|
|
||||||
| `stage_1_7_context.json` | 1.7 | references (블록 선택 결과) |
|
|
||||||
| `stage_1_8_context.json` | 1.8 | fit_result, enhancements, sub_layouts |
|
|
||||||
| `stage_1_5b_context.json` | 1.5b | containers (design_budget 추가) |
|
|
||||||
| `stage_2_context.json` | 2 | generated_html |
|
|
||||||
| `stage_3_context.json` | 3 | (rendered_html은 final.html로 별도 저장) |
|
|
||||||
| `stage_4_context.json` | 4 | measurement, quality_score |
|
|
||||||
| `final_context.json` | 최종 | 전체 context |
|
|
||||||
|
|
||||||
### HTML 시각화 (`steps/` 폴더)
|
### recipe
|
||||||
| 파일 | Stage | 내용 |
|
|
||||||
|------|-------|------|
|
|
||||||
| `stage_0.html` | 0 | 섹션/팝업/이미지 목록 |
|
|
||||||
| `stage_1a.html` | 1A | 꼭지 테이블 (purpose, layer, 영역) |
|
|
||||||
| `stage_1b.html` | 1B | 꼭지 + source_data + summary |
|
|
||||||
| `stage_1_5a.html` | 1.5a | 빈 컨테이너 (1280×720) |
|
|
||||||
| `stage_1_5a_content.html` | 1.5a | 컨테이너에 콘텐츠 배치 |
|
|
||||||
| `stage_1_5b.html` | 1.5b | 디자인 예산 (available height/width) |
|
|
||||||
| `stage_1_7.html` | 1.7 | 블록 선택 표시 |
|
|
||||||
| `stage_1_8_fit_before.html` | 1.8 | 적합성 (재배분 전) |
|
|
||||||
| `stage_1_8_fit_after.html` | 1.8 | 재배분 후 + 보강 |
|
|
||||||
| `stage_1_8_blocks.html` | 1.8 | SLOT 구조 + 블록 디자인 + 주종관계 (1280×720) |
|
|
||||||
| `stage_2.html` | 2 | 영역별 Sonnet 출력을 실제 렌더링 (역할별 개별 확인) |
|
|
||||||
| `stage_3.html` | 3 | 영역을 합쳐 슬라이드 프레임에 배치한 결과 (1280×720 실제 렌더링) |
|
|
||||||
| `stage_4.html` | 4 | 측정 결과 + 품질 점수 |
|
|
||||||
|
|
||||||
---
|
block 이름이 아니라 표현 규칙입니다.
|
||||||
|
|
||||||
## 핵심 원칙
|
예:
|
||||||
|
|
||||||
1. **콘텐츠가 구조를 결정** — 블록 CSS는 참고만. AI가 콘텐츠 전달 의도를 보고 HTML 구조 결정 (Phase R')
|
- `single_block`
|
||||||
2. **하드코딩 금지** — font-size 외 모든 수치는 동적 계산. 어떤 MDX가 들어와도 동일하게 동작
|
- `two_col_text_visual`
|
||||||
3. **스크롤 절대 금지** — overflow:auto/scroll 어떤 영역에서도 불허
|
- `stacked_summary_detail`
|
||||||
4. **Kei API 필수** — fallback 없음. 성공할 때까지 무한 재시도
|
|
||||||
5. **AI가 옵션 생성, Kei가 결정** — 공간 부족 시 하드코딩 대응이 아니라 Kei 판단 요청
|
|
||||||
6. **계산 먼저, AI 판단 나중에, 렌더링은 검증만**
|
|
||||||
7. **overflow 상태에서 출력 금지** — Vision 모델 품질 게이트 통과 필수
|
|
||||||
|
|
||||||
---
|
recipe는 이런 계약을 가질 수 있습니다.
|
||||||
|
|
||||||
## 블록 라이브러리 (38개)
|
- left/right kind
|
||||||
|
- top/bottom kind
|
||||||
|
- ratio
|
||||||
|
- vertical align
|
||||||
|
|
||||||
6개 카테고리, 38개 블록. 각 블록은 `catalog.yaml`에 용도(when), 금지(not_for), purpose_fit, schema(슬롯 정의)가 있음.
|
### block
|
||||||
|
|
||||||
| 카테고리 | 개수 | 용도 |
|
실제 구현 템플릿 후보입니다.
|
||||||
|---------|------|------|
|
|
||||||
| **headers** | 5 | 타이틀, 꼭지 헤더 |
|
|
||||||
| **cards** | 9 | 항목 나열, 카드 그리드 |
|
|
||||||
| **tables** | 3 | 비교표, 데이터 테이블 |
|
|
||||||
| **visuals** | 6 | SVG 다이어그램, 관계도 |
|
|
||||||
| **emphasis** | 10 | 강조, 인용, 결론, 불릿 |
|
|
||||||
| **media** | 5 | 이미지/사진 |
|
|
||||||
|
|
||||||
---
|
예:
|
||||||
|
|
||||||
## 기술 스택
|
- `prerequisites-3col`
|
||||||
|
- `process-product-2col`
|
||||||
|
- `compare-detail-gradient`
|
||||||
|
- `card-icon-desc`
|
||||||
|
|
||||||
| 역할 | 도구 |
|
즉, "무슨 문서냐"가 아니라 "무슨 구조냐"를 먼저 읽고, 그 구조에 맞는 표현 규칙을 정한 뒤, 마지막에 구현 블록을 고르는 방향으로 가고 있습니다.
|
||||||
|------|------|
|
|
||||||
| 서버 | FastAPI + uvicorn (포트 8001) |
|
|
||||||
| AI (Kei 실장/편집자) | Kei API → Opus (localhost:8000) |
|
|
||||||
| AI (HTML 생성) | Anthropic API → Claude Sonnet |
|
|
||||||
| AI (품질 검증) | Anthropic API → Claude Opus Vision |
|
|
||||||
| 블록 검색 | FAISS + bge-m3 |
|
|
||||||
| 템플릿 | Jinja2 (블록 디자인 레퍼런스용) |
|
|
||||||
| 렌더링 | CSS Grid + 디자인 토큰 (1280×720) |
|
|
||||||
| 렌더링 측정 | Selenium headless Chrome |
|
|
||||||
| SVG 시각화 | svg_calculator.py (N개 동적 배치) |
|
|
||||||
| 이미지 | Pillow (크기 측정) + base64 인라인 |
|
|
||||||
| 폰트 | Pretendard Variable |
|
|
||||||
| 공간 계산 | space_allocator.py + fit_verifier.py (결정론적) |
|
|
||||||
|
|
||||||
---
|
## popup / detail 계약
|
||||||
|
|
||||||
## 설치 및 실행
|
popup은 지금 다음 철학으로 정리되는 중입니다.
|
||||||
|
|
||||||
```bash
|
- 메인 슬라이드에는 존 크기에 맞는 요약만 남긴다
|
||||||
# 설치
|
- 큰 표, 시각 컴포넌트, 과다한 bullet은 상세 popup으로 분리한다
|
||||||
cd design_agent
|
- 메인에서는 `자세히보기` 링크를 제공한다
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# FAISS 인덱스 빌드 (블록 추가/수정 시)
|
현재 popup 관련 핵심은 아래입니다.
|
||||||
python scripts/build_block_index.py
|
|
||||||
|
|
||||||
# .env 설정
|
- `PopupItem` 모델이 도입되어 popup 데이터를 명시적으로 다룸
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
- `popup_id`, `popup_file` 생애주기를 분리해 관리 중
|
||||||
KEI_API_URL=http://localhost:8000
|
- 최종 목표는 popup 판단을 휴리스틱이 아니라 명시적 contract로 만드는 것
|
||||||
LOG_LEVEL=DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
다만 아직 일부 구간엔 추측 로직과 이중 관리가 남아 있어, 이 부분은 계속 정리 중입니다.
|
||||||
# 터미널 1: Kei API (필수)
|
|
||||||
cd D:\ad-hoc\kei\persona_agent
|
|
||||||
python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000
|
|
||||||
|
|
||||||
# 터미널 2: Design Agent
|
## run 산출물 구조
|
||||||
cd D:\ad-hoc\kei\design_agent
|
|
||||||
python -m uvicorn src.main:app --host 127.0.0.1 --port 8001 --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
접속: http://localhost:8001
|
각 실행은 `data/runs/{run_id}/` 아래에 저장됩니다.
|
||||||
|
|
||||||
---
|
주요 파일은 다음과 같습니다.
|
||||||
|
|
||||||
## 개선 이력
|
- `final.html`
|
||||||
|
- `final_context.json`
|
||||||
|
- `steps/*.html`
|
||||||
|
- popup/detail html
|
||||||
|
|
||||||
| Phase | 내용 | 상태 |
|
### `final.html`
|
||||||
|-------|------|------|
|
|
||||||
| A~D | 슬라이드 품질 핵심 | 완료 |
|
|
||||||
| G~N | Kei API, 스토리라인, 정합성, 블록 선택, 비중, 측정 | 완료 |
|
|
||||||
| O | 컨테이너 기반 레이아웃 | 완료 |
|
|
||||||
| P | 다후보 렌더링 비교 | 완료 (20/100점 → 방향 전환) |
|
|
||||||
| Q | 제약 기반 블록 선택 | 완료 |
|
|
||||||
| R | 하이브리드 블록 (실패 — P=Q=R 동일 구조) | 실패 |
|
|
||||||
| R' | 블록 CSS 참고 + AI 구조 결정 | 설계 확정 |
|
|
||||||
| S | 검증 합격 프롬프트 + Claude HTML 생성 | 설계 확정 |
|
|
||||||
| T | 11-Stage 파이프라인 + 디자인 레퍼런스 | 완료 (31/31 통과) |
|
|
||||||
| V | 적합성 검증 + Kei 에스컬레이션 + 서브 컨테이너 | 완료 |
|
|
||||||
| W | Stage 2 출력 품질 수정 (6건) | 진행 중 |
|
|
||||||
|
|
||||||
---
|
- 최종 렌더 결과
|
||||||
|
- 실제 눈으로 보는 산출물
|
||||||
|
|
||||||
## Kei Persona와의 관계
|
### `final_context.json`
|
||||||
|
|
||||||
```
|
- 각 단계 결과를 최종 context 형태로 저장
|
||||||
Kei Persona Agent (localhost:8000)
|
- block 선택, page_structure, measurement, quality_score 등을 확인할 때 가장 중요
|
||||||
├── Opus + RAG + 세션 컨텍스트
|
|
||||||
├── 도메인 지식 (건설/DX/BIM)
|
### `steps/*.html`
|
||||||
└── 대화/생성/피드백/실행 모드
|
|
||||||
|
- 단계별 디버그/설명용 보드
|
||||||
|
- 현재는 검토용으로 유용하지만, 일부 인코딩/설명 품질은 더 다듬을 필요가 있습니다
|
||||||
|
|
||||||
|
## 자주 봐야 하는 파일
|
||||||
|
|
||||||
|
### 파이프라인 핵심
|
||||||
|
|
||||||
|
- [src/pipeline.py](src/pipeline.py)
|
||||||
|
- [src/pipeline_context.py](src/pipeline_context.py)
|
||||||
|
- [src/section_parser.py](src/section_parser.py)
|
||||||
|
- [src/block_reference.py](src/block_reference.py)
|
||||||
|
- [src/block_assembler.py](src/block_assembler.py)
|
||||||
|
- [src/space_allocator.py](src/space_allocator.py)
|
||||||
|
|
||||||
|
### 템플릿 / 카탈로그
|
||||||
|
|
||||||
|
- [templates/catalog.yaml](templates/catalog.yaml)
|
||||||
|
- [templates/blocks/new/prerequisites-3col.html](templates/blocks/new/prerequisites-3col.html)
|
||||||
|
- [templates/blocks/redesign/process-product-2col.html](templates/blocks/redesign/process-product-2col.html)
|
||||||
|
- [templates/blocks/cards/compare-detail-gradient.html](templates/blocks/cards/compare-detail-gradient.html)
|
||||||
|
- [templates/blocks/slide-base.html](templates/blocks/slide-base.html)
|
||||||
|
주의: 현재 삭제/legacy 정리 여부를 별도로 확인해야 하는 경로입니다.
|
||||||
|
|
||||||
|
### 검증 / 측정
|
||||||
|
|
||||||
|
- [src/slide_measurer.py](src/slide_measurer.py)
|
||||||
|
- [src/validators.py](src/validators.py)
|
||||||
|
|
||||||
|
### 역사 / 계획 문서
|
||||||
|
|
||||||
|
- [PIPELINE.md](PIPELINE.md)
|
||||||
|
- [docs/history/PHASE-Y-PLAN.md](docs/history/PHASE-Y-PLAN.md)
|
||||||
|
- [ARCHITECTURE_OVERVIEW.md](ARCHITECTURE_OVERVIEW.md)
|
||||||
|
|
||||||
|
## 현재 상태 요약
|
||||||
|
|
||||||
|
### 잘 닫혀가는 것
|
||||||
|
|
||||||
|
- Type B 메인 경로
|
||||||
|
- `normalized.sections` 기반 구조 해석
|
||||||
|
- schema / recipe 기반 block selection
|
||||||
|
- `prerequisites-3col`, `process-product-2col` 같은 redesign 블록 자산화
|
||||||
|
- popup/detail 2단 표현 계약의 초안 연결
|
||||||
|
|
||||||
|
### 아직 정리 중인 것
|
||||||
|
|
||||||
|
- Type A 전체 안정화
|
||||||
|
- popup을 완전한 source of truth로 정리
|
||||||
|
- `tag_match` 와 `schema_match`의 완전한 동등 점수 비교
|
||||||
|
- step 보드 인코딩/설명 품질
|
||||||
|
- legacy 경로와 문서의 정리
|
||||||
|
|
||||||
|
## 읽는 방법 추천
|
||||||
|
|
||||||
|
프로세스를 빠르게 파악하려면 아래 순서가 좋습니다.
|
||||||
|
|
||||||
|
1. 이 `README.md`
|
||||||
|
2. [src/pipeline.py](src/pipeline.py)
|
||||||
|
3. [src/section_parser.py](src/section_parser.py)
|
||||||
|
4. [src/block_assembler.py](src/block_assembler.py)
|
||||||
|
5. 최근 run의 `final_context.json`
|
||||||
|
|
||||||
|
히스토리까지 보려면 아래 문서를 이어서 보면 좋습니다.
|
||||||
|
|
||||||
|
- [PIPELINE.md](PIPELINE.md)
|
||||||
|
- [docs/history/PHASE-Y-PLAN.md](docs/history/PHASE-Y-PLAN.md)
|
||||||
|
|
||||||
Design Agent (localhost:8001, 이 프로젝트)
|
|
||||||
├── 슬라이드 생성 전용
|
|
||||||
├── Kei API로 꼭지 추출(1A) + 컨셉 구체화(1B) + 에스컬레이션(1.8) 호출
|
|
||||||
├── Sonnet으로 HTML 생성(Stage 2)
|
|
||||||
├── Opus Vision으로 품질 검증(Stage 4)
|
|
||||||
└── 두 프로젝트는 독립. 코드 공유 없음. API 연동만.
|
|
||||||
```
|
|
||||||
|
|||||||
611
docs/history/PHASE-Y-PLAN.md
Normal file
611
docs/history/PHASE-Y-PLAN.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# Phase Y: slide-base 기반 블록 조립 파이프라인 재설계
|
||||||
|
|
||||||
|
> **작성일:** 2026-04-14
|
||||||
|
> **목적:** assembler를 slide-base.html 기반으로 재작성. 블록 선택 → 배치 → 측정 → 조정 루프 완성.
|
||||||
|
> **근거:** Phase X-BX까지 assembler가 slide-base를 무시하고 HTML을 처음부터 생성. 블록 선택(1.7)이 조립(Stage 2)에 반영 안 됨. Stage 4 품질 점수 거짓말. 전체 파이프라인 연결 끊김.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 전환
|
||||||
|
|
||||||
|
```
|
||||||
|
[이전] assembler가 HTML 전체를 하드코딩 생성. 블록 무시. slide-base 미사용.
|
||||||
|
[이후] slide-base.html 위에 tag 매칭된 블록을 배치. 사전계산 + 실측 조정 루프.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 흐름 (2026-04-15 재설계)
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] slide-base.html 로드
|
||||||
|
├── title → .slide-title
|
||||||
|
├── 핵심요약(:::note) → .slide-footer (footer_text)
|
||||||
|
└── .slide-body (590px 가용) → 영역(zone)들이 여기에 배치됨
|
||||||
|
|
||||||
|
[2] Stage 1A: Kei 꼭지 추출 (영역 없이, 꼭지만)
|
||||||
|
├── Kei에게 zone/영역 판단을 시키지 않음
|
||||||
|
├── 꼭지별 title, purpose, layer, relation_type만 추출
|
||||||
|
└── 핵심요약은 conclusion_text로 분리
|
||||||
|
|
||||||
|
[3] 코드: MDX ## 파싱 → 꼭지-대목차 매핑
|
||||||
|
├── MDX에서 ## 대목차 목록 추출 (## 없는 도입부도 포함)
|
||||||
|
├── 각 꼭지의 source_data/title이 어느 ## 아래에 속하는지 매핑
|
||||||
|
└── 대목차별 꼭지 묶음 생성
|
||||||
|
|
||||||
|
[4] 코드: 대목차별 묶음으로 블록 tag 매칭 시도 (영역 확정 단계)
|
||||||
|
├── 묶음별 item_count + 꼭지 title → catalog tag 검색
|
||||||
|
├── 매칭됨 → 이 묶음 = 하나의 영역 (코드가 확정, 블록도 확정)
|
||||||
|
└── 매칭 안 됨 → Kei에게 이 꼭지들의 영역 판단 요청
|
||||||
|
├── "이 꼭지들을 어떻게 묶을지?"
|
||||||
|
├── "sidebar로 뺄 것이 있는지?" → Type A 결정
|
||||||
|
└── Kei는 주어진 꼭지 목록 안에서만 판단 (새 영역 이름 만들지 않음)
|
||||||
|
|
||||||
|
[5] 영역 확정 + 콘텐츠 소스 결정
|
||||||
|
├── 영역 제목 = MDX 원본 ## 제목 그대로
|
||||||
|
├── 영역 콘텐츠 = normalized.sections에서 MDX 원본 텍스트
|
||||||
|
├── Kei structured_text가 아님 (Kei는 구조 판단만)
|
||||||
|
└── sidebar 지정된 영역 → Type A / 나머지 → Type B
|
||||||
|
|
||||||
|
[6] 사전 계산: 영역별 비중 → 대략적 px 배정
|
||||||
|
└── 블록 후보 필터링 (너무 큰 블록 제외)
|
||||||
|
|
||||||
|
[7] .slide-body에 블록 배치 + 실측 조정 루프
|
||||||
|
├── 블록 템플릿 Jinja2 렌더링 (슬롯에 MDX 원본 텍스트 삽입)
|
||||||
|
├── slide-base + 블록 HTML 조합
|
||||||
|
├── Selenium 측정 (measure_mode: overflow:auto) → overflow 확인
|
||||||
|
├── overflow 시:
|
||||||
|
│ ├── ① font/padding 조정 (CSS 변수, 코드)
|
||||||
|
│ ├── ② 간격 축소
|
||||||
|
│ ├── ③ 텍스트 압축 (최후 수단, AI 1회)
|
||||||
|
│ └── 재측정 (최대 3회 루프)
|
||||||
|
└── overflow 없음 → 확정
|
||||||
|
|
||||||
|
[8] 최종 HTML 출력
|
||||||
|
└── slide-base + 확정된 블록들 = final.html (overflow:hidden)
|
||||||
|
|
||||||
|
[9] 품질 검증
|
||||||
|
├── Selenium overflow 측정
|
||||||
|
├── 비전 모델 평가 (가능할 때만)
|
||||||
|
└── 미평가 시 -1 (거짓말 안 함)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 핵심 변경 (이전 대비)
|
||||||
|
|
||||||
|
| 항목 | 이전 (문제) | 이후 (재설계) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| 영역/zone 결정 | Kei가 zone 구조를 만듦 → 매번 다름, 오인 | 코드가 ##파싱 + 블록매칭으로 먼저 확정. 안 되는 것만 Kei |
|
||||||
|
| 콘텐츠 소스 | Kei structured_text (재구성) | MDX 원본 (normalized.sections) |
|
||||||
|
| 영역 제목 | Kei가 축약 ("필수요건") | MDX 원본 ## 제목 그대로 |
|
||||||
|
| Kei 역할 | 꼭지+영역+zone+텍스트 전부 | 꼭지 추출 + 성격 판단(sidebar/팝업)만 |
|
||||||
|
| 블록 매칭 시점 | 영역 확정 후 | 영역 확정 전 (블록이 영역을 결정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 태스크 목록
|
||||||
|
|
||||||
|
### Y-1: Kei 프롬프트 — zone = 대목차 (완료)
|
||||||
|
- [x] zone = `##` 대목차 단위, `###` 소목차 = zone 안 블록
|
||||||
|
- [x] 2단계 판단: 먼저 zone 잡고 → 용어정의 있으면 Type A
|
||||||
|
- [x] 결론/핵심요약 = slide-base footer (별도 zone 아님)
|
||||||
|
- [ ] Kei 프롬프트에서 결론 zone 제거 (page_structure에서 제외)
|
||||||
|
|
||||||
|
### Y-2: space_allocator — zone=bottom 지원
|
||||||
|
- [ ] `bottom` zone 전체폭 처리 추가
|
||||||
|
- [ ] Type A/B에 따라 .slide-body 내 레이아웃 분기
|
||||||
|
- [ ] 결론 zone 미생성 (slide-base footer로 처리)
|
||||||
|
|
||||||
|
### Y-3: block_reference — tag 매칭 강화
|
||||||
|
- [x] tag 기반 0순위 매칭 추가
|
||||||
|
- [x] item_count 범위("2-3") 처리
|
||||||
|
- [ ] content_pattern 가중치 추가 (item_count만으로 매칭 방지)
|
||||||
|
- [ ] 결론 role 매칭 제외 (slide-base가 처리)
|
||||||
|
|
||||||
|
### Y-4: assembler 재작성 — slide-base 기반
|
||||||
|
- [ ] slide-base.html 로드 → Jinja2 렌더링
|
||||||
|
- [ ] title, footer_text(핵심요약) 삽입
|
||||||
|
- [ ] .slide-body에 zone별 블록 HTML 삽입
|
||||||
|
- [ ] render_block_for_role()로 블록 렌더링
|
||||||
|
- [ ] 블록 CSS를 slide-base <style>에 합침
|
||||||
|
- [ ] 기존 하드코딩 assembler 제거 (fallback 아님)
|
||||||
|
|
||||||
|
### Y-5: 사이즈 조정 루프
|
||||||
|
- [ ] 사전 계산: zone 비중 → px 배정 → 블록 필터링
|
||||||
|
- [ ] 실측 조정: Selenium 측정 → overflow → font/padding 조정
|
||||||
|
- [ ] 루프 최대 3회
|
||||||
|
- [ ] overflow 해소 안 되면 텍스트 압축 (AI 1회)
|
||||||
|
|
||||||
|
### Y-6: Sonnet redesign 경로
|
||||||
|
- [ ] tag 매칭 실패 시 유사 블록 선택
|
||||||
|
- [ ] Sonnet에 블록 HTML + 콘텐츠 구조 전달 → 블록 단위 redesign
|
||||||
|
- [ ] redesign 결과를 templates/blocks/redesign/에 저장
|
||||||
|
- [ ] catalog.yaml, INDEX.md 자동 업데이트
|
||||||
|
|
||||||
|
### Y-7: step_visualizer 정합성
|
||||||
|
- [ ] 각 stage step HTML이 실제 파이프라인 데이터와 일치
|
||||||
|
- [ ] stage_1_8_blocks → 실제 블록 렌더링 (샘플 아님)
|
||||||
|
- [ ] stage_2 → slide-base + 블록 조합 결과 표시
|
||||||
|
|
||||||
|
### Y-8: Stage 4 품질 검증
|
||||||
|
- [x] 비전 실패 시 -1 (거짓말 방지)
|
||||||
|
- [x] 비전 미평가 시 차단 안 함, 경고만
|
||||||
|
- [ ] Selenium overflow 검사 정확도 검증
|
||||||
|
|
||||||
|
### Y-9: 검증 — MDX 03 end-to-end
|
||||||
|
- [ ] MDX 03 파이프라인 실행
|
||||||
|
- [ ] 각 stage 전후 데이터 연결 확인
|
||||||
|
- [ ] 최종 결과물이 85점 수준인지 시각 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 의존 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
Y-1 (Kei 프롬프트) ──→ Y-2 (space_allocator) ──→ Y-3 (block_reference)
|
||||||
|
↓
|
||||||
|
Y-4 (assembler 재작성)
|
||||||
|
↓
|
||||||
|
Y-5 (사이즈 루프)
|
||||||
|
↓
|
||||||
|
Y-6 (Sonnet redesign)
|
||||||
|
↓
|
||||||
|
Y-7 (step_visualizer)
|
||||||
|
↓
|
||||||
|
Y-8 + Y-9 (검증)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 블록 tag 업데이트 (별도 작업)
|
||||||
|
- 사용자가 다른 클로드와 진행
|
||||||
|
- catalog.yaml에 tags 필드 추가
|
||||||
|
- content_pattern, item_count, content_example 등
|
||||||
|
- Phase Y와 병렬 진행 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-14 진행 결과
|
||||||
|
|
||||||
|
### 완료된 것
|
||||||
|
|
||||||
|
| 태스크 | 상태 | 비고 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Y-1 Kei 프롬프트 | ✅ | zone=대목차(##) 단위. 결론=conclusion_text 필드. page_structure에서 제거. |
|
||||||
|
| Y-2 space_allocator | ✅ | zone=bottom 전체폭 지원. 결론 zone 미생성. slide-body 590px 기준. |
|
||||||
|
| Y-3 block_reference | ✅ | tag 매칭 0순위. item_count+content_example AND 조건. 결론 매칭 제외. |
|
||||||
|
| Y-4 assembler | ✅ | slide-base.html 로드 → zone별 블록 렌더링 → .slide-body에 배치. measure_mode 분리. |
|
||||||
|
| Y-7 step_visualizer | ✅ | 1_5a~1_8 모두 slide-base 기반 _wrap(). _hdr/_box/_calc_coords 제거. |
|
||||||
|
| Y-8 Stage 4 | ✅ | 비전 실패 시 -1. 거짓말 방지. |
|
||||||
|
| validator | ✅ | 결론 zone 필수 검증 제거. |
|
||||||
|
| slide_measurer | ✅ | zone- 클래스 감지 추가 (area-만 보던 것 수정). |
|
||||||
|
|
||||||
|
### 검증된 것 (MDX 03 파이프라인 실행)
|
||||||
|
|
||||||
|
```
|
||||||
|
Kei → zone 2개 (필수요건[1,2,3] + 혁신과변화[4,5]) + conclusion_text ✅
|
||||||
|
space_allocator → top 295px + bottom 287px (합 582px, gap 8px = 590px) ✅
|
||||||
|
block_reference → prerequisites-3col (tag_match) + compare-detail-gradient (tag_match) ✅
|
||||||
|
assembler → slide-base + 블록 렌더링 → .slide-body에 배치 ✅
|
||||||
|
Selenium → zone별 overflow 감지 (bottom +190px) ✅
|
||||||
|
Stage 4 → -1 미평가 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 근본 설계 오류 (2026-04-14 발견)
|
||||||
|
|
||||||
|
**근본 오류: 콘텐츠 소스가 잘못됨**
|
||||||
|
- 현재: Kei가 structured_text를 재구성 → assembler가 이걸 블록에 넣음
|
||||||
|
- 올바른 방향: **assembler는 normalized.sections(MDX 원본 텍스트)에서 직접 가져옴**
|
||||||
|
- Kei 역할: 구조 판단만 (zone 분류, 팝업 분리, 블록 선택). **텍스트 재구성 안 함.**
|
||||||
|
- zone 제목도 Kei가 줄인 role_name이 아니라, MDX 원본 `##` 제목을 그대로 사용
|
||||||
|
- 데이터 흐름 변경:
|
||||||
|
```
|
||||||
|
[현재] MDX → Kei structured_text(재구성) → assembler
|
||||||
|
[올바름] MDX → normalized.sections(원본) → assembler
|
||||||
|
Kei → 구조 판단(zone/블록/팝업) → assembler에 지시만
|
||||||
|
```
|
||||||
|
- 수정 대상:
|
||||||
|
1. assembler: `topic.structured_text` 대신 `normalized.sections`에서 원본 텍스트 가져오기
|
||||||
|
2. zone 제목: `role_name` 대신 `normalized.sections`의 `##` 제목 사용
|
||||||
|
3. Kei의 topic_ids로 어떤 section이 어떤 zone에 가는지 매핑
|
||||||
|
|
||||||
|
### 미해결 오류 (수정 중)
|
||||||
|
|
||||||
|
**오류 1: 블록 색상이 Figma 원본과 다름** → nth-child로 수정 완료 (인라인 style 제거)
|
||||||
|
**오류 2: ### 마크다운 헤더 그대로 노출** → _parse_topic_to_items에서 ### 스킵 수정 완료
|
||||||
|
**오류 3: 글씨 크기 px 고정** → CSS 변수 전환 수정 완료
|
||||||
|
|
||||||
|
**오류 4: slide-base HTML 주석이 출력에 노출** → re.sub로 주석 제거 수정 완료
|
||||||
|
**오류 5: zone-bottom 중복 (body가 2번 삽입)** → 주석 안 {% block body %} 제거로 수정
|
||||||
|
**오류 6: zone 제목이 Kei 축약본** → 근본 오류. normalized.sections에서 원본 제목 가져와야 함
|
||||||
|
|
||||||
|
### 미해결 오류 (원래 3건, 추가 발견 포함)
|
||||||
|
|
||||||
|
**오류 1: 블록 색상이 Figma 원본과 다름** (수정 완료)
|
||||||
|
- prerequisites-3col 블록: 3열이 각각 다른 색(파랑/금/초록)이어야 하는데 전부 같은 색
|
||||||
|
- 원인: 블록 템플릿 CSS의 `|default()` 값이 단일 색상(파랑)만 있음
|
||||||
|
- assembler에서 색상을 전달하는 방식은 잘못됨
|
||||||
|
- 해결: **블록 템플릿 CSS 자체에 열별 색상을 가지고 있어야 함** (`:nth-child(1)`, `:nth-child(2)`, `:nth-child(3)`로 각각 다른 색)
|
||||||
|
- 또는 catalog.yaml에 블록 디자인 속성(색상 팔레트)을 정의하고 assembler가 읽어서 전달
|
||||||
|
- assembler는 콘텐츠만 전달. 디자인은 블록이 가지고 있거나 catalog에서 오는 것.
|
||||||
|
|
||||||
|
**오류 2: 하단 zone에 `### 과정의 혁신`, `### 결과의 변화` 마크다운 헤더가 그대로 노출**
|
||||||
|
- `_parse_topic_to_items()`에서 `### ` 접두사를 heading으로 포함시킴
|
||||||
|
- topic.title이 이미 "과정(Process)의 혁신"인데, structured_text 첫 줄에 또 `### 과정(Process)의 혁신`이 있음
|
||||||
|
- 해결: `_parse_topic_to_items()`에서 `### `으로 시작하는 줄은 무시 (topic.title과 중복)
|
||||||
|
- 또는 structured_text 파싱 시 `### ` 접두사 제거
|
||||||
|
|
||||||
|
**오류 3: 블록 글씨 크기가 하드코딩 (px 고정)**
|
||||||
|
- 블록 템플릿 CSS에 `font-size: 27px`, `font-size: 21px` 등 Figma 원본 크기가 고정
|
||||||
|
- 컨테이너 크기에 따라 font가 조정되어야 하는데 고정이라 overflow 발생
|
||||||
|
- 해결 방향:
|
||||||
|
1. 블록 CSS에서 font-size를 CSS 변수(`var(--block-font-heading)`)로 변환
|
||||||
|
2. assembler가 zone 크기에 따라 CSS 변수 값을 계산하여 전달
|
||||||
|
3. overflow 루프에서 CSS 변수를 줄여가며 재측정
|
||||||
|
- 이것이 Y-5(사이즈 조정 루프)의 핵심
|
||||||
|
|
||||||
|
### 미해결 프로세스
|
||||||
|
|
||||||
|
**overflow 재배분 루프가 실질적으로 안 동작**
|
||||||
|
- Selenium이 overflow +190px를 감지했지만, 재배분할 surplus zone이 없음 (top도 꽉 참)
|
||||||
|
- 재배분만으로는 해결 안 됨 → font/padding 조정이 필요
|
||||||
|
- 현재 파이프라인에 font 조정 로직 없음
|
||||||
|
- Y-5에서 구현 필요:
|
||||||
|
1. overflow 감지 → font-size 1~2px 줄이기 → 재렌더링 → 재측정
|
||||||
|
2. 최대 3회 루프
|
||||||
|
3. 그래도 안 되면 텍스트 압축 (AI 1회)
|
||||||
|
|
||||||
|
**Sonnet redesign 경로 (Y-6) 미구현**
|
||||||
|
- tag 매칭 실패 시 유사 블록 기반 Sonnet redesign → templates/blocks/redesign/에 저장
|
||||||
|
- 현재는 tag 매칭 실패 시 기존 relation_type 방식으로 fallback
|
||||||
|
|
||||||
|
**콘텐츠 소스 전환 (Y-10)** → ✅ 완료 (2026-04-15)
|
||||||
|
- normalized.sections를 단일 소스로 확정
|
||||||
|
- section_parser: raw MDX 파싱 → normalized.sections 기반으로 변경
|
||||||
|
- assembler: _find_section_content() → normalized.sections에서 가져옴
|
||||||
|
- D1:/D2: 포맷 파싱 지원 추가
|
||||||
|
- Kei structured_text 의존 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-15 진행 결과
|
||||||
|
|
||||||
|
### 구조 안정화 1차 완료
|
||||||
|
|
||||||
|
| 항목 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| process wiring | ✅ 복구됨 | normalized.sections → section shape → block assignment → slide-base → final html |
|
||||||
|
| overflow control | ✅ 통과 | Selenium zone별 측정 동작, overflow 0 (최신 run) |
|
||||||
|
| single source of truth | ✅ 확정 | normalized.sections (Stage 0). raw MDX 직접 사용 안 함. |
|
||||||
|
| layout_template 결정 | ✅ 코드 | sidebar 유무로 A/B 자동 결정 (Kei 의존 아님) |
|
||||||
|
| 영역 확정 | ✅ 코드 | sub_titles 기반 블록 매칭 → 영역 확정 (Kei zone 판단 제거) |
|
||||||
|
| validator 정리 | ✅ | page_structure 검증을 section_parser 후로 이동. Type B purpose 모순 = 경고만. |
|
||||||
|
| **semantic block matching** | ❌ 미해결 | prerequisites-3col 대신 category-strip-table 선택됨 |
|
||||||
|
| **slot filling** | ❌ 미해결 | 블록 slot에 텍스트가 비어있음 (껍데기만) |
|
||||||
|
| quality gate | ⚠ 부분 | vision 404. Selenium만 동작. |
|
||||||
|
|
||||||
|
### 핵심 미해결: 블록 매칭 정확도 + slot 채움
|
||||||
|
|
||||||
|
**1. 블록 매칭이 엉뚱한 블록을 선택**
|
||||||
|
- top: prerequisites-3col(원하는 것) → category-strip-table(선택됨)
|
||||||
|
- bottom: compare-detail-gradient(원하는 것) → dark-bullet-list(선택됨)
|
||||||
|
- 원인: tag 매칭 점수에서 category-strip-table이 더 높은 점수를 받음
|
||||||
|
- 해결: tag 매칭 점수 로직 보정 필요
|
||||||
|
- prerequisites-3col의 content_example에 "기술/사람/자연"이 정확히 매칭되면 최우선
|
||||||
|
- compare-detail-gradient의 content_example에 "과정/결과"가 매칭되면 최우선
|
||||||
|
|
||||||
|
**2. slot 채움이 비어있음**
|
||||||
|
- category-strip-table 블록의 slot에 기술/사람/자연 데이터가 안 들어감
|
||||||
|
- 원인: _build_slot_data()가 sub_title별로 normalized.sections에서 content를 찾는데,
|
||||||
|
sub_title "기술(디지털)"의 content가 대목차 "DX 시행을 위한 필수 요건"의 합친 content에 있어서
|
||||||
|
개별 sub_title별로 분리 안 됨
|
||||||
|
- 해결: normalized.sections에서 sub_title별 개별 content를 직접 가져와야 함
|
||||||
|
(major_sections의 합친 content가 아니라, 개별 level=2 section)
|
||||||
|
|
||||||
|
**3. section shape와 topic 매핑 불일치**
|
||||||
|
- bottom의 topic_ids=[4]만 있음 (5가 빠짐)
|
||||||
|
- 원인: Kei가 꼭지를 매번 다르게 만들어서 매핑이 흔들림
|
||||||
|
- 해결: topic 매핑도 sub_titles 기반으로 안정화 필요
|
||||||
|
|
||||||
|
### 추가 발견 (2026-04-15 후반)
|
||||||
|
|
||||||
|
**블록 매칭은 해결됨 (sub_titles 기반 + min_height 감점 제거):**
|
||||||
|
- top: prerequisites-3col ✅ (sub_titles 3개 매칭)
|
||||||
|
- bottom: compare-detail-gradient ✅ (sub_titles 2개 매칭)
|
||||||
|
|
||||||
|
**하지만 결과물 품질이 안 맞음:**
|
||||||
|
1. 결론 텍스트 3번 중복 (블록 slot + footer)
|
||||||
|
2. bottom 좌측 텍스트 안 보임 (D1 only → desc 빈 배열)
|
||||||
|
3. 텍스트 중복 렌더링
|
||||||
|
4. zone height px 고정 (참고는 % 기반)
|
||||||
|
5. font_scale 방식으로 글자 과도 축소 (참고는 글자 크기 고정)
|
||||||
|
6. 블록이 zone height:100%로 안 채워짐
|
||||||
|
7. weight가 content 글자 수 기준 (중요도가 아님)
|
||||||
|
8. bottom 블록으로 cdg가 선택됐지만, 참고 결과물은 pp2 사용
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- cdg로도 가능하지만, pp2가 이 콘텐츠 구조(비대칭: 표+불릿 vs 불릿)에 더 적합
|
||||||
|
- pp2는 catalog 미등록 → tag 매칭 불가
|
||||||
|
- 블록 글씨 크기가 Figma 원본(18px) → 슬라이드 적용(12px) 변환 필요
|
||||||
|
- 블록 payload schema가 확정 안 됨 → slot 데이터가 제대로 안 채워짐
|
||||||
|
- fit 루프가 구조/payload 이전에 실행돼서 의미 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Y-11: 블록 자산 → payload → layout → fit → 검증 (2026-04-15 확정)
|
||||||
|
|
||||||
|
> **원칙:** 구조 → payload → layout → fit 순서. 하드코딩 금지. 전체 프로세스 구조 속에서 정리.
|
||||||
|
> **핵심:** "프로세스가 결과를 만드는" 구조. 결과를 보고 프로세스를 땜질하지 않음.
|
||||||
|
|
||||||
|
### 전체 파이프라인 (Y-11 반영)
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] slide-base.html 로드 (title + footer)
|
||||||
|
[2] Kei: 꼭지 추출만 (영역/zone 안 함)
|
||||||
|
[3] 코드: normalized.sections → 대목차 추출 → 꼭지 매핑
|
||||||
|
[4] 코드: shape 기반 block selection
|
||||||
|
sub_titles 수 + 구조 타입 → catalog tag 매칭 → 블록 확정
|
||||||
|
[5] payload 조립
|
||||||
|
블록별 payload schema에 맞게 normalized data 변환
|
||||||
|
[6] payload contract 검증 ← 새로 추가
|
||||||
|
필수 slot 비어있지 않은지, 결론이 body에 안 섞였는지
|
||||||
|
[7] layout 조립
|
||||||
|
zone % 기반 + block height:100% + 고정 글씨 크기
|
||||||
|
[8] fit 루프
|
||||||
|
overflow → padding/spacing 먼저 → 팝업 분리 → font 축소(최후)
|
||||||
|
[9] 최종 검증
|
||||||
|
block 선택 + 글자 누락 + 결론 위치 + overflow + 밀도
|
||||||
|
```
|
||||||
|
|
||||||
|
### Y-11 태스크 목록
|
||||||
|
|
||||||
|
**[Y-11a] pp2 블록 자산 정리**
|
||||||
|
- BEPs/process-product-2col.html → blocks/redesign/ 이동
|
||||||
|
- slot 구조: left_title, right_title, left_compare(asis/tobe), left_sections[], right_sections[]
|
||||||
|
- 블록 목적, 사용 조건, 미사용 조건 문서화
|
||||||
|
- HTML/CSS의 글씨 크기를 슬라이드 적용값(header:13px, mid:12px, body:11px)으로 확정
|
||||||
|
|
||||||
|
**[Y-11b] pp2 catalog.yaml 등록 + tag**
|
||||||
|
- content_pattern: "2-section-asymmetric-compare-table-and-bullets"
|
||||||
|
- item_count: 2
|
||||||
|
- content_example에 구조 설명 (문서명 하드코딩 금지)
|
||||||
|
- slide_font 필드에 슬라이드 적용 글씨 크기 기록
|
||||||
|
|
||||||
|
**[Y-11c] block selection 규칙 shape 기반 재정의**
|
||||||
|
- sub_titles 3개 + 병렬 → prerequisites-3col
|
||||||
|
- sub_titles 2개 + 비대칭(표+불릿) → pp2
|
||||||
|
- sub_titles 2개 + 대칭 비교 → cdg
|
||||||
|
- sidebar/reference → 해당 전용 블록
|
||||||
|
- pipeline Phase Y + Stage 1.7 block_reference 일관 적용
|
||||||
|
|
||||||
|
**[Y-11d] pp2 payload schema + 파이프라인 연결**
|
||||||
|
- payload 구조 확정 (left_title, right_title, left_compare, left/right_sections)
|
||||||
|
- _build_slot_data()에서 블록별 payload 생성
|
||||||
|
- D1/D2 → payload schema 변환 규칙 (D1 only 표 복원 포함)
|
||||||
|
|
||||||
|
**[Y-11e] 본문 데이터 정리 규칙**
|
||||||
|
- 결론/핵심요약 → footer 전용, 블록 payload에 절대 안 섞기
|
||||||
|
- 표 → left_compare.left_items/right_items
|
||||||
|
- 불릿 → sections[].bullets
|
||||||
|
- D1 only 평탄화 → 표 구조 복원
|
||||||
|
|
||||||
|
**[Y-11f] zone wrapper 정리**
|
||||||
|
- zone height: % 기반 (px 아님)
|
||||||
|
- block wrapper: height:100%
|
||||||
|
- zone 제목: 13px, margin-bottom:8px
|
||||||
|
- zone 간 여백: margin-bottom:1%
|
||||||
|
- 글씨 크기: catalog slide_font 값 (font_scale 아님)
|
||||||
|
|
||||||
|
**[Y-11g] 블록 내부 typography 규칙**
|
||||||
|
- prerequisites-3col: heading 12px, desc 11px, vlabel 14px
|
||||||
|
- pp2: header 13px, mid_title 12px, body 11px
|
||||||
|
- bullet 기호, padding-left, text-indent, line-height
|
||||||
|
- catalog.yaml slide_font에 기록, assembler가 읽어서 적용
|
||||||
|
|
||||||
|
**[Y-11h] payload contract 검증 (fit 전 게이트)** ✅ 구현
|
||||||
|
- 필수 slot 비어있지 않은지
|
||||||
|
- conclusion이 body payload에 안 들어갔는지
|
||||||
|
- 같은 데이터가 compare와 section에 중복 주입 안 되는지 ← 추가 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Y-11 1차 검증 결과 (2026-04-15, run 20260415_091309)
|
||||||
|
|
||||||
|
**block selection: ✅ 통과**
|
||||||
|
- top: prerequisites-3col (tag_match)
|
||||||
|
- bottom: process-product-2col (tag_match)
|
||||||
|
- overflow: top 0, bottom 0
|
||||||
|
- font_scale: 1.0 (축소 안 함)
|
||||||
|
|
||||||
|
**payload/contract/layout: ❌ 미완**
|
||||||
|
|
||||||
|
아래 항목들이 남아있음 → Y-12로 정리:
|
||||||
|
|
||||||
|
### Phase Y-12: payload 정제 → contract → layout → asset → validation
|
||||||
|
|
||||||
|
> block selection은 통과. 이제 "선택된 블록에 정확한 데이터를 정확한 형태로 넣는" 단계.
|
||||||
|
|
||||||
|
**[Y-12a] payload normalization (정제)**
|
||||||
|
- `**` `****` 마크다운 잔여 토큰 제거 (final 출력 전 cleanup gate)
|
||||||
|
- top desc: `/`로 이어진 plain text → `<div class="bul">• ...</div>` 불릿 구조
|
||||||
|
- 표 잔여 토큰 (D1: As-is, D1: 구분 등) 정리
|
||||||
|
- `[핵심요약: ...]` stray note 제거
|
||||||
|
|
||||||
|
**[Y-12b] block contract assembly (중복 제거)**
|
||||||
|
- left_compare에 들어간 데이터가 left_sections에 다시 들어가지 않게
|
||||||
|
(현재: Analogue 기반 업무의 Digital화가 compare 제목 + section mid-title에 중복)
|
||||||
|
- compare → left_sections 분리 규칙: 표 항목은 compare에만, 나머지는 sections에만
|
||||||
|
- pp2 좌우 section packing 규칙 고정
|
||||||
|
|
||||||
|
**[Y-12c] layout/style contract** — 공통 레이아웃 계약 + 블록별 내부 contract
|
||||||
|
|
||||||
|
공통 들여쓰기 계층 (모든 슬라이드에 적용):
|
||||||
|
```
|
||||||
|
대제목 (slide-base) ← left: 52px (고정)
|
||||||
|
중제목 (zone 제목) ← 대제목과 같은 시작선 (padding-left: 12px)
|
||||||
|
블록 wrapper ← 중제목보다 안쪽 (padding: 0 12px 0 24px)
|
||||||
|
소제목 ← 블록 내부 기준선
|
||||||
|
불릿 ← 소제목보다 안쪽
|
||||||
|
두번째줄 ← 첫줄 문장 시작선 정렬 (hanging indent: padding-left:14px; text-indent:-14px)
|
||||||
|
```
|
||||||
|
|
||||||
|
블록별 내부 contract:
|
||||||
|
- p3c: bar 56px, vlabel-area 56px, section left:60px
|
||||||
|
- pp2: display:flex 좌/우 병렬, 소제목 행 정렬, body padding 6px 16px
|
||||||
|
- 불릿: `.bul`, `.pp2-body-text`, `.cdg-bullet` 공통 hanging indent
|
||||||
|
|
||||||
|
**[Y-12d] asset packaging**
|
||||||
|
- 배경 텍스처 (svg/bg_slide_texture.png) → base64 내장 또는 data URI
|
||||||
|
- 화살표 이미지 (arrow) → base64
|
||||||
|
- final.html이 단독으로 열어도 asset 깨지지 않게
|
||||||
|
|
||||||
|
**[Y-12e] final validation**
|
||||||
|
- 글자 누락 없음
|
||||||
|
- markdown residue (`**`, `****`) 없음
|
||||||
|
- 본문 중복 없음 (compare/section 중복)
|
||||||
|
- 결론이 footer에만 있음
|
||||||
|
- 좌우 정렬 이상 없음
|
||||||
|
- asset 깨짐 없음
|
||||||
|
- overflow 없음
|
||||||
|
|
||||||
|
**[Y-12f] 파이프라인 실행 + 참고 비교**
|
||||||
|
- MDX 03 실행 → ✅ 완료 (run 20260415_105516, 텍스트 누락 없음)
|
||||||
|
- mdx03_final/final.html과 비교
|
||||||
|
- 하드코딩 없이 프로세스로 도달한 결과인지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Y-13: Group Schema 계층 (fit 블록 없을 때의 프로세스)
|
||||||
|
|
||||||
|
> **근거:** MDX 02를 돌렸을 때, fit한 블록이 없어서 venn-diagram 같은 엉뚱한 fallback으로 빠짐.
|
||||||
|
> fit 블록이 있으면 Y-11~12 프로세스로 충분. 없을 때 "바로 fallback 아무거나"가 아니라
|
||||||
|
> **중목차 → 소목차 관계 판단 → group schema → 블록 선택/조합** 경로가 필요.
|
||||||
|
|
||||||
|
### 핵심 프로세스
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] section group 추출
|
||||||
|
## 중목차 기준으로 하나의 section group으로 묶기
|
||||||
|
(이미 extract_major_sections()가 sub_titles를 제공)
|
||||||
|
|
||||||
|
[2] group relation classifier
|
||||||
|
같은 중목차 안의 소목차들의 관계 판단:
|
||||||
|
- 병렬 목표 (안전/생산성/소통) → parallel_3
|
||||||
|
- 비교 (과정혁신/결과변화) → compare_2
|
||||||
|
- 순서/프로세스 → process_list
|
||||||
|
- 독립 카드 → independent_cards
|
||||||
|
- 보조 설명 → summary_with_visual
|
||||||
|
Kei가 판단 지원 (코드만으로 어려움)
|
||||||
|
|
||||||
|
[3] group schema enum
|
||||||
|
relation → schema 변환:
|
||||||
|
- parallel_3 → 3열 카드/표/요약
|
||||||
|
- compare_2 → 2열 비교
|
||||||
|
- process_list → 단계/변화 목록
|
||||||
|
- summary_with_visual → 텍스트+이미지
|
||||||
|
|
||||||
|
[4] schema → block matcher
|
||||||
|
schema에 맞는 블록을 catalog에서 찾기
|
||||||
|
├── 정확히 맞는 블록 있음 → 사용
|
||||||
|
├── 유사 블록 → Sonnet redesign → blocks/redesign/ 저장
|
||||||
|
└── 없음 → composition (메인 블록 + 보조 블록 조합)
|
||||||
|
fallback 아무거나 금지
|
||||||
|
|
||||||
|
[5] payload 조립 (group schema 기준)
|
||||||
|
group schema → block payload 변환
|
||||||
|
이미지 포함 여부, bullet 정리, 표 축약, popup 분기
|
||||||
|
|
||||||
|
[6] layout / fit
|
||||||
|
zone 비율, padding, indent, overflow, popup fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Y-13 진행 결과 (2026-04-15)
|
||||||
|
|
||||||
|
**Y-13a~d: 구현 완료**
|
||||||
|
|
||||||
|
구현된 것:
|
||||||
|
- `classify_group_relations()`: D1: 개수로 병렬 항목 감지 + 키워드 기반 schema 분류
|
||||||
|
- schema 세분화: `compare_asymmetric_2col` (표+비대칭), `process_plus_visual` (불릿+시각) 추가
|
||||||
|
- 기존 `process_list` 유지 (삭제 안 함, 점진적 추가)
|
||||||
|
- `GROUP_SCHEMA_BLOCK_MAP`: 새 schema → 블록 후보 매핑 추가
|
||||||
|
- pipeline + block_reference에서 tag 실패 시 schema 후보로 선택
|
||||||
|
|
||||||
|
**MDX 03 회귀 검증 ✅:**
|
||||||
|
```
|
||||||
|
top: parallel_3 → prerequisites-3col ✅ (이전과 동일)
|
||||||
|
bottom: compare_asymmetric_2col → process-product-2col ✅ (pp2 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
**MDX 02 현재 상태:**
|
||||||
|
```
|
||||||
|
top: parallel_3_with_image → prerequisites-3col (이미지 배치 미해결)
|
||||||
|
bottom: compare_2 → compare-detail-gradient (<DxEffect> 감지 안 됨 → process_plus_visual 미적용)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 미해결 + 방향
|
||||||
|
|
||||||
|
**1. tag_match와 schema_match 동등 비교 (향후)**
|
||||||
|
- 현재: tag 실패 시에만 schema fallback
|
||||||
|
- 향후: 둘 다 점수화해서 동등 비교
|
||||||
|
- schema_match를 block selection의 1급 기준으로 승격 예정
|
||||||
|
|
||||||
|
**2. GROUP_SCHEMA_BLOCK_MAP → 선언형 이동 (향후)**
|
||||||
|
- 현재: section_parser.py에 dict 하드코딩
|
||||||
|
- 향후: catalog.yaml 또는 별도 schema 파일로 선언형 관리
|
||||||
|
|
||||||
|
**3. Kei는 보조 힌트**
|
||||||
|
- 구조적 근거(D1: 수, sub_titles 수, 키워드) = 1순위
|
||||||
|
- Kei = 보조 의미 힌트 (하드 의존 안 함)
|
||||||
|
|
||||||
|
**4. composition 경로 (향후)**
|
||||||
|
- 단일 블록으로 안 되는 경우에만
|
||||||
|
- 지금은 먼저 단일 블록 경로를 정교화
|
||||||
|
- composition은 검증 결과를 보고 필요한 경우에만 추가
|
||||||
|
|
||||||
|
**5. MDX 02 bottom 분류 정교화** → ✅ 해결
|
||||||
|
- `<DxEffect />`가 normalized에서 제거됨 → sub_titles 키워드("기대효과")로 분기
|
||||||
|
- content + sub_text 합쳐서 키워드 검색하도록 수정
|
||||||
|
- MDX 02 bottom: `process_plus_visual` → checklist-dark 후보
|
||||||
|
|
||||||
|
**6. section_parser 책임 분리 (향후)**
|
||||||
|
- 현재: group 추출 + relation 분류 + schema enum + block 후보 다 있음
|
||||||
|
- 향후: group_schema.py 별도 모듈로 분리 예정
|
||||||
|
|
||||||
|
**7. 메인/popup 2단 표현 계약 (파이프라인 공통)**
|
||||||
|
- 콘텐츠가 zone에 다 안 들어갈 때의 공통 규칙
|
||||||
|
- MDX의 `<DxEffect />` 같은 시각 컴포넌트, 큰 표, 과다 불릿 대응
|
||||||
|
- 구조:
|
||||||
|
```
|
||||||
|
메인 HTML (zone 안):
|
||||||
|
- 존 크기에 맞는 요약형 (2~3행 요약, 핵심 포인트)
|
||||||
|
- "자세히보기" 링크/버튼
|
||||||
|
popup HTML (별도 파일):
|
||||||
|
- 전체 표, 전체 bullet, 컴포넌트 원형 구조
|
||||||
|
- 예: detail_dx_effect.html (run 폴더 안)
|
||||||
|
```
|
||||||
|
- popup 분기 조건:
|
||||||
|
- 표가 크다 (행 5개 이상)
|
||||||
|
- 시각 컴포넌트가 있다 (`<DxEffect />` 등)
|
||||||
|
- 존 높이에 안 맞는다 (overflow)
|
||||||
|
- 본문에 넣으면 가독성이 깨진다
|
||||||
|
- 검증 규칙:
|
||||||
|
- popup으로 보낸 경우 본문엔 최소 요약이 남아 있어야 함 (빈칸 금지)
|
||||||
|
- 링크 대상 파일이 실제 생성돼 있어야 함
|
||||||
|
- Astro 컴포넌트 연결:
|
||||||
|
- `<DxEffect />` → `samples/src/components/dx.astro`에 연결
|
||||||
|
- 파이프라인은 Astro를 실행하지 않음
|
||||||
|
- dx.astro를 읽어서 HTML로 변환 → popup 파일로 생성
|
||||||
|
- 메인에는 요약형 카드 + 자세히보기 링크
|
||||||
|
- fit 루프와의 관계:
|
||||||
|
- overflow 발생 → font 축소(최후) 전에 popup 분리를 먼저 시도
|
||||||
|
- 순서: padding 조정 → popup 분리 → font 1단계 축소
|
||||||
|
|
||||||
|
**8. tag_match / schema_match 동등 비교 (향후)**
|
||||||
|
- 현재: tag 실패 시에만 schema fallback
|
||||||
|
- 향후: 둘 다 점수화해서 동등 비교
|
||||||
|
- schema_match를 block selection의 1급 기준으로 승격
|
||||||
|
|
||||||
|
### 의존 관계
|
||||||
|
```
|
||||||
|
Y-12 (payload/layout) → Y-13 (group schema) → Y-14 (popup 2단 표현)
|
||||||
|
MDX 03: Y-12로 충분 ✅ (fit 블록 있음, 회귀 검증 통과)
|
||||||
|
MDX 02: Y-13 분류 완료 ✅, 블록 선택 후 popup 경로 필요 (Y-14)
|
||||||
|
MDX 01: Type A — 별도 작업
|
||||||
|
```
|
||||||
307
samples/src/components/dx.astro
Normal file
307
samples/src/components/dx.astro
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
---
|
||||||
|
/* [dx2.astro] 격식 있는 비교표 스타일 */
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="formal-table">
|
||||||
|
<colgroup>
|
||||||
|
<col width="13%" />
|
||||||
|
<col width="29%" />
|
||||||
|
<col width="29%" />
|
||||||
|
<col width="30%" />
|
||||||
|
</colgroup>
|
||||||
|
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="category-header">구분</th>
|
||||||
|
<th class="stakeholder-header client">발주자</th>
|
||||||
|
<th class="stakeholder-header contractor">시공자</th>
|
||||||
|
<th class="stakeholder-header designer">설계자</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr class="highlight-row">
|
||||||
|
<td class="category-cell"><strong>필요 역량</strong></td>
|
||||||
|
<td class="center-text">실행 의지와 합리적 판단 역량</td>
|
||||||
|
<td class="center-text">기술 투자와 운영 역량</td>
|
||||||
|
<td class="center-text">기술개발 투자에 의한 S/W 역량</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="category-cell"
|
||||||
|
>수작업 의존 <br />→<br /> S/W 기반 체계화</td
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
행정서류 자동 생성 및 최소화로 <strong
|
||||||
|
>업무 생산성 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
건설기간 단축, 건설비 및 유지관리비 <strong
|
||||||
|
>총비용 최소화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
체계적 공정/자원 관리를 통한 <strong
|
||||||
|
>신뢰성 확보 및 생산성 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Model에서의 도면 추출로 쉽고 정확한
|
||||||
|
<strong>시공상세도 작성 용이</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
시스템 구축 시, 품질·안전·관리 등에 필요한
|
||||||
|
<strong>도서 작성 용이</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
SW기반 설계프로세스 체계화로 <strong
|
||||||
|
>설계 생산성 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
프로젝트 정보의 일관 유지 및 관리를 통한 <strong
|
||||||
|
>오류 최소화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
다양한 성과물과 정보물 활용으로 추가 <strong
|
||||||
|
>부가가치 창출</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="category-cell">2D<br />→<br />3D 기반 인지·검토</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
3D 모델을 통한 직관적 시각화로 <strong
|
||||||
|
>품질 향상 및 안전성 제고</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
건설단계별 수행상태에 대한 쉬운 이해로 관리 <strong
|
||||||
|
>편의성 증대</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li style="letter-spacing: -0.9px;">
|
||||||
|
직관적 시각화로 계획시공 등을 관리하여 <strong
|
||||||
|
>안전성 제고 및 품질 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
중간태, 완성태 측량을 통한<br />시·공간적 관리의 <strong
|
||||||
|
>편리성 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
3D 모델을 통한 확인/검증으로 설계
|
||||||
|
<strong>오류 최소화 및 Claim 예방</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="category-cell"
|
||||||
|
>문서 중심<br />→<br />데이터 통합 기반 협업</td
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
현장 실무자와 발주자의 원활한 의사소통으로 <strong
|
||||||
|
>오류 최소화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
디지털 환경 구축을 통한 건설 <strong
|
||||||
|
>정보 통합관리 활용성 강화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
불필요한 행정서류 감소를 통한 <strong
|
||||||
|
>협업 및 의사소통 효율 향상</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
설계 신뢰도 확보 및 발주자 <br />이익 기여로 <strong
|
||||||
|
>상호신뢰 증진</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="category-cell"
|
||||||
|
>사후 대응<br />→<br />사전 검증 중심 관리</td
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
설계변경, 민원, 재작업, 소송 등의 <strong
|
||||||
|
>사전 예방, 최소화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
설계 및 시공 오류 예방과 원활한 의사 소통으로 <strong
|
||||||
|
>공사 Risk 최소화</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
시공 전 설계검증 강화로<br />
|
||||||
|
<strong>설계 책임 리스크 감소</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* [테이블 기본 설정] */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto; /* 모바일에서 표가 잘리지 않고 스크롤되게 함 */
|
||||||
|
margin-top: 1.95rem;
|
||||||
|
font-family: "Pretendard", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse; /* 선 겹침 방지 */
|
||||||
|
border-top: 1px solid #333; /* 맨 위 굵은 선 */
|
||||||
|
border-bottom: 1px solid #333; /* 맨 아래 선 */
|
||||||
|
background: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
table-layout: fixed; /* 칸 너비 고정 */
|
||||||
|
min-width: 800px; /* 표가 너무 찌그러지지 않게 최소 너비 확보 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [헤더 스타일] - 주체별 색상 구분 */
|
||||||
|
th {
|
||||||
|
padding: 0.8rem;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
th:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
background: rgb(126, 126, 126);
|
||||||
|
} /* 구분: 회색 */
|
||||||
|
.client {
|
||||||
|
background: rgb(126, 126, 126);
|
||||||
|
} /* 발주자: 파랑 */
|
||||||
|
.contractor {
|
||||||
|
background: rgb(126, 126, 126);
|
||||||
|
} /* 시공자: 주황 */
|
||||||
|
.designer {
|
||||||
|
background: rgb(126, 126, 126);
|
||||||
|
} /* 설계자: 초록 */
|
||||||
|
|
||||||
|
/* [셀 스타일] */
|
||||||
|
td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #ddd; /* 가로 줄 */
|
||||||
|
border-left: 1px solid #eee; /* 세로 줄 (연하게) */
|
||||||
|
color: #333;
|
||||||
|
vertical-align: middle; /* 세로 중앙 정렬 */
|
||||||
|
word-break: keep-all; /* 단어 끊김 방지 */
|
||||||
|
}
|
||||||
|
td:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [1열: 구분] 스타일 */
|
||||||
|
.category-cell {
|
||||||
|
background: #f9f9f9;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [행: 필요 역량] 강조 스타일 */
|
||||||
|
.highlight-row td {
|
||||||
|
background: #f0f7ff; /* 아주 연한 파란색 배경 */
|
||||||
|
border-bottom: 2px solid #ddd; /* 구분선 강조 */
|
||||||
|
}
|
||||||
|
.highlight-row .center-text {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 텍스트 정렬 유틸리티 */
|
||||||
|
.center-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-gray {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [리스트 스타일] - 깔끔한 점 목록 */
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
list-style-type: disc; /* 기본 점 */
|
||||||
|
/* 👇 [핵심] 이 두 줄을 추가/수정 하세요! */
|
||||||
|
letter-spacing: -0.02em; /* 글자 사이를 좁혀서 더 많이 들어가게 함 */
|
||||||
|
word-break: keep-all; /* 단어가 중간에 끊기지 않게 함 */
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 강조 글씨 */
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
background: rgba(0, 0, 0, 0.05); /* 형광펜 효과 아주 연하게 */
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +1,457 @@
|
|||||||
"""유형 B'' 조립 함수 — 참고 이미지 스타일 (border 없음, 색상바+여백으로 구분)."""
|
"""유형 B'' 조립 함수 — slide-base.html + 블록 템플릿 사용.
|
||||||
|
|
||||||
|
변경 이력:
|
||||||
|
- 기존: f-string 하드코딩 HTML
|
||||||
|
- 현재: slide-base.html 래핑 + templates/blocks/ 블록 Jinja2 렌더링 + font_hierarchy 적용
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
- 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정)
|
||||||
|
- 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님)
|
||||||
|
- 블록은 콘텐츠에 맞게 재구성 (items 수 동적)
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.pipeline_context import PipelineContext
|
from src.pipeline_context import PipelineContext
|
||||||
|
|
||||||
|
BLOCKS_DIR = Path("templates/blocks")
|
||||||
|
SVG_DIR = BLOCKS_DIR / "svg"
|
||||||
|
|
||||||
|
_env = Environment(loader=FileSystemLoader(str(BLOCKS_DIR)), autoescape=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _img_b64(filename: str) -> str:
|
||||||
|
"""SVG/PNG → data URI."""
|
||||||
|
p = SVG_DIR / filename
|
||||||
|
if not p.exists():
|
||||||
|
return ""
|
||||||
|
ext = "svg+xml" if filename.endswith(".svg") else "png"
|
||||||
|
return f"data:image/{ext};base64," + base64.b64encode(p.read_bytes()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_comments(html: str) -> str:
|
||||||
|
return re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_slide_base(title: str, body_html: str, footer_text: str) -> str:
|
||||||
|
"""slide-base.html로 래핑. 공통 함수."""
|
||||||
|
sb = _strip_comments((BLOCKS_DIR / "slide-base.html").read_text(encoding="utf-8"))
|
||||||
|
r = sb.replace('{{ title|default("슬라이드") }}', title)
|
||||||
|
r = r.replace('{{ title|default("슬라이드 제목") }}', title)
|
||||||
|
r = r.replace('{% block body %}{% endblock %}', body_html)
|
||||||
|
|
||||||
|
pill = _img_b64("pill_scroll.png")
|
||||||
|
r = r.replace('{% if footer_text %}', '').replace('{% if footer_pill_bg %}', '')
|
||||||
|
r = r.replace('{{ footer_pill_bg }}', pill).replace('{% else %}', '')
|
||||||
|
r = r.replace('<div class="slide-footer-bg slide-footer--css"></div>', '')
|
||||||
|
li = r.rfind('{% endif %}')
|
||||||
|
if li > 0:
|
||||||
|
r = r[:li] + r[li + len('{% endif %}'):]
|
||||||
|
r = r.replace('{% endif %}', '').replace('{{ footer_text|safe }}', footer_text)
|
||||||
|
r = r.replace('src="svg/bg_slide_texture.png"', f'src="{_img_b64("bg_slide_texture.png")}"')
|
||||||
|
r = r.replace('src="svg/line_divider.svg"', f'src="{_img_b64("line_divider.svg")}"')
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str:
|
def _assemble_slide_html_type_b_double_prime(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||||
"""유형 B'' - 참고 이미지 스타일.
|
"""유형 B'' — slide-base.html + 블록 템플릿 + font_hierarchy.
|
||||||
|
|
||||||
border/gradient 박스 없음. 색상 바 + 폰트 크기 + 여백으로 구분.
|
블록 선택: PipelineContext.references에서 가져옴.
|
||||||
|
콘텐츠: PipelineContext.normalized.sections + structured_text에서 가져옴.
|
||||||
|
글씨 크기: font_hierarchy(core/bg/sidebar/key_msg)에서 가져옴.
|
||||||
"""
|
"""
|
||||||
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_h = ctx.font_hierarchy
|
||||||
font_size = font_h.core
|
|
||||||
title = title_text or ctx.analysis.title or ""
|
title = title_text or ctx.analysis.title or ""
|
||||||
core_message = ctx.analysis.core_message or ""
|
core_message = ctx.analysis.core_message or ""
|
||||||
|
ps = ctx.page_structure.roles
|
||||||
norm_sections = ctx.normalized.sections or []
|
norm_sections = ctx.normalized.sections or []
|
||||||
|
norm_tables = ctx.normalized.tables or []
|
||||||
|
enh = ctx.enhancement_result or {}
|
||||||
|
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||||
|
|
||||||
kei_decisions = enh.get("kei_decisions", [])
|
# zone 분류
|
||||||
popup_roles = set()
|
zones = {}
|
||||||
for d in kei_decisions:
|
|
||||||
if d.get("action") == "popup":
|
|
||||||
popup_roles.add(d.get("role", ""))
|
|
||||||
|
|
||||||
top_role = bottom_left_role = bottom_right_role = footer_role = None
|
|
||||||
for role_name, info in ps.items():
|
for role_name, info in ps.items():
|
||||||
if not isinstance(info, dict):
|
if isinstance(info, dict):
|
||||||
continue
|
zones[info.get("zone", "")] = (role_name, info)
|
||||||
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)
|
|
||||||
|
|
||||||
footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
|
top_role = zones.get("top")
|
||||||
footer_h_px = footer_ci.height_px if footer_ci else 53
|
bl_role = zones.get("bottom_left")
|
||||||
ft_top = slide_h - pad - footer_h_px
|
br_role = zones.get("bottom_right")
|
||||||
top_ci = ctx.containers.get(top_role[0]) if top_role else None
|
footer_role = zones.get("footer")
|
||||||
top_h = top_ci.height_px if top_ci else 200
|
|
||||||
top_top = pad + header_h + gap_block
|
|
||||||
bottom_top = top_top + top_h + gap_small
|
|
||||||
bottom_h = ft_top - gap_block - bottom_top
|
|
||||||
|
|
||||||
def _bold(text, role):
|
def _bold(text, role=""):
|
||||||
for kw in bold_kw.get(role, []):
|
for kw in bold_kw.get(role, []):
|
||||||
if kw in text:
|
if kw in text:
|
||||||
text = text.replace(kw, f"<strong>{kw}</strong>")
|
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
# 색상 (참고 이미지 기반)
|
# ── 상단: 블록 레퍼런스에서 block_id 확인 → 블록 템플릿 렌더링 ──
|
||||||
bar_colors = ["#2d5016", "#5c3d1a", "#1a365d"]
|
top_html = _render_top_zone(ctx, norm_sections, font_h, _bold)
|
||||||
accent = "#c05621"
|
|
||||||
|
|
||||||
# ── 상단 ──
|
# ── 하단: process-product-2col 또는 블록 레퍼런스 기반 ──
|
||||||
top_html = ""
|
bottom_html = _render_bottom_zone(ctx, norm_sections, norm_tables, font_h, _bold)
|
||||||
if top_role:
|
|
||||||
rn = top_role[0]
|
# ── font_hierarchy CSS override ──
|
||||||
topic_title = ""
|
font_css = f"""<style>
|
||||||
top_secs = [] # [(title, [(depth, text)])]
|
/* font_hierarchy: key_msg={font_h.key_msg}px, core={font_h.core}px, bg={font_h.bg}px, sidebar={font_h.sidebar}px */
|
||||||
cur_title = ""
|
.p3c-heading {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; }}
|
||||||
cur_items = []
|
.p3c-desc {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; }}
|
||||||
for s in norm_sections:
|
.p3c-desc .bul {{ padding-left: 12px; text-indent: -12px; }}
|
||||||
if s.get("level") == 3:
|
.p3c-vlabel {{ font-size: {font_h.key_msg}px !important; }}
|
||||||
break
|
.p3c-vlabel-sub {{ font-size: {font_h.core}px !important; }}
|
||||||
if not topic_title and s.get("title"):
|
.p3c-kanji {{ display: none !important; }}
|
||||||
topic_title = s["title"]
|
.p3c-vlabel-area {{ width: 56px !important; }}
|
||||||
content = s.get("content", "")
|
.p3c-section {{ left: 60px !important; right: 6px !important; }}
|
||||||
if not content:
|
.p3c-mid-line {{ left: 56px !important; }}
|
||||||
continue
|
.p3c-col {{ min-height: 0 !important; height: 100% !important; }}
|
||||||
st = s.get("title", "")
|
.block-p3c {{ height: 100% !important; }}
|
||||||
if st and st != topic_title:
|
.pp2-header-text {{ font-size: {font_h.core + 1}px !important; font-weight: 900 !important; }}
|
||||||
if cur_title:
|
.pp2-header-text--right {{ color: #ffffff !important; }}
|
||||||
top_secs.append((cur_title, cur_items))
|
.pp2-mid-title {{ font-size: {font_h.core}px !important; line-height: 1.5 !important; margin-top: 4px !important; }}
|
||||||
cur_title = st
|
.pp2-mid-title:first-child {{ margin-top: 0 !important; }}
|
||||||
cur_items = []
|
.pp2-body-text {{ font-size: {font_h.sidebar}px !important; line-height: 1.6 !important; padding-left: 12px !important; text-indent: -12px !important; font-weight: 500 !important; }}
|
||||||
for line in content.split("\n"):
|
</style>"""
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped:
|
# ── zone 제목 추출 ──
|
||||||
continue
|
# 상단: 첫 번째 level=2 (콘텐츠 없는 대제목)
|
||||||
if re.search(r'\[팝업:', stripped):
|
# 하단: level=3 직전의 level=2 (하단 대제목)
|
||||||
continue
|
top_zone_title = ""
|
||||||
if re.search(r'\[이미지:', stripped) or re.match(r'^!\[', stripped):
|
bottom_zone_title = ""
|
||||||
continue
|
for i, s in enumerate(norm_sections):
|
||||||
if re.search(r'\[핵심요약:', stripped):
|
if s.get("level") == 2:
|
||||||
|
if not s.get("content", "").strip():
|
||||||
|
# 콘텐츠 없는 level=2 = zone 제목
|
||||||
|
# 다음 section이 level=3이면 하단 제목
|
||||||
|
if i + 1 < len(norm_sections) and norm_sections[i + 1].get("level") == 3:
|
||||||
|
bottom_zone_title = s.get("title", "")
|
||||||
|
elif not top_zone_title:
|
||||||
|
top_zone_title = s.get("title", "")
|
||||||
|
|
||||||
|
# ── 조립 ──
|
||||||
|
body = f"""{font_css}
|
||||||
|
<div style="height:38%;margin-bottom:1%;padding-top:8px;">
|
||||||
|
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
|
||||||
|
{top_zone_title}
|
||||||
|
</div>
|
||||||
|
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{top_html}</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:60%;margin-top:12px;">
|
||||||
|
<div style="font-weight:700;font-size:{font_h.core + 1}px;color:#1a365d;margin-bottom:8px;">
|
||||||
|
{bottom_zone_title}
|
||||||
|
</div>
|
||||||
|
<div style="height:calc(100% - 28px);padding:0 12px 0 24px;">{bottom_html}</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
footer_text_html = f'{core_message}'.replace(
|
||||||
|
'기대할 수 있다', '<em>기대할 수 있다</em>'
|
||||||
|
) if core_message else ""
|
||||||
|
|
||||||
|
return _render_slide_base(title, body, footer_text_html)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_zone_title(sections, level=2, index=0):
|
||||||
|
"""normalized.sections에서 level=N인 제목을 index번째 가져옴."""
|
||||||
|
count = 0
|
||||||
|
for s in sections:
|
||||||
|
if s.get("level") == level:
|
||||||
|
if count == index:
|
||||||
|
return s.get("title", "")
|
||||||
|
count += 1
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_top_zone(ctx, sections, font_h, bold_fn):
|
||||||
|
"""상단 zone 렌더링 — normalized sections의 level=2 카테고리를 직접 사용."""
|
||||||
|
# 상단 topic_ids에 해당하는 sections 가져오기
|
||||||
|
ps = ctx.page_structure.roles
|
||||||
|
top_zone = None
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if isinstance(info, dict) and info.get("zone") == "top":
|
||||||
|
top_zone = (role_name, info)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not top_zone:
|
||||||
|
return "<div>상단 zone 없음</div>"
|
||||||
|
|
||||||
|
top_topic_ids = top_zone[1].get("topic_ids", [])
|
||||||
|
topic_map = {t.id: t for t in ctx.topics}
|
||||||
|
|
||||||
|
# 각 topic의 structured_text 또는 normalized section에서 콘텐츠 가져오기
|
||||||
|
categories = []
|
||||||
|
for tid in top_topic_ids:
|
||||||
|
topic = topic_map.get(tid)
|
||||||
|
if not topic:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cat_name = topic.title or ""
|
||||||
|
# structured_text 우선, 없으면 normalized sections에서 찾기
|
||||||
|
content = topic.structured_text or ""
|
||||||
|
if not content:
|
||||||
|
for s in sections:
|
||||||
|
if s.get("title") == cat_name and s.get("content"):
|
||||||
|
content = s["content"]
|
||||||
break
|
break
|
||||||
depth = 0
|
|
||||||
dm = re.match(r'^D(\d+):\s*', stripped)
|
|
||||||
if dm:
|
|
||||||
depth = int(dm.group(1))
|
|
||||||
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
|
||||||
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
|
|
||||||
cur_items.append((depth, stripped))
|
|
||||||
if cur_title:
|
|
||||||
top_secs.append((cur_title, cur_items))
|
|
||||||
|
|
||||||
cards = ""
|
if not content:
|
||||||
for ci_idx, (st, items) in enumerate(top_secs):
|
continue
|
||||||
bc = bar_colors[ci_idx % len(bar_colors)]
|
|
||||||
card = f'<div style="flex:1;">'
|
# D1/D2 마커 기반 파싱
|
||||||
card += (
|
headings = []
|
||||||
f'<div style="background:{bc};color:#fff;font-weight:700;'
|
current_heading = None
|
||||||
f'font-size:{font_size}px;padding:4px 10px;margin-bottom:4px;">'
|
for line in content.split("\n"):
|
||||||
f'{_bold(st, rn)}</div>'
|
stripped = line.strip()
|
||||||
)
|
if not stripped:
|
||||||
for depth, text in items:
|
continue
|
||||||
text = _bold(text, rn)
|
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
|
||||||
if depth <= 1 and '<strong>' in text:
|
|
||||||
card += (
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
f'<div style="padding-left:{int(font_size * 0.8)}px;margin-bottom:2px;'
|
depth = int(dm.group(1)) if dm else 0
|
||||||
f'font-size:{font_size - 1}px;color:{accent};font-weight:600;">'
|
if dm:
|
||||||
f'{text}</div>'
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
)
|
clean = stripped.lstrip("•- ").strip()
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if depth <= 1 and '<strong>' in clean:
|
||||||
|
current_heading = {"title": clean, "bullets": []}
|
||||||
|
headings.append(current_heading)
|
||||||
|
else:
|
||||||
|
if current_heading:
|
||||||
|
current_heading["bullets"].append(clean)
|
||||||
|
elif headings:
|
||||||
|
headings[-1]["bullets"].append(clean)
|
||||||
else:
|
else:
|
||||||
card += (
|
headings.append({"title": "", "bullets": [clean]})
|
||||||
f'<div style="padding-left:{int(font_size * 1.5)}px;margin-bottom:1px;'
|
|
||||||
f'font-size:{font_size - 2}px;color:#333;">'
|
|
||||||
f'\u2022 {text}</div>'
|
|
||||||
)
|
|
||||||
card += '</div>'
|
|
||||||
cards += card
|
|
||||||
|
|
||||||
top_html = (
|
categories.append({"name": cat_name, "headings": headings})
|
||||||
f'<div style="height:100%;padding:{gap_small}px;">'
|
import logging
|
||||||
f'<div style="font-weight:700;font-size:{font_size + 2}px;color:#1a365d;margin-bottom:6px;">'
|
logging.getLogger(__name__).info(f"[B'' top] cat={cat_name}, headings={len(headings)}")
|
||||||
f'{_bold(topic_title or rn, rn)}</div>'
|
|
||||||
f'<div style="display:flex;gap:{gap_small}px;flex:1;">{cards}</div></div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 하단 ──
|
if not categories:
|
||||||
bottom_title = ""
|
return "<div>콘텐츠 없음</div>"
|
||||||
|
|
||||||
|
# 블록 CSS 가져오기
|
||||||
|
p3c_raw = (BLOCKS_DIR / "new" / "prerequisites-3col.html").read_text(encoding="utf-8")
|
||||||
|
p3c_css = re.search(r'<style>(.*?)</style>', p3c_raw, re.DOTALL)
|
||||||
|
css_html = p3c_css.group(0) if p3c_css else ""
|
||||||
|
|
||||||
|
# 동적 열 생성
|
||||||
|
bar_gradients = [
|
||||||
|
"linear-gradient(180deg, #0D78D0 0%, #023056 100%)",
|
||||||
|
"linear-gradient(180deg, #FF9A23 0%, #CC5200 100%)",
|
||||||
|
"linear-gradient(180deg, #39BE49 0%, #23742C 100%)",
|
||||||
|
"linear-gradient(180deg, #7c3aed 0%, #4c1d95 100%)",
|
||||||
|
]
|
||||||
|
heading_gradients = [
|
||||||
|
"linear-gradient(180deg, #0D78D0 0%, #134D7F 100%)",
|
||||||
|
"linear-gradient(180deg, #CC5200 0%, #883700 100%)",
|
||||||
|
"linear-gradient(180deg, #39BE49 0%, #1E6328 100%)",
|
||||||
|
"linear-gradient(180deg, #7c3aed 0%, #5b21b6 100%)",
|
||||||
|
]
|
||||||
|
|
||||||
|
cols_html = ""
|
||||||
|
for ci, cat in enumerate(categories):
|
||||||
|
# 카테고리명에서 "기술(디지털)" → name="기술", sub="디지털"
|
||||||
|
name_match = re.match(r'^(.+?)[((](.+?)[))]$', cat["name"])
|
||||||
|
if name_match:
|
||||||
|
name, sub = name_match.group(1), name_match.group(2)
|
||||||
|
else:
|
||||||
|
name, sub = cat["name"], ""
|
||||||
|
|
||||||
|
bar = bar_gradients[ci % len(bar_gradients)]
|
||||||
|
hgrad = heading_gradients[ci % len(heading_gradients)]
|
||||||
|
|
||||||
|
# 항목 HTML — 동적 items 수
|
||||||
|
items = cat["headings"]
|
||||||
|
n = max(len(items), 1)
|
||||||
|
items_html = ""
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
if not item["title"] and not item["bullets"]:
|
||||||
|
continue
|
||||||
|
pct_h = int(95 / n)
|
||||||
|
pct_top = int(3 + i * (95 / n))
|
||||||
|
bul = "".join(f'<div class="bul">• {b}</div>' for b in item["bullets"])
|
||||||
|
items_html += f"""
|
||||||
|
<div class="p3c-section" style="position:absolute;left:60px;right:6px;top:{pct_top}%;height:{pct_h}%;">
|
||||||
|
<div class="p3c-heading" style="background-image:{hgrad}">{item['title']}</div>
|
||||||
|
<div class="p3c-desc">{bul}</div>
|
||||||
|
</div>"""
|
||||||
|
if i < n - 1 and len(items) > 1:
|
||||||
|
line_top = pct_top + pct_h
|
||||||
|
items_html += f'<div class="p3c-mid-line" style="position:absolute;left:56px;right:0;top:{line_top}%;border-top:1.2px dashed #000;"></div>'
|
||||||
|
|
||||||
|
cols_html += f"""
|
||||||
|
<div class="p3c-col" style="flex:1;position:relative;height:100%;border-top:1.2px solid #000;border-bottom:1.2px solid #000;">
|
||||||
|
<div class="p3c-bar" style="background:{bar};position:absolute;left:0;top:0;width:56px;height:100%;"></div>
|
||||||
|
<div class="p3c-vlabel-area" style="position:absolute;left:0;top:0;width:56px;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;z-index:3;">
|
||||||
|
<div class="p3c-vlabel">{name}</div>
|
||||||
|
{'<div class="p3c-vlabel-sub">' + sub + '</div>' if sub else ''}
|
||||||
|
</div>
|
||||||
|
{items_html}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
return f'<div class="block-p3c" style="display:flex;gap:12px;width:100%;height:100%;">{cols_html}</div>\n{css_html}'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_bottom_zone(ctx, sections, tables, font_h, bold_fn):
|
||||||
|
"""하단 zone 렌더링 — 좌우 2분할, 소제목 행 정렬."""
|
||||||
|
# 하단 콘텐츠: level=3인 sections
|
||||||
sub_secs = []
|
sub_secs = []
|
||||||
for s in norm_sections:
|
for s in sections:
|
||||||
if s.get("level") == 3:
|
if s.get("level") == 3:
|
||||||
sub_secs.append((s.get("title", ""), s.get("content", "")))
|
sub_secs.append((s.get("title", ""), s.get("content", "")))
|
||||||
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
|
|
||||||
|
|
||||||
norm_tables = ctx.normalized.tables or []
|
if not sub_secs:
|
||||||
|
return "<div>하단 콘텐츠 없음</div>"
|
||||||
|
|
||||||
|
# 좌/우 분리 (첫 번째 sub_sec가 좌, 두 번째가 우)
|
||||||
|
left_title = sub_secs[0][0] if sub_secs else ""
|
||||||
|
right_title = sub_secs[1][0] if len(sub_secs) > 1 else ""
|
||||||
|
|
||||||
|
# 좌측 소제목+불릿 파싱
|
||||||
|
left_items = _parse_sub_content(sub_secs[0][1] if sub_secs else "", tables, bold_fn)
|
||||||
|
right_items = _parse_sub_content(sub_secs[1][1] if len(sub_secs) > 1 else "", [], bold_fn)
|
||||||
|
|
||||||
|
# 좌우 소제목 행 매칭
|
||||||
|
max_rows = max(len(left_items), len(right_items))
|
||||||
|
while len(left_items) < max_rows:
|
||||||
|
left_items.append(("", []))
|
||||||
|
while len(right_items) < max_rows:
|
||||||
|
right_items.append(("", []))
|
||||||
|
|
||||||
|
# 블록 CSS
|
||||||
|
pp2_raw = (BLOCKS_DIR / "BEPs" / "process-product-2col.html").read_text(encoding="utf-8")
|
||||||
|
pp2_css = re.search(r'<style>(.*?)</style>', pp2_raw, re.DOTALL)
|
||||||
|
css_html = pp2_css.group(0) if pp2_css else ""
|
||||||
|
|
||||||
|
arrow_uri = _img_b64("arrow_asis_tobe.png")
|
||||||
|
|
||||||
|
# Grid 생성 — 행 높이 동기화 + 전체 열 gradient
|
||||||
|
rows_html = ""
|
||||||
|
for i, ((lt, lbullets), (rt, rbullets)) in enumerate(zip(left_items, right_items)):
|
||||||
|
pad = "3px 16px" if i == 0 else "2px 16px"
|
||||||
|
|
||||||
|
# 좌측
|
||||||
|
left_cell = f'<div style="padding:{pad};">'
|
||||||
|
if lt:
|
||||||
|
left_cell += f'<div class="pp2-mid-title pp2-mid-title--left">{lt}</div>'
|
||||||
|
# 테이블 (As-is → To-be) 이 있으면 첫 번째 행에 삽입
|
||||||
|
if i == 0 and tables:
|
||||||
|
left_cell += _render_compare_table(tables[0], arrow_uri, font_h)
|
||||||
|
for b in lbullets:
|
||||||
|
left_cell += f'<div class="pp2-body-text">• {b}</div>'
|
||||||
|
left_cell += '</div>'
|
||||||
|
|
||||||
|
# 우측
|
||||||
|
right_cell = f'<div style="padding:{pad};">'
|
||||||
|
if rt:
|
||||||
|
right_cell += f'<div class="pp2-mid-title pp2-mid-title--right">{rt}</div>'
|
||||||
|
for b in rbullets:
|
||||||
|
right_cell += f'<div class="pp2-body-text">• {b}</div>'
|
||||||
|
right_cell += '</div>'
|
||||||
|
|
||||||
|
rows_html += left_cell + right_cell
|
||||||
|
|
||||||
|
# 헤더
|
||||||
|
header_html = f"""
|
||||||
|
<div class="pp2-header-bar pp2-header-bar--left" style="background:linear-gradient(270deg,#a4a096 0%,#39311e 100%);border-radius:0 24px 24px 0;display:flex;align-items:center;justify-content:center;height:30px;margin-top:4px;">
|
||||||
|
<span class="pp2-header-text pp2-header-text--left" style="color:#3e3523;">{left_title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp2-header-bar pp2-header-bar--right" style="background:linear-gradient(90deg,#296b55 0%,#022017 100%);border-radius:24px 0 0 24px;display:flex;align-items:center;padding-left:20px;height:30px;margin-top:4px;">
|
||||||
|
<span class="pp2-header-text pp2-header-text--right">{right_title}</span>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div style="position:relative;width:100%;height:100%;">
|
||||||
|
<div style="position:absolute;left:0;top:0;width:50%;height:100%;background:linear-gradient(180deg,#ffffff 46%,#39311e 100%);z-index:0;"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:0;width:50%;height:100%;background:linear-gradient(0deg,#296b55 0%,#ffffff 56%);z-index:0;"></div>
|
||||||
|
<div style="position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr;width:100%;height:100%;">
|
||||||
|
{header_html}
|
||||||
|
{rows_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{css_html}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sub_content(content, tables, bold_fn):
|
||||||
|
"""하위 콘텐츠를 소제목+불릿 리스트로 파싱."""
|
||||||
|
content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', content)
|
||||||
|
items = []
|
||||||
|
current_title = ""
|
||||||
|
current_bullets = []
|
||||||
|
|
||||||
|
# 테이블 텍스트 (중복 제거용)
|
||||||
table_texts = set()
|
table_texts = set()
|
||||||
for td in norm_tables:
|
for td in tables:
|
||||||
for h in td.get("headers", []):
|
for h in td.get("headers", []):
|
||||||
table_texts.add(h.strip().lstrip("*").rstrip("*"))
|
table_texts.add(h.strip().lstrip("*").rstrip("*"))
|
||||||
for row in td.get("rows", []):
|
for row in td.get("rows", []):
|
||||||
for c in row:
|
for c in row:
|
||||||
table_texts.add(str(c).strip().lstrip("*").rstrip("*"))
|
table_texts.add(str(c).strip().lstrip("*").rstrip("*"))
|
||||||
|
|
||||||
def _render_section(sub_title, sub_content, rn, bar_color, include_table=False):
|
for line in content.split("\n"):
|
||||||
sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)
|
stripped = line.strip()
|
||||||
html = (
|
if not stripped:
|
||||||
f'<div style="height:100%;padding:{gap_small}px;">'
|
continue
|
||||||
f'<div style="background:{bar_color};color:#fff;font-weight:700;font-size:{font_size}px;'
|
|
||||||
f'padding:4px 10px;margin-bottom:6px;">{_bold(sub_title, rn)}</div>'
|
|
||||||
)
|
|
||||||
# 표
|
|
||||||
if include_table and norm_tables:
|
|
||||||
for td in norm_tables:
|
|
||||||
headers = td.get("headers", [])
|
|
||||||
rows = td.get("rows", [])
|
|
||||||
if headers and rows:
|
|
||||||
col_count = len(headers)
|
|
||||||
h_cells = "".join(
|
|
||||||
f'<th style="padding:3px 6px;font-size:{font_size - 2}px;background:{bar_color};'
|
|
||||||
f'color:#fff;border:1px solid #ccc;text-align:center;">{h}</th>'
|
|
||||||
for h in headers
|
|
||||||
)
|
|
||||||
r_html = ""
|
|
||||||
for ri, row in enumerate(rows):
|
|
||||||
bg = "#f5f5f0" if ri % 2 == 0 else "#fff"
|
|
||||||
cells = "".join(
|
|
||||||
f'<td style="padding:3px 6px;font-size:{font_size - 2}px;border:1px solid #ddd;background:{bg};">'
|
|
||||||
f'{re.sub(r"\\*\\*(.+?)\\*\\*", r"<strong>\\1</strong>", str(c))}</td>'
|
|
||||||
for c in row
|
|
||||||
)
|
|
||||||
r_html += f'<tr>{cells}</tr>'
|
|
||||||
html += f'<table style="border-collapse:collapse;width:100%;margin-bottom:6px;"><tr>{h_cells}</tr>{r_html}</table>'
|
|
||||||
# 불릿
|
|
||||||
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("\u2022 ")
|
|
||||||
clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
|
|
||||||
if clean_plain in table_texts or clean_plain == "\u27a0":
|
|
||||||
continue
|
|
||||||
if re.search(r'\[핵심요약:', clean):
|
|
||||||
break
|
|
||||||
if not clean:
|
|
||||||
continue
|
|
||||||
clean = _bold(clean, rn)
|
|
||||||
if depth == 1 and '<strong>' in clean:
|
|
||||||
html += (
|
|
||||||
f'<div style="margin-bottom:2px;font-size:{font_size - 1}px;'
|
|
||||||
f'color:{accent};font-weight:600;">{clean}</div>'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
html += (
|
|
||||||
f'<div style="padding-left:{int(font_size * 1.2)}px;margin-bottom:1px;'
|
|
||||||
f'font-size:{font_size - 2}px;color:#333;">\u2022 {clean}</div>'
|
|
||||||
)
|
|
||||||
html += '</div>'
|
|
||||||
return html
|
|
||||||
|
|
||||||
bl_html = ""
|
# D마커
|
||||||
if sub_secs and bottom_left_role:
|
dm = re.match(r'^D(\d+):\s*', stripped)
|
||||||
rn = bottom_left_role[0]
|
if dm:
|
||||||
bl_html = _render_section(sub_secs[0][0], sub_secs[0][1], rn, bar_colors[0], include_table=True)
|
stripped = re.sub(r'^D\d+:\s*', '', stripped)
|
||||||
|
|
||||||
br_html = ""
|
clean = stripped.lstrip("•- ").strip()
|
||||||
if bottom_right_role and len(sub_secs) > 1:
|
clean_plain = re.sub(r'<[^>]+>', '', clean).strip()
|
||||||
rn = bottom_right_role[0]
|
|
||||||
br_html = _render_section(sub_secs[1][0], sub_secs[1][1], rn, bar_colors[1], include_table=False)
|
|
||||||
|
|
||||||
# 결론
|
if clean_plain in table_texts or clean_plain == "➠":
|
||||||
footer_html = ""
|
continue
|
||||||
if footer_role:
|
if re.search(r'\[핵심요약:', clean):
|
||||||
rn = footer_role[0]
|
break
|
||||||
footer_html = (
|
if not clean:
|
||||||
f'<div style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
|
continue
|
||||||
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>'
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
# 소제목 감지 (볼드)
|
||||||
<style>
|
if '<strong>' in clean and len(clean) < 80:
|
||||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
if current_title or current_bullets:
|
||||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
items.append((current_title, current_bullets))
|
||||||
</style></head><body>
|
current_title = clean
|
||||||
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
current_bullets = []
|
||||||
|
else:
|
||||||
|
current_bullets.append(clean)
|
||||||
|
|
||||||
<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>
|
if current_title or current_bullets:
|
||||||
|
items.append((current_title, current_bullets))
|
||||||
|
|
||||||
<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;overflow:hidden;">
|
return items
|
||||||
{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;overflow:hidden;">
|
|
||||||
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding-bottom:4px;border-bottom:2px solid #1a365d;margin-bottom:4px;">{_bold(bottom_title, "")}</div>
|
|
||||||
<div style="display:flex;height:calc(100% - {int(font_size * 1.5 + 10)}px);">
|
|
||||||
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
|
|
||||||
{bl_html}</div>
|
|
||||||
<div style="width:1px;background:#cbd5e1;flex-shrink:0;margin:0 {gap_small}px;"></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;">
|
def _render_compare_table(table_data, arrow_uri, font_h):
|
||||||
{footer_html}</div>
|
"""As-is → To-be 비교 테이블 렌더링."""
|
||||||
|
headers = table_data.get("headers", [])
|
||||||
|
rows = table_data.get("rows", [])
|
||||||
|
if not headers or not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
</div></body></html>"""
|
def _clean_md(text):
|
||||||
|
"""**볼드** 마크다운 제거 — 테이블 셀은 일반 텍스트."""
|
||||||
|
return re.sub(r'\*\*(.+?)\*\*', r'\1', str(text))
|
||||||
|
|
||||||
|
html = '<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">'
|
||||||
|
html += '<div style="flex:1;">'
|
||||||
|
for row in rows:
|
||||||
|
html += f'<div class="pp2-body-text">• {_clean_md(row[0])}</div>'
|
||||||
|
html += '</div>'
|
||||||
|
html += f'<div style="flex-shrink:0;width:30px;text-align:center;"><img src="{arrow_uri}" style="width:30px;height:16px;object-fit:contain;" alt="→"></div>'
|
||||||
|
html += '<div style="flex:1;">'
|
||||||
|
for row in rows:
|
||||||
|
val = row[2] if len(row) > 2 else ""
|
||||||
|
html += f'<div class="pp2-body-text">• {_clean_md(val)}</div>'
|
||||||
|
html += '</div></div>'
|
||||||
|
return html
|
||||||
|
|||||||
@@ -149,25 +149,128 @@ def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
|
|||||||
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
|
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_by_tags(
|
||||||
|
catalog: list[dict],
|
||||||
|
topic_count: int,
|
||||||
|
topic_titles: list[str],
|
||||||
|
container_height_px: int,
|
||||||
|
zone: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""catalog의 tags 필드로 콘텐츠 패턴 매칭.
|
||||||
|
|
||||||
|
매칭 기준 (AND 조건):
|
||||||
|
1. item_count가 topic_count와 일치 (필수)
|
||||||
|
2. content_example에 topic 제목 키워드 포함 (가산)
|
||||||
|
3. container 크기 적합 (감점)
|
||||||
|
|
||||||
|
threshold: item_count 매칭(필수) + content_example 매칭 1개 이상
|
||||||
|
"""
|
||||||
|
if topic_count <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
|
||||||
|
for block in catalog:
|
||||||
|
tags = block.get("tags", {})
|
||||||
|
if not tags:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_count_matched = False
|
||||||
|
content_matched = 0
|
||||||
|
|
||||||
|
# item_count 매칭
|
||||||
|
tag_item_count = tags.get("item_count")
|
||||||
|
if tag_item_count:
|
||||||
|
try:
|
||||||
|
if int(tag_item_count) == topic_count:
|
||||||
|
item_count_matched = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parts = str(tag_item_count).split("-")
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
lo, hi = int(parts[0]), int(parts[1])
|
||||||
|
if lo <= topic_count <= hi:
|
||||||
|
item_count_matched = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not item_count_matched:
|
||||||
|
continue # item_count 안 맞으면 스킵
|
||||||
|
|
||||||
|
# content_example에 topic 제목 키워드 포함되는지
|
||||||
|
score = 50 # item_count 매칭 기본점
|
||||||
|
content_example = tags.get("content_example", "").lower()
|
||||||
|
if content_example:
|
||||||
|
for t in topic_titles:
|
||||||
|
key = t.split("(")[0].strip().lower()
|
||||||
|
if key and len(key) >= 2 and key in content_example:
|
||||||
|
content_matched += 1
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# source_mdx 매칭
|
||||||
|
if tags.get("source_mdx"):
|
||||||
|
score += 3
|
||||||
|
|
||||||
|
# Y-11c: shape 특성 가산점
|
||||||
|
# redesign 블록 (특화) > 범용 블록
|
||||||
|
if block.get("category") == "redesign":
|
||||||
|
score += 5
|
||||||
|
# 비교 표가 있는 블록은 비대칭 구조에서 우선
|
||||||
|
if tags.get("has_compare_table"):
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
# threshold: item_count 매칭 + content_example 1개 이상 매칭
|
||||||
|
if content_matched >= 1:
|
||||||
|
scored.append((score, block))
|
||||||
|
logger.info(
|
||||||
|
f"[T-3 tag] {block['id']}: score={score} "
|
||||||
|
f"(content_matched={content_matched}/{len(topic_titles)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
return scored[0][1]
|
||||||
|
|
||||||
|
|
||||||
def select_reference_block(
|
def select_reference_block(
|
||||||
relation_type: str,
|
relation_type: str,
|
||||||
expression_hint: str,
|
expression_hint: str,
|
||||||
container_height_px: int,
|
container_height_px: int,
|
||||||
zone: str = "body",
|
zone: str = "body",
|
||||||
role: str = "",
|
role: str = "",
|
||||||
|
topic_count: int = 0,
|
||||||
|
topic_titles: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback).
|
"""참고 블록 선택 (tag 매칭 → relation_type → fallback).
|
||||||
|
|
||||||
|
1순위: catalog tags의 content_pattern/item_count로 정확 매칭
|
||||||
|
2순위: relation_type → 카테고리 필터
|
||||||
|
3순위: fallback
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"block_id": str,
|
"block_id": str,
|
||||||
"variant": str,
|
"variant": str,
|
||||||
"visual_type": str,
|
"visual_type": str,
|
||||||
"catalog_entry": dict, # catalog.yaml의 해당 블록 전체
|
"catalog_entry": dict,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
catalog = _load_catalog()
|
catalog = _load_catalog()
|
||||||
|
|
||||||
|
# ══ 0순위: tag 기반 정확 매칭 ══
|
||||||
|
tag_match = _match_by_tags(catalog, topic_count, topic_titles or [], container_height_px, zone)
|
||||||
|
if tag_match:
|
||||||
|
logger.info(f"[T-3] tag 매칭 성공: {tag_match['id']} (content_pattern={tag_match.get('tags',{}).get('content_pattern','')})")
|
||||||
|
variant = "default"
|
||||||
|
return {
|
||||||
|
"block_id": tag_match["id"],
|
||||||
|
"variant": variant,
|
||||||
|
"visual_type": "tag_match",
|
||||||
|
"catalog_entry": tag_match,
|
||||||
|
}
|
||||||
|
|
||||||
# ── 1차 필터: relation_type → 카테고리 ──
|
# ── 1차 필터: relation_type → 카테고리 ──
|
||||||
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
|
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
|
||||||
candidates_1 = [
|
candidates_1 = [
|
||||||
@@ -462,6 +565,12 @@ def select_and_generate_references(
|
|||||||
_tokens = _load_design_tokens()
|
_tokens = _load_design_tokens()
|
||||||
gap_between = _tokens["spacing_small"]
|
gap_between = _tokens["spacing_small"]
|
||||||
|
|
||||||
|
# _plus_visual schema는 주종 관계 무시 → recipe executor가 처리
|
||||||
|
role_info_for_schema = page_structure.get(role, {})
|
||||||
|
role_schema = role_info_for_schema.get("group_schema", "") if isinstance(role_info_for_schema, dict) else ""
|
||||||
|
if "_plus_visual" in role_schema:
|
||||||
|
is_hierarchical = False # recipe로 보냄
|
||||||
|
|
||||||
if is_hierarchical:
|
if is_hierarchical:
|
||||||
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
|
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
|
||||||
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
|
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
|
||||||
@@ -477,12 +586,17 @@ def select_and_generate_references(
|
|||||||
relation_type = primary_topic.get("relation_type", "none")
|
relation_type = primary_topic.get("relation_type", "none")
|
||||||
expression_hint = primary_topic.get("expression_hint", "")
|
expression_hint = primary_topic.get("expression_hint", "")
|
||||||
|
|
||||||
|
# tag 매칭용: 이 role에 속한 모든 topic 제목
|
||||||
|
all_topic_titles = [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
|
||||||
|
|
||||||
selection = select_reference_block(
|
selection = select_reference_block(
|
||||||
relation_type=relation_type,
|
relation_type=relation_type,
|
||||||
expression_hint=expression_hint,
|
expression_hint=expression_hint,
|
||||||
container_height_px=total_height_px,
|
container_height_px=total_height_px,
|
||||||
zone=zone,
|
zone=zone,
|
||||||
role=role,
|
role=role,
|
||||||
|
topic_count=len(topic_ids),
|
||||||
|
topic_titles=all_topic_titles,
|
||||||
)
|
)
|
||||||
ref_html = generate_design_reference(
|
ref_html = generate_design_reference(
|
||||||
block_id=selection["block_id"],
|
block_id=selection["block_id"],
|
||||||
@@ -507,50 +621,134 @@ def select_and_generate_references(
|
|||||||
f"주={primary_tid}, 종={supporting_tids}"
|
f"주={primary_tid}, 종={supporting_tids}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 동급: 꼭지별 블록 선택
|
# Phase Y: sub_titles 기반 블록 매칭 (Kei topic 수에 의존 안 함)
|
||||||
topic_count = len(topic_ids)
|
role_refs = [] # 초기화
|
||||||
available_for_topics = total_height_px - gap_between * max(0, topic_count - 1)
|
role_info = page_structure.get(role, {})
|
||||||
min_block_height = min(
|
sub_titles = role_info.get("sub_titles", []) if isinstance(role_info, dict) else []
|
||||||
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
|
slot_count = len(sub_titles) if sub_titles else len(topic_ids)
|
||||||
default=1,
|
slot_titles = sub_titles if sub_titles else [topic_map.get(tid, {}).get("title", "") for tid in topic_ids]
|
||||||
)
|
|
||||||
per_topic_height = max(min_block_height, available_for_topics // topic_count)
|
|
||||||
|
|
||||||
role_refs = []
|
|
||||||
for tid in topic_ids:
|
|
||||||
topic = topic_map.get(tid, {})
|
|
||||||
relation_type = topic.get("relation_type", "none")
|
|
||||||
expression_hint = topic.get("expression_hint", "")
|
|
||||||
|
|
||||||
selection = select_reference_block(
|
|
||||||
relation_type=relation_type,
|
|
||||||
expression_hint=expression_hint,
|
|
||||||
container_height_px=per_topic_height,
|
|
||||||
zone=zone,
|
|
||||||
role=role,
|
|
||||||
)
|
|
||||||
ref_html = generate_design_reference(
|
|
||||||
block_id=selection["block_id"],
|
|
||||||
variant=selection["variant"],
|
|
||||||
catalog_entry=selection["catalog_entry"],
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_info = selection["catalog_entry"].get("schema", {})
|
|
||||||
|
|
||||||
role_refs.append({
|
|
||||||
"block_id": selection["block_id"],
|
|
||||||
"variant": selection["variant"],
|
|
||||||
"visual_type": selection["visual_type"],
|
|
||||||
"schema_info": schema_info,
|
|
||||||
"design_reference_html": ref_html,
|
|
||||||
"topic_id": tid,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
# _plus_visual schema는 direct block 선택 금지 → recipe executor가 처리
|
||||||
|
group_schema = role_info.get("group_schema", "") if isinstance(role_info, dict) else ""
|
||||||
|
if "_plus_visual" in group_schema:
|
||||||
|
from src.section_parser import get_recipe_for_schema
|
||||||
|
recipe = get_recipe_for_schema(group_schema)
|
||||||
|
recipe_type = recipe.get("recipe", "") if recipe else ""
|
||||||
|
role_refs = [{
|
||||||
|
"block_id": "__needs_recipe__",
|
||||||
|
"variant": "default",
|
||||||
|
"visual_type": "recipe",
|
||||||
|
"schema_info": {"recipe": recipe_type, "group_schema": group_schema},
|
||||||
|
"design_reference_html": "",
|
||||||
|
"topic_id": topic_ids[0],
|
||||||
|
"supporting_topic_ids": topic_ids[1:],
|
||||||
|
"is_hierarchical": True,
|
||||||
|
}]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
|
f"[V-1] {role}: _plus_visual → recipe '{recipe_type}' "
|
||||||
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
|
f"(direct block 선택 건너뜀)"
|
||||||
f"budget={per_topic_height}px)"
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Y-14: tag_match와 schema_match 동등 비교
|
||||||
|
zone_tag_match = _match_by_tags(
|
||||||
|
_load_catalog(), slot_count, slot_titles,
|
||||||
|
total_height_px, zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
# schema_match
|
||||||
|
zone_schema_match = None
|
||||||
|
if group_schema:
|
||||||
|
from src.section_parser import get_candidate_blocks_for_schema
|
||||||
|
schema_candidates = get_candidate_blocks_for_schema(group_schema)
|
||||||
|
catalog_all = _load_catalog()
|
||||||
|
for cand_id in schema_candidates:
|
||||||
|
cand = next((b for b in catalog_all if b.get("id") == cand_id), None)
|
||||||
|
if cand:
|
||||||
|
zone_schema_match = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
best_match = zone_tag_match or zone_schema_match
|
||||||
|
if zone_tag_match and zone_schema_match:
|
||||||
|
best_match = zone_tag_match
|
||||||
|
match_type = "tag_match"
|
||||||
|
logger.info(f"[V-1] {role}: tag={zone_tag_match['id']}, schema={zone_schema_match['id']} → tag 우선")
|
||||||
|
elif zone_tag_match:
|
||||||
|
match_type = "tag_match"
|
||||||
|
elif zone_schema_match:
|
||||||
|
match_type = "schema_match"
|
||||||
|
best_match = zone_schema_match
|
||||||
|
else:
|
||||||
|
match_type = None
|
||||||
|
|
||||||
|
if best_match:
|
||||||
|
ref_html = generate_design_reference(
|
||||||
|
block_id=best_match["id"],
|
||||||
|
variant="default",
|
||||||
|
catalog_entry=best_match,
|
||||||
|
)
|
||||||
|
schema_info = best_match.get("schema", {})
|
||||||
|
role_refs = [{
|
||||||
|
"block_id": best_match["id"],
|
||||||
|
"variant": "default",
|
||||||
|
"visual_type": match_type or "fallback",
|
||||||
|
"schema_info": schema_info,
|
||||||
|
"design_reference_html": ref_html,
|
||||||
|
"topic_id": topic_ids[0],
|
||||||
|
"supporting_topic_ids": topic_ids[1:],
|
||||||
|
"is_hierarchical": True,
|
||||||
|
}]
|
||||||
|
logger.info(
|
||||||
|
f"[V-1] {role}: {match_type} → {best_match['id']} "
|
||||||
|
f"(topics {topic_ids} → 블록 1개)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# tag도 schema도 없음 → 기존 fallback: 꼭지별 블록 선택
|
||||||
|
if not role_refs:
|
||||||
|
n_topics = len(topic_ids)
|
||||||
|
available_for_topics = total_height_px - gap_between * max(0, n_topics - 1)
|
||||||
|
min_block_height = min(
|
||||||
|
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
per_topic_height = max(min_block_height, available_for_topics // max(1, n_topics))
|
||||||
|
|
||||||
|
role_refs = []
|
||||||
|
for tid in topic_ids:
|
||||||
|
topic = topic_map.get(tid, {})
|
||||||
|
relation_type = topic.get("relation_type", "none")
|
||||||
|
expression_hint = topic.get("expression_hint", "")
|
||||||
|
|
||||||
|
selection = select_reference_block(
|
||||||
|
relation_type=relation_type,
|
||||||
|
expression_hint=expression_hint,
|
||||||
|
container_height_px=per_topic_height,
|
||||||
|
zone=zone,
|
||||||
|
role=role,
|
||||||
|
topic_count=1,
|
||||||
|
topic_titles=[topic.get("title", "")],
|
||||||
|
)
|
||||||
|
ref_html = generate_design_reference(
|
||||||
|
block_id=selection["block_id"],
|
||||||
|
variant=selection["variant"],
|
||||||
|
catalog_entry=selection["catalog_entry"],
|
||||||
|
)
|
||||||
|
|
||||||
|
schema_info = selection["catalog_entry"].get("schema", {})
|
||||||
|
|
||||||
|
role_refs.append({
|
||||||
|
"block_id": selection["block_id"],
|
||||||
|
"variant": selection["variant"],
|
||||||
|
"visual_type": selection["visual_type"],
|
||||||
|
"schema_info": schema_info,
|
||||||
|
"design_reference_html": ref_html,
|
||||||
|
"topic_id": tid,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
|
||||||
|
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
|
||||||
|
f"budget={per_topic_height}px)"
|
||||||
|
)
|
||||||
|
|
||||||
references[role] = role_refs
|
references[role] = role_refs
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ def get_image_sizes(content: str, base_path: str) -> list[dict[str, Any]]:
|
|||||||
abs_path = found[0]
|
abs_path = found[0]
|
||||||
logger.info(f"이미지 경로 재탐색 성공: {filename} → {abs_path}")
|
logger.info(f"이미지 경로 재탐색 성공: {filename} → {abs_path}")
|
||||||
|
|
||||||
|
# samples/images/, samples/mdx_batch/ 에서도 탐색
|
||||||
|
if not abs_path.exists():
|
||||||
|
filename = Path(rel_path).name
|
||||||
|
for search_dir in [Path("samples/images"), Path("samples/mdx_batch")]:
|
||||||
|
if search_dir.exists():
|
||||||
|
found = list(search_dir.rglob(filename))
|
||||||
|
if found:
|
||||||
|
abs_path = found[0]
|
||||||
|
logger.info(f"이미지 경로 확장 탐색 성공: {filename} → {abs_path}")
|
||||||
|
break
|
||||||
|
|
||||||
if not abs_path.exists():
|
if not abs_path.exists():
|
||||||
logger.warning(f"이미지 파일 미발견: {abs_path}")
|
logger.warning(f"이미지 파일 미발견: {abs_path}")
|
||||||
images.append({
|
images.append({
|
||||||
|
|||||||
@@ -30,33 +30,21 @@ KEI_PROMPT = (
|
|||||||
"## 3단계: 슬라이드 스토리라인 설계\n"
|
"## 3단계: 슬라이드 스토리라인 설계\n"
|
||||||
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
||||||
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
||||||
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
|
"## 4단계: 꼭지별 성격 판단\n"
|
||||||
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
|
"각 꼭지에 대해 다음을 판단하라:\n\n"
|
||||||
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
|
"### sidebar 판단\n"
|
||||||
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
|
"- 이 꼭지의 내용이 **본문과 독립된 참조 정보**(용어 정의, 개념 비교, 참조 테이블)인가?\n"
|
||||||
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
|
"- 독립 참조 → role: 'reference' (sidebar 후보)\n"
|
||||||
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
|
"- 본문 흐름의 일부 → role: 'flow'\n\n"
|
||||||
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
|
"### 팝업 판단\n"
|
||||||
"- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
|
"- <details> 안에 있는 콘텐츠 → 팝업 처리 대상\n"
|
||||||
"- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
|
"- 너무 세부적인 내용 → 팝업으로 분리 가능\n\n"
|
||||||
"- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
|
"### 핵심요약\n"
|
||||||
"- 하단: 세부 내용 2분할 (좌/우)\n"
|
"- :::note[핵심 요약] 등의 결론 텍스트가 있으면 **conclusion_text** 필드에 원본 그대로 기록\n"
|
||||||
"- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
|
"- conclusion_text는 슬라이드 하단 footer에 자동 배치됨\n\n"
|
||||||
"- 결론 키는 반드시 '결론'\n\n"
|
"**주의: page_structure, zone, 영역 배치는 판단하지 마라.**\n"
|
||||||
"선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
|
"**영역과 zone은 코드가 블록 매칭을 통해 결정한다.**\n"
|
||||||
"### 역할별 규칙 (유형 A)\n"
|
"**너는 꼭지 추출 + 각 꼭지의 성격(reference/flow, 팝업 여부)만 판단하라.**\n\n"
|
||||||
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
|
|
||||||
"- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
|
|
||||||
"- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
|
|
||||||
"- **결론**: 핵심 한 줄. footer.\n\n"
|
|
||||||
"### 역할별 규칙 (유형 B)\n"
|
|
||||||
"- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
|
|
||||||
"- 하단 좌측: zone: 'bottom_left'\n"
|
|
||||||
"- 하단 우측: zone: 'bottom_right'\n"
|
|
||||||
"- 결론: zone: 'footer'\n\n"
|
|
||||||
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
|
|
||||||
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
|
||||||
"page_structure 필드에 기록.\n\n"
|
|
||||||
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
|
"## 원본 텍스트 보존 원칙 (절대 규칙)\n"
|
||||||
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
|
"- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
|
||||||
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
|
" 원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
|
||||||
@@ -70,61 +58,76 @@ KEI_PROMPT = (
|
|||||||
"## 배치 규칙\n"
|
"## 배치 규칙\n"
|
||||||
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
||||||
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
||||||
"- 결론은 layer: 'conclusion' → 하단 배치\n"
|
"- 결론/핵심요약은 conclusion_text 필드에 기록. page_structure에 넣지 마라.\n"
|
||||||
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
|
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
|
||||||
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
|
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
|
||||||
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
|
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
|
||||||
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
|
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
|
||||||
"## 출력 형식 (JSON만)\n"
|
"## 출력 형식 (JSON만)\n"
|
||||||
"layout_template에 따라 page_structure가 달라진다.\n\n"
|
"**page_structure는 출력하지 마라. 영역/zone 배치는 코드가 결정한다.**\n\n"
|
||||||
"유형 A 예시:\n"
|
|
||||||
"```json\n"
|
"```json\n"
|
||||||
'{"title": "제목", '
|
'{"title": "슬라이드 제목 (MDX title 또는 전체 주제)", '
|
||||||
'"core_message": "핵심 메시지", '
|
'"core_message": "핵심 메시지", '
|
||||||
|
'"conclusion_text": "핵심 요약 원본 텍스트 (:::note 등에서 추출. 없으면 빈 문자열)", '
|
||||||
'"total_pages": 1, '
|
'"total_pages": 1, '
|
||||||
'"layout_template": "A", '
|
|
||||||
'"page_structure": {'
|
|
||||||
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
|
|
||||||
'"배경": {"topic_ids": [1], "weight": 0.15}, '
|
|
||||||
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
|
|
||||||
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
|
|
||||||
'"topics": ['
|
'"topics": ['
|
||||||
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
'{"id": 1, "title": "꼭지 제목 (원본 그대로)", "summary": "요약", '
|
||||||
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
|
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
|
||||||
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
|
'"source_hint": "원본에서 이 꼭지에 해당하는 텍스트 범위 설명", '
|
||||||
'"layer": "intro|core|supporting|conclusion", '
|
'"layer": "intro|core|supporting|conclusion", '
|
||||||
'"role": "flow|reference", '
|
'"role": "flow|reference", '
|
||||||
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
|
|
||||||
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||||
'"content_type": "text|image|table|mixed", '
|
'"content_type": "text|image|table|mixed", '
|
||||||
'"detail_target": false, "page": 1}], '
|
'"detail_target": false, "page": 1}], '
|
||||||
'"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_structure_hints(content: str) -> str:
|
||||||
|
"""MDX 구조에서 유형 판단 힌트를 자동 감지."""
|
||||||
|
hints = []
|
||||||
|
content_lower = content.lower()
|
||||||
|
|
||||||
|
# 용어 정의 섹션 감지
|
||||||
|
if re.search(r'##\s*\d*\.?\s*용어\s*정의', content):
|
||||||
|
hints.append("[구조 힌트] '용어 정의' 섹션 감지 → 유형 A 후보")
|
||||||
|
if re.search(r'##\s*\d*\.?\s*개념\s*비교', content):
|
||||||
|
hints.append("[구조 힌트] '개념 비교' 섹션 감지 → 유형 A 후보")
|
||||||
|
|
||||||
|
# sidebar 마크다운 감지
|
||||||
|
if 'sidebar:' in content[:200]:
|
||||||
|
pass # frontmatter의 sidebar는 Starlight 설정이므로 무시
|
||||||
|
|
||||||
|
# <details> 감지
|
||||||
|
if '<details>' in content:
|
||||||
|
hints.append("[구조 힌트] <details> 참고 사례 감지 → 팝업 처리 대상 (유형 선택과 무관)")
|
||||||
|
|
||||||
|
# 표 감지
|
||||||
|
if '|' in content and '---' in content:
|
||||||
|
hints.append("[구조 힌트] 표(테이블) 감지 → 비교 구조")
|
||||||
|
|
||||||
|
# 이미지 감지
|
||||||
|
if re.search(r'!\[.*?\]\(.*?\)', content):
|
||||||
|
hints.append("[구조 힌트] 이미지 감지 → 이미지 배치 필요")
|
||||||
|
|
||||||
|
# A 후보 힌트가 없으면 B 유력
|
||||||
|
if not any("유형 A 후보" in h for h in hints):
|
||||||
|
hints.append("[구조 힌트] 독립 참조 섹션 없음 → 유형 B 유력")
|
||||||
|
|
||||||
|
return "\n".join(hints) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
async def classify_content(content: str) -> dict[str, Any] | None:
|
async def classify_content(content: str) -> dict[str, Any] | None:
|
||||||
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
|
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
|
||||||
|
|
||||||
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
|
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
|
||||||
"""
|
"""
|
||||||
result = await _call_kei_api(content)
|
# MDX 구조 힌트를 content 앞에 추가
|
||||||
|
hints = _detect_structure_hints(content)
|
||||||
|
result = await _call_kei_api(hints + content)
|
||||||
if result:
|
if result:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, "
|
f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, "
|
||||||
|
|||||||
348
src/pipeline.py
348
src/pipeline.py
@@ -141,12 +141,23 @@ async def generate_slide(
|
|||||||
if errors:
|
if errors:
|
||||||
return {"_errors": errors}
|
return {"_errors": errors}
|
||||||
|
|
||||||
|
# popup_id 부여 (Stage 0 시점)
|
||||||
|
from src.pipeline_context import PopupItem
|
||||||
|
raw_popups = result.get("popups", [])
|
||||||
|
popup_items = []
|
||||||
|
for pi, rp in enumerate(raw_popups, 1):
|
||||||
|
popup_items.append(PopupItem(
|
||||||
|
popup_id=f"popup_{pi}",
|
||||||
|
title=rp.get("title", ""),
|
||||||
|
content=rp.get("content", ""),
|
||||||
|
))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"normalized": NormalizedContent(
|
"normalized": NormalizedContent(
|
||||||
clean_text=result["clean_text"],
|
clean_text=result["clean_text"],
|
||||||
title=result["title"],
|
title=result["title"],
|
||||||
images=result["images"],
|
images=result["images"],
|
||||||
popups=result["popups"],
|
popups=popup_items,
|
||||||
tables=result["tables"],
|
tables=result["tables"],
|
||||||
sections=result["sections"],
|
sections=result["sections"],
|
||||||
),
|
),
|
||||||
@@ -176,6 +187,7 @@ async def generate_slide(
|
|||||||
original_title = context.normalized.title or analysis_raw.get("title", "")
|
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", ""),
|
||||||
|
conclusion_text=analysis_raw.get("conclusion_text", ""),
|
||||||
title=original_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"),
|
layout_template=analysis_raw.get("layout_template", "A"),
|
||||||
@@ -200,14 +212,197 @@ async def generate_slide(
|
|||||||
if validation_errors:
|
if validation_errors:
|
||||||
return {"_errors": validation_errors}
|
return {"_errors": validation_errors}
|
||||||
|
|
||||||
|
# Phase Y: page_structure는 Kei가 만들지 않음.
|
||||||
|
# Kei 응답에 page_structure가 있어도 무시.
|
||||||
|
# 코드가 section_parser + 블록 매칭으로 생성 (Stage 1A 후 별도 단계)
|
||||||
return {
|
return {
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
"topics": topics,
|
"topics": topics,
|
||||||
"page_structure": page_structure,
|
"page_structure": PageStructure(roles={}), # 빈 상태, 아래에서 채움
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2)
|
ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2)
|
||||||
|
|
||||||
|
# ── Phase Y: 영역 확정 (코드: normalized.sections 기반 + 블록 매칭) ──
|
||||||
|
from src.section_parser import extract_major_sections, extract_conclusion_text, map_topics_to_sections, classify_group_relations, get_candidate_blocks_for_schema, detect_component_popups
|
||||||
|
from src.block_reference import _match_by_tags, _load_catalog
|
||||||
|
|
||||||
|
# source of truth = normalized.sections (Stage 0 산출물)
|
||||||
|
norm_sections = ctx.normalized.sections if hasattr(ctx.normalized, 'sections') else []
|
||||||
|
if hasattr(norm_sections, '__iter__') and norm_sections:
|
||||||
|
if hasattr(norm_sections[0], 'model_dump'):
|
||||||
|
norm_sections = [s.model_dump() for s in norm_sections]
|
||||||
|
elif not isinstance(norm_sections[0], dict):
|
||||||
|
norm_sections = [dict(s) for s in norm_sections]
|
||||||
|
|
||||||
|
major_sections = extract_major_sections(norm_sections)
|
||||||
|
|
||||||
|
# popup 대상 sub_title 목록 (컴포넌트 태그가 있던 섹션)
|
||||||
|
popup_sub_titles = []
|
||||||
|
component_tags = re.findall(r'<([A-Z]\w+)\s*/>', ctx.raw_content)
|
||||||
|
if component_tags:
|
||||||
|
raw_lines = ctx.raw_content.split("\n")
|
||||||
|
for tag_name in component_tags:
|
||||||
|
current_section = ""
|
||||||
|
for line in raw_lines:
|
||||||
|
if line.strip().startswith("### "):
|
||||||
|
current_section = re.sub(r'^#{1,3}\s*\d*\.?\d*\s*', '', line.strip()).strip()
|
||||||
|
if f"<{tag_name}" in line:
|
||||||
|
if current_section:
|
||||||
|
popup_sub_titles.append(current_section)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Y-13b: group relation 분류
|
||||||
|
major_sections = classify_group_relations(
|
||||||
|
major_sections, normalized_sections=norm_sections,
|
||||||
|
popup_sub_titles=popup_sub_titles,
|
||||||
|
)
|
||||||
|
|
||||||
|
# conclusion_text: raw MDX에서 추출 또는 기존 값 정제
|
||||||
|
conclusion_text = ctx.analysis.conclusion_text or ""
|
||||||
|
if not conclusion_text:
|
||||||
|
conclusion_text = extract_conclusion_text(ctx.raw_content)
|
||||||
|
# 선행 불릿 마커 정제 (Kei가 * 포함해서 넣을 수 있음)
|
||||||
|
if conclusion_text:
|
||||||
|
conclusion_text = re.sub(r'^[\*•\-]\s*', '', conclusion_text).strip()
|
||||||
|
conclusion_text = re.sub(r'\*+', '', conclusion_text).strip()
|
||||||
|
if conclusion_text != (ctx.analysis.conclusion_text or ""):
|
||||||
|
ctx = ctx.model_copy(update={
|
||||||
|
"analysis": ctx.analysis.model_copy(update={"conclusion_text": conclusion_text}),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 꼭지-대목차 매핑
|
||||||
|
topic_dicts = [t.model_dump() for t in ctx.topics]
|
||||||
|
section_topic_map = map_topics_to_sections(topic_dicts, major_sections)
|
||||||
|
|
||||||
|
# 대목차별 묶음으로 블록 tag 매칭 → 영역 확정
|
||||||
|
# 블록 매칭 기준: Kei topic 수가 아니라 normalized sub_titles 수
|
||||||
|
catalog = _load_catalog()
|
||||||
|
page_struct_roles = {}
|
||||||
|
zone_names = ["top", "bottom"]
|
||||||
|
|
||||||
|
# major_sections를 title로 빠르게 찾기
|
||||||
|
major_sec_map = {s["title"]: s for s in major_sections}
|
||||||
|
|
||||||
|
for i, (sec_title, tids) in enumerate(section_topic_map.items()):
|
||||||
|
# sub_titles = normalized.sections에서 파싱된 소항목 목록
|
||||||
|
sec = major_sec_map.get(sec_title, {})
|
||||||
|
sub_titles = sec.get("sub_titles", [])
|
||||||
|
|
||||||
|
# sidebar 판단: 모든 꼭지가 reference면 sidebar
|
||||||
|
all_reference = all(
|
||||||
|
t.role == "reference" for t in ctx.topics if t.id in tids
|
||||||
|
)
|
||||||
|
if all_reference:
|
||||||
|
zone = "sidebar"
|
||||||
|
elif i < len(zone_names):
|
||||||
|
zone = zone_names[i]
|
||||||
|
else:
|
||||||
|
zone = f"bottom_{i}"
|
||||||
|
|
||||||
|
# 블록 매칭: sub_titles 수 기준 (Kei topic 수 아님)
|
||||||
|
slot_count = len(sub_titles) if sub_titles else len(tids)
|
||||||
|
tag_match = _match_by_tags(catalog, slot_count, sub_titles, 300, zone)
|
||||||
|
if tag_match:
|
||||||
|
logger.info(f"[Phase Y] '{sec_title}' → 블록 {tag_match['id']} (tag_match) 확정")
|
||||||
|
else:
|
||||||
|
# Y-13d: tag 매칭 실패 → group schema 후보로 블록 찾기
|
||||||
|
group_schema = sec.get("group_schema", "")
|
||||||
|
schema_candidates = get_candidate_blocks_for_schema(group_schema)
|
||||||
|
if schema_candidates:
|
||||||
|
# catalog에서 첫 번째 존재하는 후보 선택
|
||||||
|
for cand_id in schema_candidates:
|
||||||
|
cand = next((b for b in catalog if b.get("id") == cand_id), None)
|
||||||
|
if cand:
|
||||||
|
tag_match = cand # schema 기반 선택
|
||||||
|
logger.info(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 {cand_id}")
|
||||||
|
break
|
||||||
|
if not tag_match:
|
||||||
|
logger.warning(f"[Y-13d] '{sec_title}' → schema={group_schema} → 블록 매칭 실패")
|
||||||
|
|
||||||
|
# weight: 콘텐츠 양(content 길이) 기반
|
||||||
|
sec_content_len = len(sec.get("content", ""))
|
||||||
|
page_struct_roles[sec_title] = {
|
||||||
|
"zone": zone,
|
||||||
|
"topic_ids": tids,
|
||||||
|
"weight": sec_content_len,
|
||||||
|
"sub_titles": sub_titles,
|
||||||
|
"sub_types": sec.get("sub_types", []),
|
||||||
|
"group_schema": sec.get("group_schema", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# weight를 비율로 변환 (합계 1.0)
|
||||||
|
total_content = sum(info["weight"] for info in page_struct_roles.values())
|
||||||
|
if total_content > 0:
|
||||||
|
for role in page_struct_roles:
|
||||||
|
page_struct_roles[role]["weight"] = round(page_struct_roles[role]["weight"] / total_content, 2)
|
||||||
|
else:
|
||||||
|
# content가 없으면 균등 분배
|
||||||
|
n = len(page_struct_roles)
|
||||||
|
for role in page_struct_roles:
|
||||||
|
page_struct_roles[role]["weight"] = round(1.0 / n, 2)
|
||||||
|
|
||||||
|
# Phase Y: layout_template도 코드가 결정
|
||||||
|
# sidebar zone이 있으면 Type A, 없으면 Type B
|
||||||
|
has_sidebar = any(
|
||||||
|
info.get("zone") == "sidebar" for info in page_struct_roles.values()
|
||||||
|
)
|
||||||
|
determined_layout = "A" if has_sidebar else "B"
|
||||||
|
logger.info(f"[Phase Y] 영역 확정: {list(page_struct_roles.keys())} → layout={determined_layout}")
|
||||||
|
|
||||||
|
ctx = ctx.model_copy(update={
|
||||||
|
"page_structure": PageStructure(roles=page_struct_roles),
|
||||||
|
"mdx_sections": major_sections, # normalized.sections 기반 대목차 (assembler용)
|
||||||
|
"analysis": ctx.analysis.model_copy(update={"layout_template": determined_layout}),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Phase Y: page_structure 검증 (section_parser가 만든 결과)
|
||||||
|
from src.validators import validate_page_structure
|
||||||
|
ps_errors = validate_page_structure(page_struct_roles)
|
||||||
|
if ps_errors:
|
||||||
|
logger.warning(f"[Phase Y] page_structure 검증 경고: {ps_errors}")
|
||||||
|
|
||||||
|
# Y-14: 컴포넌트 popup 감지 + target_role 확정
|
||||||
|
component_popups = detect_component_popups(ctx.raw_content, ctx.base_path or "samples/mdx")
|
||||||
|
if component_popups:
|
||||||
|
from src.pipeline_context import PopupItem
|
||||||
|
existing_popups = list(ctx.normalized.popups or [])
|
||||||
|
|
||||||
|
# target_role 결정: raw MDX에서 컴포넌트 태그가 어느 ## 섹션에 있었는지
|
||||||
|
raw_lines = ctx.raw_content.split("\n")
|
||||||
|
for cp in component_popups:
|
||||||
|
tag = cp.get("tag", f"<{cp['name']} />")
|
||||||
|
target_role = None
|
||||||
|
current_section = None
|
||||||
|
for line in raw_lines:
|
||||||
|
if line.strip().startswith("## "):
|
||||||
|
# ## 번호 제거: "## 2. DX 기반..." → "DX 기반..."
|
||||||
|
# "## 2. DX 기반..." → "DX 기반..."
|
||||||
|
sec_title = re.sub(r'^#{1,3}\s*\d*\.?\s*', '', line.strip()).strip()
|
||||||
|
# page_structure roles에서 매칭
|
||||||
|
for rname in page_struct_roles:
|
||||||
|
if sec_title and len(sec_title) >= 3 and sec_title[:6] in rname:
|
||||||
|
current_section = rname
|
||||||
|
break
|
||||||
|
if tag.replace(" ", "") in line.replace(" ", ""):
|
||||||
|
target_role = current_section
|
||||||
|
break
|
||||||
|
|
||||||
|
existing_popups.append(PopupItem(
|
||||||
|
popup_id=f"comp_{cp['name']}",
|
||||||
|
title=f"상세: {cp['name']}",
|
||||||
|
content=cp["content_html"],
|
||||||
|
source=cp["source"],
|
||||||
|
is_component=True,
|
||||||
|
target_role=target_role,
|
||||||
|
))
|
||||||
|
logger.info(f"[Y-14] 컴포넌트 popup: {cp['name']} → target_role='{target_role}'")
|
||||||
|
|
||||||
|
ctx = ctx.model_copy(update={
|
||||||
|
"normalized": ctx.normalized.model_copy(update={"popups": existing_popups}),
|
||||||
|
})
|
||||||
|
logger.info(f"[Y-14] 컴포넌트 popup {len(component_popups)}개 추가")
|
||||||
|
|
||||||
# ── Stage 1B: 컨셉 구체화 ──
|
# ── Stage 1B: 컨셉 구체화 ──
|
||||||
yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."}
|
yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."}
|
||||||
|
|
||||||
@@ -449,7 +644,7 @@ async def generate_slide(
|
|||||||
build_enhancement_report, calculate_sub_layout,
|
build_enhancement_report, calculate_sub_layout,
|
||||||
EnhancementAnalysis,
|
EnhancementAnalysis,
|
||||||
)
|
)
|
||||||
from src.block_assembler import assemble_slide_html
|
from src.block_assembler import assemble_slide_html_final as assemble_slide_html
|
||||||
from src.slide_measurer import measure_rendered_heights
|
from src.slide_measurer import measure_rendered_heights
|
||||||
|
|
||||||
refs_dict = {}
|
refs_dict = {}
|
||||||
@@ -520,6 +715,9 @@ async def generate_slide(
|
|||||||
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
|
# ── filled→측정→Kei 재판단 루프 (최대 3회) ──
|
||||||
kei_decisions = []
|
kei_decisions = []
|
||||||
updated_containers = dict(context.containers)
|
updated_containers = dict(context.containers)
|
||||||
|
fit_analysis = None
|
||||||
|
filled_measurement = {}
|
||||||
|
font_scale = 1.0 # fit 루프에서 축소
|
||||||
MAX_FIT_RETRIES = 3
|
MAX_FIT_RETRIES = 3
|
||||||
|
|
||||||
for fit_round in range(MAX_FIT_RETRIES):
|
for fit_round in range(MAX_FIT_RETRIES):
|
||||||
@@ -533,8 +731,8 @@ async def generate_slide(
|
|||||||
"containers": updated_containers,
|
"containers": updated_containers,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── filled: 컨테이너에 블록+텍스트 채움 ──
|
# ── filled: 컨테이너에 블록+텍스트 채움 (측정용: overflow:auto) ──
|
||||||
filled_html = assemble_slide_html(context)
|
filled_html = assemble_slide_html(context, measure_mode=True, font_scale=font_scale)
|
||||||
(steps_dir / f"stage_1_8_filled{'_r'+str(fit_round) if fit_round else ''}.html").write_text(
|
(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'
|
filled_html.replace('</head><body>', '</head><body>\n'
|
||||||
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
f'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
||||||
@@ -642,6 +840,11 @@ async def generate_slide(
|
|||||||
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
|
f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# ── Phase Y: font_scale 축소 (재배분만으로 부족할 때) ──
|
||||||
|
# 재배분 후에도 여전히 overflow면 font를 줄임
|
||||||
|
font_scale = max(0.7, font_scale - 0.1)
|
||||||
|
logger.info(f"[Stage 1.8] round {fit_round+1}: font_scale → {font_scale:.1f}")
|
||||||
|
|
||||||
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
|
# ── Kei 에스컬레이션: overflow 있으면 팝업 분리 판단 요청 ──
|
||||||
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
|
# calculate_fit의 needs_escalation 또는 Selenium 측정의 실제 overflow
|
||||||
if fit_analysis.needs_escalation or has_overflow:
|
if fit_analysis.needs_escalation or has_overflow:
|
||||||
@@ -676,15 +879,19 @@ async def generate_slide(
|
|||||||
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
|
logger.info(f"[Stage 1.8] round {fit_round+1}: 재배분으로 해결됨")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Step 4: 보강 제안 분석
|
# Step 4: 보강 제안 분석 (fit_analysis가 있을 때만)
|
||||||
enhancements = analyze_enhancements(
|
if fit_analysis:
|
||||||
topics=[t.model_dump() for t in context.topics],
|
enhancements = analyze_enhancements(
|
||||||
page_structure=context.page_structure.roles,
|
topics=[t.model_dump() for t in context.topics],
|
||||||
references=refs_dict,
|
page_structure=context.page_structure.roles,
|
||||||
analysis=fit_analysis,
|
references=refs_dict,
|
||||||
normalized=normalized,
|
analysis=fit_analysis,
|
||||||
core_message=core_message,
|
normalized=normalized,
|
||||||
)
|
core_message=core_message,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
enhancements = EnhancementAnalysis()
|
||||||
|
logger.info("[Stage 1.8] overflow 없음 — 보강 분석 스킵")
|
||||||
|
|
||||||
# Step 5: Kei에게 보강 제안 확인 요청
|
# Step 5: Kei에게 보강 제안 확인 요청
|
||||||
if enhancements.enhancements:
|
if enhancements.enhancements:
|
||||||
@@ -744,15 +951,19 @@ async def generate_slide(
|
|||||||
# 재배분된 컨테이너 크기 업데이트
|
# 재배분된 컨테이너 크기 업데이트
|
||||||
updated_containers = {}
|
updated_containers = {}
|
||||||
for role, ci in context.containers.items():
|
for role, ci in context.containers.items():
|
||||||
new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
|
if fit_analysis and fit_analysis.redistribution:
|
||||||
|
new_h = fit_analysis.redistribution.get(role, ci.height_px)
|
||||||
|
else:
|
||||||
|
new_h = ci.height_px
|
||||||
updated_containers[role] = ci.model_copy(update={
|
updated_containers[role] = ci.model_copy(update={
|
||||||
"height_px": int(new_h),
|
"height_px": int(new_h),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Step 7: 세부 컨테이너 배치 계산
|
# Step 7: 세부 컨테이너 배치 계산
|
||||||
sub_layouts = {}
|
sub_layouts = {}
|
||||||
for role, rf in fit_analysis.roles.items():
|
fit_roles = fit_analysis.roles if fit_analysis else {}
|
||||||
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis.redistribution else rf.allocated_px
|
for role, rf in fit_roles.items():
|
||||||
|
new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis and fit_analysis.redistribution else rf.allocated_px
|
||||||
ci = context.containers.get(role)
|
ci = context.containers.get(role)
|
||||||
if not ci or not rf.topic_fits:
|
if not ci or not rf.topic_fits:
|
||||||
continue
|
continue
|
||||||
@@ -801,7 +1012,7 @@ async def generate_slide(
|
|||||||
popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
|
popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
|
||||||
for pr in popup_refs:
|
for pr in popup_refs:
|
||||||
# 팝업 원본 찾기
|
# 팝업 원본 찾기
|
||||||
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.title if hasattr(p, 'title') else p.get("title", ""))), None)
|
||||||
if not popup:
|
if not popup:
|
||||||
continue
|
continue
|
||||||
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
|
# 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
|
||||||
@@ -842,7 +1053,7 @@ async def generate_slide(
|
|||||||
continue # 공간 부족하면 건너뜀
|
continue # 공간 부족하면 건너뜀
|
||||||
summary = await call_kei_summarize_popup(
|
summary = await call_kei_summarize_popup(
|
||||||
popup_title=pr,
|
popup_title=pr,
|
||||||
popup_content=popup.get("content", ""),
|
popup_content=popup.content if hasattr(popup, 'content') else popup.get("content", ""),
|
||||||
available_width_px=available_w,
|
available_width_px=available_w,
|
||||||
available_height_px=available_h,
|
available_height_px=available_h,
|
||||||
font_size=fs,
|
font_size=fs,
|
||||||
@@ -891,6 +1102,7 @@ async def generate_slide(
|
|||||||
"containers": updated_containers,
|
"containers": updated_containers,
|
||||||
"sub_layouts": sub_layouts,
|
"sub_layouts": sub_layouts,
|
||||||
"measurement": filled_measurement,
|
"measurement": filled_measurement,
|
||||||
|
"font_scale": font_scale, # Phase Y: fit 루프에서 확정된 font 축소 비율
|
||||||
"fit_result": {
|
"fit_result": {
|
||||||
"roles": {
|
"roles": {
|
||||||
role: {
|
role: {
|
||||||
@@ -899,10 +1111,10 @@ async def generate_slide(
|
|||||||
"allocated_px": rf.allocated_px,
|
"allocated_px": rf.allocated_px,
|
||||||
"shortfall_px": rf.shortfall_px,
|
"shortfall_px": rf.shortfall_px,
|
||||||
}
|
}
|
||||||
for role, rf in fit_analysis.roles.items()
|
for role, rf in (fit_analysis.roles.items() if fit_analysis else {}.items())
|
||||||
},
|
},
|
||||||
"redistribution": fit_analysis.redistribution,
|
"redistribution": fit_analysis.redistribution if fit_analysis else {},
|
||||||
"needs_escalation": fit_analysis.needs_escalation,
|
"needs_escalation": fit_analysis.needs_escalation if fit_analysis else False,
|
||||||
},
|
},
|
||||||
"enhancement_result": {
|
"enhancement_result": {
|
||||||
"kei_decisions": kei_decisions,
|
"kei_decisions": kei_decisions,
|
||||||
@@ -972,9 +1184,10 @@ async def generate_slide(
|
|||||||
async def stage_2(context: PipelineContext) -> dict:
|
async def stage_2(context: PipelineContext) -> dict:
|
||||||
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
|
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
|
||||||
if context.analysis.layout_template in ("B", "B'", "B''"):
|
if context.analysis.layout_template in ("B", "B'", "B''"):
|
||||||
from src.block_assembler import assemble_slide_html
|
from src.block_assembler import assemble_slide_html_final
|
||||||
generated = assemble_slide_html(context)
|
fs = context.font_scale if hasattr(context, 'font_scale') else 1.0
|
||||||
logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
|
generated = assemble_slide_html_final(context, font_scale=fs)
|
||||||
|
logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
|
||||||
return {"generated_html": generated}
|
return {"generated_html": generated}
|
||||||
|
|
||||||
# Type A: 기존 Sonnet 재구성 코드 그대로
|
# Type A: 기존 Sonnet 재구성 코드 그대로
|
||||||
@@ -1094,7 +1307,7 @@ async def generate_slide(
|
|||||||
capture_slide_screenshot, context.rendered_html
|
capture_slide_screenshot, context.rendered_html
|
||||||
)
|
)
|
||||||
|
|
||||||
quality_score = 100
|
quality_score = -1 # 비전 미평가 시 -1 (거짓 100점 방지)
|
||||||
if screenshot_b64:
|
if screenshot_b64:
|
||||||
analysis_dict = {
|
analysis_dict = {
|
||||||
"topics": [t.model_dump() for t in context.topics],
|
"topics": [t.model_dump() for t in context.topics],
|
||||||
@@ -1111,6 +1324,8 @@ async def generate_slide(
|
|||||||
"localization": f"품질 {quality_score}/100 < 30",
|
"localization": f"품질 {quality_score}/100 < 30",
|
||||||
"instruction": "출력 차단",
|
"instruction": "출력 차단",
|
||||||
}]}
|
}]}
|
||||||
|
else:
|
||||||
|
logger.warning("[Stage 4] 비전 품질 평가 실패 — quality_score=-1 (미평가)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"measurement": measurement,
|
"measurement": measurement,
|
||||||
@@ -1139,7 +1354,10 @@ async def generate_slide(
|
|||||||
]
|
]
|
||||||
logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")
|
logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")
|
||||||
|
|
||||||
if quality < 30:
|
if quality < 0:
|
||||||
|
# 비전 미평가: 차단하지 않고 경고만. Selenium overflow 검사는 통과한 상태.
|
||||||
|
logger.warning(f"[Stage 5] 비전 미평가 (quality={quality}) — Selenium 측정만으로 통과")
|
||||||
|
elif quality < 30:
|
||||||
logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
|
logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
|
||||||
yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
|
yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
|
||||||
return
|
return
|
||||||
@@ -1149,22 +1367,44 @@ async def generate_slide(
|
|||||||
html = embed_images(html, ctx.base_path)
|
html = embed_images(html, ctx.base_path)
|
||||||
|
|
||||||
ctx = ctx.model_copy(update={"rendered_html": html})
|
ctx = ctx.model_copy(update={"rendered_html": html})
|
||||||
ctx.save_snapshot("final")
|
|
||||||
|
|
||||||
# final.html 저장
|
# Stage 5: popup_file 확정 (save_snapshot 전에 완료)
|
||||||
run_dir = ctx.get_run_dir()
|
run_dir = ctx.get_run_dir()
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
popups = ctx.normalized.popups
|
||||||
|
if popups:
|
||||||
|
updated_popups = []
|
||||||
|
for i, popup in enumerate(popups, 1):
|
||||||
|
popup_title = popup.title
|
||||||
|
popup_content = popup.content
|
||||||
|
pid = popup.popup_id or f"popup_{i}"
|
||||||
|
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
|
||||||
|
popup_filename = f"첨부{i}_{safe_title}.html"
|
||||||
|
# popup_file 확정 → 새 PopupItem으로 (Pydantic immutable 대응)
|
||||||
|
updated_popups.append(popup.model_copy(update={"popup_file": popup_filename}))
|
||||||
|
ctx = ctx.model_copy(update={
|
||||||
|
"normalized": ctx.normalized.model_copy(update={"popups": updated_popups}),
|
||||||
|
})
|
||||||
|
popups = ctx.normalized.popups # 업데이트된 참조
|
||||||
|
|
||||||
|
ctx.save_snapshot("final")
|
||||||
|
|
||||||
|
# stage_4 검증판을 final 시점 context로 재생성 (popup_file 등 반영)
|
||||||
|
from src.step_visualizer import generate_step_html
|
||||||
|
try:
|
||||||
|
generate_step_html(ctx, "stage_4")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Stage 5] stage_4 재생성 실패: {e}")
|
||||||
|
|
||||||
|
# final.html 저장
|
||||||
(run_dir / "final.html").write_text(html, encoding="utf-8")
|
(run_dir / "final.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
# Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
|
# Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
|
||||||
popups = ctx.normalized.popups
|
|
||||||
if popups:
|
if popups:
|
||||||
for i, popup in enumerate(popups, 1):
|
for i, popup in enumerate(popups, 1):
|
||||||
popup_title = popup.get("title", f"첨부{i}")
|
popup_title = popup.title
|
||||||
popup_content = popup.get("content", "")
|
popup_content = popup.content
|
||||||
# 파일명에서 특수문자 제거
|
popup_filename = popup.popup_file or f"첨부{i}.html"
|
||||||
safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
|
|
||||||
popup_filename = f"첨부{i}_{safe_title}.html"
|
|
||||||
# TP-6: 첨부 HTML에 디자인 토큰 적용
|
# TP-6: 첨부 HTML에 디자인 토큰 적용
|
||||||
import re as _re
|
import re as _re
|
||||||
# JSX style={{}} 잔여 정리
|
# JSX style={{}} 잔여 정리
|
||||||
@@ -1179,27 +1419,27 @@ async def generate_slide(
|
|||||||
|
|
||||||
# 콘텐츠 유형별 CSS
|
# 콘텐츠 유형별 CSS
|
||||||
if has_table:
|
if has_table:
|
||||||
# 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
|
content_css = (
|
||||||
content_css = """
|
"table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }\n"
|
||||||
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; }\n"
|
||||||
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%; }\n"
|
||||||
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
|
"th:nth-child(2) { width: 16%; }\n"
|
||||||
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; }\n"
|
||||||
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); }"
|
||||||
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
|
)
|
||||||
elif has_list:
|
elif has_list:
|
||||||
# 카드형 리스트: 항목별 박스, 하위 항목은 인라인
|
content_css = (
|
||||||
content_css = """
|
"ul { padding-left: 0; margin: 12px 0; list-style: none; }\n"
|
||||||
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; }\n"
|
||||||
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; }\n"
|
||||||
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; }\n"
|
||||||
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; }'
|
||||||
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
|
)
|
||||||
else:
|
else:
|
||||||
# 기본 (텍스트)
|
content_css = (
|
||||||
content_css = """
|
"ul { padding-left: 20px; margin: 8px 0; }\n"
|
||||||
ul {{ padding-left: 20px; margin: 8px 0; }}
|
"li { margin-bottom: 4px; font-size: 13px; }"
|
||||||
li {{ margin-bottom: 4px; font-size: 13px; }}"""
|
)
|
||||||
|
|
||||||
popup_html = f"""<!DOCTYPE html>
|
popup_html = f"""<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|||||||
@@ -23,12 +23,30 @@ from pydantic import BaseModel, Field, model_validator
|
|||||||
# 하위 모델
|
# 하위 모델
|
||||||
# ──────────────────────────────────────
|
# ──────────────────────────────────────
|
||||||
|
|
||||||
|
class PopupItem(BaseModel):
|
||||||
|
"""팝업/첨부 항목.
|
||||||
|
|
||||||
|
생애주기:
|
||||||
|
Stage 0 → title, content 확정
|
||||||
|
Y-14 감지 → popup_id 확정, is_component, source
|
||||||
|
Stage 2 → popup_id로 참조 (popup_file은 아직 없음)
|
||||||
|
Stage 5 저장 → popup_file 확정 (run_dir + 파일명 정책)
|
||||||
|
"""
|
||||||
|
popup_id: str = "" # 감지 시점에 확정 (예: "popup_1", "comp_DxEffect")
|
||||||
|
title: str = ""
|
||||||
|
content: str = ""
|
||||||
|
source: str | None = None
|
||||||
|
is_component: bool = False
|
||||||
|
target_role: str | None = None # Y-14에서 확정: 이 popup이 속하는 role 이름
|
||||||
|
popup_file: str | None = None # Stage 5에서 확정
|
||||||
|
|
||||||
|
|
||||||
class NormalizedContent(BaseModel):
|
class NormalizedContent(BaseModel):
|
||||||
"""Stage 0 출력: MDX 정규화 결과."""
|
"""Stage 0 출력: MDX 정규화 결과."""
|
||||||
clean_text: str = ""
|
clean_text: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
images: list[dict[str, str]] = Field(default_factory=list)
|
images: list[dict[str, str]] = Field(default_factory=list)
|
||||||
popups: list[dict[str, str]] = Field(default_factory=list)
|
popups: list[PopupItem] = Field(default_factory=list)
|
||||||
tables: list[dict[str, Any]] = Field(default_factory=list)
|
tables: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
sections: list[dict[str, Any]] = Field(default_factory=list)
|
sections: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -61,6 +79,7 @@ class PageStructure(BaseModel):
|
|||||||
class Analysis(BaseModel):
|
class Analysis(BaseModel):
|
||||||
"""Stage 1A 출력: Kei 분석 결과 전체."""
|
"""Stage 1A 출력: Kei 분석 결과 전체."""
|
||||||
core_message: str = ""
|
core_message: str = ""
|
||||||
|
conclusion_text: str = "" # Phase Y: slide-base footer에 들어갈 핵심요약 원본 텍스트
|
||||||
title: str = ""
|
title: str = ""
|
||||||
total_pages: int = 1
|
total_pages: int = 1
|
||||||
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
|
layout_template: str = "A" # Phase X-B: Kei가 선택한 유형 (A 또는 B)
|
||||||
@@ -166,6 +185,9 @@ class PipelineContext(BaseModel):
|
|||||||
topics: list[Topic] = Field(default_factory=list)
|
topics: list[Topic] = Field(default_factory=list)
|
||||||
page_structure: PageStructure = Field(default_factory=PageStructure)
|
page_structure: PageStructure = Field(default_factory=PageStructure)
|
||||||
|
|
||||||
|
# ── Phase Y: MDX 원본 섹션 (## 파싱 결과) ──
|
||||||
|
mdx_sections: list[dict[str, Any]] = Field(default_factory=list) # [{title, content, level, is_intro}]
|
||||||
|
|
||||||
# ── Stage 1.5a ──
|
# ── Stage 1.5a ──
|
||||||
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
|
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
|
||||||
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
|
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
|
||||||
@@ -178,6 +200,7 @@ class PipelineContext(BaseModel):
|
|||||||
|
|
||||||
# ── Stage 1.8 ──
|
# ── Stage 1.8 ──
|
||||||
fit_result: dict[str, Any] = Field(default_factory=dict)
|
fit_result: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
font_scale: float = 1.0 # Phase Y: fit 루프에서 확정된 font 축소 비율
|
||||||
enhancement_result: dict[str, Any] = Field(default_factory=dict)
|
enhancement_result: dict[str, Any] = Field(default_factory=dict)
|
||||||
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화
|
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화
|
||||||
|
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
|||||||
"page_number": page_idx + 1,
|
"page_number": page_idx + 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
base_template = env.get_template("slide-base.html")
|
base_template = env.get_template("blocks/slide-base.html")
|
||||||
html = base_template.render(
|
html = base_template.render(
|
||||||
slide_title=title,
|
slide_title=title,
|
||||||
pages=pages_rendered,
|
pages=pages_rendered,
|
||||||
@@ -425,7 +425,7 @@ def render_slide(layout: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
||||||
|
|
||||||
base_template = env.get_template("slide-base.html")
|
base_template = env.get_template("blocks/slide-base.html")
|
||||||
html = base_template.render(
|
html = base_template.render(
|
||||||
slide_title=layout.get("title", ""),
|
slide_title=layout.get("title", ""),
|
||||||
pages=[{
|
pages=[{
|
||||||
|
|||||||
573
src/section_parser.py
Normal file
573
src/section_parser.py
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
"""Phase Y: 영역 확정 모듈.
|
||||||
|
|
||||||
|
normalized.sections(Stage 0 산출물)를 기반으로 ## 대목차 구조를 파악하고,
|
||||||
|
Kei 꼭지를 대목차에 매핑하여 영역을 확정한다.
|
||||||
|
|
||||||
|
source of truth = normalized.sections (Stage 0)
|
||||||
|
raw MDX는 사용하지 않음 (보존용/증거용으로만 존재).
|
||||||
|
|
||||||
|
용도:
|
||||||
|
- Kei 꼭지를 대목차에 매핑
|
||||||
|
- 대목차별 묶음으로 블록 tag 매칭
|
||||||
|
- 영역 확정 (코드가, Kei가 아님)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_major_sections(normalized_sections: list[dict]) -> list[dict]:
|
||||||
|
"""normalized.sections에서 ## 대목차(level=2)를 추출하고,
|
||||||
|
각 대목차 아래의 소목차(level=3) content를 합쳐서 반환.
|
||||||
|
|
||||||
|
normalized.sections 구조:
|
||||||
|
[{"level": 2, "title": "DX 시행을 위한 필수 요건", "content": ""},
|
||||||
|
{"level": 2, "title": "기술(디지털)", "content": "D1: ..."},
|
||||||
|
{"level": 3, "title": "과정(Process)의 혁신", "content": "D1: ..."}]
|
||||||
|
|
||||||
|
반환:
|
||||||
|
[{"title": "DX 시행을 위한 필수 요건", "content": "기술+사람+자연 합침", "sub_titles": ["기술","사람","자연"]},
|
||||||
|
{"title": "Process의 혁신과 Product의 변화", "content": "과정+결과 합침", "sub_titles": ["과정","결과"]}]
|
||||||
|
"""
|
||||||
|
if not normalized_sections:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# level=2 중 content가 비어있는 것 = 대목차 헤더 (아래 level=2/3이 소속)
|
||||||
|
# level=2 중 content가 있는 것 = 대목차 헤더가 없는 독립 섹션 (소목차)
|
||||||
|
# level=3 = 소목차
|
||||||
|
|
||||||
|
major_sections = []
|
||||||
|
current_major = None
|
||||||
|
|
||||||
|
for sec in normalized_sections:
|
||||||
|
level = sec.get("level", 2)
|
||||||
|
title = sec.get("title", "")
|
||||||
|
content = sec.get("content", "")
|
||||||
|
|
||||||
|
if level == 2 and not content.strip():
|
||||||
|
# 대목차 헤더 (빈 content = 아래 섹션들의 그룹 헤더)
|
||||||
|
if current_major:
|
||||||
|
major_sections.append(current_major)
|
||||||
|
current_major = {
|
||||||
|
"title": title,
|
||||||
|
"content": "",
|
||||||
|
"sub_titles": [],
|
||||||
|
}
|
||||||
|
elif level == 2 and content.strip():
|
||||||
|
# content가 있는 level=2 = 소목차 또는 독립 섹션
|
||||||
|
if current_major:
|
||||||
|
# 현재 대목차 아래의 소목차
|
||||||
|
current_major["content"] += f"\n{content}" if current_major["content"] else content
|
||||||
|
current_major["sub_titles"].append(title)
|
||||||
|
else:
|
||||||
|
# 대목차 없이 시작된 독립 섹션 (도입부)
|
||||||
|
current_major = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"sub_titles": [title],
|
||||||
|
}
|
||||||
|
elif level == 3:
|
||||||
|
# 소목차 → 현재 대목차에 합침
|
||||||
|
if current_major:
|
||||||
|
current_major["content"] += f"\n{content}" if current_major["content"] else content
|
||||||
|
current_major["sub_titles"].append(title)
|
||||||
|
else:
|
||||||
|
# 대목차 없는 level=3 (비정상이지만 처리)
|
||||||
|
current_major = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"sub_titles": [title],
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_major:
|
||||||
|
major_sections.append(current_major)
|
||||||
|
|
||||||
|
# 빈 섹션 제거
|
||||||
|
major_sections = [s for s in major_sections if s["content"].strip()]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[section_parser] {len(major_sections)}개 대목차: "
|
||||||
|
+ ", ".join(f'"{s["title"]}" (sub: {s["sub_titles"]})' for s in major_sections)
|
||||||
|
)
|
||||||
|
|
||||||
|
return major_sections
|
||||||
|
|
||||||
|
|
||||||
|
def detect_component_popups(raw_content: str, base_path: str = "") -> list[dict]:
|
||||||
|
"""Y-14: MDX에서 import된 Astro 컴포넌트를 감지하고 popup 대상으로 등록.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"name": "DxEffect", "source": "components/dx.astro",
|
||||||
|
"resolved_path": "실제 파일 경로", "content_html": "astro HTML 내용"}]
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
popups = []
|
||||||
|
# import 문 파싱
|
||||||
|
imports = re.findall(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', raw_content)
|
||||||
|
# self-closing 태그 사용 여부
|
||||||
|
used_tags = set(re.findall(r'<(\w+)\s*/>', raw_content))
|
||||||
|
|
||||||
|
for name, source in imports:
|
||||||
|
if name not in used_tags:
|
||||||
|
continue # import만 하고 사용 안 한 것은 무시
|
||||||
|
|
||||||
|
# astro 파일 경로 해석
|
||||||
|
resolved = ""
|
||||||
|
content_html = ""
|
||||||
|
if base_path:
|
||||||
|
# MDX 기준 상대경로 → 절대경로
|
||||||
|
mdx_dir = Path(base_path)
|
||||||
|
candidate = mdx_dir / source
|
||||||
|
if not candidate.exists():
|
||||||
|
# samples/src/components/ 에서 찾기
|
||||||
|
candidate = Path(base_path).parent.parent / "src" / "components" / Path(source).name
|
||||||
|
if not candidate.exists():
|
||||||
|
# 프로젝트 루트에서 찾기
|
||||||
|
candidate = Path("samples/src/components") / Path(source).name
|
||||||
|
if candidate.exists():
|
||||||
|
resolved = str(candidate)
|
||||||
|
raw = candidate.read_text(encoding="utf-8")
|
||||||
|
# astro frontmatter 제거
|
||||||
|
if raw.startswith("---"):
|
||||||
|
end = raw.find("---", 3)
|
||||||
|
if end > 0:
|
||||||
|
content_html = raw[end + 3:].strip()
|
||||||
|
else:
|
||||||
|
content_html = raw
|
||||||
|
else:
|
||||||
|
content_html = raw
|
||||||
|
|
||||||
|
popups.append({
|
||||||
|
"name": name,
|
||||||
|
"source": source,
|
||||||
|
"resolved_path": resolved,
|
||||||
|
"content_html": content_html,
|
||||||
|
"tag": f"<{name} />",
|
||||||
|
})
|
||||||
|
logger.info(f"[Y-14] 컴포넌트 popup 감지: {name} → {resolved or source}")
|
||||||
|
|
||||||
|
return popups
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_sub_types(
|
||||||
|
sub_titles: list[str], full_content: str,
|
||||||
|
normalized_sections: list[dict] | None = None,
|
||||||
|
popup_sub_titles: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""B-1: 각 sub_title의 콘텐츠 유형을 점수 기반 힌트로 판단.
|
||||||
|
|
||||||
|
점수 항목:
|
||||||
|
- 병렬 소목차 구조 (sub_titles 수, 대등성)
|
||||||
|
- 각 항목 길이 (D2 본문 길이)
|
||||||
|
- D1/D2 패턴 밀도
|
||||||
|
- popup/component 존재 여부 (popup_sub_titles)
|
||||||
|
|
||||||
|
Returns: [{title: str, sub_type: str}]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
lines = full_content.split("\n")
|
||||||
|
norm_secs = normalized_sections or []
|
||||||
|
|
||||||
|
for st in sub_titles:
|
||||||
|
st_key = re.sub(r'\*+', '', st.split("(")[0].strip()).lower()
|
||||||
|
sub_content = ""
|
||||||
|
|
||||||
|
# 1차: normalized_sections에서 섹션 title로 매칭
|
||||||
|
for sec in norm_secs:
|
||||||
|
sec_title = sec.get("title", "").lower()
|
||||||
|
if st_key and len(st_key) >= 2 and st_key in sec_title:
|
||||||
|
sub_content = sec.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2차: D1: 항목 내 매칭 (sub_title이 D1 항목명인 경우)
|
||||||
|
if not sub_content:
|
||||||
|
capturing = False
|
||||||
|
for line in lines:
|
||||||
|
d1_match = re.match(r'^D1:\s*(.*)', line.strip())
|
||||||
|
if d1_match:
|
||||||
|
d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower()
|
||||||
|
if capturing:
|
||||||
|
break
|
||||||
|
if st_key and len(st_key) >= 2 and st_key in d1_text:
|
||||||
|
capturing = True
|
||||||
|
sub_content += line.strip() + "\n"
|
||||||
|
elif capturing:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped:
|
||||||
|
sub_content += stripped + "\n"
|
||||||
|
|
||||||
|
# 점수 계산
|
||||||
|
scores = {
|
||||||
|
"parallel_card_candidate": 0,
|
||||||
|
"text_list_candidate": 0,
|
||||||
|
"visual_detail_candidate": 0,
|
||||||
|
"table_heavy_candidate": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
d2_lines = re.findall(r'^D2:', sub_content, re.MULTILINE)
|
||||||
|
d2_total_len = sum(len(l) for l in re.findall(r'^D2:\s*(.*)', sub_content, re.MULTILINE))
|
||||||
|
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', sub_content))
|
||||||
|
is_empty = len(sub_content.strip()) < 10
|
||||||
|
|
||||||
|
# parallel_card: 짧은 D2, 항목이 대등
|
||||||
|
if len(d2_lines) >= 1 and d2_total_len < 200:
|
||||||
|
scores["parallel_card_candidate"] += 3
|
||||||
|
if len(sub_titles) >= 3:
|
||||||
|
scores["parallel_card_candidate"] += 2
|
||||||
|
|
||||||
|
# text_list: 긴 D2 본문
|
||||||
|
if d2_total_len >= 100:
|
||||||
|
scores["text_list_candidate"] += 3
|
||||||
|
if len(d2_lines) >= 3:
|
||||||
|
scores["text_list_candidate"] += 2
|
||||||
|
|
||||||
|
# visual_detail: content 비거나 popup/component
|
||||||
|
if is_empty:
|
||||||
|
scores["visual_detail_candidate"] += 5
|
||||||
|
if "컴포넌트" in sub_content or "[팝업:" in sub_content:
|
||||||
|
scores["visual_detail_candidate"] += 3
|
||||||
|
# popup_sub_titles에 포함되면 강하게 visual_detail
|
||||||
|
popup_subs = popup_sub_titles or []
|
||||||
|
if any(st_key in ps.lower() for ps in popup_subs):
|
||||||
|
scores["visual_detail_candidate"] += 6
|
||||||
|
# content가 핵심요약/결론 + D1 1줄 이하면 실질적으로 빈 것 — visual_detail
|
||||||
|
# D1이 2개 이상이면 실제 본문 콘텐츠로 봄
|
||||||
|
d1_lines = re.findall(r'^D1:', sub_content, re.MULTILINE)
|
||||||
|
content_without_markers = re.sub(r'\[핵심요약:[^\]]*\]', '', sub_content).strip()
|
||||||
|
if len(d1_lines) <= 1 and len(content_without_markers) < 50 and sub_content.strip():
|
||||||
|
scores["visual_detail_candidate"] += 4
|
||||||
|
# D1이 여러 개면 본문형 content → text_list 가점
|
||||||
|
if len(d1_lines) >= 2:
|
||||||
|
scores["text_list_candidate"] += 3
|
||||||
|
|
||||||
|
# table_heavy
|
||||||
|
if has_table:
|
||||||
|
scores["table_heavy_candidate"] += 5
|
||||||
|
|
||||||
|
# 최고 점수 candidate 선택
|
||||||
|
best_type = max(scores, key=scores.get)
|
||||||
|
best_score = scores[best_type]
|
||||||
|
|
||||||
|
# 점수가 0이면 content 길이로 fallback
|
||||||
|
if best_score == 0:
|
||||||
|
if sub_content.strip():
|
||||||
|
best_type = "text_list_candidate"
|
||||||
|
else:
|
||||||
|
best_type = "visual_detail_candidate"
|
||||||
|
|
||||||
|
results.append({"title": st, "sub_type": best_type})
|
||||||
|
logger.debug(f"[sub_type] '{st}': {best_type} (scores={scores})")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def classify_group_relations(
|
||||||
|
major_sections: list[dict],
|
||||||
|
topics: list[dict] | None = None,
|
||||||
|
normalized_sections: list[dict] | None = None,
|
||||||
|
popup_sub_titles: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Y-13b: 각 대목차의 sub_titles 간 관계를 판단하여 group_schema를 부여.
|
||||||
|
|
||||||
|
규칙 기반 판단 (Kei 없이):
|
||||||
|
- sub_titles 3개 + 병렬 → parallel_cluster
|
||||||
|
- sub_titles 2개 + 비대칭 → compare_asymmetric_paired
|
||||||
|
- sub_titles 2개 + 순서/변화 → sequence_list
|
||||||
|
- sub_titles 1개 → single_block
|
||||||
|
- sub_titles 4개+ → card_cluster_N
|
||||||
|
|
||||||
|
Returns: major_sections에 group_schema 필드 추가하여 반환
|
||||||
|
"""
|
||||||
|
for sec in major_sections:
|
||||||
|
sub_titles = sec.get("sub_titles", [])
|
||||||
|
content = sec.get("content", "")
|
||||||
|
content_lower = content.lower()
|
||||||
|
n = len(sub_titles)
|
||||||
|
|
||||||
|
# sub_titles가 1개 이하지만 content에 D1: 항목이 여러 개면 → 실제 병렬 항목 수
|
||||||
|
if n <= 1:
|
||||||
|
d1_items = re.findall(r'^D1:\s*\*?\*?(.+?)\*?\*?\s*$', content, re.MULTILINE)
|
||||||
|
# 이미지/표 관련 D1 제외
|
||||||
|
d1_items = [d for d in d1_items if not d.strip().startswith('!') and not d.strip().startswith('As-is')]
|
||||||
|
if len(d1_items) >= 2:
|
||||||
|
n = len(d1_items)
|
||||||
|
sec["sub_titles"] = [re.sub(r'\*+', '', d).strip() for d in d1_items]
|
||||||
|
sub_titles = sec["sub_titles"]
|
||||||
|
|
||||||
|
if n == 0 or n == 1:
|
||||||
|
sec["group_schema"] = "single_block"
|
||||||
|
elif n == 3:
|
||||||
|
sec["group_schema"] = "parallel_cluster"
|
||||||
|
elif n == 2:
|
||||||
|
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', content))
|
||||||
|
compare_hints = ["vs", "비교", "차이", "반면"]
|
||||||
|
asymmetric_hints = ["혁신", "변화", "변환", "전환"]
|
||||||
|
process_hints = ["과정", "단계", "수행", "주체"]
|
||||||
|
sub_text = " ".join(sub_titles).lower()
|
||||||
|
effect_hints = ["기대효과", "효과", "성과", "결과물"]
|
||||||
|
|
||||||
|
all_text = content_lower + " " + sub_text
|
||||||
|
has_compare = any(h in all_text for h in compare_hints)
|
||||||
|
has_asymmetric = any(h in all_text for h in asymmetric_hints)
|
||||||
|
has_process = any(h in all_text for h in process_hints)
|
||||||
|
has_effect = any(h in all_text for h in effect_hints)
|
||||||
|
|
||||||
|
if has_table and has_asymmetric:
|
||||||
|
sec["group_schema"] = "compare_asymmetric_paired"
|
||||||
|
elif has_process and has_effect:
|
||||||
|
sec["group_schema"] = "sequence_plus_visual"
|
||||||
|
elif has_process:
|
||||||
|
sec["group_schema"] = "sequence_list"
|
||||||
|
elif has_compare:
|
||||||
|
sec["group_schema"] = "compare_paired"
|
||||||
|
else:
|
||||||
|
sec["group_schema"] = "compare_paired"
|
||||||
|
elif n == 4:
|
||||||
|
sec["group_schema"] = "card_cluster_4"
|
||||||
|
else:
|
||||||
|
sec["group_schema"] = f"card_cluster_{n}"
|
||||||
|
|
||||||
|
# 시각 앵커 포함 여부 (이미지, 차트, 컴포넌트 등)
|
||||||
|
has_visual = "이미지" in content or "![" in content or ".png" in content
|
||||||
|
if has_visual:
|
||||||
|
sec["group_schema"] += "_plus_visual"
|
||||||
|
|
||||||
|
# B-1: subsection typing — 각 sub_title의 콘텐츠 유형을 점수 기반으로 판단
|
||||||
|
sec["sub_types"] = _classify_sub_types(sub_titles, content, normalized_sections, popup_sub_titles)
|
||||||
|
|
||||||
|
logger.info(f"[Y-13b] '{sec['title']}': sub={n}개, schema={sec['group_schema']}, sub_types={[s['sub_type'] for s in sec['sub_types']]}")
|
||||||
|
|
||||||
|
return major_sections
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
# schema alias: 회귀 안전을 위해 old → new 매핑 유지
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
SCHEMA_ALIASES = {
|
||||||
|
"parallel_3": "parallel_cluster",
|
||||||
|
"parallel_3_with_image": "parallel_cluster_plus_visual",
|
||||||
|
"compare_2": "compare_paired",
|
||||||
|
"compare_asymmetric_2col": "compare_asymmetric_paired",
|
||||||
|
"process_plus_visual": "sequence_plus_visual",
|
||||||
|
"process_list": "sequence_list",
|
||||||
|
"single_section": "single_block",
|
||||||
|
"card_list_4": "card_cluster_4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_schema(schema: str) -> str:
|
||||||
|
"""old schema 이름 → new 이름으로 해소. 이미 new면 그대로 반환."""
|
||||||
|
return SCHEMA_ALIASES.get(schema, schema)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
# schema → recipe 매핑 (표현 계약)
|
||||||
|
# recipe = 블록 이름이 아닌, 레이아웃 계약
|
||||||
|
# ══════════════════════════════════════
|
||||||
|
SCHEMA_RECIPE_MAP = {
|
||||||
|
"parallel_cluster": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "parallel_cards",
|
||||||
|
"blocks": ["prerequisites-3col", "card-compare-3col", "card-icon-desc"],
|
||||||
|
},
|
||||||
|
"parallel_cluster_plus_visual": {
|
||||||
|
"recipe": "two_col_text_visual",
|
||||||
|
"left_kind": "parallel_cards",
|
||||||
|
"right_kind": "visual_anchor",
|
||||||
|
"ratio": "7:3",
|
||||||
|
"vertical_align": "center",
|
||||||
|
# direct single-block mapping 금지: p3c는 2층 구조(label+heading)라서
|
||||||
|
# 1층 구조(목표 제목만)인 plus_visual에서는 부적합.
|
||||||
|
# composition으로 쓸 가능성은 열어둠 (향후 blocks_composition에 추가 가능).
|
||||||
|
"blocks_left": ["card-icon-desc", "card-compare-3col", "card-text-grid"],
|
||||||
|
},
|
||||||
|
"compare_paired": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "compare_cards",
|
||||||
|
"blocks": ["compare-detail-gradient", "comparison-2col"],
|
||||||
|
},
|
||||||
|
"compare_asymmetric_paired": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "compare_asymmetric",
|
||||||
|
"blocks": ["process-product-2col", "compare-detail-gradient"],
|
||||||
|
},
|
||||||
|
"sequence_list": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "sequence_cards",
|
||||||
|
"blocks": ["card-step-vertical", "checklist-dark", "card-numbered"],
|
||||||
|
},
|
||||||
|
"sequence_plus_visual": {
|
||||||
|
"recipe": "two_col_text_detail",
|
||||||
|
"left_kind": "text_list",
|
||||||
|
"right_kind": "summary_and_popup",
|
||||||
|
"ratio": "6:4",
|
||||||
|
"vertical_align": "top",
|
||||||
|
"blocks_left": ["card-icon-desc", "card-step-vertical", "card-numbered"],
|
||||||
|
},
|
||||||
|
"single_block": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "text_list",
|
||||||
|
"blocks": ["dark-bullet-list", "checklist-dark", "card-numbered"],
|
||||||
|
},
|
||||||
|
"card_cluster_4": {
|
||||||
|
"recipe": "single_block",
|
||||||
|
"block_kind": "card_grid",
|
||||||
|
"blocks": ["card-icon-desc", "card-text-grid", "card-numbered"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recipe_for_schema(schema: str) -> dict:
|
||||||
|
"""schema → recipe 표현 계약 반환. alias 자동 해소."""
|
||||||
|
resolved = resolve_schema(schema)
|
||||||
|
# _plus_visual suffix 분리: base schema에서 recipe 찾고, visual 플래그 추가
|
||||||
|
base = resolved.replace("_plus_visual", "")
|
||||||
|
has_visual = "_plus_visual" in resolved
|
||||||
|
|
||||||
|
recipe = SCHEMA_RECIPE_MAP.get(resolved)
|
||||||
|
if recipe:
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
# base schema로 fallback하되 visual 플래그 추가
|
||||||
|
recipe = SCHEMA_RECIPE_MAP.get(base)
|
||||||
|
if recipe and has_visual:
|
||||||
|
# base recipe를 복사해서 visual 힌트 추가
|
||||||
|
r = dict(recipe)
|
||||||
|
r["has_visual"] = True
|
||||||
|
return r
|
||||||
|
|
||||||
|
# card_cluster_N → card_cluster_4 fallback
|
||||||
|
if base.startswith("card_cluster_"):
|
||||||
|
return SCHEMA_RECIPE_MAP.get("card_cluster_4", {})
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# C-1: recipe kind ↔ sub_type 호환 규칙
|
||||||
|
KIND_SUBTYPE_COMPAT = {
|
||||||
|
"parallel_cards": ["parallel_card_candidate"],
|
||||||
|
"text_list": ["text_list_candidate"],
|
||||||
|
"visual_anchor": ["visual_detail_candidate"],
|
||||||
|
"summary_and_popup": ["visual_detail_candidate"],
|
||||||
|
"compare_cards": ["parallel_card_candidate", "text_list_candidate"],
|
||||||
|
"compare_asymmetric": ["text_list_candidate", "table_heavy_candidate"],
|
||||||
|
"sequence_cards": ["text_list_candidate"],
|
||||||
|
"card_grid": ["parallel_card_candidate"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_kind_compatibility(recipe_kind: str, sub_types: list[dict]) -> bool:
|
||||||
|
"""recipe의 left_kind/right_kind가 실제 sub_type과 호환되는지 확인."""
|
||||||
|
compatible = KIND_SUBTYPE_COMPAT.get(recipe_kind, [])
|
||||||
|
if not compatible:
|
||||||
|
return True # 규칙 없으면 호환 가정
|
||||||
|
actual_types = [s.get("sub_type", "") for s in sub_types]
|
||||||
|
return any(t in compatible for t in actual_types)
|
||||||
|
|
||||||
|
|
||||||
|
def get_candidate_blocks_for_schema(group_schema: str) -> list[str]:
|
||||||
|
"""Y-13d: group schema에 맞는 블록 후보 ID 목록 반환. recipe 경유.
|
||||||
|
|
||||||
|
주의: *_plus_visual schema는 direct single-block 매칭 금지.
|
||||||
|
이 함수는 recipe 내부의 블록 후보를 반환할 뿐,
|
||||||
|
실제 선택은 recipe executor가 담당.
|
||||||
|
"""
|
||||||
|
recipe = get_recipe_for_schema(group_schema)
|
||||||
|
if not recipe:
|
||||||
|
return []
|
||||||
|
# recipe 유형에 따라 블록 후보 반환
|
||||||
|
recipe_type = recipe.get("recipe", "")
|
||||||
|
if recipe_type in ("two_col_text_visual", "two_col_text_detail"):
|
||||||
|
return recipe.get("blocks_left", [])
|
||||||
|
else:
|
||||||
|
return recipe.get("blocks", [])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_conclusion_text(raw_content: str) -> str:
|
||||||
|
"""raw MDX에서 :::note[핵심 요약] 텍스트만 추출.
|
||||||
|
이것만 raw MDX에서 가져옴 (normalized에 없을 수 있으므로).
|
||||||
|
"""
|
||||||
|
note_match = re.search(r':::note\[([^\]]*)\]\s*([\s\S]*?):::', raw_content)
|
||||||
|
if note_match:
|
||||||
|
text = note_match.group(2).strip()
|
||||||
|
# 마크다운 볼드/불릿 잔여 제거
|
||||||
|
text = re.sub(r'^\*\s*\*\*', '', text)
|
||||||
|
text = re.sub(r'\*\*$', '', text)
|
||||||
|
text = text.strip("* ")
|
||||||
|
# 선행 불릿 마커(*, •, -) 제거
|
||||||
|
text = re.sub(r'^[\*•\-]\s*', '', text).strip()
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def map_topics_to_sections(
|
||||||
|
topics: list[dict],
|
||||||
|
sections: list[dict],
|
||||||
|
) -> dict[str, list[int]]:
|
||||||
|
"""Kei 꼭지들을 대목차 섹션에 매핑.
|
||||||
|
|
||||||
|
각 꼭지의 title을 보고 어느 섹션의 content에 포함되는지 판단.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"1. DX 시행을 위한 필수 요건": [1, 2, 3], "2. Process의 혁신과 Product의 변화": [4, 5]}
|
||||||
|
"""
|
||||||
|
section_topics: dict[str, list[int]] = {}
|
||||||
|
for sec in sections:
|
||||||
|
section_topics[sec["title"]] = []
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
tid = topic.get("id", 0)
|
||||||
|
t_title = topic.get("title", "").lower()
|
||||||
|
t_hint = topic.get("source_hint", "").lower()
|
||||||
|
|
||||||
|
best_section = None
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for sec in sections:
|
||||||
|
sec_content = sec["content"].lower()
|
||||||
|
sec_title = sec["title"].lower()
|
||||||
|
# sub_titles에서도 매칭
|
||||||
|
sub_titles_lower = " ".join(s.lower() for s in sec.get("sub_titles", []))
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# 꼭지 제목이 섹션 content에 포함되는지
|
||||||
|
key = t_title.split("(")[0].strip()
|
||||||
|
if key and len(key) >= 2:
|
||||||
|
if key in sec_content:
|
||||||
|
score += 10
|
||||||
|
if key in sec_title:
|
||||||
|
score += 5
|
||||||
|
if key in sub_titles_lower:
|
||||||
|
score += 8 # sub_title에 직접 매칭
|
||||||
|
|
||||||
|
# source_hint에 섹션 제목 키워드가 포함되는지
|
||||||
|
sec_key = sec_title.split(".")[-1].strip().lower()[:10]
|
||||||
|
if sec_key and len(sec_key) >= 2 and sec_key in t_hint:
|
||||||
|
score += 3
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_section = sec["title"]
|
||||||
|
|
||||||
|
if best_section and best_score > 0:
|
||||||
|
section_topics[best_section].append(tid)
|
||||||
|
else:
|
||||||
|
# 매칭 안 되면 첫 번째 섹션에 넣음
|
||||||
|
if sections:
|
||||||
|
section_topics[sections[0]["title"]].append(tid)
|
||||||
|
logger.warning(f"[section_parser] 꼭지 {tid} '{t_title}' 섹션 매핑 실패 → 첫 섹션")
|
||||||
|
|
||||||
|
# 빈 섹션 제거
|
||||||
|
section_topics = {k: v for k, v in section_topics.items() if v}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[section_parser] 꼭지-섹션 매핑: "
|
||||||
|
+ ", ".join(f'"{k}": {v}' for k, v in section_topics.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
return section_topics
|
||||||
@@ -34,11 +34,11 @@ _MEASURE_SCRIPT = """
|
|||||||
containers: {}
|
containers: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zone 측정 (area-* 클래스)
|
// Zone 측정 (area-* 또는 zone-* 클래스)
|
||||||
var areaDivs = slide.querySelectorAll('[class*="area-"]');
|
var areaDivs = slide.querySelectorAll('[class*="area-"], [class*="zone-"]');
|
||||||
for (var i = 0; i < areaDivs.length; i++) {
|
for (var i = 0; i < areaDivs.length; i++) {
|
||||||
var zone = areaDivs[i];
|
var zone = areaDivs[i];
|
||||||
var areaMatch = zone.className.match(/area-(\\w+)/);
|
var areaMatch = zone.className.match(/(?:area|zone)-(\\w+)/);
|
||||||
if (!areaMatch) continue;
|
if (!areaMatch) continue;
|
||||||
var areaName = areaMatch[1];
|
var areaName = areaMatch[1];
|
||||||
|
|
||||||
|
|||||||
@@ -468,9 +468,9 @@ def build_containers_type_b(
|
|||||||
inner_w = slide_width - pad * 2
|
inner_w = slide_width - pad * 2
|
||||||
|
|
||||||
# 역할을 zone별로 분류
|
# 역할을 zone별로 분류
|
||||||
top_roles = [] # zone=top
|
top_roles = [] # zone=top
|
||||||
bottom_roles = [] # zone=bottom_left, bottom_right
|
bottom_roles = [] # zone=bottom (전체폭) 또는 bottom_left/bottom_right (2분할)
|
||||||
footer_role = None # zone=footer
|
footer_role = None # zone=footer (Phase Y: 결론은 slide-base가 처리, 여기서 무시)
|
||||||
|
|
||||||
for role_name, info in page_structure.items():
|
for role_name, info in page_structure.items():
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
@@ -478,32 +478,46 @@ def build_containers_type_b(
|
|||||||
zone = info.get("zone", "")
|
zone = info.get("zone", "")
|
||||||
if zone == "top":
|
if zone == "top":
|
||||||
top_roles.append((role_name, info))
|
top_roles.append((role_name, info))
|
||||||
elif zone in ("bottom_left", "bottom_right"):
|
elif zone in ("bottom", "bottom_left", "bottom_right"):
|
||||||
bottom_roles.append((role_name, info))
|
bottom_roles.append((role_name, info))
|
||||||
elif zone == "footer":
|
elif zone == "footer":
|
||||||
footer_role = (role_name, info)
|
footer_role = (role_name, info)
|
||||||
|
|
||||||
# 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
|
# Phase Y: slide-base.html 기준으로 가용 높이 계산
|
||||||
total_available = slide_height - pad * 2 - header_h - gap_block
|
# slide-base: .slide-body = top:65px, height:590px
|
||||||
|
# 하단 footer pill = 41px (slide-base가 관리, 여기서 빼지 않음)
|
||||||
|
slide_body_top = 65 # slide-base .slide-body top
|
||||||
|
slide_body_h = 590 # slide-base .slide-body height
|
||||||
|
total_available = slide_body_h
|
||||||
|
|
||||||
# footer 높이: weight 비율 (최소 보장)
|
# footer zone이 있으면 기존 방식으로 공간 배분 (하위 호환)
|
||||||
footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
|
# footer zone이 없으면 (Phase Y) slide-base footer가 처리 → 전체를 zone에 사용
|
||||||
footer_h_raw = int(total_available * footer_weight)
|
if footer_role:
|
||||||
_footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
|
footer_weight = footer_role[1].get("weight", 0.1)
|
||||||
footer_h = max(_footer_min, footer_h_raw)
|
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)
|
||||||
|
middle_h = total_available - footer_h - gap_block
|
||||||
|
else:
|
||||||
|
footer_h = 0
|
||||||
|
middle_h = total_available
|
||||||
|
|
||||||
# 중간 영역: footer + gap 제외
|
# Phase Y: zone 제목 + gap 공간 확보
|
||||||
middle_h = total_available - footer_h - gap_block
|
zone_count = len(top_roles) + len(bottom_roles)
|
||||||
|
zone_title_h = 28 # zone 제목 높이 (assembler와 동일)
|
||||||
|
zone_gap = 16 # zone 간 여백 (assembler와 동일)
|
||||||
|
zone_overhead = zone_count * zone_title_h + max(0, zone_count - 1) * zone_gap
|
||||||
|
usable_h = middle_h - zone_overhead
|
||||||
|
|
||||||
# 상단/하단 높이: weight 비율로
|
# 상단/하단 높이: weight 비율로 (usable 영역에서)
|
||||||
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
|
top_weight = sum(info.get("weight", 0) for _, info in top_roles)
|
||||||
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
|
bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
|
||||||
total_mid_weight = top_weight + bottom_weight
|
total_mid_weight = top_weight + bottom_weight
|
||||||
if total_mid_weight <= 0:
|
if total_mid_weight <= 0:
|
||||||
total_mid_weight = 1
|
total_mid_weight = 1
|
||||||
|
|
||||||
top_h = int(middle_h * top_weight / total_mid_weight)
|
top_h = int(usable_h * top_weight / total_mid_weight)
|
||||||
bottom_h = middle_h - top_h - gap_small # gap_small: 상단-하단 사이
|
bottom_h = usable_h - top_h
|
||||||
|
|
||||||
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
|
# 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
|
||||||
img_ratio = 0
|
img_ratio = 0
|
||||||
@@ -541,16 +555,20 @@ def build_containers_type_b(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 하단 역할: 2분할
|
# 하단 역할: zone에 따라 전체폭 또는 2분할
|
||||||
bottom_col_w = (inner_w - gap_block) // 2
|
has_bottom_full = any(info.get("zone") == "bottom" for _, info in bottom_roles)
|
||||||
|
bottom_col_w = inner_w if has_bottom_full else (inner_w - gap_block) // 2
|
||||||
|
|
||||||
for role_name, info in bottom_roles:
|
for role_name, info in bottom_roles:
|
||||||
|
zone = info.get("zone", "bottom_left")
|
||||||
|
w = inner_w if zone == "bottom" else bottom_col_w
|
||||||
specs[role_name] = ContainerSpec(
|
specs[role_name] = ContainerSpec(
|
||||||
role=role_name,
|
role=role_name,
|
||||||
zone=info.get("zone", "bottom_left"),
|
zone=zone,
|
||||||
topic_ids=info.get("topic_ids", []),
|
topic_ids=info.get("topic_ids", []),
|
||||||
weight=info.get("weight", 0),
|
weight=info.get("weight", 0),
|
||||||
height_px=bottom_h,
|
height_px=bottom_h,
|
||||||
width_px=bottom_col_w,
|
width_px=w,
|
||||||
max_height_cost=_max_allowed_height_cost(bottom_h),
|
max_height_cost=_max_allowed_height_cost(bottom_h),
|
||||||
block_constraints={},
|
block_constraints={},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,50 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
COLORS_A = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||||
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
FONT_MAP_A = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||||
|
|
||||||
|
# Type B용 동적 색상 팔레트
|
||||||
|
_COLOR_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#dc2626", "#0891b2"]
|
||||||
|
|
||||||
|
# 하위 호환: 기존 코드에서 COLORS/FONT_MAP 참조하는 곳 대응
|
||||||
|
COLORS = COLORS_A
|
||||||
|
FONT_MAP = FONT_MAP_A
|
||||||
|
|
||||||
|
|
||||||
|
def _is_type_b(ctx) -> bool:
|
||||||
|
"""page_structure에 zone 키가 있으면 Type B."""
|
||||||
|
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||||||
|
for info in ps.values():
|
||||||
|
if isinstance(info, dict) and info.get("zone") in ("top", "bottom_left", "bottom_right"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_roles(ctx) -> list[str]:
|
||||||
|
"""page_structure의 실제 역할명 목록 (순서: zone 기준)."""
|
||||||
|
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||||||
|
if _is_type_b(ctx):
|
||||||
|
zone_order = {"top": 0, "bottom_left": 1, "bottom_right": 2, "footer": 3}
|
||||||
|
roles = []
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if isinstance(info, dict):
|
||||||
|
z = info.get("zone", "")
|
||||||
|
roles.append((zone_order.get(z, 9), role_name))
|
||||||
|
return [r for _, r in sorted(roles)]
|
||||||
|
else:
|
||||||
|
return ["배경", "본심", "첨부", "결론"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_color(role: str, ctx=None) -> str:
|
||||||
|
"""역할명 → 색상. Type A는 고정, Type B는 동적."""
|
||||||
|
if role in COLORS_A:
|
||||||
|
return COLORS_A[role]
|
||||||
|
if ctx:
|
||||||
|
roles = _get_roles(ctx)
|
||||||
|
idx = roles.index(role) if role in roles else 0
|
||||||
|
return _COLOR_PALETTE[idx % len(_COLOR_PALETTE)]
|
||||||
|
return "#666666"
|
||||||
|
|
||||||
|
|
||||||
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
|
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
|
||||||
@@ -74,21 +116,65 @@ def _tokens():
|
|||||||
return _load_design_tokens()
|
return _load_design_tokens()
|
||||||
|
|
||||||
|
|
||||||
def _calc_coords(containers: dict, ratio: tuple) -> dict:
|
def _calc_coords(containers: dict, ratio: tuple, ctx=None) -> dict:
|
||||||
|
"""역할별 좌표 계산. Type A/B 자동 분기."""
|
||||||
t = _tokens()
|
t = _tokens()
|
||||||
pad = t.get("spacing_page", 40)
|
pad = t.get("spacing_page", 40)
|
||||||
gap = t.get("spacing_block", 20)
|
gap = t.get("spacing_block", 20)
|
||||||
small = t.get("spacing_small", 8)
|
small = t.get("spacing_small", 8)
|
||||||
header_h = 66
|
header_h = 66
|
||||||
|
|
||||||
inner_w = 1280 - pad * 2
|
inner_w = 1280 - pad * 2
|
||||||
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
|
|
||||||
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
|
|
||||||
|
|
||||||
def gh(c):
|
def gh(c):
|
||||||
if hasattr(c, "height_px"): return c.height_px
|
if hasattr(c, "height_px"): return c.height_px
|
||||||
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
||||||
|
|
||||||
|
def gw(c):
|
||||||
|
if hasattr(c, "width_px"): return c.width_px
|
||||||
|
return c.get("width_px", 0) if isinstance(c, dict) else 0
|
||||||
|
|
||||||
|
# Type B 감지
|
||||||
|
if ctx and _is_type_b(ctx):
|
||||||
|
ps = ctx.page_structure.roles
|
||||||
|
coords = {"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h}}
|
||||||
|
|
||||||
|
# zone별 컨테이너 찾기
|
||||||
|
zone_map = {}
|
||||||
|
for role_name, info in ps.items():
|
||||||
|
if isinstance(info, dict):
|
||||||
|
zone_map[info.get("zone", "")] = role_name
|
||||||
|
|
||||||
|
top_role = zone_map.get("top", "")
|
||||||
|
bl_role = zone_map.get("bottom_left", "")
|
||||||
|
br_role = zone_map.get("bottom_right", "")
|
||||||
|
ft_role = zone_map.get("footer", "")
|
||||||
|
|
||||||
|
top_h = gh(containers.get(top_role, {}))
|
||||||
|
bl_h = gh(containers.get(bl_role, {}))
|
||||||
|
br_h = gh(containers.get(br_role, {}))
|
||||||
|
ft_h = gh(containers.get(ft_role, {}))
|
||||||
|
|
||||||
|
top_top = pad + header_h + gap
|
||||||
|
bottom_top = top_top + top_h + small
|
||||||
|
bottom_h = max(bl_h, br_h)
|
||||||
|
ft_top = bottom_top + bottom_h + gap
|
||||||
|
bottom_col_w = (inner_w - gap) // 2
|
||||||
|
|
||||||
|
if top_role:
|
||||||
|
coords[top_role] = {"l": pad, "t": top_top, "w": inner_w, "h": top_h}
|
||||||
|
if bl_role:
|
||||||
|
coords[bl_role] = {"l": pad, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
|
||||||
|
if br_role:
|
||||||
|
coords[br_role] = {"l": pad + bottom_col_w + gap, "t": bottom_top, "w": bottom_col_w, "h": bottom_h}
|
||||||
|
if ft_role:
|
||||||
|
coords[ft_role] = {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h}
|
||||||
|
|
||||||
|
return coords
|
||||||
|
|
||||||
|
# Type A (기존)
|
||||||
|
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
|
||||||
|
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
|
||||||
|
|
||||||
bg_h = gh(containers.get("배경", {}))
|
bg_h = gh(containers.get("배경", {}))
|
||||||
core_h = gh(containers.get("본심", {}))
|
core_h = gh(containers.get("본심", {}))
|
||||||
sb_h = gh(containers.get("첨부", {}))
|
sb_h = gh(containers.get("첨부", {}))
|
||||||
@@ -107,12 +193,38 @@ def _calc_coords(containers: dict, ratio: tuple) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _wrap(title, subtitle, slide_body):
|
def _wrap(title, subtitle, slide_body, ctx=None):
|
||||||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
"""slide-base.html 기반 래핑. step 시각화도 실제 슬라이드와 같은 기반 사용."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
slide_base_path = Path(__file__).parent.parent / "templates" / "blocks" / "slide-base.html"
|
||||||
|
slide_title = ""
|
||||||
|
footer_text = ""
|
||||||
|
if ctx:
|
||||||
|
slide_title = ctx.analysis.title if ctx.analysis else ""
|
||||||
|
footer_text = ctx.analysis.conclusion_text if ctx.analysis else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = slide_base_path.read_text(encoding="utf-8")
|
||||||
|
# {% block body %} → slide_body로 치환
|
||||||
|
raw = raw.replace("{% block body %}{% endblock %}", slide_body)
|
||||||
|
from jinja2 import Template
|
||||||
|
template = Template(raw)
|
||||||
|
slide_html = template.render(title=slide_title, footer_text=footer_text, footer_pill_bg="")
|
||||||
|
# step 라벨 추가
|
||||||
|
label = (f'<div style="position:fixed;top:4px;left:4px;z-index:999;font-size:14px;'
|
||||||
|
f'font-weight:bold;background:rgba(255,255,255,0.9);padding:4px 8px;border-radius:4px;">'
|
||||||
|
f'{title}</div>'
|
||||||
|
f'<div style="position:fixed;top:26px;left:4px;z-index:999;font-size:10px;'
|
||||||
|
f'color:#666;background:rgba(255,255,255,0.9);padding:2px 8px;border-radius:4px;">'
|
||||||
|
f'{subtitle}</div>')
|
||||||
|
return slide_html.replace('<body>', f'<body>{label}')
|
||||||
|
except Exception:
|
||||||
|
# fallback: 기존 방식
|
||||||
|
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
<style>
|
<style>
|
||||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
*{{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;}}
|
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;}}.bl-t{{flex:1;}}
|
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
|
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
|
||||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
|
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
|
||||||
@@ -263,23 +375,39 @@ def _gen_stage_1b(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_5a(ctx, steps_dir):
|
def _gen_stage_1_5a(ctx, steps_dir):
|
||||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
"""slide-base 위에 빈 zone 컨테이너만 표시."""
|
||||||
fh = ctx.font_hierarchy
|
ps = ctx.page_structure.roles
|
||||||
title = ctx.analysis.title or "슬라이드"
|
gap = 8
|
||||||
body = _hdr(coords["header"], title)
|
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
# zone 순서
|
||||||
c = coords[role]
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
cl = COLORS[role]
|
roles_sorted = sorted(
|
||||||
fk = FONT_MAP[role]
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
font = getattr(fh, fk, "?")
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
|
)
|
||||||
f'<b style="color:{cl};font-size:13px;">{role}</b><br>'
|
|
||||||
f'<span style="color:#888;font-size:10px;">{c["w"]}x{c["h"]}px / font:{font}px</span></div>')
|
|
||||||
body += _box(c, role, inner)
|
|
||||||
|
|
||||||
r = ctx.container_ratio
|
body_html = ""
|
||||||
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
|
for i, (role, info) in enumerate(roles_sorted):
|
||||||
|
ci = ctx.containers.get(role)
|
||||||
|
if not ci:
|
||||||
|
continue
|
||||||
|
cl = _get_color(role, ctx)
|
||||||
|
zone = info.get("zone", "")
|
||||||
|
w = ci.width_px
|
||||||
|
h = ci.height_px
|
||||||
|
tids = info.get("topic_ids", [])
|
||||||
|
|
||||||
|
body_html += (
|
||||||
|
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
|
||||||
|
f'margin-bottom:{gap}px;display:flex;align-items:center;justify-content:center;">'
|
||||||
|
f'<div style="text-align:center;">'
|
||||||
|
f'<b style="color:{cl};font-size:14px;">{role}</b><br>'
|
||||||
|
f'<span style="color:#888;font-size:11px;">zone: {zone} / {w}×{h}px</span><br>'
|
||||||
|
f'<span style="color:#aaa;font-size:10px;">topics: {tids}</span>'
|
||||||
|
f'</div></div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
html = _wrap("Stage 1.5a: 빈 컨테이너", "slide-base 위에 zone 배치", body_html, ctx=ctx)
|
||||||
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -288,19 +416,28 @@ def _gen_stage_1_5a(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_5a_content(ctx, steps_dir):
|
def _gen_stage_1_5a_content(ctx, steps_dir):
|
||||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
"""slide-base 위 zone에 topic 콘텐츠 배치."""
|
||||||
title = ctx.analysis.title or "슬라이드"
|
|
||||||
body = _hdr(coords["header"], title)
|
|
||||||
ps = ctx.page_structure.roles
|
ps = ctx.page_structure.roles
|
||||||
topic_map = {t.id: t for t in ctx.topics}
|
topic_map = {t.id: t for t in ctx.topics}
|
||||||
|
gap = 8
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
c = coords[role]
|
roles_sorted = sorted(
|
||||||
cl = COLORS[role]
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
info = ps.get(role, {})
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
)
|
||||||
|
|
||||||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role}</div>']
|
body_html = ""
|
||||||
|
for role, info in roles_sorted:
|
||||||
|
ci = ctx.containers.get(role)
|
||||||
|
if not ci:
|
||||||
|
continue
|
||||||
|
cl = _get_color(role, ctx)
|
||||||
|
tids = info.get("topic_ids", [])
|
||||||
|
w = ci.width_px
|
||||||
|
h = ci.height_px
|
||||||
|
|
||||||
|
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
|
||||||
for tid in tids:
|
for tid in tids:
|
||||||
t = topic_map.get(tid)
|
t = topic_map.get(tid)
|
||||||
if not t:
|
if not t:
|
||||||
@@ -308,16 +445,18 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
|
|||||||
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}</div>')
|
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}</div>')
|
||||||
sd = t.source_data
|
sd = t.source_data
|
||||||
if sd:
|
if sd:
|
||||||
# 불릿으로 표시
|
for sent in sd.split(", ")[:5]:
|
||||||
for sent in sd.split(", "):
|
|
||||||
sent = sent.strip()
|
sent = sent.strip()
|
||||||
if sent:
|
if sent:
|
||||||
lines.append(f'<div class="bl" style="font-size:10px;color:#444;"><span class="bl-m">•</span><span class="bl-t">{sent}</span></div>')
|
lines.append(f'<div style="font-size:10px;color:#444;padding-left:12px;">• {sent}</div>')
|
||||||
|
|
||||||
inner = f'<div style="padding:6px 10px;overflow:hidden;">{"".join(lines)}</div>'
|
body_html += (
|
||||||
body += _box(c, role, inner)
|
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||||||
|
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||||||
|
f'{"".join(lines)}</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
|
html = _wrap("Stage 1.5a: 콘텐츠 배치", "zone별 topic source_data", body_html, ctx=ctx)
|
||||||
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -326,36 +465,41 @@ def _gen_stage_1_5a_content(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_5b(ctx, steps_dir):
|
def _gen_stage_1_5b(ctx, steps_dir):
|
||||||
"""영역별 디자인 예산 (available height/width, fits 여부)."""
|
"""slide-base 위 zone별 디자인 예산."""
|
||||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
ps = ctx.page_structure.roles
|
||||||
title = ctx.analysis.title or "슬라이드"
|
gap = 8
|
||||||
body = _hdr(coords["header"], title)
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
|
roles_sorted = sorted(
|
||||||
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
|
)
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
body_html = ""
|
||||||
c = coords[role]
|
for role, info in roles_sorted:
|
||||||
cl = COLORS[role]
|
|
||||||
ci = ctx.containers.get(role)
|
ci = ctx.containers.get(role)
|
||||||
if not ci:
|
if not ci:
|
||||||
continue
|
continue
|
||||||
|
cl = _get_color(role, ctx)
|
||||||
|
w, h = ci.width_px, ci.height_px
|
||||||
db = ci.design_budget
|
db = ci.design_budget
|
||||||
if db and hasattr(db, 'model_dump'):
|
if db and hasattr(db, 'model_dump'):
|
||||||
db = db.model_dump()
|
db = db.model_dump()
|
||||||
elif not isinstance(db, dict):
|
elif not isinstance(db, dict):
|
||||||
db = {}
|
db = {}
|
||||||
|
|
||||||
avail_h = db.get("available_height_px", 0)
|
avail_h = db.get("available_height_px", 0)
|
||||||
avail_w = db.get("available_width_px", 0)
|
avail_w = db.get("available_width_px", 0)
|
||||||
fits = db.get("fits", False)
|
fits = db.get("fits", False)
|
||||||
icon = "✅" if fits else "⚠️"
|
icon = "✅" if fits else "⚠️"
|
||||||
|
|
||||||
inner = (f'<div style="padding:6px 10px;">'
|
body_html += (
|
||||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}×{c["h"]}px)</div>'
|
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||||||
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px</div>'
|
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||||||
f'<div style="font-size:10px;color:#555;">fits: {fits}</div>'
|
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{h}px)</div>'
|
||||||
f'</div>')
|
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px / fits: {fits}</div>'
|
||||||
body += _box(c, role, inner)
|
f'</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
|
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body_html, ctx=ctx)
|
||||||
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -364,30 +508,36 @@ def _gen_stage_1_5b(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_7(ctx, steps_dir):
|
def _gen_stage_1_7(ctx, steps_dir):
|
||||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
"""slide-base 위 zone별 선택된 블록 표시."""
|
||||||
title = ctx.analysis.title or "슬라이드"
|
ps = ctx.page_structure.roles
|
||||||
body = _hdr(coords["header"], title)
|
gap = 8
|
||||||
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
|
roles_sorted = sorted(
|
||||||
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
|
)
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
body_html = ""
|
||||||
c = coords[role]
|
for role, info in roles_sorted:
|
||||||
cl = COLORS[role]
|
ci = ctx.containers.get(role)
|
||||||
|
if not ci:
|
||||||
|
continue
|
||||||
|
cl = _get_color(role, ctx)
|
||||||
|
w, h = ci.width_px, ci.height_px
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
|
|
||||||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
|
lines = [f'<div style="font-size:11px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({w}×{h}px)</div>']
|
||||||
for r in ref_list:
|
for r in ref_list:
|
||||||
bid = r.block_id
|
vtype_label = "tag_match ✅" if r.visual_type == "tag_match" else r.visual_type
|
||||||
var = r.variant
|
lines.append(f'<div style="font-size:12px;margin-bottom:2px;"><b>{r.block_id}</b> ({r.variant}) — {vtype_label}</div>')
|
||||||
vtype = r.visual_type
|
|
||||||
line = f'<b>{bid}</b> ({var}) <span style="color:#888;font-size:9px;">{vtype}</span>'
|
|
||||||
# 주종 정보 — model_dump에서 확인
|
|
||||||
rd = r.model_dump() if hasattr(r, "model_dump") else {}
|
|
||||||
# BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
|
|
||||||
lines.append(f'<div style="font-size:11px;margin-bottom:2px;">{line}</div>')
|
|
||||||
|
|
||||||
inner = f'<div style="padding:6px 10px;">{"".join(lines)}</div>'
|
body_html += (
|
||||||
body += _box(c, role, inner)
|
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||||||
|
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||||||
|
f'{"".join(lines)}</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
|
html = _wrap("Stage 1.7: 블록 선택", "tag 매칭 기반 블록 선택", body_html, ctx=ctx)
|
||||||
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -411,30 +561,35 @@ def _gen_stage_1_8_filled(ctx, steps_dir):
|
|||||||
|
|
||||||
|
|
||||||
def _gen_stage_1_8_fit_before(ctx, steps_dir):
|
def _gen_stage_1_8_fit_before(ctx, steps_dir):
|
||||||
"""before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시."""
|
"""slide-base 위 zone별 초기 배정 (weight 기반)."""
|
||||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
ps = ctx.page_structure.roles
|
||||||
title = ctx.analysis.title or "슬라이드"
|
gap = 8
|
||||||
body = _hdr(coords["header"], title)
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
|
roles_sorted = sorted(
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
c = coords[role]
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
cl = COLORS[role]
|
)
|
||||||
|
|
||||||
|
body_html = ""
|
||||||
|
for role, info in roles_sorted:
|
||||||
|
ci = ctx.containers.get(role)
|
||||||
|
if not ci:
|
||||||
|
continue
|
||||||
|
cl = _get_color(role, ctx)
|
||||||
|
w, h = ci.width_px, ci.height_px
|
||||||
|
weight = info.get("weight", 0)
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
||||||
|
|
||||||
ps = ctx.page_structure.roles
|
body_html += (
|
||||||
info = ps.get(role, {})
|
f'<div style="width:{w}px;height:{h}px;border:2px dashed {cl};border-radius:6px;'
|
||||||
weight = info.get("weight", 0) if isinstance(info, dict) else 0
|
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||||||
|
f'<div style="font-size:11px;color:{cl};font-weight:700;">{role} ({w}×{h}px)</div>'
|
||||||
|
f'<div style="font-size:10px;color:#555;">weight: {weight} / 블록: {blocks}</div>'
|
||||||
|
f'</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
inner = (f'<div style="padding:6px 10px;">'
|
html = _wrap("Stage 1.8: before", "weight 비중 초기 배정. 블록 채움 전.", body_html, ctx=ctx)
|
||||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{role} ({c["w"]}x{c["h"]}px)</div>'
|
|
||||||
f'<div style="font-size:10px;color:#555;">weight: {weight}</div>'
|
|
||||||
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>'
|
|
||||||
f'</div>')
|
|
||||||
body += _box(c, role, inner)
|
|
||||||
|
|
||||||
html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
|
|
||||||
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -443,65 +598,48 @@ def _gen_stage_1_8_fit_before(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_8_fit_after(ctx, steps_dir):
|
def _gen_stage_1_8_fit_after(ctx, steps_dir):
|
||||||
fit = ctx.fit_result
|
"""slide-base 위 zone별 재배분 결과."""
|
||||||
enh = ctx.enhancement_result
|
ps = ctx.page_structure.roles
|
||||||
|
fit = ctx.fit_result or {}
|
||||||
|
enh = ctx.enhancement_result or {}
|
||||||
redist = fit.get("redistribution", {})
|
redist = fit.get("redistribution", {})
|
||||||
roles_fit = fit.get("roles", {})
|
roles_fit = fit.get("roles", {})
|
||||||
|
gap = 8
|
||||||
|
|
||||||
# 재배분된 컨테이너
|
zone_priority = {"top": 0, "bottom": 1, "bottom_left": 2, "bottom_right": 3}
|
||||||
new_c = {}
|
roles_sorted = sorted(
|
||||||
for role, ci in ctx.containers.items():
|
[(r, info) for r, info in ps.items() if isinstance(info, dict)],
|
||||||
new_h = int(redist.get(role, ci.height_px))
|
key=lambda x: zone_priority.get(x[1].get("zone", ""), 9),
|
||||||
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
|
)
|
||||||
|
|
||||||
coords = _calc_coords(new_c, ctx.container_ratio)
|
body_html = ""
|
||||||
title = ctx.analysis.title or "슬라이드"
|
for role, info in roles_sorted:
|
||||||
body = _hdr(coords["header"], title)
|
ci = ctx.containers.get(role)
|
||||||
|
if not ci:
|
||||||
emps = enh.get("emphasis_blocks", [])
|
continue
|
||||||
bolds = enh.get("bold_keywords", {})
|
cl = _get_color(role, ctx)
|
||||||
sups = enh.get("supplement_blocks", [])
|
w = ci.width_px
|
||||||
|
old_h = ci.height_px
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
|
||||||
c = coords[role]
|
|
||||||
cl = COLORS[role]
|
|
||||||
rf = roles_fit.get(role, {})
|
|
||||||
status = rf.get("fit_status", "?")
|
|
||||||
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
|
|
||||||
old_h = rf.get("allocated_px", 0)
|
|
||||||
new_h = int(redist.get(role, old_h))
|
new_h = int(redist.get(role, old_h))
|
||||||
needed = rf.get("total_required_px", 0)
|
rf = roles_fit.get(role, {})
|
||||||
|
status = rf.get("fit_status", "OK")
|
||||||
|
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "✅")
|
||||||
delta = new_h - old_h
|
delta = new_h - old_h
|
||||||
|
delta_str = f" ({delta:+d}px)" if delta != 0 else ""
|
||||||
|
|
||||||
ref_list = ctx.references.get(role, [])
|
ref_list = ctx.references.get(role, [])
|
||||||
blocks = ", ".join(r.block_id for r in ref_list)
|
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else ""
|
||||||
|
|
||||||
delta_str = f" <span style='color:#16a34a;'>({delta:+d}px)</span>" if delta != 0 else ""
|
body_html += (
|
||||||
|
f'<div style="width:{w}px;height:{new_h}px;border:2px solid {cl};border-radius:6px;'
|
||||||
|
f'margin-bottom:{gap}px;padding:6px 10px;overflow:hidden;">'
|
||||||
|
f'<div style="font-size:11px;color:{cl};font-weight:700;">{icon} {role} ({w}×{new_h}px){delta_str}</div>'
|
||||||
|
f'<div style="font-size:10px;color:#555;">블록: {blocks}</div>'
|
||||||
|
f'</div>\n'
|
||||||
|
)
|
||||||
|
|
||||||
inner = (f'<div style="padding:6px 10px;">'
|
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items()) if redist else "재배분 없음"
|
||||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}x{new_h}px){delta_str}</div>'
|
html = _wrap("Stage 1.8: 재배분 후", f"재배분: {redist_str}", body_html, ctx=ctx)
|
||||||
f'<div style="font-size:10px;color:#555;">필요: {needed:.0f}px / 재배분 후: {new_h}px</div>'
|
|
||||||
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>')
|
|
||||||
|
|
||||||
# 보강 정보
|
|
||||||
role_emps = [e for e in emps if e.get("role") == role]
|
|
||||||
role_bolds = bolds.get(role, [])
|
|
||||||
role_sups = [s for s in sups if s.get("role") == role]
|
|
||||||
|
|
||||||
if role_emps:
|
|
||||||
for e in role_emps:
|
|
||||||
inner += f'<div style="margin-top:3px;background:#991b1b;color:#fff;border-radius:2px;padding:2px 6px;font-size:9px;font-weight:700;">강조: {e.get("sentence","")[:40]}...</div>'
|
|
||||||
if role_sups:
|
|
||||||
for s in role_sups:
|
|
||||||
inner += f'<div style="margin-top:3px;font-size:9px;color:#2563eb;">보충: {s.get("block_id")} ({s.get("content_source")})</div>'
|
|
||||||
if role_bolds:
|
|
||||||
inner += f'<div style="margin-top:3px;font-size:9px;color:#475569;">bold: {role_bolds[:4]}</div>'
|
|
||||||
|
|
||||||
inner += '</div>'
|
|
||||||
body += _box(c, role, inner)
|
|
||||||
|
|
||||||
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items())
|
|
||||||
html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body)
|
|
||||||
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -510,161 +648,44 @@ def _gen_stage_1_8_fit_after(ctx, steps_dir):
|
|||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
def _gen_stage_1_8_blocks(ctx, steps_dir):
|
def _gen_stage_1_8_blocks(ctx, steps_dir):
|
||||||
"""재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시.
|
"""slide-base 위에 실제 블록 렌더링. assemble_slide_html_final과 동일한 결과."""
|
||||||
debug_steps/step2_phase_v.html 수준의 시각화."""
|
from src.block_assembler import assemble_slide_html_final
|
||||||
import re as _re
|
html = assemble_slide_html_final(ctx)
|
||||||
|
|
||||||
fit = ctx.fit_result or {}
|
|
||||||
redist = fit.get("redistribution", {})
|
|
||||||
topic_map = {t.id: t for t in ctx.topics}
|
|
||||||
ps = ctx.page_structure.roles
|
|
||||||
|
|
||||||
new_c = {}
|
|
||||||
for role, ci in ctx.containers.items():
|
|
||||||
new_h = int(redist.get(role, ci.height_px))
|
|
||||||
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
|
|
||||||
|
|
||||||
coords = _calc_coords(new_c, ctx.container_ratio)
|
|
||||||
title = ctx.analysis.title or "슬라이드"
|
|
||||||
|
|
||||||
all_block_css = set()
|
|
||||||
slide_body = _hdr(coords["header"], title)
|
|
||||||
legend_lines = []
|
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
|
||||||
c = coords[role]
|
|
||||||
cl = COLORS[role]
|
|
||||||
ref_list = ctx.references.get(role, [])
|
|
||||||
info = ps.get(role, {})
|
|
||||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
|
||||||
|
|
||||||
if not ref_list:
|
|
||||||
slide_body += _box(c, role, f'<div style="padding:6px;color:#888;">블록 없음</div>')
|
|
||||||
continue
|
|
||||||
|
|
||||||
r0 = ref_list[0]
|
|
||||||
bid = r0.block_id
|
|
||||||
var = r0.variant
|
|
||||||
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
|
|
||||||
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
|
|
||||||
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
|
|
||||||
|
|
||||||
# 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로
|
|
||||||
raw = r0.design_reference_html or ""
|
|
||||||
# CSS 추출
|
|
||||||
styles = _re.findall(r'<style>(.*?)</style>', raw, _re.DOTALL)
|
|
||||||
for s in styles:
|
|
||||||
all_block_css.add(s)
|
|
||||||
clean = _re.sub(r'<style>.*?</style>', '', raw, flags=_re.DOTALL)
|
|
||||||
|
|
||||||
# SLOT 주석을 보이는 텍스트로 변환
|
|
||||||
def _slot_comment_to_visible(match):
|
|
||||||
text = match.group(1).strip()
|
|
||||||
if 'SLOT:' in text:
|
|
||||||
return f'<span style="color:#888;font-size:9px;background:#f0f0f0;padding:1px 4px;border-radius:2px;">{text}</span>'
|
|
||||||
return ''
|
|
||||||
clean = _re.sub(r'<!--\s*(SLOT:[^>]+?)-->', _slot_comment_to_visible, clean)
|
|
||||||
# 나머지 주석 제거
|
|
||||||
clean = _re.sub(r'<!--.*?-->', '', clean, flags=_re.DOTALL)
|
|
||||||
|
|
||||||
# 태그 라벨 (동적)
|
|
||||||
tag_parts = [f"{role} ({c['w']}×{c['h']})", bid]
|
|
||||||
if is_hier:
|
|
||||||
sup_str = "+".join(f"꼭지{st}" for st in sup_tids)
|
|
||||||
tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개")
|
|
||||||
tag_label = " · ".join(tag_parts)
|
|
||||||
|
|
||||||
# 종속 꼭지 SLOT 표시
|
|
||||||
sub_slot = ""
|
|
||||||
if is_hier and sup_tids:
|
|
||||||
for st in sup_tids:
|
|
||||||
st_topic = topic_map.get(st)
|
|
||||||
st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else ""
|
|
||||||
sub_slot += (
|
|
||||||
f'<div style="padding-left:8px;border-left:2px solid {cl};margin-top:4px;'
|
|
||||||
f'font-size:10px;color:{cl};">'
|
|
||||||
f'SLOT: 하위 (꼭지{st} — {st_purpose})</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# key-msg SLOT (본심만)
|
|
||||||
keymsg_slot = ""
|
|
||||||
if role == "본심" and ctx.analysis.core_message:
|
|
||||||
keymsg_slot = (
|
|
||||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:3px;'
|
|
||||||
f'padding:2px 6px;font-size:9px;color:#1e40af;font-weight:700;margin-top:4px;">'
|
|
||||||
f'SLOT: key-msg</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
inner = (
|
|
||||||
f'<div style="position:relative;padding-top:18px;width:100%;height:100%;">'
|
|
||||||
f'<span style="position:absolute;top:-16px;left:4px;font-size:9px;font-weight:700;'
|
|
||||||
f'background:white;padding:1px 6px;border-radius:3px;border:1px solid {cl};'
|
|
||||||
f'color:{cl};white-space:nowrap;">{tag_label}</span>'
|
|
||||||
f'{clean}{sub_slot}{keymsg_slot}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
slide_body += (
|
|
||||||
f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;'
|
|
||||||
f'height:{c["h"]}px;border:2px dashed {cl};border-radius:6px;overflow:hidden;">'
|
|
||||||
f'{inner}</div>\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# 범례
|
|
||||||
if is_hier:
|
|
||||||
primary_topic = topic_map.get(primary_tid)
|
|
||||||
p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else ""
|
|
||||||
legend_lines.append(
|
|
||||||
f'• {role}: 꼭지{primary_tid}({p_layer}) + '
|
|
||||||
f'{"+".join(f"꼭지{st}" for st in sup_tids)} → '
|
|
||||||
f'<b>주종 관계 → {bid} 1개</b>'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for r in ref_list:
|
|
||||||
t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None)
|
|
||||||
t_layer = t.layer if t and hasattr(t, 'layer') else ""
|
|
||||||
legend_lines.append(f'• {role}: 꼭지{r.topic_id}({t_layer}) → <b>{r.block_id}</b>')
|
|
||||||
|
|
||||||
css_block = "\n".join(all_block_css)
|
|
||||||
legend_html = "<br>".join(legend_lines)
|
|
||||||
|
|
||||||
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;}}
|
|
||||||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
|
|
||||||
{css_block}
|
|
||||||
</style></head><body>
|
|
||||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)</div>
|
|
||||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.</div>
|
|
||||||
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
|
|
||||||
{slide_body}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:16px;font-size:12px;color:#555;line-height:1.8;">
|
|
||||||
<b>블록 선택 근거 (layer 기반):</b><br>{legend_html}
|
|
||||||
</div></body></html>"""
|
|
||||||
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _gen_stage_2(ctx, steps_dir):
|
def _gen_stage_2(ctx, steps_dir):
|
||||||
"""Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
|
"""Stage 2 결과: 영역별 HTML 생성 결과.
|
||||||
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
|
Type A: Sonnet 영역별 출력. Type B: slide-base 완전 HTML."""
|
||||||
gen = ctx.generated_html or {}
|
gen = ctx.generated_html or {}
|
||||||
sub_layouts = ctx.sub_layouts or {}
|
sub_layouts = ctx.sub_layouts or {}
|
||||||
ps = ctx.page_structure.roles
|
ps = ctx.page_structure.roles
|
||||||
|
|
||||||
# body_html에서 배경/본심 분리 (spacer로 구분)
|
# Type B: generated_html이 str (완전한 HTML)
|
||||||
|
if isinstance(gen, str):
|
||||||
|
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;}}</style>
|
||||||
|
</head><body>
|
||||||
|
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: slide-base + 블록 템플릿 조립 결과 (Type B)</div>
|
||||||
|
<div style="font-size:11px;color:#666;margin-bottom:12px;">slide-base.html 배경 위에 블록 템플릿으로 조립된 최종 HTML</div>
|
||||||
|
<iframe srcdoc="{gen.replace('"', '"')}" style="width:1280px;height:720px;border:1px solid #ccc;"></iframe>
|
||||||
|
</body></html>"""
|
||||||
|
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Type A: dict (body_html, sidebar_html, footer_html)
|
||||||
|
import re as _re
|
||||||
|
|
||||||
body_html = gen.get("body_html", "")
|
body_html = gen.get("body_html", "")
|
||||||
sidebar_html = gen.get("sidebar_html", "")
|
sidebar_html = gen.get("sidebar_html", "")
|
||||||
footer_html = gen.get("footer_html", "")
|
footer_html = gen.get("footer_html", "")
|
||||||
|
|
||||||
# body_html = 배경 + spacer + 본심. spacer로 분리
|
|
||||||
import re as _re
|
|
||||||
spacer_pattern = r'<div style="height:\d+px;"></div>'
|
spacer_pattern = r'<div style="height:\d+px;"></div>'
|
||||||
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
|
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
|
||||||
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
|
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
|
||||||
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
|
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
|
||||||
|
|
||||||
# 역할별 HTML 매핑
|
|
||||||
role_htmls = {}
|
role_htmls = {}
|
||||||
if bg_html and "배경" in ps:
|
if bg_html and "배경" in ps:
|
||||||
role_htmls["배경"] = bg_html
|
role_htmls["배경"] = bg_html
|
||||||
@@ -680,11 +701,11 @@ def _gen_stage_2(ctx, steps_dir):
|
|||||||
redist = fit.get("redistribution", {})
|
redist = fit.get("redistribution", {})
|
||||||
sections = []
|
sections = []
|
||||||
|
|
||||||
for role in ["배경", "본심", "첨부", "결론"]:
|
for role in _get_roles(ctx):
|
||||||
rhtml = role_htmls.get(role, "")
|
rhtml = role_htmls.get(role, "")
|
||||||
if not rhtml:
|
if not rhtml:
|
||||||
continue
|
continue
|
||||||
cl = COLORS.get(role, "#333")
|
cl = _get_color(role, ctx)
|
||||||
ci = ctx.containers.get(role)
|
ci = ctx.containers.get(role)
|
||||||
if not ci:
|
if not ci:
|
||||||
continue
|
continue
|
||||||
@@ -745,6 +766,51 @@ Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_
|
|||||||
# Stage 4: 품질 게이트
|
# Stage 4: 품질 게이트
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|
||||||
|
def _gen_structure_validation(ctx) -> str:
|
||||||
|
"""sample-based 구조 검증. "어긋나면 안 된다" 기준."""
|
||||||
|
import re as _re
|
||||||
|
checks = []
|
||||||
|
html = ctx.rendered_html if hasattr(ctx, 'rendered_html') else ""
|
||||||
|
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||||||
|
|
||||||
|
# 1. 본문 텍스트 visible (body에 실제 텍스트가 있는지)
|
||||||
|
body_start = html.find('<body') if html else -1
|
||||||
|
body_text = _re.sub(r'<[^>]+>', '', html[body_start:]) if body_start > 0 else ""
|
||||||
|
body_text = _re.sub(r'\s+', ' ', body_text).strip()
|
||||||
|
text_len = len(body_text)
|
||||||
|
ok = text_len > 100
|
||||||
|
checks.append(("본문 텍스트 visible", f"{'✅' if ok else '❌'} {text_len}자"))
|
||||||
|
|
||||||
|
# 2. detail link 개수 (role당 1개)
|
||||||
|
link_count = len(_re.findall(r'자세히보기', html)) if html else 0
|
||||||
|
popup_count = len(ctx.normalized.popups) if hasattr(ctx.normalized, 'popups') else 0
|
||||||
|
ok = link_count <= max(popup_count, 1)
|
||||||
|
checks.append(("detail link 개수", f"{'✅' if ok else '⚠️'} {link_count}개 (popup {popup_count}개)"))
|
||||||
|
|
||||||
|
# 3. body 안 <style> 0개
|
||||||
|
body_styles = len(_re.findall(r'<style', html[body_start:])) if body_start > 0 else -1
|
||||||
|
ok = body_styles == 0
|
||||||
|
checks.append(("body 안 <style>", f"{'✅' if ok else '❌'} {body_styles}개"))
|
||||||
|
|
||||||
|
# 4. conclusion * 없음
|
||||||
|
ct = ctx.analysis.conclusion_text if hasattr(ctx.analysis, 'conclusion_text') else ""
|
||||||
|
ok = not ct.startswith("*")
|
||||||
|
checks.append(("conclusion 선행 * 없음", f"{'✅' if ok else '❌'} \"{ct[:30]}\""))
|
||||||
|
|
||||||
|
# 5. sub_types 분류 확인
|
||||||
|
for rname, rinfo in ps.items():
|
||||||
|
if not isinstance(rinfo, dict):
|
||||||
|
continue
|
||||||
|
sub_types = rinfo.get("sub_types", [])
|
||||||
|
for st in sub_types:
|
||||||
|
checks.append((f"sub_type: {st.get('title','')[:20]}", st.get("sub_type", "미분류")))
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for name, result in checks:
|
||||||
|
rows += f'<tr><td style="padding:6px 8px;">{name}</td><td style="padding:6px 8px;">{result}</td></tr>\n'
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _gen_stage_4(ctx, steps_dir):
|
def _gen_stage_4(ctx, steps_dir):
|
||||||
"""Stage 4 결과: 측정값 + 품질 점수."""
|
"""Stage 4 결과: 측정값 + 품질 점수."""
|
||||||
measurement = ctx.measurement or {}
|
measurement = ctx.measurement or {}
|
||||||
@@ -766,15 +832,76 @@ def _gen_stage_4(ctx, steps_dir):
|
|||||||
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
|
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
|
||||||
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
|
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
|
||||||
|
|
||||||
score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626"
|
if isinstance(quality_score, (int, float)) and quality_score < 0:
|
||||||
|
score_color = "#d97706" # 미평가 = 주황
|
||||||
|
quality_score = "미평가 (비전 모델 미응답)"
|
||||||
|
elif isinstance(quality_score, (int, float)) and quality_score >= 80:
|
||||||
|
score_color = "#16a34a"
|
||||||
|
else:
|
||||||
|
score_color = "#dc2626"
|
||||||
|
|
||||||
|
# 블록/recipe 정보 (page_structure에서)
|
||||||
|
recipe_rows = ""
|
||||||
|
ps = ctx.page_structure.roles if hasattr(ctx.page_structure, 'roles') else {}
|
||||||
|
refs = ctx.references if hasattr(ctx, 'references') else {}
|
||||||
|
for rname, rinfo in ps.items():
|
||||||
|
if not isinstance(rinfo, dict):
|
||||||
|
continue
|
||||||
|
schema = rinfo.get("group_schema", "")
|
||||||
|
zone = rinfo.get("zone", "")
|
||||||
|
# block_id
|
||||||
|
role_refs = refs.get(rname, [])
|
||||||
|
block_id = ""
|
||||||
|
if role_refs:
|
||||||
|
r0 = role_refs[0]
|
||||||
|
block_id = r0.block_id if hasattr(r0, 'block_id') else r0.get("block_id", "")
|
||||||
|
is_recipe = block_id == "__needs_recipe__"
|
||||||
|
block_display = f"recipe ({schema})" if is_recipe else block_id
|
||||||
|
recipe_rows += (
|
||||||
|
f'<tr><td style="padding:6px 8px;">{zone}</td>'
|
||||||
|
f'<td style="padding:6px 8px;">{rname}</td>'
|
||||||
|
f'<td style="padding:6px 8px;">{schema}</td>'
|
||||||
|
f'<td style="padding:6px 8px;">{block_display}</td></tr>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# popup 정보
|
||||||
|
popup_rows = ""
|
||||||
|
popups = ctx.normalized.popups if hasattr(ctx.normalized, 'popups') else []
|
||||||
|
for p in popups:
|
||||||
|
pid = p.popup_id if hasattr(p, 'popup_id') else ""
|
||||||
|
target = p.target_role if hasattr(p, 'target_role') else ""
|
||||||
|
pfile = p.popup_file if hasattr(p, 'popup_file') else ""
|
||||||
|
popup_rows += (
|
||||||
|
f'<tr><td style="padding:6px 8px;">{pid}</td>'
|
||||||
|
f'<td style="padding:6px 8px;">{target or "미연결"}</td>'
|
||||||
|
f'<td style="padding:6px 8px;">{pfile or "미확정"}</td></tr>\n'
|
||||||
|
)
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
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:sans-serif;word-break:keep-all;}}</style>
|
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}
|
||||||
|
table{{border-collapse:collapse;font-size:12px;width:100%;max-width:700px;margin-top:8px;}}
|
||||||
|
th{{padding:8px;text-align:left;}}td{{padding:6px 8px;border-bottom:1px solid #ddd;}}</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
|
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
|
||||||
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
|
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
|
||||||
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
|
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
|
||||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;margin-top:8px;">
|
|
||||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">영역</th><th style="padding:8px;">clientH</th><th style="padding:8px;">scrollH</th><th style="padding:8px;">excess</th></tr>{zone_rows}</table>
|
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Overflow 측정</div>
|
||||||
|
<table>
|
||||||
|
<tr style="background:#1e293b;color:white;"><th>영역</th><th>clientH</th><th>scrollH</th><th>excess</th></tr>{zone_rows}</table>
|
||||||
|
|
||||||
|
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">블록/Recipe 선택</div>
|
||||||
|
<table>
|
||||||
|
<tr style="background:#1e293b;color:white;"><th>zone</th><th>role</th><th>schema</th><th>block/recipe</th></tr>{recipe_rows}</table>
|
||||||
|
|
||||||
|
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">Popup 연결</div>
|
||||||
|
<table>
|
||||||
|
<tr style="background:#1e293b;color:white;"><th>popup_id</th><th>target_role</th><th>popup_file</th></tr>{popup_rows if popup_rows else '<tr><td colspan="3">없음</td></tr>'}</table>
|
||||||
|
|
||||||
|
<div style="font-weight:700;margin-top:16px;margin-bottom:4px;">구조 검증</div>
|
||||||
|
<table>
|
||||||
|
<tr style="background:#1e293b;color:white;"><th>검증 항목</th><th>결과</th></tr>
|
||||||
|
{_gen_structure_validation(ctx)}
|
||||||
|
</table>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")
|
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")
|
||||||
|
|||||||
@@ -158,51 +158,8 @@ def validate_stage_1a(
|
|||||||
})
|
})
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
# weight 합 검증 (0.9~1.1)
|
# Phase Y: page_structure 검증은 validate_page_structure()에서 별도 수행.
|
||||||
total_weight = sum(
|
# Stage 1A에서는 Kei 응답의 page_structure를 검증하지 않음.
|
||||||
info.get("weight", 0) for info in page_struct.values()
|
|
||||||
if isinstance(info, dict)
|
|
||||||
)
|
|
||||||
if total_weight < 0.9 or total_weight > 1.1:
|
|
||||||
errors.append({
|
|
||||||
"severity": "RETRYABLE",
|
|
||||||
"field": "page_structure.weight",
|
|
||||||
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
|
|
||||||
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 유형에 따른 구조 검증
|
|
||||||
layout_template = analysis.get("layout_template", "A")
|
|
||||||
if layout_template == "A":
|
|
||||||
# 유형 A: 본심 필수
|
|
||||||
core_info = page_struct.get("본심", {})
|
|
||||||
if not core_info or not isinstance(core_info, dict):
|
|
||||||
errors.append({
|
|
||||||
"severity": "RETRYABLE",
|
|
||||||
"field": "page_structure.본심",
|
|
||||||
"localization": "본심 역할이 page_structure에 없음",
|
|
||||||
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
|
||||||
})
|
|
||||||
elif core_info.get("weight", 0) < 0.3:
|
|
||||||
errors.append({
|
|
||||||
"severity": "RETRYABLE",
|
|
||||||
"field": "page_structure.본심.weight",
|
|
||||||
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
|
|
||||||
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
|
|
||||||
})
|
|
||||||
elif layout_template == "B":
|
|
||||||
# 유형 B: 결론(footer) 필수, 나머지 자유
|
|
||||||
has_footer = any(
|
|
||||||
isinstance(info, dict) and info.get("zone") == "footer"
|
|
||||||
for info in page_struct.values()
|
|
||||||
)
|
|
||||||
if not has_footer and "결론" not in page_struct:
|
|
||||||
errors.append({
|
|
||||||
"severity": "RETRYABLE",
|
|
||||||
"field": "page_structure.footer",
|
|
||||||
"localization": "결론(footer) 역할이 없음",
|
|
||||||
"instruction": "유형 B에서도 결론 역할(zone: footer)은 필수이다.",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 필수 필드 검증
|
# 필수 필드 검증
|
||||||
for t in topics:
|
for t in topics:
|
||||||
@@ -243,7 +200,8 @@ def validate_stage_1a(
|
|||||||
# 원본 ## 섹션 수 vs topic 수 비교
|
# 원본 ## 섹션 수 vs topic 수 비교
|
||||||
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
|
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
|
||||||
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
|
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
|
||||||
max_diff = 4 if layout_template == "B" else 2
|
_layout = analysis.get("layout_template", "A")
|
||||||
|
max_diff = 4 if _layout == "B" else 2
|
||||||
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
|
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
|
||||||
errors.append({
|
errors.append({
|
||||||
"severity": "RETRYABLE",
|
"severity": "RETRYABLE",
|
||||||
@@ -336,18 +294,29 @@ def validate_stage_1b(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ── 모순 탐지 (결정 테이블) ──
|
# ── 모순 탐지 (결정 테이블) ──
|
||||||
|
# Phase Y: Type B에서는 purpose/relation_type이 블록 선택의 핵심 입력이 아님
|
||||||
|
# (tag 매칭이 item_count + content_example로 동작)
|
||||||
|
# → Type B: 경고만 (파이프라인 계속). Type A: hard fail 유지.
|
||||||
|
|
||||||
if purpose in CONTRADICTIONS:
|
if purpose in CONTRADICTIONS:
|
||||||
if relation_type in CONTRADICTIONS[purpose]:
|
if relation_type in CONTRADICTIONS[purpose]:
|
||||||
errors.append({
|
if layout_template == "B":
|
||||||
"severity": "RETRYABLE",
|
# Type B: 경고만
|
||||||
"field": f"topics[{tid}].relation_type",
|
logger.warning(
|
||||||
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
|
f"[T-2 모순경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' "
|
||||||
"current_value": f"purpose={purpose}, relation_type={relation_type}",
|
f"— Type B에서는 보조 힌트이므로 경고만"
|
||||||
"evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가",
|
)
|
||||||
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
|
else:
|
||||||
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
|
# Type A: hard fail 유지
|
||||||
})
|
errors.append({
|
||||||
|
"severity": "RETRYABLE",
|
||||||
|
"field": f"topics[{tid}].relation_type",
|
||||||
|
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
|
||||||
|
"current_value": f"purpose={purpose}, relation_type={relation_type}",
|
||||||
|
"evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가",
|
||||||
|
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
|
||||||
|
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
|
||||||
|
})
|
||||||
|
|
||||||
if purpose in SOFT_WARNINGS:
|
if purpose in SOFT_WARNINGS:
|
||||||
if relation_type in SOFT_WARNINGS[purpose]:
|
if relation_type in SOFT_WARNINGS[purpose]:
|
||||||
@@ -400,3 +369,35 @@ def validate_stage_1b(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_page_structure(page_struct: dict) -> list[dict]:
|
||||||
|
"""Phase Y: section_parser가 생성한 page_structure 검증.
|
||||||
|
|
||||||
|
Stage 1A 후, section_parser + 블록 매칭으로 page_structure가 채워진 후 호출.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not page_struct:
|
||||||
|
errors.append({
|
||||||
|
"severity": "FATAL",
|
||||||
|
"field": "page_structure",
|
||||||
|
"localization": "page_structure가 비어있음",
|
||||||
|
"instruction": "section_parser가 영역을 생성하지 못함",
|
||||||
|
})
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# weight 합 검증 (0.9~1.1)
|
||||||
|
total_weight = sum(
|
||||||
|
info.get("weight", 0) for info in page_struct.values()
|
||||||
|
if isinstance(info, dict)
|
||||||
|
)
|
||||||
|
if total_weight < 0.9 or total_weight > 1.1:
|
||||||
|
errors.append({
|
||||||
|
"severity": "RETRYABLE",
|
||||||
|
"field": "page_structure.weight",
|
||||||
|
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
|
||||||
|
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
/* ── Headers (비대칭 라운드 — 자체 배경 유지) ── */
|
/* ── Headers (비대칭 라운드 — 자체 배경 유지) ── */
|
||||||
.cdg-header { padding: 12px 28px; display: flex; min-height: 52px; align-items: center; }
|
.cdg-header { padding: 12px 28px; display: flex; min-height: 52px; align-items: center; }
|
||||||
.cdg-header-text { font-size: 26px; font-weight: var(--weight-black, 900); color: #000; line-height: 1.3; }
|
.cdg-header-text { font-size: var(--cdg-heading-font, 26px); font-weight: var(--weight-black, 900); color: #000; line-height: 1.3; }
|
||||||
.cdg-header-warm {
|
.cdg-header-warm {
|
||||||
background: linear-gradient(90deg, rgba(165,161,150,0.15), rgba(57,50,30,0.85));
|
background: linear-gradient(90deg, rgba(165,161,150,0.15), rgba(57,50,30,0.85));
|
||||||
border-radius: 0 28px 28px 0; justify-content: flex-end; text-align: right; margin-right: 4px;
|
border-radius: 0 28px 28px 0; justify-content: flex-end; text-align: right; margin-right: 4px;
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
.cdg-cell-teal { background: none; }
|
.cdg-cell-teal { background: none; }
|
||||||
|
|
||||||
/* ── Section Title & Body ── */
|
/* ── Section Title & Body ── */
|
||||||
.cdg-sec-title { font-size: 18px; font-weight: var(--weight-black, 900); line-height: 1.4; word-break: keep-all; margin-bottom: 4px; }
|
.cdg-sec-title { font-size: var(--cdg-body-font, 18px); font-weight: var(--weight-black, 900); line-height: 1.4; word-break: keep-all; margin-bottom: 4px; }
|
||||||
.cdg-title-warm { color: var(--color-warm-brown, #5C3714); }
|
.cdg-title-warm { color: var(--color-warm-brown, #5C3714); }
|
||||||
.cdg-title-teal { color: var(--color-dark-teal, #084C56); }
|
.cdg-title-teal { color: var(--color-dark-teal, #084C56); }
|
||||||
.cdg-sec-body { padding-left: 8px; }
|
.cdg-sec-body { padding-left: 8px; }
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
/* ── Bullets ── */
|
/* ── Bullets ── */
|
||||||
.cdg-bullet {
|
.cdg-bullet {
|
||||||
position: relative; padding-left: 14px;
|
position: relative; padding-left: 14px;
|
||||||
font-size: 14px; font-weight: var(--weight-bold, 700); color: #1a1a1a;
|
font-size: var(--cdg-body-font, 14px); font-weight: var(--weight-bold, 700); color: #1a1a1a;
|
||||||
line-height: 1.7; word-break: keep-all;
|
line-height: 1.7; word-break: keep-all;
|
||||||
}
|
}
|
||||||
.cdg-bullet::before { content: '•'; position: absolute; left: 0; color: #666; }
|
.cdg-bullet::before { content: '•'; position: absolute; left: 0; color: #666; }
|
||||||
|
|||||||
193
templates/blocks/new/prerequisites-3col.html
Normal file
193
templates/blocks/new/prerequisites-3col.html
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!-- 필수요건 3열 비교: 3개 카테고리 세로 색상바 + 한자 + 제목/설명 2단 -->
|
||||||
|
<!--
|
||||||
|
📋 prerequisites-3col
|
||||||
|
─────────────────
|
||||||
|
용도: 3개 필수요건/카테고리를 나란히 비교. 각 카테고리에 색상 바 + 한자 + 세로 라벨 + 2개 하위 항목(제목+설명).
|
||||||
|
슬롯:
|
||||||
|
columns[3]:
|
||||||
|
name — 세로 라벨 (예: "기술")
|
||||||
|
sub — 세로 부제 (예: "디지털")
|
||||||
|
kanji_top, kanji_bottom — 한자 2글자 (예: 技, 術)
|
||||||
|
bar_gradient — 색상 바 CSS gradient
|
||||||
|
heading_gradient_top, heading_gradient_bottom — 제목 gradient text
|
||||||
|
items[2]: (heading, desc)
|
||||||
|
Figma 원본: Frame 1171281190 (node 45:15, 2123×724)
|
||||||
|
수학적 계산:
|
||||||
|
scale = 1280 / 2123.13 = 0.60290
|
||||||
|
col width: 690→416px, bar width: 152.5→92px
|
||||||
|
title font: 45px × S = 27px, desc font: 35px × S = 21px
|
||||||
|
kanji font: 50px × S = 30px
|
||||||
|
CSS 요소: gradient bar, gradient text, border/dashed lines (전부 CSS)
|
||||||
|
이미지 의존: 없음 (순수 CSS)
|
||||||
|
-->
|
||||||
|
<div class="block-p3c">
|
||||||
|
{% for col in columns %}
|
||||||
|
<div class="p3c-col">
|
||||||
|
<!-- gradient 색상 바 -->
|
||||||
|
<div class="p3c-bar"></div>
|
||||||
|
|
||||||
|
<!-- 세로 라벨 영역 -->
|
||||||
|
<div class="p3c-vlabel-area">
|
||||||
|
<div class="p3c-vlabel">{{ col.name }}</div>
|
||||||
|
<div class="p3c-vlabel-sub">{{ col.sub }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 한자 -->
|
||||||
|
{% if col.kanji_top %}
|
||||||
|
<div class="p3c-kanji p3c-kanji--top">{{ col.kanji_top }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if col.kanji_bottom %}
|
||||||
|
<div class="p3c-kanji p3c-kanji--bottom">{{ col.kanji_bottom }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 상단 항목 -->
|
||||||
|
<div class="p3c-section p3c-section--top">
|
||||||
|
<div class="p3c-heading">
|
||||||
|
{{ col.entries[0].heading|safe }}
|
||||||
|
</div>
|
||||||
|
<div class="p3c-desc">
|
||||||
|
{% if col.entries[0].bullets is defined and col.entries[0].bullets %}
|
||||||
|
{% for b in col.entries[0].bullets %}<div class="bul">• {{ b }}</div>{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{{ col.entries[0].desc|safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 중간 구분선 -->
|
||||||
|
<div class="p3c-mid-line"></div>
|
||||||
|
|
||||||
|
<!-- 하단 항목 -->
|
||||||
|
<div class="p3c-section p3c-section--bottom">
|
||||||
|
<div class="p3c-heading">
|
||||||
|
{{ col.entries[1].heading|safe }}
|
||||||
|
</div>
|
||||||
|
<div class="p3c-desc">
|
||||||
|
{% if col.entries[1].bullets is defined and col.entries[1].bullets %}
|
||||||
|
{% for b in col.entries[1].bullets %}<div class="bul">• {{ b }}</div>{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{{ col.entries[1].desc|safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 테두리 -->
|
||||||
|
<div class="p3c-border-top"></div>
|
||||||
|
<div class="p3c-border-bottom"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.block-p3c {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p3c-col {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-top: 1.2px solid #000;
|
||||||
|
border-bottom: 1.2px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
gradient 색상 바: Figma 152.5×595
|
||||||
|
→ 비율 7.2% of col width → flex none 72px (or %)
|
||||||
|
3가지 색상: 파랑(#0D78D0→#023056), 주황(#FF9A23→#CC5200), 초록(#39BE49→#23742C)
|
||||||
|
*/
|
||||||
|
.p3c-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: 56px; height: 100%; /* 슬라이드 적용: 72→56px */
|
||||||
|
}
|
||||||
|
/* Figma 원본 열별 색상 */
|
||||||
|
.p3c-col:nth-child(1) .p3c-bar { background: linear-gradient(180deg, #0D78D0 0%, #023056 100%); }
|
||||||
|
.p3c-col:nth-child(2) .p3c-bar { background: linear-gradient(180deg, #FF9A23 0%, #CC5200 100%); }
|
||||||
|
.p3c-col:nth-child(3) .p3c-bar { background: linear-gradient(180deg, #39BE49 0%, #23742C 100%); }
|
||||||
|
|
||||||
|
/* 세로 라벨: 바 좌측 절반에 세로 글자 */
|
||||||
|
.p3c-vlabel-area {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: 56px; height: 100%; /* 슬라이드 적용: 36→56px (bar와 동일) */
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.p3c-vlabel, .p3c-vlabel-sub {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: upright;
|
||||||
|
font-weight: 700; font-size: 14px; /* 슬라이드 적용 */
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.p3c-vlabel-sub { font-size: 12px; }
|
||||||
|
|
||||||
|
/* 한자: 바 우측 절반 */
|
||||||
|
.p3c-kanji {
|
||||||
|
position: absolute;
|
||||||
|
left: 36px; width: 36px;
|
||||||
|
font-weight: 700; font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.p3c-kanji--top { top: 15%; height: 30%; }
|
||||||
|
.p3c-kanji--bottom { top: 55%; height: 30%; }
|
||||||
|
|
||||||
|
/* 제목+설명 영역: 슬라이드 적용 */
|
||||||
|
.p3c-section {
|
||||||
|
position: absolute;
|
||||||
|
left: 60px; right: 6px; /* 슬라이드 적용: 80→60px */
|
||||||
|
}
|
||||||
|
.p3c-section--top { top: 3%; height: 47%; }
|
||||||
|
.p3c-section--bottom { top: 53%; height: 47%; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
제목: gradient text
|
||||||
|
Figma 45px × S = 27px → block 18px
|
||||||
|
*/
|
||||||
|
.p3c-heading {
|
||||||
|
font-weight: 700; font-size: 12px; /* 슬라이드 적용: 18→12px */
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
/* Figma 원본 열별 heading 색상 */
|
||||||
|
.p3c-col:nth-child(1) .p3c-heading { background-image: linear-gradient(180deg, #0D78D0 0%, #134D7F 100%); }
|
||||||
|
.p3c-col:nth-child(2) .p3c-heading { background-image: linear-gradient(180deg, #FF9A23 0%, #CC5200 100%); }
|
||||||
|
.p3c-col:nth-child(3) .p3c-heading { background-image: linear-gradient(180deg, #39BE49 0%, #23742C 100%); }
|
||||||
|
|
||||||
|
/*
|
||||||
|
설명: Figma 35px × S = 21px → block 14px
|
||||||
|
#3E3523
|
||||||
|
*/
|
||||||
|
.p3c-desc {
|
||||||
|
font-weight: 500; font-size: 11px; /* 슬라이드 적용: 14→11px */
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #3E3523;
|
||||||
|
}
|
||||||
|
.p3c-desc .bul {
|
||||||
|
padding-left: 12px;
|
||||||
|
text-indent: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 중간 구분선: dashed */
|
||||||
|
.p3c-mid-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 56px; right: 0; /* bar width와 동일 */
|
||||||
|
top: 50%;
|
||||||
|
border-top: 1.2px dashed #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테두리 (상하 실선은 col border로 처리) */
|
||||||
|
</style>
|
||||||
228
templates/blocks/redesign/process-product-2col.html
Normal file
228
templates/blocks/redesign/process-product-2col.html
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<!-- Process/Product 2단 비교: 좌측 과정의 혁신 + 우측 결과의 혁신 -->
|
||||||
|
<!--
|
||||||
|
📋 process-product-2col
|
||||||
|
─────────────────
|
||||||
|
용도: Process 혁신과 Product 변화를 좌우 2단으로 비교 (redesign: 슬라이드 적용 크기)
|
||||||
|
원본: BEPs/process-product-2col.html (Figma 크기)
|
||||||
|
슬라이드 적용: header 13px, mid_title 12px, body 11px (참고: mdx03_final 기준)
|
||||||
|
슬롯:
|
||||||
|
left_title, right_title: 좌/우 제목
|
||||||
|
left_sections[], right_sections[]: 섹션 목록 (title + bullets[])
|
||||||
|
left_compare: As-is → To-be 비교 (선택, title + left_items[] + right_items[])
|
||||||
|
Figma 원본: Frame 1171276073 (3848x1487 → 1280x495)
|
||||||
|
색상 (Figma에서 추출):
|
||||||
|
좌 배경: linear-gradient(180deg, #ffffff 46%, #39311e 100%)
|
||||||
|
우 배경: linear-gradient(0deg, #296b55 0%, #ffffff 56%)
|
||||||
|
좌 제목바: linear-gradient(270deg, #a4a096, #39311e)
|
||||||
|
우 제목바: linear-gradient(0deg, #296b55, #022017)
|
||||||
|
좌 제목텍스트: gradient(#296b55→#123328) + solid #3e3523
|
||||||
|
우 제목텍스트: gradient(#296b55→#123328) + solid #225e4a
|
||||||
|
좌 중제목: #5c3614, 16.6px/900
|
||||||
|
우 중제목: #084c56, 16.6px/900
|
||||||
|
본문: #000000, 13.3px/700, lineH=23.3px
|
||||||
|
-->
|
||||||
|
<div class="block-pp2">
|
||||||
|
<!-- 좌/우 배경 gradient (absolute) -->
|
||||||
|
<div class="pp2-bg pp2-bg--left"></div>
|
||||||
|
<div class="pp2-bg pp2-bg--right"></div>
|
||||||
|
|
||||||
|
<!-- 헤더 행 -->
|
||||||
|
<div class="pp2-header-bar pp2-header-bar--left">
|
||||||
|
<span class="pp2-header-text pp2-header-text--left">{{ left_title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp2-header-bar pp2-header-bar--right">
|
||||||
|
<span class="pp2-header-text pp2-header-text--right">{{ right_title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- compare 행 (좌측에만) -->
|
||||||
|
{% if left_compare %}
|
||||||
|
<div class="pp2-cell pp2-cell--left">
|
||||||
|
<div class="pp2-mid-title pp2-mid-title--left">{{ left_compare.title }}</div>
|
||||||
|
<div class="pp2-compare">
|
||||||
|
<div class="pp2-compare-col">
|
||||||
|
{% for item in left_compare.left_items %}
|
||||||
|
<div class="pp2-body-text">• {{ item }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="pp2-compare-arrow">
|
||||||
|
{% if arrow_image %}<img src="{{ arrow_image }}" alt="→" class="pp2-arrow-img">
|
||||||
|
{% else %}<span class="pp2-arrow-text">➠</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="pp2-compare-col">
|
||||||
|
{% for item in left_compare.right_items %}
|
||||||
|
<div class="pp2-body-text">• {{ item }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pp2-cell pp2-cell--right">
|
||||||
|
{% if paired_rows and paired_rows|length > 0 %}
|
||||||
|
<div class="pp2-mid-title pp2-mid-title--right">{{ paired_rows[0].right.title }}</div>
|
||||||
|
{% for bullet in paired_rows[0].right.bullets %}
|
||||||
|
<div class="pp2-body-text">• {{ bullet }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 나머지 행들 (좌/우 행 정렬) -->
|
||||||
|
{% for row in paired_rows %}
|
||||||
|
{% if loop.index0 > 0 or not left_compare %}
|
||||||
|
<div class="pp2-cell pp2-cell--left">
|
||||||
|
{% if row.left %}
|
||||||
|
<div class="pp2-mid-title pp2-mid-title--left">{{ row.left.title }}</div>
|
||||||
|
{% for bullet in row.left.bullets %}
|
||||||
|
<div class="pp2-body-text">• {{ bullet }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="pp2-cell pp2-cell--right">
|
||||||
|
{% if row.right %}
|
||||||
|
<div class="pp2-mid-title pp2-mid-title--right">{{ row.right.title }}</div>
|
||||||
|
{% for bullet in row.right.bullets %}
|
||||||
|
<div class="pp2-body-text">• {{ bullet }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.block-pp2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좌/우 배경 (absolute로 깔기) */
|
||||||
|
.pp2-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.pp2-bg--left {
|
||||||
|
left: 0; width: 50%;
|
||||||
|
background: linear-gradient(180deg, #ffffff 46%, #39311e 100%);
|
||||||
|
}
|
||||||
|
.pp2-bg--right {
|
||||||
|
left: 50%; width: 50%;
|
||||||
|
background: linear-gradient(0deg, #296b55 0%, #ffffff 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* grid cell 공통 */
|
||||||
|
.pp2-cell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 2px 12px;
|
||||||
|
}
|
||||||
|
.pp2-cell--left { grid-column: 1; }
|
||||||
|
.pp2-cell--right { grid-column: 2; }
|
||||||
|
|
||||||
|
/* 제목 바: redesign 축소 */
|
||||||
|
.pp2-header-bar {
|
||||||
|
height: 30px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좌 제목바: 우측 둥글게, 그라데이션 우→좌 */
|
||||||
|
.pp2-header-bar--left {
|
||||||
|
grid-column: 1;
|
||||||
|
background: linear-gradient(270deg, #a4a096 0%, #39311e 100%);
|
||||||
|
border-radius: 0 24px 24px 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우 제목바: 좌측 둥글게, 그라데이션 좌→우 */
|
||||||
|
.pp2-header-bar--right {
|
||||||
|
grid-column: 2;
|
||||||
|
background: linear-gradient(90deg, #296b55 0%, #022017 100%);
|
||||||
|
border-radius: 24px 0 0 24px;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제목 텍스트: redesign 13px */
|
||||||
|
.pp2-header-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좌 제목: Figma solid fill #3e3523 */
|
||||||
|
.pp2-header-text--left {
|
||||||
|
color: #3e3523;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우 제목: 흰색 (gradient 배경 위 가독성) */
|
||||||
|
.pp2-header-text--right {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 본문 영역 (flex 방식 호환) */
|
||||||
|
.pp2-body {
|
||||||
|
padding: 4px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 중제목: redesign 12px */
|
||||||
|
.pp2-mid-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.pp2-mid-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.pp2-mid-title--left {
|
||||||
|
color: #5c3614;
|
||||||
|
}
|
||||||
|
.pp2-mid-title--right {
|
||||||
|
color: #084c56;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 본문: redesign 11px + 소제목 대비 들여쓰기 + hanging indent */
|
||||||
|
.pp2-body-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000000;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding-left: 14px;
|
||||||
|
text-indent: -14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As-is → To-be 비교 */
|
||||||
|
.pp2-compare {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.pp2-compare-col {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pp2-compare-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 84px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pp2-arrow-img {
|
||||||
|
width: 84px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.pp2-arrow-text {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #5c3614;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{{ slide_title | default('슬라이드') }}</title>
|
|
||||||
<link rel="stylesheet" href="/static/base.css">
|
|
||||||
<style>
|
|
||||||
{% for page in pages %}
|
|
||||||
.slide-{{ page.page_number }} {
|
|
||||||
grid-template-areas: {{ page.grid_areas }};
|
|
||||||
grid-template-columns: {{ page.grid_columns | default('1fr') }};
|
|
||||||
grid-template-rows: {{ page.grid_rows | default('auto 1fr auto') }};
|
|
||||||
}
|
|
||||||
{% for block in page.blocks %}
|
|
||||||
.slide-{{ page.page_number }} .area-{{ block.area }} {
|
|
||||||
grid-area: {{ block.area }};
|
|
||||||
}
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
/* 다중 페이지: 페이지 간 간격 */
|
|
||||||
.slide + .slide {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 인쇄 시 페이지 분리 */
|
|
||||||
@media print {
|
|
||||||
.slide {
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
.slide + .slide {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% for page in pages %}
|
|
||||||
<div class="slide slide-{{ page.page_number }}">
|
|
||||||
{% if loop.first and slide_title %}
|
|
||||||
<div class="slide-title" style="grid-area: header;">{{ slide_title }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for block in page.blocks %}
|
|
||||||
<div class="area-{{ block.area }}" style="{{ block.style_override | default('') }}">
|
|
||||||
{{ block.html }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
<script>
|
|
||||||
/* 인쇄 시 <details> 자동 펼침, 인쇄 후 복원 */
|
|
||||||
window.onbeforeprint = function() {
|
|
||||||
document.querySelectorAll('details').forEach(function(d) { d.open = true; });
|
|
||||||
};
|
|
||||||
window.onafterprint = function() {
|
|
||||||
document.querySelectorAll('details').forEach(function(d) { d.open = false; });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user