From e7848b602d969ef32e5620273ccfa9c7d57bb7de Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Mon, 4 May 2026 08:21:28 +0900 Subject: [PATCH] Add Phase Z runtime foundation - add visual fit classifier, router, retry, and failure routing modules - add composition planner and catalog-driven mapper - add Phase Z pipeline orchestration and architecture docs --- docs/architecture/FRAME-INTEGRATION-MAP.md | 183 +++ .../PHASE-Z-CATALOG-RUNTIME-DESIGN.md | 1398 +++++++++++++++++ .../PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md | 220 +++ .../PHASE-Z-FRAME-STYLE-INVENTORY.md | 229 +++ src/phase_z2_classifier.py | 395 +++++ src/phase_z2_composition.py | 571 +++++++ src/phase_z2_failure_router.py | 237 +++ src/phase_z2_mapper.py | 609 +++++++ src/phase_z2_pipeline.py | 1227 +++++++++++++++ src/phase_z2_retry.py | 215 +++ src/phase_z2_router.py | 181 +++ 11 files changed, 5465 insertions(+) create mode 100644 docs/architecture/FRAME-INTEGRATION-MAP.md create mode 100644 docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md create mode 100644 docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md create mode 100644 docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md create mode 100644 src/phase_z2_classifier.py create mode 100644 src/phase_z2_composition.py create mode 100644 src/phase_z2_failure_router.py create mode 100644 src/phase_z2_mapper.py create mode 100644 src/phase_z2_pipeline.py create mode 100644 src/phase_z2_retry.py create mode 100644 src/phase_z2_router.py diff --git a/docs/architecture/FRAME-INTEGRATION-MAP.md b/docs/architecture/FRAME-INTEGRATION-MAP.md new file mode 100644 index 0000000..c697eb2 --- /dev/null +++ b/docs/architecture/FRAME-INTEGRATION-MAP.md @@ -0,0 +1,183 @@ +# Frame Integration Map — 32 Figma Frame ↔ Phase Z Zone 통합 매핑 + +> **핵심 방향 (2026-04-28 정정)** +> +> Figma frame 은 슬라이드에 **원본 그대로 꽂는 게 아니라**, **디자인 레퍼런스 / 구조 패턴 / 슬롯 힌트**로 본다. +> **최종 결과는 항상 현재 slide-body 의 Zone 안에 맞게 재구성한다.** +> +> - 원본이 full-slide 디자인이어도 → Zone 안 맞게 축약 / 재배치 / 슬롯화해서 사용 +> - 복합 슬라이드여도 → Zone 안에서는 일부 패턴만 참고해 재구성 +> +> → 분류는 **"원본 크기"** 가 아니라 **"Zone 에 어떻게 적용할지"** 기준으로 한다. + +--- + +## 라벨 정의 + +### Zone 적용 방식 (zone_application) + +| 값 | 의미 | +|---|---| +| `zone_direct` | Zone 안에 거의 그대로 적용 가능 (구조 / 사이즈 변환 최소) | +| `zone_adapt` | 구조는 맞지만 Zone 크기에 맞게 재구성 필요 | +| `zone_extract` | 전체 frame 중 일부 패턴만 추출해서 Zone 에 사용 | +| `reference_only` | 직접 구조로 쓰기보다 디자인 톤 / 아이디어만 참고 | +| `reject` | Phase Z 에서 사용하지 않음 | + +### Zone 적합성 (zone_fit) + +| 값 | 의미 | +|---|---| +| `high` | Zone 에 바로 맞추기 쉬움 (단순 리스트 / 표 등) | +| `medium` | 조정하면 가능 (3 단 카드 / 다이어그램 등) | +| `low` | 많이 재구성해야 함 (복합 구조 / 분할 패널) | +| `reference` | 참고용 | + +### 검토 상태 (review_status) + +| 값 | 의미 | +|---|---| +| `auto_estimated` | 코드가 layout 패턴으로 추정. 사용자 검토 필요 | +| `user_confirmed` | 사용자 검토 후 확정 | +| `needs_review` | 자동 추정에 의문 — 사람이 Figma 캔버스 직접 확인 필요 | + +--- + +## 1차 — 32 Frame Zone 적용 분류 + +> ⚠️ **`legacy 스타일 출처(참고)` 컬럼 의미** +> +> 기존 `templates/blocks/` 는 **Phase Z 의 실제 조립 재료가 아님** (삭제 / 폐기 방향). +> 이 컬럼은 **frame ↔ 블록 매핑이 아니라**, frame 의 디자인 / 시각 언어를 만들 때 참고할 수 있는 **스타일 출처** (색감, 여백, 폰트 위계, 표 스타일, 카드 스타일, pill / badge, SVG / CSS 구현 힌트) 만 가리킨다. +> +> Phase Z 의 실제 실행 기준은 **새 frame / zone catalog**. +> 정밀화는 [`PHASE-Z-STYLE-SOURCES.md`](PHASE-Z-STYLE-SOURCES.md) (예정) 에서 별도 진행. + +| Frame | Figma ID | 패턴 | Zone 적합성 | Zone 적용 방식 | 검토 상태 | legacy 스타일 출처(참고) | 비고 | +|---|---|---|---|---|---|---|---| +| **01** | `1171281172` | `circular-nodes-6` | `medium` | `zone_adapt` | `auto_estimated` | `templates/blocks/visuals/` (다이어그램) | S/W 개발 방향 순환도. 6 노드 — Zone 사이즈에 맞춰 시각 재구성 | +| **02** | `1171281173` | `bullet-cards-4-plus-center` | `low` | `zone_extract` | `auto_estimated` | (복합 — 일부 패턴 추출) | 4 카드 + 중앙 강조. Zone 에서는 4 카드 부분만 추출하거나 중앙만 강조로 사용 | +| **03** | `1171281174` | `list-numbered-4` | `high` | `zone_direct` | `auto_estimated` | `templates/blocks/cards/` 또는 list 류 | 4 항목 번호 리스트 — 단순 구조, Zone 직접 적용 | +| **04** | `1171281175` | `quadrilateral-relations` | `medium` | `zone_extract` | `user_confirmed` | (관계도 — 기존 venn / cycle 로 부족 가능) | 4 actors + 관계도 + hierarchy 단계 결합된 복합형. 큰 Zone 에서는 zone_adapt 가능, 기본은 actor / hierarchy 패턴 추출 | +| **05** | `1171281176` | `side-card-with-list` | `medium` | `zone_extract` | `auto_estimated` | (좌우 분할 — 일부 추출) | 좌측 카드 + 우측 리스트. Zone 안에서 좌·우 패턴 추출 | +| **06** | `1171281177` | `full-page-map-banner` | `reference` | `reference_only` | `user_confirmed` | (콘텐츠 슬롯형 X — 참고용) | 지도 / 마커 / 현황 시각자료 중심. 일반 텍스트 Zone 구조로 직접 사용 X. 지도형 콘텐츠가 있을 때 디자인 참고 | +| **07** | `1171281178` | `2col-paired-list` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` 또는 페어드 | 2 컬럼 페어드 리스트 — Zone 사이즈 맞춰 표 재구성 | +| **08** | `1171281179` | `3-section-framework` | `medium` | `zone_extract` | `auto_estimated` | (3 섹션 — 일부 추출) | 효율적 정보 관리 (What/How/When). 3 섹션 패턴 추출 | +| **09** | `1171281180` | `list-stacked-vertical` | `high` | `zone_direct` | `auto_estimated` | `templates/blocks/cards/` 또는 list 류 | 5 항목 세로 리스트 — Zone 직접 적용 | +| **10** | `1171281181` | `radial-diagram-5` | `medium` | `zone_adapt` | `auto_estimated` | `templates/blocks/visuals/` | 5 way 방사형 다이어그램 — Zone 시각 재구성 | +| **11** | `1171281182` | `cards-3-category` | `medium` | `zone_extract` | `auto_estimated` | (3 카드 — 패턴 추출) | 시공단계 BIM 활용 — 3 카드 패턴 추출 | +| **12** | `1171281189` | `cycle-3way-intersection` | `medium` | `zone_adapt` | `auto_estimated` | `templates/blocks/visuals/venn-diagram.html` | 3 way 벤다이어그램. Zone 사이즈 맞춰 SVG 재구성 | +| **13** | `1171281190` | `3-column` | `medium` | `zone_extract` | `auto_estimated` | (3 단 — 패턴 추출) | 필수조건 (pillar 3). Zone 안에 3 단 패턴 추출. 02-2.2 매칭 실패 정답 frame (3차 대상) | +| **14** | `1171281191` | `persona-3col` | `medium` | `zone_extract` | `auto_estimated` | `templates/blocks/cards/card-text-grid.html` (참고) | 주체별 기대효과. Zone 안에서 3 카드 패턴 추출 | +| **15** | `1171281192` | `policy-4card-plus-list` | `low` | `zone_extract` | `auto_estimated` | (복합 — 일부 추출) | 4 카드 + 부속 리스트. Zone 안에서 4 카드만 추출하거나 리스트만 사용 | +| **16** | `1171281193` | `quadrant-4` | `medium` | `zone_extract` | `auto_estimated` | (4 사분면 — 패턴 추출) | BIM 수행 이슈 4사분면. 4 사분면 패턴 추출 | +| **17** | `1171281194` | `paired-rows-2x2` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` | 2x2 페어드 행 — Zone 표 재구성 | +| **18** | `1171281195` | `compare-rows` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/compare-3col-badge.html` (참고) | BIM·DX 비교. 매칭 시스템 검증된 대표 frame. Zone 안 표 재구성 | +| **19** | `1171281197` | `cards-3-compare` | `medium` | `zone_extract` | `auto_estimated` | (3 단 비교 — 패턴 추출) | 설계방식 왜곡 — 3 비교 패턴 추출 | +| **20** | `1171281198` | `cards-3-header` | `medium` | `zone_extract` | `auto_estimated` | (3 헤더형 — 패턴 추출) | DX 는 S/W 가 필수 — 3 헤더 패턴 추출 | +| **21** | `1171281201` | `split-panel-diagram` | `low` | `zone_extract` | `auto_estimated` | (분할 패널 — 패턴 추출) | Solution Engn. S/W. 분할 패널 패턴 일부 추출 | +| **22** | `1171281202` | `split-panel-numbered` | `low` | `zone_extract` | `auto_estimated` | (분할 패널 + 번호 — 패턴 추출) | Model 특화 Engn. S/W. 분할 + 번호 패턴 추출 | +| **23** | `1171281203` | `table-2col` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` | Application S/W 의 구분. **table family 중복 — variant 통합 검토** | +| **24** | `1171281204` | `table-3col` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` | Engn. S/W 구성과 특징. **table family 중복 — variant 통합 검토** | +| **25** | `1171281205` | `left-categories-right-logos` | `medium` | `zone_extract` | `auto_estimated` | (좌우 분할 — 패턴 추출) | 상용 Engn. S/W. 좌우 분할 패턴 추출 | +| **26** | `1171281206` | `cards-4-grid` | `medium` | `zone_extract` | `auto_estimated` | (4 카드 — 패턴 추출) | 상용 S/W 의존 4 대 문제. 4 카드 그리드 추출 | +| **27** | `1171281208` | `central-split-synthesis` | `low` | `zone_extract` | `auto_estimated` | (중앙 합성 — 일부 추출) | 건설산업 고부가가치화. 중앙 + 양쪽 패턴 일부 추출 | +| **28** | `1171281209` | `title-plus-3-emphasis` | `medium` | `zone_extract` | `auto_estimated` | (제목 + 3 강조 — 패턴 추출) | 현존 상용 S/W 의 현실. 제목 + 3 카드 패턴 추출 | +| **29** | `1171281210` | `banner-top-2col-bottom` | `low` | `zone_extract` | `auto_estimated` | (banner + 2col 복합 — 패턴 추출) | Process/Product 혁신 (AS-IS/TO-BE). Zone 안에서 banner + 2col 패턴만 추출 | +| **30** | `1171281211` | `table-3col` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` | 산업별 현황. **table family 중복 — variant 통합 검토** | +| **31** | `1171281212` | `table-3col` | `high` | `zone_adapt` | `auto_estimated` | `templates/blocks/tables/` | 산업별 특성과 발전방향. **table family 중복 — variant 통합 검토** | +| **32** | `1171281213` | `central-5-goals` | `low` | `zone_extract` | `auto_estimated` | (5 목표 복합 — 일부 추출) | 정책 달성. 중앙 5 목표 패턴 일부 추출 | + +--- + +## 자동 추정 룰 + +### layout 패턴 → Zone 적용 방식 + +| 패턴 / family | Zone 적용 방식 | Zone 적합성 | 근거 | +|---|---|---|---| +| `list-*` (단순 리스트) | `zone_direct` | `high` | 구조 단순 — Zone 안 그대로 | +| `compare-rows` / `table-*col` / `paired-rows-*` (표 / 페어드) | `zone_adapt` | `high` | 표 구조 — Zone 사이즈 맞춰 재구성 | +| `cycle-*` / `circular-*` / `radial-*` / `quadrilateral-*` (시각 다이어그램) | `zone_adapt` | `medium` | 시각 디자인 — Zone 사이즈 맞춰 SVG 재구성 | +| `cards-3-*` / `persona-3col` / `3-column` / `cards-4-grid` / `quadrant-4` / `3-section-*` (3·4 단 카드 / 칼럼) | `zone_extract` | `medium` | 패턴 일부 추출 (카드 / 칼럼) | +| `side-*` / `left-*-right-*` / `title-plus-*` (좌우 / 제목+카드) | `zone_extract` | `medium` | 부분 패턴 추출 | +| `banner-top-*-bottom` / `bullet-cards-*-plus-center` / `policy-*-plus-list` (복합) | `zone_extract` | `low` | 복합 구조 — Zone 에서는 일부만 추출 | +| `split-panel-*` / `central-*` (분할 / 중앙 합성) | `zone_extract` | `low` | 새 시각 패턴 — 일부 추출 | +| `full-page-map-*` (지도 / 배너) | `reference_only` | `reference` | 콘텐츠 슬롯형 X — 디자인 참고만 | + +--- + +## 분포 요약 + +### Zone 적용 방식 + +| 적용 방식 | 개수 | Frame | +|---|---|---| +| `zone_direct` | **2** | 03, 09 | +| `zone_adapt` | **11** | 01, 04, 07, 10, 12, 17, 18, 23, 24, 30, 31 | +| `zone_extract` | **18** | 02, 05, 08, 11, 13, 14, 15, 16, 19, 20, 21, 22, 25, 26, 27, 28, 29, 32 | +| `reference_only` | **1** | 06 | +| `reject` | **0** | — | + +**합계 검증** : 2 + 11 + 18 + 1 + 0 = **32** ✓ + +### Zone 적합성 + +| 적합성 | 개수 | Frame | +|---|---|---| +| `high` | **9** | 03, 07, 09, 17, 18, 23, 24, 30, 31 | +| `medium` | **15** | 01, 04, 05, 08, 10, 11, 12, 13, 14, 16, 19, 20, 25, 26, 28 | +| `low` | **7** | 02, 15, 21, 22, 27, 29, 32 | +| `reference` | **1** | 06 | + +**합계 검증** : 9 + 15 + 7 + 1 = **32** ✓ + +### 검토 상태 + +| review_status | 개수 | Frame | +|---|---|---| +| `auto_estimated` | **30** | (04, 06 제외 모두) | +| `needs_review` | **0** | (04, 06 사용자 확정 완료) | +| `user_confirmed` | **2** | **04** (zone_extract 확정), **06** (reference_only 확정) | + +--- + +## 핵심 메시지 + +> **모든 frame 은 최종적으로 Zone 에 맞게 들어간다.** +> **원본이 full-slide 인지 아닌지는 참고 정보일 뿐, 최종 사용 단위는 Zone 이다.** +> +> - 사용자가 의미한 "needs_split" = 실제 의미는 `zone_extract` 또는 `zone_adapt` +> - frame 은 디자인 레퍼런스 / 구조 패턴 / 슬롯 힌트 +> - Phase Z output 은 항상 Zone 에 맞는 재구성 결과 + +--- + +## 다음 단계 + +### ✅ 완료 (1 차) +- 32 frame 자동 추정 표 작성 +- Frame 04, 06 사용자 확정 (`user_confirmed`) +- 컬럼명 정정 : `legacy 대응 블록(참고, 미검증)` → `legacy 스타일 출처(참고)` + +### ✅ 완료 (2 차, 2026-04-28) — Phase Z Frame Style Inventory 작성 + +산출물 : [`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md) + +- Source Policy : 메인 (`figma_to_html_agent/blocks/` 32 frame) / 토큰 (`templates/styles/tokens/`) / legacy (`templates/blocks/structures/`) +- Frame Inventory 32 행 (변환 14 + 미변환 18 보일러플레이트) +- Token Inventory 18 행 (`covered` 7 / `gap_candidate` 5 / `hierarchy_mapping_only` 3 / `hold_recheck_after_conversion` 3) +- Legacy Reference 6 행 (모두 `delete_after_extract` 후보) + +> ⚠️ inventory = **추출 / 검증 단계**. 실제 token 파일 생성 / 변경 / catalog 설계 / templates/blocks 삭제 등 **실행은 별도 승인 단계**. + +### 후속 작업 +- **3차** — Frame 14 anchor_sets 재라벨링 (02-2.2 매칭 실패 교정 — 매칭 성능 교정 작업, 별도 진행) +- **추가** — table family (23/24/30/31) variant 통합 설계 (Phase Z catalog 작성 시) +- **확정 처리** — 2 차 조사 결과 후 high confidence frame 부터 `user_confirmed` 일괄 처리 + +--- + +## 부록 — 제외 / 특수 항목 + +`1171281171` 은 `texts.md` 만 존재하고 `index.html` / `analysis.md` 가 없어 Phase Z frame / style inventory 메인 대상에서 제외한다. 정체는 미확인. diff --git a/docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md b/docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md new file mode 100644 index 0000000..4d2368a --- /dev/null +++ b/docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md @@ -0,0 +1,1398 @@ +# Phase Z Catalog / Runtime Design + +> **본 문서는 Phase Z-1 사전 작업의 마지막 설계 산출물이며, Phase Z-2 본격 구현 (catalog / runtime / matching 통합) 의 입력 문서다.** +> +> ⚠️ 본 문서는 **1 차 — skeleton + 책임 영역 정의**. 각 영역은 "무엇을 결정하는가 / 무엇을 결정하지 않는가" 까지만. 세부 설계는 단계적 추가. +> ⚠️ 본 문서 = **설계 책임 정리**. 코드 / 파일 변경 / 기존 자산 삭제 X. + +--- + +## 1. 문서 위치 / 목적 + +본 문서는 design_agent 의 Phase Z 흐름에서 **catalog (frame ↔ Zone 매핑) + runtime (Jinja2 template + AI 경계) 의 책임 분리** 를 정의한다. + +**위치 (Phase Z-1 사전 작업 산출물 흐름)** : + +1. [`FRAME-INTEGRATION-MAP.md`](FRAME-INTEGRATION-MAP.md) — 32 frame Zone 적용 분류 +2. [`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md) — 32 frame + 18 token + 6 legacy +3. **본 문서** — 위 두 인벤토리 + IMPROVEMENT-REDESIGN.md 5 단계 흐름 → catalog / runtime 책임 정리 + +→ Phase Z-2 본격 구현의 *입력 문서*. 본 문서는 코드 / 파일 변경을 *지시하지 않음*. + +**목적** : + +- frame inventory + style inventory 가 catalog 어디에서 책임지는지 정의 +- AI 가 *건드리지 않는* 영역 명시 (Phase R' 회귀 방지) +- 코드 구현 시 어떤 책임이 어디 살아야 하는지 사전 합의 + +--- + +## 2. 입력 문서 + +| 문서 | 본 설계가 받는 입력 | +|---|---| +| [`IMPROVEMENT-REDESIGN.md`](../../IMPROVEMENT-REDESIGN.md) | Phase Z 5 단계 흐름, 위계 + 용어, 절대 룰 (텍스트 무손실 / MDX 1 = 슬라이드 1 / 자유 디자인 금지 / 불일치 시 레이아웃 회귀) | +| [`FRAME-INTEGRATION-MAP.md`](FRAME-INTEGRATION-MAP.md) | 32 frame Zone 적용 분류 (`zone_direct` / `zone_adapt` / `zone_extract` / `reference_only`), 검토 상태, 분포 | +| [`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md) | Frame Inventory 32 행 (변환 14 + 미변환 18) + Token Inventory 18 행 (covered / gap_candidate / hierarchy_mapping_only / hold_recheck_after_conversion) + Legacy Reference 6 행 | + +--- + +## 3. 핵심 원칙 (Phase Z 준수) + +| 원칙 | 의미 | +|---|---| +| **final unit = Zone** | 슬라이드의 최종 조립 단위는 Zone. frame 은 절대 슬라이드에 직접 안 꽂힘 | +| **frame = reference / pattern / slot hint** | frame 은 디자인 레퍼런스 / 구조 패턴 / 슬롯 힌트만. 원본 그대로 사용 X | +| **AI = zone content only** | AI 호출은 zone 안 콘텐츠 매핑 / 텍스트 다듬기 / 디자인 변형 단위로만 | +| **HTML / Jinja2 = code only** | HTML 구조 / 레이아웃 / 프리셋 결정 / 새 frame 결정은 코드만 (AI 호출 X) | +| **legacy blocks runtime reuse X** | 기존 `templates/blocks/structures/` 6 파일은 runtime 재사용 후보 X. 스타일 / 토큰 매핑 참고만 | + +--- + +## 4. 책임 영역 (8 영역) + +각 영역의 **무엇을 결정하는가 / 무엇을 결정하지 않는가** 만 본 1 차에서 정의. 세부 설계는 다음 차수. + +### 4.1 Zone Catalog + +**결정함** : +- frame ↔ Zone 매핑 룰 (어느 frame 이 어느 Zone 에 들어갈 수 있는가) +- 레이아웃 프리셋 (Type A / B / B' / B'') 별 Zone 구성 +- 매칭 분기 (완벽 / 어정쩡 / 안 됨) 의 Zone 단위 의사결정 +- `zone_application` (`zone_direct/adapt/extract/reference_only`) 의 catalog 안 표현 + +**결정 안 함** : +- 실제 frame 의 시각 스타일 (→ 4.2 Family CSS) +- HTML 구조 (→ 4.3 Jinja2 Runtime Templates) +- AI 호출 시점 / 입력 (→ 4.4 AI Boundary) + +### 4.2 Family CSS + +**결정함** : +- frame family 별 시각 / 스타일 규칙 정의 + - 후보 family (Frame Inventory 의 Phase Z Target 컬럼 기반) : `compare-paired.css` / `compare-table.css` / `pill-list.css` / `three-pillar.css` / `persona-cards.css` / `quadrant.css` / `split-panel.css` / `split-center.css` 등 +- variant 분기 (예 : `compare-paired.css` 의 `paired-rows` vs `vs-center-badge`) +- token (color / gradient) 사용 + 신규 token 후보 흡수 / 기각 + +**결정 안 함** : +- HTML 마크업 자체 (→ 4.3) +- frame ↔ Zone 매핑 (→ 4.1) +- 텍스트 콘텐츠 (→ 4.4) + +### 4.3 Jinja2 Runtime Templates + +**결정함** : +- HTML 구조 (slide-base + body + zone slot + frame slot 의 마크업 골격) +- 슬롯 의미 매핑 (frame slot ↔ MDX 콘텐츠) +- 검증 (overflow / 시각 품질) hook 위치 +- 본문 preview ↔ 팝업 원문 분리 위치 (텍스트 무손실 보존) + +**결정 안 함** : +- 시각 스타일 (→ 4.2) +- frame 선택 (→ 4.1) +- AI 호출 (→ 4.4) + +### 4.4 AI Boundary + +**결정함** : +- AI 호출 **허용** 범위 — zone 안 콘텐츠 매핑 / 텍스트 다듬기 / 슬롯 의미 미세 조정 +- AI 호출 **금지** 범위 — HTML 구조 / 레이아웃 / 프리셋 결정 / 새 frame 결정 (Phase R' 회귀 방지) +- AI 입력 / 출력 형식 (zone 단위 컨텍스트만, 슬라이드 전체 X) + +**결정 안 함** : +- AI 모델 / 프롬프트 자체 (구현 단계) +- AI 호출 횟수 / 캐싱 (구현 단계) + +### 4.5 Token Resolution + +**결정함** : +- frame raw px (figma 1280 폭 기준) ↔ slide-body 스케일 (1200×590) 매핑 룰 +- `hierarchy_mapping_only` 행 (Token Inventory) 이 catalog 어디에서 적용되는가 +- 신규 token (`gap_candidate`) 채택 / 기각 의사결정 위치 +- `hold_recheck_after_conversion` token 의 재검증 시점 + +**결정 안 함** : +- token 값 자체 (→ Style Inventory 영역) +- token 파일 생성 / 변경 (구현 단계, 사용자 승인 후) + +### 4.6 Asset Policy + +**결정함** : +- 자산 의존도 큰 frame (F01 9 자산 / F07 16 자산 / F14 사진 / F22 15 자산 등) 의 처리 정책 +- placeholder / 사용자 제공 / SVG 변환 / 이미지 유지 분기 룰 +- `reference_only` frame (F06) 의 자산 정책 (직접 적용 X) + +**결정 안 함** : +- 실제 자산 파일 / 경로 (구현 단계) +- placeholder 의 시각 디자인 (구현 단계) + +### 4.7 Variant Model + +**결정함** : +- family 안 variant 의 표현 방식 후보 (CSS class modifier / data attribute / template parameter 등) +- variant ↔ frame 매핑 룰 (`compare-paired.css` 의 `paired-rows` ↔ Frame 17 / `vs-center-badge` ↔ Frame 18 등) +- variant 충돌 시 의사결정 (예 : 같은 zone 에 두 variant 가능 시) + +**결정 안 함** : +- 구체 CSS 구현 (→ 4.2) +- variant 추가 / 폐기 (구현 단계) + +### 4.8 Fallback Behavior + +**결정함** : +- 5 차 Fallback (코드 → AI 콘텐츠 조정 → 단일 프리셋 강제) 의 catalog 단위 책임 +- 매칭 실패 시 zone 단위 동작 +- AI 콘텐츠 조정 (4 차) 의 입력 / 출력 경계 (frame / zone / container 유지, 콘텐츠만 조정) +- 레이아웃 회귀 (불일치 시 그릇 변경) 의 catalog 단위 트리거 + +**결정 안 함** : +- AI 호출 자체 (→ 4.4) +- HTML 재구성 (→ 4.3) + +--- + +## 5. 비범위 + +본 문서는 **설계 책임 정의** 만. 다음은 본 문서 비범위 : + +- ❌ 코드 구현 (Phase Z-2 이후) +- ❌ 기존 `templates/blocks/` 삭제 (사용자 승인 후 별도 단계) +- ❌ `templates/styles/frame-patterns/` 신규 파일 생성 (사용자 승인 후) +- ❌ `templates/styles/tokens/` 의 `gap_candidate` token 추가 (사용자 승인 후) +- ❌ legacy structures 6 파일 삭제 (사용자 승인 후) +- ❌ AI 모델 / 프롬프트 / 호출 횟수 결정 +- ❌ Style Inventory 의 추가 항목 작성 (별도 작업) + +--- + +## 6. 단계적 작성 계획 + +| 차수 | 작성 내용 | 상태 | +|---|---|---| +| 1 차 | skeleton + 책임 영역 정의 | ✅ | +| 2 차 | Zone Catalog 본격 — frame ↔ Zone 매핑 / 프리셋 / 매칭 분기 (본 문서 § 7) | ✅ | +| 3 차 | Family CSS 본격 — Area 후보 / variant 참조 / token 참조 / asset 표시 (본 문서 § 8) | ✅ | +| 4 차 | Jinja2 Runtime Templates 본격 — 입력 계약 / 조립 위계 / status 인터페이스 / AI 진입 (본 문서 § 9) | ✅ | +| 5a 차 | AI Boundary 본격 — 호출 단위 / 허용 입출력 / 금지 출력 (본 문서 § 10) | ✅ | +| 5b 차 | Token Resolution 본격 — 4 status 처리 룰 / scale 위계 매핑 / 보류 원칙 (본 문서 § 11) | ✅ | +| 5c 차 | Asset Policy 본격 — 의존도 / 자산 유형 / 5 상태 라벨 / asset_meta (본 문서 § 12) | ✅ | +| 5d 차 | Variant Model 본격 — 표현 방식 선택 / family 별 variant 관리 / 추가-분리-폐기 기준 (본 문서 § 13) | ✅ | +| 5e 차 | Fallback Behavior 본격 — status 진입 후 동작 / 5 차 Fallback / AI 경계 / 레이아웃 회귀 (본 문서 § 14) | ✅ | +| 6 차 | 통합 검증 + Phase Z-2 구현 진입 결정 (본 문서 § 15) | ✅ 검증 완료, 구현 착수 별도 승인 | + +각 차수마다 user 검토 후 다음 진입. **6 차 완료 = 현재 위치** (1~5e 본격 설계 + 통합 검증 모두 완료). Phase Z-2 구현 착수는 *별도 사용자 승인* 후. + +--- + +## 7. Zone Catalog — 2 차 본격 설계 + +> ⚠️ 본 섹션은 **Zone Catalog 책임 영역만** 다룬다. Family CSS / Jinja2 / Fallback 실제 동작 / AI 호출 등 다른 영역 침범 X. +> ⚠️ Frame Inventory / FRAME-INTEGRATION-MAP.md 의 표 / 통계는 다시 베끼지 않음 — 링크 참조 + catalog 안에서 어떻게 *소비* 되는지만. + +### 7.1 Zone Catalog 의 역할 + +Zone Catalog 는 다음을 책임진다 : + +1. **레이아웃 프리셋 ↔ MDX 콘텐츠** 결정 — Type A / B / B' / B'' 중 어느 프리셋이 본 MDX 에 적합한가 +2. **Zone 슬롯 ↔ frame** 매핑 — 각 Zone 에 어느 frame 후보가 들어갈 수 있나 +3. **catalog status 라벨 부여** — `zone_application × confidence` 매트릭스 결과 (§ 7.4) + +받는 입력 : +- `FRAME-INTEGRATION-MAP.md` — 32 frame 의 `zone_application` 라벨 +- 매칭 시스템 (V1~V4, `tests/matching/`) — frame ↔ MDX confidence 점수 + +출력 : +- 본 MDX 에 대한 *Zone 별 catalog status 라벨* +- 후속 영역 (Family CSS / Fallback / AI) 으로 전달되는 신호 + +→ **Zone Catalog 는 라벨 부여까지만**. 라벨 후 실제 적용은 Family CSS / Fallback Behavior / AI Boundary 영역. + +### 7.2 레이아웃 프리셋 + Zone 타입 + +Phase Z 위계의 두 레벨을 분리해서 정의한다. + +#### 7.2-1 레이아웃 프리셋 후보 + +slide-body (≈ 1200×590) 를 어떻게 나누는가. 프리셋 4 종 (IMPROVEMENT-REDESIGN.md 참조) : + +| Preset | 분배 형태 | 적합한 콘텐츠 신호 | +|---|---|---| +| `Type A` | sidebar-right (`title / body / sidebar`) | reference 꼭지 1+ | +| `Type B` | top + bottom (단일 강조 + 보조) | hero-detail 류 | +| `Type B'` | two-column (좌·우 대등) | 비교 류 | +| `Type B''` | three-section (3 영역 분할) | 3 카테고리 / 3 단계 | + +→ **catalog 가 결정** — MDX 콘텐츠 신호 ↔ 프리셋 선택. 결정 룰의 구체화는 추가 차수. + +→ **결정 안 함** — 프리셋 안의 시각 디자인 (Family CSS), 프리셋 내부 HTML 구조 (Jinja2). + +#### 7.2-2 Zone 타입 후보 + +각 프리셋이 만들어낸 *슬롯의 역할* 후보. 슬롯 위치 ↔ 콘텐츠 role. + +| Zone slot 위치 (예) | 받을 수 있는 콘텐츠 role 후보 | +|---|---| +| `top` | summary / overview / key-insight / table-header | +| `bottom_l` / `bottom_r` | detail / compare-side / supporting | +| `sidebar` | reference / quote / supporting-list | +| `body` (single) | full-pattern / hero-detail / compare-rows | + +→ **catalog 가 결정** — 어느 Zone 슬롯이 어느 콘텐츠 role 을 받을지. + +→ **결정 안 함** — 슬롯 안 시각 디자인, 슬롯 안 frame 의 stylization. + +### 7.3 frame zone_application 과 catalog 의 관계 + +`FRAME-INTEGRATION-MAP.md` 의 4 라벨 (`zone_direct / zone_adapt / zone_extract / reference_only`) 을 catalog 가 어떻게 *소비* 하는가 : + +| zone_application | catalog 의 소비 방식 | +|---|---| +| `zone_direct` | frame 구조 그대로 zone 슬롯에 매핑. catalog 는 슬롯 사이즈만 통과 | +| `zone_adapt` | frame 구조 동일, 사이즈 / 비율 재조정 hint 와 함께 후속 영역 (Family CSS) 에 전달 | +| `zone_extract` | frame 의 일부 패턴만 (예 : 3-column 만 추출) hint 와 함께 후속 영역에 전달 | +| `reference_only` | runtime 적용 X — `not_runtime_candidate` 라벨링. 디자인 톤 참고만 | + +→ 4 라벨의 *분류 자체* 는 FRAME-INTEGRATION-MAP.md 가 결정. catalog 는 *소비 방식* 만 책임. + +### 7.4 catalog status 라벨 — `zone_application × confidence` 매트릭스 + +본 catalog 의 *핵심 출력*. 두 축은 **직교** : + +- `zone_application` = frame 자체의 Zone 적용 방식 (frame 의 *고유 속성*) +- `confidence` = 특정 MDX 콘텐츠 ↔ frame 매칭 품질 (콘텐츠 ↔ frame *상호작용*) + +매트릭스 (4 × 3 = 12 셀, 6 라벨) : + +| | `high` | `medium` | `low` | +|---|---|---|---| +| `zone_direct` | `matched_zone` | `review_required` | `fallback_candidate` | +| `zone_adapt` | `adapt_matched_zone` | `review_required` | `fallback_candidate` | +| `zone_extract` | `extract_matched_zone` | `review_required` | `fallback_candidate` | +| `reference_only` | `not_runtime_candidate` | `not_runtime_candidate` | `not_runtime_candidate` | + +> **원칙 — `medium` confidence 는 자동 확정하지 않는다.** +> +> Zone 적용 방식이 `zone_direct`, `zone_adapt`, `zone_extract` 중 무엇이든, +> confidence 가 `medium` 이면 `review_required` 로 보낸다. + +이유 : Phase Z 절대 룰 *"불일치 시 레이아웃 회귀 — 콘텐츠 줄이지 않고 그릇 변경"* 과 정합. 자동화가 과감해지는 것 방지. + +각 라벨의 의미 : + +| 라벨 | 의미 | +|---|---| +| `matched_zone` | `zone_direct` × `high`. 즉시 적용 후보 | +| `adapt_matched_zone` | `zone_adapt` × `high`. 사이즈 재조정 적용 후보 | +| `extract_matched_zone` | `zone_extract` × `high`. 일부 패턴 추출 적용 후보 | +| `review_required` | 모든 `medium`. 사람 / 후속 단계 검토 필요 | +| `fallback_candidate` | `zone_direct/adapt/extract` × `low`. fallback 진입 | +| `not_runtime_candidate` | `reference_only` × any. 디자인 톤 참고만 | + +> `fallback_candidate` 는 fallback 동작을 실행한다는 뜻이 아니라, Fallback Behavior 설계로 넘길 **후보 상태** 를 의미한다. + +→ **catalog 는 라벨 부여까지만**. 라벨 후의 실제 동작은 Fallback Behavior / AI Boundary / Family CSS 영역. + +### 7.5 Zone Catalog 가 결정하지 않는 것 + +Skeleton § 4.1 의 "결정 안 함" 확장 + 본 2 차에서 추가 명확화 : + +- ❌ frame 의 시각 / 스타일 (→ Family CSS, 미작성) +- ❌ HTML 구조 / 슬롯 마크업 (→ Jinja2 Runtime Templates, 미작성) +- ❌ AI 호출 시점 / 입력 형식 (→ AI Boundary, 미작성) +- ❌ `fallback_candidate` 진입 후 실제 동작 (→ Fallback Behavior, 미작성) +- ❌ `review_required` 진입 후 처리 흐름 (→ AI Boundary 또는 Fallback Behavior, 미작성) +- ❌ `not_runtime_candidate` frame 의 디자인 톤 적용 방식 (→ Asset Policy 또는 Style Inventory) +- ❌ variant 표현 방식 (→ Variant Model, 미작성) +- ❌ token 선택 / 적용 (→ Token Resolution, 미작성) +- ❌ confidence 임계값 자체 (예 : 몇 % 이상이 `high` 인가) — 매칭 시스템 (V1~V4) 의 책임. catalog 는 라벨화된 confidence 만 받음 + +--- + +### 7 차수 자체 점검 (작성 후) + +- ✅ Zone Catalog 영역만 다룸 (Family CSS / Jinja2 / Fallback 실제 동작 / AI 호출 침범 X) +- ✅ Frame Inventory 32 행 표 redescription 0 — 링크 참조만 +- ✅ FRAME-INTEGRATION-MAP.md 분포 통계 redescription 0 +- ✅ 직교 축 (`zone_application` ⊥ `confidence`) 명시 +- ✅ "medium 자동 확정 X" 원칙 prominent callout +- ✅ Skeleton § 4.1 의 "결정 안 함" 8 항목 § 7.5 에서 확장 +- ✅ catalog 가 *결정함* 까지만, 결정 후 동작은 후속 영역 명시 + +--- + +## 8. Family CSS — 3 차 본격 설계 + +> ⚠️ **룰 1** — 본 섹션은 **Family CSS 관점에서 variant / token / asset 을 어떻게 *참조* 하는지만** 다룬다. Variant Model 자체, Token Resolution 자체, Asset Policy 자체는 후속 dedicated 영역 (§ 4.5 / 4.6 / 4.7) 에서 결정. +> +> ⚠️ **룰 2** — Family CSS 는 *frame 별 CSS 가 아니라* **family 단위 CSS** 다. frame 차이는 variant / meta 로 표현한다. + +### 8.1 Family CSS 의 역할 + +Family CSS 는 다음을 책임진다 : + +1. **family 단위 시각 / 스타일 규칙** 정의 — 같은 family 안 frame 들의 공통 패턴 +2. **variant 슬롯 노출** — frame 차이를 family 안 variant 로 받아낼 수 있는 hook +3. **token / asset 참조 인터페이스** — Token Inventory / Asset Policy 의 *소비자* + +받는 입력 : +- Frame Inventory 의 `Phase Z Target` 컬럼 (family 후보) +- Token Inventory 의 4 status (covered / gap_candidate / hierarchy_mapping_only / hold) +- Frame Inventory 의 `Asset Notes` (자산 의존도) +- Zone Catalog (§ 7) 의 status 라벨 (matched_zone / adapt_matched_zone / extract_matched_zone — Family CSS 적용 후보 신호) + +→ **Family CSS 는 family 단위 패턴 정의까지만**. variant 모델 자체 / token 채택 / asset 정책은 후속 영역. + +### 8.2 Family CSS Area 후보 (11) + +본 섹션의 **centerpiece**. Frame Inventory 의 `Phase Z Target` 컬럼을 family 단위로 수렴한 결과. + +| Family CSS Area 후보 | 흡수 frame | 변형 축 (variant 후보) | asset 의존도 | Notes | +|---|---|---|---|---| +| `compare-paired.css` (후보) | F17, F18 | `paired-rows` (F17) / `vs-center-badge` (F18) | mid | F17 R16 두루마리 pill 이미지 의존. F18 의 center badge 컬럼이 variant 키 | +| `compare-table.css` (후보) | F23, F24 (+ F30, F31 미변환 통합 후보) | `columns[N=2~4]` / `header_style` / `row_density` | low | line 은 CSS border 변환 가능. 30/31 변환 후 variant range 확정 | +| `split-panel.css` (후보) | F21, F22 | `right_items[N=3~8]` / `left_categories[N=2~5]` / `bracket_image` (optional) | high | 14~15 자산 (배경 / 뱃지 / 화살표 / 행 바). Phase Z 재현 시 자산 풀 필수 | +| `persona-cards.css` (후보) | F14 | `actor_count[3]` / `actor_hue_palette` / `photo_required` | high | 사진 ×3 필수 (placeholder / 사용자 자산), badge outer/inner ×6, 체크박스 ×20 | +| `three-pillar.css` (후보) | F13 | `pillars[3]` (현재 고정) / `column_hue_palette` | low | 아이콘 PNG 1, 테두리 SVG 4 → CSS 변환 가능 | +| `quadrant.css` (후보) | F16 | `quadrants[4]` (고정) / `bar_style` / `center_quote` (optional) | mid | 배경 텍스처 PNG ×4 동일, bar SVG → CSS gradient 변환 가능 | +| `pill-list.css` (후보) | F09 | `items[N=3~7]` / `stacking_pattern` / `arc_decoration` (optional) | low | 아크 SVG / 화살표 SVG 만 이미지 유지. pill 본체 CSS | +| `cycle.css` (후보) + svg helpers (helper area 후보) | F12 | `main_circles[3]` (현재 고정) / `accent_circles[6]` | mid | 19 SVG (Ellipse) — 좌표 기반 SVG 재구성 가능. bg_texture PNG 1 | +| `split-center.css` (후보) | F27 | (sparse data — 추가 관찰 후 확정) | mid | flat.md sparse. variant range 후속 차수에서 | +| `system-diagram.css` (후보) | F07 | (sparse data — 추가 관찰 후 확정) | very high | 16 자산. 자산 풀 필수 | +| (asset-heavy / reference-like family 후보) | F01 | (variant 미정 — pure CSS family 아님) | very high | 9 자산 모두 이미지. CSS 패턴이라기보다 *asset slot 컴포넌트*. family 분류 자체 검토 후보 | + +**주의** : +- Family CSS Area 후보 = 결정 X. 실제 파일 생성 / 이름은 사용자 승인 후. +- 11 개 → 최종 family 수는 더 줄 수 있음 (특히 sparse data F07 / F27 / F01 은 추가 검증 후 통합 / 분리 결정). +- F01 은 *pure CSS family 아님* — 별도 분류 필요할 수 있음 (asset-heavy / reference-like family). + +→ FRAME-INTEGRATION-MAP.md 의 frame 별 분류는 redescribe 안 함. 본 표는 **family 수렴 결과** 만. + +### 8.3 variant 참조 방식 + +Family CSS 는 frame 차이를 variant 로 받아낸다. variant 의 **표현 방식 후보** : + +| 후보 방식 | 예시 | 적합한 케이스 | +|---|---|---| +| **CSS class modifier** | `.compare-paired.--vs-center-badge` | 시각 / 구조가 크게 다른 variant (F17 vs F18 의 pill alternation vs center badge) | +| **Data attribute** | `[data-variant="paired-rows"]` | 마크업은 같고 styling 만 분기 | +| **Template parameter** | Jinja2 `{{ variant }}` 변수 + family CSS 의 조건부 selector | 슬롯 수 / cardinality 차이 (예 : `columns[N=2~4]`) | +| **Variant family 자체 분기** | `compare-table--2col.css` / `compare-table--3col.css` (별도 entry) | columns 차이가 너무 클 때 (현재 시점에서는 over-split, 비추천) | + +**Family CSS 가 결정함** : variant 별로 *어느 표현 방식* 을 채택하는가 (참조 인터페이스만) + +**Family CSS 가 결정하지 않음** : variant 자체의 분류 / 추가 / 폐기 (→ § 4.7 Variant Model, 5 차) + +→ 본 차수에서는 표현 방식 후보 4 개 나열까지만. 어느 family 가 어느 방식을 쓸지의 결정은 5 차 Variant Model. + +### 8.4 token 참조 방식 + +Family CSS 가 Token Inventory 의 4 status 를 어떻게 *참조* 하는가 : + +| Token Inventory status | Family CSS 의 참조 방식 | +|---|---| +| `covered` | 기존 token 그대로 `var(--color-block-title-from)` 등 사용. 신규 정의 X | +| `gap_candidate` | **참조 placeholder 사용** — `var(--c-table-header-cyan)` (후보) 식으로 *후보 이름* 표기. 실제 token 추가 / 채택은 § 4.5 Token Resolution 의 결정 | +| `hierarchy_mapping_only` | frame raw px (figma 1280 폭) 직접 사용 X. slide-body 위계 토큰 (`--font-zone-title` / `--font-sub-title` 등) 참조 + zoom / scale 메타는 § 4.5 의 결정 | +| `hold_recheck_after_conversion` | Family CSS 에서 사용 X. 폐기 단정 X (32 frame 변환 후 재검증) | + +**Family CSS 가 결정함** : 어느 token 을 어느 슬롯에 쓰는가 (참조 매핑) + +**Family CSS 가 결정하지 않음** : token 자체의 추가 / 변경 / 폐기 (→ § 4.5 Token Resolution, 5 차) + +> Token Inventory 의 4 status 표는 [`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md) 참조. 본 섹션에서 redescribe X. + +### 8.5 asset 의존 표시 방식 + +asset-heavy family 의 self-policy 표시. § 8.2 의 `asset 의존도` 컬럼이 이미 분류 (low / mid / high / very high). Family CSS 는 이 분류를 self-meta 로 표시. + +| 의존도 | Family CSS 의 표시 방식 (후보) | +|---|---| +| `low` | meta 표시 불필요 | +| `mid` | family 헤더 주석에 `asset-dependency: mid` + 자산 항목 명시 (예 : SVG 아이콘 / 이미지 1~3 개) | +| `high` | family 헤더 주석에 `asset-dependency: high` + placeholder 정책 명시 (예 : "사진 ×3 — 사용자 제공 자산 필요") | +| `very high` | family 헤더 주석에 `asset-dependency: very high` + **runtime 적용 시 자산 풀 / placeholder 필수** 경고 | + +특수 케이스 : F01 같은 asset-heavy / reference-like family 는 **pure CSS family 가 아님** 명시. family 분류 자체가 asset slot 컴포넌트 / reference 후보 임을 self-meta 로. + +**Family CSS 가 결정함** : 의존도 self-meta 표시 형식 (참조 인터페이스) + +**Family CSS 가 결정하지 않음** : asset placeholder 의 실제 디자인 / 자산 풀 / 사용자 제공 정책 (→ § 4.6 Asset Policy, 5 차) + +### 8.6 Family CSS 가 결정하지 않는 것 + +Skeleton § 4.2 의 "결정 안 함" 확장 + 본 3 차에서 추가 명확화 : + +- ❌ HTML 마크업 자체 (→ Jinja2 Runtime Templates, 미작성) +- ❌ frame ↔ Zone 매핑 (→ Zone Catalog, § 7 작성됨) +- ❌ 텍스트 콘텐츠 / AI 호출 (→ AI Boundary, 미작성) +- ❌ variant 모델 자체 (분류 / 추가 / 폐기) — 본 § 8.3 에서는 *참조 표현 방식* 후보만 (→ § 4.7 Variant Model, 5 차) +- ❌ token 자체 (값 / 추가 / 변경 / 폐기) — 본 § 8.4 에서는 *참조 방식* 만 (→ § 4.5 Token Resolution, 5 차) +- ❌ asset 정책 자체 (placeholder 디자인 / 자산 풀 / 사용자 제공 룰) — 본 § 8.5 에서는 *self-meta 표시* 만 (→ § 4.6 Asset Policy, 5 차) +- ❌ frame 별 CSS — Family CSS 는 family 단위 (룰 2 재확인) +- ❌ 실제 CSS 파일 생성 / 이름 확정 / 위치 — 사용자 승인 후 별도 단계 +- ❌ legacy `templates/blocks/structures/` 6 파일 삭제 — 사용자 승인 후 + +--- + +### 8 차수 자체 점검 (작성 후) + +- ✅ Family CSS 영역만 다룸 (Variant Model 자체 / Token Resolution 자체 / Asset Policy 자체 / Jinja2 / AI 침범 X) +- ✅ Frame Inventory `Phase Z Target` 컬럼 redescription 0 — family 수렴 결과만 +- ✅ Token Inventory 4 status 표 redescription 0 — 링크 참조만 +- ✅ FRAME-INTEGRATION-MAP.md 분류 redescription 0 +- ✅ 룰 1 (참조하는지만 다룸) + 룰 2 (frame 별 X / family 단위) 섹션 머리에 명시 +- ✅ family 후보 11 개 centerpiece (자산 의존도 분류 포함) +- ✅ F01 asset-heavy / reference-like family 별도 분류 명시 +- ✅ Skeleton § 4.2 의 "결정 안 함" 8.6 에서 확장 + 5 차 dedicated 영역 cross-reference + +--- + +## 9. Jinja2 Runtime Templates — 4 차 본격 설계 + +> ⚠️ **룰 1** — Jinja2 는 HTML 구조를 조립한다. AI 는 Jinja2 구조를 만들지 않는다. AI 출력은 이미 정해진 zone slot 안의 콘텐츠 값으로만 들어온다. +> +> ⚠️ **룰 2** — Jinja2 는 *조립 실행자* 다. status label 결정 / variant 채택 결정 / fallback 정책은 다른 영역에서 받은 결정. Jinja2 는 그 결정을 받아 *조립* 만 한다. + +### 9.1 Jinja2 Runtime Templates 의 역할 + +Jinja2 는 다음을 책임진다 : + +1. **HTML 구조 조립** — slide-base → slide-body → layout preset → zone → family partial 의 마크업 조합 +2. **슬롯 배치** — zone slot 위치에 family partial / 콘텐츠 삽입 +3. **검증 hook 위치** — overflow 측정 / 시각 품질 평가 fixture 위치 +4. **본문 preview ↔ 팝업 원문 분리** — 텍스트 무손실 보존 (Phase Z 절대 룰) + +받는 입력 : +- Zone Catalog (§ 7) : `layout_preset`, zone status label +- Family CSS (§ 8) : family partial 식별자, variant +- AI 출력 : zone 단위 콘텐츠 +- MDX 원문 : preview / popup 분리용 + +→ Jinja2 = **조립 실행자**. 받은 결정을 마크업으로 직조하기만. + +### 9.2 입력 데이터 계약 + +Jinja2 가 받는 입력의 *필드 shape* : + +| 필드 | 출처 | shape / 예시 | +|---|---|---| +| `slide_meta` | MDX 분석 (Stage 1) | `{ chapter_title, conclusion, footer_text }` | +| `layout_preset` | Zone Catalog (§ 7.2-1) | `Type A` / `Type B` / `Type B'` / `Type B''` | +| `zones[]` | Zone Catalog (§ 7) | `[{ zone_id, slot_position, status_label, frame_id, content }, ...]` | +| `zone.content` | AI 출력 (zone 단위) + MDX 원문 | `{ preview_text, popup_full, slot_payload }` | +| `family_meta` | Family CSS (§ 8) | `{ family_id, variant, asset_dependency }` | + +**Jinja2 가 결정함** : 위 입력 shape 를 *수신* 하고 *마크업* 으로 변환. + +**Jinja2 가 결정하지 않음** : 필드 값 자체 (각 출처 영역의 결정), 새 필드 추가 / 변경. + +### 9.3 조립 위계 + +Phase Z 위계가 Jinja2 안에서 어떻게 표현되는가 : + +``` +slide-base (existing template) + ├─ slide-title ← MDX 대목차 자동 매핑 + ├─ slide-divider (고정) + ├─ slide-body (≈ 1200×590) + │ └─ layout preset (Type A / B / B' / B'') + │ └─ zones[] (preset 별 slot 정의) + │ └─ family partial (family CSS 영역) + │ └─ slot content (AI 출력 + MDX 원문) + └─ slide-footer ← MDX 대목차 결론 자동 매핑 +``` + +**Jinja2 가 결정함** : +- 위계 각 단계의 wrapper / container 마크업 +- preset → zones 분배 메커니즘 (preset-specific Jinja2 partial) +- zone → family partial 임베딩 위치 +- preview ↔ popup 분리 마크업 위치 + +**Jinja2 가 결정하지 않음** : +- preset 선택 (→ Zone Catalog § 7.2-1) +- zone slot 의 콘텐츠 role 매핑 (→ Zone Catalog § 7.2-2) +- family partial 의 시각 / 스타일 (→ Family CSS § 8) +- frame 별 차이 (→ family variant, § 8.3) + +### 9.4 Zone Catalog status label → 조립 인터페이스 + +§ 7.4 의 6 status label 을 Jinja2 가 어떻게 *받아* 조립하는가. **정책 결정 X, 조립 인터페이스만**. + +| Status label | Jinja2 의 조립 인터페이스 | +|---|---| +| `matched_zone` | family partial 렌더 대상. 입력 shape 그대로 마크업 조립 | +| `adapt_matched_zone` | family partial 렌더 대상 (adapt hint 를 `family_meta` 로 전달, 실제 스타일 처리는 Family CSS) | +| `extract_matched_zone` | family partial 렌더 대상 (extract hint 를 `family_meta` 로 전달) | +| `review_required` | preview / review payload 로 분리 마크업, 검토 marker 부착 | +| `fallback_candidate` | **Jinja2 직접 렌더 대상 아님** — Fallback Behavior 로 위임 | +| `not_runtime_candidate` | **런타임 family partial 렌더 대상 아님** — 디자인 톤 참고만 (Asset Policy / Style Inventory) | + +> 본 표는 *Jinja2 의 조립 인터페이스* 만 정의한다. 실제 fallback 동작 / review 처리 흐름 / not_runtime 의 디자인 톤 적용은 § 4.6 / § 4.8 의 영역. + +### 9.5 AI 출력의 Jinja2 진입 위치 + +AI 출력은 **`zone.content` 의 sub-필드** (§ 9.2) 로만 들어온다. + +| 진입 허용 필드 | 의미 | +|---|---| +| `zone.content.preview_text` | zone slot 본문 preview (AI 가 다듬은 짧은 표시 텍스트) | +| `zone.content.popup_full` | 팝업 / `
` / 별도 레이어 원문 (MDX 무손실) | +| `zone.content.slot_payload` | family partial 의 슬롯별 콘텐츠 (예 : `{ title, body, items[] }`) | + +**진입 금지 영역** (Phase R' 회귀 방지) : + +- ❌ HTML 구조 자체 (slide-base / slide-body / preset / zone / family partial 의 wrapper) +- ❌ class 명 / data attribute / id +- ❌ Jinja2 template (`{% %}` / `{{ }}` 매크로) +- ❌ family 선택 / variant 선택 / preset 선택 +- ❌ 새 frame 결정 / catalog status label 결정 + +> AI 가 출력할 수 있는 것은 *콘텐츠 값* 만. 구조 / 스타일 / 매핑은 모두 코드의 영역. +> +> 이 경계가 무너지면 Phase R' 의 "AI 가 HTML 구조 직접 생성" 회귀로 빠진다. **Jinja2 의 입력 계약 (§ 9.2) 이 이 경계의 강제 메커니즘**. + +### 9.6 Jinja2 가 결정하지 않는 것 + +Skeleton § 4.3 의 "결정 안 함" 확장 + 본 4 차 추가 : + +- ❌ frame 시각 / 스타일 (→ Family CSS § 8) +- ❌ frame ↔ Zone 매핑 / preset 선택 / status label 부여 (→ Zone Catalog § 7) +- ❌ AI 모델 / 프롬프트 / 호출 횟수 (→ AI Boundary § 4.4, 5 차) +- ❌ token 자체 (→ Token Resolution § 4.5, 5 차) +- ❌ asset 정책 자체 (→ Asset Policy § 4.6, 5 차) +- ❌ variant 모델 자체 (→ Variant Model § 4.7, 5 차) +- ❌ fallback 실제 동작 (→ Fallback Behavior § 4.8, 5 차) +- ❌ review_required 진입 후 검토 흐름 (→ AI Boundary 또는 Fallback Behavior, 5 차) +- ❌ 실제 Jinja2 파일 생성 / 마크업 코드 작성 — 사용자 승인 후 별도 단계 +- ❌ CSS class / id naming 확정 — 구현 단계 + +--- + +### 9 차수 자체 점검 (작성 후) + +- ✅ Jinja2 영역만 다룸 (정책 결정 / Family CSS / AI 영역 / Fallback 동작 침범 X) +- ✅ Frame Inventory / Token Inventory / Section 7~8 표 redescription 0 +- ✅ 룰 1 (HTML 조립 / AI 는 zone slot 콘텐츠만) + 룰 2 (조립 실행자 / 정책 결정자 X) 섹션 머리 명시 +- ✅ § 9.2 입력 데이터 계약 필드 shape 5 개 명시 +- ✅ § 9.4 status label *조립 인터페이스* 까지만, 정책 결정 X (fallback 동작 / review 흐름은 후속 영역) +- ✅ § 9.5 AI 진입 허용 / 금지 명시 — Phase R' 회귀 방지 메커니즘 +- ✅ § 9.6 boundary 10 항목 + 5 차 dedicated 영역 cross-reference + +--- + +## 10. AI Boundary — 5a 본격 설계 + +> ⚠️ **룰 1 — AI 호출 단위는 zone 안 콘텐츠다.** +> +> 슬라이드 전체 / 레이아웃 / 프리셋 / family 선택 / 새 frame 결정 / status label 결정은 AI 의 영역이 아니다. + +> ⚠️ **룰 2 — AI 출력은 `zone.content.*` 의 콘텐츠 값만이다.** +> +> HTML / CSS / class / Jinja2 / 구조 결정은 AI 출력에 포함하지 않는다. + +### 10.1 AI Boundary 의 역할 + +AI Boundary 는 다음을 책임진다 : + +1. **AI 호출 단위 정의** — 한 호출 = 하나의 zone 안 콘텐츠 scope +2. **허용 입력 / 출력 형식 정의** — AI 가 받는 것 / 만드는 것의 contract +3. **금지 영역 정의** — AI 가 만들지 말아야 할 것 (Phase R' 회귀 방지) +4. **Jinja2 입력 계약 (§ 9.2) 과의 정합** — `zone.content.*` 필드와 cross-reference + +받는 입력 : +- Zone Catalog (§ 7) : zone 정보 (zone_id, slot_position, frame_id, status_label) +- Family CSS (§ 8) : family meta (family_id, variant, asset_dependency) +- MDX 원문 : 해당 zone 에 속하는 부분 + +→ AI Boundary = **AI 의 활동 반경 정의**. 모델 / 프롬프트 / 호출 빈도는 본 차수 X. + +### 10.2 AI 호출 단위 — zone content only (scope) + +한 AI 호출 = **하나의 zone 안 콘텐츠** 단위. + +| scope 단위 | 허용 / 금지 | +|---|---| +| 한 zone 안 콘텐츠 | ✅ AI 호출 단위 | +| 슬라이드 전체 | ❌ | +| 레이아웃 / 프리셋 결정 | ❌ | +| family / variant 선택 | ❌ | +| 새 frame 발굴 / 결정 | ❌ | +| status label 부여 | ❌ | + +→ 한 슬라이드 = N 개 zone = 최대 N 회 AI 호출 (zone 별 1 회 단위). 호출 횟수 / 빈도 / 시점은 본 차수 X (파이프라인 / Fallback 영역). + +### 10.3 허용 입력 / 허용 출력 + +#### 허용 입력 (AI 가 받는 것) + +| 필드 | 의미 | +|---|---| +| `zone_meta` | `{ zone_id, slot_position, status_label, frame_id }` | +| `family_meta` | `{ family_id, variant, asset_dependency }` | +| `mdx_excerpt` | 해당 zone 에 속하는 MDX 원문 | +| `style_hint` | family 의 스타일 힌트 (예 : "title 위계", "body 위계") — Family CSS 영역에서 전달 | +| `layout_meta` | `{ preset_id, neighbor_zones }` — 이웃 zone 컨텍스트 (선택) | + +#### 허용 출력 (AI 가 만드는 것 — `zone.content.*`) + +| 필드 | 의미 | +|---|---| +| `zone.content.preview_text` | zone slot 본문 preview (짧은 표시 텍스트) | +| `zone.content.popup_full` | 팝업 / `
` 원문 (MDX 무손실 또는 AI 가 재구성한 무손실 버전) | +| `zone.content.slot_payload` | family partial 슬롯별 콘텐츠 (예 : `{ title, body, items[] }`) | + +→ 출력 3 필드 모두 § 9.2 의 `zone.content.*` 안에 들어간다. + +### 10.4 금지 출력 + +AI 가 출력에 포함하면 안 되는 것 : + +- ❌ HTML 구조 (`
`, `
`, `` 등 wrapper / container 마크업) +- ❌ CSS class 명 / id / data attribute +- ❌ Jinja2 template 매크로 (`{% %}` / `{{ }}`) +- ❌ family 선택 / variant 선택 / preset 선택 +- ❌ 새 frame 결정 / catalog status label 결정 +- ❌ token 추가 / 변경 / 폐기 결정 +- ❌ asset placeholder 디자인 +- ❌ fallback 정책 / review 정책 + +> 이 금지 출력 영역이 무너지면 **Phase R' 회귀** (AI 가 HTML 구조 직접 생성) 로 빠진다. § 9.5 의 *진입 위치 차단* + § 10.4 의 *금지 출력 명시* 가 두 면 차단 메커니즘. + +### 10.5 Jinja2 입력 계약 (§ 9.2) 과의 연결 + +AI 출력은 Jinja2 입력 계약의 **`zone.content` 필드** 로 들어간다. 입력 계약 안에서 *AI 영역 ↔ 다른 영역* 이 완전 분리됨 : + +| Jinja2 입력 계약 필드 (§ 9.2) | 출처 영역 | AI 가 만드는가 | +|---|---|---| +| `zone.content.preview_text` | AI Boundary | ✅ | +| `zone.content.popup_full` | AI Boundary | ✅ | +| `zone.content.slot_payload` | AI Boundary | ✅ | +| `slide_meta` | MDX 분석 (Stage 1) | ❌ | +| `layout_preset` | Zone Catalog (§ 7) | ❌ | +| `zones[]` 자체 / `zone_id` / `status_label` | Zone Catalog (§ 7) | ❌ | +| `family_meta` | Family CSS (§ 8) | ❌ | + +→ **Jinja2 입력 계약 자체가 boundary 강제 메커니즘**. AI 가 잘못된 영역에 출력해도 입력 계약 단계에서 reject 가능 (구현 단계 결정). + +### 10.6 AI Boundary 가 결정하지 않는 것 + +Skeleton § 4.4 의 "결정 안 함" 확장 + 본 5a 추가 : + +- ❌ 구체 프롬프트 / 모델 (구현 영역) +- ❌ 호출 비용 / 토큰 사용량 / 응답 시간 (구현 영역) +- ❌ **AI 호출 시점 / 빈도** — AI Boundary 는 *호출 시 무엇을 받고 무엇을 만드는가* 까지만, *언제 부르는가* 는 파이프라인 / Fallback Behavior 영역 +- ❌ fallback 실제 실행 절차 (→ Fallback Behavior § 4.8, 5e) +- ❌ review_required 의 검토 흐름 (→ Fallback Behavior 또는 파이프라인) +- ❌ token 자체 (값 / 추가 / 변경 / 폐기) (→ Token Resolution § 4.5, 5b) +- ❌ asset 정책 자체 (→ Asset Policy § 4.6, 5c) +- ❌ variant 모델 자체 (→ Variant Model § 4.7, 5d) +- ❌ HTML 구조 / Jinja2 마크업 (→ Jinja2 § 9, Phase R' 회귀 방지) +- ❌ AI 응답 검증 / 재호출 정책 (구현 영역) + +--- + +### 5a 차수 자체 점검 (작성 후) + +- ✅ AI Boundary 영역만 다룸 (Token / Asset / Variant / Fallback / Jinja2 / Zone Catalog 침범 X) +- ✅ Frame Inventory / Token Inventory / Section 7~9 표 redescription 0 +- ✅ 룰 1 (호출 단위 = zone) + 룰 2 (출력 = `zone.content.*` 콘텐츠 값만) 섹션 머리 명시 +- ✅ § 10.4 금지 출력 + § 9.5 금지 진입 = 두 면 Phase R' 회귀 차단 +- ✅ § 10.5 가 § 9.2 입력 계약과의 cross-reference 명시 (AI ↔ 다른 영역 분리) +- ✅ § 10.6 에 "AI 호출 시점 / 빈도" 비범위 명시 (Fallback / 파이프라인 정책 침범 차단) +- ✅ Skeleton § 4.4 의 "결정 안 함" 10 항목으로 확장 + +--- + +## 11. Token Resolution — 5b 본격 설계 + +> ⚠️ **룰 1 — Token Resolution 은 token 적용 판단을 정의하지만 token 파일을 수정하지 않는다.** +> +> `covered`, `gap_candidate`, `hierarchy_mapping_only`, `hold_recheck_after_conversion` 은 모두 *설계 상태* 이며 *구현 결정* 이 아니다. + +> ⚠️ **룰 2 — frame raw px 는 slide-body token 값과 직접 매칭하지 않는다.** +> +> typography / spacing / radius 는 *위계 매핑* 만 수행하고, 실제 값은 slide-body scale 에서 재산정한다. + +### 11.1 Token Resolution 의 역할 + +Token Resolution 은 다음을 책임진다 : + +1. **4 status 적용 판단 룰** — Token Inventory 의 4 status (covered / gap_candidate / hierarchy_mapping_only / hold_recheck_after_conversion) 별 적용 가능성 판단 +2. **frame raw px ↔ slide-body scale 위계 매핑 룰** — figma 1280 폭 기준 raw px 와 slide-body 1200×590 토큰 스케일 사이의 매핑 원칙 +3. **추가 / 변경 / 폐기 보류 원칙** — token 파일 수정은 본 차수 X +4. **family CSS 와의 참조 인터페이스** (§ 8.4 cross-reference) + +받는 입력 : +- Token Inventory 18 행 ([`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md)) +- Frame Inventory 의 Style Elements 관찰값 +- 기존 `templates/styles/tokens/` 3 파일 (colors / spacing / typography) + +→ Token Resolution = **token 적용 판단 정의**. 실제 token 파일 수정 / 신규 추가 / 폐기는 본 차수 X. + +### 11.2 Token Inventory 4 status 처리 룰 + +| Status | Token Resolution 판단 룰 | +|---|---| +| `covered` | 기존 token 정확 hex 일치 검증된 상태. family CSS / runtime 에서 *as-is 참조*. 변경 / 추가 결정 X | +| `gap_candidate` | **working name 후보** 로만 유지. 이름 자체 (`--c-table-header-cyan` 등) 도 *아직 확정 X*. 실제 token 추가 / 채택 / 명명은 Phase Z-2 구현 단계 사용자 승인 후 | +| `hierarchy_mapping_only` | typography / spacing / radius 는 raw px 직접 매칭 X. *위계만* 보고 slide-body scale 에서 재산정 (§ 11.3) | +| `hold_recheck_after_conversion` | 미사용 / 폐기 단정 X. 18 미변환 frame 변환 후 *재검증* | + +→ 본 표는 4 status 의 *적용 판단* 까지만. token 자체의 값 / 이름 / 추가 / 변경 / 폐기는 본 차수 X. + +### 11.3 frame raw px ↔ slide-body scale 위계 매핑 + +scale 차이 : + +| 출처 | scale | 예시 값 | +|---|---|---| +| frame raw px | figma 1280 폭 기준 (zoom 0.49 ~ 1.11 다양) | title 70px / body 35~42px / radius 5~50px | +| slide-body 토큰 | slide-body 1200×590 스케일 | `--font-zone-title` 13px / `--font-body` 11px / `--card-radius` 6px | + +위계 매핑 룰 (typography 예시) : + +| frame raw 위계 | slide-body 위계 매핑 (후보) | +|---|---| +| frame title (raw 70px Bold + gradient) | `--font-zone-title` (13px) 또는 `--font-sub-title` (12px) — 위계 매칭 | +| frame heading (raw 45~55px Bold) | `--font-sub-title` 위계 | +| frame body (raw 35~42px Medium) | `--font-body` (11px) 위계 | +| frame caption (raw 30px) | `--font-caption` (10px) 위계 | + +→ **위계 매핑 룰만** 본 차수에서 결정. *실제 px 값 매핑* 은 slide-body scale 재산정 영역 (구현 단계). + +같은 룰이 spacing / radius 에도 적용 : +- frame raw radius 5 / 30 / 50px ↔ slide-body `--card-radius` 위계 +- frame raw gap 8 / 12 / 25px ↔ slide-body `--space-*` 위계 + +### 11.4 family CSS 와의 참조 인터페이스 (§ 8.4 cross-reference) + +§ 8.4 가 결정함 : family CSS 의 token 참조 *syntax* (`var(--color-block-title-from)` 형태) +§ 11.4 가 결정함 : family CSS 가 어느 token 을 *어떤 조건으로* 참조 가능한가의 판단 + +| Status | family CSS 참조 가능성 | +|---|---| +| `covered` | ✅ 즉시 참조 가능 | +| `gap_candidate` | △ working name 후보로 참조 (token 자체 미정 — 구현 단계 reject 가능) | +| `hierarchy_mapping_only` | ✅ 위계 매칭으로 참조 (raw px 직접 사용 X) | +| `hold_recheck_after_conversion` | ❌ 참조 X | + +→ 참조 *syntax* 는 § 8.4. 본 표는 Token Resolution 의 *판단 perspective* 만. + +### 11.5 추가 / 변경 / 폐기 보류 원칙 + +| 행위 | 본 차수 결정 | 위임 | +|---|---|---| +| 신규 token 추가 | ❌ X | Phase Z-2 구현 진입 시 사용자 승인 | +| working name 확정 (`--c-*` 등) | ❌ X | 사용자 승인 영역 | +| 기존 token 값 변경 | ❌ X (covered 는 frame 정확 일치, 변경 사유 없음) | — | +| hold token 폐기 | ❌ X | 32 frame 전체 변환 후 재검증 | +| 명명 컨벤션 (`--c-` / `--g-` / `--fs-` 등) 확정 | ❌ X | working 단계, 사용자 승인 후 최종 | + +→ Phase Z 절대 룰 *"기존 templates 삭제 / 교체 실행 X"* + *"새 token / css 파일 생성 X"* 와 정합. + +### 11.6 Token Resolution 이 결정하지 않는 것 + +Skeleton § 4.5 의 "결정 안 함" 확장 + 본 5b 추가 : + +- ❌ token 값 자체 (→ Style Inventory / Frame 관찰값) +- ❌ token 파일 생성 / 수정 / 삭제 (구현 영역) +- ❌ 신규 token 이름 최종 확정 (사용자 승인 영역) +- ❌ slide-body scale 재산정의 구체 px 값 (구현 영역) +- ❌ confidence 임계값 (→ 매칭 시스템 V1~V4) +- ❌ family CSS 의 token 참조 *syntax* (→ § 8.4) +- ❌ asset 정책 자체 (→ Asset Policy § 4.6, 5c) +- ❌ variant 모델 자체 (→ Variant Model § 4.7, 5d) +- ❌ AI 호출 자체 (→ AI Boundary § 4.4 / § 10) +- ❌ HTML / CSS / Jinja2 마크업 (→ § 8 / § 9) + +--- + +### 5b 차수 자체 점검 (작성 후) + +- ✅ Token Resolution 영역만 다룸 (Style Inventory / 구현 / family CSS syntax / Asset / Variant / AI 침범 X) +- ✅ Token Inventory 18 행 redescription 0 — 4 status *처리 룰* 만 +- ✅ § 8.4 의 표 redescription 0 — *참조 가능성 판단* perspective 만 +- ✅ 룰 1 (token 파일 수정 X / 4 status = 설계 상태) + 룰 2 (raw px ≠ slide-body, 위계 매핑만) 섹션 머리 명시 +- ✅ § 11.2 에 working name 후보 표현 (gap_candidate 이름 미확정) +- ✅ § 11.3 에 위계 매핑 룰만, 실제 값은 구현 영역 +- ✅ § 11.5 추가 / 변경 / 폐기 / 명명 / 컨벤션 5 행위 모두 보류 원칙 +- ✅ § 11.6 boundary 10 항목 + 5c~5e dedicated 영역 cross-reference + +--- + +## 12. Asset Policy — 5c 본격 설계 + +> ⚠️ **룰 1 — Asset Policy 는 자산 처리 기준을 정의하지만 자산을 생성하지 않는다.** +> +> 이미지 / SVG / texture / icon / placeholder 는 모두 *설계 상태* 이며, 실제 파일 생성은 구현 단계. + +> ⚠️ **룰 2 — 자산 의존 family 는 metadata 로 표시하고, Jinja2 / Family CSS 는 그 metadata 를 참조한다.** +> +> Asset Policy 는 자산 필요 여부와 처리 분기를 정의한다. 렌더 구조나 CSS 구현은 다른 영역의 책임. + +> ⚠️ **룰 3 — Asset Policy 는 생성 도구를 결정하지 않는다.** +> +> Gemini / imagegen / Figma export / 사용자 제공 중 무엇을 쓸지는 *구현 단계* 결정. + +### 12.1 Asset Policy 의 역할 + +Asset Policy 는 다음을 책임진다 : + +1. **자산 의존도 등급 처리 룰** — `low` / `mid` / `high` / `very high` 정책 해석 +2. **자산 유형 분기** — 사진 / texture / icon / 다이어그램 SVG / 장식 5 분류 +3. **자산 상태 라벨** — `placeholder_allowed` / `user_asset_required` / `asset_optional` / `convertible_to_css_or_svg` / `reference_only` +4. **`asset_meta` 정의** — Jinja2 / Family CSS 가 받을 metadata 형식 +5. **추가 / 변경 / 폐기 / 도구 보류 원칙** — 자산 파일 생성 / 도구 선택은 본 차수 X + +받는 입력 : +- Frame Inventory `Asset Notes` 컬럼 (자산 항목 / 의존도 관찰값) +- Family CSS § 8.5 의 의존도 self-meta 표시 (`low` / `mid` / `high` / `very high`) + +→ Asset Policy = **자산 처리 기준 정의**. 자산 생성 / 도구 / placeholder 디자인은 본 차수 X. + +### 12.2 자산 의존도 등급 처리 룰 + +§ 8.5 와의 perspective 분리 : +- § 8.5 = Family CSS 가 *어떻게 self-meta 를 표시* 하는가 (형식) +- § 12.2 = 각 등급이 *정책적으로 어떻게 해석* 되는가 (의미) + +| 등급 | 정책 해석 | 처리 룰 | +|---|---|---| +| `low` | 자산 거의 X. CSS 변환으로 충분 | 별도 정책 불필요. `asset_meta` 불필요 | +| `mid` | 일부 자산 (1~3 개) 이미지 유지 가능 | `asset_meta` 에 자산 항목 + 상태 라벨 표시 | +| `high` | 다수 자산 (10+ 또는 사진). 정책 분기 필수 | `asset_meta` 에 상태 라벨 + 자산 별 처리 분기. `user_asset_required` 가능성 큼 | +| `very high` | 자산이 family 의 핵심 | `asset_meta` 에 상태 라벨 + family 자체의 `reference_only` / asset-heavy 표시 검토 | + +Frame Inventory 에서 `high` / `very high` family : `split-panel` (F21/F22) / `persona-cards` (F14) / `system-diagram` (F07) / F01 (reference-like) — 자세한 자산 목록은 [`PHASE-Z-FRAME-STYLE-INVENTORY.md`](PHASE-Z-FRAME-STYLE-INVENTORY.md) 의 `Asset Notes` 참조 (redescription X). + +### 12.3 자산 유형 분기 + +각 family 의 자산을 5 유형으로 분류 : + +| 자산 유형 | 특징 | 처리 후보 | +|---|---|---| +| **사진** (사람 / 시공 / 풍경) | 시각 의미 핵심, 재현 / 대체 곤란 | `user_asset_required` 우선 | +| **texture** (배경) | 분위기 / 톤 표현 | `placeholder_allowed` / `convertible_to_css_or_svg` / *생성 가능성* (도구 미정) | +| **icon** (단순 그래픽) | 단순 형태, 의미 핵심 | `convertible_to_css_or_svg` (SVG 인라인 가능성) | +| **다이어그램 SVG** (좌표 기반) | 수학 재구성 가능 | `convertible_to_css_or_svg` (코드 재구성 가능성) | +| **장식** (아크 / 화살표 / 구분선) | 단순 형태, 옵셔널 | `asset_optional` / `convertible_to_css_or_svg` | + +→ "생성 가능성" 표현 까지만. 실제 도구 선택 (Gemini / imagegen / Figma export / 사용자 제공) 은 룰 3 에 따라 본 차수 X. + +### 12.4 자산 상태 라벨 (5 후보) — centerpiece + +본 섹션의 핵심. 각 자산 슬롯이 어떤 처리 정책을 갖는지 라벨링. + +| 라벨 | 의미 | 적용 케이스 후보 | +|---|---|---| +| `placeholder_allowed` | placeholder 로 대체 가능 (의미 보존 영향 적음) | texture / 일부 장식 | +| `user_asset_required` | 사용자 제공 필수 (placeholder 불가) | 사진 (시공 / 인물) — 의미 보존 핵심. **5 라벨 중 유일한 의무** | +| `asset_optional` | 자산 없이 렌더 가능 | 장식 (아크 / 화살표) | +| `convertible_to_css_or_svg` | 코드 변환 가능 (자산 불필요) | icon / 다이어그램 SVG / 단순 장식 | +| `reference_only` | runtime 적용 X. 디자인 톤 참고만 | F06 (지도) / F01 (image-heavy reference) family | + +자산 유형 × 상태 라벨 직교 매트릭스 (가능성 표) : + +| 자산 유형 \ 상태 | `placeholder_allowed` | `user_asset_required` | `asset_optional` | `convertible_to_css_or_svg` | `reference_only` | +|---|---|---|---|---|---| +| 사진 | ❌ | ✅ | ❌ | ❌ | △ (reference-like family 만) | +| texture | ✅ | △ | △ | ✅ | ❌ | +| icon | △ | ❌ | △ | ✅ | ❌ | +| 다이어그램 SVG | ❌ | ❌ | ❌ | ✅ | △ | +| 장식 | ✅ | ❌ | ✅ | ✅ | ❌ | + +→ 같은 유형이라도 family / 의도에 따라 라벨이 다를 수 있음. **라벨 부여 자체는 family 별로 결정 (Phase Z-2 구현 진입 시 사용자 승인)**. + +### 12.5 `asset_meta` 정의 + Jinja2 / Family CSS 참조 인터페이스 + +Asset Policy 가 정의하는 metadata 형식 : + +| 필드 | shape | 의미 | +|---|---|---| +| `family_id` | string | 어느 family 의 자산인가 | +| `dependency_level` | `low` / `mid` / `high` / `very_high` | 의존도 등급 | +| `assets[]` | `[{ slot_id, asset_type, status_label, hint }, ...]` | family 안 자산 슬롯 목록 | +| `assets[].asset_type` | `photo` / `texture` / `icon` / `diagram_svg` / `decoration` | 자산 유형 (5 분류) | +| `assets[].status_label` | 5 상태 라벨 중 하나 | 처리 정책 | +| `assets[].hint` | string (optional) | 자산 의미 / 위치 hint (placeholder 디자인 시 참고) | + +Jinja2 / Family CSS 가 받는 방식 : +- Jinja2 (§ 9.2 입력 계약) 의 `family_meta.asset_dependency` 가 본 `asset_meta.dependency_level` 로 채워짐 +- Family CSS (§ 8.5 self-meta) 가 `asset_meta` 의 `dependency_level` + `assets[].status_label` 참조 + +→ `asset_meta` *정의* 는 본 § 12.5. 실제 *참조 syntax* 는 § 8.5 / § 9.2 (perspective 분리). + +### 12.6 Asset Policy 가 결정하지 않는 것 + +Skeleton § 4.6 의 "결정 안 함" 확장 + 본 5c 추가 : + +- ❌ 실제 자산 파일 생성 / 다운로드 / 변환 (구현 영역) +- ❌ **생성 도구 선택** (Gemini / imagegen / Figma export / 사용자 제공) — 룰 3 +- ❌ placeholder 의 시각 디자인 자체 (구현 영역) +- ❌ SVG 코드 작성 (구현 영역) +- ❌ 사용자 자산 수집 UX / workflow (구현 영역) +- ❌ asset_meta 의 어느 family 의 어느 슬롯이 어느 라벨인지 *최종 부여* (사용자 승인 영역) +- ❌ token 자체 (→ Token Resolution § 4.5 / § 11) +- ❌ variant 모델 자체 (→ Variant Model § 4.7, 5d) +- ❌ HTML / CSS / Jinja2 syntax (→ § 8 / § 9) +- ❌ AI 호출 (→ AI Boundary § 4.4 / § 10) + +--- + +### 5c 차수 자체 점검 (작성 후) + +- ✅ Asset Policy 영역만 다룸 (Token / Variant / Fallback / Jinja2 / Family CSS syntax / AI 침범 X) +- ✅ Frame Inventory `Asset Notes` redescription 0 — 자세한 자산 목록 링크 참조 +- ✅ § 8.5 의 self-meta 표시 redescription 0 — *정책 해석* perspective 만 +- ✅ § 9.2 의 family_meta redescription 0 — asset_meta 정의 + cross-reference +- ✅ 룰 1 + 룰 2 + 룰 3 (생성 도구 결정 X) 섹션 머리 명시 +- ✅ § 12.4 5 상태 라벨 centerpiece (`placeholder_allowed` / `user_asset_required` / `asset_optional` / `convertible_to_css_or_svg` / `reference_only`) +- ✅ `_required` 는 `user_asset_required` 만 (의무 / 정책 상태 분리) +- ✅ § 12.3 자산 유형 5 분류 + § 12.4 직교 매트릭스 +- ✅ "생성 가능성" 표현 까지만 (도구 결정 X) +- ✅ § 12.6 boundary 10 항목 + 5d~5e dedicated 영역 cross-reference + +--- + +## 13. Variant Model — 5d 본격 설계 + +> ⚠️ **룰 1 — Variant Model 은 frame 차이를 family 안에서 표현하는 기준을 정의한다.** +> +> frame 별 CSS 를 만들기 위한 장치가 아니라, *family 단위 수렴을 유지* 하기 위한 장치다. + +> ⚠️ **룰 2 — variant 선택은 코드 / catalog 의 결정이다.** +> +> AI 는 variant 를 선택하거나 새 variant 를 만들지 않는다. + +### 13.1 Variant Model 의 역할 + +Variant Model 은 다음을 책임진다 : + +1. **variant 가 필요한 경우 / 불필요한 경우 판단 기준** — § 8.3 의 4 표현 방식 후보를 *언제* 사용할지 +2. **표현 방식 선택 기준** — class modifier / data attribute / template parameter / variant family 중 어느 것을 어느 케이스에 적용할지의 criteria +3. **family 별 variant 후보 관리 정책** — § 8.2 의 11 family 별 variant 축을 *어떻게 관리* 할지 +4. **family 분리 vs variant 흡수의 경계 기준** — 시각 / 구조 차이가 클 때 어느 쪽으로 가는지 + +받는 입력 : +- Family CSS § 8.3 — 4 표현 방식 후보 (class modifier / data attribute / template parameter / variant family) +- Family CSS § 8.2 — 11 family 후보 + 각 variant 축 1 차 정리 +- Frame Inventory `Notes` 의 variant meta (예 : F17 의 `paired-rows`, F18 의 `vs-center-badge`) + +→ Variant Model = **variant 의 *선택 기준 / 관리 정책* 정의**. 실제 class 이름 / parameter 이름 / CSS 구현은 본 차수 X. + +### 13.2 variant 가 필요한 경우 (판단 기준) + +variant 는 *family 를 유지하기 위한 장치* 다. 다음 케이스 별 적합한 처리 : + +| 케이스 | 적합한 처리 | +|---|---| +| 같은 family, **시각 / 구조 차이 작음** | variant (class modifier 등) — § 8.3 의 4 표현 방식 후보 중 | +| 같은 family, **cardinality 차이만** (예 : `columns[N=2~4]`) | template parameter | +| 같은 family, **asset 차이만** | asset_meta 분기 (§ 12.5) — variant 영역 X | +| **시각 / 구조 차이 큼** | **별도 family 분리** (variant 로 우겨 넣지 X) | +| 사용 안 되는 variant | 폐기 보류 — 32 frame 전체 변환 후 재검증 (§ 13.5) | + +> 🔒 **핵심 원칙** — variant 는 family 를 *유지* 하기 위한 장치이며, family 를 **억지로** 유지하기 위한 장치는 아니다. +> +> frame 차이가 family 의 시각 정체성을 깨뜨릴 정도면 variant 가 아니라 *별도 family 분리* 후보. § 13.5 의 분리 기준 참조. + +### 13.3 4 표현 방식 선택 기준 (§ 8.3 cross-reference) + +§ 8.3 = Family CSS perspective 에서 4 후보 표현 방식 정의. 본 § 13.3 = *어느 경우 어느 방식을 선택* 할지의 criteria. + +| 표현 방식 (§ 8.3 참조) | 적합 criteria | 적합하지 않은 criteria | +|---|---|---| +| **CSS class modifier** | 시각 / 구조 차이가 family 안 변형 (예 : F17 `paired-rows` ↔ F18 `vs-center-badge`) | cardinality 차이만 / asset 차이만 | +| **Data attribute** | 마크업 같고 styling 만 분기 / runtime 동적 변경 가능성 | 시각 차이가 크고 구조까지 다른 경우 | +| **Template parameter** | cardinality 차이 (예 : `columns[N=2~4]`, `items[N=3~7]`) / 슬롯 수 변화 | 시각 디자인 차이 | +| **Variant family** (별도 entry) | 차이가 너무 커 한 family 안 일관 표현 어려운 경우 | 차이 작음 (over-split 위험) | + +→ 본 표는 *criteria* 만. 어느 family 가 어느 방식을 채택할지 *결정* 은 § 8.3 (Family CSS perspective) + 사용자 승인. § 8.3 의 표 redescription 0. + +### 13.4 family 별 variant 후보 (관리 perspective) + +§ 8.2 = Family CSS perspective 에서 11 family 후보 + 각 variant 축 1 차 정리. 본 § 13.4 = *Variant Model 측 관리 후보* perspective. + +| Family CSS Area | 1 차 variant 축 (§ 8.2 참조) | Variant Model 관리 후보 | +|---|---|---| +| `compare-paired.css` | `paired-rows` (F17) / `vs-center-badge` (F18) | **CSS class modifier** 적합 (시각 / 구조 차이 family 안 변형) | +| `compare-table.css` | `columns[N=2~4]` / `header_style` / `row_density` | **template parameter** 적합 (cardinality 차이) | +| `split-panel.css` | `right_items[N=3~8]` / `left_categories[N=2~5]` / `bracket_image` | **template parameter** 적합 | +| `persona-cards.css` | `actor_count[3]` / `actor_hue_palette` / `photo_required` | F14 단일 frame, *추가 frame 변환 후 재검증* | +| `three-pillar.css` | `pillars[3]` (현재 고정) / `column_hue_palette` | F13 단일 frame, *추가 frame 변환 후 재검증* | +| `quadrant.css` | `quadrants[4]` (고정) / `bar_style` / `center_quote` | F16 단일 frame, *추가 frame 변환 후 재검증* | +| `pill-list.css` | `items[N=3~7]` / `stacking_pattern` / `arc_decoration` | F09 단일 frame, *추가 frame 변환 후 재검증* | +| `cycle.css` | `main_circles[3]` / `accent_circles[6]` | F12 단일 frame, *추가 frame 변환 후 재검증* | +| `split-center.css` | (sparse data) | F27, *추가 관찰 후 확정* | +| `system-diagram.css` | (sparse data) | F07, *추가 관찰 후 확정* | +| `(asset-heavy / reference-like)` | F01 — pure CSS family X | **variant 영역 X** (→ asset_meta 영역, § 12.5) | + +→ § 8.2 의 11 family 표 redescription 0. *관리 perspective* 만 (어느 표현 방식이 적합한가 + 단일 frame family 의 재검증 필요성). + +### 13.5 추가 / 분리 / 폐기 기준 + +| 행위 | 기준 | 본 차수 결정 | +|---|---|---| +| **variant 추가** | 같은 family 안 frame 추가 시 (예 : 18 미변환 frame 변환 후) — 기존 variant 흡수 가능 vs 새 variant 필요 | 기준만 정의, 실제 추가는 사용자 승인 | +| **family 분리** | variant 가 family 의 시각 정체성을 깨뜨리는 수준일 때 — variant 흡수 보다 family 분리 후보 (§ 13.2 핵심 원칙) | 기준만 정의, 실제 분리는 사용자 승인 | +| **variant 폐기** | 32 frame 전체 변환 후 사용 안 되는 variant 발견 시 | 폐기 보류, 재검증 후 결정 | + +→ Phase Z 절대 룰 *"기존 templates 삭제 / 교체 실행 X"* + *"새 family CSS 파일 생성 X"* 와 정합. + +### 13.6 Variant Model 이 결정하지 않는 것 + +Skeleton § 4.7 의 "결정 안 함" 확장 + 본 5d 추가 : + +- ❌ 실제 class / data attribute / parameter 이름 (구현 영역) +- ❌ family 분리 / 통합 *최종 결정* (사용자 승인 영역) +- ❌ variant CSS / Jinja2 구현 (→ Family CSS § 8 / Jinja2 § 9 의 *구현*) +- ❌ AI 가 variant 선택 / 추가 / 변경 (Phase R' 회귀 방지 — 룰 2) +- ❌ Jinja2 parameter 이름 (구현 영역) +- ❌ token 자체 (→ Token Resolution § 4.5 / § 11) +- ❌ asset 자체 (→ Asset Policy § 4.6 / § 12) +- ❌ fallback 정책 (→ Fallback Behavior § 4.8, 5e) +- ❌ HTML / CSS / Jinja2 syntax (→ § 8 / § 9) + +--- + +### 5d 차수 자체 점검 (작성 후) + +- ✅ Variant Model 영역만 다룸 (Token / Asset / Fallback / Jinja2 / Family CSS 구현 / AI 침범 X) +- ✅ § 8.3 의 4 표현 방식 후보 표 redescription 0 — *selection criteria* perspective 만 +- ✅ § 8.2 의 11 family 표 redescription 0 — *관리 perspective* 만 +- ✅ Frame Inventory `Notes` variant meta redescription 0 — cross-reference 만 +- ✅ 룰 1 (frame 별 CSS X / family 수렴 장치) + 룰 2 (variant 선택은 코드/catalog, AI X) 섹션 머리 명시 +- ✅ § 13.2 핵심 원칙 callout — "family 를 억지로 유지하기 위한 장치 X" +- ✅ § 13.5 추가 / 분리 / 폐기 모두 사용자 승인 영역 +- ✅ § 13.6 boundary 9 항목 + 5e dedicated 영역 cross-reference + +--- + +## 14. Fallback Behavior — 5e 본격 설계 + +> ⚠️ **룰 1 — Fallback Behavior 는 Zone Catalog status 라벨 진입 후의 *동작 정책* 이다.** +> +> status 결정 자체는 § 7 의 책임, 본 § 14 는 *진입 후 동작* 만 다룬다. + +> ⚠️ **룰 2 — fallback 중 AI 호출은 *zone content 조정 AI* 다.** +> +> 레이아웃 / family / variant / status 결정은 AI 영역이 아니다. + +> ⚠️ **룰 3 — 레이아웃 회귀는 Zone Catalog status 변경으로만 트리거한다.** +> +> AI / Jinja2 가 직접 레이아웃을 변경하지 않는다. + +### 14.1 Fallback Behavior 의 역할 + +Fallback Behavior 는 다음을 책임진다 : + +1. **Zone Catalog status 라벨 진입 후의 동작 정책** — `fallback_candidate`, `review_required` 가 어떻게 처리되는가 +2. **5 차 Fallback 단계의 catalog / runtime 책임** — IMPROVEMENT-REDESIGN.md 의 fallback 흐름 안에서 본 설계가 넘겨야 할 상태와 경계 +3. **fallback 중 AI 호출 boundary** — § 10 의 룰을 fallback context 에서 재확인 +4. **레이아웃 회귀 트리거** — Phase Z 절대 룰 ("콘텐츠 줄이지 X, 그릇 변경") 의 catalog 단위 메커니즘 + +받는 입력 : +- Zone Catalog (§ 7.4) 의 6 status 라벨 (특히 `fallback_candidate`, `review_required`) +- IMPROVEMENT-REDESIGN.md 의 5 차 Fallback 흐름 + +→ Fallback Behavior = **status 진입 후 동작 정책 정의**. status 결정 / AI 호출 자체 / HTML 조립 / 레이아웃 결정은 본 차수 X. + +### 14.2 fallback_candidate 진입 후 동작 + +`fallback_candidate` (§ 7.4 매트릭스의 low confidence cells) 진입 후의 catalog 단위 동작 : + +| 동작 | catalog / runtime 책임 | +|---|---| +| zone 단위로 Fallback Behavior 흐름 진입 | catalog 가 status 부여, fallback 단계는 IMPROVEMENT-REDESIGN.md 5 차 따름 (§ 14.4) | +| 다른 family / 다른 frame 후보 검색 | catalog 의 재매칭 룰 (구체 알고리즘은 매칭 시스템 V1~V4 영역) | +| 레이아웃 회귀 트리거 검토 | § 14.6 의 절차 | +| AI 호출 (4 차 fallback 에서) | § 14.5 의 boundary 적용 | + +> 🔒 **`fallback_candidate` 는 AI 에게 레이아웃을 묻는 신호가 아니다.** +> +> AI 호출이 fallback 흐름 중 발생하더라도, AI 의 활동 반경은 *zone content 조정* 까지만. 레이아웃 / family / variant / status / 새 frame 결정은 모두 코드 / catalog 영역. + +### 14.3 review_required 진입 후 동작 + +`review_required` (§ 7.4 의 모든 medium confidence cells) 진입 후 : + +| 동작 | catalog / runtime 책임 | +|---|---| +| review payload 분리 + marker 부착 | runtime (Jinja2 § 9.4) | +| 사용자 검토 큐 진입 | 파이프라인 / 구현 영역 (catalog 책임 X) | +| 검토 결과 반영 | 사용자 결정 → catalog 가 status 변경 (예 : `matched_zone` 으로 승격 또는 `fallback_candidate` 로 강등) | + +→ review 흐름 자체는 파이프라인 / 구현 영역. catalog 는 status 변경 인터페이스만 제공. + +### 14.4 5 차 Fallback 단계 (cross-reference) + +5 차 Fallback 단계 (1 차~5 차) 흐름은 [`IMPROVEMENT-REDESIGN.md`](../../IMPROVEMENT-REDESIGN.md) 의 fallback 흐름 따른다. + +본 문서는 그 흐름 안에서 catalog / runtime 이 *넘겨야 할 상태와 금지 경계* 를 정의한다 : + +- 1~3 차 fallback (코드만) — catalog 가 재매칭 / status 재부여 +- 4 차 fallback (AI 콘텐츠 조정) — § 14.5 의 AI 호출 boundary 적용 +- 5 차 fallback (단일 프리셋 강제) — § 14.6 의 레이아웃 회귀 절차 + +→ 5 차 단계의 *구체 알고리즘 / 임계값 / 호출 횟수* 는 IMPROVEMENT-REDESIGN.md + 구현 영역 (redescription X). + +### 14.5 fallback 중 AI 호출 — § 10 룰 재확인 + +4 차 fallback (AI 콘텐츠 조정) 에서 AI 호출 발생 시, § 10 의 룰이 *그대로* 적용 : + +- ✅ AI 입력 (§ 10.3) : `zone_meta`, `family_meta`, `mdx_excerpt`, `style_hint`, `layout_meta` +- ✅ AI 출력 (§ 10.3) : `zone.content.preview_text`, `zone.content.popup_full`, `zone.content.slot_payload` +- ❌ AI 출력 금지 (§ 10.4) : HTML / class / Jinja2 / family / variant / preset / 새 frame / status + +> 🔒 **재확인 — fallback context 에서도 AI 의 호출 단위는 *zone 안 콘텐츠* 만.** +> +> *fallback_candidate 진입* 자체는 AI 호출 트리거가 아니다. AI 호출은 4 차 fallback 의 *콘텐츠 조정* 단계에서만 발생하며, 그때도 § 10 의 룰 (룰 1·2 + § 10.4) + § 9 의 룰 (룰 1·2 + § 9.5) = **5 면 차단** 그대로 작동. + +### 14.6 레이아웃 회귀 — Zone Catalog 트리거 + +Phase Z 절대 룰 *"불일치 시 레이아웃 회귀 — 콘텐츠 줄이지 X, 그릇 변경"* 의 catalog 단위 메커니즘 : + +| 단계 | 책임 | +|---|---| +| 회귀 트리거 감지 | catalog (§ 7) — status 분포 모니터링 (예 : 모든 zone 이 `fallback_candidate` / `review_required` 일 때) | +| 다른 layout preset 후보 검색 | catalog (§ 7.2-1 의 4 프리셋 중) | +| preset 변경 → zone 재구성 | catalog 가 새 preset 의 zone 구성 결정 (§ 7.2-2), runtime 이 재조립 (§ 9) | +| AI 호출 (있다면) | § 14.5 의 boundary — *콘텐츠 조정* 까지만, 새 layout 결정 X | + +> 🔒 **원칙 — 레이아웃 회귀는 그릇 변경이다.** +> +> 콘텐츠, 텍스트, MDX 원문을 줄이지 않는다. +> AI 가 새 layout 을 만들지 않는다. +> 4 프리셋 `Type A / B / B' / B''` 안에서 다른 그릇을 선택한다. + +### 14.7 Fallback Behavior 가 결정하지 않는 것 + +Skeleton § 4.8 의 "결정 안 함" 확장 + 본 5e 추가 : + +- ❌ Zone Catalog status 라벨 *부여 자체* (→ § 7.4) +- ❌ AI 호출 자체 / AI 모델 / 프롬프트 / 호출 빈도 (→ § 4.4 / § 10 + 구현 영역) +- ❌ Jinja2 의 status 별 조립 인터페이스 (→ § 9.4) +- ❌ Family CSS 의 fallback 스타일링 (→ § 8 / § 4.2) +- ❌ token 자체 (→ § 11) +- ❌ asset 자체 (→ § 12) +- ❌ variant 자체 (→ § 13) +- ❌ 5 차 Fallback 의 구체 알고리즘 / 임계값 / 호출 횟수 (→ IMPROVEMENT-REDESIGN.md + 구현 영역) +- ❌ review 흐름의 사용자 검토 UX / 큐 메커니즘 (→ 파이프라인 / 구현 영역) +- ❌ 새 layout preset 추가 / 폐기 — 4 프리셋 안에서만 분기 (자유 디자인 금지) + +--- + +### 5e 차수 자체 점검 (작성 후) + +- ✅ Fallback Behavior 영역만 다룸 (status 부여 / AI 호출 자체 / Jinja2 조립 / Family CSS / token / asset / variant 침범 X) +- ✅ § 7.4 6 status 매트릭스 redescription 0 — *진입 후 동작* perspective 만 +- ✅ § 9.4 status 별 조립 인터페이스 redescription 0 +- ✅ § 10 AI Boundary 룰 redescription 0 — fallback context 의 *재확인* 만 +- ✅ IMPROVEMENT-REDESIGN.md 5 차 Fallback 흐름 redescription 0 — § 14.4 짧은 cross-reference + 책임 perspective +- ✅ 룰 1 (status 진입 후 동작) + 룰 2 (AI = zone content 조정) + 룰 3 (레이아웃 회귀 = catalog 트리거) 섹션 머리 명시 +- ✅ § 14.2 callout — "`fallback_candidate` 는 AI 에게 레이아웃 묻는 신호 X" +- ✅ § 14.5 callout — fallback context 에서도 AI = zone 콘텐츠만 +- ✅ § 14.6 callout — "레이아웃 회귀는 그릇 변경" (Phase Z 절대 룰 prominent) +- ✅ § 14.7 boundary 10 항목 + 모든 dedicated 영역 cross-reference + +--- + +## 15. 통합 검증 — 6 차 + +> 본 섹션은 1~5e 차수 결과의 *검증* 만 다룬다. 새 설계 없음, 기존 결정의 무결성 + Phase Z-2 진입 준비 판단만. +> 발견된 이슈는 § 15.4, Phase Z-2 진입 준비 판정은 § 15.5. + +### 15.1 검증 방법 + +검증 도구 : + +- **grep** — 패턴 기반 무결성 / 표현 위반 탐지 (cross-reference, AI 결정 표현, legacy 재사용 표현 등) +- **의미 검토** — boundary 충돌 / 톤 / 후보 vs 확정 구분 (grep 만으로 잡히지 않는 경계 표현) + +검증 항목 (8 개) : + +| 항목 | 내용 | 도구 | +|---|---|---| +| 0 | cross-reference 무결성 | grep + 매칭 | +| 1 | 8 책임 영역 boundary 충돌 없음 | 의미 검토 | +| 2 | AI 가 layout / preset / family / variant / HTML 결정 표현 없음 | grep + 의미 | +| 3 | Type A / B / B' / B'' 선택이 코드 / catalog 책임으로 일관 | grep + 의미 | +| 4 | fallback 이 AI layout 요청처럼 읽히지 않음 | 의미 검토 | +| 5 | token / asset / variant *후보* 가 구현 확정처럼 읽히지 않음 | grep + 의미 | +| 6 | legacy `templates/blocks` runtime 재사용처럼 읽히는 표현 없음 | grep + 의미 | +| 7 | Phase Z-2 구현 전 보류 항목이 명확함 | grep + 의미 | + +사전 분기 룰 (§ 15.2 결과에 따른 § 15.3 진행 방식) : + +| broken 수 | 처리 | +|---|---| +| 0 개 | 그대로 § 15.3 진행 | +| 1~2 개 | 즉시 수정 → § 15.3 진행, § 15.4 에 "non-blocker, 수정 완료" 기록 | +| 3~4 개 | 수정 후 진행, § 15.5 에 "minor drift 발견 / 수정" 명시 | +| 5 개+ | § 15.3 은 현재 상태로만 수행, § 15.5 에 "기준선 흔들림" 명시 | + +severity 라벨 (§ 15.4) : + +| 라벨 | 의미 | +|---|---| +| `blocker` | Phase Z-2 진입 전 반드시 해결 | +| `non-blocker` | Phase Z-2 진입 가능, 후속 정리 | + +### 15.2 cross-reference 무결성 + +문서 내 § 패턴 매칭 결과 : + +- **참조 추출** — § 4.1 ~ 4.8, § 7 / 7.1 / 7.2-1 / 7.2-2 / 7.3 / 7.4 / 7.5, § 8 ~ 14 의 모든 `N.M` 패턴 (50+ 회 인용) +- **실제 섹션 매칭** — 모든 인용이 실제 섹션 (`##` / `###` / `####`) 으로 해석됨. § 7.2-1 / 7.2-2 (sub-subsection) 도 실제 `####` 헤딩 존재 (line 214 / 229) +- **broken 수** — **0 개** + +→ § 15.1 분기 룰의 "0 개" 케이스. 그대로 § 15.3 진행. + +### 15.3 7 항목 검증 결과 + +| 항목 | 결과 | 근거 (대표 line) | +|---|---|---| +| 1. 8 영역 boundary 충돌 없음 | ✅ PASS | 각 영역의 "X 가 결정하지 않는 것" 섹션 (§ 7.5 / 8.6 / 9.6 / 10.6 / 11.6 / 12.6 / 13.6 / 14.7) 이 다른 영역 dedicated section 으로 cross-reference. 침범 표현 0. | +| 2. AI 가 layout / preset / family / variant / HTML 결정 표현 없음 | ✅ PASS | line 47, 99, 552, 587, 617, 659, 1049, 1111, 1147 — 모두 *금지* 또는 *Phase R' 회귀 방지* 맥락. 결정 표현 0. | +| 3. Type A / B / B' / B'' = 코드 / catalog 책임 일관 | ✅ PASS | line 47 ("코드만"), 196 ("catalog 가 결정"), 225, 480, 587, 617, 1158, 1181 — 모두 catalog 또는 "AI X" 로 일관. | +| 4. fallback ≠ AI layout 요청 | ✅ PASS | line 291, 1111 (🔒), 1147 (🔒), 1149, 1162 (🔒) — 명시적 callout. fallback_candidate 진입 ≠ AI 호출 트리거 명시. | +| 5. token / asset / variant *후보* ≠ 구현 확정 | ✅ PASS | "후보" 59 회 사용, 모두 working name / 후보 상태. line 737 ("아직 확정 X"), 785, 904, 993, 1038, 1066 — "Phase Z-2 구현 진입 시 사용자 승인" 패턴. | +| 6. legacy `templates/blocks` runtime 재사용 표현 없음 | ✅ PASS | line 48 ("legacy blocks runtime reuse X ... 참고만"), 159 / 162 / 433 — 삭제 보류 + runtime 재사용 X 명시. | +| 7. Phase Z-2 보류 항목 명확 | ✅ PASS | line 3, 20, 158, 181, 737, 785, 904 + "(구현 영역)" 태그 17 회 — Phase Z-2 입력 문서 위치 명확, 보류 항목 sweep 가능. | + +→ **7 항목 모두 PASS**. + +### 15.4 발견된 이슈 + +| 이슈 | severity | 처리 | +|---|---|---| +| (없음) | — | — | + +→ blocker / non-blocker 모두 0 건. 검증 결과 깨끗. + +### 15.5 Phase Z-2 진입 준비 상태 + +| 항목 | 상태 | +|---|---| +| 1~5e 차수 본격 설계 | ✅ 완료 | +| cross-reference 무결성 | ✅ broken 0 (§ 15.2) | +| 7 항목 boundary / 회귀 방지 | ✅ 7/7 PASS (§ 15.3) | +| 발견된 issue | ✅ 0 건 (§ 15.4) | +| Phase Z-2 입력 문서 위치 | ✅ 명확 (§ 1 / § 6) | + +**판정** : Phase Z-2 진입 *기준선 (설계 baseline)* 준비됨. + +> 🔒 **Phase Z-2 진입 준비는 *구현 시작 승인이 아니다*. 구현 착수는 별도 사용자 승인 후 진행한다.** + +--- + +## 16. Phase Z-2 MVP-1 scaffold 허용 규칙 + +> 본 섹션은 § 15 통합 검증 완료 후 추가된 *구현 규칙*. Phase Z-2 MVP-1 이 HTML 출력을 위해 필요한 *실행 껍데기 (scaffold)* 의 허용 범위 + 종료 조건 명시. +> *예외* 가 아닌 *원칙 안의 허용 규칙* — legacy 재사용 X / 정식 family 확정 X 는 그대로 유지. + +### 16.1 배경 — 왜 필요한가 + +Phase Z-2 MVP-1 은 : +- legacy `templates/blocks/structures/*` runtime 재사용 X (§ 1, line 48) +- 신규 token / asset / variant / Family CSS *확정* 보류 (§ 11.5 / 12.5 / 13.5) + +→ 실제 HTML 렌더링을 위해서는 *얇은 실행 껍데기* 가 필요. 이를 *원칙 안의 허용 규칙* 으로 정의한다. + +### 16.2 핵심 경계 + +| 항목 | 상태 | +|---|---| +| legacy runtime 재사용 | ❌ 금지 (기존 룰 유지) | +| 정식 Family CSS *확정* | ❌ 보류 (§ 11.5 / 12.5 / 13.5 유지) | +| MVP 출력용 scaffold | ✅ 허용 (본 § 16 의 룰 내) | + +### 16.3 scaffold 허용 룰 + +| 룰 | 내용 | +|---|---| +| 위치 | `templates/phase_z2/` (별도 디렉토리. 기존 `templates/blocks/` 와 분리) | +| 마커 | 각 scaffold 파일 상단에 1 줄 주석 필수 :
`` | +| 토큰 | 기존 token 만 참조. 신규 token 추가 X | +| 자산 | 기존 asset 만. 신규 asset 추가 X | +| variant | § 8.2 의 11 family 후보 안에서만. 신규 variant 추가 X | +| legacy 파일 import | ❌ 금지. legacy `templates/blocks/structures/*.html` 는 *stylization 참고만*, 직접 import / include / extend X | +| 시각 디자인 도출 | frame 메타 + Figma 좌표 + 기존 token 으로 *수학적 도출*. legacy 시각 모방 우회 금지 | + +### 16.4 종료 조건 + +scaffold 의 수명 = *MVP-1 ~ MVP-2 직전* : + +- **MVP-1** : scaffold 로 HTML 출력 +- **MVP-2** : 정식 Family CSS partial 로 *대체*. `templates/phase_z2/` 디렉토리 삭제 또는 정식 위치로 이동 +- **종료 조건** : MVP-2 entry 시 본 § 16 의 scaffold 룰 *무효화* + +> 🔒 **scaffold 는 *실행 껍데기* 이지 *디자인 확정* 이 아니다.** MVP-2 에서 정식 Family CSS partial 로 대체하지 않으면 scaffold 가 *영구화* 되는 위험. MVP-2 진입 시 — *§ 16 무효화 + scaffold 삭제 + Family CSS 확정* = **3 동시 조건**. + +### 16.5 cross-reference + +- § 1 line 48 — legacy runtime reuse X (유지) +- § 8 — Family CSS 정식 확정 보류 (유지) +- § 11.5 / 12.5 / 13.5 — 신규 token / asset / variant 보류 (유지) +- § 15.5 — Phase Z-2 진입 준비 상태 (본 § 16 은 진입 후 *구현 시* 적용) +- § 17 — MVP-1 결과 (mvp1_test5: visual fidelity FAIL) 에 따른 *방향 전환*. scaffold polish 중단, frame-derived partial promotion 으로 진화 + +--- + +## 17. Frame-derived Partial Promotion — MVP-1.5+ + +> 본 섹션은 § 16 *scaffold 허용 룰* 의 **방향 전환**. MVP-1 progression (mvp1_test2 ~ mvp1_test5) 에서 발견된 사실 — *scaffold 가 V4 매칭한 frame 의 실제 변환물을 대체하지 못함* — 에 따른 진화. scaffold polish 는 중단, frame-derived partial promotion 으로 전환. + +### 17.1 배경 — MVP-1 progression (교훈 보존) + +| run_id | 결과 | 교훈 | +|---|---|---| +| mvp1_test2 | data chain 동작, raw dump 발견 | mapper output 이 slot 모양이어야 함 (raw section dump X) | +| mvp1_test3 | data PASS, visual FAIL (overflow + slide-base contract 미충족) | "기술 chain PASS ≠ MVP PASS". 시각 게이트 필수 | +| mvp1_test4 | overflow check PASS, 내부 clipping 미검출 | `overflow:hidden` 은 안전장치. 진짜 게이트는 Selenium *내부 clipping* 검출 | +| mvp1_test5 | runtime PASS, **visual fidelity FAIL** | **scaffold polish 한계** — V4 가 고른 frame 의 *실제 변환물* 을 runtime 이 안 쓰면 visual fidelity 못 나옴 | + +→ **방향 전환** : scaffold polish 중단. **frame-derived partial promotion** 으로 진화. + +### 17.2 § 16 scaffold vs § 17 promotion + +| 항목 | § 16 scaffold | § 17 frame-derived partial | +|---|---|---| +| 출처 | 코드에서 직접 작성 (token + 색 추정) | `figma_to_html_agent/blocks/{id}/index.html` (1:1 Figma 변환물) | +| 시각 fidelity | 근사 | Figma 와 1:1 (정확한 색 / 비율 / 구조) | +| Templating | `{{ slot_payload.* }}` | `{{ slot_payload.* }}` (동일) | +| 위치 | `templates/phase_z2/frames/` | `templates/phase_z2/families/` | +| 사용 범위 | *frame 미변환 시 임시* — 변환 완료 시 즉시 § 17 partial 로 대체 | *변환 완료된 frame* 에 대한 정식 runtime partial | + +### 17.3 Promotion 규칙 + +| 룰 | 내용 | +|---|---| +| Source | `figma_to_html_agent/blocks/{frame_id}/index.html` (1:1 변환물 *필수*) | +| Target | `templates/phase_z2/families/{template_id}.html` | +| 변환 방식 | **templating** — index.html 의 *구조 / CSS / 색 / 비율* 유지, 텍스트만 `{{ slot_payload.* }}` 로 치환 | +| 단순 복사 | ❌ 금지 — runtime 에서 V4 매칭한 콘텐츠 못 받음 | +| 1:1 변환물 없는 frame | 변환 먼저 (figma_to_html_agent 영역, *별도 작업 + 사용자 승인 필수*) | +| 마커 | partial 파일 상단에 :
`` | + +### 17.4 § 16 scaffold 와의 관계 + +- § 16 scaffold = *frame 미변환 시 임시 처리* 로만 허용 (변환물 없는 frame) +- 변환 완료 시 즉시 § 17 partial 로 대체. scaffold 는 폐기 +- legacy `templates/blocks/structures/` runtime 재사용 X 는 **그대로 유지** (§ 1 line 48) + +### 17.5 종료 조건 + +partial 의 수명 = *MVP-1.5 ~ MVP-2 직전*. MVP-2 에서 정식 Family CSS 확정 시 : +- partial 위치 (`templates/phase_z2/families/`) → 정식 family CSS 위치로 이동 / 통합 +- § 17 룰 무효화 + +> 🔒 **partial = *frame-derived 정확한 산출물* 이지만 *family CSS 확정* 은 아니다.** MVP-2 에서 정식 family 로 통합하지 않으면 partial 이 *영구화* 되는 위험. § 16 scaffold 와 동일한 lifecycle 관리 필요. + +### 17.6 cross-reference + +- § 16 — scaffold 허용 룰 (MVP-1 한정, 미변환 frame 임시 처리) +- § 8 — Family CSS 정식 확정 (MVP-2 종료 시점) +- § 11.5 / 12.5 / 13.5 — 신규 token / asset / variant 보류 (유지) + +--- diff --git a/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md b/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md new file mode 100644 index 0000000..3f810b8 --- /dev/null +++ b/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md @@ -0,0 +1,220 @@ +# Phase Z-2 — fit_classifier / overflow_router spec + +**Status** : v0 spec (2026-04-29). 정의만. 구현은 별도 step (사용자 승인 후). + +--- + +## 0. 목적 / 위치 + +자동 파이프라인이 Selenium 으로 *detect 한* overflow / clipping 을 *어떤 pipeline action 으로 routing 할지* 결정하는 layer 의 spec. + +현재 파이프라인은 detection 까지 정상 작동 (Selenium + debug.json 으로 신호 캡처). 그러나 detection 결과를 받은 직후 `sys.exit(1)` 으로 abort — **detection 과 action 사이의 decision layer 가 비어 있음**. + +``` +parse_mdx → align → composition (v0.2: capacity_fit) → render (Jinja2) + ↓ +Selenium visual_runtime_check ← 기존 (detection) + ↓ +🆕 fit_classifier ← 신규 (사실 분류) + ↓ +🆕 overflow_router ← 신규 (정책 결정) + ↓ +action : + - zone_ratio_retry ← 신규 미구현 + - layout_adjust ← 신규 미구현 + - details_popup_escalation ← 신규 미구현 (CLAUDE.md 의
원칙 활성) + - frame_reselect ← 신규 미구현 (V4 top-k 활용) + - adapter_needed ← composition v0.1.1 partial + - abort ← 기존 (현재 default) +``` + +**핵심 원칙** : classifier = *사실 분류* (이 overflow 가 어떤 종류인가), router = *정책 결정* (그 종류면 무엇을 할 것인가). 두 layer 분리 — 같은 분류가 context (retry 횟수 등) 에 따라 다른 action 을 요구할 수 있음. + +--- + +## 1. fit_classifier 입력 schema + +### 1.1 detection-side (기존 — 이미 캡처됨) + +| 입력 | 출처 | 비고 | +|---|---|---| +| `clipped_inner: [{class_name, excess_x, excess_y, scrollWidth/Height, clientWidth/Height}]` | `run_overflow_check` Selenium JS | ✅ | +| zone 별 `overflowed`, `excess_y/x` | 같음 | ✅ | +| slide / slide_body level overflow | 같음 | ✅ | + +### 1.2 composition-side (기존 — composition v0.2) + +| 입력 | 출처 | 비고 | +|---|---|---| +| `unit.frame_template_id` / `contract_id` | composition + pipeline | ✅ | +| `capacity_fit` (item count, fit_status) | `mapper.compute_capacity_fit` | ✅ (v0.2) | +| `content_truncated_count` (zone 별) | pipeline 의 mapper 호출 후 | ✅ (v0.1.1) | +| zone size (`height_px`, `min_height_px`, `content_weight`) | `compute_zone_layout` | ✅ | + +### 1.3 신규 입력 (이번 spec 에서 정의) + +| 입력 | 비고 | +|---|---| +| `semantic_content_type` | className → 의미적 분류. §2 registry 참조 | +| `line_equivalent` | excess_y / 해당 element 의 line-height (1 줄 단위로 환산) | +| `structural_unit_drop_count` | structural_unit 중 *완전히 또는 부분적으로 잘린* 개수 | +| `retry_budget_used` (router 의 상태) | 같은 slide 에 대해 router 가 이미 시도한 retry 횟수 | + +--- + +## 2. className → semantic content_type registry + +| className 패턴 | semantic type | 설명 | +|---|---|---| +| `transform-block`, `transform-block__*`, `transform-row*` | `structural_unit` | paired comparison (AS-IS/TO-BE 한 쌍이 의미 단위). 행 단위 자르면 의미 깨짐 | +| `text-line`, `text-line--bullet`, `text-line--indent-*` | `text_flow` | 자유 wrap, 줄 단위 자르기 가능 | +| `*table*`, native `
` | `tabular` | 행/열 단위 의미 — 행 잘리면 의미 손실 | +| `f29b`, `f13b`, `f16b` (frame-family root) | `frame_internal` | frame 자체가 zone 안에 못 들어감 (zone level 문제) | +| `*__cell`, `*__pillar`, `*__quadrant` 등 frame 내부 cell | `frame_internal_cell` | frame 내부 cell 단위 (cell 내부 content 가 cell 경계 초과) | +| `*__title`, `*__section-title`, `*__banner`, `*__label` 등 | `frame_label` | 제목/라벨 단위 (text 와 비슷하지만 wrap 제약 있음) | +| ``, ``, `*-bg` 등 | `visual_asset` | 시각 자산 (cropping 가능) | +| 매칭 안 됨 | `unknown` | classifier 가 보수적으로 처리 (가장 안전한 action 선택) | + +본 registry 는 신규 module (예: `src/phase_z2_classifier.py`) 의 상수 또는 catalog yaml entry 로 구현. + +--- + +## 3. fit_classifier 출력 taxonomy + +### 3.1 카테고리 정의 (계산 가능한 룰) + +| 카테고리 | 판정 룰 | +|---|---| +| `frame_capacity_mismatch` | composition 단계의 `capacity_fit.fit_status` ∈ {`strict_mismatch`, `exceeds_max`, `below_min`, `exceeds_truncate`}. → 이미 v0.2 가 잡고 있는 영역. 본 카테고리는 *post-render 검증 / 누락된 케이스 캐치* 용 | +| `structural_major_overflow` | content_type = `structural_unit` 또는 `tabular` AND `structural_unit_drop_count` ≥ 1 (1 개 이상 *완전 단위* 잘림) | +| `structural_minor_overflow` | content_type = `structural_unit` 또는 `tabular` AND `structural_unit_drop_count` < 1 (마지막 1 단위가 *부분만* 잘림, 즉 boundary spill) | +| `tabular_overflow` | content_type = `tabular` (위와 별도 — 표는 행 1개라도 잘리면 popup) | +| `layout_zone_mismatch` | content_type = `frame_internal` (frame root 자체 overflow) — zone 이 frame 을 못 담음 | +| `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] | +| `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 | +| `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 | + +### 3.2 분류 우선순위 (위에서 아래로) + +1. `frame_capacity_mismatch` (composition 결과 우선) +2. `tabular_overflow` (표는 즉시 popup 영역) +3. `structural_major_overflow` (1+ 완전 단위 잘림) +4. `layout_zone_mismatch` (frame root level) +5. `structural_minor_overflow` (boundary spill — 양 작음) +6. `moderate_overflow` +7. `minor_overflow` +8. `hard_visual_fail` (fallback) + +### 3.3 핵심 구분 + +- **structural_minor vs structural_major** : 부분만 잘렸나 (`< 1` unit) vs 완전 단위가 잘렸나 (`≥ 1` unit). 부분 잘림은 zone 을 조금 더 주면 fit 가능. 완전 단위 잘림은 의미 손실 — popup escalation. +- **structural vs moderate vs minor** : content type 이 *구조적 의미 단위* 인지 여부. 같은 px 양이라도 text_flow 는 minor, structural_unit 은 structural_minor 이상. + +--- + +## 4. overflow_router action mapping + +| 카테고리 | action | retry budget | fallback (안 풀리면) | +|---|---|---|---| +| `minor_overflow` | `zone_ratio_retry` (양보 가능 zone 식별 → compute_zone_layout 재실행) | 1 | escalate → `moderate_overflow` 처리 | +| `moderate_overflow` | `layout_adjust` (8-preset 중 다른 preset 검토 + zone ratio 재분배) | 1 | escalate → `structural_major_overflow` 처리 | +| `structural_minor_overflow` | `zone_ratio_retry` (구조 자르지 않도록 zone 키움) | 1 | escalate → `structural_major_overflow` 처리 | +| `structural_major_overflow` | `details_popup_escalation` (`
/` path) | N/A | popup 미구현 시 → `frame_reselect` → `adapter_needed` | +| `tabular_overflow` | `details_popup_escalation` 또는 `frame_reselect` (table-friendly frame 후보) | N/A | 없으면 `adapter_needed` | +| `frame_capacity_mismatch` | `frame_reselect` (V4 top-k rank 2+ 평가) | 1 | 없으면 `adapter_needed` | +| `layout_zone_mismatch` | `layout_adjust` 또는 `zone_ratio_retry` | 1 | escalate → `frame_reselect` | +| `hard_visual_fail` | `abort` (현재 sys.exit(1) 그대로) | — | — | + +--- + +## 5. current code gap + +### 5.1 이미 있어서 *재사용* 할 것 + +- detection (Selenium `run_overflow_check`) — clipped_inner / excess_y / className 모두 캡처 +- composition v0.2 의 `compute_capacity_fit` (item count level) +- composition v0.1.1 의 `adapter_needed` catch (mapper FitError) +- debug.json 의 `slide_status` / `zones` / `candidates_summary` (입력 자료원) +- 8-preset layout vocabulary + `compute_zone_layout` + +### 5.2 새로 만들어야 할 것 + +| 신규 항목 | 비고 | +|---|---| +| **content_type registry** (§2) | className → semantic type. classifier 의 핵심 입력 | +| **`fit_classifier` 모듈** | §1 입력 → §3 카테고리 | +| **`overflow_router` 모듈** | §4 카테고리 → action | +| `zone_ratio_retry` action 구현 | compute_zone_layout 의 retry path | +| `layout_adjust` action 구현 | preset 동적 변경 | +| `details_popup_escalation` 구현 | `
/` runtime + slot_payload 분리 룰 (큰 작업) | +| `frame_reselect` 구현 | V4 top-k 사용 (rank-2+ 평가) | + +### 5.3 정의 vs 구현 분리 + +본 spec 은 **정의만**. 위 신규 항목 중 어느 axis 부터 구현할지는 *별도 step* 에서 사용자 승인 후. 한꺼번에 다 만들지 X. + +--- + +## 6. 본 spec 의 활용 — *visual fix 결정의 검증 자료* + +본 spec 은 *룰북*. 향후 overflow 발생 시 : + +1. classifier 가 *어떤 카테고리* 인지 결정 +2. router 가 *어떤 action* 인지 결정 +3. action 이 *현재 구현되어 있나* 확인 +4. 미구현이면 → "본 spec 의 이 path 미구현이라 처리 불가" 로 명확히 보고 + +**중요 활용** : 누군가 (Claude 든 사람이든) "padding 줄여서 끼우자" 같은 fix axis 를 제시하면 — 이 fix 가 본 spec 의 어느 action 에도 매핑되지 않음 → **자동 반려**. 본 spec 을 검증 자료로 가지면 *symptom-silencing fix* 가 들어올 자리가 없어짐. + +--- + +## 7. MDX 03 의 10px clipping 을 본 spec 으로 분류 (검증용 sample, fix 대상 X) + +> MDX 03 = sample instance. spec 룰의 *작동 검증* 용 — fix 대상 아님. + +### 측정값 +- excess_y = 10px (~0.6 줄, transform-row line-height 15.95px 기준) +- clipped element className = `transform-block` 의 마지막 row (cell 내부) +- semantic content_type = `structural_unit` (transform-row pair) +- structural_unit_drop_count = 0.6 (1 개의 마지막 row 가 부분만 잘림 — 완전 단위 1 개가 아님) +- composition `capacity_fit.fit_status` = `ok` + +### 분류 적용 (§3.2 우선순위) + +1. `frame_capacity_mismatch`? — capacity_fit ok → ✗ +2. `tabular_overflow`? — content_type 이 tabular 아님 → ✗ +3. `structural_major_overflow`? — drop_count `< 1` → ✗ +4. `layout_zone_mismatch`? — frame_internal 아님 → ✗ +5. `structural_minor_overflow`? — content_type = structural_unit AND drop_count `< 1` → **✓** + +**카테고리 = `structural_minor_overflow`** + +### Action mapping 적용 (§4) + +→ `zone_ratio_retry` (구조 자르지 않도록 F29 zone 을 더 키움) + +### 현재 구현 상태 + +→ `zone_ratio_retry` 는 **MISSING**. 따라서 본 spec 기준으로 MDX 03 은 *classifier 가 정상 작동하면 structural_minor_overflow 로 분류되고 zone_ratio_retry 로 routing 되어야 하는 case* 인데, *그 path 가 현재 미구현*. 따라서 정직한 상태는 `RENDERED_WITH_VISUAL_REGRESSION` 그대로 유지. + +### 반례 검증 (이전 잘못된 fix) + +이전에 시도했던 "transform-block padding 6→4 + transform-row padding 3→2" : +- 본 spec 의 어떤 action 에도 매핑되지 X (`density_reduce` 같은 action 자체가 없음) +- 따라서 본 spec 기준으로 **자동 반려되는 fix axis** — 들어올 자리 없음 + +이게 본 spec 이 *visual fix 결정의 검증 자료* 로 작동한다는 증거. + +--- + +## 8. 다음 step (구현 우선순위 — 사용자 결정 영역) + +본 spec 정의 후 구현 axis 후보 (사용자가 우선순위 결정): + +- A. content_type registry + fit_classifier (분류 layer) +- B. overflow_router (정책 layer) +- C. `zone_ratio_retry` action (가장 자주 트리거될 action) +- D. `details_popup_escalation` (큰 작업 — 새 path) +- E. `frame_reselect` (V4 top-k 사용 layer) + +각 axis 는 *별도 step* 으로 한 단위씩. 한꺼번에 묶지 X. diff --git a/docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md b/docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md new file mode 100644 index 0000000..becd4b7 --- /dev/null +++ b/docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md @@ -0,0 +1,229 @@ +# Phase Z Frame Style Inventory + +> Phase Z 가 계승할 **색감, 여백, 폰트 위계, 표 / 카드 / 다이어그램 스타일, pill / badge, SVG / CSS 구현 힌트** 를 추출하는 인벤토리. +> +> ⚠️ 이 문서는 **블록 매핑이 아니다.** Figma frame 은 디자인 레퍼런스 / 구조 패턴 / 슬롯 힌트로 본다 ([`FRAME-INTEGRATION-MAP.md`](FRAME-INTEGRATION-MAP.md) 참조). +> +> ⚠️ `Phase Z Target` 컬럼은 **결정이 아니라 후보**. 사용자 승인 후 프로모션 게이트에서 확정. + +--- + +## 1. Source Policy + +| 구분 | 위치 | 역할 | +|---|---|---| +| **메인 소스** | `figma_to_html_agent/blocks/{figma_id}/` | 32 frame (1171281171 제외) — Phase Z 스타일 추출의 1 차 출처 | +| **토큰 소스** | `templates/styles/tokens/` | `colors.css` / `spacing.css` / `typography.css` — Phase Z 에서 계승 / 조정 | +| **legacy 참고** | `templates/blocks/structures/` 등 | Phase Z 의 실제 조립 재료 X. 폐기 / 아카이브 방향. 스타일 / 시각 언어 참고만 | + +추출 우선 순위 : + +1. **변환 완료 frame** (`index.html` + `flat.md` 보유) — 실제 CSS 관찰 기반 스타일 추출 +2. **미변환 frame** — `Style Elements` / `Extracted Style Hints` / `Phase Z Target` 보류, 변환 완료 후 갱신 +3. **MCP / Figma 직접 조회 사용 X** — `figma_to_html_agent` 의 본업이므로 inventory 작성 단계에서 침범하지 않음 + +--- + +## 2. Frame Inventory — 컬럼 정의 + +| 컬럼 | 의미 | +|---|---| +| **Frame** | `FRAME-INTEGRATION-MAP.md` 의 row 번호 (01~32) | +| **Figma ID** | `figma_to_html_agent/blocks/` 디렉토리 ID | +| **Layout** | `layouts.yaml` controlled vocabulary | +| **Style Elements** | frame 안에서 *관찰되는* 스타일 요소 (gradient bar, pill, table header, radial node, soft shadow 등) | +| **Extracted Style Hints** | Phase Z 에 *계승할* 구체 힌트 (예 : "table header dark fill + white text", "card gap 12~16px") | +| **Phase Z Target** | **후보** 위치 (예 : `tokens/colors.css`, `styles/frame-patterns/table.css`, `svg-helpers/`). 결정 X | +| **Asset Notes** | 자산 의존도 / Phase Z 재사용 가능성 (예 : "타이틀 아이콘 PNG 1 개, conclusion box 는 CSS 변환 가능") | +| **Notes** | cardinality / optional slot / 변형 축 / 기타 | + +### 작성 룰 + +1. **관찰 가능한 값만 작성** — 변환 frame 의 셀은 `flat.md` + `index.html` 에 *실제 있는* 관찰값만 채운다. flat.md 깊이는 frame 마다 다를 수 있고, 빠진 항목 (예 : 변형 축 명시 없음) 은 채우지 않고 비운다. **다른 frame 깊이에 맞추기 위한 추론 / 추측 채움은 하지 않는다.** +2. **Phase Z Target 후보는 가능한 한 family 단위로 수렴** — 같은 layout family (표 / 카드 / 다이어그램 / 리스트 / banner) 의 frame 들은 동일한 target 파일 후보를 가리키게 작성. variant 차이는 별도 target 으로 쪼개기보다 `Notes` 에 메타로 남긴다. 표기는 항상 `(후보)` 접미사 — "만들 파일" 이 아니라 "수렴 위치 후보" 로 읽히게. +3. **scale / zoom 은 Notes 에 메타** — Figma 원본 폭이 1280 이 아닐 경우 `flat.md` 의 scale / zoom 값을 그대로 두 (raw px 는 원본 기준). Phase Z slide-body (≈1200×590) 에 적용 시 재계산이 필요하다는 사실을 `Notes` 에 한 줄 기록. +4. **redescription 금지** — `analysis.md` 의 cardinality / slot / anchor / layout 설명, `FRAME-INTEGRATION-MAP.md` 의 비고 / 검토 상태를 Inventory 에 다시 베끼지 않는다. 같은 정보가 두 문서에 들어가면 drift 위험. + +### 미변환 frame 의 셀 표기 + +- `Style Elements` : *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* +- `Extracted Style Hints` / `Asset Notes` / `Notes` : `—` +- `Phase Z Target` : `TBD after conversion` (단, `reference_only` frame 은 `N/A — reference_only`) + +--- + +## 3. Frame Inventory (32 행) + +> 14 변환 완료 frame 은 `flat.md` + `index.html` 관찰 기반 스타일 추출. +> 18 미변환 frame 은 보일러플레이트 일괄. +> 작성 룰 #1~4 (위 섹션 2) 준수. + +| Frame | Figma ID | Layout | Style Elements | Extracted Style Hints | Phase Z Target | Asset Notes | Notes | +|---|---|---|---|---|---|---|---| +| **01** | `1171281172` | `circular-nodes-6` | • 6 원형 노드 absolute 배치 (각 노드 = 배경 원 + 내부 아이콘 + 라벨)

• 모든 노드 / 연결선 / 중앙 / 배경 = 이미지 자산 (9 개) | • **2D 다이어그램 패턴** — 노드 좌표 절대 배치
• 자산 의존도 매우 큼 — 본 frame 의 시각 구성은 거의 이미지 | svg helpers (helper area 후보, 자산 의존 제한적) | 9 자산 (배경, 중앙, 노드 ×6, 연결선) — 모두 이미지 유지. Phase Z 재현 시 자산 풀 / placeholder 필요 | 원본 1579×981, scale 0.81064. 변형 축 명시 없음 (flat.md sparse) | +| **02** | `1171281173` | `bullet-cards-4-plus-center` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **03** | `1171281174` | `list-numbered-4` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **04** | `1171281175` | `quadrilateral-relations` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **05** | `1171281176` | `side-card-with-list` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **06** | `1171281177` | `full-page-map-banner` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `N/A — reference_only` | — | — | +| **07** | `1171281178` | `2col-paired-list` | • 좌 H/W 7 항목 + 우 S/W 6 항목 + 중앙 시스템 원 + 하단 ground 이미지
• 16 자산 (배경 / 패널 / 중앙 원 / 장식 아이콘 / 헤더 바 SVG) | • **2D 복합 시스템 구성도 패턴**
• 자산 의존도 매우 큼 — Phase Z 재구성 곤란 | `styles/frame-patterns/system-diagram.css` (후보, 자산 의존 제한적) | 16 자산 모두 이미지 유지. Phase Z 재현 시 자산 풀 필수 | 원본 2446×1943, scale 0.52331. 변형 축 명시 없음 (flat.md sparse) | +| **08** | `1171281179` | `3-section-framework` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **09** | `1171281180` | `list-stacked-vertical` | • 5 pill 행 : `bg: rgba(255,255,255,0.5)`, `border-bottom: 3px solid {color}`, `radius: 30px`, `box-shadow: 2px 4px 5px rgba(0,0,0,0.5)`, `padding: 10px 20px`
• pill 색상 5 개 : `#fb5915` / `#e79000` / `#e9a804` / `#919f00` / `#0d6361`
• 다이아몬드 stacking : 넓→좁→좁→넓→넓 (좌측 indent 변화)
• 타이틀 바 : `#fbd5b9`, `radius: 5px`, shadow
• 좌측 아크 장식 SVG (이미지) + 화살표 SVG (`rotate(-90deg)`) | • **pill row + colored bottom border** : 핵심 패턴, 색만 갈아끼우면 N=3~7 동작
• **다이아몬드 stacking 패턴** : indent 차이로 시각 리듬
• translucent bg + colored border = 부드러운 카테고리 분리 | `styles/frame-patterns/pill-list.css` (후보) + `tokens/colors.css` 5 pill color palette (후보) | 좌측 아크 SVG / 화살표 SVG 2 개 — 이미지 유지. pill 본체는 CSS 변환 완료 | 변형 축 : `items[N=3~7]`, `stacking_pattern` (required), `arc_decoration` / `vertical_label` (optional). 원본 1153×592 (scale 1.11015 — 원본이 1280 보다 작음, zoom up 처리) | +| **10** | `1171281181` | `radial-diagram-5` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **11** | `1171281182` | `cards-3-category` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **12** | `1171281189` | `cycle-3way-intersection` | • 메인 3 원 (350×350) : outer + inner SVG (Ellipse585~592) + 중앙 라벨 (50px Bold white, `text-shadow: #cc5200`)
• 액센트 6 원 (130.9 px) : 한자 라벨 (45px Bold white, 같은 text-shadow)
• 사이드 라벨 6 그룹 : 40px Bold + 30px Medium desc
• 영역별 heading color : 상단 `#cc5200` / 좌측 `#604f32` / 우측 `#124133`, desc 공통 `#525151`
• 장식 RECT : gradient 회전 + `mix-blend-mode: multiply`
• 타이틀 : 70px Bold gradient `#000→#883700` | • **3 원 교차 다이어그램** : main 3 + accent 6 으로 영역 표현
• **white text + colored text-shadow** : 깊이 부여 효과
• **영역별 hue 분리** (`#cc5200` / `#604f32` / `#124133`) — 시각 zone 구분
• bg_texture multiply blending — 부드러운 배경 강조
• 사이드 라벨 위계 : Bold heading + Medium desc | svg helpers (helper area 후보, 원 / 교차 영역) + `styles/frame-patterns/cycle.css` (후보) + `tokens/colors.css` 영역별 hue palette (후보) | 19 SVG (Ellipse585~603 outer/inner pairs) — 좌표 기반 SVG 재구성 가능. bg_texture PNG 1 개는 이미지 유지 | 변형 축 명시 없음 (메인 3 원 고정 가능성). 원본 2195×1195, scale 0.58312. 수학 : main 350px = 15.94% width / accent 130.9px = 5.96% width | +| **13** | `1171281190` | `3-column` | • 3 컬럼 (각 690 원본) : 좌 BAR (152.5 px) gradient + 우 본문
• BAR gradient 3 가지 : 기술 `#0D78D0→#023056` / 사람 `#FF9A23→#CC5200` / 자연 `#39BE49→#23742C`
• 한자 (技術 / 人材 / 天地) : 50px Bold white on bar
• 헤딩 : 45px Bold gradient (top / bottom 별도 gradient 2 종)
• 본문 : 35px Medium `#3E3523`
• 세로 라벨 (rotate 90°) — 옵셔널 메타
• 테두리 : 실선 + 점선 SVG | • **3-pillar 카드 패턴** : 동등 카테고리 3 개 (예 : 기술/사람/자연)
• **gradient bar + 한자 + heading + body** 조합
• **컬럼별 hue rotation** (blue / orange / green) — 의미 차별화
• heading 도 gradient (단색 X) — 일관된 시각 언어 | `styles/frame-patterns/three-pillar.css` (후보) + `tokens/colors.css` 3 column gradient palette (후보) | 아이콘 PNG 1 개 + 테두리 SVG 4 개 (CSS border 변환 가능). 자산 의존 적음 | 변형 축 명시 없음 (3 pillar 고정 가능성 큼). 원본 2123×724, scale 0.60290. 수학 : 열 너비 416px / 바 92px after scale | +| **14** | `1171281191` | `persona-3col` | • 3 컬럼 동일 사이즈 (833×1845 원본) + 각 컬럼 텍스처 BG 이미지
• 컬러 오버레이 (opacity 0.80) — 컬럼별 다른 색감 hue
• 하단 사진 3 개 : `border-radius: 49~50px`, opacity 0.70
• 상단 원형 뱃지 (3 개) : outer + inner 이미지 + 한글 라벨
• 라벨 색 hue rotation : 발주자 `#285B4A` / 시공자 `#445A2F` / 설계자 `#743002`
• 체크박스 불릿 아이콘 (이미지, 32×32)
• 불릿 텍스트 : 40px Medium `#000` | • **3 컬럼 persona / actor 카드 패턴** : 역할별 한 컬럼
• **컬럼별 hue rotation** : 같은 톤 안에서 색상만 다르게 (역할 구분)
• **타이틀을 원형 뱃지로 표현** — 컬럼 상단 절반 걸침 (overhang)
• **사진을 borderless 가 아니라 둥근 corner + opacity 처리** (텍스트 가독성 확보)
• **체크박스 불릿** — 토큰화 가능 (Phase Z list-marker 후보) | `styles/frame-patterns/persona-cards.css` (후보) + `tokens/colors.css` 컬럼별 actor hue palette (후보) + svg helpers 원형 뱃지 (helper area 후보) | 다수 이미지 자산 : BG texture (×3), overlay (×3), photo (×3), badge outer/inner (×6), 체크박스 (×20 동일). 사진은 컨텍스트 의존 → Phase Z 에서 placeholder / 사용자 제공 자산 필요. 체크박스는 SVG 단순 대체 가능 | 원본 2601×1927 (대형 frame), scale 0.49213 | +| **15** | `1171281192` | `policy-4card-plus-list` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **16** | `1171281193` | `quadrant-4` | • 2×2 사분면 (각 1080×270 헤더/푸터바 + 본문)
• 헤더/푸터 bar (4 개) : brown gradient (좌측, `270deg, rgba(165,161,150,0.5)→#39321E`) / green gradient (우측, `270deg, rgba(41,107,85,0.5)→#032118`)
• Bar 라벨 : 60px Black white + `text-shadow: 0 0 4px #322c1e`
• 사분면 헤드라인 : red `#ff0000` 55px Bold (강조)
• 본문 : black 42px Regular, bullet list (`
  • `, 텍스트 마커)
    • 중앙 원 + 영문 인용 (white 55px Bold)
    • 배경 텍스처 PNG (`border-radius: 50px`, ×4 동일) | • **2×2 quadrant 패턴** : 4 사분면에 헤드라인 + body 쌍 + 헤더/푸터 bar
    • **bar 색 양극** (brown / green) — 좌·우 의미 구분
    • **bar text 강한 시각 강조** : 60px Black + text-shadow
    • **사분면 헤드라인 red** — 문제 / 이슈 강조 패턴
    • 중앙 원 + 인용 — 결론 표현 (옵셔널)
    • bullet — `
    • ` 텍스트 마커 (이미지 마커 X) | `styles/frame-patterns/quadrant.css` (후보) + `tokens/colors.css` bar gradient + 강조 red (후보) | 배경 텍스처 PNG (×4 동일) + 중앙 원 PNG + bar SVG (×4, CSS gradient 변환 가능). 배경 / 원만 이미지 유지 | 변형 축 : `quadrants[4]` (required, 고정), `bar_labels[4]` (required), `center_quote` / `center_image` / `bg_texture` (optional). 원본 2226×1766, scale 0.57503 | +| **17** | `1171281194` | `paired-rows-2x2` | • 4 행 (각 좌 pill + 본문 + 분할선 + 우 pill + 본문)
      • 행 배경 : `border: 3px #60A451`, `radius: 30px`, `bg: rgba(250,237,203,0.15)`
      • 분할선 : `dashed 2px #60A451` (CSS)
      • pill 이미지 (R16: 두루마리 곡선) — 좌측 `left:-45.3% width:145.3%` / 우측 `left:0 width:151.25%`
      • pill 라벨 : 40px Bold white
      • 본문 : 36px Medium `#0c271e`
      • 행 교대 pill 회전 : 상행 정상 / 하행 `rotate(180deg)`
      • 타이틀 : 70px Bold gradient `#CC5200→#883700` | • **paired-rows 패턴** : 좌 / 우 라벨 + body 페어, 분할선 중앙
      • **두루마리 pill (R16)** : 이미지 기반 곡선 형상 — CSS 재구성 곤란
      • **상/하 pill 회전 교대** = 시각 리듬
      • translucent bg + colored border = visual containment
      • dashed 분할선 = soft separation | `styles/frame-patterns/compare-paired.css` (후보, Frame 18 과 같은 family) + `tokens/colors.css` border / bg color (후보) | 타이틀 아이콘 PNG + 두루마리 pill PNG (R16 frame 배치, CSS 재구성 곤란) + 분할선 SVG (CSS 변환 가능) | 변형 축 : `rows[N=3~6]`, `pill_alternation` 상/하 교대 (required), `pill_image` (required), `bg_border_color: #60A451` (required). 원본 1857×1326, scale 0.68927. **compare-paired family — variant : `paired-rows` (pill alternation)** | +| **18** | `1171281195` | `compare-rows` | • 타이틀 70px Bold gradient text (`#CC5200 → #883700`) + 아이콘
      • 서브헤더 pill bar : `linear-gradient(270deg, #285B4A → #4A4026)`, `border-radius: 50px`
      • 중앙 카테고리 뱃지 12 개 : 같은 gradient (alpha 0.64~0.8), `border-radius: 10px`
      • 좌·우 텍스트 색 양극 : `#5C3714` (BIM 측, 갈색계) ↔ `#285B4A` (DX 측, 청록계), 40px Bold
      • 결론 박스 : `#FAEDCB` + `mix-blend-mode: multiply`
      • 결론 강조 텍스트 : `#AE3607` 55px Bold | • **다행 비교표 패턴** : 좌 (BIM/AS-IS) ↔ 중앙 카테고리 라벨 ↔ 우 (DX/TO-BE) 의 3 컬럼 페어드
      • **양극 색 표현** : 좌·우를 명도·색상이 다른 두 hue 로 분리 (대비 의도)
      • **gradient 재사용** : title gradient + bar/badge gradient 가 동일 팔레트 (저채도 그린 + 다크 brown) 변주
      • **결론 처리** : multiply blending + accent color 로 강조 | `styles/frame-patterns/compare-paired.css` (후보, Frame 17 과 같은 family) + `tokens/colors.css` 의 `--g-title` / `--c-as-is` / `--c-to-be` (후보) + `styles/blocks/conclusion.css` multiply blend (후보) | 타이틀 아이콘 PNG 1 개 (이미지 유지) + 화살표 SVG 1 개 (`rotate(180deg)`, 이미지 유지) + 결론 박스 SVG → CSS 변환 완료 (자산 불필요) | 변형 축 : `rows[N=8~15]` (required), `title` (required), `conclusion` / `arrow_decoration` (optional). 원본 1868×1908, scale 0.68524. **compare-paired family — variant : `vs-center-badge` (좌·우 텍스트 + 중앙 카테고리 라벨 컬럼)** | +| **19** | `1171281197` | `cards-3-compare` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **20** | `1171281198` | `cards-3-header` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **21** | `1171281201` | `split-panel-diagram` | • 좌 다이어그램 이미지 + 우 4 번호 항목 + 하단 결론 바
      • 14 자산 (다이어그램 요소 / 번호 뱃지 / 행 바 / 화살표 / 결론 바) — 모두 이미지 유지 | • **split-panel 패턴** (이미지-기반 다이어그램 + 번호 리스트)
      • 자산 의존 큼 — Phase Z 재구성 시 다이어그램 이미지 제공 필요 | `styles/frame-patterns/split-panel.css` (후보, Frame 22 와 같은 family) | 14 자산 모두 이미지 유지. 다이어그램 이미지 = 핵심 자산 | 변형 축 : `right_items[N=3~6]`, `conclusion_text` (optional). 원본 1889×824, scale 0.67761. flat.md sparse | +| **22** | `1171281202` | `split-panel-numbered` | • 좌 패널 : 배경 IMG + 카테고리 텍스트 (40px Bold white, text-shadow) + detail (35px Medium black)
      • 우 패널 : 5 행 (번호 뱃지 IMG + 행 바 IMG + 텍스트 45px Medium `#11231d` + 화살표 IMG `rotate(180deg)`)
      • 중앙 연결 : 세로 괄호 IMG + 커넥터 IMG
      • 행 바 (×5 동일 이미지)
      • 번호 뱃지 (5 개 별개 이미지)
      • 타이틀 : 70px/50px gradient | • **split-panel + numbered list 패턴** : 좌 카테고리 패널 + 우 번호 항목 페어
      • 카테고리 텍스트 = text-shadow + white (배경 위 가독성)
      • 번호 뱃지 + 행 바 + 화살표 = 단위 리스트 행 컴포넌트
      • 중앙 괄호 / 커넥터 = 좌 ↔ 우 연결 시각화 | `styles/frame-patterns/split-panel.css` (후보, Frame 21 과 같은 family) | 15 자산 (좌측 BG, 타이틀 장식, 구분선 ×3, 세로 괄호, 커넥터, 행 바 ×5 동일, 뱃지 ×5 별개, 화살표 ×5 동일, 타이틀 아이콘) — 모두 이미지 유지 | 변형 축 : `left_categories[N=2~5]`, `right_items[N=3~8]` (required), `bg_image` (required), `bracket_image` (optional). 원본 1863×834, scale 0.68707 | +| **23** | `1171281203` | `table-2col` | • 열 헤더 bar (3 개) : `#589e8d` (구분/좌) / `#ef7a26` (우), 40px Bold white
      • 행 배경 교대 : white / `rgba(253,198,158,0.16)`
      • 강조 키워드 : `#a14101` Bold inline
      • 본문 : black 40px Medium
      • 그리드 라인 : 모두 CSS border
      • 배경 텍스처 PNG (상단 / 하단 분할) | • **compare-table 패턴** (구분 컬럼 + N 열 비교)
      • **헤더 bar 색상 양극** (`#589e8d` 청록 / `#ef7a26` 오렌지) — 의미 구분
      • **행 alternating bg** = readability
      • **강조 키워드 inline color** (`#a14101`) — 표 셀 안 강조 | `styles/frame-patterns/compare-table.css` (후보, Frame 24 와 같은 family) + `tokens/colors.css` 헤더 bar palette (후보) | 배경 PNG 2 개 + 아이콘 PNG + line SVG ×5 (모두 CSS border 변환). 배경 PNG 만 이미지 유지 | 변형 축 : `columns[2]`, `rows[N=3~7]` (required), `header_colors[2]` (required), `bg_images[2]` (optional). 원본 1924×2014, scale 0.66527. **table family — Frame 24 / 30 / 31 과 column 수 / 행 수만 다름, variant 통합 후보** | +| **24** | `1171281204` | `table-3col` | • 열 헤더 bar (4 개) : `#589e8d` (구분/상용) / `rgba(62,53,35,0.9)` (3rd) / `#ef7a26` (전용), 40px Bold white
      • 행 배경 교대 : white / `rgba(253,198,158,0.16)`
      • 강조 키워드 : `#a14101` Bold inline
      • 본문 : black 35px Medium
      • 그리드 라인 : 모두 CSS border
      • 행 라벨 (좌측 열) : 35px Bold | • **compare-table 패턴** (Frame 23 의 3-column variant — 같은 family)
      • **헤더 색상 3-way** (`#589e8d` / 다크 brown / `#ef7a26`) — Frame 23 의 2-way 확장
      • **행 라벨 좌측 열** = 행 그룹 식별자 (예 : 개념 / 개발주체 / 성과품 / 사용) | `styles/frame-patterns/compare-table.css` (후보, Frame 23 과 같은 family — variant: `columns[N=2~4]`) + `tokens/colors.css` 헤더 bar palette 확장 (후보) | 아이콘 PNG + line SVG ×8 (전부 CSS border 변환). 자산 의존 거의 없음 | 변형 축 : `columns[N=2~4]`, `rows[N=3~6]` (required), `header_colors[N]` (required), `highlight_color` (optional, default `#a14101`). 원본 1869×1926, scale 0.68511. **table family — Frame 23 / 30 / 31 과 variant 통합 후보** | +| **25** | `1171281205` | `left-categories-right-logos` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **26** | `1171281206` | `cards-4-grid` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **27** | `1171281208` | `central-split-synthesis` | • 좌 (생산성 향상) + 우 (디지털 전환) + 중앙 원 (건설산업의 고부가가치화)
      • 상단 헤더 bar / 하단 결론 bar (SVG `rotate(180deg)`)
      • 2D 배치 (중앙 원 좌 / 우 영역 겹침) → absolute + zoom | • **split-center 패턴** : 좌 / 우 / 중앙 3-area 합성
      • 중앙 원 = 좌·우 영역에 걸침 (overhang) — 결론 표현 방식 | `styles/frame-patterns/split-center.css` (후보) | 변환 완료 (preview.png 존재). 자산 상세 미기록 (flat.md sparse) | 원본 1697×914, scale 0.75427. flat.md sparse — 추가 관찰 필요 | +| **28** | `1171281209` | `title-plus-3-emphasis` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **29** | `1171281210` | `banner-top-2col-bottom` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **30** | `1171281211` | `table-3col` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **31** | `1171281212` | `table-3col` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | +| **32** | `1171281213` | `central-5-goals` | *미변환 — 스타일 추출 보류. analysis.md 기준 layout / slot 존재만 확인.* | — | `TBD after conversion` | — | — | + +--- + +## 4. Token Inventory + +> 14 변환 frame 에서 관찰된 값과 기존 `templates/styles/tokens/` 의 매칭 검증. +> +> ⚠️ **본 inventory 는 신규 발굴표가 아니라 *기존 token 검증 + gap 발견 + 보류* 정리표**. +> ⚠️ 추출 / 검증만. 실제 token 파일 생성 / 변경 / 폐기는 별도 승인 단계. + +### 작성 룰 (최종 6 개) + +1. **2+ frame 에서 반복 관찰된 값만 행 승격**. +2. **1 frame 에서만 관찰된 값은 Token Inventory 에 올리지 않음** — 해당 Frame Inventory 의 `Notes` 에 "관찰 보류 (single-frame)" 로만 남김. +3. **패턴 / 기법은 token 이 아니라 family CSS / variant 영역** — `mix-blend-mode`, R16 pill 곡선, hue rotation, badge overhang, pill alternation 등. +4. **타이포 / 스페이싱은 값 직접 매칭이 아니라 위계 매핑** — figma raw px (frame 1280 폭 기준) ↔ slide-body token (slide-body 스케일) 은 스케일이 다르므로 위계만. +5. **gradient 는 from / to pair 단위 한 행**. +6. **`covered` 는 hex 정확 일치일 때만** — 부분 일치 / 변형은 `gap_candidate` 또는 Notes 에 별도 표기. + +### 명명 컨벤션 (가벼운 가이드) + +신규 token 후보에만 적용. 기존 token (`--color-*`, `--font-*`, `--space-*`, `--card-*`) 은 그대로 사용. + +- `--c-*` color +- `--g-*` gradient +- `--fs-*` font-size +- `--sp-*` spacing +- `--r-*` radius +- `--sh-*` shadow + +### 컬럼 정의 + +| 컬럼 | 의미 | +|---|---| +| **Token Category** | color / gradient / typography / spacing / radius / shadow | +| **Existing Token** | `templates/styles/tokens/{file}` 의 token 명. 없으면 `—` | +| **Covered Frame Observations** | frame 번호 + 짧은 컨텍스트 라벨만 (값 X). status 에 따라 의미 다름 (아래 참조) | +| **Gap / Candidate** | 신규 token 후보 (이름 + 값 + `(후보)`). 기존 token 검증인 행은 `—` | +| **Status** | `covered` / `gap_candidate` / `hierarchy_mapping_only` / `hold_recheck_after_conversion` | +| **Notes** | family CSS cross-reference + 변형 메타 + scale 경계 등 | + +### `Covered Frame Observations` 셀의 Status 별 의미 + +| Status | `Covered Frame Observations` 의 의미 | +|---|---| +| `covered` | 이 token 이 *cover 한* frame 들 (hex 정확 일치) | +| `gap_candidate` | 이 후보가 *target 으로 하는* frame 들 (2+ frame 에서 동일 값 관찰) | +| `hierarchy_mapping_only` | 이 위계 매핑이 *적용 가능한* frame 들 (값 직접 매칭 X) | +| `hold_recheck_after_conversion` | 현재 cover 한 frame 없음 (`—`). 14 converted 기준 미관찰 — 전체 변환 후 재검증 | + +### Token Inventory — 18 행 + +| Token Category | Existing Token | Covered Frame Observations | Gap / Candidate | Status | Notes | +|---|---|---|---|---|---| +| gradient | `--color-block-title-from` / `--color-block-title-to` (`#CC5200` / `#883700`) | F17, F18 (frame inner title) | — | `covered` | compare-paired family 의 title gradient slot. F12, F13 의 title 은 `from` 이 `#000` 으로 변형 — 별도 행 (`gap_candidate`) 처리 | +| gradient | `--color-col-1-from` / `--color-col-1-to` (`#0D78D0` / `#023056`) | F13 (기술 bar — blue tone) | — | `covered` | three-pillar.css 의 column 1. 1 frame 매칭이지만 token 자체가 frame 값을 정확 흡수 | +| gradient | `--color-col-2-from` / `--color-col-2-to` (`#FF9A23` / `#CC5200`) | F13 (사람 bar — orange tone) | — | `covered` | three-pillar.css 의 column 2. `#CC5200` 는 title gradient `from` 과 같은 hex 이지만 의미 다름 (column-2 끝값) | +| gradient | `--color-col-3-from` / `--color-col-3-to` (`#39BE49` / `#23742C`) | F13 (자연 bar — green tone) | — | `covered` | three-pillar.css 의 column 3 | +| color | `--color-compare-left` (`#5c3714`) | F18 (BIM / AS-IS 측 텍스트) | — | `covered` | compare-paired.css 의 좌측 / AS-IS 색 | +| color | `--color-compare-right` (`#285b4a`) | F14 (발주자 라벨), F18 (DX / TO-BE 측 텍스트) | — | `covered` | compare-paired.css 의 우측 / TO-BE + persona-cards.css 의 actor 색. F14, F18 모두 hex 정확 일치 (대소문자 제외) — 의미 다르지만 token 재사용 가능 | +| color | `--color-compare-badge` (`#ae3607`) | F18 (결론 강조 / VS 뱃지) | — | `covered` | compare-paired.css 의 결론 / VS 뱃지 강조색 | +| gradient | — | F12, F13 (title — `#000` → `#883700`) | `--g-title-dark: linear-gradient(#000, #883700)` (후보) | `gap_candidate` | title gradient 변종 (`from` 이 `#000`). `--color-block-title-to` 와 끝값 공유 — 신규 token 으로 묶을지 / variant 처리할지 검토. 영향 family : 미정 (cycle.css / three-pillar.css) | +| color | — | F23, F24 (table 헤더 좌 / 구분 — 청록 톤) | `--c-table-header-cyan: #589e8d` (후보) | `gap_candidate` | compare-table.css 의 좌측 / 구분 헤더. 기존 `--color-table-header-bg: #64748b` (회색) 과 다른 톤 — 회색 헤더 token 은 `hold` 행 참조 | +| color | — | F23, F24 (table 헤더 우 / 전용 — 오렌지 톤) | `--c-table-header-orange: #ef7a26` (후보) | `gap_candidate` | compare-table.css 의 우측 헤더 | +| color | — | F23, F24 (table 행 강조 키워드) | `--c-table-highlight: #a14101` (후보) | `gap_candidate` | compare-table.css 의 inline 강조색 | +| color | — | F23, F24 (table 행 교대 배경) | `--c-table-row-alt: rgba(253,198,158,0.16)` (후보) | `gap_candidate` | compare-table.css 의 alternating row bg. white / `--c-table-row-alt` 교대 | +| typography | `--font-slide-title` (22px) / `--font-zone-title` (13px) / `--font-sub-title` (12px) | F12, F13, F17, F18 (frame inner title — raw 70px Bold) | — | `hierarchy_mapping_only` | frame raw 70px 는 figma 1280 폭 기준 — slide-body 스케일과 다름. **위계 매핑만**. frame inner title 이 slide-body 안에서 어느 위계 (`--font-zone-title` / `--font-sub-title`) 로 매핑될지는 catalog 설계 단계 결정 | +| typography | `--font-body` (11px) | F12, F13, F16, F17, F18, F22, F23, F24 (본문 — raw 35~42px Medium) | — | `hierarchy_mapping_only` | frame raw 35~42px 는 figma 1280 폭 기준. slide-body 안에서는 `--font-body` 위계 적용 | +| spacing / radius | `--space-md` / `--space-lg` / `--card-radius` 등 | F09 (pill `radius: 30px`), F16 (배경 `radius: 50px`), F17 (행 `radius: 30px`), F18 (badge `radius: 10px`, pill `radius: 50px`), F23/F24 (셀 padding) | — | `hierarchy_mapping_only` | frame raw radius / gap / padding 은 figma 폭 기준. slide-body 안에서는 위계 매핑 + 재산정 필요 | +| color | `--color-dark-card-1` (`#1a365d`) / `-2` (`#1e3a2f`) / `-3` (`#3b1f2b`) / `-title` (`#fbbf24`) / `-body` (`#e2e8f0`) | — | — | `hold_recheck_after_conversion` | 다크 카드 시각 시스템 5 token. 14 converted 기준 미관찰. 전체 32 frame 변환 후 재검증 (특히 미변환 zone_extract 18 개) | +| color | `--color-pill-bg` (`#1e293b`) / `--color-pill-text` (`#ffffff`) | — | — | `hold_recheck_after_conversion` | 다크 pill 스타일. F09 pill (translucent + colored border) / F18 badge (gradient) 와 다른 톤. 14 converted 기준 미관찰 — 전체 변환 후 재검증 | +| color | `--color-table-header-bg` (`#64748b`) / `--color-table-header-text` (`#ffffff`) | — | — | `hold_recheck_after_conversion` | 회색계 표 헤더. F23 / F24 의 colored 헤더 (`#589e8d` / `#ef7a26`) 와 다른 톤. 14 converted 기준 미관찰. 향후 회색 헤더 frame 등장 시 재검증 | + +--- + +## 5. Legacy Reference + +- legacy structures 6 개는 runtime 재사용 후보 X +- frame 변환본 (`figma_to_html_agent/blocks/`) 이 우선 source +- disposition 분류 목적은 archive / delete 판단 +- Phase Z catalog / runtime 설계 근거로 **직접 사용 X** + +### 발견 — 6 파일 모두 frame 변환본의 *slide-body 스케일 재구현* + +각 legacy file 의 헤더 주석에 `Source: figma_to_html_agent/blocks/{figma_id}` 명시 (확인). figma 변환 (raw 1280 폭, 40~70px 폰트) → slide-body 스케일 (`var(--font-sub-title)` 12px, `var(--space-sm)` 8px 등 token 적용) 재구현 시도. 즉 *figma 변환과 별개의 legacy* 가 아니라, *figma 변환에서 파생된 slide-body 스케일 시도*. + +→ Phase Z runtime 은 frame catalog + family CSS 로 rebuild 예정. legacy structures 는 *변환 검증 증거 / 토큰 매핑 참고* 외 직접 사용 X. + +### `Phase Z Disposition` 값 + +- `archive` — 보존 (스타일 / 토큰 매핑 증거 가치) +- `delete_after_extract` — Style Note 추출 후 삭제 +- `hold_until_catalog_ready` — Phase Z catalog 안정화 전까지 유지 + +### Legacy Reference — 6 행 + +| Legacy File | Current Role | Phase Z Disposition | Style Note | Notes | +|---|---|---|---|---| +| `compare-table-2col.html` | F23 (`1171281203`) 의 slide-body 스케일 재구현. 표 헤더 colored / 행 교대 bg / 강조 inline color | `delete_after_extract` (후보) | inline hex (`#589e8d` / `#ef7a26` / `#a14101` / `rgba(253,198,158,0.16)`) 가 Token Inventory 의 `gap_candidate` 4 행과 정확 일치 | `compare-table.css` family 의 first reference 가치 | +| `compare-table-3col.html` | F24 (`1171281204`) 의 3-column variant slide-body 재구현 | `delete_after_extract` (후보) | F23 과 같은 hex + 추가 `rgba(62,53,35,0.9)` (column 2 어두운 brown 헤더). 단일 frame 관찰값 — Token Inventory 비승격 (룰 #2) | `compare-table.css` family variant — F23 과 통합 후보 | +| `compare-vs-rows.html` | F18 (`1171281195`) 의 slide-body 재구현 | `delete_after_extract` (후보) | `var(--color-compare-left)` / `var(--color-compare-right)` 기존 token 활용 — covered token 검증 증거 | `compare-paired.css` family | +| `issues-paired-rows.html` | F17 (`1171281194`) 의 slide-body 재구현 | `delete_after_extract` (후보) | `--color-row-border: #60A451` inline 정의 — F17 단일 frame 관찰값, Token Inventory 비승격 (룰 #2). family CSS variant 처리 검토 | `compare-paired.css` family | +| `prerequisites-3col.html` | F13 (`1171281190`) 의 slide-body 재구현 | `delete_after_extract` (후보) | `--color-col-N-{from,to}` 기존 token 활용 가능 — covered token 검증 증거 | `three-pillar.css` family | +| `stacked-arrow-list.html` | F09 (`1171281180`) 의 slide-body 재구현 | `delete_after_extract` (후보) | 타이틀 바 `#fbd5b9` / 텍스트 `#144838` inline — F09 단일 frame 관찰값, Token Inventory 비승격 (룰 #2) | `pill-list.css` family | + +--- + +## 6. 진행 단계 + +| 단계 | 상태 | +|---|---| +| 33 frame ↔ Integration Map 32 행 대조 | ✅ 완료 (`FRAME-INTEGRATION-MAP.md` row 21~28 ID 정정 반영) | +| Inventory 골격 + 샘플 5 행 (이 문서) | ✅ 본 단계 | +| 사용자 검토 | ⬜ | +| Frame Inventory 27 행 일괄 확장 (32 frame 완성) | ⬜ | +| Token Inventory 본격 작성 | ⬜ | +| Legacy Reference 본격 작성 | ⬜ | +| Phase Z catalog / runtime template 설계 | ⬜ (별도 단계) | +| 사용자 승인 → `templates/blocks/` 신규 구조 교체 (프로모션 게이트) | ⬜ (별도 단계) | + +### 샘플 5 검증 포인트 + +| 검증 항목 | 결과 | +|---|---| +| 변환 / 미변환 frame 이 같은 양식에 들어가는가 | ✅ — 미변환은 `Style Elements` 셀 1 개에 통일 보일러플레이트, 나머지 4 셀은 `—` (`Phase Z Target` 만 `TBD after conversion` 또는 `N/A — reference_only`) | +| `Style Elements` 와 `Extracted Style Hints` 의 구분이 명확한가 | ✅ (Frame 18, 14) — 관찰 사실 (CSS 값 / 픽셀) vs 계승 의도 (패턴 / 의미) | +| `Phase Z Target` 후보 표현이 통일되는가 | ✅ — 변환 frame 은 "(후보)" 명시, 미변환은 `TBD after conversion`, reference_only 는 `N/A — reference_only` | +| 미변환 frame 이 `analysis.md` 내용을 redescribe 하지 않는가 | ✅ — 보일러플레이트 1 행 외 어떤 redescription 도 없음 | +| 변환 frame 의 `Notes` 가 스타일 계승에 의미 있는 메타만 담는가 | ⚠️ 샘플은 적정. 32 행 확장 시 단순 출처 (Scale 값, "대표 frame" 등) 는 추가로 정리 필요 | + +--- + +## 7. 부록 — 제외 / 특수 항목 + +`figma_to_html_agent/blocks/` 의 33 개 디렉토리 중 `1171281171` 은 본 인벤토리 메인 32 frame 에서 제외한다 (`texts.md` 만 존재, `index.html` / `analysis.md` 없음, 정체 미확인). 상세는 [`FRAME-INTEGRATION-MAP.md` 부록](FRAME-INTEGRATION-MAP.md#부록--제외--특수-항목) 참조. diff --git a/src/phase_z2_classifier.py b/src/phase_z2_classifier.py new file mode 100644 index 0000000..a0a76fb --- /dev/null +++ b/src/phase_z2_classifier.py @@ -0,0 +1,395 @@ +"""Phase Z-2 fit_classifier v0 (A1 — 분류 layer 만). + +Selenium visual_runtime_check 의 결과 (clipped_inner / zone overflow) 를 +spec `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` §3 taxonomy +의 *category* 로 분류하는 layer. + +본 모듈은 ***분류만***. action / router / rerender / behavior 변경 X. +출력 = debug.json 의 `fit_classification` trace. + +원칙 : + - className 이라는 raw 문자열 → semantic content_type 매핑은 *registry* 가 담당 + - excess_y (px) → line_equivalent 환산은 content_type 별 line-height 기준 + - category 결정은 spec §3.2 우선순위 그대로 적용 (frame_capacity_mismatch → + tabular → structural_major → layout_zone_mismatch → structural_minor → + moderate → minor → hard_visual_fail) + - 모든 결정은 trace 에 명시 — *어느 룰이 왜 적용됐는지* debug 로 검증 가능 + +다음 step (별도 — A2) : + overflow_router 가 본 module 의 category 를 받아 action 으로 매핑. + 본 step 에서 router 는 X. +""" + +from __future__ import annotations + +import re +from typing import Optional + + +# ─── §2 className → semantic content_type registry ─────────────── +# spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §2 의 registry 그대로. +# 패턴은 *위에서 아래로* 첫 매칭 우선. 더 specific 한 패턴이 위에 와야 함. + +CONTENT_TYPE_PATTERNS: list[tuple[str, str, str]] = [ + # (regex pattern, semantic_content_type, description) + + # transform-block / transform-row → structural_unit + # spec : "paired comparison (AS-IS/TO-BE 한 쌍이 의미 단위)" + (r"^transform-block(__|$)", "structural_unit", + "transform-block — paired comparison container"), + (r"^transform-row(__|$)", "structural_unit", + "transform-row — AS-IS/TO-BE pair row"), + (r"^transform-rows$", "structural_unit", + "transform-rows wrapper"), + + # tabular — table 클래스 또는 native
+ (r"(^|[-_])table($|[-_])", "tabular", + "table — tabular content"), + + # text-line family → text_flow + (r"^text-line(--|$)", "text_flow", + "text-line — free-flowing text/bullet"), + + # frame internal cell (frame 내부의 단위 cell) + (r"^f\d+b__cell(--|$)", "frame_internal_cell", + "frame internal cell"), + (r"^f\d+b__pillar(--|$)", "frame_internal_cell", + "frame internal pillar"), + (r"^f\d+b__quadrant(--|$)", "frame_internal_cell", + "frame internal quadrant"), + + # frame label / title / banner / ribbon + (r"^f\d+b__title$", "frame_label", + "frame title"), + (r"^f\d+b__section-title", "frame_label", + "frame section-title"), + (r"^f\d+b__banner", "frame_label", + "frame banner"), + (r"^f\d+b__ribbon", "frame_label", + "frame ribbon"), + (r"__label", "frame_label", + "frame label"), + + # frame root (f29b, f13b, f16b 자체) + (r"^f\d+b$", "frame_internal", + "frame family root"), + + # visual asset + (r"__bg(\b|$)", "visual_asset", "background asset"), + (r"^bg-", "visual_asset", "background asset"), + (r"__icon(\b|$)", "visual_asset", "icon asset"), + (r"^img-", "visual_asset", "image asset"), +] + + +def classify_content_type(class_name: str) -> tuple[str, str]: + """className 문자열 (공백 구분 multiple tokens 가능) → (semantic_content_type, match_reason). + + 공백으로 split 한 후 각 token 에 대해 CONTENT_TYPE_PATTERNS 순차 매칭. + *첫 매칭* 이 우선 (registry 의 순서가 우선순위). + 매칭 안 되면 ('unknown', ''). + + 예 : + 'f29b__cell f29b__cell--left' → ('frame_internal_cell', "...") + 'transform-block' → ('structural_unit', "...") + 'text-line text-line--bullet' → ('text_flow', "...") + """ + if not class_name: + return ("unknown", "") + + tokens = class_name.strip().split() + for token in tokens: + for pattern, ctype, desc in CONTENT_TYPE_PATTERNS: + if re.search(pattern, token): + return (ctype, f"token '{token}' matched pattern '{pattern}' ({desc})") + return ("unknown", f"no pattern matched any of tokens {tokens}") + + +# ─── line_equivalent 환산 ───────────────────────────────────────── +# content_type 별 *대표 단위 height* — excess_y 를 줄(또는 단위) 단위로 환산. +# structural_unit / tabular 의 경우는 "1 단위" = transform-row 또는 table-row. + +DEFAULT_UNIT_HEIGHTS: dict[str, float] = { + # transform-row : padding 3+3 + line-height 11×1.45=15.95 ≈ 21.95 + "structural_unit": 21.95, + # text-line : font 11 × line-height 1.6 = 17.6 + "text_flow": 17.6, + # tabular row : 추정치 (실제 표 case 들어오면 calibration) + "tabular": 22.0, + # frame label / title : font 13 × line-height 1.3 = 16.9 + "frame_label": 16.9, + # frame_internal* : 보수적 default (text-line 기준) + "frame_internal": 17.6, + "frame_internal_cell": 17.6, + # visual asset : crop 가능, 단위는 의미 없음 (line_eq 사용 안 됨) + "visual_asset": 17.6, + # unknown : text-line default + "unknown": 17.6, +} + + +def compute_line_equivalent(excess_y: float, content_type: str) -> float: + """excess_y (px) → line_equivalent (몇 줄 / 단위 분량인가). + + content_type 별 default unit height 사용. 단위 height 가 0 이거나 없으면 0 반환. + 소수점 2 자리 round. + """ + unit_h = DEFAULT_UNIT_HEIGHTS.get(content_type, 17.6) + if unit_h <= 0: + return 0.0 + return round(float(excess_y) / unit_h, 2) + + +# ─── §3 taxonomy classifier ────────────────────────────────────── + +# spec §3.2 우선순위 : +# 1. frame_capacity_mismatch (composition 결과 우선) +# 2. tabular_overflow +# 3. structural_major_overflow +# 4. layout_zone_mismatch +# 5. structural_minor_overflow +# 6. moderate_overflow +# 7. minor_overflow +# 8. hard_visual_fail (fallback) + + +def classify_overflow( + *, + excess_y: float, + excess_x: float, + class_name: str, + inner_content_signals: Optional[list[str]] = None, + capacity_fit_status: Optional[str] = None, +) -> dict: + """단일 overflow event (clipped_inner 또는 zone-self) 를 spec §3 category 로 분류. + + Args: + excess_y / excess_x : Selenium 측정 overflow px + class_name : Selenium 이 캡처한 className 문자열 (multi-token 가능) + inner_content_signals : Selenium 이 추가로 보고한 *내부 콘텐츠 신호* list + (예: ['structural_unit'] — clipped cell 안에 transform-block 이 있음). + className 이 frame_internal_cell 같은 *컨테이너* 일 때 *실제 overflow 한 + content 의 type* 을 추론하기 위해 사용. + capacity_fit_status : composition v0.2 의 capacity_fit.fit_status (있으면 우선) + + Returns: + dict with inputs / derived / category / rule_applied + """ + inner_content_signals = list(inner_content_signals or []) + raw_type, type_match = classify_content_type(class_name) + + # 컨테이너 (frame_internal_cell / frame_internal) 의 경우 inner signal 로 refine. + # 이유 : Selenium 이 overflow:hidden 컨테이너 (cell) 를 잡지만, 실제 *overflow 한 + # content* 는 그 안의 transform-block / table / text-line. 컨테이너 className 만 + # 보고는 *어떤 종류의 content 가 잘리고 있는지* 모름. inner signal 이 그걸 알려줌. + refined_via_inner = None + if raw_type in {"frame_internal_cell", "frame_internal", "unknown"} and inner_content_signals: + # spec §3.2 우선순위 따라 — tabular > structural_unit > text_flow + if "tabular" in inner_content_signals: + content_type, refined_via_inner = "tabular", "tabular (inner_signal)" + elif "structural_unit" in inner_content_signals: + content_type, refined_via_inner = "structural_unit", "structural_unit (inner_signal)" + elif "text_flow" in inner_content_signals: + content_type, refined_via_inner = "text_flow", "text_flow (inner_signal)" + else: + content_type = raw_type + else: + content_type = raw_type + + line_equivalent = compute_line_equivalent(excess_y, content_type) + + inputs = { + "excess_y": float(excess_y), + "excess_x": float(excess_x), + "class_name": class_name, + "inner_content_signals": inner_content_signals, + "capacity_fit_status": capacity_fit_status, + } + derived = { + "container_content_type": raw_type, # className 만 본 결과 + "container_match": type_match, + "content_type": content_type, # inner signal 로 refine 된 *최종* 분류 + "content_type_refined_via_inner": refined_via_inner, + "line_equivalent": line_equivalent, + "unit_height_used": DEFAULT_UNIT_HEIGHTS.get(content_type, 17.6), + } + + def result(category: str, rule: str) -> dict: + return { + "inputs": inputs, + "derived": derived, + "category": category, + "rule_applied": rule, + } + + # 1. frame_capacity_mismatch — composition 결과가 이미 mismatch 신호 + if capacity_fit_status in {"strict_mismatch", "exceeds_max", "below_min", "exceeds_truncate"}: + return result( + "frame_capacity_mismatch", + f"capacity_fit_status='{capacity_fit_status}' — composition 단계의 " + f"capacity_fit 가 이미 mismatch 신호 (spec §3.2 우선순위 1)", + ) + + # 2. tabular_overflow — 표는 어떤 양이든 popup 영역 + if content_type == "tabular": + return result( + "tabular_overflow", + f"content_type=tabular — 표는 행 단위 자르면 의미 손실 (spec §3.2 우선순위 2)", + ) + + # 3. structural_major_overflow — 1 개 이상 *완전 단위* 잘림 + if content_type == "structural_unit" and line_equivalent >= 1.0: + return result( + "structural_major_overflow", + f"content_type=structural_unit AND line_equivalent={line_equivalent} >= 1.0 — " + f"의미 단위 1+ 완전 잘림 (spec §3.2 우선순위 3)", + ) + + # 4. layout_zone_mismatch — frame root 자체 overflow + if content_type == "frame_internal": + return result( + "layout_zone_mismatch", + f"content_type=frame_internal — frame root 자체가 zone 안에 못 들어감 " + f"(spec §3.2 우선순위 4)", + ) + + # 5. structural_minor_overflow — boundary spill (부분만 잘림) + if content_type == "structural_unit": + return result( + "structural_minor_overflow", + f"content_type=structural_unit AND line_equivalent={line_equivalent} < 1.0 — " + f"boundary spill (부분 단위 잘림, 완전 단위 손실 아님) (spec §3.2 우선순위 5)", + ) + + # 6. moderate_overflow — text/label flow 의 중간 양 + if content_type in {"text_flow", "frame_label"} and 1.5 < line_equivalent <= 4.0: + return result( + "moderate_overflow", + f"content_type={content_type} AND line_equivalent={line_equivalent} ∈ (1.5, 4] " + f"(spec §3.2 우선순위 6)", + ) + + # 7. minor_overflow — text/label flow 의 작은 양 + if content_type in {"text_flow", "frame_label"} and line_equivalent <= 1.5: + return result( + "minor_overflow", + f"content_type={content_type} AND line_equivalent={line_equivalent} ≤ 1.5 " + f"(spec §3.2 우선순위 7)", + ) + + # 8. hard_visual_fail — fallback (위 어디에도 안 잡힘) + return result( + "hard_visual_fail", + f"위 매핑 모두 미적용 (content_type={content_type}, line_equivalent=" + f"{line_equivalent}) — fallback (spec §3.2 우선순위 8)", + ) + + +# ─── visual_runtime_check 결과 → 전체 fit_classification trace ──── + + +def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> dict: + """Selenium overflow + composition 의 zone debug → 전체 fit_classification 산출. + + 각 overflow event (zone-self overflow / cell-level clipped_inner) 를 개별 분류. + + Args: + overflow : run_overflow_check 결과 (passed, slide, zones[], ...) + debug_zones : pipeline 의 debug_zones list (zone 별 capacity_fit / template_id 등) + + Returns: + dict : + visual_check_passed : Selenium 통과 여부 + classifications : 각 overflow event 의 분류 결과 list + summary : 텍스트 요약 (n events, categories seen) + categories_seen : 등장한 카테고리 unique list + unclassified_signals : 미분류 신호 (raw Selenium 결과 중 분류 안 된 것) + """ + if overflow.get("passed", False): + return { + "visual_check_passed": True, + "classifications": [], + "summary": "visual check passed — no overflow to classify", + "categories_seen": [], + "unclassified_signals": [], + } + + # zone position → debug_zones 매핑 (capacity_fit_status 추출용) + capacity_status_by_position: dict[str, Optional[str]] = {} + template_id_by_position: dict[str, Optional[str]] = {} + for dz in (debug_zones or []): + pos = dz.get("position") + capacity_status_by_position[pos] = ( + (dz.get("composition_rationale") or {}) + .get("capacity_fit", {}) + .get("fit_status") + ) + template_id_by_position[pos] = dz.get("v4_template_id") + + classifications: list[dict] = [] + + for z in overflow.get("zones", []): + zone_position = z.get("position", "?") + zone_template_id = z.get("template_id") or template_id_by_position.get(zone_position) + capacity_fit_status = capacity_status_by_position.get(zone_position) + + # zone-self overflow (frame root 자체) + if z.get("overflowed"): + cls = classify_overflow( + excess_y=z.get("excess_y", 0), + excess_x=z.get("excess_x", 0), + class_name=zone_template_id and f"f{re.sub(r'[^0-9]', '', str(zone_template_id))[:2] or '0'}b" or "f?b", + # zone 자체는 frame root 패턴 매칭 → frame_internal 으로 분류 의도 + capacity_fit_status=capacity_fit_status, + ) + cls["source"] = "zone_self_overflow" + cls["zone_position"] = zone_position + cls["zone_template_id"] = zone_template_id + classifications.append(cls) + + # cell-level clipped_inner + for c in z.get("clipped_inner", []): + cls = classify_overflow( + excess_y=c.get("excess_y", 0), + excess_x=c.get("excess_x", 0), + class_name=c.get("class_name", ""), + inner_content_signals=c.get("inner_content_signals") or [], + capacity_fit_status=capacity_fit_status, + ) + cls["source"] = "clipped_inner" + cls["zone_position"] = zone_position + cls["zone_template_id"] = zone_template_id + cls["client_height"] = c.get("clientHeight") + cls["scroll_height"] = c.get("scrollHeight") + classifications.append(cls) + + # slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조) + unclassified: list[dict] = [] + slide_m = overflow.get("slide") or {} + if slide_m.get("overflowed"): + unclassified.append({ + "level": "slide", + "excess_y": slide_m.get("excess_y"), + "excess_x": slide_m.get("excess_x"), + "note": "slide-level overflow — 보통 zone 단위 분류로 충분, 미분류 보고만", + }) + body_m = overflow.get("slide_body") or {} + if body_m.get("overflowed"): + unclassified.append({ + "level": "slide_body", + "excess_y": body_m.get("excess_y"), + "excess_x": body_m.get("excess_x"), + "note": "slide_body overflow — 위와 같음", + }) + + categories = sorted({c["category"] for c in classifications}) + return { + "visual_check_passed": False, + "classifications": classifications, + "summary": ( + f"{len(classifications)} overflow event(s) classified, " + f"categories: {categories or 'none'}" + ), + "categories_seen": categories, + "unclassified_signals": unclassified, + } diff --git a/src/phase_z2_composition.py b/src/phase_z2_composition.py new file mode 100644 index 0000000..da83f76 --- /dev/null +++ b/src/phase_z2_composition.py @@ -0,0 +1,571 @@ +"""Phase Z-2 Composition Planner v0. + +Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는 결정 layer. + +위치 : + parse_mdx → align_sections_to_v4_granularity → [본 모듈] → render + +원칙 (절대 룰) : + - 특정 MDX / frame / section 하드코딩 X (예: "04-2 면" / "F16 이면") + - 모든 결정 = catalog 메타 + V4 evidence parametric + - 같은 코드가 MDX 02/03/04/05/06... 모두 처리 — 결과는 케이스마다 다름 + - drilling 결과 = 입력 (재료), composition planner 결과 = 출력 (zone units) + - slide-level layout = zone 까지만 나눔. zone 내부 분할은 frame partial 책임 + +8 layout preset vocabulary : + L1 single / L2 horizontal-2 / L3 vertical-2 + L4 top-1-bottom-2 / L5 top-2-bottom-1 + L6 left-1-right-2 / L7 left-2-right-1 + L8 grid-2x2 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +# ─── 8 Layout Preset Vocabulary ──────────────────────────────── + +LAYOUT_PRESETS: dict[str, dict] = { + "single": { + "zones": 1, + "topology": "single", + "positions": ["primary"], + "css_areas": '"primary"', + "css_cols": "1fr", + "css_rows": "1fr", + }, + "horizontal-2": { + "zones": 2, + "topology": "rows", + "positions": ["top", "bottom"], + "css_areas": '"top" "bottom"', + "css_cols": "1fr", + "css_rows": "1fr 1fr", + }, + "vertical-2": { + "zones": 2, + "topology": "cols", + "positions": ["left", "right"], + "css_areas": '"left right"', + "css_cols": "1fr 1fr", + "css_rows": "1fr", + }, + "top-1-bottom-2": { + "zones": 3, + "topology": "T", + "positions": ["top", "bottom-left", "bottom-right"], + "css_areas": '"top top" "bottom-left bottom-right"', + "css_cols": "1fr 1fr", + "css_rows": "1fr 1fr", + }, + "top-2-bottom-1": { + "zones": 3, + "topology": "inverted-T", + "positions": ["top-left", "top-right", "bottom"], + "css_areas": '"top-left top-right" "bottom bottom"', + "css_cols": "1fr 1fr", + "css_rows": "1fr 1fr", + }, + "left-1-right-2": { + "zones": 3, + "topology": "side-T-left", + "positions": ["left", "right-top", "right-bottom"], + "css_areas": '"left right-top" "left right-bottom"', + "css_cols": "1fr 1fr", + "css_rows": "1fr 1fr", + }, + "left-2-right-1": { + "zones": 3, + "topology": "side-T-right", + "positions": ["left-top", "right", "left-bottom"], + "css_areas": '"left-top right" "left-bottom right"', + "css_cols": "1fr 1fr", + "css_rows": "1fr 1fr", + }, + "grid-2x2": { + "zones": 4, + "topology": "2x2", + "positions": ["top-left", "top-right", "bottom-left", "bottom-right"], + "css_areas": '"top-left top-right" "bottom-left bottom-right"', + "css_cols": "1fr 1fr", + "css_rows": "1fr 1fr", + }, +} + + +# ─── CompositionUnit ──────────────────────────────────────────── + +@dataclass +class CompositionUnit: + """Slide 내 1 zone 후보 = MDX section(s) + 매칭된 frame. + + source_section_ids : 1 개 = single, 2+ = merged + merge_type : + - "single" : 단일 section + - "parent_merged" : parent V4 entry 존재 (v0) + - "parent_merged_inferred" : parent V4 entry 없음, child evidence 로 추론 (v0.1) + frame_* : V4 evidence 그대로 (catalog 메타 X 하드코딩 X) + score : 종합 점수 + rationale : score breakdown 추적 + review_required : True 면 자동 선택 X — debug 에만 노출, 사용자/AI 검토 후 + 별도 path (light_edit / restructure / AI restructuring) 로 처리 + review_reasons : 왜 review_required 가 True 인지 (자가검증용 — child label mix / + template_id 불일치 / cardinality 불호환 등) + """ + source_section_ids: list[str] + merge_type: str + frame_template_id: str + frame_id: str + frame_number: int + confidence: float + label: str # use_as_is / light_edit / restructure / reject + phase_z_status: str + raw_content: str + title: str + score: float = 0.0 + rationale: dict = field(default_factory=dict) + + # 자동 파이프라인 단계 상태 (review/UI 개념 X — 현재는 자동 결정 + 명확한 실패 기록만) + # auto_selectable=False 면 자동 선택 단계에서 제외. filter_reasons 가 그 이유. + # 예: parent_merged_inferred 의 W1/W2/W3 (rep status / all reject / majority not-auto-renderable) + # 사용자/AI 검토는 별 layer (interactive editor) 에서 처리. 본 dataclass 는 자동 결정 완결. + auto_selectable: bool = True + filter_reasons: list[str] = field(default_factory=list) + # informational signals — auto_selectable 여부와 무관. future axis 가 점수화할 영역. + # 예: "children disagree on rank-1 template_id" / "minority of children non-auto-renderable" + notes: list[str] = field(default_factory=list) + + +# ─── Heading Tree ────────────────────────────────────────────── + +def derive_parent_id(section_id: str) -> Optional[str]: + """section_id 에서 parent 도출 — V4 키 컨벤션 기반. + + 예시 (코멘트, 룰 X) : + - "04-2.1" → "04-2" (decimal suffix → strip) + - "04-1" → None (top-level, no parent) + - "04" → None + """ + parts = section_id.split("-", 1) + if len(parts) != 2: + return None + mdx_id, suffix = parts + if "." in suffix: + parent_suffix = suffix.split(".")[0] + return f"{mdx_id}-{parent_suffix}" + return None + + +def build_heading_tree(sections) -> dict: + """Section list → tree {section_id: {section, children}}.""" + tree = {s.section_id: {"section": s, "children": []} for s in sections} + for s in sections: + parent = derive_parent_id(s.section_id) + if parent and parent in tree: + tree[parent]["children"].append(s.section_id) + return tree + + +# ─── Candidate Generation ────────────────────────────────────── + +def _apply_capacity_fit(candidate: CompositionUnit, capacity_fit_fn) -> None: + """capacity_fit_fn 결과를 candidate 의 rationale + auto_selectable + filter_reasons 에 반영. + + fit_status 가 'ok' / 'no_contract' / 'unknown_source_shape' 이면 auto_selectable 영향 X + (no_contract 는 catalog-only mapper 가 별도로 ValueError 처리). + 그 외 (strict_mismatch / exceeds_max / below_min / exceeds_truncate) 는 silent loss 또는 + mapper FitError 가 발생할 후보 → auto_selectable=False + filter_reasons 'C1: ...'. + """ + if capacity_fit_fn is None: + return + fit = capacity_fit_fn(candidate.frame_template_id, candidate.raw_content) + candidate.rationale["capacity_fit"] = fit + if fit["fit_status"] in {"ok", "no_contract", "unknown_source_shape"}: + return + candidate.auto_selectable = False + candidate.filter_reasons.append( + f"C1: capacity mismatch ({fit['fit_status']}) — {fit['mismatch_reason']}" + ) + + +def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, + auto_renderable_statuses: Optional[set[str]] = None, + capacity_fit_fn=None): + """Generate composition candidates. + + v0.1 candidate types : + 1. single : per leaf section (V4 entry 필수) + 2. parent_merged : parent 자체에 V4 entry 존재 (parent 가 직접 매칭됨) + 3. parent_merged_inferred : parent V4 없음. child evidence 로 representative + template_id 추론 + + 원칙 : + - 특정 section_id / template_id / frame 하드코딩 X + - 모든 결정 = derive_parent_id() + V4 evidence + v4_label_to_status mapping + 주입된 fn (파라메트릭) + + Args: + sections : align 결과 + v4_lookup_fn : (section_id) → V4Match | None + v4_label_to_status : V4 label → Phase Z status mapping + auto_renderable_statuses : 자동 렌더 허용 status set (W1/W3 판정 입력) + capacity_fit_fn : Optional (template_id, content) → fit dict. + 제공되면 모든 candidate 에 적용 — capacity mismatch 시 auto_selectable=False + (silent truncate / mapper FitError 사전 차단). + + Returns: + list[CompositionUnit] + """ + if auto_renderable_statuses is None: + auto_renderable_statuses = set() + + candidates = [] + + # 1. Separate + for s in sections: + match = v4_lookup_fn(s.section_id) + if match is None: + continue + c = CompositionUnit( + source_section_ids=[s.section_id], + merge_type="single", + frame_template_id=match.template_id, + frame_id=match.frame_id, + frame_number=match.frame_number, + confidence=match.confidence, + label=match.label, + phase_z_status=v4_label_to_status.get(match.label, "unknown"), + raw_content=s.raw_content, + title=s.title, + ) + _apply_capacity_fit(c, capacity_fit_fn) + candidates.append(c) + + # parent → children 그룹화 + parent_to_children: dict[str, list] = {} + for s in sections: + pid = derive_parent_id(s.section_id) + if pid: + parent_to_children.setdefault(pid, []).append(s) + + # 2. parent_merged (parent 자체가 V4 에 매칭된 경우) + for pid, children in parent_to_children.items(): + parent_match = v4_lookup_fn(pid) + if parent_match is None: + continue # branch 3 가 처리 + if len(children) < 2: + continue # merge 의미 없음 + merged_raw = "\n\n".join(c.raw_content for c in children) + c_pm = CompositionUnit( + source_section_ids=[c.section_id for c in children], + merge_type="parent_merged", + frame_template_id=parent_match.template_id, + frame_id=parent_match.frame_id, + frame_number=parent_match.frame_number, + confidence=parent_match.confidence, + label=parent_match.label, + phase_z_status=v4_label_to_status.get(parent_match.label, "unknown"), + raw_content=merged_raw, + title=pid, + ) + _apply_capacity_fit(c_pm, capacity_fit_fn) + candidates.append(c_pm) + + # 3. parent_merged_inferred (v0.1) — parent V4 없음, child evidence 기반 + for pid, children in parent_to_children.items(): + if v4_lookup_fn(pid) is not None: + continue # branch 2 가 이미 처리 + if len(children) < 2: + continue + # children 중 V4 매칭 있는 것들만 evidence 로 사용 + child_matches: list[tuple] = [] + for c in children: + m = v4_lookup_fn(c.section_id) + if m is not None: + child_matches.append((c, m)) + if len(child_matches) < 2: + continue # 최소 2 child evidence 필요 + + # representative = 가장 confidence 높은 child match (v0.1.1 단순 룰) + # 향후 axes : top-k convergence, template family agreement, cardinality_fit 등 + rep_child, rep_match = max(child_matches, key=lambda cm: cm[1].confidence) + + # 자동 선택 가능 여부 = auto_selectable. default True (strong inferred merge). + # 다음 weak 신호 중 하나라도 있으면 auto_selectable=False (filter_reasons 에 사유) : + # W1 : representative status 가 auto-renderable 아님 → 자동 렌더 자체가 막힘 + # W2 : 모든 child 가 reject → merge 의미 자체가 없음 + # W3 : auto-renderable 아닌 child label 이 majority (>50%) + # informational notes (auto_selectable 영향 X, future axis 점수화 영역) : + # N1 : children 의 rank-1 template_id 가 서로 다름 → top-k / family compat + # N2 : non-auto-renderable child label 이 일부 (소수) 존재 + rep_status = v4_label_to_status.get(rep_match.label, "unknown") + child_labels = [m.label for _, m in child_matches] + child_template_ids_unique = sorted({m.template_id for _, m in child_matches}) + n_children = len(child_matches) + n_not_auto = sum( + 1 for l in child_labels + if v4_label_to_status.get(l) not in auto_renderable_statuses + ) + + filter_reasons: list[str] = [] + notes: list[str] = [] + + if rep_status not in auto_renderable_statuses: + filter_reasons.append( + f"W1: representative status '{rep_status}' (label={rep_match.label}) " + f"not in auto_renderable_statuses={sorted(auto_renderable_statuses)}." + ) + if all(l == "reject" for l in child_labels): + filter_reasons.append( + "W2: all children labeled 'reject' — merge has no fit basis." + ) + if n_children > 0 and n_not_auto * 2 > n_children: + non_auto_labels = sorted({ + l for l in child_labels + if v4_label_to_status.get(l) not in auto_renderable_statuses + }) + filter_reasons.append( + f"W3: majority of children ({n_not_auto}/{n_children}) have " + f"non-auto-renderable labels {non_auto_labels}." + ) + + if len(child_template_ids_unique) > 1: + notes.append( + f"N1: children's rank-1 template_id differs ({child_template_ids_unique}). " + f"representative='{rep_match.template_id}' (highest child confidence). " + f"top-k / family compatibility 평가는 future axis." + ) + if 0 < n_not_auto <= n_children // 2: + non_auto_labels_minority = sorted({ + l for l in child_labels + if v4_label_to_status.get(l) not in auto_renderable_statuses + }) + notes.append( + f"N2: minority ({n_not_auto}/{n_children}) of children non-auto-renderable " + f"({non_auto_labels_minority}). representative is auto-renderable, merge proceeds." + ) + + auto_selectable = len(filter_reasons) == 0 + + merged_raw = "\n\n".join(c.raw_content for c, _ in child_matches) + c_inf = CompositionUnit( + source_section_ids=[c.section_id for c, _ in child_matches], + merge_type="parent_merged_inferred", + frame_template_id=rep_match.template_id, + frame_id=rep_match.frame_id, + frame_number=rep_match.frame_number, + confidence=rep_match.confidence, + label=rep_match.label, + phase_z_status=rep_status, + raw_content=merged_raw, + title=pid, + auto_selectable=auto_selectable, + filter_reasons=filter_reasons, + notes=notes, + ) + _apply_capacity_fit(c_inf, capacity_fit_fn) + candidates.append(c_inf) + + return candidates + + +# ─── Scoring ─────────────────────────────────────────────────── + +# v0 label weights — V4 label → score multiplier. +# 향후 axes 추가 (cardinality_fit / hierarchy_coherence / density) 시 확장. +V0_LABEL_WEIGHT = { + "use_as_is": 1.0, + "light_edit": 0.7, + "restructure": 0.4, + "reject": 0.0, +} + + +def score_candidate(c: CompositionUnit) -> CompositionUnit: + """v0 scoring : confidence × label_weight. + + 추후 추가될 axes (rationale 에 자리만 잡아둠) : + - cardinality_fit : item_count vs frame ideal/min/max + - hierarchy_coherence : merge_type 적합도 + - density_score : content 밀도 vs zone 크기 + """ + label_weight = V0_LABEL_WEIGHT.get(c.label, 0.0) + frame_compat = c.confidence * label_weight + c.score = frame_compat + # 기존 rationale 보존 (예: collect_candidates 가 넣은 capacity_fit) + c.rationale.update({ + "frame_compat": round(frame_compat, 4), + "confidence": c.confidence, + "label": c.label, + "label_weight": label_weight, + "merge_type": c.merge_type, + # placeholders for future axes + "hierarchy_coherence": None, + "density_score": None, + }) + return c + + +# ─── Selection ───────────────────────────────────────────────── + +def select_composition_units(candidates, allowed_statuses: set[str]) -> list[CompositionUnit]: + """Greedy non-overlapping selection by score, with coverage tiebreak. + + 1. 모든 candidate 점수 매김 + 2. filter : + - phase_z_status ∈ allowed_statuses + - auto_selectable=True (W1/W2/W3 신호 통과) + 3. 정렬 키 = (score desc, source_section_ids 수 desc) + — 동점이면 더 많은 section 을 cover 하는 후보 우선. + parent_merged_inferred 가 같은 점수의 single 후보를 *coverage 우위* 로 이김. + 4. greedy : 이미 covered 된 section 을 가진 후보는 skip + 5. 최종 선택 = covered set 채워나감 + + auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음. + UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X). + """ + scored = [score_candidate(c) for c in candidates] + viable = [ + c for c in scored + if c.phase_z_status in allowed_statuses and c.auto_selectable + ] + viable.sort(key=lambda c: (c.score, len(c.source_section_ids)), reverse=True) + + selected = [] + covered = set() + for c in viable: + if any(sid in covered for sid in c.source_section_ids): + continue + selected.append(c) + covered.update(c.source_section_ids) + + return selected + + +# ─── Layout Preset Selection ─────────────────────────────────── + +def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]: + """v0 : count-based default selection. + + 1 unit → single + 2 units → horizontal-2 (default. vertical-2 는 aspect signal 추가 시 분기) + 3 units → top-1-bottom-2 (default. 다른 3-zone variant 는 content-weight signal 추가 시 분기) + 4 units → grid-2x2 + + v0 한계 : + - aspect / content-weight 신호 미반영 → 2 units 는 항상 horizontal, 3 units 는 항상 top-1-bottom-2 + - 향후 unit.raw_content 기반 weight 산정 시 정교화 + """ + n = len(units) + if n == 0: + return None + if n == 1: + return "single" + if n == 2: + return "horizontal-2" + if n == 3: + return "top-1-bottom-2" + if n == 4: + return "grid-2x2" + raise ValueError( + f"Composition v0 : layout for {n} units not supported (max 4). " + "Larger counts require split-into-multiple-slides decision (future)." + ) + + +# ─── Public entry — composition pipeline ─────────────────────── + +def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, + allowed_statuses: set[str], + capacity_fit_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: + """Composition planner v0.2 entry. + + v0.2 변경 : + - capacity_fit_fn 주입 시 모든 candidate 에 capacity 사전 검사 + (silent truncate / mapper FitError 사전 차단). 불일치 시 auto_selectable=False + + filter_reason 'C1: ...'. + + v0.1 / v0.1.1 동작 (유지) : + - parent_merged_inferred candidate 생성 (parent V4 없어도) + - review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정 + - selection : score desc + coverage 우세 tiebreak + + Returns: + units : 자동 선택된 composition units + layout_preset : 8 vocabulary 중 하나 (또는 None) + debug : 후보 전체 + capacity_fit + filter_reasons + preset 결정 근거 + """ + candidates = collect_candidates( + sections, v4_lookup_fn, v4_label_to_status, + auto_renderable_statuses=allowed_statuses, + capacity_fit_fn=capacity_fit_fn, + ) + scored_all = [score_candidate(c) for c in candidates] + + units = select_composition_units(candidates, allowed_statuses) + preset = select_layout_preset(units) + + def _candidate_state(c: CompositionUnit) -> str: + if c in units: + return "selected" + if c.phase_z_status not in allowed_statuses: + return "filtered_status" # V4 label → status not auto-renderable + if not c.auto_selectable: + # filter_reasons prefix 로 capacity 와 weak 구분 + if any(r.startswith("C") for r in c.filter_reasons): + return "filtered_capacity" # C1 (capacity mismatch) + return "filtered_weak" # W1/W2/W3 (parent_merged_inferred only) + return "filtered_lost" # viable 였지만 coverage 충돌로 밀림 + + candidates_summary = [ + { + "source_section_ids": c.source_section_ids, + "merge_type": c.merge_type, + "template_id": c.frame_template_id, + "label": c.label, + "phase_z_status": c.phase_z_status, + "score": c.score, + "selection_state": _candidate_state(c), + "auto_selectable": c.auto_selectable, + "filter_reasons": list(c.filter_reasons), + "notes": list(c.notes), + "capacity_fit": c.rationale.get("capacity_fit"), + } + for c in scored_all + ] + + merge_candidates = [ + s for s in candidates_summary + if s["merge_type"] in {"parent_merged", "parent_merged_inferred"} + ] + capacity_mismatches = [ + s for s in candidates_summary + if s["selection_state"] == "filtered_capacity" + ] + + debug = { + "planner_version": "v0.2", + "selection_rule": ( + "score desc, then source_section_ids count desc (coverage tiebreak). " + "filter = phase_z_status ∉ allowed_statuses OR auto_selectable=False. " + "auto_selectable=False 사유 : C1 (capacity mismatch — silent truncate / FitError 차단), " + "W1 (rep not auto-renderable), W2 (all children reject), W3 (majority children non-auto-renderable)." + ), + "candidates_total": len(scored_all), + "candidates_viable_auto": len([ + c for c in scored_all + if c.phase_z_status in allowed_statuses and c.auto_selectable + ]), + "candidates_summary": candidates_summary, + "merge_candidates": merge_candidates, + "capacity_mismatches": capacity_mismatches, + "selected_units_count": len(units), + "layout_preset": preset, + "layout_preset_rationale": ( + f"v0 count-based: {len(units)} units → {preset}" + if preset else "no viable units" + ), + } + + return units, preset, debug diff --git a/src/phase_z2_failure_router.py b/src/phase_z2_failure_router.py new file mode 100644 index 0000000..f7e1f34 --- /dev/null +++ b/src/phase_z2_failure_router.py @@ -0,0 +1,237 @@ +"""Phase Z-2 retry_failure_classifier + next_action_router (A4 — 분류 / 매핑만). + +A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 : + 1. **retry_failure_classifier** : 실패 type 을 4 종 중 하나로 분류 + 2. **next_action_router** : failure_type → next_proposed_action 매핑 + +본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup +실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가. + +**잠근 매핑** (사용자 잠금 — 2026-04-29) : + +| failure_type | next_proposed_action | +|---|---| +| donor_slack_insufficient | layout_adjust | +| no_donor_candidates | layout_adjust | +| rerender_still_fails | frame_reselect | +| not_attempted | none | + +**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) : +``` +layout_adjust (가장 가벼움 — zone 배치만 변경) + ↓ 그래도 안 되면 +frame_reselect (중간 — frame 자체 변경) + ↓ 그래도 안 되면 +details_popup_escalation (가장 invasive — content popup, 마지막 resort) +``` + +`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow / +frame_reselect 실패 이후 단계에서 다룸 (별 step). +""" + +from __future__ import annotations + +from typing import Optional + + +# ─── §A4-1 failure_type registry ────────────────────────────────── + +FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = { + "not_attempted": ( + "retry was not attempted (router_active=False or zone_ratio_retry " + "not in proposed actions). 정상 path 의 일부 — 실패 X" + ), + "donor_slack_insufficient": ( + "primary donor 의 slack 이 target_added_px 보다 작음. 현재 layout 안 " + "redistribution 한도 도달" + ), + "no_donor_candidates": ( + "donor 후보 자체 없음 — single layout / sibling visual fail / capacity " + "mismatch / slack 0 등의 이유로 zone redistribution 불가" + ), + "rerender_still_fails": ( + "redistribution 실행 + rerender 까지 했는데도 visual_check 실패. " + "현재 frame/zone 조합이 content 와 맞지 않음" + ), +} + + +# ─── §A4-2 next_action mapping (사용자 잠금) ────────────────────── + +NEXT_ACTION_BY_FAILURE: dict[str, str] = { + "donor_slack_insufficient": "layout_adjust", + "no_donor_candidates": "layout_adjust", + "rerender_still_fails": "frame_reselect", + "not_attempted": "none", +} + +NEXT_ACTION_RATIONALE: dict[str, str] = { + "donor_slack_insufficient": ( + "현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 " + "(layout_adjust). frame 자체는 아직 의심 대상 X" + ), + "no_donor_candidates": ( + "donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 " + "sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음" + ), + "rerender_still_fails": ( + "redistribution + rerender 까지 했는데도 visual fail → 현재 " + "frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). " + "popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)" + ), + "not_attempted": ( + "retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요" + ), +} + +# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태 +NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { + "layout_adjust": "MISSING", + "frame_reselect": "MISSING", + "none": "n/a", +} + + +# ─── classifier ────────────────────────────────────────────────── + + +def classify_retry_failure(retry_trace: dict) -> Optional[dict]: + """retry_trace → failure classification. + + Returns: + None : retry 가 *성공* 한 case (retry_passed=True). 분류할 failure 없음. + dict : {failure_type, classification_rule} + """ + # case 0 : retry 성공 — failure 없음 + if retry_trace.get("retry_passed"): + return None + + # case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action) + if not retry_trace.get("retry_attempted"): + return { + "failure_type": "not_attempted", + "classification_rule": ( + "retry_attempted=False — router_active=False or zone_ratio_retry " + "not in proposed_actions" + ), + } + + # case 2 : plan 단계 실패 (rerender 안 일어남) + plan = retry_trace.get("plan") or {} + if plan and not plan.get("feasible"): + reason = (plan.get("failure_reason") or "") + reason_lower = reason.lower() + + # donor slack insufficient — primary donor 가 있으나 slack 부족 + if ( + "primary donor" in reason_lower + and "slack" in reason_lower + and "target_added_px" in reason_lower + ): + return { + "failure_type": "donor_slack_insufficient", + "classification_rule": ( + "plan.feasible=False AND failure_reason matches " + "'primary donor ... slack ... target_added_px ...'" + ), + } + + # no donor candidates — sibling 자체 없거나 모두 자격 미달 + if "no donor candidates" in reason_lower: + return { + "failure_type": "no_donor_candidates", + "classification_rule": ( + "plan.feasible=False AND failure_reason matches " + "'no donor candidates'" + ), + } + + # 위 두 패턴 미매칭 — 보수적으로 no_donor_candidates 로 분류 + # (donor 가 거의 모두 막힌 경우 와 구조적으로 비슷) + return { + "failure_type": "no_donor_candidates", + "classification_rule": ( + f"plan.feasible=False, failure_reason did not match known patterns. " + f"defaulting to 'no_donor_candidates'. raw failure_reason: {reason!r}" + ), + } + + # case 3 : plan feasible AND rerender 했는데 visual fail + if retry_trace.get("rerender_attempted") and not retry_trace.get("retry_passed"): + return { + "failure_type": "rerender_still_fails", + "classification_rule": ( + "plan.feasible=True AND rerender_attempted=True AND retry_passed=False" + ), + } + + # case 4 (defensive) : 어떤 case 에도 안 잡힘 — 보수적 fallback + return { + "failure_type": "not_attempted", + "classification_rule": ( + "no failure pattern matched (defensive fallback). retry_trace 구조 " + "예상과 다름 — 검토 필요" + ), + } + + +# ─── router ────────────────────────────────────────────────────── + + +def route_retry_failure(failure_type: str) -> dict: + """failure_type → next_proposed_action mapping. + + Returns: + dict : + next_proposed_action + next_action_rationale + next_action_implementation_status + mapping_source + """ + next_action = NEXT_ACTION_BY_FAILURE.get(failure_type) + if next_action is None: + return { + "next_proposed_action": None, + "next_action_rationale": ( + f"failure_type '{failure_type}' has no mapping in NEXT_ACTION_BY_FAILURE" + ), + "next_action_implementation_status": "unknown", + "mapping_source": "no mapping (unknown failure_type)", + } + return { + "next_proposed_action": next_action, + "next_action_rationale": NEXT_ACTION_RATIONALE.get(failure_type, ""), + "next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get( + next_action, "unknown" + ), + "mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)", + } + + +# ─── enrichment wrapper ────────────────────────────────────────── + + +def enrich_retry_trace_with_failure_classification(retry_trace: dict) -> dict: + """retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가. + + Mutates retry_trace in place AND returns it. + + retry_passed=True 인 경우 → 두 필드 모두 None (failure 없음, escalation 없음). + """ + fc = classify_retry_failure(retry_trace) + if fc is None: + # retry succeeded — no failure to classify + retry_trace["failure_classification"] = None + retry_trace["next_action_proposal"] = None + return retry_trace + + failure_type = fc["failure_type"] + nr = route_retry_failure(failure_type) + + retry_trace["failure_classification"] = { + "failure_type": failure_type, + "failure_type_description": FAILURE_TYPE_DESCRIPTIONS.get(failure_type, ""), + "classification_rule": fc["classification_rule"], + } + retry_trace["next_action_proposal"] = nr + return retry_trace diff --git a/src/phase_z2_mapper.py b/src/phase_z2_mapper.py new file mode 100644 index 0000000..d3ceb9c --- /dev/null +++ b/src/phase_z2_mapper.py @@ -0,0 +1,609 @@ +"""Phase Z-2 contract-based generic mapper (v0). + +frame 별 hand-coded mapper 의 대체 — catalog `frame_contracts.yaml` 에 선언된 +source_shape / cardinality / role_order / payload builder 를 읽고 +MdxSection → slot_payload 변환. + +원칙 : + - frame ↔ mapper 의 binding = catalog 가 결정 (Python registry hardcoded X) + - cardinality / role_order / payload 형태 = catalog + - reusable primitive : ITEM_PARSERS / COLUMN_BODY_PARSERS / PAYLOAD_BUILDERS named registry + - cardinality strict 위반 → FitError → fallback path 신호 (AI restructuring 후보) + +dispatch 모델 : + contract.payload.builder = named entry of PAYLOAD_BUILDERS + builder 가 (section, units, contract) → slot_payload dict 산출 + builder 내부에서 ITEM_PARSERS / COLUMN_BODY_PARSERS 등 sub-primitive 호출 + +v0 등록 frame : + - F13 (three_parallel_requirements) → builder=items_with_role / item_parser=pillar_item + - F29 (process_product_two_way) → builder=process_product_pair / column body parsers +F16 는 다음 step. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Callable + +import yaml + + +PROJECT_ROOT = Path(__file__).parent.parent +CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml" + + +class FitError(Exception): + """Contract 위반 — fallback path (AI restructuring) 로 넘어가야 하는 신호. + + cardinality 위반 / source_shape mismatch 등. message 에 위반 이유 명시. + """ + + +# ─── Catalog loading ────────────────────────────────────────────── + +_CATALOG_CACHE: dict | None = None + + +def load_frame_contracts() -> dict: + global _CATALOG_CACHE + if _CATALOG_CACHE is None: + _CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {} + return _CATALOG_CACHE + + +def get_contract(template_id: str) -> dict | None: + return load_frame_contracts().get(template_id) + + +# ─── Source-shape splitters ────────────────────────────────────── + +def _split_top_bullets(content: str) -> list[tuple[str, list[str]]]: + """top-level bullet groups → [(top_line, nested_lines), ...].""" + groups = [] + cur_top, cur_nested = None, [] + for line in content.splitlines(): + if not line.strip(): + continue + if re.match(r"^[\*\-]\s", line): + if cur_top is not None: + groups.append((cur_top, cur_nested)) + cur_top, cur_nested = line, [] + elif line.startswith(" ") and cur_top is not None: + cur_nested.append(line) + if cur_top is not None: + groups.append((cur_top, cur_nested)) + return groups + + +def _split_h3_subsections(content: str) -> list[tuple[str, str]]: + """### N(.N) TITLE 단위 split → [(title, body), ...]. + + body = subsection 내부 (### 다음 줄 ~ 다음 ### 직전). + """ + pattern = re.compile(r"^###\s+(\d+(?:\.\d+)?)\s+(.+?)$", re.MULTILINE) + matches = list(pattern.finditer(content)) + units = [] + for i, m in enumerate(matches): + title = m.group(2).strip() + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(content) + body = content[start:end].strip() + units.append((title, body)) + return units + + +def split_source(source_shape: str, content: str) -> list: + if source_shape == "top_bullets": + return _split_top_bullets(content) + if source_shape == "h3_subsections": + return _split_h3_subsections(content) + raise ValueError( + f"Contract supports source_shape in (top_bullets, h3_subsections). " + f"got '{source_shape}'." + ) + + +# ─── Shared text helpers ────────────────────────────────────────── + +def _split_label_for_bar(label: str) -> tuple[str, str]: + """'기술(디지털)' → ('기술', '(디지털)'). 괄호 없으면 (label, '').""" + m = re.match(r"^([^(]+?)\s*(\([^)]+\))\s*$", label.strip()) + if m: + return m.group(1).strip(), m.group(2).strip() + return label.strip(), "" + + +def _extract_bold_or_plain(top_line: str) -> str: + bold = re.search(r"\*\*(.+?)\*\*", top_line) + if bold: + return bold.group(1).strip() + return top_line.strip().lstrip("*-").strip() + + +def _text_lines_with_indent(nested_lines: list[str], base_indent: int = 0) -> list[dict]: + text_lines = [] + for line in nested_lines: + if not line.strip(): + continue + s = line.strip() + if s in ("
", "
", "---"): + continue + if not re.match(r"^[\*\-]\s", s): + continue + indent = len(line) - len(line.lstrip()) + rel = max(0, indent - base_indent) + indent_level = max(0, rel // 2) + text = re.sub(r"^[\*\-]\s+", "", s) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text_lines.append({"text": text, "indent": indent_level}) + return text_lines + + +def _extract_markdown_table(content: str) -> tuple[list[dict] | None, str]: + """Markdown 표 → [{from, to}] (column 1 = from, column 3 = to). + + AS-IS / TO-BE 형식의 3-column 표 (from | arrow | to) 를 transforms 로 변환. + Returns (transforms_or_None, content_without_table). + """ + pattern = re.compile( + r"(^[ \t]*\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|\n(?:[ \t]*\|[^\n]+\|\n?)+)", + re.MULTILINE, + ) + m = pattern.search(content) + if not m: + return None, content + rows = [r.strip() for r in m.group(1).strip().splitlines() if r.strip()] + transforms = [] + for r in rows[2:]: + cells = [c.strip() for c in r.strip("|").split("|")] + if len(cells) >= 3: + f = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[0]) + t = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[2]) + transforms.append({"from": f, "to": t}) + remaining = content[:m.start()] + content[m.end():] + return (transforms or None), remaining + + +# ─── Item parser primitives (top-bullet 단위) ───────────────────── + +def _parse_nested_pillar_sections(nested_lines: list[str]) -> list[dict]: + """Pillar nested → [{heading, text_lines}, ...].""" + sections = [] + cur_heading = None + cur_text_lines: list[dict] = [] + section_base_indent: int | None = None + + for line in nested_lines: + if not line.strip(): + continue + indent = len(line) - len(line.lstrip()) + stripped = line.strip() + if not re.match(r"^[\*\-]\s", stripped): + continue + + if section_base_indent is None or indent <= section_base_indent: + if cur_heading is not None: + sections.append({"heading": cur_heading, "text_lines": cur_text_lines}) + bold = re.search(r"\*\*(.+?)\*\*", stripped) + cur_heading = (bold.group(1).strip() if bold + else stripped.lstrip("*-").strip()) + cur_text_lines = [] + section_base_indent = indent + else: + rel_indent = indent - section_base_indent + indent_level = max(0, (rel_indent - 2) // 2) + text = re.sub(r"^[\*\-]\s+", "", stripped) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + cur_text_lines.append({"text": text, "indent": indent_level}) + + if cur_heading is not None: + sections.append({"heading": cur_heading, "text_lines": cur_text_lines}) + return sections + + +def parse_pillar_item(unit: tuple[str, list[str]]) -> dict: + """F13 pillar — bold = label, label 분해, nested = sections.""" + top_line, nested_lines = unit + label = _extract_bold_or_plain(top_line) + label_main, label_paren = _split_label_for_bar(label) + sections = _parse_nested_pillar_sections(nested_lines) + return { + "label": label, + "label_main": label_main, + "label_paren": label_paren, + "sections": sections, + } + + +def parse_quadrant_item(unit: tuple[str, list[str]]) -> dict: + """F16 quadrant — bold = label, nested = body (text_lines flat list, no heading). + + F13 pillar 와의 차이 : + - pillar_item 은 nested 안에서 heading + text_lines 계층 분리 + - quadrant_item 은 nested 전체를 하나의 text_lines list 로 (heading 없음) + Returns: + {label, body: [{text, indent}, ...]} + """ + top_line, nested_lines = unit + label = _extract_bold_or_plain(top_line) + non_empty = [l for l in nested_lines if l.strip()] + base = min((len(l) - len(l.lstrip()) for l in non_empty), default=0) + body = _text_lines_with_indent(nested_lines, base_indent=base) + return {"label": label, "body": body} + + +ITEM_PARSERS: dict[str, Callable] = { + "pillar_item": parse_pillar_item, + "quadrant_item": parse_quadrant_item, +} + + +# ─── Column body parsers (h3 subsection body 단위) ──────────────── + +def _parse_column_sections(body: str, transform_first: bool) -> list[dict]: + """Column body → list of sections. + + transform_first=True 면 첫 top-bullet 의 nested 안에 markdown table 이 있으면 + text_lines 대신 transforms 로 산출 (AS-IS/TO-BE). + """ + groups = _split_top_bullets(body) + sections = [] + for i, (top_line, nested_lines) in enumerate(groups): + title = _extract_bold_or_plain(top_line) + if i == 0 and transform_first: + nested_text = "\n".join(nested_lines) + transforms, _ = _extract_markdown_table(nested_text) + if transforms: + sections.append({"title": title, "transforms": transforms}) + continue + non_empty = [l for l in nested_lines if l.strip()] + base = min((len(l) - len(l.lstrip()) for l in non_empty), default=0) + sections.append({ + "title": title, + "text_lines": _text_lines_with_indent(nested_lines, base_indent=base), + }) + return sections + + +def parse_column_with_transform(body: str) -> list[dict]: + """첫 top-bullet 이 AS-IS/TO-BE 표 가능 (F29 process column).""" + return _parse_column_sections(body, transform_first=True) + + +def parse_column_plain(body: str) -> list[dict]: + """모두 일반 text_lines section (F29 product column).""" + return _parse_column_sections(body, transform_first=False) + + +COLUMN_BODY_PARSERS: dict[str, Callable] = { + "column_with_transform": parse_column_with_transform, + "column_plain": parse_column_plain, +} + + +# ─── Payload builders (named registry — top-level dispatch) ─────── + +def _resolve_title(section, payload_spec: dict, contract: dict) -> dict: + """payload.title.source 처리 — v0 = section.title 만 지원.""" + title_spec = payload_spec.get("title", {}) or {} + src = title_spec.get("source") + if src is None: + return {} + if src == "section.title": + return {"title": section.title} + raise ValueError( + f"Contract '{contract['template_id']}' has unsupported title source " + f"'{src}'. v0 supports 'section.title' only." + ) + + +def _build_items_with_role(section, units, contract) -> dict: + """F13-style — top_bullets 각 → array item, role_order[i] 가 item.role_field 채움. + + builder_options : + item_parser : ITEM_PARSERS key + array_root : payload[array_root] 에 list 부착 + role_field : item dict 에 role 부착할 key (선택) + """ + options = contract["payload"]["builder_options"] + parser_name = options["item_parser"] + parser = ITEM_PARSERS.get(parser_name) + if parser is None: + raise ValueError( + f"Contract '{contract['template_id']}' references item_parser='{parser_name}' " + f"but ITEM_PARSERS has no such entry." + ) + + role_order = contract.get("role_order", []) or [] + role_field = options.get("role_field") + + items = [] + for i, unit in enumerate(units): + item = parser(unit) + if role_field and i < len(role_order): + item[role_field] = role_order[i] + items.append(item) + + payload: dict = {} + payload.update(_resolve_title(section, contract["payload"], contract)) + payload[options["array_root"]] = items + return payload + + +def _build_process_product_pair(section, units, contract) -> dict: + """F29-style — h3 subsections 2 개 = 2 명명 column. + + builder_options : + pad_sections_to : N (sections list 길이 강제 — 미달 시 빈 section 으로 채움) + columns : list of + - title_to : subsection title → payload[title_to] + body_to : parsed sections → payload[body_to] = {"sections": [...]} + body_parser : COLUMN_BODY_PARSERS key + pad_empty : empty section template (선택, default = {"title": "", "text_lines": []}) + """ + options = contract["payload"]["builder_options"] + pad_to = options.get("pad_sections_to") + cols = options["columns"] + + if len(units) < len(cols): + raise FitError( + f"Contract '{contract['template_id']}' builder process_product_pair needs " + f"{len(cols)} subsection units, got {len(units)} in section " + f"'{getattr(section, 'section_id', '?')}'." + ) + + payload: dict = {} + payload.update(_resolve_title(section, contract["payload"], contract)) + + for i, col in enumerate(cols): + sub_title, sub_body = units[i] + parser_name = col["body_parser"] + parser = COLUMN_BODY_PARSERS.get(parser_name) + if parser is None: + raise ValueError( + f"Contract '{contract['template_id']}' references column body_parser=" + f"'{parser_name}' but COLUMN_BODY_PARSERS has no such entry." + ) + sections_list = parser(sub_body) + if pad_to is not None: + empty_template = col.get("pad_empty", {"title": "", "text_lines": []}) + while len(sections_list) < pad_to: + sections_list.append(dict(empty_template)) + sections_list = sections_list[:pad_to] + payload[col["title_to"]] = sub_title + payload[col["body_to"]] = {"sections": sections_list} + + return payload + + +def _build_quadrant_flat_slots(section, units, contract) -> dict: + """F16-style — top_bullets 각 → flat keyed slots (quadrant_N_label / quadrant_N_body). + + F13/F29 와의 차이 = output shape 가 array 도 named columns 도 아닌 flat keyed. + role/position 은 index 1..N 으로 implicit (1=TL, 2=TR, 3=BL, 4=BR — partial template 결정). + + builder_options : + item_parser : ITEM_PARSERS key (각 unit → {label, body} dict 산출) + pad_to : N (units 수 < N 이면 빈 slot 으로 채움) + truncate_at : M (units 수 > M 이면 M+1 부터 무시 + _truncated_count 기록) + label_key_pattern : "quadrant_{n}_label" (n = 1-based index) + body_key_pattern : "quadrant_{n}_body" + empty_label : pad slot 의 label 값 (default = "") + empty_body : pad slot 의 body 값 (default = []) + """ + options = contract["payload"]["builder_options"] + parser_name = options["item_parser"] + parser = ITEM_PARSERS.get(parser_name) + if parser is None: + raise ValueError( + f"Contract '{contract['template_id']}' references item_parser='{parser_name}' " + f"but ITEM_PARSERS has no such entry." + ) + + pad_to = options.get("pad_to", 4) + truncate_at = options.get("truncate_at", pad_to) + label_key = options.get("label_key_pattern", "quadrant_{n}_label") + body_key = options.get("body_key_pattern", "quadrant_{n}_body") + empty_label = options.get("empty_label", "") + empty_body = options.get("empty_body", []) + + payload: dict = {} + payload.update(_resolve_title(section, contract["payload"], contract)) + + visible_units = list(units[:truncate_at]) + parsed = [parser(u) for u in visible_units] + + for i in range(pad_to): + n = i + 1 + if i < len(parsed): + payload[label_key.format(n=n)] = parsed[i]["label"] + payload[body_key.format(n=n)] = parsed[i]["body"] + else: + payload[label_key.format(n=n)] = empty_label + # list / dict default 는 항상 새 객체 — shared reference 방지 + payload[body_key.format(n=n)] = list(empty_body) if isinstance(empty_body, list) else empty_body + + if len(units) > truncate_at: + payload["_truncated_count"] = len(units) - truncate_at + + return payload + + +PAYLOAD_BUILDERS: dict[str, Callable] = { + "items_with_role": _build_items_with_role, + "process_product_pair": _build_process_product_pair, + "quadrant_flat_slots": _build_quadrant_flat_slots, +} + + +# ─── Generic mapper (single dispatch via builder) ──────────────── + +def _check_cardinality(contract: dict, units: list, section) -> None: + card = contract.get("cardinality", {}) or {} + n = len(units) + strict = card.get("strict") + if strict is not None and n != strict: + raise FitError( + f"Contract '{contract['template_id']}' expects strict {strict} units " + f"(source_shape={contract['source_shape']}), got {n} " + f"in section '{getattr(section, 'section_id', '?')}'. " + f"overflow_policy={card.get('overflow_policy', 'abort_or_review')}." + ) + mn = card.get("min") + if mn is not None and n < mn: + raise FitError( + f"Contract '{contract['template_id']}' expects min {mn} units, got {n} " + f"in section '{getattr(section, 'section_id', '?')}'." + ) + mx = card.get("max") + if mx is not None and n > mx: + raise FitError( + f"Contract '{contract['template_id']}' expects max {mx} units, got {n} " + f"in section '{getattr(section, 'section_id', '?')}'." + ) + + +def compute_capacity_fit(template_id: str, content: str) -> dict: + """Content 의 item_count vs template contract capacity 비교 (planner 단계 사전 검사). + + 목적 : 자동 파이프라인이 "이 frame 에 이 content 넣으면 잘린다 / 안 맞는다" 를 + render 전에 미리 알도록. silent truncate / FitError 차단의 입력 신호. + + Returns: + dict with : + item_count : source_shape 으로 split 한 unit 수 + source_shape : contract 의 source_shape ('top_bullets' / 'h3_subsections' / ...) + capacity : {strict, min, max, truncate_at, pad_to} (없는 키는 None) + fit_status : 'ok' / 'strict_mismatch' / 'exceeds_max' / 'below_min' / + 'exceeds_truncate' / 'no_contract' / 'unknown_source_shape' + mismatch_reason : str | None — fit_status != 'ok' 일 때 이유 + + fit 룰 (자동 파이프라인이 silent loss 방지하기 위한 보수적 규칙): + 1. strict cardinality 가 있으면 정확히 일치해야 함 + 2. max 가 있으면 그 이하 + 3. min 이 있으면 그 이상 + 4. truncate_at 이 있으면 그 이하 (초과 시 builder 가 자르므로 = 콘텐츠 손실) + 5. pad_to 만 있고 item_count 가 부족 → mismatch 아님 (빈 slot 으로 채워질 뿐, 손실 X) + """ + contract = get_contract(template_id) + if contract is None: + return { + "item_count": None, + "source_shape": None, + "capacity": {"strict": None, "min": None, "max": None, + "truncate_at": None, "pad_to": None}, + "fit_status": "no_contract", + "mismatch_reason": ( + f"no contract for template_id='{template_id}' — capacity check skipped. " + f"이 candidate 는 catalog-only dispatch 의 ValueError 가 mapper 단계에서 발생할 것." + ), + } + + source_shape = contract.get("source_shape") + try: + units = split_source(source_shape, content) + except ValueError: + return { + "item_count": None, + "source_shape": source_shape, + "capacity": {"strict": None, "min": None, "max": None, + "truncate_at": None, "pad_to": None}, + "fit_status": "unknown_source_shape", + "mismatch_reason": f"source_shape='{source_shape}' is not supported by split_source().", + } + item_count = len(units) + + cardinality = contract.get("cardinality") or {} + strict = cardinality.get("strict") + mn = cardinality.get("min") + mx = cardinality.get("max") + + builder_options = (contract.get("payload") or {}).get("builder_options") or {} + truncate_at = builder_options.get("truncate_at") + pad_to = builder_options.get("pad_to") + + capacity = { + "strict": strict, + "min": mn, + "max": mx, + "truncate_at": truncate_at, + "pad_to": pad_to, + } + + if strict is not None and item_count != strict: + return { + "item_count": item_count, + "source_shape": source_shape, + "capacity": capacity, + "fit_status": "strict_mismatch", + "mismatch_reason": ( + f"strict cardinality {strict}, content has {item_count} items. " + f"mapper 가 FitError 를 raise 할 것." + ), + } + if mx is not None and item_count > mx: + return { + "item_count": item_count, + "source_shape": source_shape, + "capacity": capacity, + "fit_status": "exceeds_max", + "mismatch_reason": f"max cardinality {mx}, content has {item_count} items.", + } + if mn is not None and item_count < mn: + return { + "item_count": item_count, + "source_shape": source_shape, + "capacity": capacity, + "fit_status": "below_min", + "mismatch_reason": f"min cardinality {mn}, content has {item_count} items.", + } + if truncate_at is not None and item_count > truncate_at: + return { + "item_count": item_count, + "source_shape": source_shape, + "capacity": capacity, + "fit_status": "exceeds_truncate", + "mismatch_reason": ( + f"builder truncate_at {truncate_at}, content has {item_count} items " + f"({item_count - truncate_at} would be silently dropped). " + f"silent truncate 방지 위해 자동 선택 X." + ), + } + + return { + "item_count": item_count, + "source_shape": source_shape, + "capacity": capacity, + "fit_status": "ok", + "mismatch_reason": None, + } + + +def map_with_contract(section, contract: dict) -> dict: + """MdxSection + contract → slot_payload via named PAYLOAD_BUILDERS dispatch. + + Steps : + 1. source_shape 따라 raw_content split → units + 2. cardinality check (위반 → FitError) + 3. payload.builder 의 named entry 조회 → builder(section, units, contract) + """ + units = split_source(contract["source_shape"], section.raw_content) + _check_cardinality(contract, units, section) + + payload_spec = contract["payload"] + builder_name = payload_spec.get("builder") + if not builder_name: + raise ValueError( + f"Contract '{contract['template_id']}' missing payload.builder. " + f"available: {sorted(PAYLOAD_BUILDERS.keys())}" + ) + builder = PAYLOAD_BUILDERS.get(builder_name) + if builder is None: + raise ValueError( + f"Contract '{contract['template_id']}' references payload.builder=" + f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. " + f"available: {sorted(PAYLOAD_BUILDERS.keys())}" + ) + return builder(section, units, contract) diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py new file mode 100644 index 0000000..2d03df8 --- /dev/null +++ b/src/phase_z2_pipeline.py @@ -0,0 +1,1227 @@ +"""Phase Z-2 MVP-1.5b — single slide + Type B + frame-derived adapted blocks. + +원래 Phase Z 설계 복귀 (멀티-슬라이드 / native-fit 모두 폐기) : +- MDX 1 = slide 1 +- slide-base → slide-body → layout preset (Type B) → zones[] → frame-derived block (zone-compatible adapt) +- frame은 시각 언어 / slot 구성 / 패턴의 source. native geometry 통째 삽입 X. +- AI 는 layout / zone / frame / variant 선택에 관여 X — code / catalog 가 결정. + +MVP-1.5b spec : +- 대상 : MDX 03 (회귀) +- 출력 : data/runs/{run_id}/phase_z2/final.html (single slide) +- AI : 미사용 — MDX → slot_payload 결정론적 매핑 +- status : matched_zone only — non-matched 발생 시 abort + error.json +- layout : 2 sections → Type B (top + bottom zones) +- Frame partials : templates/phase_z2/families/{template_id}.html (Figma 시각 언어 promote, geometry adapt) +- Assets : render time copy → data/runs/{run_id}/phase_z2/assets/{template_id}/ + +상세 설계 : +- docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md § 17 (frame-derived partial promotion + zone-compatible adapt) + +이전 실험 실패 기록 : +- mvp1_test5 : scaffold 임의 — frame 느낌 부재 +- mvp1.5_test3 : frame native 통째 — slide 대체 +- mvp1.5a_test1 : 멀티-슬라이드 — MDX 1=slide 1 위반 +- mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류 +""" + +import json +import re +import shutil +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import yaml +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from phase_z2_composition import ( + LAYOUT_PRESETS, + CompositionUnit, + plan_composition, +) +from phase_z2_mapper import ( + FitError, + compute_capacity_fit, + get_contract, + map_with_contract, +) +from phase_z2_classifier import classify_visual_runtime_check +from phase_z2_router import route_fit_classification +from phase_z2_retry import ( + DEFAULT_SAFETY_MARGIN_PX, + apply_retry_to_layout_css, + plan_zone_ratio_retry, +) +from phase_z2_failure_router import enrich_retry_trace_with_failure_classification + + +# ─── Constants ────────────────────────────────────────────────── + +PROJECT_ROOT = Path(__file__).parent.parent +TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2" +ASSETS_SOURCE_BASE = PROJECT_ROOT / "figma_to_html_agent" / "blocks" +V4_RESULT_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml" +RUNS_DIR = PROJECT_ROOT / "data" / "runs" + +# V4 label → Phase Z status (§ 7.4 매트릭스) +V4_LABEL_TO_PHASE_Z_STATUS = { + "use_as_is": "matched_zone", + "light_edit": "adapt_matched_zone", + "restructure": "extract_matched_zone", + "reject": "fallback_candidate", +} +MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"} +# adapt_matched_zone (V4 light_edit) = frame 구조 동일, 텍스트만 minor edit 필요. +# minor edit 정책 (mapper 의무) : +# 1. MDX item 수 < frame slot 수 → 빈 slot 그대로 (Jinja2 {% if %} 로 스킵) +# 2. MDX item 수 > frame slot 수 → 추가 item 누락 (truncate) — debug.json 에 기록 +# 3. 텍스트 길이 mismatch → 그대로 통과 (overflow 는 zone-fit + Selenium check 가 처리) +# 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future +# AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로. + +# Slide-body geometry (legacy contract) +SLIDE_BODY_HEIGHT = 590 +GRID_GAP = 12 + +# zone min-height fallback — contract 에 visual_hints.min_height_px 없을 때 사용. +# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이. +DEFAULT_ZONE_MIN_HEIGHT_PX = 100 + +# content_weight 계산 가중치 +CONTENT_WEIGHT_COEFFS = { + "text_per_chars": 800, # text_len / 800 = score + "top_bullet": 0.4, + "nested_bullet": 0.15, + "table_bonus": 1.5, + "subsection": 0.6, +} + + +# ─── Data classes ─────────────────────────────────────────────── + +@dataclass +class MdxSection: + section_id: str + section_num: int + title: str + raw_content: str + + +@dataclass +class V4Match: + section_id: str + frame_id: str + frame_number: int + template_id: str + confidence: float + label: str + + +def to_phase_z_status(match: V4Match) -> str: + return V4_LABEL_TO_PHASE_Z_STATUS.get(match.label, "unknown") + + +# ─── MDX parsing ──────────────────────────────────────────────── + +def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]: + """basic MDX parser — ## level sections only. V4 무관 (matching artifact 모름). + + section.raw_content 에 ### sub-section 그대로 포함. V4 granularity 와 align 은 + align_sections_to_v4_granularity() 가 처리. + """ + text = mdx_path.read_text(encoding="utf-8") + + fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) + slide_title = "" + if fm_match: + fm = yaml.safe_load(fm_match.group(1)) + slide_title = fm.get("title", "") + text = text[fm_match.end():] + + footer_match = re.search(r":::note\[[^\]]*\]\n(.*?)\n:::", text, re.DOTALL) + footer_text = None + if footer_match: + body = footer_match.group(1) + bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body) + footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip()) + text = text[:footer_match.start()] + text[footer_match.end():] + + sections = [] + section_pattern = re.compile(r"^##\s+(\d+)\.\s+(.+?)$", re.MULTILINE) + matches = list(section_pattern.finditer(text)) + + mdx_num_match = re.match(r"(\d+)", mdx_path.stem) + mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00" + + for i, m in enumerate(matches): + section_num = int(m.group(1)) + title_text = m.group(2).strip() + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + raw_content = text[start:end].strip() + sections.append(MdxSection( + section_id=f"{mdx_id}-{section_num}", + section_num=section_num, + title=f"{section_num}. {title_text}", + raw_content=raw_content, + )) + + return slide_title, sections, footer_text + + +# ─── V4 lookup ────────────────────────────────────────────────── + +def load_v4_result() -> dict: + return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8")) + + +def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]: + """V4 section granularity 에 맞춰 sections 조정. + + 각 section 에 대해 : + - V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭) + - V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill + - V4 에 키 없고 ### 도 없음 → 원본 그대로 (V4 lookup 단계에서 자연스럽게 abort) + + 설계 원칙 : + - parser (parse_mdx) = MDX 만 앎 (V4 무관) + - aligner (이 함수) = V4 키 기준 granularity 결정 + - runtime parser 가 matching artifact 의 granularity 를 *따라가는* 구조 + """ + v4_keys = set(v4.get("mdx_sections", {}).keys()) + aligned: list[MdxSection] = [] + + for section in sections: + if section.section_id in v4_keys: + aligned.append(section) + continue + + # ### drill 시도 + sub_pattern = re.compile(r"^###\s+(\d+\.\d+)\s+(.+?)$", re.MULTILINE) + sub_matches = list(sub_pattern.finditer(section.raw_content)) + if not sub_matches: + aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨 + continue + + # ### sub-section 추출 + mdx_id = section.section_id.split("-")[0] # e.g., "04" + for i, m in enumerate(sub_matches): + subnum = m.group(1) # e.g., "2.1" + sub_title = m.group(2).strip() + start = m.end() + end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(section.raw_content) + raw = section.raw_content[start:end].strip() + aligned.append(MdxSection( + section_id=f"{mdx_id}-{subnum}", # e.g., "04-2.1" + section_num=section.section_num, + title=f"{subnum} {sub_title}", + raw_content=raw, + )) + + return aligned + + +def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]: + sec = v4.get("mdx_sections", {}).get(section_id) + if not sec: + return None + judgments = sec.get("judgments_full32", []) + if not judgments: + return None + top = judgments[0] + return V4Match( + section_id=section_id, + frame_id=str(top["frame_id"]), + frame_number=int(top["frame_number"]), + template_id=top["template_id"], + confidence=float(top["confidence"]), + label=top["label"], + ) + + +# ─── Content weight + zone layout 계산 ───────────────────────── +# layout preset 선택은 phase_z2_composition.select_layout_preset (composition v0) 가 담당. +# 본 모듈의 select_layout_preset 은 이전 단순 count-based 구현이었고 dead code 로 제거 (2026-04-29). + +def compute_content_weight(section: MdxSection) -> dict: + """Section 의 콘텐츠 부피 측정 — text/bullet/table/subsection 합성 score.""" + text = section.raw_content + lines = text.splitlines() + + text_len = len(text) + top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l)) + nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l)) + has_table = bool(re.search(r"\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|", text)) + subsections = len(re.findall(r"^###\s", text, re.MULTILINE)) + + c = CONTENT_WEIGHT_COEFFS + score = ( + text_len / c["text_per_chars"] + + top_bullets * c["top_bullet"] + + nested_bullets * c["nested_bullet"] + + (c["table_bonus"] if has_table else 0) + + subsections * c["subsection"] + ) + return { + "score": round(score, 3), + "text_length": text_len, + "top_bullets": top_bullets, + "nested_bullets": nested_bullets, + "has_table": has_table, + "subsection_count": subsections, + } + + +def compute_zone_layout(zones_data: list[dict], + total_height: int = SLIDE_BODY_HEIGHT, + gap: int = GRID_GAP) -> dict: + """zone height 계산 — frame_min_height_px 우선 + 남은 공간 content_weight 비율 분배. + + Returns dict with per-zone heights + reasoning trace. + """ + n = len(zones_data) + if n == 0: + return {"heights_px": [], "ratios": [], "zones": []} + + available = total_height - gap * (n - 1) + + # Step 1: 각 zone 의 min_height 할당 — pipeline 가 zones_data 에 frame contract 의 + # visual_hints.min_height_px 를 미리 주입했음. 없으면 DEFAULT_ZONE_MIN_HEIGHT_PX. + min_heights = [ + z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX) + for z in zones_data + ] + total_min = sum(min_heights) + min_scaled = False + if total_min > available: + scale = available / total_min + min_heights = [int(m * scale) for m in min_heights] + total_min = sum(min_heights) + min_scaled = True + + remaining = available - total_min + + # Step 2: 남은 공간을 content_weight 비율로 분배 + weights = [z["content_weight"]["score"] for z in zones_data] + total_w = sum(weights) if sum(weights) > 0 else n + extras = [int(round(remaining * (w / total_w))) for w in weights] + + # Step 3: rounding 보정 (마지막 zone 잔여 흡수) + heights = [m + e for m, e in zip(min_heights, extras)] + diff = available - sum(heights) + if diff != 0 and heights: + heights[-1] += diff + + ratios = [round(h / total_height, 3) for h in heights] + + return { + "computation": "min_height_first + content_weight_distribution", + "slide_body_height": total_height, + "gap": gap, + "available_after_gap": available, + "min_heights_px": min_heights, + "min_scaled": min_scaled, + "total_min_height": total_min, + "remaining_after_min": remaining, + "content_weights": [{"position": z["position"], + "template_id": z["template_id"], + "score": w} + for z, w in zip(zones_data, weights)], + "weight_shares": [round(w / total_w, 3) for w in weights], + "extras_px": extras, + "heights_px": heights, + "ratios": ratios, + } + + +# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용. +# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29). + + +def build_layout_css(layout_preset: str, zones_data: list[dict], + gap: int = GRID_GAP) -> dict: + """Composition v0 layout preset → CSS grid 문자열. + + horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지 + (MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default. + 향후 cardinality_fit / density_score axis 가 score_candidate 에 들어가면 + cols/rows 도 dynamic 으로 확장 가능. + """ + preset = LAYOUT_PRESETS[layout_preset] + + if layout_preset == "horizontal-2": + zl = compute_zone_layout(zones_data, gap=gap) + rows = " ".join(f"{h}px" for h in zl["heights_px"]) + return { + "areas": preset["css_areas"], + "cols": preset["css_cols"], + "rows": rows, + "heights_px": zl["heights_px"], + "ratios": zl["ratios"], + "computation": zl["computation"], + "dynamic_rows": True, + "raw_zone_layout": zl, + } + + return { + "areas": preset["css_areas"], + "cols": preset["css_cols"], + "rows": preset["css_rows"], + "heights_px": [], + "ratios": [], + "computation": "fr_default_from_preset", + "dynamic_rows": False, + "raw_zone_layout": None, + } + + +# ─── Abort ────────────────────────────────────────────────────── + +def abort_with_error(run_dir: Path, section: MdxSection, + match: Optional[V4Match], stage: str, reason: str): + error_data = { + "section": {"id": section.section_id, "title": section.title}, + "frame": { + "id": match.frame_id if match else None, + "number": match.frame_number if match else None, + "template_id": match.template_id if match else None, + }, + "v4_label": match.label if match else None, + "phase_z_status": to_phase_z_status(match) if match else None, + "confidence": match.confidence if match else None, + "stage": stage, + "reason": reason, + } + run_dir.mkdir(parents=True, exist_ok=True) + err_path = run_dir / "error.json" + err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ {stage}", file=sys.stderr) + print(f" section : {section.section_id} — {section.title}", file=sys.stderr) + if match: + print(f" frame : {match.frame_id} ({match.template_id})", file=sys.stderr) + print(f" status : V4 label '{match.label}' → Phase Z '{to_phase_z_status(match)}'", file=sys.stderr) + print(f" reason : {reason}", file=sys.stderr) + print(f" error : {err_path}", file=sys.stderr) + sys.exit(1) + + +# ─── Slot mapping (catalog-only dispatch) ────────────────────── + +def _known_contract_ids() -> list[str]: + from phase_z2_mapper import load_frame_contracts + return list(load_frame_contracts().keys()) + + +def map_mdx_to_slots(section: MdxSection, template_id: str) -> dict: + """template_id → slot_payload via catalog contract only. + + F13/F29/F16 등 모든 frame 의 slot 구조 / cardinality / role / payload builder 는 + `templates/phase_z2/catalog/frame_contracts.yaml` 에 선언. legacy hand-coded + mapper / MAPPER_BY_TEMPLATE / COLOR_CLASS_BY_KEYWORD / 관련 helper 는 + F13/F29/F16 transition (2026-04-29) 후 모두 제거. + + template_id 가 catalog 에 없으면 ValueError — fallback 없음. + 새 frame 추가 = catalog yaml 에 entry 추가 + (필요시) 새 builder/parser 등록. + """ + contract = get_contract(template_id) + if contract is None: + raise ValueError( + f"No frame_contracts entry for template_id='{template_id}'. " + f"Add an entry in templates/phase_z2/catalog/frame_contracts.yaml. " + f"Known contracts: {sorted(_known_contract_ids())}." + ) + return map_with_contract(section, contract) + + +# ─── Asset copy ───────────────────────────────────────────────── + +def copy_assets(template_id: str, run_dir: Path) -> Optional[Path]: + """Frame asset (Figma) 폴더 복사 — frame_id 는 catalog contract 에서 도출. + + contract 에 `frame_id` 없으면 (asset 없는 frame) None 반환. + 이전엔 pipeline.py 에 TEMPLATE_TO_FRAME_ID Python dict 가 있었지만 catalog 로 이전 (2026-04-29). + """ + contract = get_contract(template_id) + frame_id = (contract or {}).get("frame_id") + if not frame_id: + return None + src = ASSETS_SOURCE_BASE / str(frame_id) / "assets" + if not src.exists(): + return None + dst = run_dir / "assets" / template_id + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + return dst + + +# ─── Render (single slide + Type B) ──────────────────────────── + +def _read_token_css() -> str: + token_dir = PROJECT_ROOT / "templates" / "styles" / "tokens" + files = ["typography.css", "spacing.css", "colors.css"] + parts = [] + for f in files: + path = token_dir / f + if path.exists(): + parts.append(f"/* === {f} === */\n{path.read_text(encoding='utf-8')}") + return "\n\n".join(parts) + + +def _attempt_zone_ratio_retry( + *, + run_dir: Path, + out_path: Path, + slide_title: str, + slide_footer: Optional[str], + zones_data: list[dict], + debug_zones: list[dict], + layout_preset: str, + layout_css: dict, + overflow: dict, + fit_classification: dict, + router_decision: dict, + gap_px: int, +) -> dict: + """A3 zone_ratio_retry orchestration. + + locked rules : + - retry budget = 1 + - slide-base / spacing / gap 고정 + - target zone height 만 증가, sibling donor 에서 같은 양 차감 + - donor 룰 strict (visual ok / capacity ok / slack > 0 / min_height 보존) + - (b) revert : redistribution fail 또는 rerender 후 visual fail 시 original final.html 그대로 + + Returns: + retry_trace dict (always returned, even when no retry attempted) with : + retry_attempted : bool + retry_action : 'zone_ratio_retry' or None + plan : phase_z2_retry.plan_zone_ratio_retry() 결과 (있을 때만) + rerender_attempted : bool + retry_passed : bool + retry_failure_reason : str or None + retried_candidate_path : str or None (rerender 한 경우 진단 artifact 경로) + post_retry_overflow : dict (retry_passed=True 일 때만) + post_retry_debug_zones : list (retry_passed=True 일 때만 — height_px 갱신본) + post_retry_layout_css : dict (retry_passed=True 일 때만) + """ + base_trace = { + "retry_attempted": False, + "retry_action": None, + "plan": None, + "rerender_attempted": False, + "retry_passed": False, + "retry_failure_reason": None, + "retried_candidate_path": None, + "safety_margin_px": DEFAULT_SAFETY_MARGIN_PX, + "policy": ( + "A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, " + "donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), " + "(b) revert on redistribution fail or rerender visual fail." + ), + } + + # 1. retry attempt 자체가 적절한지 판단 + if not router_decision.get("router_active"): + base_trace["retry_skipped_reason"] = "router_active=False (visual check passed — no overflow)" + return base_trace + + proposed = router_decision.get("proposed_actions_summary") or [] + if "zone_ratio_retry" not in proposed: + base_trace["retry_skipped_reason"] = ( + f"zone_ratio_retry not in proposed_actions {proposed} (다른 action category)" + ) + return base_trace + + # 2. plan + base_trace["retry_attempted"] = True + base_trace["retry_action"] = "zone_ratio_retry" + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=fit_classification, + router_decision=router_decision, + safety_margin_px=DEFAULT_SAFETY_MARGIN_PX, + ) + base_trace["plan"] = plan + + if plan is None: + base_trace["retry_failure_reason"] = "plan_zone_ratio_retry returned None — no target classification matched zone_ratio_retry" + return base_trace + + if not plan.get("feasible"): + # redistribution check 실패 → rerender 안 함, original final.html 그대로 + base_trace["retry_failure_reason"] = plan.get("failure_reason") + print( + f" retry : zone_ratio_retry redistribution INFEASIBLE — " + f"target {plan['target_zone_position']} needs {plan['target_added_px']}px, " + f"{plan.get('failure_reason')}" + ) + return base_trace + + # 3. feasible — apply plan to layout_css, rerender to candidate path (NOT final.html yet) + new_layout_css = apply_retry_to_layout_css( + layout_css, plan, zones_data, + total_height=SLIDE_BODY_HEIGHT, gap_px=gap_px, + ) + candidate_path = run_dir / "retried_candidate.html" + candidate_html = render_slide( + slide_title, slide_footer, zones_data, layout_preset, new_layout_css, gap_px=gap_px, + ) + candidate_path.write_text(candidate_html, encoding="utf-8") + base_trace["rerender_attempted"] = True + base_trace["retried_candidate_path"] = str(candidate_path.relative_to(PROJECT_ROOT)) + + print( + f" retry : zone_ratio_retry attempted — target {plan['target_zone_position']} " + f"+{plan['target_added_px']}px (donor {plan['donor_zone_position']} -{plan['donor_reduced_px']}px) " + f"→ rerender to retried_candidate.html → visual check" + ) + + # 4. 후 visual check on candidate + candidate_overflow = run_overflow_check(candidate_path) + + if candidate_overflow.get("passed", False): + # 성공 — final.html 을 candidate 로 promote + out_path.write_text(candidate_html, encoding="utf-8") + # debug_zones height_px / ratio 갱신 (post-retry 상태) + new_heights = new_layout_css["heights_px"] + new_ratios = new_layout_css["ratios"] + post_retry_debug_zones = [] + for i, dz in enumerate(debug_zones): + new_dz = dict(dz) + new_dz["height_px"] = new_heights[i] if i < len(new_heights) else dz.get("height_px") + new_dz["ratio"] = new_ratios[i] if i < len(new_ratios) else dz.get("ratio") + new_dz["zone_height_post_retry"] = True + post_retry_debug_zones.append(new_dz) + + base_trace["retry_passed"] = True + base_trace["post_retry_overflow"] = candidate_overflow + base_trace["post_retry_debug_zones"] = post_retry_debug_zones + base_trace["post_retry_layout_css"] = new_layout_css + print(f" retry : PASSED — final.html promoted to retried version") + return base_trace + + # 5. rerender 후에도 visual fail → (b) revert : final.html 은 original 그대로 (이미 written) + base_trace["retry_passed"] = False + base_trace["retry_failure_reason"] = ( + f"rerender visual_check failed: {candidate_overflow.get('fail_reasons')}. " + f"reverting to original final.html (retried_candidate.html stays as diagnostic only)." + ) + base_trace["candidate_overflow_summary"] = { + "passed": False, + "fail_reasons": candidate_overflow.get("fail_reasons", []), + } + print(f" retry : FAILED — candidate visual_check 도 실패. revert to original. ({candidate_path.name} 은 diagnostic 으로 보존)") + return base_trace + + +def render_slide(slide_title: str, slide_footer: Optional[str], + zones_data: list[dict], layout_preset: str, + layout_css: dict, gap_px: int = GRID_GAP) -> str: + """Single slide HTML — slide_base.html + 8-preset layout vocabulary. + + layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag. + Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입. + """ + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + autoescape=select_autoescape(["html"]), + ) + for zone in zones_data: + partial = env.get_template(f"families/{zone['template_id']}.html") + zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"]) + + base = env.get_template("slide_base.html") + return base.render( + slide_title=slide_title, + slide_footer=slide_footer, + zones=zones_data, + layout_preset=layout_preset, + layout_css=layout_css, + gap_px=gap_px, + token_css=_read_token_css(), + ) + + +# ─── Selenium check (single slide + per-zone) ────────────────── + +def run_overflow_check(html_path: Path) -> dict: + """Single slide + per-zone overflow + clipping check.""" + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.chrome.service import Service + + options = Options() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--window-size=1400,900") + + chromedriver_candidates = [ + PROJECT_ROOT / "chromedriver", + PROJECT_ROOT / "chromedriver.exe", + ] + driver = None + last_err = None + for path in chromedriver_candidates: + if path.is_file(): + try: + driver = webdriver.Chrome(service=Service(str(path)), options=options) + break + except Exception as e: + last_err = e + if driver is None: + try: + driver = webdriver.Chrome(options=options) + except Exception as e: + return {"passed": False, "error": f"selenium init failed: {last_err or e}"} + + try: + driver.get(html_path.resolve().as_uri()) + driver.set_window_size(1400, 900) + driver.implicitly_wait(1) + result = driver.execute_script(r""" + const measure = (el) => ({ + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + scrollWidth: el.scrollWidth, + scrollHeight: el.scrollHeight, + excess_x: Math.max(0, el.scrollWidth - el.clientWidth), + excess_y: Math.max(0, el.scrollHeight - el.clientHeight), + overflowed: (el.scrollWidth > el.clientWidth + 5) || + (el.scrollHeight > el.clientHeight + 5), + }); + const slide = document.querySelector('.slide'); + if (!slide) return { error: '.slide not found' }; + + const slideM = measure(slide); + slideM.size_correct = slide.clientWidth === 1280 && slide.clientHeight === 720; + + const body = document.querySelector('.slide-body'); + const bodyM = body ? measure(body) : null; + + const zones = []; + slide.querySelectorAll('.zone').forEach((z) => { + const pos = z.getAttribute('data-zone-position') || 'unknown'; + const tid = z.getAttribute('data-template-id') || '?'; + const m = measure(z); + m.position = pos; + m.template_id = tid; + + // 내부 clipping 검사 — frame-family root/cell 단위. + // tolerance / threshold 그대로. inner_content_signals 만 추가 보강 (detection 데이터 늘림). + const clipped = []; + z.querySelectorAll('[class*="f13b"], [class*="f29b"], [class*="f16b"]').forEach((el) => { + const dx = el.scrollWidth - el.clientWidth; + const dy = el.scrollHeight - el.clientHeight; + if (dx > 5 || dy > 5) { + // inner content signals — clipped cell 안에 *어떤 종류의 콘텐츠가 들어있는지* 보고. + // classifier 가 frame_internal_cell 만 봐서는 부족하니 inner 까지 본다. + const inner_signals = []; + if (el.querySelector('.transform-block, .transform-row, .transform-rows')) { + inner_signals.push('structural_unit'); + } + if (el.querySelector('table')) { + inner_signals.push('tabular'); + } + if (el.querySelector('.text-line')) { + inner_signals.push('text_flow'); + } + clipped.push({ + class_name: el.className, + inner_content_signals: inner_signals, + excess_x: Math.max(0, dx), + excess_y: Math.max(0, dy), + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + scrollWidth: el.scrollWidth, + scrollHeight: el.scrollHeight, + }); + } + }); + m.clipped_inner = clipped; + zones.push(m); + }); + + return { slide: slideM, slide_body: bodyM, zones }; + """) + + screenshot_path = html_path.parent / "preview.png" + try: + driver.save_screenshot(str(screenshot_path)) + result["screenshot"] = str(screenshot_path.relative_to(PROJECT_ROOT)) + except Exception as e: + result["screenshot_error"] = str(e) + finally: + driver.quit() + + if "error" in result: + return {"passed": False, **result} + + fail_reasons = [] + if not result["slide"]["size_correct"]: + fail_reasons.append( + f"slide size != 1280x720 (got {result['slide']['clientWidth']}x{result['slide']['clientHeight']})" + ) + if result["slide"]["overflowed"]: + fail_reasons.append( + f"slide overflowed by {result['slide']['excess_y']}px (vert) / {result['slide']['excess_x']}px (horiz)" + ) + if result.get("slide_body") and result["slide_body"]["overflowed"]: + fail_reasons.append( + f"slide-body overflowed by {result['slide_body']['excess_y']}px (vert)" + ) + for z in result["zones"]: + if z["overflowed"]: + fail_reasons.append( + f"zone--{z['position']} ({z['template_id']}) overflowed by {z['excess_y']}px (vert) / {z['excess_x']}px (horiz)" + ) + for c in z.get("clipped_inner", []): + fail_reasons.append( + f"zone--{z['position']}: inner clipped .{c['class_name']} — " + f"excess {c['excess_y']}px vert / {c['excess_x']}px horiz " + f"(content {c['scrollHeight']} vs container {c['clientHeight']})" + ) + + result["passed"] = len(fail_reasons) == 0 + result["fail_reasons"] = fail_reasons + return result + + +def write_overflow_error(run_dir: Path, overflow: dict) -> Path: + error_data = { + "stage": "visual_runtime_check", + "reason": "Visual runtime contract 위반 — slide / slide-body / zone overflow / clipping.", + "fail_reasons": overflow.get("fail_reasons", []), + "details": overflow, + } + err_path = run_dir / "error.json" + err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") + return err_path + + +# ─── Debug.json (single slide + zones[]) ─────────────────────── + +def compute_slide_status(sections: list[MdxSection], + units: list[CompositionUnit], + comp_debug: dict, + overflow: dict, + adapter_needed_units: Optional[list[dict]] = None, + debug_zones: Optional[list[dict]] = None) -> dict: + """Slide 산출물의 정확한 상태 계산 — 자동 파이프라인 결과 보고. + + 축 : + - rendered : final.html 이 디스크에 쓰였는가 + - visual_check_passed : Selenium per-zone overflow / clipping 통과 여부 + - full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered + - adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고) + - content_truncated_count : builder 가 truncate 한 zone 수 (informational) + + overall enum : + PASS — visual OK + full coverage + adapter_needed=0 + RENDERED_WITH_VISUAL_REGRESSION — full coverage 이지만 visual fail + PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK + PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다 + (adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용) + """ + aligned_ids = [s.section_id for s in sections] + covered = set() + for u in units: + covered.update(u.source_section_ids) + filtered_ids = sorted(set(aligned_ids) - covered) + full_coverage = len(filtered_ids) == 0 + visual_passed = bool(overflow.get("passed", False)) + + adapter_needed_units = list(adapter_needed_units or []) + content_truncated = [] + for z in (debug_zones or []): + tc = z.get("content_truncated_count") + if tc: + content_truncated.append({ + "position": z["position"], + "source_section_ids": z["source_section_ids"], + "template_id": z["v4_template_id"], + "truncated_count": tc, + }) + + # 필터된 section 의 사유 (auto pipeline 결정 트레이스 — review 개념 X) + filtered_section_reasons = [] + for c in comp_debug.get("candidates_summary", []): + if c.get("selection_state") == "selected": + continue + cand_ids = c.get("source_section_ids", []) + if any(sid in filtered_ids for sid in cand_ids): + filtered_section_reasons.append({ + "section_ids": cand_ids, + "merge_type": c.get("merge_type"), + "template_id": c.get("template_id"), + "v4_label": c.get("label"), + "phase_z_status": c.get("phase_z_status"), + "score": c.get("score"), + "selection_state": c.get("selection_state"), # filtered_status / filtered_weak / filtered_lost + "filter_reasons": c.get("filter_reasons", []), + }) + + if full_coverage and visual_passed: + overall = "PASS" + elif full_coverage and not visual_passed: + overall = "RENDERED_WITH_VISUAL_REGRESSION" + elif not full_coverage and visual_passed: + overall = "PARTIAL_COVERAGE" + else: + overall = "PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION" + + return { + "rendered": True, + "visual_check_passed": visual_passed, + "full_mdx_coverage": full_coverage, + "aligned_section_ids": aligned_ids, + "covered_section_ids": sorted(covered), + "filtered_section_ids": filtered_ids, + "filtered_section_reasons": filtered_section_reasons, + "visual_fail_reasons": list(overflow.get("fail_reasons") or []), + "adapter_needed_count": len(adapter_needed_units), + "adapter_needed_units": adapter_needed_units, + "content_truncated_count": len(content_truncated), + "content_truncated_units": content_truncated, + "overall": overall, + "note": ( + "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. " + "overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. " + "adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. " + "content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)." + ), + } + + +def write_debug_json(run_dir: Path, layout_preset: str, + debug_zones: list[dict], + layout_css: dict, + visual_runtime_check: Optional[dict] = None, + composition_debug: Optional[dict] = None, + slide_status: Optional[dict] = None, + fit_classification: Optional[dict] = None, + router_decision: Optional[dict] = None, + retry_trace: Optional[dict] = None) -> Path: + debug = { + "v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)), + "v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS, + "mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES), + "mode": "composition_v0_layout_8preset", + "mode_note": ( + "MVP-1.5b w/ composition planner v0 — sections → candidates (separate / " + "parent_merged) → score → greedy select → 8-preset layout vocabulary " + "(single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / " + "left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; " + "v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후." + ), + "layout_preset": layout_preset, + "layout_css": layout_css, + "slide_status": slide_status, + "fit_classification": fit_classification, + "router_decision": router_decision, + "retry_trace": retry_trace, + "composition_planner_debug": composition_debug, + "zones": debug_zones, + "visual_runtime_check": visual_runtime_check, + } + debug_path = run_dir / "debug.json" + debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8") + return debug_path + + +# ─── Main entry ──────────────────────────────────────────────── + +def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: + """MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary. + + Pipeline : + parse_mdx → align_sections_to_v4_granularity → plan_composition → + mapper per unit → render slide_base + frame partial → Selenium check + """ + mdx_path = Path(mdx_path) + if run_id is None: + run_id = time.strftime("%Y%m%d_%H%M%S") + "_phase_z2" + run_dir = RUNS_DIR / run_id / "phase_z2" + + print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}") + + # 1. Parse MDX (V4 무관) + slide_title, sections, slide_footer = parse_mdx(mdx_path) + print(f" parsed : title='{slide_title}', sections={len(sections)} " + f"({[s.section_id for s in sections]}), footer={'yes' if slide_footer else 'no'}") + + # 2. Load V4 + v4 = load_v4_result() + + # 3. Align sections to V4 granularity (### drill if needed) + sections = align_sections_to_v4_granularity(sections, v4) + print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})") + + # 4. Composition planner v0 — replaces per-section + select_layout_preset. + # candidate (separate / parent_merged) → score → greedy non-overlapping select → + # layout preset (count-based v0). + def lookup_fn(sid: str) -> Optional[V4Match]: + return lookup_v4_match(v4, sid) + + units, layout_preset, comp_debug = plan_composition( + sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES, + capacity_fit_fn=compute_capacity_fit, + ) + + if not units or layout_preset is None: + # composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는 + # status filter 통과 못 함. error.json 기록 후 abort. + run_dir.mkdir(parents=True, exist_ok=True) + error_data = { + "stage": "composition_planner", + "reason": ( + "Composition planner v0 selected 0 viable units. " + f"Either no V4 entries for any section, or all candidates filtered out by " + f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}." + ), + "aligned_section_ids": [s.section_id for s in sections], + "composition_debug": comp_debug, + } + err_path = run_dir / "error.json" + err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ composition_planner", file=sys.stderr) + print(f" reason : 0 viable units after composition v0", file=sys.stderr) + print(f" error : {err_path}", file=sys.stderr) + sys.exit(1) + + print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)") + for u in units: + print(f" unit : {u.source_section_ids} merge={u.merge_type} → " + f"frame {u.frame_number} ({u.frame_template_id}) " + f"label={u.label} score={u.score:.3f}") + + # 5. Per-unit: synthesize MdxSection → mapper → assets → zone data + # mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X. + positions = LAYOUT_PRESETS[layout_preset]["positions"] + zones_data = [] + debug_zones = [] + adapter_needed_units: list[dict] = [] + + for i, unit in enumerate(units): + position = positions[i] if i < len(positions) else f"zone_{i}" + synth_section = MdxSection( + section_id="+".join(unit.source_section_ids), + section_num=0, + title=unit.title, + raw_content=unit.raw_content, + ) + contract = get_contract(unit.frame_template_id) + builder_name = contract["payload"].get("builder") + visual_hints = contract.get("visual_hints") or {} + min_height_px = visual_hints.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX) + contract_frame_id = contract.get("frame_id") + + # mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip + try: + slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id) + except FitError as e: + adapter_record = { + "position": position, + "source_section_ids": unit.source_section_ids, + "merge_type": unit.merge_type, + "template_id": unit.frame_template_id, + "fit_error": str(e), + } + adapter_needed_units.append(adapter_record) + print(f" adapter : zone--{position} {unit.source_section_ids} → " + f"{unit.frame_template_id} FitError → adapter_needed (skip render)") + continue + + run_dir.mkdir(parents=True, exist_ok=True) + assets_dir = copy_assets(unit.frame_template_id, run_dir) + content_weight = compute_content_weight(synth_section) + truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우 + + zones_data.append({ + "position": position, + "template_id": unit.frame_template_id, + "slot_payload": slot_payload, + "content_weight": content_weight, + "min_height_px": min_height_px, + }) + debug_zones.append({ + "position": position, + "source_section_ids": unit.source_section_ids, + "merge_type": unit.merge_type, + "title": unit.title, + "v4_rank1_frame_id": unit.frame_id, + "v4_rank1_frame_number": unit.frame_number, + "v4_template_id": unit.frame_template_id, + "v4_label": unit.label, + "v4_confidence": unit.confidence, + "phase_z_status": unit.phase_z_status, + "composition_score": unit.score, + "composition_rationale": unit.rationale, + "composition_notes": list(unit.notes), # parent_merged_inferred 정보 신호 + "mapper_type": "contract", + "contract_id": unit.frame_template_id, + "contract_frame_id": contract_frame_id, + "builder": builder_name, + "min_height_px": min_height_px, + "slot_payload_keys": sorted(slot_payload.keys()), + "content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름) + "assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None, + "content_weight": content_weight, + }) + + # 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default + layout_css = build_layout_css(layout_preset, zones_data) + if layout_css["dynamic_rows"]: + for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]): + dz["height_px"] = h + dz["ratio"] = r + print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}") + else: + print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})") + + # 7. Render single slide + html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css) + + # 8. Write final.html + out_path = run_dir / "final.html" + out_path.write_text(html, encoding="utf-8") + print(f" html : {out_path}") + + # 9. Selenium check + print(" visual : running per-zone overflow check ...") + overflow = run_overflow_check(out_path) + + # 10. fit_classifier v0 (A1) — Selenium 결과 → spec §3 category 분류 layer. + # *분류만*. action / router / rerender X. behavior 변경 0. + fit_classification = classify_visual_runtime_check(overflow, debug_zones) + + # 11. overflow_router v0 (A2) — category → proposed_action 매핑 layer. + # *매핑까지만*. 실행 / rerender / behavior 변경 X. + # classifications 각 entry 에 proposed_action 추가, router_decision summary 반환. + router_decision = route_fit_classification(fit_classification) + + # 11.5 zone_ratio_retry action (A3) — A3 locked rules (사용자 잠금) 그대로. + # retry budget = 1, slide-base 고정, donor 룰, (b) revert 정책. + retry_trace = _attempt_zone_ratio_retry( + run_dir=run_dir, + out_path=out_path, + slide_title=slide_title, + slide_footer=slide_footer, + zones_data=zones_data, + debug_zones=debug_zones, + layout_preset=layout_preset, + layout_css=layout_css, + overflow=overflow, + fit_classification=fit_classification, + router_decision=router_decision, + gap_px=GRID_GAP, + ) + # retry 가 *성공* 했으면 overflow / fit_classification / router_decision / debug_zones 를 + # post-retry 상태로 갱신 (slide_status 가 새 상태 반영하도록). + if retry_trace.get("retry_passed"): + overflow = retry_trace["post_retry_overflow"] + debug_zones = retry_trace["post_retry_debug_zones"] + layout_css = retry_trace["post_retry_layout_css"] + # post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False + fit_classification = classify_visual_runtime_check(overflow, debug_zones) + router_decision = route_fit_classification(fit_classification) + + # 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X) + # retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보). + enrich_retry_trace_with_failure_classification(retry_trace) + + # 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X) + slide_status = compute_slide_status( + sections, units, comp_debug, overflow, + adapter_needed_units=adapter_needed_units, + debug_zones=debug_zones, + ) + + # 13. Debug.json + debug_path = write_debug_json( + run_dir, layout_preset, debug_zones, layout_css, overflow, + composition_debug=comp_debug, + slide_status=slide_status, + fit_classification=fit_classification, + router_decision=router_decision, + retry_trace=retry_trace, + ) + print(f" debug : {debug_path}") + + # 13. Status report + overall = slide_status["overall"] + print(f" status : {overall}") + if slide_status["filtered_section_ids"]: + print(f" filtered_sections = {slide_status['filtered_section_ids']}") + if slide_status["adapter_needed_count"]: + print(f" adapter_needed_count = {slide_status['adapter_needed_count']}") + if slide_status["content_truncated_count"]: + print(f" content_truncated = " + f"{[(c['position'], c['truncated_count']) for c in slide_status['content_truncated_units']]}") + if not slide_status["visual_check_passed"]: + for r in (overflow.get("fail_reasons") or [])[:3]: + print(f" visual_fail = {r}") + # fit_classifier + router 결과 요약 + if not fit_classification["visual_check_passed"]: + cats = fit_classification["categories_seen"] + print(f" fit_categories = {cats}") + if router_decision["router_active"]: + actions = router_decision["proposed_actions_summary"] + status = router_decision["implementation_status_summary"] + missing = router_decision["missing_actions_pending_impl"] + print(f" proposed_actions = {actions}") + print(f" impl_status_summary = {status}") + if missing: + print(f" missing_actions = {missing} (현재 미구현 → abort)") + # retry 결과 요약 (A3) + failure classification / next action proposal (A4) + if retry_trace.get("retry_attempted"): + print(f" retry_action = {retry_trace['retry_action']}") + print(f" retry_passed = {retry_trace['retry_passed']}") + if not retry_trace["retry_passed"]: + print(f" retry_failure = {retry_trace.get('retry_failure_reason')}") + fc = retry_trace.get("failure_classification") or {} + nap = retry_trace.get("next_action_proposal") or {} + if fc.get("failure_type"): + print(f" failure_type = {fc['failure_type']}") + if nap.get("next_proposed_action"): + print(f" next_proposed_action = {nap['next_proposed_action']} " + f"(impl_status={nap.get('next_action_implementation_status')})") + + # 13. Exit 정책 — visual fail 은 abort, partial coverage 는 abort 안 하지만 PASS 도 아님 + if not slide_status["visual_check_passed"]: + err_path = write_overflow_error(run_dir, overflow) + print(f"\n[Phase Z-2 MVP-1.5b] FAIL @ visual_runtime_check ({overall})", file=sys.stderr) + for reason in overflow.get("fail_reasons", [overflow.get("error", "unknown")]): + print(f" - {reason}", file=sys.stderr) + print(f" error : {err_path}", file=sys.stderr) + sys.exit(1) + + if not slide_status["full_mdx_coverage"]: + print( + f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 " + f"sections {slide_status['filtered_section_ids']} 가 composition planner 에서 " + f"필터됨 (allowed_statuses 미통과). final.html 은 viable units 만 렌더된 " + f"partial artifact. full MDX slide 아님." + ) + return out_path + + print( + f"\n[Phase Z-2 MVP-1.5b] {overall} — visual check OK + full MDX coverage. " + f"최종 사용자 브라우저 검증 후 ship 가능." + ) + return out_path + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python phase_z2_pipeline.py [run_id]", file=sys.stderr) + sys.exit(2) + mdx = Path(sys.argv[1]) + rid = sys.argv[2] if len(sys.argv) > 2 else None + run_phase_z2_mvp1(mdx, rid) diff --git a/src/phase_z2_retry.py b/src/phase_z2_retry.py new file mode 100644 index 0000000..4155d0e --- /dev/null +++ b/src/phase_z2_retry.py @@ -0,0 +1,215 @@ +"""Phase Z-2 zone_ratio_retry action v0 (A3 — 실제 zone redistribution 구현). + +router 가 *제안* 한 zone_ratio_retry action 의 **실행 layer**. + +원칙 (A3 locked rules — 사용자 잠금 7+1) : + 1. retry budget = 1 — 한 번만 시도 + 2. slide / slide-body / title / divider / footer / zone gap 모두 고정 + (공통 spacing 깎기 금지) + 3. 조정 대상 = router 가 지목한 *target zone* 만 height 증가 + 4. donor 선택 기준 : + - 같은 layout 의 sibling zone + - visual_check 통과 (이 zone 자체엔 overflow 없음) + - capacity_fit 가 ok + - 현재 height > min_height_px (slack > 0) + - donor min_height_px 아래로 줄지 X + - 여러 후보면 slack 가장 큰 것부터 (greedy) + - 부족 시 retry 실패 + 5. target_added_px = observed excess_y + safety_margin (small fixed) + — donor 가 min_height 아래로 가면 실패 + 6. retry 후 status : + - 성공 → PASS 가능 + - 실패 → RENDERED_WITH_VISUAL_REGRESSION 유지 (CSS/padding/tolerance 보정 X) + 7. debug trace 필수 (retry_attempted / target / donor / before/after / passed / reason) + 8. revert 정책 ((b)) : + - redistribution check 실패 → rerender 안 함, original final.html 유지 + - rerender 후 visual_check 실패 → original 로 revert (final.html 변경 X), + retried_candidate.html 은 *진단 artifact* 로만 별도 보관 + - retry 성공 시에만 final.html = retried version + +본 module 은 *plan + apply layer*. rerender / final.html 갱신 / revert 는 pipeline 이. +""" + +from __future__ import annotations + +import math +from typing import Optional + + +# 작은 고정 safety margin — 실험적 default. debug 에 기록. +DEFAULT_SAFETY_MARGIN_PX = 4 + + +def plan_zone_ratio_retry( + *, + debug_zones: list[dict], + overflow: dict, + fit_classification: dict, + router_decision: dict, + safety_margin_px: int = DEFAULT_SAFETY_MARGIN_PX, +) -> Optional[dict]: + """zone_ratio_retry 의 redistribution plan 을 산출. + + *plan 만*. 실제 height 적용 / rerender X (caller 가 처리). + + Returns: + None : retry 시도 자체가 불필요 (router 가 zone_ratio_retry 제안 X) + dict : retry attempt 정보 (feasible 여부 + 상세) + + feasible=True 이면 caller 가 zones_after 로 layout_css 재구성 + rerender 시도. + feasible=False 이면 caller 는 retry 포기 (original final.html 유지). + """ + if not router_decision.get("router_active"): + return None + + # zone_ratio_retry 가 router 제안에 포함된 첫 classification 을 target 으로 + target_cls = None + for cls in fit_classification.get("classifications", []) or []: + if cls.get("proposed_action") == "zone_ratio_retry": + target_cls = cls + break + if target_cls is None: + return None # 다른 action (popup / reselect) — 본 retry 대상 아님 + + target_zone_position = target_cls.get("zone_position") + target_excess_y = float(target_cls.get("inputs", {}).get("excess_y", 0)) + # round up to integer (subpixel 끼면 부족할 수 있음) + target_added_px = int(math.ceil(target_excess_y)) + int(safety_margin_px) + + # zones_before — debug_zones 의 height_px 를 모음 + zones_before: dict[str, int] = {} + zone_min_by_pos: dict[str, int] = {} + for dz in debug_zones: + pos = dz.get("position") + if pos is None: + continue + h = dz.get("height_px") + m = dz.get("min_height_px") + if h is None or m is None: + continue + zones_before[pos] = int(h) + zone_min_by_pos[pos] = int(m) + + # overflow zone 별 visual fail 정보 + overflow_zone_status: dict[str, dict] = {} + for z in overflow.get("zones", []) or []: + overflow_zone_status[z.get("position")] = z + + # donor 후보 식별 + donor_candidates: list[dict] = [] + for dz in debug_zones: + pos = dz.get("position") + if pos is None or pos == target_zone_position: + continue + # rule 4-(a) sibling 확인은 layout 내 sibling = 같은 zones list 안에 있으면 OK + # (본 함수는 1 layout 내 zones 만 받음) + + # rule 4-(b) visual_check 통과 — 이 zone 에 자체 overflow / clipped_inner 없음 + zinfo = overflow_zone_status.get(pos, {}) + zone_self_overflow = bool(zinfo.get("overflowed")) + zone_inner_clipped = bool(zinfo.get("clipped_inner")) + if zone_self_overflow or zone_inner_clipped: + continue + + # rule 4-(c) capacity_fit 가 ok + cap_status = ( + (dz.get("composition_rationale") or {}).get("capacity_fit", {}).get("fit_status") + ) + # 'ok' 아니거나 missing/unknown 이면 보수적으로 제외 (no_contract 는 허용 — capacity_fit 자체 부재) + if cap_status not in {"ok", "no_contract", None}: + continue + + # rule 4-(d) 현재 height > min_height + height = zones_before.get(pos) + min_h = zone_min_by_pos.get(pos) + if height is None or min_h is None: + continue + slack = height - min_h + if slack <= 0: + continue + + donor_candidates.append({ + "position": pos, + "current_height": height, + "min_height": min_h, + "slack": slack, + "capacity_fit_status": cap_status, + }) + + # rule 4-(f) 여러 후보면 slack 가장 큰 것부터 + donor_candidates.sort(key=lambda d: d["slack"], reverse=True) + + # base plan dict (failure / success 공용) + base_plan = { + "target_zone_position": target_zone_position, + "target_excess_y": target_excess_y, + "target_added_px": target_added_px, + "safety_margin_px_used": int(safety_margin_px), + "donor_candidates_considered": donor_candidates, + "zones_before": dict(zones_before), + } + + if not donor_candidates: + return { + **base_plan, + "feasible": False, + "donor_zone_position": None, + "donor_reduced_px": 0, + "zones_after": dict(zones_before), + "failure_reason": ( + f"no donor candidates eligible (sibling visual_check OK + " + f"capacity_fit ok/no_contract + slack > 0)" + ), + } + + # A3 minimal : single primary donor (multi-donor 는 future) + primary_donor = donor_candidates[0] + if primary_donor["slack"] < target_added_px: + return { + **base_plan, + "feasible": False, + "donor_zone_position": primary_donor["position"], + "donor_max_slack": primary_donor["slack"], + "donor_reduced_px": 0, + "zones_after": dict(zones_before), + "failure_reason": ( + f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px " + f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + " + f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis." + ), + } + + # feasible + zones_after = dict(zones_before) + zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px + zones_after[primary_donor["position"]] = ( + zones_before[primary_donor["position"]] - target_added_px + ) + + return { + **base_plan, + "feasible": True, + "donor_zone_position": primary_donor["position"], + "donor_reduced_px": target_added_px, + "zones_after": zones_after, + } + + +def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dict], + total_height: int, gap_px: int) -> dict: + """retry plan 의 zones_after 를 반영한 *새* layout_css 반환 (mutation X). + + horizontal-2 같은 dynamic_rows 인 경우만 해당. fr-default layout 은 retry target 아님 + (왜냐하면 dynamic heights 가 없으면 redistribution 의미 없음). + """ + new_layout_css = dict(layout_css) + # zone position 순서대로 height_px 추출 + new_heights_px = [plan["zones_after"][zd["position"]] for zd in zones_data] + new_layout_css["heights_px"] = new_heights_px + new_layout_css["rows"] = " ".join(f"{h}px" for h in new_heights_px) + new_layout_css["ratios"] = [round(h / total_height, 3) for h in new_heights_px] + new_layout_css["computation"] = "zone_ratio_retry override (A3)" + new_layout_css["dynamic_rows"] = True + new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy() + new_layout_css["raw_zone_layout"]["retry_applied"] = True + return new_layout_css diff --git a/src/phase_z2_router.py b/src/phase_z2_router.py new file mode 100644 index 0000000..e81dc0f --- /dev/null +++ b/src/phase_z2_router.py @@ -0,0 +1,181 @@ +"""Phase Z-2 overflow_router v0 (A2 — 정책 매핑 layer 만). + +fit_classifier 의 출력 (category) 를 spec §4 의 *proposed_action* 으로 매핑하는 layer. + +본 module 은 ***매핑까지만***. 실제 action 실행은 별도 step (A3+). +출력 = 각 classification 에 proposed_action 추가 + router 전체 summary. + +원칙 : + - classifier = 사실 분류 (category 결정) + - router = 정책 결정 (그 category 면 무엇을 *제안* 할 것인가) + - 본 단계는 *제안 trace* 만. pipeline behavior / abort 정책 / rerender 변경 X + - 실행 안 됨 → 현재 코드는 여전히 visual_check_passed=False 시 sys.exit(1) + 그러나 debug.json 에 *어떤 action 이 제안됐는지* 가 기록됨 + +다음 step (별도 — A3) : + zone_ratio_retry action 의 *실제 구현* — 지금 spec §4 mapping 의 가장 자주 + 트리거되는 action. +""" + +from __future__ import annotations + +from typing import Optional + + +# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ── + +# category → proposed_action (primary) +ACTION_BY_CATEGORY: dict[str, str] = { + "minor_overflow": "zone_ratio_retry", + "moderate_overflow": "layout_adjust", + "structural_minor_overflow": "zone_ratio_retry", + "structural_major_overflow": "details_popup_escalation", + "tabular_overflow": "details_popup_escalation", + "frame_capacity_mismatch": "frame_reselect", + "layout_zone_mismatch": "layout_adjust", + "hard_visual_fail": "abort", +} + +# 매핑 근거 — *왜 이 category 면 이 action 인가* trace 용 +ACTION_RATIONALE: dict[str, str] = { + "minor_overflow": + "1.5 줄 미만 text/label flow → zone 양보 / spacing 재계산으로 fit 가능", + "moderate_overflow": + "1.5~4 줄 text/label → layout/zone ratio 재분배 필요", + "structural_minor_overflow": + "structural unit boundary spill (<1 unit drop) → zone 양보로 fit, 단위 자르기 X", + "structural_major_overflow": + "1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate", + "tabular_overflow": + "표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)", + "frame_capacity_mismatch": + "composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가", + "layout_zone_mismatch": + "frame root 자체 overflow → layout preset 변경 또는 zone 키움", + "hard_visual_fail": + "위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)", +} + +# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준) +# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용 +ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { + "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration + "layout_adjust": "MISSING", + "details_popup_escalation": "MISSING", # CLAUDE.md 의
원칙은 있음, runtime 미구현 + "frame_reselect": "MISSING", # V4 top-k 자료는 있음, planner 가 rank-1 만 + "adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch + "abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default +} + + +# ─── 단일 분류 → routing 결과 ───────────────────────────────────── + + +def route_action(category: str) -> dict: + """category → proposed_action mapping 결과 (단일). + + Returns: + dict : + proposed_action : action 이름 (또는 None) + rationale : *왜* 이 action 인가 + implementation_status : implemented / partial / missing / unknown + mapping_source : "spec §4 ACTION_BY_CATEGORY" 또는 "no mapping" + """ + action = ACTION_BY_CATEGORY.get(category) + if action is None: + return { + "proposed_action": None, + "rationale": f"category '{category}' has no mapping in ACTION_BY_CATEGORY", + "implementation_status": "unknown", + "mapping_source": "no mapping (unknown category)", + } + return { + "proposed_action": action, + "rationale": ACTION_RATIONALE.get(category, ""), + "implementation_status": ACTION_IMPLEMENTATION_STATUS.get(action, "unknown"), + "mapping_source": "spec §4 ACTION_BY_CATEGORY", + } + + +# ─── fit_classification 전체 → router decision ────────────────── + + +def route_fit_classification(fit_classification: dict) -> dict: + """fit_classification 의 모든 classifications 에 proposed_action 추가 + summary. + + 각 classification 에 다음 필드를 *추가* (기존 필드 보존) : + - proposed_action + - proposed_action_rationale + - proposed_action_implementation_status + - proposed_action_mapping_source + + Returns: + router decision summary dict : + router_active : True/False (visual_check_passed=False 일 때만 True) + proposed_actions_summary : unique action 들 sorted list + implementation_status_summary : {status: count} dict + routed_count : 처리된 classification 수 + routed_details : per-classification routing trace + missing_actions_pending_impl : 본 routing 에서 *현재 미구현* 인 action 모음 + note : 사용자 안내 텍스트 + """ + if fit_classification.get("visual_check_passed", True): + return { + "router_active": False, + "proposed_actions_summary": [], + "implementation_status_summary": {}, + "routed_count": 0, + "routed_details": [], + "missing_actions_pending_impl": [], + "note": "visual check passed — no overflow to route", + } + + classifications = fit_classification.get("classifications", []) or [] + routed_details = [] + + for cls in classifications: + category = cls.get("category", "hard_visual_fail") + routing = route_action(category) + + # classification entry 에 proposed_action 정보 *추가* (기존 필드 보존) + cls["proposed_action"] = routing["proposed_action"] + cls["proposed_action_rationale"] = routing["rationale"] + cls["proposed_action_implementation_status"] = routing["implementation_status"] + cls["proposed_action_mapping_source"] = routing["mapping_source"] + + routed_details.append({ + "source": cls.get("source"), + "zone_position": cls.get("zone_position"), + "category": category, + "proposed_action": routing["proposed_action"], + "implementation_status": routing["implementation_status"], + }) + + # summary + actions_seen = sorted({ + r["proposed_action"] for r in routed_details + if r["proposed_action"] is not None + }) + status_breakdown: dict[str, int] = {} + missing_actions: list[str] = [] + for r in routed_details: + s = r["implementation_status"] + status_breakdown[s] = status_breakdown.get(s, 0) + 1 + if s == "MISSING" and r["proposed_action"] not in missing_actions: + missing_actions.append(r["proposed_action"]) + + return { + "router_active": True, + "proposed_actions_summary": actions_seen, + "implementation_status_summary": status_breakdown, + "routed_count": len(routed_details), + "routed_details": routed_details, + "missing_actions_pending_impl": sorted(missing_actions), + "note": ( + "router 는 category → proposed_action 매핑까지 담당. 실제 action 실행은 " + "pipeline 의 별도 orchestrator 가 처리 (예: zone_ratio_retry 는 " + "_attempt_zone_ratio_retry 에서 실행). proposed_action 의 implementation_status " + "가 IMPLEMENTED 이면 pipeline 이 시도하고 결과는 retry_trace 에 기록, " + "MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료." + ), + }