docs + V4 catalog + samples + Phase Q legacy 보존

전체 26 files (20 추가 + 6 수정), 10507 insertions.

Phase Z 문서 :
- docs/architecture/PHASE-Z-CHANGE-LOG.md (신설) — axis-by-axis 의사결정 history
  (newest-on-top). Step 7-A 부터 6 entry 박힘 + 2026-05-08 / 2026-05-08 #2
  (compat 매트릭스 폐기 / 6-B 폐기 / F14 표현 정정 / label gate policy 분리).
- docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md (수정) — Step 5/6/9 Gap note
  append (구조 무변, append-only). 6-B 폐기 사실 + Refinement F.
- docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md (수정) — snapshot date
  2026-05-08 갱신. §3 핵심 missing item 5 (Step 5/6/9 boundary axis breakdown
  + 폐기 기록). §6 한 줄 갱신 — 다음 axis 후보 A~F.

Project root docs :
- PLAN.md / PROGRESS.md / README.md (수정) — 토큰 체계 / 폴더 구조 / 설계 문서 /
  역할 분리 반영.
- IMPROVEMENT-REDESIGN.md (신설) — Phase Z 설계 핵심 문서.
- PROCESS_OVERVIEW.html (신설) — 파이프라인 개요 시각.
- docs/tasks/* (신설) — Phase Z task 문서.

V4 catalog (Phase Z runtime 필수 의존성) :
- tests/matching/v4_full32_result.yaml (신설, 4888 줄) — V4 매칭 결과 32 frame
  × 10 MDX section. lookup_v4_match() / lookup_v4_candidates() 가 본 파일 read.
  Phase Z runtime 이 *없으면 즉시 abort* — clone 후 즉시 동작 가능 보장.

Samples :
- samples/mdx_batch/04.mdx (신설) — MDX04 기본 sample.
- samples/mdx/04. DX 지연 요인.mdx (신설) — MDX04 원본.

Phase Q legacy 보존 (별 axis "Phase Q audit & salvage" 영역) :
- src/block_matcher_tfidf.py / catalog_blocks.py / frame_extractor.py /
  pipeline_v2.py — Phase Q (옛 파이프라인) src 신규 untracked 파일들.
  Phase Z runtime 와 의존성 0. Phase Q audit axis 에서 검토 예정.
- scripts/eval_block_matcher.py / fetch_all_frame_screenshots.py /
  match_17_units_my_matcher.py / match_mdx_strict.py / match_mdx_to_frames_tfidf.py /
  ocr_augment_texts.py / run_pipeline_v2.py / previews/ — Phase Q 작업 시
  사용한 옛 script. 같이 보존.
- run_mdx03_pipeline.py (수정) — Phase Q 진입점 (no flag) + Phase Z 진입점
  (--phase-z2 flag) 동시 wrapper. Phase Z 만 사용 시 `python -m
  src.phase_z2_pipeline samples/mdx_batch/03.mdx <run_id>` 직접 호출.

비-scope :
- tests/matching/ (v4_full32_result.yaml 외 ~63MB) — V4 진화 history /
  reports / DECK / ATTACH. Phase Q audit axis 에서 검토.
- tests/pipeline/ (~15MB) — pipeline data. Phase Q audit 영역.
- templates/catalog/blocks.yaml — 옛 block catalog. Phase Q audit.
- templates/phase_z2/frames/ — 옛 frame partial 위치. Phase Q audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:47:58 +09:00
parent ec83405770
commit 85c680f02a
26 changed files with 10507 additions and 46 deletions

578
IMPROVEMENT-REDESIGN.md Normal file
View File

@@ -0,0 +1,578 @@
# Pipeline 개선 — 매칭 시스템 통합 (Redesign)
> **작성일**: 2026-04-28
> **목적**: 매칭 시스템 (V1~V4) 테스트 결과를 기존 design_agent pipeline 에 통합. AI 호출을 줄이고 결정론적 매칭 + 정밀한 공간 관리를 결합.
> **상태**: 설계 단계. 실행은 사용자 승인 후 단계적 진행.
---
## 1. 배경 / 문제의식
### 기존 pipeline 의 한계
| 문제 | 현황 |
|---|---|
| AI 호출 과다 | Stage 1A / 1B / 1B-ST / 1.7 / 1.8 / 2 / 4 — 5~6 회 호출 |
| Type A/B/B'/B'' 하드코딩 | 콘텐츠별 전용 코드 분기 (오답노트 #2 위반 우려) |
| 블록 catalog 50+ 개 vs 실제 사용 7.9% | Phase P 결과 — 다양성 부족 |
| 유령 블록 / overflow 출력 | Phase Q 에서 일부 해결 |
### 매칭 시스템 (V1~V4) 테스트 결과
`tests/matching/` 에서 별도 검증한 결과:
| 항목 | 결과 |
|---|---|
| TARGET 정답률 | 3/4 (75%) — Logistic Regression 가중치 학습 + LOOCV 4/4 검증 |
| BM25 / IDF 비교 | 현 방식 우위 (4/4 vs 3/4) |
| V4 slot 축 ablation | Top-1 매칭 7/7 동일 — slot 축 frame 선별 무영향 확인 |
| 발견된 약점 | 8 가지 (`tests/PROGRESS.md` 참조) |
### 통합 필요성
매칭 시스템이 **콘텐츠 분석 / 디자인 추천** 부분을 코드로 대체 가능함을 확인. 단, 1280×720 안 정밀 공간 관리는 매칭 시스템이 다루지 않으므로 기존 pipeline 의 공간 관리 로직 (`space_allocator`, `fit_verifier`, `slide_measurer`) 은 유지 필요.
**콘텐츠 분석은 매칭 시스템, 공간 관리는 기존 pipeline** 으로 책임 분리.
---
## 2. 영역 분리 — 3 가지
| 영역 | 무엇을 하나 | 누가 담당 |
|---|---|---|
| **A. 콘텐츠 분석 / 디자인 추천** | "이 MDX 콘텐츠에 어떤 디자인이 어울리나?" | **매칭 시스템 V1~V4 (신규)** |
| **B. 공간 관리 / 사이즈 조정** | "1280×720 안에 어떻게 끼워 넣나? overflow 안 나게?" | **기존 pipeline (유지)** |
| **C. HTML 생성 / 슬롯 채움** | "선택된 디자인에 MDX 텍스트를 어떻게 넣나?" | **기존 + V4 label 분기** |
### 핵심 원칙
- A 는 매칭 시스템으로 완전 대체 (AI 호출 줄임)
- B 는 절대 손대지 않음 (정밀한 공간 관리는 기존 로직이 검증됨)
- C 는 매칭 결과의 label 에 따라 분기 (코드 / AI 1회 / Sonnet 재구성)
---
## 3. 핵심 결정 사항 (확정)
### 3.1 처리 단위
```
MDX 1 파일 = 대목차 1개 = 슬라이드 1장
└ 중목차들 = 슬라이드 안의 frame 들 (조합)
└ 소목차들 = 더 작은 단위 frame 또는 슬롯
```
### 3.2 매칭 알고리즘 — 소→중→대 합치기 룰
```
[1단계] 모든 소목차에 V1~V4 매칭 시도
각 소목차의 best frame 도출
[2단계] 같은 중목차 내 소목차 매칭이 비슷하면 → 중목차로 묶어서 다시 매칭
더 큰 frame 단위로 합치기 가능한지 확인
[3단계] 중목차들이 비슷하면 → 대목차로 묶어서 매칭
슬라이드 1장 전체를 1 frame 으로 처리 가능한지 확인
→ 가장 적합한 합치기 단위 + frame 조합 채택
```
### 3.3 레이아웃 프리셋
기존 design_agent 의 4 가지 Type 으로 시작:
| 프리셋 | 조건 | 비고 |
|---|---|---|
| Type A | 참조 자료 (용어 정의 등) 별도 존재 | 기존 그대로 유지 (오답노트 #7) |
| Type B | 본문 흐름만 | 기존 그대로 |
| Type B' | 카드형 + 표 | 기존 그대로 |
| Type B'' | 색상바 + 여백 분리 | 기존 그대로 |
→ 4 개로 시작. 운영하면서 부족하면 추가.
### 3.4 V4 label 분기
매칭 결과의 label 이 후속 작업 비용을 결정:
| label | confidence | 후속 작업 | 비용 |
|---|---|---|---|
| use_as_is | ≥ 0.90 | 슬롯에 텍스트만 매핑 | 자동, AI 0회 |
| light_edit | ≥ 0.75 | AI 가 슬롯 텍스트 다듬기 | AI 1회 |
| restructure | ≥ 0.60 | 가장 유사 frame 참고 + Sonnet 변형 | 사람 + AI |
| reject | < 0.60 | 대안 frame 시도 → 그래도 안 되면 유사 frame 참고 | 사람 + AI |
### 3.5 자유 HTML 생성 금지
→ 항상 frame DB 의 어떤 frame 을 참고하여 생성. Phase R' 식 자유 디자인은 X.
### 3.6 텍스트 원문 보존 원칙 (절대 룰)
> **슬라이드 본문은 preview / 요약 가능하지만, MDX 원문은 popup / detail 에 무손실 보존한다.**
- 슬라이드 본문에 표시되는 것 = 일부 / 핵심 / preview 가능
- 단, 원문이 잘리거나 사라지지 않음 — 항상 팝업 / "자세히 보기" 에 무손실 저장
- "텍스트 압축 / trim / restructure" 같은 처리 금지 (오답노트 #5 준수)
- 안 들어가면 슬라이드를 키우는 게 아니라 → 본문은 preview, 팝업에 원문 보존
- AI 가 텍스트를 "줄여서 채워 넣지" 않는다 — preview 로 가공 후, 원문은 detail 에 보존
### 3.7 팝업 처리 룰
- MDX 에 팝업 콘텐츠 (`<details>`) 가 있으면 별도 보존
- 슬라이드 1장 룰 절대 유지 (2장으로 분리 X)
- 처리: **본문 요약 / 일부만 표시 + "자세히 보기" 클릭 → 팝업으로 전체**
- 전체를 팝업으로 빼는 것 X
- 팝업 콘텐츠도 MDX 원문 무손실 (3.6 원칙 적용)
---
## 4. 위계 + 용어 정리
### 4.1 슬라이드 위계
```
[ 슬라이드 ] 1280 × 720
├─ slide-title ← MDX 대목차 제목 (자동 매핑)
├─ slide-divider (고정)
├─ slide-body ≈ 1200 × 590 ─ ★ 여기에 콘텐츠
│ │
│ └─ 레이아웃 (Type A / B / B' / B'')
│ │
│ └─ Zone (top / bottom_l / bottom_r 등)
│ │
│ └─ 프레임 (Figma 디자인)
└─ slide-footer ← MDX 대목차 결론 (자동 매핑)
```
### 4.2 용어
| 용어 | 의미 | 단위 / 위치 |
|---|---|---|
| **슬라이드** | 1280×720 한 장 | 가장 큰 단위 |
| **slide-base** | 배경 + 제목 + 구분선 + 결론 pill (모든 슬라이드 공통 그릇) | `templates/blocks/slide-base.html` 고정 |
| **slide-body** | 본문 가용 영역 (≈ 1200×590) | slide-base 안 |
| **레이아웃 (Layout)** | Type A / B / B' / B'' (4 가지 프리셋) | slide-body 안 |
| **Zone (영역)** | 레이아웃이 결정한 콘텐츠 구역 (top / bottom 등) | 레이아웃 안 |
| **컨테이너 (Container)** | zone 의 px 명세 (코드 레벨 표현) | zone 의 구체화 |
| **프레임 (Frame)** | Figma 디자인 단위 (= 기존 "블록") | zone 안 |
### 4.3 MDX → 슬라이드 매핑
| MDX 위치 | 슬라이드 위치 |
|---|---|
| `# 대목차 제목` | `slide-title` |
| 본문 (`##` / `###` 중·소목차) | `slide-body` 안 — 레이아웃 / zone / 프레임 |
| `# 대목차 결론` (있을 시) | `slide-footer` |
| `<details>` 팝업 콘텐츠 | 슬라이드 위 별도 레이어 |
### 4.4 매칭 시스템 (V1~V4) 의 frame 단위 — 명확화
- 매칭 시스템에서 **frame 32 개를 슬라이드 1장 단위로 검증** 함 (예: Frame 18 = BIM/DX 비교 슬라이드 통째로)
- 새 흐름에서는 **frame = zone 안 디자인 단위**
- → frame DB 라벨링 재검토 필요 (사이즈 분류: "슬라이드 전체 / zone 1 개 / 작은 박스")
- 8.1 항목 (32 frame 사이즈 라벨링) 의 핵심 작업
---
## 5. 새 Pipeline Flow
```
═══════════════════════════════════════════════════════════════
[전처리 — 한 번만, 결과 캐싱]
═══════════════════════════════════════════════════════════════
T-0. Figma frame DB 구축
· 각 frame 의 키워드 (핵심/세트/연관) 라벨링 ✅ 완료
· 각 frame 의 구조 (layout, family, relation_type,
cardinality, content_affinity,
structure_intent, slots,
alternative_patterns) 라벨링 ✅ 완료
· 각 frame 의 사이즈 비율 (전체 / 절반 / 1/3 / 박스) ✅ 대체 완료 (Zone 적용 분류 — `docs/architecture/FRAME-INTEGRATION-MAP.md` 의 `zone_direct/adapt/extract/reference_only`)
· 각 frame 의 스타일 / 시각 언어 인벤토리 ✅ 완료 (`docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md`)
T-1. (새 frame 추가 시) 자동 라벨링 룰
· 새 Figma frame 입력 → V3 의 detect_mdx_v2_profile 로 자동 감지
· 사람이 검수 후 DB 추가
T-2. 레이아웃 프리셋 정의 (4 개)
· 각 프리셋의 grid / area 정의
· 어떤 frame 사이즈 조합에 어울리나 메타 정보
═══════════════════════════════════════════════════════════════
[실행 — MDX 1 파일 처리 = 슬라이드 1 장 생성]
═══════════════════════════════════════════════════════════════
STAGE 1) MDX 분석 + 레이아웃 매칭 [코드]
· MDX 정규화 + 외부 컴포넌트 인라인 (Phase Y)
· 팝업 콘텐츠 (<details>) 별도 보존
· 대 / 중 / 소목차 트리 추출
· 콘텐츠 성격 분석 (V3 의 detect_mdx_v2_profile)
· 4 가지 레이아웃 (Type A / B / B' / B'') 중 1 개 결정
· MDX 대목차 제목 → slide-title
· MDX 대목차 결론 → slide-footer
STAGE 2) Zone 별 텍스트 1 차 배치 [코드]
· 레이아웃이 결정한 zone 들에 콘텐츠 분배
· 어떤 중 / 소목차가 어떤 zone 에 갈지 결정
· 컨테이너 (zone 의 px) 계산 — space_allocator.py
STAGE 3) Zone 별 프레임 매칭 (V1~V4 매칭 시스템) [코드 + label 분기 AI]
┌──────────────────────────────────────────────┐
│ 각 zone 의 콘텐츠와 Figma 프레임 DB 매칭 │
│ V1 키워드 → V2 의미 → V3 구조 → V4 종합 판정 │
│ │
│ 매칭 결과 분기: │
│ ├ 매칭 완벽 (use_as_is) → 텍스트 업데이트 │
│ ├ 매칭 어정쩡 (light_edit │
│ │ / restructure) → 디자인 참고 │
│ │ 재구성 (Sonnet) │
│ └ 매칭 안 됨 (reject) → 디자인 컨셉 │
│ 바탕 재구성 │
│ │
│ 자유 디자인 금지 — 항상 프레임 DB 참고 │
└──────────────────────────────────────────────┘
STAGE 4) 프레임 내용 검토 + 컨테이너 조정 [코드 + AI fallback]
· zone 별 검증 (넘침 / 공란 / 적정 / 불균형)
· 보정:
├ 넘침 → 본문 미리보기 + "자세히 보기" 팝업
│ (MDX 원문 절대 줄이지 않음 — 3.6 원칙)
├ 공란 → MDX 팝업 콘텐츠 있으면 일부 끌어옴
├ 적정 → 그대로
└ 불균형 → 레이아웃 자체 부적절 → STAGE 1 회귀
(5 차 Fallback — 6 장 참조)
STAGE 5) HTML 조립 + 검증 + 출력 [코드 + AI]
· slide-base.html (배경 + 제목 + 구분선 + 결론 pill)
+ slide-body 안 (레이아웃 + zone + 프레임)
+ slide-title / slide-footer 채움
+ 팝업 레이어
· Jinja2 렌더링
· 실측 검증 (Selenium) — 넘침 차단
· 시각 검증 (Vision — Opus) — 품질 평가
· final.html 출력
```
---
## 6. 컨테이너 검증 + Fallback 룰 (STAGE 4 상세)
### 6.1 검증 케이스 분류
```
[모든 컨테이너 검증 결과 종합]
케이스 1) 모두 적정
→ 다음 단계 진행
케이스 2) 일부 Overflow + 나머지 적정
→ 본문 요약 + "자세히 보기" 팝업 처리
케이스 3) 일부 Underflow (특정 컨테이너만 공란) + 나머지 정상
❗ 레이아웃 잘못 → STAGE 1 회귀 (레이아웃 재선택)
케이스 4) 일부 Overflow + 일부 Underflow 동시
❗ 레이아웃 잘못 (불균형) → STAGE 1 회귀 (레이아웃 재선택)
케이스 5) 모두 Underflow + 채울 콘텐츠 없음
✅ 공란 허용 (콘텐츠 자체가 적은 슬라이드)
```
### 6.2 5 차 Fallback (불균형 검출 시)
```
[1차] 매칭 → 적합 프리셋 → 검증
OK → 종료
불균형 → 2차
[2차] 다른 프리셋 시도 (예: Type B → Type B')
또는 frame 합치기 단위 변경 (소→중 또는 중→대)
OK → 종료
불균형 → 3차
[3차] 매칭 결과 자체 변경 (Top-1 → Top-2 frame)
OK → 종료
불균형 → 4차
[4차] AI 콘텐츠 조정 의뢰 (Sonnet 1회)
현재 매칭된 frame / zone / container 는 유지한 채,
zone 안 콘텐츠 분량과 슬롯 매핑만 AI 로 조정한다.
· 긴 텍스트 → 본문 preview + 팝업 원문 분리 제안
· 슬롯 의미 매핑 미세 조정
· 불필요한 반복 표현을 preview 에서 정리
· 원문은 popup / detail 에 무손실 보존
⚠️ AI 는 zone 안 콘텐츠만 조정한다.
⚠️ 레이아웃 / 프리셋 / 새 frame 결정 / HTML 구조 생성은 코드만 수행한다.
OK → 종료
안 되면 → 5차
[5차] 단일 프리셋 강제 (Type A 또는 가장 큰 frame 만 사용)
나머지 콘텐츠 → "자세히 보기" 팝업
OK → 종료
그래도 안 되면 → 사람 개입 알림
```
### 6.3 Fallback 단계별 비율 추정
| 단계 | 종료 비율 (가설) | 비용 |
|---|---|---|
| 1차 | 80%+ | 낮음 (코드만) |
| 2~3차 | 15% | 낮음 (코드만) |
| 4차 (AI) | 4% | 중간 |
| 5차 (단일 + 팝업) | 1% 이하 | 낮음 |
| 사람 개입 | 매우 드물게 | 최후 수단 |
→ 실제 비율은 MDX 데이터로 검증 필요. 일단 가설.
---
## 7. 단계적 진행 계획
### Phase Z-0 — 매칭 시스템 (✅ 완료)
```
✅ V1~V4 매칭 시스템 구축
✅ 32 frame 키워드 + 구조 라벨링
✅ TARGET 3/4 정답
✅ 보고서 (DECK 1~7)
```
### Phase Z-1 사전 작업 — 진행 중
```
✅ 완료 (2026-04-28)
· Frame Integration Map (32 frame Zone 적용 분류)
→ docs/architecture/FRAME-INTEGRATION-MAP.md
· Frame Style Inventory (32 frame + 18 token + 6 legacy)
→ docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md
⬜ 미진행 (Phase Z-1 본격 진입 전)
· catalog / runtime 설계 prep
· slide-base 검증
⚠️ 미실행 / 의도적으로 보류 (승인 전)
· 기존 templates/blocks 삭제 / 교체
· catalog / runtime 구현
· templates/styles/frame-patterns 신규 파일 생성
· 새 token (gap_candidate) 추가
```
### Phase Z-1 — 통합 prototype (본격)
```
[목표] MDX 03 (회귀 기준) 으로 매칭 시스템 + 기존 pipeline 통합 검증
작업:
· Stage 1.7 (블록 선택) 만 V4 로 교체
· 32 frame ↔ Type B/B'/B'' 매핑 테이블 작성
· MDX 03 결과 기존과 비교 (회귀 통과)
완료 기준:
· MDX 03 기존 결과 대비 overflow 없음
· 텍스트 누락 없음 (MDX 원문 무손실 보존, 3.6 원칙)
· AI 호출 수 감소 (Stage 1.7 의 Kei 1회 제거)
· 옵션 플래그 off 시 동일 경로 복귀 (회귀 방지)
리스크: 낮음 (한 곳만 교체)
산출물: 매칭 시스템이 기존 pipeline 안에서 작동 확인
```
### Phase Z-2 — 매칭 + 프리셋 통합
```
[목표] Type A/B/B'/B'' 프리셋과 매칭 결과 연결
작업:
· 매칭 결과 → 4 프리셋 중 1 개 자동 선택 룰
· 합치기 룰 (소→중→대) 구현
· MDX 02, 01 적용 검증
완료 기준:
· MDX 03 / 02 / 01 모두 매칭 + 프리셋 자동 선택 작동
· 합치기 룰이 소목차 → 중목차 → 대목차 순서로 작동
· MDX 원문 무손실 유지 (3.6 원칙)
리스크: 중간
산출물: MDX 03/02/01 모두 매칭 + 프리셋 자동
```
### Phase Z-3 — 컨테이너 검증 + Fallback
```
[목표] 컨테이너 별 검증 + 5 차 Fallback 로직
작업:
· 불균형 검출 룰 (Overflow / Underflow / 동시)
· STAGE 4 의 1~5 차 Fallback 단계 구현
· 팝업 처리 룰 (안 들어가면 자세히 보기)
완료 기준:
· 컨테이너 별 검증 (Overflow / Underflow / 불균형) 작동
· Fallback 5 차 단계 작동 (각 단계 독립 검증)
· 팝업 처리 — preview 본문 + 무손실 팝업
리스크: 중간 ~ 높음
산출물: 안정적 자동 처리
```
### Phase Z-4 — 전체 통합 + 검증
```
[목표] 새 pipeline 으로 모든 MDX 처리 + 회귀 확인
작업:
· MDX 03 / 02 / 01 회귀
· Selenium + Vision 검증
· AI 호출 수 / 처리 시간 측정
완료 기준:
· MDX 03 / 02 / 01 모두 새 pipeline 통과
· Vision 품질 평가 합격
· AI 호출 수 / 처리 시간 기존 대비 감소
산출물: 새 pipeline 완성
```
---
## 8. 검토 / 결정 필요한 항목
### 8.1 32 frame 의 사이즈 라벨링
frame DB 에 "이 frame 은 슬라이드 전체 / 위 절반 / 좌측 1/3 / 작은 박스" 같은 사이즈 분류 필요.
| 옵션 | 방법 |
|---|---|
| (a) 사용자 직접 분류 | 정확하지만 시간 소요 |
| (b) Figma 캔버스 사이즈 자동 추정 | 빠르지만 부정확 가능 |
| (c) 매칭 시스템의 기존 라벨 (layout) 활용 + 보완 | 절충 |
**추천 (c)** : 기존 layout 라벨 (compare-rows / table-2col / cards-3col 등) 을 사이즈 라벨에 매핑하는 룰 작성.
### 8.2 프리셋 자동 선택 룰
frame 들의 사이즈 / 개수 → 4 프리셋 중 1 개 선택하는 룰.
예시:
- frame 1 개 + 사이즈 = 슬라이드 전체 → Type A 또는 Type B 단일
- frame 2 개 + 사이즈 = 위/아래 절반 → Type B (상단 + 하단)
- frame 2 개 + 사이즈 = 좌/우 절반 → Type A (사이드바)
### 8.3 합치기 룰 종료 조건
소→중→대 합치기 룰에서 어디까지 합칠지 결정:
- 소목차 매칭 결과가 "비슷" 하다는 기준은?
- 같은 frame_id ? 같은 layout family ? V4 axes 일부 일치 ?
### 8.4 슬롯 매핑 — 코드 + AI 하이브리드 비율
- 어디까지 코드 휴리스틱? (제목 → title 같은 명확한 것)
- 어디부터 AI? (의미 매핑 / 사이즈 조정 / 텍스트 다듬기)
---
## 9. 회귀 방지 / 안전 원칙
### 9.1 기존 작동 코드 보존
- **Type A 코드** (오답노트 #7) — 절대 건드리지 않음
- **공간 관리 로직** (`space_allocator`, `fit_verifier`, `slide_measurer`) — 그대로 유지
- **Phase Y 진행 중 작업** — MDX 외부 컴포넌트 처리 그대로
### 9.2 회귀 검증
- 단계마다 **MDX 03 (회귀 기준)** 결과 기존과 비교
- 변경 전 / 변경 후 다음 항목 측정:
- 최종 HTML 의 시각 결과 (스크린샷 비교)
- AI 호출 수
- 처리 시간
- overflow 발생 여부
### 9.3 옵션 플래그 도입
```
USE_MATCHING_SYSTEM = False # 기본값
```
- 새 기능은 플래그로 도입
- 문제 발생 시 즉시 기존 경로로 fallback
- 안정화 후 기본값 True
### 9.4 핵심 원칙 (오답노트.md 준수)
- **거짓말 금지** — 못 하면 못 한다고 명시
- **하드코딩 금지** — 결과물 고치지 말고 프로세스 고침
- **검증 없이 넘어가지 마라** — 단계마다 실측 + 회귀
- **텍스트 원문 무손실 보존** — 슬라이드 본문은 preview / 일부만 표시 가능, MDX 원문은 팝업 / detail 에 무손실 보존 (3.6 원칙)
- **프로세스 만들어라** — 매번 사고하여 판단 (AI 든 코드든)
---
## 10. 참고 문서
### 우선순위 — 충돌 시 어느 문서가 최신인가
**매칭 시스템 관련**:
- ✅ 최신 기준 = `tests/PIPELINE.md` (V1~V4 통합 정리)
- ⚠️ `tests/matching/CURRENT_STATUS.md` 등 옛 문서는 **과거 기록 (참조 X)**
- 옛 문서에 "V2/V3/V4 미완료" 로 적혀 있어도 무시. 실제는 모두 완료.
**design_agent 전체**:
- ✅ 최신 기준 = `PIPELINE.md` + 이 문서 (`IMPROVEMENT-REDESIGN.md`)
- ⚠️ `ARCHITECTURE_OVERVIEW.md`**deprecated** (2026-03-27 스냅샷, Type A/B 분기 등 미반영)
### 매칭 시스템 (V1~V4)
| 문서 | 위치 |
|---|---|
| 매칭 시스템 V1~V4 통합 정리 | `tests/PIPELINE.md` |
| 매칭 시스템 진행 / 약점 | `tests/PROGRESS.md` |
| 매칭 시스템 단계별 계획 | `tests/PLAN.md` |
| 매칭 시스템 코드 + 결과 + 보고서 | `tests/pipeline/` |
| 매칭 시스템 원본 (보존) | `tests/matching/` |
### 기존 design_agent
| 문서 | 위치 |
|---|---|
| 현재 pipeline 흐름 | `PIPELINE.md` |
| 전체 Phase 이력 | `PROGRESS.md` |
| 단계별 계획 | `PLAN.md` |
| 절대 원칙 | `오답노트.md` |
| 프로젝트 규칙 | `CLAUDE.md` |
| README | `README.md` |
### 보고서 (임원 보고용)
| 문서 | 위치 |
|---|---|
| DECK 1~7 (TARGET / Holdout / 방법 / DB / 키워드) | `tests/pipeline/reports/DECK_*.html` |
---
## 11. 리스크 + 대응 방안
설계안에서 실행계획으로 넘어갈 때 고려해야 할 리스크.
| # | 리스크 | 대응 방안 |
|---|---|---|
| 1 | **V4 가 디자인 적합성은 보지만 공간 적합성은 보지 않음** | 기존 공간 관리 (B 영역, `space_allocator` / `fit_verifier`) 그대로 유지. STAGE 4 컨테이너 검증으로 보완. V4 confidence 와 별개로 컨테이너 단위 overflow / underflow 측정 |
| 2 | **slot 의미 매핑 부재** (BIM → col_a_label 같은 매핑 없음) | STAGE 3 (zone 별 프레임 매칭) 의 슬롯 매핑에 AI 1회 호출 (Sonnet). 또는 frame DB 에 매핑 룰 사전 정의. 매칭 시스템의 slot 축 ablation 결과 — 이 영역은 별도 작업 필요 |
| 3 | **32 frame DB 의 사이즈 라벨 부족** | 8.1 항목 결정 후 라벨링 작업 (사용자 직접 또는 매칭 시스템의 layout 라벨 활용). Phase Z-1 시작 전 완료 필요 |
| 4 | **기존 Type B'/B'' 하드코딩 관성 재발 위험** | 4 프리셋 안에서만 조합. 새 콘텐츠별 전용 코드 절대 금지 (오답노트 #2). 새 변형 필요 시 — 코드 분기 추가 X, frame DB 또는 프리셋 추가로 해결 |
| 5 | **보고서용 매칭 성능 ≠ 실제 렌더 품질** | TARGET 3/4 정답률은 매칭 단독 성능. 실제 슬라이드 품질은 Phase Z-1 ~ Z-4 단계마다 MDX 03 실 렌더 + Vision 평가 회귀 |
| 6 | **MDX 분석 키워드 사전의 부정확성** (V3) | Hybrid 룰 — 코드 자동 + Kei fallback (신뢰도 낮을 때). 또는 추후 LLM 분석으로 교체 (`tests/PROGRESS.md` 의 Phase E.1, E.2) |
| 7 | **합치기 룰 (소→중→대) 무한 재시도 위험** | Fallback 5 차로 명확히 종료 (STAGE 4). 각 단계 최대 1 회 시도. 5 차 후엔 사람 개입 알림 |
| 8 | **매칭 시스템의 02-2.2 매칭 실패 사례** | 매칭 시스템 자체 약점 (`tests/PROGRESS.md` 약점 #1). Phase Z-1 진행 전 frame 14 의 anchor_sets 재라벨링 필요 (사전 작업) |
---
## 다음 단계
1. **이 문서 검토** — 사용자 + 협업자
2. **검토 사항 (7장) 결정** — 4 가지 항목
3. **리스크 대응 (11장) 사전 작업** — 32 frame 사이즈 라벨링 + frame 14 재라벨링
4. **Phase Z-1 시작** — 통합 prototype
5. **단계별 회귀 검증** — MDX 03 기준

44
PLAN.md
View File

@@ -451,6 +451,50 @@ Phase V (적합성 검증):
---
## Phase Z: 매칭 시스템 통합 (2026-04-28 ~)
> **상세 설계**: [IMPROVEMENT-REDESIGN.md](IMPROVEMENT-REDESIGN.md)
>
> 별도 검증된 매칭 시스템 (V1~V4, `tests/matching/`) 을 기존 pipeline 에 통합.
### 단계적 진행
| 단계 | 내용 | 산출물 |
|---|---|---|
| **Phase Z-1** | 통합 prototype — Stage 1.7 (블록 선택) 만 V4 로 교체 | MDX 03 회귀 통과 |
| **Phase Z-2** | 매칭 + 4 프리셋 (Type A/B/B'/B'') 통합 | MDX 03/02/01 자동 매칭 |
| **Phase Z-3** | 컨테이너 검증 + 5 차 Fallback | 안정적 자동 처리 |
| **Phase Z-4** | 전체 통합 + 회귀 검증 | 새 pipeline 완성 |
### 사전 결정 사항 (검토 완료)
- **위계** : slide → slide-base → slide-body → 레이아웃 → Zone → 프레임
- **5 단계 흐름** : MDX 분석/레이아웃 → Zone 텍스트 배치 → 프레임 매칭 → 검토 → 출력
- **매칭 분기** : 완벽 / 어정쩡 / 안 됨 → 후속 작업 차등
- **레이아웃 프리셋** : Type A / B / B' / B'' (기존 4 가지 활용)
- **절대 룰** : 텍스트 원문 무손실, 자유 디자인 금지, MDX 1 = 슬라이드 1
### 사전 작업 진행 상태
**완료** (2026-04-28)
- 32 frame Zone 적용 분류 (`zone_direct` / `zone_adapt` / `zone_extract` / `reference_only`) — [docs/architecture/FRAME-INTEGRATION-MAP.md](docs/architecture/FRAME-INTEGRATION-MAP.md)
- Frame / Style Inventory (32 frame + 18 token + 6 legacy) — [docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md](docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md)
**미진행**
- catalog / runtime 설계 prep
- slide-base 검증
⚠️ **미실행 / 의도적으로 보류** (승인 전)
- 기존 `templates/blocks/` 삭제 / 교체
- catalog / runtime 구현
- `templates/styles/frame-patterns/` 신규 파일 생성
- 새 token (`gap_candidate`) 추가
- legacy structures 6 파일 삭제
> ⚠️ **Phase Z-1 자체는 진행 중**. 위 ✅ 는 *사전 작업 중 일부* 완료 표시.
---
## 기술 스택
| 역할 | 도구 | 비고 |

631
PROCESS_OVERVIEW.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -154,6 +154,15 @@ Phase R은 접근 C로 가기로 합의했으나, 구현에서 기존 블록 선
## 📋 Phase R': 접근 C — 블록 CSS 참고 + AI 구조 결정 (설계 확정)
> ⚠️ **Legacy — Phase Z 로 대체됨 (2026-04-28)**
>
> Phase R' 의 "AI 가 HTML 구조를 직접 생성" 흐름은 Phase Z 에서 다음과 같이 변경됨:
> - **HTML 구조** = `slide-base.html` + 코드 (Jinja2) 가 결정 (AI 가 생성하지 않음)
> - **AI 의 역할** = zone 안의 콘텐츠 / 텍스트 매핑 / 텍스트 다듬기 / 디자인 변형만
> - **자유 디자인 금지** — 항상 Figma 프레임 DB 참고
>
> 아래 R' 설계 내용은 히스토리 / 참고용. 실제 구현은 [`IMPROVEMENT-REDESIGN.md`](IMPROVEMENT-REDESIGN.md) 의 5 단계 흐름 따름.
**상세:** [IMPROVEMENT-PHASE-R-PRIME.md](IMPROVEMENT-PHASE-R-PRIME.md)
### 핵심 전환
@@ -165,6 +174,8 @@ R' (접근 C): 콘텐츠가 구조를 결정 → 블록 CSS를 참고하여 HT
### 프로세스 변경
> ⚠️ **아래 표는 폐기된 Phase R' 기록이며 신규 구현 지시가 아니다.** 새 구현은 [IMPROVEMENT-REDESIGN.md](IMPROVEMENT-REDESIGN.md) 의 Phase Z 5 단계 흐름 따름.
| 단계 | 현재 (P=Q=R) | R' (접근 C) |
|------|-------------|------------|
| 1단계 Kei 분석 | 유지 | 유지 |
@@ -213,15 +224,115 @@ C_reference.png와 동일 수준의 결과를 **자동으로** 생성:
---
## Phase Z: 매칭 시스템 통합 설계 (2026-04-28)
### 배경
별도 검증한 매칭 시스템 (V1~V4) 을 기존 design_agent pipeline 에 통합하기 위한 설계.
**매칭 시스템 (`tests/matching/`) 검증 결과**:
- V1 키워드 매칭 (Logistic Regression 가중치, TARGET 4/4 LOOCV 검증)
- V2 의미 매칭 (ko-sroberta cosine)
- V3 구조 매칭 (layout / content_affinity / structure_intent)
- V4 종합 판정 (5축 + 라벨)
- TARGET 정답률 3/4 (75%), BM25 / IDF 보다 우위
- V4 slot 축 ablation: Top-1 매칭 7/7 동일 (slot 축 frame 선별 무영향)
### 설계 결정 사항
**위계 + 용어 정리** :
```
[ slide ] 1280×720
├─ slide-title ← MDX 대목차 제목
├─ slide-divider (고정)
├─ slide-body ≈ 1200×590 ← 콘텐츠 영역
│ └─ 레이아웃 (Type A/B/B'/B'')
│ └─ Zone (top/bottom_l/bottom_r 등)
│ └─ 프레임 (Figma 디자인 단위)
└─ slide-footer ← MDX 대목차 결론
```
**5 단계 새 흐름**:
1. STAGE 1 — MDX 분석 + 레이아웃 매칭 (Type A/B/B'/B'')
2. STAGE 2 — Zone 별 텍스트 1차 배치
3. STAGE 3 — Zone 별 프레임 매칭 (완벽 / 어정쩡 / 안 됨 분기)
4. STAGE 4 — 프레임 검토 + 컨테이너 조정 (5 차 Fallback)
5. STAGE 5 — HTML 조립 + 검증 + 출력
**핵심 원칙**:
- MDX 1 파일 = 대목차 1 개 = 슬라이드 1 장
- 텍스트 원문 무손실 보존 (본문 미리보기 + 팝업 원문)
- 자유 디자인 금지 (항상 Figma 프레임 DB 참고)
- 불일치 시 레이아웃 회귀 (콘텐츠 줄이지 않고 그릇 변경)
### 산출물 (이번 세션)
| 파일 | 용도 |
|---|---|
| [`IMPROVEMENT-REDESIGN.md`](IMPROVEMENT-REDESIGN.md) | 매칭 시스템 통합 설계 문서 (전체 명세) |
| [`PROCESS_OVERVIEW.html`](PROCESS_OVERVIEW.html) | 임원 보고용 A4 2 페이지 (프로세스 + 구조도) |
| [`tests/PIPELINE.md`](tests/PIPELINE.md) | V1~V4 통합 정리 + frame 단위 명확화 |
| [`tests/pipeline/`](tests/pipeline/) | 매칭 시스템 코드 + 결과 + 보고서 (분류 복사본) |
### 다음 단계 (구현)
`IMPROVEMENT-REDESIGN.md`**단계적 진행 계획** 참조.
- **Phase Z-1**: 통합 prototype (MDX 03 회귀 통과)
- **Phase Z-2**: 매칭 + 프리셋 통합
- **Phase Z-3**: 컨테이너 검증 + Fallback
- **Phase Z-4**: 전체 통합 + 검증
### 발견된 약점 (8가지) — Phase Z 진행 시 대응
`IMPROVEMENT-REDESIGN.md` 11 장 (리스크) + `tests/PROGRESS.md` 약점 표 참조.
핵심 :
- 02-2.2 매칭 실패 (Frame 14 anchor 재라벨링 필요)
- 32 frame DB 사이즈 라벨링 부족 (zone 단위 vs 슬라이드 단위)
- slot 의미 매핑 부재
- V3 콘텐츠 성격 분류 부정확 (키워드 사전 한계)
---
## Phase Z-1 사전 작업 진행 (2026-04-28)
> **Phase Z-1 자체는 진행 중**. 본 entry 는 사전 작업 중 **Frame / Style Inventory 완료** 만 기록.
### ✅ 완료 — Frame / Style Inventory (사전 작업 일부)
| 산출 | 위치 | 내용 |
|---|---|---|
| Frame Integration Map | [`docs/architecture/FRAME-INTEGRATION-MAP.md`](docs/architecture/FRAME-INTEGRATION-MAP.md) | 32 frame Zone 적용 분류 (`zone_direct` / `zone_adapt` / `zone_extract` / `reference_only`). row 21~28 Figma ID 정정. 1171281171 부록 처리 |
| Frame Style Inventory | [`docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md`](docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md) | 32 frame (변환 14 + 미변환 18) + Token 18 행 (covered 7 / gap 5 / hierarchy 3 / hold 3) + Legacy 6 행 |
### ⬜ 다음 — Phase Z-2 본격 (catalog / runtime 설계)
- Phase Z-1 의 catalog / runtime 설계 prep 부분 (slide-base 검증 등) 은 미진행
- Phase Z-2 본격 (매칭 + 4 프리셋 통합) 도 미진행
### ⚠️ 미실행 / 의도적으로 보류
- 기존 `templates/blocks/` 삭제 / 교체 실행
- catalog / runtime 구현
- `templates/styles/frame-patterns/` 신규 파일 생성
- `templates/styles/tokens/``gap_candidate` token 추가
- legacy structures 6 파일 삭제
→ 모두 **승인 전 보류**. Frame / Style Inventory 는 추출 / 검증 단계이고, 실제 변경은 별도 승인 단계.
---
## 프로젝트 구조
| 항목 | 파일 | 상태 |
|------|------|------|
| 프로젝트 규칙 | CLAUDE.md | Phase R' 반영 |
| 개선 계획 | IMPROVEMENT.md | Phase R' 반영 |
| 진행 추적 | PROGRESS.md | 이 파일 (2026-03-30 갱신) |
| 프로젝트 규칙 | CLAUDE.md | Phase Z 반영 (2026-04-28) |
| 개선 계획 (이전) | IMPROVEMENT.md | Phase R' 반영 |
| **개선 계획 (신규)** | **IMPROVEMENT-REDESIGN.md** | **Phase Z 매칭 시스템 통합 (2026-04-28)** |
| 임원 보고용 | PROCESS_OVERVIEW.html | Phase Z 흐름 반영 |
| 진행 추적 | PROGRESS.md | 이 파일 (2026-04-28 갱신) |
| 전체 감사 | CLEANUP-AUDIT.md | 유효/무력화 분류 완료 |
| Phase별 상세 | IMPROVEMENT-PHASE-{A~R'}.md | 각 Phase 기록 |
| Phase R 실패 기록 | IMPROVEMENT-PHASE-R.md | 블록 선택 위에 variant 패치 — 실패 |
| Phase R' 설계 | IMPROVEMENT-PHASE-R-PRIME.md | 접근 C 기반 재설계 |
| README | README.md | Phase R' 반영 |
| 매칭 시스템 (별도 검증) | tests/PIPELINE.md + tests/pipeline/ | V1~V4 검증 완료 (2026-04-27) |
| README | README.md | Phase Z 반영 |

View File

@@ -19,9 +19,12 @@ MDX 입력 → 정규화 → 꼭지 추출(AI) → zone 구분 → BEPs 매칭
### 구조
- **slide-base:** 1280×720 슬라이드 프레임. 대목차 + 구분선 + 본문 영역 + 핵심 인사이트(footer)
- **zone:** 본문 영역 안에서 중목차(##) 기준으로 나뉘는 영역 (top/bottom 등)
- **블록:** zone 안에 들어가는 디자인 단위. Figma에서 추출한 BEPs 디자인을 HTML/CSS로 변환한 것
- **catalog:** 블록의 메타 정보 (구조, 슬롯, 매칭 조건)
- **레이아웃:** slide-body 안의 zone 분배 형태 (Type A/B/B'/B'')
- **zone:** 레이아웃이 정한 콘텐츠 영역 (top/bottom 등)
- **프레임 (구 "블록"):** zone 안에 들어가는 디자인 단위. Figma에서 추출한 BEPs 디자인을 HTML/CSS로 변환한 것
- **catalog:** 프레임의 메타 정보 (구조, 슬롯, 매칭 조건)
> ⚠️ Phase Z (2026-04-28) 부터 "블록" → "프레임" 으로 용어 통일. 기존 코드 (`block_reference.py`, `templates/blocks/`, `BLOCK-RULES.md` 등) 는 점진적 정리.
### 주요 파일
@@ -70,20 +73,32 @@ MDX 입력 → 정규화 → 꼭지 추출(AI) → zone 구분 → BEPs 매칭
### AS-IS → TO-BE
> ⚠️ 아래 AS-IS / TO-BE 는 Phase Q ~ T 시점의 흐름. **현재 (Phase Z) 흐름은 [`IMPROVEMENT-REDESIGN.md`](IMPROVEMENT-REDESIGN.md)** 참조.
```
AS-IS:
AS-IS (Phase Q ~ T):
AI가 먼저 꼭지를 추출하고
→ 매칭 블록이 있으면 삽입, 없으면 코드가 1회 고정 렌더
→ 빈 공간이 있어도 그냥 둠
→ 블록마다 font-size, color가 직접 박혀있어서 섞이면 위계 안 맞음
TO-BE:
TO-BE (Phase Q ~ T):
중목차 기준으로 zone을 먼저 나누고
→ TF-IDF로 BEPs 매칭 시도
→ 매칭되면 블록 삽입 + 크기 조절 (direct-fit)
→ 안 되면 AI가 꼭지 정리 + 유사 디자인으로 redesign + 반복 조정 (recipe)
→ 빈 공간/overflow를 자동 재분배
→ 모든 블록이 토큰 기반이라 스타일 통일
NEW (Phase Z, 2026-04-28 ~):
STAGE 1) MDX 분석 + 레이아웃 매칭 (Type A/B/B'/B'')
STAGE 2) Zone 별 텍스트 1차 배치
STAGE 3) Zone 별 프레임 매칭 — V1~V4 (완벽/어정쩡/안됨 분기)
STAGE 4) 프레임 검토 + 컨테이너 조정 (5차 Fallback)
STAGE 5) HTML 조립 (slide-base + Jinja2) + 검증 + 출력
⭐ AI = zone 안 콘텐츠만 / HTML 구조 = 코드 (Jinja2)
⭐ 자유 디자인 금지 — 프레임 DB 참고 필수
⭐ MDX 원문 무손실 보존
```
---
@@ -97,21 +112,33 @@ TO-BE:
| MDX 01 (Type A, sidebar 구조) | ✅ 완료 (개별) | 미연결 |
| 토큰 기반 CSS 체계 | - | ✅ 정의 완료, slide-base 적용 |
| Figma 블록 추출 | - | 진행 중 (`figma_to_html_agent/blocks/`) |
| **매칭 시스템 (V1~V4)** | - | **✅ 별도 검증 완료** (`tests/`) — TARGET 3/4 |
| **Phase Z 통합 설계** | - | **✅ 설계 완료** (`IMPROVEMENT-REDESIGN.md`) — 구현 대기 |
---
## 다음 단계 방향
## 다음 단계 방향 — Phase Z 매칭 시스템 통합
| 순서 | 단계 | 내용 |
|------|------|------|
| 1 | 폴더 구조 정리 | structures/recipes/legacy 분리 |
| 2 | 기존 블록 점진 전환 | 분류(direct-fit/recipe/rewrite) → 토큰 기반 전환 |
| 3 | catalog 고도화 | 파일명 중심 → 속성 테이블 기반 매칭 |
| 4 | 파이프라인 연결 | TF-IDF 매칭 + recipe/composition 경로 |
| 5 | fit 루프 확장 | 빈 공간 재분배, preview 축약, 자동 조정 반복 |
| 6 | 시각 품질 검증 | 정렬, 위계, 가독성 검증 강화 |
상세: [IMPROVEMENT-REDESIGN.md](IMPROVEMENT-REDESIGN.md)
상세: [IMPROVEMENT-PLAN.md](docs/architecture/IMPROVEMENT-PLAN.md)
| 단계 | 내용 |
|------|------|
| Phase Z-1 | 통합 prototype — Stage 1.7 만 V4 로 교체, MDX 03 회귀 |
| Phase Z-2 | 매칭 + 프리셋 (Type A/B/B'/B'') 통합 |
| Phase Z-3 | 컨테이너 검증 + 5 차 Fallback |
| Phase Z-4 | 전체 통합 + 검증 |
**핵심 위계** (Phase Z 정리) :
```
slide → slide-base → slide-body → 레이아웃 → Zone → 프레임
```
**5 단계 새 흐름** :
1. MDX 분석 + 레이아웃 매칭
2. Zone 별 텍스트 배치
3. Zone 별 프레임 매칭 (완벽 / 어정쩡 / 안 됨)
4. 프레임 검토 + 컨테이너 조정
5. HTML 조립 + 검증 + 출력
---
@@ -119,6 +146,13 @@ TO-BE:
| 문서 | 내용 |
|------|------|
| [IMPROVEMENT-PLAN.md](docs/architecture/IMPROVEMENT-PLAN.md) | 개선 설계 (목표/방향/6단계 계획) |
| [TOKENS-v1.md](docs/architecture/TOKENS-v1.md) | 토큰 위계 기준표 초안 |
| [BLOCK-RULES.md](docs/architecture/BLOCK-RULES.md) | 블록 작성 규칙 (에이전트 간 계약서) |
| [IMPROVEMENT-REDESIGN.md](IMPROVEMENT-REDESIGN.md) | **Phase Z 통합 설계** (위계 / 용어 / 5 단계 흐름) |
| [docs/architecture/FRAME-INTEGRATION-MAP.md](docs/architecture/FRAME-INTEGRATION-MAP.md) | **32 frame Zone 적용 분류** (`zone_direct/adapt/extract/reference_only`) |
| [docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md](docs/architecture/PHASE-Z-FRAME-STYLE-INVENTORY.md) | **Frame / Style / Token 인벤토리** (32 frame + 18 token + 6 legacy) |
| [PROCESS_OVERVIEW.html](PROCESS_OVERVIEW.html) | 임원 보고용 A4 2 페이지 (프로세스 + 구조도) |
| [tests/PIPELINE.md](tests/PIPELINE.md) | 매칭 시스템 (V1~V4) 통합 정리 |
| [PROGRESS.md](PROGRESS.md) | 전체 Phase 이력 (Phase 1~T + R' + Z) |
| [CLAUDE.md](CLAUDE.md) | 프로젝트 규칙 + Phase Z 위계 |
| [IMPROVEMENT-PLAN.md](docs/architecture/IMPROVEMENT-PLAN.md) | 개선 설계 (이전 Phase Q 까지) |
| [TOKENS-v1.md](docs/architecture/TOKENS-v1.md) | 토큰 위계 기준표 |
| [BLOCK-RULES.md](docs/architecture/BLOCK-RULES.md) | 블록 작성 규칙 |

View File

@@ -0,0 +1,226 @@
# Phase Z — change log
**역할** : axis-by-axis 의사결정 / reframe / 폐기 / lock 의 *history* 기록.
**관련 문서** :
- [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) — 22-step 도면 (구조 lock)
- [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md) — 현재 snapshot (자주 갱신)
- 본 문서 = 누적 history (newest-on-top, append-only)
**본 문서의 목적** :
- "왜 이 안을 폐기했는지" / "왜 이 axis 로 쪼갰는지" 의 회귀 시 trace
- STATUS-BOARD 가 *현재* 라면 본 문서는 *과거*
- entry 단위 = 한 axis (한 결정 단위)
**format** :
```
## YYYY-MM-DD — Step X-Y / axis 이름
scope:
- 무엇이 추가 / 변경 / 폐기됐나
lock:
- 사용자 결정 lock
why:
- 의사결정 근거 / 회귀 방지 사실
next axis:
- 이어지는 다음 axis
```
---
## 2026-05-08 #2 — axis naming / scope 정정 (6-B 폐기 + F14 표현 정정 + label gate policy 분리)
scope:
- **6-B (frame ownership transfer) 폐기** — misframed axis.
- **"F14 / F11 / F18 frame contract 등록" 표현 폐기** — `V4 frame 후보 → Phase Z render path 연결 확장` 으로 rename.
- **label gate policy 재검토 = 별 axis 로 분리** (= 6-B 안에 숨어 있던 진짜 content).
- forex/status.md §0 Refinement F + §3 진행 순서 + §5 갱신.
- `PHASE-Z-PIPELINE-STATUS-BOARD.md` §3 item 5 + §6 갱신.
- `PHASE-Z-PIPELINE-OVERVIEW.md` Step 6 Gap note 정정.
lock:
[6-B 폐기]
- V4 = frame 선택 (점수 + label).
- Step 6 = V4 rank-1 을 default 로 *전사* (선택 X, 전사).
- Step 9 = V4 후보를 application_plan 으로 *번역* (재선택 X, 번역).
- 따라서 "Step 6 의 frame 채택 책임을 Step 9 로 이전" = *허구* (Step 6 이 그런 책임을 원래부터 안 가짐).
[F14 표현 정정 — 3 layer 분리 lock]
- Figma → HTML 변환 (`figma_to_html_agent/blocks/`) = 32 frame 모두 끝 (이미 layer).
- V4 catalog (`tests/matching/v4_full32_result.yaml`) = 32 frame 매칭 끝 (이미 layer).
- Phase Z render path = F13 / F29 / F16 만 연결 — 나머지 미연결 (작업 layer).
- 작업 = adapter 박기 (contract + partial + builder + fresh run 검증). *Figma 새 디자인 X / V4 새 매칭 X*.
- frame 당 4 가지 entry :
1. `templates/phase_z2/catalog/frame_contracts.yaml` — contract entry.
2. `templates/phase_z2/families/{template_id}.html` — Phase Z runtime partial. *`figma_to_html_agent/blocks/{frame_id}/index.html` source 와 별도 layer* — slot/payload 받게 변형.
3. `src/phase_z2_mapper.py` — PAYLOAD_BUILDERS / ITEM_PARSERS entry.
4. fresh run 검증.
[label gate policy 분리]
- 현재 Step 6 의 `MVP1_ALLOWED_STATUSES = {matched_zone, adapt_matched_zone}` binary gate.
- restructure / reject label = 자동 drop (현재).
- 정책 question = restructure 도 unit 으로 살려둘지 / Step 9 v0 4-mode 해석으로 대체할지.
- 자체 가치는 Step 17 / 19 fallback 또는 Step 9 v1 활성화 후 발현 — 단독 axis 가치 X.
- 현 시점 = 별 axis 로 *명시* 만 (구현 axis X).
why:
- mental model 정정 = layer 책임을 정확히 박기.
- 6-B 와 F14 표현 모두 layer 가 잘못 그려졌었음 — 같은 종류의 정정.
- label gate 는 6-B 안에 숨어 있던 *진짜 axis* — 분리해서 추적성 확보.
- compat 매트릭스 폐기 (2026-05-08 #1 entry) 와 같은 패턴 — *misframed axis 는 폐기 + history 박힘*.
next axis (별 axis 후보 list):
- (A) V4 frame 후보 → Phase Z render path 연결 확장 (F14 / F11 / F18 등 미연결 frame adapter)
- (B) Step 17 details_popup_escalation 구현
- (C) Step 4 unit_count 산출 logic
- (D) Step 3 / 4 render path 활성화 (Layer A activation)
- (E) label gate policy 재검토 (= 6-B 의 진짜 content 였던 것)
- (F) Step 9 v1 scoring + auto decision
---
## 2026-05-08 — Step 5/6/9 boundary reframe
scope:
- Step 9 의 *"compat 매트릭스" 안 폐기* (region × frame slot count 표) — V4 cardinality 재계산 위험.
- Step 9 = **application_plan** 으로 reframe — V4 후보 + layout/region/display 통합 *적용 계획* (V4 axis 재계산 X).
- Step 5/6/7/8/9 boundary 재정리 — 진행 순서 lock = `5 → 6-A → 7-conn → 8-conn → 6-B → 9`.
- Step 6 = `6-A` (additive, logic 무변) + `6-B` (logic 변화) 두 axis 로 분리.
lock:
- V4 점수 재계산 X — V4 의 anchor / cardinality / relation / slot / content 산식은 Step 5 에서 끝남.
- V4 후보 삭제 X — Step 5 = non-reject max-6 후보 list (raw 32 entry 는 `tests/matching/v4_full32_result.yaml` 영속).
- V4 label 존중 — Step 9 = label (use_as_is / light_edit / restructure / reject) → application_mode (direct_insert / same_frame_with_adjustment / layout_or_region_change / exclude) 변환.
- 진행 순서 = 5 → 6-A → 7-conn → 8-conn → 6-B → 9 (risk 분산: 6-B 를 9 신설 직전으로 미룸).
why:
- V4 confidence 산식 (`tests/matching/template_fit.py`) 의 5 axis 가 이미 frame 자체 적합도 평가. compat 매트릭스 = 동일 axis 재계산 → 폐기 결정.
- V4 가 ** 보는 영역 = layout / zone / region / display / contract — 이게 Step 9 의 진짜 영역.
- Step 6 가 V4 rank-1 즉시 frame 채택 + layout 일부 결정해버려서 Step 9 가 받을 candidate list 없음 → Step 5 (rank-1 → top-N) + Step 6 (frame 채택 빼기) 선행 reframe 필요.
- 6-A / 6-B 분리 이유 = 6-A 는 schema 확장 (안전), 6-B 는 selection logic 변경 (위험). 함께 하면 위험 누적.
next axis:
- Step 5 보완 = `lookup_v4_candidates()` 추가 (non-reject max-6) + `step05_v4_evidence.json` schema 확장 (`v4_candidates` list + `candidate_status`).
- backward compat 유지 = `lookup_v4_match()` (rank-1) 보존, Step 6 호출처 무변.
---
## 2026-05-07 — Step 8-B-2 / display strategy candidate function
scope:
- `select_display_strategy_candidates(content_type, long_text, large_table, fits_in_region)` 함수 추가.
- `load_display_strategies()` + `DISPLAY_STRATEGIES` 모듈 변수 + `_KNOWN_CONTENT_TYPES` frozenset.
- catalog (`templates/phase_z2/regions/display_strategies.yaml`) 의 `applies_to` / `forbidden_for` 직독 기반 hard filter.
lock:
- `text_block / table / image / details``dropped` 절대 X (catalog `forbidden_for` 박혀 있음 — 원문 무손실).
- hard filter = catalog `applies_to` / `forbidden_for` 직독 (hardcoding X).
- escalate signal (`long_text` / `large_table` / `fits_in_region == False`) → `inline_preview_with_details` 우선.
- decorative_element 의 dropped 는 `inline_full` 후순위 (공간 부족 신호 전에는 일단 보여주기).
- unknown content_type → `ValueError` (catalog scope 위반).
why:
- 8-A catalog 위에 candidate 함수 추가 = passive piece 패턴 (7-A/B + 8-A 와 일관).
- 원문 무손실 lock 의 코드 enforcement.
- Step 9 application_plan 의 display_strategy axis input.
next axis:
- Step 9 진입 시도 (compat 매트릭스 안) → 폐기 → boundary reframe (2026-05-08 entry 참조).
---
## 2026-05-07 — Step 8-B-1 / region layout candidate function
scope:
- `load_region_layouts()` + `REGION_LAYOUTS` 모듈 변수.
- `select_region_layout_candidates(region_count, ..., ratio_asymmetric, ...)` 함수 추가 — SPEC §2.5 sequential first-match decision tree.
- 6 entry catalog 와 1:1 일치 (catalog 직독, hardcoding X).
lock:
- `region_count < 1 or > 4``ValueError` (SPEC §2.5 vocabulary scope).
- `region-vertical-stack``default_fallback: true` (SPEC 박힘).
- `ratio_asymmetric` 게이트 = `region-main-support` 의 catalog `candidate_when` 과 1:1 일치 (initial 누락 → Codex 검출 → 박힘).
- `region_count == 1``[region-single]` only (fallback X).
why:
- SPEC §2.5 결정 트리 6 entry 의 코드 enforcement.
- catalog 와 함수 1:1 일치 (drift 방지) — 초기 ratio_asymmetric 시그니처 누락 발견 후 정정.
- Step 9 application_plan 의 region axis input.
next axis:
- Step 8-B-2 (display strategy candidate function).
---
## 2026-05-07 — Step 8-A / regions catalog (region + display)
scope:
- `templates/phase_z2/regions/region_layouts.yaml` 신설 — SPEC §2.5 6 entry (region-single / vertical-stack / horizontal-split / main-support / preview-details / grid-2x2).
- `templates/phase_z2/regions/display_strategies.yaml` 신설 — 4 entry (inline_full / inline_preview_with_details / details_only / dropped).
- `templates/phase_z2/regions/regions_preview.html` 신설 — 6 region card + 4 display strategy card 시각 검증.
lock:
- **axis 분리** : region (structure) ≠ display (policy). 두 catalog 는 직교 — 같은 region 이 다른 display strategy 와 결합 가능.
- **single source of truth** : `preserves_original` 은 display_strategies 의 책임 (region_layouts 에서 제거 — 사용자 검토 후 박힘).
- `detail_trigger.placement: top-right` (사용자 lock — 본문 흐름 방해 X / 보조 동작 위치 / popup 진입 일관 위치).
- `dropped``applies_to: [decorative_element]` only / `forbidden_for: [text_block, table, image, details]` (사용자 절대 룰 — 원문 무손실).
- `inline_preview_with_details` / `details_only``preserves_original: true` (popup 안 원문 보존).
why:
- region 구조 vocabulary 와 display 정책 vocabulary 가 다른 axis. 한 catalog 에 합치면 단일 enum 필드로 표현 안 됨.
- 사용자 절대 lock (text/table/image/details 절대 dropped X) 의 catalog enforcement.
- 자유 디자인 금지 lock 과 정합 — preview HTML 에서 사람이 두 axis 시각 검증 가능.
next axis:
- Step 8-B-1 / 8-B-2 (각 catalog 의 candidate function).
---
## 2026-05-07 — Step 7-B / layout candidate function
scope:
- `select_layout_candidates(unit_count)` 함수 추가 — `templates/phase_z2/layouts/layouts.yaml` catalog 직독 + `unit_count` 매칭.
- 출력 정렬 = `default_selection: true` 우선 + catalog 순서.
lock:
- 입력 = `unit_count` (Step 4 의 placement unit count = section_count + lead_orphan promotion).
- `default_selection: true` 인 entry 가 list 의 첫 위치.
- `unit_count` 매칭 entry 0 개면 빈 list (ValueError X — Step 9 가 fallback path 처리).
why:
- 명명 정정 = `section_count``unit_count` (lead_orphan promotion 후 의미 정확).
- Step 9 application_plan 의 layout axis input (V4 후보 + layout 후보 통합 평가).
- single-decision (`select_layout_preset`) 은 backward compat 으로 유지 — runtime 호출처는 default 만 사용.
next axis:
- Step 8-A (regions catalog).
---
## 2026-05-07 — Step 7-A / layout catalog 분리
scope:
- `templates/phase_z2/layouts/layouts.yaml` 신설 — 8 preset (single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2).
- `templates/phase_z2/layouts/layouts_preview.html` 신설 — 8 preset 시각 검증.
- `load_layout_presets()` 함수 추가 — catalog → 기존 `LAYOUT_PRESETS` 와 동일 dict shape 반환.
- `src/phase_z2_composition.py` 의 hardcoded `LAYOUT_PRESETS` dict → catalog 이전 (logic 무변, backward compat).
lock:
- **필드 정정** = 단일 `status` enum (implemented / defined) → 두 직교 axis 필드로 분리:
- `render_ready: bool` — layout 의 grid 정의 + 검증된 렌더 path 존재 여부
- `default_selection: bool``select_layout_preset()` 의 default 픽 여부 (Step 7-B / 9 의 single-decision 영역)
- `candidate_when` 필드 추가 — Step 7-B / Step 9 input (현재 single-decision logic 은 무시 — inert).
- backward compat 유지 = `load_layout_presets()` 이 같은 dict shape 반환 → `LAYOUT_PRESETS` 사용처 무변.
why:
- 사용자 절대 lock = "모든 catalog data 는 yaml/HTML 에서 사람이 보고 modify 가능" — hardcoded dict 위반.
- `status` 단일 enum 은 *render 가능성**default 선택* 두 axis 를 합쳐서 모호 — render_ready / default_selection 분리 (사용자 검토 후 박힘).
- 모든 기존 8 preset 의 logic 은 무변 — runtime 결과 동일 (regression 0).
next axis:
- Step 7-B (layout candidate function).

View File

@@ -211,6 +211,7 @@ inline preview = 원문의 *일부* 만 빌려 보여주는 것. 원문은 detai
- **Status** : ⚠ partial
- **Code 위치** : `lookup_v4_match()` in `phase_z2_pipeline.py`
- **Gap** : 현재 *rank-1 만* 반환. top-k 사용 안 됨. sibling group 후보도 없음.
- **Note (사용자 잠금 2026-05-08)** : Step 5 axis = *non-reject max-6 후보 list* 로 보완 (rank-1 → top-N). raw 32 entry 는 `v4_full32_result.yaml` 영속, `step05_v4_evidence.json` = 정제 list. `lookup_v4_match()` 유지 (Step 6 backward compat). Step 9 application_plan input.
#### Step 6. Composition Planning
어떤 MDX 덩어리를 하나의 *slide-level zone unit* 으로 볼지 결정. child 따로 / sibling 묶기 / parent 단위.
@@ -219,6 +220,8 @@ inline preview = 원문의 *일부* 만 빌려 보여주는 것. 원문은 detai
- **Status** : ⚠ partial
- **Code 위치** : `src/phase_z2_composition.py` (`plan_composition`, `parent_merged_inferred`, `capacity_fit` integration)
- **Gap** : section_layout_signature / content_object 구조 input 부재 (step 3, 4 가 없어서). frame compatibility 도 rank-1 매칭만 활용.
- **Note (사용자 잠금 2026-05-08)** : Step 6 = *6-A 박힘*. **6-A** (additive, logic 무변) = `CompositionUnit` 에 `v4_candidates: list[V4Match]` 필드 추가, 기존 단일 frame 필드는 `candidates[0]` 호환 유지 (✓ 박힘).
- **Note (사용자 잠금 2026-05-08 #2)** : **6-B (frame ownership transfer) 폐기 = misframed axis**. 정정된 mental model = *V4 가 frame 선택, Step 6 은 V4 rank-1 을 default 로 전사, Step 9 는 V4 후보를 application_plan 으로 번역*. Step 6 은 frame 채택 책임을 *원래부터 안 가짐* — 따라서 "Step 6 frame 채택 책임 → Step 9 이전" = 허구. 6-B 의 *실제 가능한 코드 변화* = MVP1_ALLOWED_STATUSES binary gate 위치 결정 → 이건 별 axis (label gate policy 재검토). 자세한 사유 = `PHASE-Z-CHANGE-LOG.md` 2026-05-08 #2 entry.
#### Step 7. Slide-Level Layout Planning
composition unit 개수와 성격을 보고 slide 전체 layout 선택. *기존 Type A/B/B'/B'' 의 후속 — 8-vocabulary 로 명시화*.
@@ -255,6 +258,7 @@ composition unit 개수와 성격을 보고 slide 전체 layout 선택. *기존
- **Status** : ⚠ partial — *step 5 와 분리되지 않음 + region-level 미구현 (zone 단위 만)*
- **Code 위치** : `plan_composition()` 이 V4 rank-1 즉시 선택 (step 5 와 conflate, zone 단위)
- **Gap** : top-k 활용 / composition 제약 반영한 final 단계가 없음. *region-level 매칭 부재* (현재 zone 단위만). restructure label 은 현재 *filter* (선택 X). MVP1_ALLOWED_STATUSES = {matched_zone, adapt_matched_zone} 만 통과.
- **Note (사용자 잠금 2026-05-08)** : Step 9 = **application_plan reframe**. V4 후보 (Step 5) + layout 후보 (Step 7-B) + region/display 후보 (Step 8-B-1/2) 통합 적용 계획. *V4 axis 재계산 X* — V4 의 anchor / cardinality / relation / slot / content 산식은 Step 5 에서 끝남. Step 9 = V4 label (use_as_is / light_edit / restructure / reject) → application_mode (direct_insert / same_frame_with_adjustment / layout_or_region_change / exclude) 변환 + layout/region/display 통합. 폐기 안 = "compat 매트릭스" (region × frame slot count) — V4 cardinality 재계산 위험 (PHASE-Z-CHANGE-LOG.md 2026-05-08 entry 참조).
#### Step 10. Frame Contract 확인
선택된 frame 의 contract 읽어서 accepted_content_types / slots / sub_zones / cardinality / capacity / visual_hints / density envelope / asset 확인.

View File

@@ -1,6 +1,6 @@
# Phase Z — pipeline status board
**Snapshot date** : 2026-05-04 (B1~B5 + trace-only runtime 연결 closure 반영 — Layer A telemetry first activation)
**Snapshot date** : 2026-05-08 (Step 7-A/B + 8-A/B-1/B-2 catalog/candidate fn axis closure / Step 5-6-9 boundary reframe lock)
**역할** : 현재 위치표 / grading snapshot. *지도 본문* 은 [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md).
| 문서 | 역할 | 변동 |
@@ -43,8 +43,8 @@ Step 0 은 본체가 아닌 *준비 조건*. Step 1 (MDX 업로드) 부터가 ru
| A | 4 | Section Internal Composition Planning | ⚠ partial (B2 v0 dormant module + trace-only runtime 호출, render path 미연결) |
| A | 5 | Matching Evidence 생성 | ⚠ partial (rank-1 only) |
| A | 6 | Composition Planning | ⚠ partial |
| A | 7 | Slide-Level Layout Planning | ⚠ partial (count-based) |
| A | 8 | Zone + Internal Region Ratio Planning | ⚠ partial (zone-level horizontal-2 만 dynamic, region-level 은 B2 안 partial) |
| A | 7 | Slide-Level Layout Planning | ⚠ partial (count-based / 7-A catalog + 7-B candidate fn 추가, runtime 호출처 X) |
| A | 8 | Zone + Internal Region Ratio Planning | ⚠ partial (zone-level horizontal-2 만 dynamic / 8-A region+display catalog + 8-B-1/2 candidate fn 추가, runtime 호출처 X / region-level 은 B2 안 partial) |
| A | 9 | Region-Level Frame / Display Selection | ⚠ partial (B4 가 catalog cover + declaration order 로 frame 선택 분담 / V4 evidence 미통합 / Step 5 와 conflate 잔존) |
| A | 10 | Frame Contract 확인 | ⚠ partial (B3 의 accepted_content_types + sub_zones 선언 추가 — B4 만 읽음, mapper 미읽음 / density envelope 별 axis) |
| A | 11 | Content Unit / Child Group → Internal Region → Frame Slot Mapping | ⚠ partial (B4 v0 dormant 2-stage + region 1:1 sub_zone + narrowest first + trace-only runtime 호출, render path 미연결) |
@@ -94,6 +94,20 @@ Step 0 은 본체가 아닌 *준비 조건*. Step 1 (MDX 업로드) 부터가 ru
- tabular_overflow / image_aspect_mismatch 검사 부재 (Step 14)
- layout_adjust / frame_reselect / details_popup_escalation / image_fit /
frame_internal_fit_candidate (Step 17 missing actions) 미구현
5. Step 5 / 6 / 9 boundary axis breakdown (2026-05-08 lock + 2026-05-08 #2 정정)
- Step 5 = rank-1 only → non-reject max-6 후보 list 로 보완 (✓ 박힘)
- Step 6-A = CompositionUnit v4_candidates 필드 additive (✓ 박힘)
- Step 7-conn / Step 8-conn = layout / region / display candidates artifact 연결 (✓ 박힘, Step 8 placeholder signals)
- Step 9 v0 = passive application_plan artifact (✓ 박힘 — V4 후보 + layout/region/display
통합 적용 계획, V4 axis 재계산 X)
- 폐기된 안 (2 가지) :
· "compat 매트릭스" (region × frame slot count) — V4 cardinality 재계산 위험
(CHANGE-LOG.md 2026-05-08 entry 참조)
· "6-B (frame ownership transfer)" — misframed axis. V4 가 frame 선택, Step 6 은
전사, Step 9 는 번역. Step 6 의 frame 채택 책임 = 허구 (CHANGE-LOG.md
2026-05-08 #2 entry 참조). 6-B 의 진짜 content (label gate policy) 는 별 axis.
- 닫힌 axis = 5 / 6-A / 7-conn / 8-conn / Step 9 v0
```
**Cross-cutting Layer A — 진전 단계 정리** :
@@ -147,7 +161,7 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
## 6. 현재 병목 (한 줄)
> 현재 Phase Z 의 *Layer A pre-render planning* (Step 3 / 4 / 11) 은 본 session 작업으로 ❌ → ⚠ partial 전이 (B1/B2/B4 dormant module + trace-only runtime 호출). *Layer A telemetry 의 first activation* — debug.json 의 placement_trace per-zone + frame_slot_metrics F29 partial 기록. 단 **render path 활성화는 미완** : B4 PlacementPlan 이 mapper output 을 *대체하지 않고* trace-only / region-id / content_unit_id marker 가 partial template 에 *미주입* / B4 frame_selection 이 V4 evidence *미통합*. 핵심 다음 axis = **(B5 후속) render path 의 placement_trace 활용 + region marker runtime activation + V4 통합**. *runtime contract-registered / verified frame set 이 text-frame 중심* 한계는 잔존 (frame inventory audit / refinement 별 axis).
> 현재 Phase Z 의 *Layer A pre-render planning* (Step 3 / 4 / 11) 은 본 session 작업으로 ❌ → ⚠ partial 전이 (B1/B2/B4 dormant module + trace-only runtime 호출). *Layer A telemetry 의 first activation* — debug.json 의 placement_trace per-zone + frame_slot_metrics F29 partial 기록. 단 **render path 활성화는 미완** : B4 PlacementPlan 이 mapper output 을 *대체하지 않고* trace-only / region-id / content_unit_id marker 가 partial template 에 *미주입* / B4 frame_selection 이 V4 evidence *미통합*. **Step 5/6/9 boundary axis breakdown 닫힘** (Step 5 / 6-A / 7-conn / 8-conn / Step 9 v0 박힘 — passive application_plan artifact). 6-B (frame ownership transfer) = misframed axis 폐기 (CHANGE-LOG.md 2026-05-08 #2). 핵심 다음 axis 후보 (별 axis lock) = **(A) V4 frame 후보 → Phase Z render path 연결 확장 (F14/F11/F18 등 미연결 frame adapter — contract + partial + builder, *figma 새 디자인 X / V4 새 매칭 X*)**, **(B) Step 17 details_popup_escalation**, **(C) Step 4 unit_count 산출**, **(D) Step 3/4 render path 활성화 (Layer A activation)**, **(E) label gate policy 재검토 (= 6-B 의 진짜 content 였던 것)**, **(F) Step 9 v1 scoring + auto decision**. *runtime contract-registered / verified frame set 이 text-frame 중심* 한계는 (A) 가 직접 푸는 axis.
---

View File

@@ -0,0 +1,77 @@
# 요청: Figma 전체 프레임 texts.md 추출
## 목표
Figma 파일의 **모든 프레임**에서 texts.md를 추출한다.
HTML, CSS, 이미지 등은 불필요. **텍스트만** 추출.
## Figma 파일
- URL: https://www.figma.com/design/9S6LsQyO6zlRxtiqZccOUM/Untitled?node-id=29-373&t=DjVfH90i8r4YiiM6-1
## 이미 완료된 프레임 (14개)
아래 프레임들은 이미 texts.md가 있으므로 **건너뛴다**.
```
1171281172, 1171281178, 1171281180, 1171281189,
1171281190, 1171281191, 1171281193, 1171281194,
1171281195, 1171281201, 1171281202, 1171281203,
1171281204, 1171281208
```
## 작업 내용
1. Figma 파일에서 **최상위 프레임 전체 목록** 조회
2. 이미 완료된 14개를 제외한 **나머지 프레임들**에 대해
3. 각 프레임별로 `figma_to_html_agent/blocks/{frame_id}/texts.md` 생성
## texts.md 포맷
기존과 동일한 구조:
```markdown
# Frame {frame_id} — 텍스트 (TF-IDF 매칭용)
> 프레임 안의 모든 텍스트를 빠짐없이 추출.
## 타이틀
프레임의 메인 제목
## 서브헤더 (있으면)
서브 제목
## 열1: 라벨 (열 구조인 경우)
### 소제목
텍스트 내용
## 행1 (행 구조인 경우)
라벨
본문 텍스트
## 결론 (있으면)
결론 텍스트
```
### 핵심 규칙
- 프레임 안의 **모든 텍스트 노드**를 빠짐없이 추출
- 위치/크기 기준으로 **타이틀/서브/본문** 구분
- 큰 텍스트(상단) → 타이틀
- 중간 텍스트 → 서브헤더/라벨
- 작은 텍스트(본문) → body
- 열/행 구조가 보이면 `## 열1`, `## 행1` 등으로 구분
- MCP `get_metadata` + `get_design_context`로 텍스트 전수 대조
## 저장 위치
```
figma_to_html_agent/blocks/{frame_id}/texts.md
```
프레임 폴더가 없으면 새로 생성.
## 용도
이 texts.md는 나중에 **MDX 중목차/소목차와 TF-IDF 매칭**할 때 사용됩니다.
그래서 텍스트가 빠지면 매칭이 안 되므로, **빠짐없이** 추출하는 것이 중요합니다.

View File

@@ -1,32 +1,36 @@
"""MDX 03을 기존 파이프라인으로 Stage 1.7까지 돌린 뒤, 산출물을 저장.
"""MDX 03을 기존 파이프라인 (Phase Q) 또는 Phase Z-2 MVP-1 로 실행.
파이프라인 코드 그대로 사용:
- Stage 0: mdx_normalizer
- Stage 1A: kei_client.classify_content (Kei API)
- Stage 1B: kei_client.refine_concepts + generate_structured_text
- Stage 1.5a: space_allocator (컨테이너 계산 + font_hierarchy)
- Stage 1.7: block_reference (블록 선택)
기본 (no flag) : 기존 Phase Q 파이프라인 — async, Kei API 사용
- Stage 0: mdx_normalizer
- Stage 1A/1B: kei_client (분류 + 정제)
- Stage 1.5a: space_allocator
- Stage 1.7: block_reference
- 출력 : data/runs/{run_id}/final.html
Stage 2(조립)는 여기서 하지 않음 — 산출물만 저장.
`--phase-z2` flag : Phase Z-2 MVP-1 — sync, AI 미사용, 결정론적
- matched_zone only (V4 use_as_is) — 그 외 abort + error.json
- 출력 : data/runs/{run_id}/phase_z2/final.html
- 상세 : src/phase_z2_pipeline.py + docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md § 16
"""
import argparse
import asyncio
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from src.pipeline import generate_slide
DEFAULT_MDX = Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")
async def main():
mdx_path = Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")
async def run_phase_q(mdx_path: Path):
"""기존 Phase Q 파이프라인 실행."""
from src.pipeline import generate_slide
content = mdx_path.read_text(encoding="utf-8")
print(f"MDX 03: {mdx_path.name}")
print(f"내용 길이: {len(content)}")
print()
print(f"[Phase Q] MDX: {mdx_path.name}")
print(f" 내용 길이: {len(content)}\n")
start = time.time()
async for event in generate_slide(content, base_path=str(mdx_path.parent)):
@@ -39,10 +43,33 @@ async def main():
print(f"\n완료! ({elapsed:.1f}초)")
if isinstance(data, dict):
run_id = data.get("run_id", "")
print(f"run_id: {run_id}")
print(f"결과: data/runs/{run_id}/")
print(f" run_id: {run_id}")
print(f" 결과: data/runs/{run_id}/")
elif ev_type == "error":
print(f" 에러: {data}")
print(f" 에러: {data}", file=sys.stderr)
asyncio.run(main())
def run_phase_z2(mdx_path: Path, run_id: str = None):
"""Phase Z-2 MVP-1 파이프라인 실행 (sync, AI 미사용)."""
from src.phase_z2_pipeline import run_phase_z2_mvp1
run_phase_z2_mvp1(mdx_path, run_id)
def main():
parser = argparse.ArgumentParser(description="MDX 03 pipeline runner.")
parser.add_argument("--phase-z2", action="store_true",
help="Phase Z-2 MVP-1 (sync, AI 미사용, matched_zone only).")
parser.add_argument("--mdx", type=Path, default=DEFAULT_MDX,
help=f"MDX 파일 경로 (default: {DEFAULT_MDX}).")
parser.add_argument("--run-id", type=str, default=None,
help="run_id 오버라이드 (default: timestamp).")
args = parser.parse_args()
if args.phase_z2:
run_phase_z2(args.mdx, args.run_id)
else:
asyncio.run(run_phase_q(args.mdx))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,262 @@
---
title: DX 지연 요인
sidebar:
order: 03
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
<br/>
---
<br/>
## 2. DX 추진의 실태
<br/>
### 2.1 정책 및 발주 체계
- **실질적 기술 경쟁을 저해하는 정책 집행**
- 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행
- 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행
<br/>
- **적용 효과가 있는 사례도 없이 방침부터 도입**
- DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족
- BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발
<br/>
- **엔지니어링 S/W에 대한 개념 부재**
- 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택
- 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸
<br/>
- **기술투자(R&D) 없는 성과 창출 기대**
- 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각
- 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>
<img src="/assets/images/발주처반응.png" />
</details>
### 2.2 조직 및 수행 역량
- **공학적 개념 정립 부재**
- DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중
- 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존
<br/>
- **‘본업 기술력 확보’ 우선의 개념 부재**
- 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족
- 국가·발주처의 지침·방침에만 의존한 업체의 기술력
<br/>
- **DX/BIM의 근본 취지와 목표의 이해 부족**
- DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재
- 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피
<br/>
- **과거의 타성에 머무르고 있는 기술자 집단**
- 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지
- 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>
<img src="/assets/images/설계시공업계반응.png" />
</details>
<br/>
---
:::note[핵심 요약]
* 검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.
:::

262
samples/mdx_batch/04.mdx Normal file
View File

@@ -0,0 +1,262 @@
---
title: DX 지연 요인
sidebar:
order: 03
---
## 1. DX에 대한 인식
<br/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifycontent: 'center' }}>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>기술 및 소프트웨어 이해도</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #1a73e8',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"무슨 말인지 잘 모르겠다, 어디까지 어떻게 해야 하는지 모르겠다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>기본지침, 시행지침 등 새롭게 알아야 할 게 너무 많다.</li>
<li>3D 모델과 별 차이점을 모르겠다. S/W마다 사용법이 다르다.</li>
<li>필요한 것은 쉽고 간단한 건데, 왜 이렇게 복잡하게 만들까?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>효과와 효율성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #d93025',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"2D 설계 후 전환설계를 수행하는데 효과는 모르겠고, 효율은 낮다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>성과품 작성은 기존과 같게 하고, 추가 업무만 발생해 효율이 낮다.</li>
<li>BIM으로 인해 가중되는 업무 대비 효과가 거의 없어 보인다.</li>
<li>결과적으로 큰 차이를 못 느끼겠고, 이런 노력이 정말 가치 있는 일이야?</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>인력 및 교육</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #f9ab00',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"수행 인력이 부족하고, 기존 직원들은 어떻게 교육해야 하나"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>교육시간 손실로 일손이 더 필요해지고, 적응하는데도 시간이 걸린다.</li>
<li>신입사원은 없고 BIM 수행을 할 수 있는 기술자가 부족하다.</li>
<li>여러 회사의 S/W별 사용법이 달라 새로운 S/W에 적용에 시간이 필요하다.</li>
</ul>
</div>
<div style={{
width: '100%',
minWidth: '300px',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>경제적 부담</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #34a853',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"S/W 구독료만 크게 발생되고, 비용 보전은 안 된다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>사용해야 할 S/W의 종류가 너무 많고 복잡한데 모두 필요한가?</li>
<li>모든 Project에 적용되는 것도 아닌데, 다수의 S/W 구독료를 내야 한다.</li>
<li>디지털전환 하기 위해 이 비용을 쓰는게 정말 경제적인 이득이 있는 거야?</li>
</ul>
</div>
<div style={{
width: '100%',
background: '#fff',
border: '1px solid #e1e4e8',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 6px rgba(0,0,0,0.05)',
transition: 'transform 0.2s, box-shadow 0.2s',
cursor: 'default',
marginTop: '10px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-3px)';
e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
}}
>
<div style={{ display: 'flex', alignitems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem'}}>실무 및 적용성</h3>
</div>
<p style={{
background: '#f8f9fa',
borderLeft: '4px solid #8e24aa',
padding: '10px 15px',
margin: '0 0 15px 0',
fontSize: '1rem',
fontWeight: 'bold',
color: '#333'
}}>"실무적 사용에 의한 효율성 증진보다는 홍보, PQ용으로 사용한다"</p>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#555', fontSize: '1.0rem', lineHeight: '1.8' }}>
<li>구체적 적용에 의한 비용, 시간, 품질 등의 효과 사례가 없다.</li>
<li>지형, 선형, 도로, 교량 Model을 만드는 S/W가 모두 달라 적용이 어렵다.</li>
<li>속성정보를 반영하지 않은 형상 위주의 3D 모델 제작에만 초점이 맞춰져 있다.</li>
</ul>
</div>
</div>
<br/>
---
<br/>
## 2. DX 추진의 실태
<br/>
### 2.1 정책 및 발주 체계
- **실질적 기술 경쟁을 저해하는 정책 집행**
- 모든 설계사가 수행 능력을 갖추었다는 전제하에 정책 시행
- 수행 능력이 없는 업체 선정 후 성과품의 수준을 낮추어 시행
<br/>
- **적용 효과가 있는 사례도 없이 방침부터 도입**
- DX/BIM 적용에 따른 실무적 이득이 있다고 판단된 사례 부족
- BIM 지침/방침 등을 시행 경험과 효과 검증도 없이 남발
<br/>
- **엔지니어링 S/W에 대한 개념 부재**
- 다양한 엔지니어링 S/W의 특성에 대한 깊은 이해 없이 범용 S/W 선택
- 대형 Global S/W 회사에 과도한 의존과 이에 예속되는 방침 남발로 전용 S/W 소멸
<br/>
- **기술투자(R&D) 없는 성과 창출 기대**
- 단순 BIM S/W만 구입하면 될 것이라는 안일한 생각
- 실질적 기술 개발 투자 노력 없이 남들이 하는 대로 하면 된다는 착각
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>발주처 반응</summary>
<img src="/assets/images/발주처반응.png" />
</details>
### 2.2 조직 및 수행 역량
- **공학적 개념 정립 부재**
- DX와 BIM의 차이점을 명확히 구분하지 못하고 접근 방식과 기술적 도구 사이의 혼란만 가중
- 단순히 기술적 도구의 사용에 초점을 맞추느라 3D모델 제작 S/W에 과도하게 의존
<br/>
- **‘본업 기술력 확보’ 우선의 개념 부재**
- 고도의 전문지식과 현장 경험이 축적된 Manual의 중요성과 필요성에 대한 이해 부족
- 국가·발주처의 지침·방침에만 의존한 업체의 기술력
<br/>
- **DX/BIM의 근본 취지와 목표의 이해 부족**
- DX에 의한 과정의 혁신과 결과물의 변화에 대한 고민 부재
- 기술자가 직접 3D모델을 만들고 수정하며 설계를 수행하지 않고, 별도로 외주 처리하여 본질 회피
<br/>
- **과거의 타성에 머무르고 있는 기술자 집단**
- 설계/감리/시공 임직원들의 Digital 무지와 전략적 무지
- 교육과 학습을 통한 인재 양성보다 당장 실무 활용이 가능한 타사 인력 빼오기에 집중
<br/>
<details>
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>설계·시공업계 반응</summary>
<img src="/assets/images/설계시공업계반응.png" />
</details>
<br/>
---
:::note[핵심 요약]
* 검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족은 DX 지연을 반복시키고 있다.
:::

View File

@@ -0,0 +1,82 @@
"""블록 매칭 비교 스크립트.
기존 tag/item_count 매칭과 새 TF-IDF 매칭을 나란히 비교.
"진짜 좋아졌나?"를 판단하기 위한 도구.
사용법:
python scripts/eval_block_matcher.py
출력:
각 MDX의 중목차별로:
- legacy 매칭 결과 (기존)
- tfidf 매칭 결과 (새)
- 일치 여부
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.mdx_normalizer import normalize_mdx_content as normalize_mdx
from src.pipeline_v2 import match_blocks_for_sections
def evaluate_mdx(mdx_path: Path):
"""단일 MDX에 대해 TF-IDF 매칭 결과를 출력."""
content = mdx_path.read_text(encoding="utf-8")
result = normalize_mdx(content)
sections = result.get("sections", [])
print(f"\n{'='*60}")
print(f"MDX: {mdx_path.name}")
print(f"{'='*60}")
v2_results = match_blocks_for_sections(sections)
for zone_name, info in v2_results.items():
path = info["path"]
match = info.get("match")
sub_titles = info.get("sub_titles", [])
candidates = info.get("candidates", [])
print(f"\n zone: {zone_name}")
print(f" sub_titles: {sub_titles}")
print(f" path: {path}")
if match:
print(f" ✅ direct-fit: {match['block_id']} (score={match['score']})")
else:
print(f" → recipe 경로")
if candidates:
for i, c in enumerate(candidates):
print(f" 후보 {i+1}: {c['block_id']} (score={c['score']})")
else:
print(f" 후보 없음")
def main():
mdx_dir = Path("samples/mdx")
if not mdx_dir.exists():
print(f"MDX 폴더 없음: {mdx_dir}")
return
mdx_files = sorted(mdx_dir.glob("*.mdx"))
if not mdx_files:
print("MDX 파일 없음")
return
print(f"블록 매칭 평가 ({len(mdx_files)}개 MDX)")
print(f"catalog: templates/catalog/blocks.yaml")
for mdx_path in mdx_files:
try:
evaluate_mdx(mdx_path)
except Exception as e:
print(f"\n{mdx_path.name}: {e}")
print(f"\n{'='*60}")
print("평가 완료")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,180 @@
"""모든 Figma 프레임의 스크린샷을 번호 붙은 단일 폴더로 정리.
결과:
data/figma_previews/01.png, 02.png, ..., 32.png
data/figma_previews/index.json ({number: {frame_id, node_id, title}})
"""
from __future__ import annotations
import base64
import json
import sys
from pathlib import Path
from urllib import error, request
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.frame_extractor import extract_all_frames
MCP_URL = "http://127.0.0.1:3845/mcp"
OUT_DIR = Path("data/figma_previews")
# frame_id → node_id (32개, metadata에서 추출)
FRAME_NODE_MAP = {
"1171281172": "145:8352",
"1171281173": "182:2870",
"1171281174": "182:2810",
"1171281175": "182:2829",
"1171281176": "182:3046",
"1171281177": "182:3053",
"1171281178": "145:8394",
"1171281179": "182:3024",
"1171281180": "112:87",
"1171281181": "182:2572",
"1171281182": "182:2523",
"1171281189": "100:65",
"1171281190": "51:99",
"1171281191": "100:132",
"1171281192": "182:2602",
"1171281193": "106:205",
"1171281194": "112:7",
"1171281195": "106:252",
"1171281197": "182:2727",
"1171281198": "182:2766",
"1171281201": "145:8310",
"1171281202": "112:49",
"1171281203": "145:8266",
"1171281204": "145:8223",
"1171281205": "182:2668",
"1171281206": "182:2643",
"1171281208": "145:8504",
"1171281209": "145:8523",
"1171281210": "181:2519",
"1171281211": "181:2520",
"1171281212": "181:2521",
"1171281213": "181:2522",
}
def parse_sse(body: str) -> dict:
for line in body.splitlines():
if line.startswith("data: "):
return json.loads(line[6:])
raise RuntimeError(f"No data line in response: {body[:200]}")
def post(payload: dict, session_id: str | None = None) -> tuple[dict, str | None]:
data = json.dumps(payload).encode()
req = request.Request(
MCP_URL,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
)
if session_id:
req.add_header("mcp-session-id", session_id)
with request.urlopen(req, timeout=60) as resp:
body = resp.read().decode()
sid = resp.headers.get("mcp-session-id")
return (parse_sse(body) if body.strip() else {}, sid)
def initialize() -> str:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "frame-dumper", "version": "1.0"},
},
}
_, sid = post(payload)
notify = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
data = json.dumps(notify).encode()
req = request.Request(
MCP_URL,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"mcp-session-id": sid or "",
},
)
try:
request.urlopen(req, timeout=10).read()
except error.HTTPError:
pass
return sid or ""
def get_screenshot(session_id: str, node_id: str, call_id: int) -> bytes:
payload = {
"jsonrpc": "2.0",
"id": call_id,
"method": "tools/call",
"params": {"name": "get_screenshot", "arguments": {"nodeId": node_id}},
}
resp, _ = post(payload, session_id=session_id)
if "error" in resp:
raise RuntimeError(f"MCP error for {node_id}: {resp['error']}")
for item in resp.get("result", {}).get("content", []):
if item.get("type") == "image":
return base64.b64decode(item["data"])
raise RuntimeError(f"No image in response for {node_id}")
def main() -> int:
# frame_id → title_text 맵
frames = extract_all_frames("figma_to_html_agent/blocks")
title_map = {f["frame_id"]: (f.get("title_text") or "").replace("\n", " ")[:80] for f in frames}
# 정렬된 frame_id 목록에 1부터 번호 매김
frame_ids = sorted(FRAME_NODE_MAP.keys())
OUT_DIR.mkdir(parents=True, exist_ok=True)
print("[init] MCP session...")
sid = initialize()
print(f"[init] session-id={sid}")
index: dict[str, dict] = {}
for i, fid in enumerate(frame_ids, start=1):
node_id = FRAME_NODE_MAP[fid]
num = f"{i:02d}"
out_path = OUT_DIR / f"{num}.png"
title = title_map.get(fid, "")
if out_path.exists():
print(f"[{num}] {fid} (node {node_id}) — 이미 있음, skip")
else:
print(f"[{num}] {fid} (node {node_id}) fetching...")
try:
png = get_screenshot(sid, node_id, 100 + i)
out_path.write_bytes(png)
print(f" saved {len(png)} bytes → {out_path}")
except Exception as e:
print(f" FAILED: {e}")
continue
index[num] = {
"frame_id": fid,
"node_id": node_id,
"title_text": title,
"png": f"{num}.png",
}
(OUT_DIR / "index.json").write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\n[done] {len(index)}개 저장, index: {OUT_DIR/'index.json'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,334 @@
"""17개 콘텐츠 단위를 각각 추출하여 내 매처(src/block_matcher_tfidf.py, 32프레임 IDF 고정)로 매칭.
단위:
1. MDX01-intro — 중목차 앞 본문
2. MDX01-intro-details — 팝업: 혼용 대표 사례
3. MDX01-1 — 중목차: 용어 정의
4. MDX01-2 — 중목차: 용어간 상호관계 (본문, 표 제외)
5. MDX01-2-image — 이미지 캡션: DX와 핵심기술간 상호관계
6. MDX01-2-details — 팝업+표: DX와 BIM 구분 12행
7. MDX02-1 — 중목차: DX의 궁극적 목표
8. MDX02-1-image — 이미지 캡션
9. MDX02-2 — 중목차(컨테이너): 타이틀 + 도입부만
10. MDX02-2.1 — 소목차: 업무 수행 과정의 변화
11. MDX02-2.2 — 소목차: 주체별 기대효과 (본문, 표 제외)
12. MDX02-2.2-table — 표: 발주자/시공자/설계자
13. MDX03-1 — 중목차: 필수 요건
14. MDX03-2 — 중목차(컨테이너)
15. MDX03-2.1 — 소목차: 과정의 혁신 (본문, 표 제외)
16. MDX03-2.1-table — 표: As-is/To-be
17. MDX03-2.2 — 소목차: 결과의 변화
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.block_matcher_tfidf import TfidfBlockMatcher
TOP_K = 3
PREVIEW_DIR = Path("data/figma_previews")
_INDEX: dict[str, dict] = json.loads((PREVIEW_DIR / "index.json").read_text(encoding="utf-8"))
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
def num_of(fid: str) -> str:
return FRAME_TO_NUM.get(fid, f"?({fid})")
def ftitle(matcher: TfidfBlockMatcher, fid: str) -> str:
for f in matcher.frames:
if f["frame_id"] == fid:
return (f.get("title_text") or "").replace("\n", " ")[:50]
return ""
def strip_tags(t: str) -> str:
t = re.sub(r"<[^>]+>", " ", t)
t = re.sub(r"\{/\*.*?\*/\}", " ", t, flags=re.DOTALL)
t = re.sub(r"\{[^{}]*\}", " ", t)
t = re.sub(r"\s+", " ", t).strip()
return t
def extract_details(raw: str) -> list[dict]:
out = []
for m in re.finditer(r"<details\b[^>]*>([\s\S]*?)</details>", raw, re.IGNORECASE):
body = m.group(1)
sm = re.search(r"<summary\b[^>]*>([\s\S]*?)</summary>", body, re.IGNORECASE)
summary = strip_tags(sm.group(1)) if sm else ""
rest = body[sm.end():] if sm else body
out.append({
"summary": summary,
"body": strip_tags(rest),
"start": m.start(),
"end": m.end(),
})
return out
def split_h2(raw: str) -> list[dict]:
"""raw → [{'title', 'body', 'start', 'end'}] for each ## section."""
iters = list(re.finditer(r"^##\s+(.+?)$", raw, re.MULTILINE))
out = []
for i, m in enumerate(iters):
end = iters[i+1].start() if i+1 < len(iters) else len(raw)
out.append({
"title": m.group(1).strip(),
"start": m.start(),
"end": end,
"body_raw": raw[m.end():end],
})
return out
def split_h3(raw: str) -> list[dict]:
iters = list(re.finditer(r"^###\s+(.+?)$", raw, re.MULTILINE))
out = []
for i, m in enumerate(iters):
end = iters[i+1].start() if i+1 < len(iters) else len(raw)
out.append({
"title": m.group(1).strip(),
"start": m.start(),
"end": end,
"body_raw": raw[m.end():end],
})
return out
def extract_tables(raw: str) -> list[str]:
"""Markdown 표 ( | ... | ... | ) 블록을 각각 문자열로 반환."""
lines = raw.splitlines()
tables = []
cur = []
for ln in lines:
if re.match(r"^\s*\|.*\|\s*$", ln):
cur.append(ln.strip())
else:
if len(cur) >= 2:
tables.append("\n".join(cur))
cur = []
if len(cur) >= 2:
tables.append("\n".join(cur))
return tables
def extract_image_captions(raw: str) -> list[str]:
"""![alt](path) + 그 근처의 이탤릭 [그림 N] 캡션 모음."""
out = []
for m in re.finditer(r"!\[([^\]]*)\]\(([^)]+)\)", raw):
alt = m.group(1).strip()
path = m.group(2).strip()
# 뒤 300자 안에 *[그림 ...]* 캡션 찾기
after = raw[m.end():m.end()+400]
cap = re.search(r"\*\[그림[^\]]*\][^*]*\*", after)
caption = cap.group(0).strip("*").strip() if cap else ""
out.append(f"{alt} {path} {caption}".strip())
return out
def remove_details_and_tables(raw: str) -> str:
t = re.sub(r"<details[\s\S]*?</details>", " ", raw, flags=re.IGNORECASE)
# 표 라인 제거
t = "\n".join(ln for ln in t.splitlines() if not re.match(r"^\s*\|.*\|\s*$", ln))
return t
def build_17_units() -> list[dict]:
mdx_dir = Path("samples/mdx")
raw01 = (mdx_dir / "01. 건설산업 DX의 올바른 이해(0127).mdx").read_text(encoding="utf-8")
raw02 = (mdx_dir / "02. DX의 시행 목표 및 기대효과.mdx").read_text(encoding="utf-8")
raw03 = (mdx_dir / "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx").read_text(encoding="utf-8")
units: list[dict] = []
# ─── MDX 01 ───
h2_01 = split_h2(raw01)
intro_01 = raw01[: h2_01[0]["start"]] if h2_01 else raw01
details_01 = extract_details(raw01)
images_01 = extract_image_captions(raw01)
# 1. MDX01-intro: 첫 ## 전 본문(details 제외)
intro_text = remove_details_and_tables(intro_01)
units.append({"id": "MDX01-intro", "kind": "중목차 앞 본문",
"label": "용어 혼용 문제 제기",
"text": strip_tags(intro_text)})
# 2. MDX01-intro-details
d0 = details_01[0] if details_01 else {"summary": "", "body": ""}
units.append({"id": "MDX01-intro-details", "kind": "팝업",
"label": "혼용 대표 사례",
"text": f"{d0['summary']} {d0['body']}"})
# 3. MDX01-1
body_01_1 = h2_01[0]["body_raw"]
units.append({"id": "MDX01-1", "kind": "중목차",
"label": "용어 정의",
"text": f"{h2_01[0]['title']} {strip_tags(remove_details_and_tables(body_01_1))}"})
# 4. MDX01-2 (본문만, details/표 제거)
body_01_2 = h2_01[1]["body_raw"]
units.append({"id": "MDX01-2", "kind": "중목차",
"label": "용어간 상호관계",
"text": f"{h2_01[1]['title']} {strip_tags(remove_details_and_tables(body_01_2))}"})
# 5. MDX01-2-image
units.append({"id": "MDX01-2-image", "kind": "이미지",
"label": "DX1.png",
"text": images_01[0] if images_01 else ""})
# 6. MDX01-2-details
d1 = details_01[1] if len(details_01) >= 2 else {"summary": "", "body": ""}
units.append({"id": "MDX01-2-details", "kind": "팝업+표",
"label": "DX와 BIM의 구분 12행 비교표",
"text": f"{d1['summary']} {d1['body']}"})
# ─── MDX 02 ───
h2_02 = split_h2(raw02)
images_02 = extract_image_captions(raw02)
# 7. MDX02-1
body_02_1 = h2_02[0]["body_raw"]
units.append({"id": "MDX02-1", "kind": "중목차",
"label": "DX의 궁극적 목표",
"text": f"{h2_02[0]['title']} {strip_tags(remove_details_and_tables(body_02_1))}"})
# 8. MDX02-1-image
units.append({"id": "MDX02-1-image", "kind": "이미지",
"label": "궁극적목표.png",
"text": images_02[0] if images_02 else ""})
# 9. MDX02-2 컨테이너 (title + ### 이전 본문)
body_02_2 = h2_02[1]["body_raw"]
h3_02_2 = split_h3(body_02_2)
pre_h3 = body_02_2[: h3_02_2[0]["start"]] if h3_02_2 else body_02_2
units.append({"id": "MDX02-2", "kind": "중목차(컨테이너)",
"label": "DX 기반 Process 혁신 기대효과",
"text": f"{h2_02[1]['title']} {strip_tags(pre_h3)}"})
# 10. MDX02-2.1 (표 제거)
body_021 = h3_02_2[0]["body_raw"]
units.append({"id": "MDX02-2.1", "kind": "소목차",
"label": "업무 수행 과정의 변화",
"text": f"{h3_02_2[0]['title']} {strip_tags(remove_details_and_tables(body_021))}"})
# 11. MDX02-2.2 (표 제거)
body_022 = h3_02_2[1]["body_raw"]
units.append({"id": "MDX02-2.2", "kind": "소목차",
"label": "주체별 기대효과",
"text": f"{h3_02_2[1]['title']} {strip_tags(remove_details_and_tables(body_022))}"})
# 12. MDX02-2.2-table
tables_022 = extract_tables(body_022)
units.append({"id": "MDX02-2.2-table", "kind": "",
"label": "발주자/시공자/설계자 4×3 표",
"text": strip_tags(tables_022[0]) if tables_022 else ""})
# ─── MDX 03 ───
h2_03 = split_h2(raw03)
# 13. MDX03-1
body_03_1 = h2_03[0]["body_raw"]
units.append({"id": "MDX03-1", "kind": "중목차",
"label": "필수 요건 (기술/사람/자연)",
"text": f"{h2_03[0]['title']} {strip_tags(remove_details_and_tables(body_03_1))}"})
# 14. MDX03-2 컨테이너
body_03_2 = h2_03[1]["body_raw"]
h3_03_2 = split_h3(body_03_2)
pre_h3_2 = body_03_2[: h3_03_2[0]["start"]] if h3_03_2 else body_03_2
units.append({"id": "MDX03-2", "kind": "중목차(컨테이너)",
"label": "Process/Product 혁신",
"text": f"{h2_03[1]['title']} {strip_tags(pre_h3_2)}"})
# 15. MDX03-2.1 (표 제거)
body_031 = h3_03_2[0]["body_raw"]
units.append({"id": "MDX03-2.1", "kind": "소목차",
"label": "과정(Process)의 혁신",
"text": f"{h3_03_2[0]['title']} {strip_tags(remove_details_and_tables(body_031))}"})
# 16. MDX03-2.1-table
tables_031 = extract_tables(body_031)
units.append({"id": "MDX03-2.1-table", "kind": "",
"label": "As-is/To-be 3행 비교표",
"text": strip_tags(tables_031[0]) if tables_031 else ""})
# 17. MDX03-2.2
body_032 = h3_03_2[1]["body_raw"]
units.append({"id": "MDX03-2.2", "kind": "소목차",
"label": "결과(Product)의 변화",
"text": f"{h3_03_2[1]['title']} {strip_tags(remove_details_and_tables(body_032))}"})
return units
def main() -> int:
print("[init] TF-IDF 인덱스 로딩 (src/block_matcher_tfidf.py, 32프레임 IDF 고정)...")
matcher = TfidfBlockMatcher()
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
units = build_17_units()
md_lines: list[str] = [
"# 17개 콘텐츠 단위별 매칭 (내 매처: 32프레임 IDF 고정)",
"",
"엔진: `src/block_matcher_tfidf.py` (프레임 32개만으로 IDF 사전 계산, 확장어 주입).",
"각 단위의 텍스트를 쿼리로 주입하고 top-3 프레임을 출력.",
"",
"| # | 단위 ID | 종류 | 라벨 | 텍스트 길이 | 1위 | 2위 | 3위 |",
"|---|---|---|---|---|---|---|---|",
]
rel = Path("..") / ".." / ".." / PREVIEW_DIR
details_sections: list[str] = []
for i, u in enumerate(units, start=1):
q = u["text"]
print(f"\n[{i:02d}] {u['id']} ({u['kind']}) — {u['label']} 텍스트 {len(q)}")
if not q.strip():
print(" (텍스트 비어 있음)")
row = f"| {i} | {u['id']} | {u['kind']} | {u['label']} | 0 | — | — | — |"
md_lines.append(row)
continue
top = matcher.match(q, sub_titles=None, d1_items=None, top_k=len(matcher.frames))
top3 = [r for r in top[:TOP_K] if r["score"] > 0]
for rank, r in enumerate(top3, start=1):
num = num_of(r["frame_id"])
print(f" {rank}. #{num} {r['score']*100:5.1f}% | frame {r['frame_id']} | {ftitle(matcher, r['frame_id'])}")
cells = []
for slot in range(3):
if slot < len(top3):
r = top3[slot]
n = num_of(r["frame_id"])
cells.append(f"**#{n}** {r['score']*100:.1f}%")
else:
cells.append("")
row = (f"| {i} | {u['id']} | {u['kind']} | {u['label']} | {len(q)} | "
+ " | ".join(cells) + " |")
md_lines.append(row)
# 상세 섹션
details_sections.append(f"\n### {i}. {u['id']}{u['label']}")
details_sections.append(f"- 종류: {u['kind']} · 텍스트 길이: {len(q)}")
preview = q[:120] + ("" if len(q) > 120 else "")
details_sections.append(f"- 쿼리 미리보기: _{preview}_\n")
details_sections.append("| rank | # | preview | score | frame_id | title_text |")
details_sections.append("|---|---|---|---|---|---|")
for rank, r in enumerate(top3, start=1):
n = num_of(r["frame_id"])
prev = f"![]({(rel / (n+'.png')).as_posix()})"
details_sections.append(
f"| {rank} | **#{n}** | {prev} | **{r['score']*100:.1f}%** | "
f"`{r['frame_id']}` | {ftitle(matcher, r['frame_id'])} |"
)
if not top3:
details_sections.append("| — | — | — | 0% | — | (매칭 없음) |")
md_lines.append("\n## 상세\n")
md_lines.extend(details_sections)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path("data/runs") / f"{ts}_17units_my_matcher"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / "match_report.md"
out.write_text("\n".join(md_lines), encoding="utf-8")
print(f"\n[saved] {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

391
scripts/match_mdx_strict.py Normal file
View File

@@ -0,0 +1,391 @@
"""MDX ↔ Figma Frame 매칭 (엄밀한 헤딩 구조 + 팝업 포함 버전).
mdx_normalizer 대신 raw MDX를 직접 파싱하여:
- 중목차 = ## 헤딩 (오직 ## 만)
- 소목차 = ### 헤딩 (오직 ### 만)
- 팝업 = <details><summary>...</summary>...</details> (summary + body 분리 보존)
출력 레벨:
L1 대목차 : MDX 전체 raw text (팝업 body 포함)
L2 중목차 : 각 ## 섹션 본문 + 그 섹션 안 팝업 body 포함
L3 소목차 : 각 ### 섹션 본문 (해당 섹션에 속한 팝업 body 포함)
L4 팝업 : 각 <details> 의 summary + body 단독 쿼리
매칭 엔진은 src/block_matcher_tfidf.py 그대로 재사용.
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.block_matcher_tfidf import TfidfBlockMatcher
TOP_K = 3
PREVIEW_DIR = Path("data/figma_previews")
INDEX_PATH = PREVIEW_DIR / "index.json"
_INDEX: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
MDX_FILES = [
("01", Path("samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx")),
("02", Path("samples/mdx/02. DX의 시행 목표 및 기대효과.mdx")),
("03", Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")),
]
# ═══════════════════════════════════════════════════════════
# MDX 파서
# ═══════════════════════════════════════════════════════════
def strip_tags(text: str) -> str:
"""HTML/JSX 태그 제거 + 마크다운 포맷 기호 살짝 정리."""
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\{/\*.*?\*/\}", " ", text, flags=re.DOTALL)
text = re.sub(r"\{[^{}]*\}", " ", text) # JSX prop {…}
text = text.replace("\\", " ")
text = re.sub(r"\s+", " ", text).strip()
return text
def parse_details(text: str) -> list[dict]:
"""MDX 내 모든 <details> 블록을 추출. summary + body 반환.
각 엔트리: {"summary": str, "body": str, "raw_start": int, "raw_end": int}
"""
popups: list[dict] = []
for m in re.finditer(r"<details\b[^>]*>([\s\S]*?)</details>", text, re.IGNORECASE):
block = m.group(1)
sm = re.search(r"<summary\b[^>]*>([\s\S]*?)</summary>", block, re.IGNORECASE)
summary = strip_tags(sm.group(1)) if sm else ""
body = block[sm.end():] if sm else block
popups.append({
"summary": summary,
"body": strip_tags(body),
"raw_start": m.start(),
"raw_end": m.end(),
})
return popups
def parse_mdx_structure(raw: str) -> dict:
"""Raw MDX → 헤딩 트리 + 팝업 목록.
반환:
{
"doc_title": str, # frontmatter title 또는 None
"intro": str, # 첫 ## 이전 텍스트
"h2": [
{
"title": str,
"body": str, # ## 섹션 본문 (### 이전까지)
"h3": [ {"title": str, "body": str, "popups": [...]}, ... ],
"popups": [ {"summary", "body"} ], # 이 ## 섹션에 직접 속한 팝업
},
...
],
"all_popups": [ ... ], # 전체 팝업
}
"""
# frontmatter에서 title
fm = re.match(r"---\s*\n([\s\S]*?)\n---\s*\n", raw)
doc_title = None
if fm:
tm = re.search(r"^title:\s*(.+)$", fm.group(1), re.MULTILINE)
if tm:
doc_title = tm.group(1).strip().strip('"').strip("'")
raw_body = raw[fm.end():]
else:
raw_body = raw
# ## 헤딩 찾기
h2_iter = list(re.finditer(r"^##\s+(.+?)$", raw_body, re.MULTILINE))
intro = raw_body[: h2_iter[0].start()].strip() if h2_iter else raw_body.strip()
h2_nodes: list[dict] = []
for i, m in enumerate(h2_iter):
title = m.group(1).strip()
start = m.end()
end = h2_iter[i + 1].start() if i + 1 < len(h2_iter) else len(raw_body)
section_raw = raw_body[start:end]
# ### 헤딩
h3_iter = list(re.finditer(r"^###\s+(.+?)$", section_raw, re.MULTILINE))
body_before_h3 = section_raw[: h3_iter[0].start()] if h3_iter else section_raw
h3_nodes: list[dict] = []
for j, m3 in enumerate(h3_iter):
t3 = m3.group(1).strip()
s3 = m3.end()
e3 = h3_iter[j + 1].start() if j + 1 < len(h3_iter) else len(section_raw)
sub_raw = section_raw[s3:e3]
sub_popups = parse_details(sub_raw)
# body = sub_raw + 팝업 본문 합친 문자열(쿼리용) — 팝업은 inline이므로 raw에 이미 포함되지만 태그 제거 후 텍스트로 강조
h3_nodes.append({
"title": t3,
"body": strip_tags(sub_raw),
"popups": sub_popups,
})
# 이 ## 섹션 직속 팝업 (### 이전 부분에 있는 것)
section_popups = parse_details(body_before_h3)
h2_nodes.append({
"title": title,
"body": strip_tags(body_before_h3),
"h3": h3_nodes,
"popups": section_popups,
})
all_popups: list[dict] = []
all_popups.extend(parse_details(intro))
for h2 in h2_nodes:
all_popups.extend(h2["popups"])
for h3 in h2["h3"]:
all_popups.extend(h3["popups"])
return {
"doc_title": doc_title,
"intro": strip_tags(intro),
"intro_popups": parse_details(intro),
"h2": h2_nodes,
"all_popups": all_popups,
}
# ═══════════════════════════════════════════════════════════
# 매칭 + 출력
# ═══════════════════════════════════════════════════════════
def num_of(frame_id: str) -> str:
return FRAME_TO_NUM.get(frame_id, f"?({frame_id})")
def frame_title(matcher: TfidfBlockMatcher, fid: str) -> str:
for f in matcher.frames:
if f["frame_id"] == fid:
return (f.get("title_text") or "").replace("\n", " ")[:60]
return ""
def run_query(matcher: TfidfBlockMatcher, query_text: str) -> list[dict]:
"""쿼리 텍스트 하나를 받아서 matcher로 돌린다.
block_matcher_tfidf.match는 (zone_title, sub_titles, d1_items) 인자를 받지만
내부에서는 단순히 문자열로 합쳐 전처리 → TF-IDF 유사도. 여기서는 전체 쿼리를
첫 인자(zone_title)로 넣어 동일한 전처리 경로를 탄다.
"""
return matcher.match(query_text, sub_titles=None, d1_items=None, top_k=len(matcher.frames))
def print_ranking(top: list[dict], matcher: TfidfBlockMatcher, indent: str = " "):
if not top or top[0]["score"] <= 0:
print(f"{indent}(매칭 없음, score=0)")
return
for rank, r in enumerate(top[:TOP_K], start=1):
if r["score"] <= 0:
break
num = num_of(r["frame_id"])
print(
f"{indent} {rank}. #{num} score={r['score']*100:5.1f}% "
f"| frame {r['frame_id']} | {frame_title(matcher, r['frame_id'])}"
)
def md_ranking_table(top: list[dict], matcher: TfidfBlockMatcher, run_dir_depth: int = 3) -> list[str]:
rel = Path(*[".." for _ in range(run_dir_depth)]) / PREVIEW_DIR
lines = [
"| rank | # | preview | score | frame_id | title_text |",
"|---|---|---|---|---|---|",
]
for rank, r in enumerate(top[:TOP_K], start=1):
if r["score"] <= 0:
break
num = num_of(r["frame_id"])
preview = f"![]({(rel / (num + '.png')).as_posix()})"
lines.append(
f"| {rank} | **#{num}** | {preview} | **{r['score']*100:.1f}%** | "
f"`{r['frame_id']}` | {frame_title(matcher, r['frame_id'])} |"
)
if len(lines) == 2:
lines.append("| — | — | — | 0% | — | (매칭 없음) |")
return lines
def evaluate_mdx(matcher: TfidfBlockMatcher, mdx_id: str, mdx_path: Path, md_lines: list[str]):
raw = mdx_path.read_text(encoding="utf-8")
parsed = parse_mdx_structure(raw)
doc_title = parsed["doc_title"] or mdx_path.stem
print("\n" + "=" * 100)
print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})")
print(f" 중목차(##) {len(parsed['h2'])}개 · "
f"소목차(###) {sum(len(h2['h3']) for h2 in parsed['h2'])}개 · "
f"팝업(<details>) {len(parsed['all_popups'])}")
print("=" * 100)
md_lines.append(f"\n## MDX {mdx_id}{doc_title}\n")
md_lines.append(f"파일: `{mdx_path.as_posix()}`")
md_lines.append(
f"- 중목차(##) **{len(parsed['h2'])}개** · "
f"소목차(###) **{sum(len(h2['h3']) for h2 in parsed['h2'])}개** · "
f"팝업(<details>) **{len(parsed['all_popups'])}개**\n"
)
# ─── L1 대목차: 전체 MDX ───
full_text = strip_tags(raw)
l1_top = run_query(matcher, full_text)
print(f"\n┌─ L1 대목차 [전체 MDX, 팝업 포함]")
print_ranking(l1_top, matcher, indent="")
md_lines.append("### 🟦 L1 — 대목차 (전체 MDX, 팝업 포함)\n")
md_lines.extend(md_ranking_table(l1_top, matcher))
md_lines.append("")
# ─── L2 중목차: 각 ## ───
print(f"\n┌─ L2 중목차 [## 섹션별]")
md_lines.append("\n### 🟩 L2 — 중목차 (각 ## 섹션, 팝업 body 포함)\n")
for zi, h2 in enumerate(parsed["h2"], start=1):
# 쿼리: ## title + body(### 이전) + 직속 popup + 각 ### body/popup
parts = [h2["title"], h2["body"]]
for p in h2["popups"]:
parts.append(p["summary"])
parts.append(p["body"])
for h3 in h2["h3"]:
parts.append(h3["title"])
parts.append(h3["body"])
for p in h3["popups"]:
parts.append(p["summary"])
parts.append(p["body"])
query = " ".join(parts)
top = run_query(matcher, query)
pop_titles = [p["summary"] for p in h2["popups"]] + [
p["summary"] for h3 in h2["h3"] for p in h3["popups"]
]
print(f"\n│ [중 {zi}] ## {h2['title']}")
print(f"│ 소목차: {[h3['title'] for h3 in h2['h3']] or '(없음)'}")
print(f"│ 팝업: {pop_titles or '(없음)'}")
print_ranking(top, matcher, indent="")
md_lines.append(f"\n#### 중 {zi}: `## {h2['title']}`\n")
md_lines.append(f"- 소목차(###): {[h3['title'] for h3 in h2['h3']] or '(없음)'}")
md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n")
md_lines.extend(md_ranking_table(top, matcher))
md_lines.append("")
# ─── L3 소목차: 각 ### ───
total_h3 = sum(len(h2["h3"]) for h2 in parsed["h2"])
print(f"\n┌─ L3 소목차 [### 섹션별, 총 {total_h3}개]")
if total_h3 == 0:
print("│ (이 MDX에는 ### 소목차 없음)")
md_lines.append(f"\n### 🟨 L3 — 소목차 (각 ### 섹션)\n")
if total_h3 == 0:
md_lines.append("_(이 MDX에는 ### 소목차 없음)_\n")
for h2 in parsed["h2"]:
for h3 in h2["h3"]:
parts = [h3["title"], h3["body"]]
for p in h3["popups"]:
parts.append(p["summary"])
parts.append(p["body"])
query = " ".join(parts)
top = run_query(matcher, query)
pop_titles = [p["summary"] for p in h3["popups"]]
print(f"\n│ [소] ### {h3['title']} (상위 중목차: {h2['title']})")
print(f"│ 팝업: {pop_titles or '(없음)'}")
print_ranking(top, matcher, indent="")
md_lines.append(
f"\n#### 소: `### {h3['title']}` _(상위 중목차: `{h2['title']}`)_\n"
)
md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n")
md_lines.extend(md_ranking_table(top, matcher))
md_lines.append("")
# ─── L4 팝업: 각 <details> ───
print(f"\n┌─ L4 팝업 [<details> 단독 매칭, 총 {len(parsed['all_popups'])}개]")
if not parsed["all_popups"]:
print("│ (팝업 없음)")
md_lines.append(f"\n### 🟥 L4 — 팝업 (<details> 단독 매칭)\n")
if not parsed["all_popups"]:
md_lines.append("_(팝업 없음)_\n")
for pi, pop in enumerate(parsed["all_popups"], start=1):
query = f"{pop['summary']} {pop['body']}"
top = run_query(matcher, query)
preview = pop["body"][:80] + ("" if len(pop["body"]) > 80 else "")
print(f"\n│ [팝 {pi}] summary={pop['summary']!r} ({len(pop['body'])}자)")
print(f"│ preview: {preview}")
print_ranking(top, matcher, indent="")
md_lines.append(f"\n#### 팝 {pi}: `<details>` — **{pop['summary']}**\n")
md_lines.append(f"- 본문 길이: {len(pop['body'])}자 · preview: _{preview}_\n")
md_lines.extend(md_ranking_table(top, matcher))
md_lines.append("")
def build_frame_legend(matcher: TfidfBlockMatcher, md_lines: list[str]) -> None:
md_lines.append("\n## 프레임 번호 전체 색인 (01 ~ 32)\n")
md_lines.append("| # | preview | frame_id | title_text |")
md_lines.append("|---|---|---|---|")
rel = Path("..") / ".." / ".." / PREVIEW_DIR
for num in sorted(_INDEX.keys()):
entry = _INDEX[num]
preview = f"![]({(rel / (num + '.png')).as_posix()})"
md_lines.append(
f"| **#{num}** | {preview} | `{entry['frame_id']}` | "
f"{frame_title(matcher, entry['frame_id'])} |"
)
md_lines.append("")
def main() -> int:
print("[init] TF-IDF 인덱스 로딩...")
matcher = TfidfBlockMatcher()
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
md_lines: list[str] = [
"# MDX ↔ Figma Frame 매칭 (엄밀한 구조 + 팝업 포함)",
"",
"raw MDX를 직접 파싱하여 **## 만 중목차**, **### 만 소목차**, `<details>` 을 팝업으로 분리.",
"bullet 항목(`* **제목**`)은 헤딩이 아니므로 섹션 body에 포함되며 별도 레벨로 취급하지 않음.",
"",
"| 단계 | 입도 | 쿼리 구성 |",
"|---|---|---|",
"| 🟦 L1 대목차 | MDX 1개 | 전체 MDX raw text (팝업 body 포함) |",
"| 🟩 L2 중목차 | 각 `##` | `## title + body + 하위 ### body + 팝업 body` |",
"| 🟨 L3 소목차 | 각 `###` | `### title + body + 자기 팝업 body` |",
"| 🟥 L4 팝업 | 각 `<details>` | `summary + body` |",
"",
"점수는 순수 TF-IDF cosine similarity × 100 (%). 판정 라벨 없음.",
]
for mdx_id, p in MDX_FILES:
if p.exists():
evaluate_mdx(matcher, mdx_id, p, md_lines)
else:
print(f"[skip] 없음: {p}")
build_frame_legend(matcher, md_lines)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path("data/runs") / f"{ts}_mdx_match_strict"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / "match_report.md"
out.write_text("\n".join(md_lines), encoding="utf-8")
print(f"\n[saved] {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,238 @@
"""MDX → Figma Frame 매칭 (TF-IDF) — 대목차 / 중목차 / 소목차 3단계 모두 출력.
프레임은 data/figma_previews/index.json 의 번호(01~32)로 표기한다.
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.block_matcher_tfidf import TfidfBlockMatcher
from src.mdx_normalizer import normalize_mdx_content
from src.section_parser import extract_major_sections
TOP_K = 3
THRESHOLD = 0.15 # pipeline_v2 direct-fit 커트오프 (표기에 사용 안 함)
PREVIEW_DIR = Path("data/figma_previews")
INDEX_PATH = PREVIEW_DIR / "index.json"
# index.json 로드: {"01": {"frame_id": "1171281172", ...}, ...}
_INDEX: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
NUM_TO_FRAME: dict[str, str] = {k: v["frame_id"] for k, v in _INDEX.items()}
MDX_FILES = [
("01", Path("samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx")),
("02", Path("samples/mdx/02. DX의 시행 목표 및 기대효과.mdx")),
("03", Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")),
]
def num_of(frame_id: str) -> str:
return FRAME_TO_NUM.get(frame_id, f"?({frame_id})")
def extract_d1_items(content: str) -> list[str]:
return [
re.sub(r"\*+", "", d).strip()
for d in re.findall(r"^D1:\s*(.*)", content, re.MULTILINE)
]
def frame_title(matcher: TfidfBlockMatcher, fid: str) -> str:
for f in matcher.frames:
if f["frame_id"] == fid:
return (f.get("title_text") or "").replace("\n", " ")[:60]
return ""
def print_ranking(label: str, top: list[dict], matcher: TfidfBlockMatcher, indent: str = " "):
if not top or top[0]["score"] <= 0:
print(f"{indent}(매칭 없음, score=0)")
return
for rank, r in enumerate(top[:TOP_K], start=1):
if r["score"] <= 0:
break
num = num_of(r["frame_id"])
print(
f"{indent} {rank}. #{num} score={r['score']*100:5.1f}% "
f"| frame {r['frame_id']} | {frame_title(matcher, r['frame_id'])}"
)
def md_ranking_table(top: list[dict], matcher: TfidfBlockMatcher) -> list[str]:
rel = Path("..") / ".." / ".." / PREVIEW_DIR # run dir 기준
lines = [
"| rank | # | preview | score | frame_id | title_text |",
"|---|---|---|---|---|---|",
]
for rank, r in enumerate(top[:TOP_K], start=1):
if r["score"] <= 0:
break
num = num_of(r["frame_id"])
preview = f"![]({(rel / (num + '.png')).as_posix()})"
lines.append(
f"| {rank} | **#{num}** | {preview} | **{r['score']*100:.1f}%** | "
f"`{r['frame_id']}` | {frame_title(matcher, r['frame_id'])} |"
)
if len(lines) == 2:
lines.append("| — | — | — | 0% | — | (매칭 없음) |")
return lines
def evaluate_mdx(
matcher: TfidfBlockMatcher,
mdx_id: str,
mdx_path: Path,
md_lines: list[str],
) -> None:
content = mdx_path.read_text(encoding="utf-8")
norm = normalize_mdx_content(content)
flat_sections = norm.get("sections", [])
zones = extract_major_sections(flat_sections)
doc_title = norm.get("title") or mdx_path.stem
print("\n" + "=" * 100)
print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})")
print(f"flat sections: {len(flat_sections)} | zones(중목차): {len(zones)}")
print("=" * 100)
md_lines.append(f"\n## MDX {mdx_id}{doc_title}\n")
md_lines.append(
f"파일: `{mdx_path.as_posix()}` · "
f"평면 section {len(flat_sections)}개 · zone(중목차) {len(zones)}\n"
)
# ═══════════ L1: 대목차 (MDX 전체) ═══════════
l1_subs = [z["title"] for z in zones] + [
st for z in zones for st in z.get("sub_titles", [])
]
l1_top = matcher.match(doc_title, l1_subs, d1_items=None, top_k=len(matcher.frames))
print(f"\n┌─ L1 대목차 [전체 MDX] '{doc_title}'")
print(f"│ zones: {[z['title'] for z in zones]}")
print_ranking("L1", l1_top, matcher, indent="")
md_lines.append("### 🟦 L1 — 대목차 (전체 MDX)\n")
md_lines.append(f"- 쿼리: `{doc_title}` + 모든 zone/sub title")
md_lines.append(f"- zone 목록: {[z['title'] for z in zones]}")
md_lines.append("")
md_lines.extend(md_ranking_table(l1_top, matcher))
md_lines.append("")
# ═══════════ L2: 중목차 (zone 단위) ═══════════
print(f"\n┌─ L2 중목차 [zone 단위]")
md_lines.append("### 🟩 L2 — 중목차 (zone 단위)\n")
for zi, zone in enumerate(zones, start=1):
z_title = zone["title"]
sub_titles = zone.get("sub_titles", [])
z_content = zone.get("content", "")
d1 = extract_d1_items(z_content)
top = matcher.match(z_title, sub_titles, d1, top_k=len(matcher.frames))
print(f"\n│ [zone {zi}] {z_title}")
print(f"│ sub_titles: {sub_titles}")
print(f"│ d1_items: {len(d1)}")
print_ranking("L2", top, matcher, indent="")
md_lines.append(f"\n#### zone {zi}: **{z_title}**")
md_lines.append(f"- sub_titles: {sub_titles}")
md_lines.append(f"- d1_items: {len(d1)}")
md_lines.append("")
md_lines.extend(md_ranking_table(top, matcher))
md_lines.append("")
# ═══════════ L3: 소목차 (평면 section 각각) ═══════════
# normalize의 sections 중 content가 있는 것만 = 실제 소목차
sub_sections = [s for s in flat_sections if s.get("content", "").strip()]
print(f"\n┌─ L3 소목차 [개별 sub-section, {len(sub_sections)}개]")
md_lines.append("### 🟨 L3 — 소목차 (개별 sub-section)\n")
for si, sec in enumerate(sub_sections, start=1):
s_title = sec.get("title", "")
s_content = sec.get("content", "")
d1 = extract_d1_items(s_content)
top = matcher.match(s_title, sub_titles=None, d1_items=d1, top_k=len(matcher.frames))
# 이 섹션이 어느 zone에 속하는지 찾기
parent_zone = ""
for z in zones:
if s_title in z.get("sub_titles", []):
parent_zone = z["title"]
break
print(f"\n│ [sub {si}] {s_title} (zone: {parent_zone})")
print(f"│ d1_items: {len(d1)}")
print_ranking("L3", top, matcher, indent="")
md_lines.append(f"\n#### sub {si}: **{s_title}** _(zone: {parent_zone})_")
md_lines.append(f"- d1_items: {len(d1)}")
md_lines.append("")
md_lines.extend(md_ranking_table(top, matcher))
md_lines.append("")
def build_frame_legend(matcher: TfidfBlockMatcher, md_lines: list[str]) -> None:
md_lines.append("\n## 프레임 번호 전체 색인 (01 ~ 32)\n")
md_lines.append("| # | preview | frame_id | title_text |")
md_lines.append("|---|---|---|---|")
rel = Path("..") / ".." / ".." / PREVIEW_DIR
for num in sorted(_INDEX.keys()):
entry = _INDEX[num]
preview = f"![]({(rel / (num + '.png')).as_posix()})"
md_lines.append(
f"| **#{num}** | {preview} | `{entry['frame_id']}` | "
f"{frame_title(matcher, entry['frame_id'])} |"
)
md_lines.append("")
def main() -> int:
print("[init] TF-IDF 인덱스 로딩...")
matcher = TfidfBlockMatcher()
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
print(f"[init] direct-fit 임계값 = {THRESHOLD*100:.0f}%")
md_lines: list[str] = [
"# MDX ↔ Figma Frame 매칭 (TF-IDF 순수 점수) — L1/L2/L3 3단계",
"",
"프레임은 `data/figma_previews/{번호}.png` 의 번호로 표기. 하단에 번호-프레임 색인.",
"",
"| 단계 | 입도 | 쿼리 구성 |",
"|---|---|---|",
"| 🟦 L1 대목차 | MDX 전체 1개 | doc title + 모든 zone/sub title |",
"| 🟩 L2 중목차 | zone 단위 | zone title + sub_titles + d1_items |",
"| 🟨 L3 소목차 | 개별 sub-section 각각 | sub title + 자기 content의 d1_items |",
"",
f"- 인덱싱된 프레임: {len(matcher.frames)}",
"- **각 표는 순수 TF-IDF cosine similarity × 100 을 %로 표시한 점수 랭킹.**",
"- 판정/분기(recipe/direct-fit) 라벨은 출력하지 않음. 점수만 그대로 본다.",
]
for mdx_id, p in MDX_FILES:
if p.exists():
evaluate_mdx(matcher, mdx_id, p, md_lines)
else:
print(f"[skip] 없음: {p}")
build_frame_legend(matcher, md_lines)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path("data/runs") / f"{timestamp}_mdx_match"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / "match_report.md"
out.write_text("\n".join(md_lines), encoding="utf-8")
print(f"\n[saved] {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,398 @@
"""32개 프레임 preview PNG에 EasyOCR + 이미지 전처리를 돌려,
기존 texts.md에 없는 '이미지 베이크 텍스트' 델타를 추출/보강.
흐름:
1. 원본 PNG 로드
2. 두 가지 변형을 OCR:
(a) 원본 그대로
(b) 2배 업스케일 + 대비 강화 (녹색/저대비 장식 텍스트 잡기용)
3. 두 결과 합치고 confidence 컷 (low=0.15, high=0.5)
4. 오인식 교정 사전 적용 (SIW→S/W, 움합의→융합의 등)
5. 기존 texts.md 토큰과 비교하여 델타 추출
6. 프레임별 통계(감지 수, 델타 수, 누락 여부) 리포트
7. --apply 시 texts.md 파일들에 델타 추가
사용:
python scripts/ocr_augment_texts.py # 드라이런 (리포트만)
python scripts/ocr_augment_texts.py --apply # texts.md 수정
python scripts/ocr_augment_texts.py --only 1171281172
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
PREVIEW_DIR = Path("data/figma_previews")
INDEX_PATH = PREVIEW_DIR / "index.json"
BLOCKS_DIR = Path("figma_to_html_agent/blocks")
APPEND_SECTION_HEADER = "## OCR 보강 (이미지 베이크 텍스트, 자동 추출)"
APPEND_SECTION_MARKER = "<!-- OCR_AUGMENT_V1 -->"
# conf 기준
CONF_HIGH = 0.5 # 이 이상은 그대로 채택
CONF_LOW = 0.15 # 이 이하는 버림. 사이 구간은 교정 사전 거쳐야 채택
# 자주 틀리는 오인식 → 올바른 표현 (정확히 일치 시만 치환)
OCR_CORRECTIONS: dict[str, str] = {
"siw": "S/W",
"sw": "S/W",
"hiw": "H/W",
"hw": "H/W",
"움합의": "융합의",
"(직관지 역할": "직관지 역할",
"패텔입": "패러다임",
"|말": "개발",
"대발": "개발",
"Civil": "Civil",
"I/W": "S/W",
"l/w": "S/W",
}
# 버리고 싶은 노이즈 패턴 (OCR이 기호/잔여물 잡은 것)
NOISE_PATTERNS = [
re.compile(r"^[\W_]+$"), # 기호만
re.compile(r"^\d{1,2}$"), # 숫자 1-2자리
re.compile(r"^.$"), # 한 글자
]
def is_noise(text: str) -> bool:
for p in NOISE_PATTERNS:
if p.match(text):
return True
return False
def apply_corrections(text: str) -> str:
"""교정 사전 적용. 대소문자 무시 완전 일치만."""
key = text.strip().lower()
if key in OCR_CORRECTIONS:
return OCR_CORRECTIONS[key]
# 부분 치환 (문구 안에 숨은 경우)
result = text
for bad, good in OCR_CORRECTIONS.items():
pattern = re.compile(re.escape(bad), re.IGNORECASE)
result = pattern.sub(good, result)
return result
def normalize_for_compare(text: str) -> str:
t = text.lower()
t = re.sub(r"[^\w가-힣]+", "", t)
return t
def load_existing_tokens(texts_md: Path) -> set[str]:
if not texts_md.exists():
return set()
text = texts_md.read_text(encoding="utf-8")
# 기존 OCR 섹션 제외
if APPEND_SECTION_MARKER in text:
idx = text.find(APPEND_SECTION_MARKER)
header_idx = text.rfind(APPEND_SECTION_HEADER, 0, idx)
if header_idx >= 0:
text = text[:header_idx]
lines = []
for ln in text.splitlines():
s = ln.strip()
if s.startswith("#") or s.startswith(">"):
continue
lines.append(ln)
body = " ".join(lines)
tokens: set[str] = set()
for tok in re.split(r"[\s\|\-·•/,.()\[\]:;!?#`'\"*~_+=<>&]+", body):
if not tok:
continue
norm = normalize_for_compare(tok)
if norm and len(norm) >= 2:
tokens.add(norm)
return tokens
def preprocess_upscale(png_path: Path, scale: float = 2.0, contrast: float = 1.4):
"""이미지를 업스케일 + 대비 강화해서 bytes 반환."""
from PIL import Image, ImageEnhance
img = Image.open(png_path).convert("RGB")
w, h = img.size
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
img = ImageEnhance.Contrast(img).enhance(contrast)
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def run_ocr_variants(reader, png_path: Path) -> list[tuple[str, float, tuple]]:
"""원본 + 업스케일 두 번 OCR. (text, conf, bbox_center) 리스트."""
import numpy as np
from PIL import Image
collected: list[tuple[str, float, tuple]] = []
# 1) 원본
res1 = reader.readtext(str(png_path), detail=1, paragraph=False)
for bbox, text, conf in res1:
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
center = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
collected.append((text, float(conf), center))
# 2) 업스케일 + 대비 강화
enhanced_bytes = preprocess_upscale(png_path)
img = np.array(Image.open(__import__("io").BytesIO(enhanced_bytes)).convert("RGB"))
res2 = reader.readtext(img, detail=1, paragraph=False)
for bbox, text, conf in res2:
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
# 원본 좌표계로 환산 (÷2)
center = ((min(xs) + max(xs)) / 4, (min(ys) + max(ys)) / 4)
collected.append((text, float(conf), center))
return collected
def dedupe_by_position(items: list[tuple[str, float, tuple]]) -> list[tuple[str, float, tuple]]:
"""같은 위치(±30px)에서 중복 감지된 것들을 confidence 높은 쪽으로 축약."""
result: list[tuple[str, float, tuple]] = []
for text, conf, center in sorted(items, key=lambda r: -r[1]):
dupe = False
for rt, rc, rcenter in result:
if abs(rcenter[0] - center[0]) < 30 and abs(rcenter[1] - center[1]) < 30:
# 텍스트 정규화 같으면 중복
if normalize_for_compare(rt) == normalize_for_compare(text):
dupe = True
break
# 같은 위치에서 더 긴 버전이 이미 있으면 중복으로 간주
if normalize_for_compare(text) in normalize_for_compare(rt):
dupe = True
break
if not dupe:
result.append((text, conf, center))
return result
def extract_accepted(items: list[tuple[str, float, tuple]]) -> list[tuple[str, float]]:
"""confidence + 교정 적용 후 최종 채택된 (text, conf) 리스트.
규칙:
- 교정 사전에 명시된 오인식(예: '패텔입''패러다임')은 confidence 무관 채택
- 그 외 conf < CONF_LOW는 노이즈로 버림
- CONF_LOW ~ CONF_HIGH 사이: 한글 2자 이상 또는 교정 발생한 것만
- CONF_HIGH 이상: 그대로 채택
"""
accepted: list[tuple[str, float]] = []
for text, conf, _ in items:
if is_noise(text):
continue
corrected = apply_corrections(text)
was_corrected = corrected != text
if is_noise(corrected):
continue
if was_corrected:
# 교정 사전 매칭 → conf 무관 채택 (신뢰도는 0.99로 덮어씀 — 사전 매칭 확신)
accepted.append((corrected, max(conf, 0.99)))
continue
if conf < CONF_LOW:
continue
if conf < CONF_HIGH:
if not re.search(r"[가-힣]{2,}", corrected):
continue
accepted.append((corrected, conf))
return accepted
def find_delta(accepted: list[tuple[str, float]], existing: set[str]) -> list[tuple[str, float]]:
delta: list[tuple[str, float]] = []
seen: set[str] = set()
for phrase, conf in accepted:
n = normalize_for_compare(phrase)
if not n or len(n) < 2:
continue
if n in seen:
continue
if n in existing:
continue
words = [w for w in re.split(r"[\s\|\-·•/,.()\[\]:;!?#`'\"*~_+=<>&]+", phrase) if w]
word_norms = [normalize_for_compare(w) for w in words]
has_new = any(wn and len(wn) >= 2 and wn not in existing for wn in word_norms)
if not has_new and n not in existing:
continue
seen.add(n)
delta.append((phrase, conf))
return delta
def strip_prev_ocr_section(text: str) -> str:
marker = APPEND_SECTION_MARKER
idx = text.find(marker)
if idx < 0:
return text
header_idx = text.rfind(APPEND_SECTION_HEADER, 0, idx)
cut = header_idx if header_idx >= 0 else idx
return text[:cut].rstrip() + "\n"
def append_delta(texts_md: Path, delta: list[tuple[str, float]]) -> str:
original = texts_md.read_text(encoding="utf-8") if texts_md.exists() else ""
cleaned = strip_prev_ocr_section(original)
if not delta:
return cleaned
ts = datetime.now().strftime("%Y-%m-%d")
lines = [
"",
APPEND_SECTION_HEADER,
"",
f"> EasyOCR(2x 업스케일 + 대비강화) 자동 추출 ({ts}). 기존 텍스트 레이어에 없던 단어/문구만.",
APPEND_SECTION_MARKER,
"",
]
for phrase, conf in delta:
lines.append(f"- {phrase} _(conf={conf:.2f})_")
lines.append("")
return cleaned.rstrip() + "\n" + "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="texts.md에 실제 반영")
ap.add_argument("--only", type=str, default="")
args = ap.parse_args()
idx: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
print("[init] EasyOCR 로딩 (한/영, CPU)...")
import easyocr
reader = easyocr.Reader(["ko", "en"], gpu=False, verbose=False)
print("[init] OK")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path("data/runs") / f"{ts}_ocr_augment"
out_dir.mkdir(parents=True, exist_ok=True)
numbers = sorted(idx.keys())
summary_rows: list[dict] = []
detail_lines: list[str] = []
for num in numbers:
entry = idx[num]
fid = entry["frame_id"]
if args.only and fid != args.only:
continue
png = PREVIEW_DIR / f"{num}.png"
texts_md = BLOCKS_DIR / fid / "texts.md"
if not png.exists():
continue
print(f"[{num}] {fid} OCR...", end="", flush=True)
raw_items = run_ocr_variants(reader, png)
deduped = dedupe_by_position(raw_items)
accepted = extract_accepted(deduped)
existing = load_existing_tokens(texts_md)
delta = find_delta(accepted, existing)
# 저신뢰 detection (잠재 누락 신호): conf < LOW 인데 위치 정보가 있는 것 개수
low_conf_count = sum(1 for _, c, _ in raw_items if c < CONF_LOW)
print(f" 감지(중복제거) {len(deduped)}개 채택 {len(accepted)}개 델타 {len(delta)}"
f"저신뢰잔여 {low_conf_count}")
summary_rows.append({
"num": num,
"fid": fid,
"raw": len(raw_items),
"dedup": len(deduped),
"accepted": len(accepted),
"delta": len(delta),
"low_conf": low_conf_count,
"delta_items": delta,
"low_conf_items": [(t, c) for t, c, _ in raw_items if c < CONF_LOW],
})
detail_lines.append(f"\n### {num}. frame `{fid}`")
detail_lines.append(f"- OCR 감지(중복제거 후): {len(deduped)}")
detail_lines.append(f"- 기존 texts.md 토큰: {len(existing)}")
detail_lines.append(f"- 채택(교정 후): {len(accepted)}")
detail_lines.append(f"- **델타(신규 보강): {len(delta)}개**")
if delta:
detail_lines.append("")
detail_lines.append("| 신규 문구 | conf |")
detail_lines.append("|---|---|")
for p, c in delta:
detail_lines.append(f"| {p} | {c:.2f} |")
low = summary_rows[-1]["low_conf_items"]
if low:
detail_lines.append("")
detail_lines.append(f"<details><summary>저신뢰 잔여 {len(low)}개 (잠재 누락 단서)</summary>")
detail_lines.append("")
for t, c in sorted(low, key=lambda x: -x[1])[:20]:
detail_lines.append(f"- `{t}` (conf={c:.3f})")
if len(low) > 20:
detail_lines.append(f"- ... 외 {len(low)-20}")
detail_lines.append("</details>")
if args.apply:
new_text = append_delta(texts_md, delta)
texts_md.parent.mkdir(parents=True, exist_ok=True)
texts_md.write_text(new_text, encoding="utf-8")
# ─── summary ───
frames_with_delta = [r for r in summary_rows if r["delta"] > 0]
frames_no_delta = [r for r in summary_rows if r["delta"] == 0]
report = [
"# OCR 보강 리포트 (EasyOCR + 전처리 + 교정)",
"",
f"- 드라이런: {'적용됨 (--apply)' if args.apply else '드라이런 (texts.md 미수정)'}",
f"- 대상 프레임: {len(summary_rows)}",
f"- **텍스트 누락(델타 > 0) 프레임: {len(frames_with_delta)}개**",
f"- 델타 없음(보강 불필요) 프레임: {len(frames_no_delta)}",
"",
"## 프레임별 요약",
"",
"| # | frame_id | 감지 | 채택 | **델타** | 저신뢰 | 델타 미리보기 |",
"|---|---|---|---|---|---|---|",
]
for r in summary_rows:
preview = "; ".join(p for p, _ in r["delta_items"][:4])
if len(r["delta_items"]) > 4:
preview += ""
mark = "🔴" if r["delta"] > 0 else "·"
report.append(
f"| {r['num']} | `{r['fid']}` | {r['dedup']} | {r['accepted']} | "
f"{mark} **{r['delta']}** | {r['low_conf']} | {preview} |"
)
report.append("\n## 텍스트 누락 프레임 리스트 (델타 > 0)\n")
if frames_with_delta:
for r in frames_with_delta:
items = ", ".join(p for p, _ in r["delta_items"])
report.append(f"- **#{r['num']}** `{r['fid']}` — 델타 {r['delta']}개: {items}")
else:
report.append("_(누락 없음)_")
report.append("\n## 상세")
report.extend(detail_lines)
out = out_dir / "report.md"
out.write_text("\n".join(report), encoding="utf-8")
print(f"\n[saved] {out}")
if args.apply:
print("[applied] texts.md 파일들 업데이트 완료")
else:
print("[dryrun] --apply 를 붙이면 texts.md 에 반영됩니다")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,416 @@
"""MDX04 F16 override slide-fit preview — slide_fit_preview, NOT a Phase Z final.
배경:
- V4 top1 = F26. 사용자 semantic review 로 F16 채택
- 사유: MDX04 04-2.* 는 4-issue diagnostic 구조 → F16 quadrant pattern 적합
- F26 figma 1:1 변환 부재 (별도 작업 보류)
- anchor 보정 / detect_mdx 수정 / v4_full32_result.yaml 변경 모두 없음
매핑 (사용자 결정 — B 수정판, 그대로 유지):
04-2.1 (4) + 04-2.2 (4) = 8 항목을 4 원인군으로 그룹핑. 04-2.2 보존.
레이아웃 전환 (composition_preview → slide_fit_preview):
이전: 1280×1230 비표준 (composition preview)
현재: 1280×720 표준 슬라이드 (slide_fit_preview)
├ title bar (1280×56)
├ body (1200×590)
│ ├ zone-left (340×590) = 04-1 compact 5-card stack
│ └ zone-right (840×590) = F16 quadrant zone-fit (4분면 + center quote)
└ footer pill (1280×48)
F16 native dim (1280×1015) 폐기. zone (840×590) 에 맞게 좌표 재계산. 폰트 축소.
"""
import json
import re
import sys
from datetime import datetime
from html import escape
from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
ROOT = Path(__file__).resolve().parents[2]
MDX_PATH = ROOT / "samples" / "mdx_batch" / "04.mdx"
V4_RESULT = ROOT / "tests" / "matching" / "v4_full32_result.yaml"
RUN_DIR = ROOT / "data" / "runs" / "mdx04_f16_override"
TEMPLATES_DIR = RUN_DIR / "templates"
# ─── 그룹핑 정의 (사용자 예시 그대로) ──────────────────────────
GROUPING_RULE = {
'description': '04-2.1 (4 정책 항목) + 04-2.2 (4 조직 항목) = 8 항목을 4 원인군으로 그룹핑. F16 4 분면 ribbon = 그룹명.',
'reason': 'user wants 04-2.2 보존 + F16 4 분면 디자인 활용. 1:1 짝짓기 강제 회피.',
'groups': [
{
'quadrant': 'q1',
'name': '정책 집행 / 제도 운용 문제',
'items': [
{'source': '04-2.1', 'index': 0}, # 실질적 기술 경쟁을 저해하는 정책 집행
{'source': '04-2.1', 'index': 1}, # 적용 효과가 있는 사례도 없이 방침부터 도입
],
},
{
'quadrant': 'q2',
'name': '개념 이해 부족',
'items': [
{'source': '04-2.1', 'index': 2}, # 엔지니어링 S/W에 대한 개념 부재
{'source': '04-2.2', 'index': 0}, # 공학적 개념 정립 부재
{'source': '04-2.2', 'index': 2}, # DX/BIM의 근본 취지와 목표의 이해 부족
],
},
{
'quadrant': 'q3',
'name': '기술 투자 / 본업 기술력 부족',
'items': [
{'source': '04-2.1', 'index': 3}, # 기술투자(R&D) 없는 성과 창출 기대
{'source': '04-2.2', 'index': 1}, # '본업 기술력 확보' 우선의 개념 부재
],
},
{
'quadrant': 'q4',
'name': '조직 / 수행 역량 문제',
'items': [
{'source': '04-2.2', 'index': 3}, # 과거의 타성에 머무르고 있는 기술자 집단
],
},
],
}
# ─── MDX 04 파싱 ────────────────────────────────────────────────
RE_SUBSECTION_HEAD = re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE)
RE_TOP_BULLET = re.compile(r'^-\s+\*\*([^*]+)\*\*\s*$')
def extract_subsection_items(text, num_label):
lines = text.split('\n')
start = None
for i, ln in enumerate(lines):
m = RE_SUBSECTION_HEAD.match(ln.strip())
if m and m.group(1) == num_label:
start = i
break
if start is None:
return None, []
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j].strip()
if RE_SUBSECTION_HEAD.match(s) or s == '---':
end = j
break
section_title = lines[start].lstrip('# ').strip()
body_lines = lines[start + 1:end]
items = []
cur = None
for ln in body_lines:
stripped = ln.strip()
m = RE_TOP_BULLET.match(stripped)
if m:
if cur is not None:
items.append(cur)
cur = {'headline': m.group(1).strip(), 'subs': []}
continue
m2 = re.match(r'^-\s+(.+)$', stripped)
if m2 and cur is not None and not stripped.startswith('- **'):
cur['subs'].append(m2.group(1).strip())
if cur is not None:
items.append(cur)
return section_title, items
def extract_section_04_1_cards(text):
m = re.search(r'## 1\. DX에 대한 인식(.*?)(?=^## 2\.)', text, re.DOTALL | re.MULTILINE)
if not m:
return None, []
body = m.group(1)
cards = []
h3_iter = list(re.finditer(r'<h3[^>]*>([^<]+)</h3>', body))
for idx, h3m in enumerate(h3_iter):
label = h3m.group(1).strip()
section_end = h3_iter[idx + 1].start() if idx + 1 < len(h3_iter) else len(body)
section_text = body[h3m.end():section_end]
# 인용 (첫 <p> 의 따옴표 텍스트)
quote_m = re.search(r'<p[^>]*>(?:["“])(.+?)(?:["”])</p>', section_text, re.DOTALL)
if not quote_m:
quote_m = re.search(r'<p[^>]*>([^<]+)</p>', section_text, re.DOTALL)
quote = quote_m.group(1).strip() if quote_m else ''
bullets = [b.strip() for b in re.findall(r'<li[^>]*>([^<]+)</li>', section_text)]
cards.append({'label': label, 'quote': quote, 'bullets': bullets})
return '1. DX에 대한 인식', cards
# ─── F16 grouped mapper ────────────────────────────────────────
def map_to_f16_grouped(items_2_1, items_2_2, slide_title):
"""8 items (2.1 4 + 2.2 4) → 4 quadrant groups (사용자 그룹핑 룰 적용)."""
source_map = {'04-2.1': items_2_1, '04-2.2': items_2_2}
payload = {'center_quote': slide_title}
for group in GROUPING_RULE['groups']:
q = group['quadrant']
items_for_q = []
for ref in group['items']:
src_items = source_map[ref['source']]
idx = ref['index']
if idx < len(src_items):
src_item = src_items[idx]
items_for_q.append({
'source': '[' + ref['source'].replace('04-', '') + ']',
'headline': src_item['headline'],
'subs': src_item['subs'],
})
payload[f'{q}_label'] = group['name']
payload[f'{q}_items'] = items_for_q
return payload
def map_to_5card_compact_slots(cards, section_title):
return {'section_title': section_title, 'cards': cards}
# ─── V4 metadata lookup ───────────────────────────────────────
def get_top1(v4, sid):
sec = v4.get('mdx_sections', {}).get(sid)
if not sec:
return None
j = sec.get('judgments_full32', [])
return j[0] if j else None
def get_frame_judgment(v4, sid, frame_number):
sec = v4.get('mdx_sections', {}).get(sid)
if not sec:
return None
for e in sec.get('judgments_full32', []):
if e['frame_number'] == frame_number:
return e
return None
# ─── 메인 ──────────────────────────────────────────────────────
def main():
if not MDX_PATH.exists():
print(f"ERROR: MDX 04 not found at {MDX_PATH}", file=sys.stderr)
sys.exit(1)
if not V4_RESULT.exists():
print(f"ERROR: V4 result not found at {V4_RESULT}", file=sys.stderr)
sys.exit(1)
mdx_text = MDX_PATH.read_text(encoding='utf-8')
v4 = yaml.safe_load(V4_RESULT.read_text(encoding='utf-8'))
title_2_1, items_2_1 = extract_subsection_items(mdx_text, '2.1')
title_2_2, items_2_2 = extract_subsection_items(mdx_text, '2.2')
title_1, cards_1 = extract_section_04_1_cards(mdx_text)
env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(['html', 'xml']),
)
f16_zonefit_tpl = env.get_template("bim_issues_quadrant_four_zonefit.html.j2")
cards5_left_tpl = env.get_template("cards_5_left_zone.html.j2")
slide_fit_tpl = env.get_template("slide_fit_base.html.j2")
# 04-2 통합 (그룹핑) → F16 zone-fit
payload_f16 = map_to_f16_grouped(items_2_1, items_2_2, slide_title='DX 지연<br>요인')
html_f16_zonefit = f16_zonefit_tpl.render(slot_payload=payload_f16)
# 04-1 → 5-card left zone
payload_cards = map_to_5card_compact_slots(cards_1, section_title=title_1)
html_cards_left = cards5_left_tpl.render(slot_payload=payload_cards)
# slide_fit base 조립 (1280×720)
slide_fit_html = slide_fit_tpl.render(
slide_title='4. DX 지연 요인',
slide_meta='F16 user_semantic_override · slide_fit_preview',
zone_left=html_cards_left,
zone_right=html_f16_zonefit,
slide_footer='검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족이 DX 지연을 반복시킨다',
)
# 통합 1 슬라이드 페이지 (banner + slide_fit + metadata)
timestamp = datetime.now().isoformat(timespec='seconds')
page_html = f'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>MDX04 1280×720 slide_fit · F16 user_semantic_override</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Noto Sans KR', 'Pretendard', sans-serif; background: #e8ecf0; padding: 24px; }}
.preview-banner {{ max-width: 1280px; margin: 0 auto 16px; background: #fff7ed; border: 2px solid #f59e0b; border-radius: 8px; padding: 12px 16px; font-size: 12px; color: #92400e; line-height: 1.6; }}
.preview-banner strong {{ color: #78350f; }}
.preview-banner ul {{ margin-top: 6px; padding-left: 18px; font-family: monospace; font-size: 11px; }}
.slide-wrap {{ display: flex; justify-content: center; }}
</style>
</head>
<body>
<div class="preview-banner">
<strong>MDX04 slide_fit_preview · 1280×720 표준 (NOT a Phase Z final)</strong><br>
composition_preview (1280×1230) → <strong>slide_fit_preview (1280×720)</strong> 전환.
같은 grouping rule 유지 (04-2.* 8 항목 → 4 원인군). 04-2.2 보존. F16 native dim 폐기, zone-fit 적용.
<ul>
<li>layout = title (56) + body (1200×590, left 340 + right 840) + footer pill (48)</li>
<li>zone-left = 04-1 compact 5-card stack</li>
<li>zone-right = F16 quadrant zone-fit (4분면 + center quote)</li>
<li>q1 = 정책 집행 / 제도 운용 (2.1×2) · q2 = 개념 이해 부족 (2.1×1 + 2.2×2)</li>
<li>q3 = 기술 투자 / 본업 기술력 부족 (2.1×1 + 2.2×1) · q4 = 조직 / 수행 역량 (2.2×1)</li>
</ul>
</div>
<div class="slide-wrap">
{slide_fit_html}
</div>
</body>
</html>
'''
(RUN_DIR / "index.html").write_text(page_html, encoding='utf-8')
# 단독 slide_fit (banner 없이 슬라이드 자체만)
standalone_slide = f'''<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><title>MDX04 1280×720 slide_fit (standalone)</title>
<style>* {{margin:0;padding:0;box-sizing:border-box}} body {{font-family:'Noto Sans KR',sans-serif;background:#e8ecf0;padding:20px;display:flex;justify-content:center}}</style></head><body>
{slide_fit_html}
</body></html>
'''
(RUN_DIR / "slide_1280x720.html").write_text(standalone_slide, encoding='utf-8')
# debug.json
top1_2_1 = get_top1(v4, '04-2.1')
top1_2_2 = get_top1(v4, '04-2.2')
top1_1 = get_top1(v4, '04-1')
f16_2_1 = get_frame_judgment(v4, '04-2.1', 16)
f16_2_2 = get_frame_judgment(v4, '04-2.2', 16)
# grouping coverage 검증 (모든 8 항목 사용됐는지)
used = set()
for g in GROUPING_RULE['groups']:
for ref in g['items']:
used.add((ref['source'], ref['index']))
expected = set([('04-2.1', i) for i in range(len(items_2_1))]
+ [('04-2.2', i) for i in range(len(items_2_2))])
missing = sorted(expected - used)
extra = sorted(used - expected)
debug = {
'kind': 'mdx04_f16_override_slide_fit',
'preview_stage': 'slide_fit_preview',
'transition_from': 'composition_preview (1280×1230 비표준)',
'transition_to': 'slide_fit_preview (1280×720 표준)',
'transition_note': '같은 grouping rule 유지. F16 native height (1015px) 폐기. zone-fit 좌표 재계산. 폰트 축소.',
'is_phase_z_final': False,
'is_diagnostic': True,
'is_preview_or_result_candidate': True,
'generated_at': timestamp,
'v4_source': str(V4_RESULT.relative_to(ROOT)),
'mdx_source': str(MDX_PATH.relative_to(ROOT)),
'integrated_slide': True,
'layout': {
'slide_dimensions': '1280×720',
'title_bar_height': 56,
'body': {'width': 1200, 'height': 590, 'left_zone': 340, 'right_zone': 840, 'gap': 20},
'footer_pill_height': 48,
'zone_left': '04-1 compact 5-card stack (frame library gap)',
'zone_right': '04-2 통합 F16 quadrant zone-fit (grouped)',
'mdx_one_slide_principle': True,
'standard_16_9': True,
},
'override_decision': {
'selected_frame_source': 'user_semantic_override',
'selected_frame': 'F16',
'selected_template_id': 'bim_issues_quadrant_four',
'reason': 'F16 quadrant pattern semantically/visually appropriate for MDX04 04-2.* '
'(four-issue diagnostic structure). V4 top1 F26 figma 변환 부재 + semantic '
'review 에서 F16 가 더 적합 판단.',
},
'grouping_rule': GROUPING_RULE,
'grouping_coverage': {
'total_items': len(items_2_1) + len(items_2_2),
'mapped_items': len(used),
'missing': [{'source': s, 'index': i} for s, i in missing],
'extra': [{'source': s, 'index': i} for s, i in extra],
'all_items_preserved': not missing,
},
'sections': {
'04-2.1': {
'mdx_title': title_2_1,
'item_count': len(items_2_1),
'v4_top1': {
'frame_number': top1_2_1['frame_number'],
'template_id': top1_2_1['template_id'],
'label': top1_2_1['label'],
'confidence': top1_2_1['confidence'],
},
'selected_frame': 16,
'original_label': f16_2_1['label'] if f16_2_1 else None,
'original_confidence': f16_2_1['confidence'] if f16_2_1 else None,
},
'04-2.2': {
'mdx_title': title_2_2,
'item_count': len(items_2_2),
'v4_top1': {
'frame_number': top1_2_2['frame_number'],
'template_id': top1_2_2['template_id'],
'label': top1_2_2['label'],
'confidence': top1_2_2['confidence'],
},
'selected_frame': 16,
'original_label': f16_2_2['label'] if f16_2_2 else None,
'original_confidence': f16_2_2['confidence'] if f16_2_2 else None,
'preserved_in_grouping': True,
},
'04-1': {
'mdx_title': title_1,
'card_count': len(cards_1),
'v4_top1': {
'frame_number': top1_1['frame_number'],
'template_id': top1_1['template_id'],
'label': top1_1['label'],
'confidence': top1_1['confidence'],
} if top1_1 else None,
'selected_frame': None,
'override_note': '5-card library gap (32 frame DB 에 cardinality.ideal=5 frame 부재). '
'compact 5-column grid 로 통합 슬라이드 상단에 배치.',
},
},
'caveats': [
'정식 Phase Z final 아님 — V4 lookup 우회',
'preview_stage = slide_fit_preview (1280×720 표준). 이전 composition_preview (1280×1230) 에서 전환',
'F16 partial template = preview 전용 (data/runs/mdx04_f16_override/templates/) — design_agent/templates/phase_z2 미수정',
'anchor 보정 / detect_mdx 수정 / v4_full32_result.yaml 변경 없음',
'04-2.1 의 F16 original_label = reject (anchor=0). override 로 진행',
'04-2.2 의 F16 original_label = restructure (사용 가능 라벨)',
'04-2.2 보존 — 그룹핑으로 8 항목 모두 분면에 매핑',
'04-1 = 5-card library gap. zone-left 에 compact stack 으로 배치',
'그룹핑 룰은 사용자 semantic 결정 (yaml/dict 로 명시). 자동 생성 아님',
'F16 native dim (1280×1015) 폐기 — zone (840×590) 에 맞춰 좌표 재계산. 폰트 14px(ribbon)/11.5px(headline)/9.5px(sub)',
'slide-fit 으로 폰트 작아짐 → 가독성 trade-off. composition_preview 와 비교 필요',
],
}
(RUN_DIR / "debug.json").write_text(
json.dumps(debug, ensure_ascii=False, indent=2), encoding='utf-8',
)
# 이전 composition_preview 산출물 정리 — slide_fit_preview 로 대체
for old in ["slide_04-2.1.html", "slide_04-2.2.html", "slide_04-1.html",
"slide_04-2_grouped.html", "slide_04-1_compact.html"]:
p = RUN_DIR / old
if p.exists():
p.unlink()
print(f"[mdx04_f16_override_slide_fit] generated:")
print(f" index : {RUN_DIR / 'index.html'}")
print(f" slide 1280×720 : {RUN_DIR / 'slide_1280x720.html'}")
print(f" debug : {RUN_DIR / 'debug.json'}")
print()
print(f"Coverage: {len(used)}/{len(expected)} items mapped, missing={list(missing)}")
print(f"Stage: composition_preview → slide_fit_preview")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,404 @@
"""MDX04 partial preview — diagnostic only, NOT a Phase Z final.
목적: F16 (`bim_issues_quadrant_four`) 가 04-2.1 / 04-2.2 의 4 항목 구조와
시각적으로 정합하는지 사용자가 눈으로 확인.
방식:
- V4 runtime 우회 (정식 Phase Z 아님)
- F16 figma 원본 HTML 을 iframe 으로 임베드 (디자인 형태 그대로)
- 04-2.1 / 04-2.2 의 MDX 4 항목을 옆에 시각화 (구조 비교)
- 04-1 = frame library gap (5-card 구조, 매칭 frame 부재) placeholder
- diagnostic banner + V4 metadata + debug.json
출력:
data/runs/mdx04_partial_preview/index.html
data/runs/mdx04_partial_preview/debug.json
data/runs/mdx04_partial_preview/f16_original/ (figma 원본 + assets)
"""
import json
import re
import sys
from datetime import datetime
from html import escape
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
MDX_PATH = ROOT / "samples" / "mdx_batch" / "04.mdx"
V4_RESULT = ROOT / "tests" / "matching" / "v4_full32_result.yaml"
RUN_DIR = ROOT / "data" / "runs" / "mdx04_partial_preview"
# ─── MDX 04 의 04-2.1 / 04-2.2 섹션 추출 (### bullet) ──────────────
RE_SUBSECTION_HEAD = re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE)
RE_TOP_BULLET = re.compile(r'^-\s+\*\*([^*]+)\*\*\s*$')
def extract_subsection(text, num_label):
"""### {num_label} ... 부터 다음 ### 또는 --- 직전까지 추출."""
lines = text.split('\n')
start = None
for i, ln in enumerate(lines):
m = RE_SUBSECTION_HEAD.match(ln.strip())
if m and m.group(1) == num_label:
start = i
break
if start is None:
return None, []
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j].strip()
if RE_SUBSECTION_HEAD.match(s) or s == '---':
end = j
break
section_title = lines[start].lstrip('# ').strip()
body_lines = lines[start + 1:end]
# 4 항목 추출 (top bullet + nested bullets)
items = []
cur = None
for ln in body_lines:
stripped = ln.strip()
m = RE_TOP_BULLET.match(stripped)
if m:
if cur is not None:
items.append(cur)
cur = {'headline': m.group(1).strip(), 'subs': []}
continue
m2 = re.match(r'^-\s+(.+)$', stripped)
if m2 and cur is not None and not stripped.startswith('- **'):
cur['subs'].append(m2.group(1).strip())
if cur is not None:
items.append(cur)
return section_title, items
def extract_section_04_1(text):
"""04-1 = ## 1. DX에 대한 인식. <h3> 카드 5 개 + 각 카드 안 인용 + bullet 3 개."""
lines = text.split('\n')
start = None
for i, ln in enumerate(lines):
if ln.strip() == '## 1. DX에 대한 인식':
start = i
break
if start is None:
return None, []
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j].strip()
if s.startswith('## ') and s != '## 1. DX에 대한 인식':
end = j
break
body = '\n'.join(lines[start:end])
# <h3> 라벨 + 다음 <p> 인용 + <ul><li> bullet 3 개
cards = []
for m in re.finditer(r'<h3[^>]*>([^<]+)</h3>', body):
cards.append({'label': m.group(1).strip()})
return lines[start].lstrip('# ').strip(), cards
# ─── V4 metadata lookup ──────────────────────────────────────────
def get_f16_judgment(v4, section_id):
sec = v4['mdx_sections'].get(section_id)
if not sec:
return None
for e in sec['judgments_full32']:
if e['frame_number'] == 16:
return e
return None
def get_top1(v4, section_id):
sec = v4['mdx_sections'].get(section_id)
if not sec:
return None
j = sec.get('judgments_full32', [])
return j[0] if j else None
# ─── HTML 렌더링 ─────────────────────────────────────────────────
def render_items_html(items):
parts = ['<div class="items-list">']
for i, it in enumerate(items, 1):
parts.append('<div class="item">')
parts.append(f'<div class="item-headline">{i}. {escape(it["headline"])}</div>')
if it['subs']:
parts.append('<ul class="item-subs">')
for s in it['subs']:
parts.append(f'<li>{escape(s)}</li>')
parts.append('</ul>')
parts.append('</div>')
parts.append('</div>')
return '\n'.join(parts)
def render_cards_html(cards):
parts = ['<div class="cards-list">']
for i, c in enumerate(cards, 1):
parts.append(f'<div class="card">{i}. {escape(c["label"])}</div>')
parts.append('</div>')
return '\n'.join(parts)
def render_v4_metadata_html(j, label_note=''):
if j is None:
return '<div class="v4-meta v4-meta-missing">V4 entry not found</div>'
axes = j.get('axes', {})
return f'''<div class="v4-meta">
<div class="v4-meta-row">
<span class="v4-meta-key">V4 rank:</span><span class="v4-meta-val">{j["v4_full_rank"]}</span>
<span class="v4-meta-key">conf:</span><span class="v4-meta-val">{j["confidence"]:.4f}</span>
<span class="v4-meta-key">label:</span><span class="v4-meta-val v4-label-{j["label"]}">{j["label"]}</span>
</div>
<div class="v4-meta-row v4-axes">
<span class="v4-meta-key">axes:</span>
anchor={axes.get("anchor", 0):.2f} ·
cardinality={axes.get("cardinality", 0):.2f} ·
relation={axes.get("relation", 0):.2f} ·
slot={axes.get("slot", 0):.2f} ·
content={axes.get("content", 0):.4f}
</div>
{f'<div class="v4-meta-row v4-note">{label_note}</div>' if label_note else ''}
</div>'''
def render_section(section_id, mdx_title, items_html, j_f16, top1, note):
"""좌: F16 figma 원본 iframe / 우: MDX 텍스트 4 항목 / 하: V4 metadata."""
label_note = note
return f'''<section class="preview-section" id="sec-{section_id}">
<header class="section-head">
<h2>{escape(section_id)} · {escape(mdx_title)}</h2>
<div class="section-sub">
F16 (bim_issues_quadrant_four) candidate · top1 = F{top1["frame_number"]} ({top1["label"]}, conf {top1["confidence"]:.4f})
</div>
</header>
<div class="section-body">
<div class="col col-figma">
<div class="col-label">F16 figma 원본 (디자인 형태)</div>
<div class="iframe-frame">
<iframe src="f16_original/index.html" frameborder="0" scrolling="no"></iframe>
</div>
</div>
<div class="col col-mdx">
<div class="col-label">MDX 04 {escape(section_id)} 본문 (4 항목)</div>
{items_html}
</div>
</div>
{render_v4_metadata_html(j_f16, label_note)}
</section>'''
def render_04_1_placeholder(top1):
return f'''<section class="preview-section preview-gap" id="sec-04-1">
<header class="section-head">
<h2>04-1 · DX에 대한 인식</h2>
<div class="section-sub">
Frame library gap — 5-card 구조, 32 frame DB 에 cardinality.ideal=5 frame 부재 (이번 preview 제외)
</div>
</header>
<div class="gap-note">
<strong>왜 제외</strong>: 04-1 은 5 개 카드 (기술/효과/인력/경제/실무) — h3_cards=5 인식까지는 정상. 다만 32 frame
중 5-card 대응 frame 이 없어 V4 multi-constraint 통과 가능 frame 자체가 없음 (사용 가능 0/32, 모두 reject).
이건 detect bug 가 아니라 <strong>frame library readiness 문제</strong>.
<br><br>
V4 top1 = F{top1["frame_number"]} (conf {top1["confidence"]:.4f}, {top1["label"]}) — F16 도 rank 15, conf 0.361, reject.
</div>
</section>'''
# ─── 메인 ────────────────────────────────────────────────────────
def main():
if not V4_RESULT.exists():
print(f"ERROR: V4 result not found at {V4_RESULT}", file=sys.stderr)
sys.exit(1)
if not MDX_PATH.exists():
print(f"ERROR: MDX 04 not found at {MDX_PATH}", file=sys.stderr)
sys.exit(1)
mdx_text = MDX_PATH.read_text(encoding='utf-8')
v4 = yaml.safe_load(V4_RESULT.read_text(encoding='utf-8'))
# 04-2.1
title_2_1, items_2_1 = extract_subsection(mdx_text, '2.1')
j16_2_1 = get_f16_judgment(v4, '04-2.1')
top1_2_1 = get_top1(v4, '04-2.1')
# 04-2.2
title_2_2, items_2_2 = extract_subsection(mdx_text, '2.2')
j16_2_2 = get_f16_judgment(v4, '04-2.2')
top1_2_2 = get_top1(v4, '04-2.2')
# 04-1
title_1, cards_1 = extract_section_04_1(mdx_text)
top1_1 = get_top1(v4, '04-1')
# HTML 조립
section_2_1_html = render_section(
'04-2.1', title_2_1,
render_items_html(items_2_1),
j16_2_1, top1_2_1,
note='F16 candidate — V4 label=reject (anchor=0). 의미 매칭 회복했으나 anchor terms 부재로 multi-constraint 탈락. preview 목적 = F16 디자인 / 04-2.1 본문 정합성 시각 확인.'
)
section_2_2_html = render_section(
'04-2.2', title_2_2,
render_items_html(items_2_2),
j16_2_2, top1_2_2,
note='F16 restructure — V4 label=restructure 통과 (사용 가능 라벨). preview 목적 = F16 디자인이 04-2.2 본문에 시각적으로 fit 한지 확인.'
)
section_1_html = render_04_1_placeholder(top1_1)
timestamp = datetime.now().isoformat(timespec='seconds')
page_html = f'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>MDX04 Partial Preview · diagnostic only</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, "Pretendard", "Apple SD Gothic Neo", sans-serif;
background: #f5f6f8; color: #111; line-height: 1.5;
padding: 24px;
}}
.banner {{
background: #fff7ed; border: 2px solid #f59e0b; border-radius: 8px;
padding: 16px 20px; margin: 0 auto 24px; max-width: 1400px;
}}
.banner h1 {{ font-size: 18px; color: #92400e; margin-bottom: 6px; }}
.banner p {{ font-size: 13px; color: #78350f; }}
.banner .timestamp {{ font-size: 11px; color: #b45309; margin-top: 8px; font-family: monospace; }}
.preview-section {{
max-width: 1400px; margin: 0 auto 32px;
background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
overflow: hidden;
}}
.section-head {{ padding: 16px 20px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }}
.section-head h2 {{ font-size: 18px; color: #111827; margin-bottom: 4px; }}
.section-sub {{ font-size: 13px; color: #6b7280; }}
.section-body {{
display: grid; grid-template-columns: 1fr 1fr; gap: 0;
border-bottom: 1px solid #e5e7eb;
}}
.col {{ padding: 16px 20px; }}
.col-figma {{ border-right: 1px solid #e5e7eb; background: #fafafa; }}
.col-label {{
font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 12px; font-weight: 600;
}}
.iframe-frame {{
width: 100%; height: 380px;
background: #fff; border: 1px solid #d1d5db; border-radius: 4px;
overflow: hidden; position: relative;
}}
.iframe-frame iframe {{
width: 1280px; height: 720px;
transform: scale(0.5); transform-origin: top left;
}}
.items-list {{ display: flex; flex-direction: column; gap: 14px; }}
.item {{ padding: 12px 14px; background: #f3f4f6; border-left: 3px solid #2563eb; border-radius: 4px; }}
.item-headline {{ font-weight: 700; color: #111; font-size: 14px; margin-bottom: 6px; }}
.item-subs {{ list-style: disc; padding-left: 20px; font-size: 13px; color: #374151; }}
.item-subs li {{ margin-bottom: 3px; }}
.cards-list {{ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }}
.card {{ padding: 10px 12px; background: #f3f4f6; border-radius: 4px; font-size: 13px; }}
.v4-meta {{ padding: 12px 20px; background: #f9fafb; font-family: monospace; font-size: 12px; color: #374151; }}
.v4-meta-row {{ margin-bottom: 4px; }}
.v4-meta-key {{ color: #6b7280; margin-right: 4px; }}
.v4-meta-val {{ color: #111; margin-right: 12px; font-weight: 600; }}
.v4-axes {{ font-size: 11px; color: #6b7280; }}
.v4-note {{ font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.5; font-family: inherit; }}
.v4-label-use_as_is {{ color: #059669; }}
.v4-label-light_edit {{ color: #2563eb; }}
.v4-label-restructure {{ color: #d97706; }}
.v4-label-reject {{ color: #dc2626; }}
.preview-gap {{ background: #fef2f2; }}
.preview-gap .section-head {{ background: #fee2e2; border-bottom-color: #fecaca; }}
.gap-note {{ padding: 16px 20px; font-size: 13px; color: #7f1d1d; }}
</style>
</head>
<body>
<div class="banner">
<h1>MDX04 Partial Preview · diagnostic only</h1>
<p>이 출력은 정식 Phase Z final 이 아닙니다. F16 (`bim_issues_quadrant_four`) 가 04-2.1 / 04-2.2 의 4 항목 구조와
시각적으로 정합하는지 확인하기 위한 진단용 preview 입니다. V4 runtime / mapper / partial 우회.
04-1 은 frame library gap 으로 이번 preview 제외.</p>
<div class="timestamp">generated: {timestamp}</div>
</div>
{section_2_1_html}
{section_2_2_html}
{section_1_html}
</body>
</html>'''
out_html = RUN_DIR / "index.html"
out_html.write_text(page_html, encoding='utf-8')
debug = {
'kind': 'mdx04_partial_preview',
'is_phase_z_final': False,
'is_diagnostic': True,
'purpose': 'F16 디자인 / 04-2.* 4 항목 구조 시각 정합성 확인',
'generated_at': timestamp,
'v4_source': str(V4_RESULT.relative_to(ROOT)),
'mdx_source': str(MDX_PATH.relative_to(ROOT)),
'sections': {
'04-2.1': {
'mdx_title': title_2_1,
'item_count': len(items_2_1),
'top1': top1_2_1,
'f16_judgment': j16_2_1,
'preview_label': 'F16 candidate (V4 label = reject, conf 0.648, anchor=0)',
},
'04-2.2': {
'mdx_title': title_2_2,
'item_count': len(items_2_2),
'top1': top1_2_2,
'f16_judgment': j16_2_2,
'preview_label': 'F16 restructure (V4 label = restructure, 사용 가능 통과)',
},
'04-1': {
'mdx_title': title_1,
'card_count': len(cards_1),
'top1': top1_1,
'preview_label': 'EXCLUDED — frame library gap (5-card structure, no matching frame in 32 DB)',
},
},
'caveats': [
'정식 Phase Z final 아님 — V4 runtime / mapper / partial 모두 우회',
'F16 figma 원본 HTML 을 그대로 임베드 — 디자인 형태만 시각화 (텍스트 슬롯 매핑 X)',
'04-2.1 의 F16 V4 label = reject (anchor=0) — 의미 매칭 회복했으나 anchor terms 부재',
'04-2.2 의 F16 V4 label = restructure — 사용 가능 라벨, 단 정식 partial 미작성',
'04-1 = frame library readiness 문제 (detect bug 아님)',
],
}
out_debug = RUN_DIR / "debug.json"
out_debug.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding='utf-8')
print(f"[mdx04_partial_preview] generated:")
print(f" html : {out_html}")
print(f" debug : {out_debug}")
print(f" figma : {RUN_DIR / 'f16_original' / 'index.html'}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,45 @@
"""Pipeline v2 실행 스크립트.
사용법:
python scripts/run_pipeline_v2.py
python scripts/run_pipeline_v2.py samples/mdx/03.*.mdx
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.pipeline_v2 import generate_slide_v2
def main():
# 인자로 MDX 경로, 없으면 기본값
if len(sys.argv) > 1:
mdx_path = Path(sys.argv[1])
else:
mdx_path = Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")
if not mdx_path.exists():
print(f"MDX 파일 없음: {mdx_path}")
return
content = mdx_path.read_text(encoding="utf-8")
print(f"MDX: {mdx_path.name}")
print(f"길이: {len(content)}")
print()
start = time.time()
result = generate_slide_v2(content, base_path=str(mdx_path.parent))
elapsed = time.time() - start
print(f"\n완료! ({elapsed:.1f}초)")
print(f"run_id: {result['run_id']}")
print(f"결과: {result['run_dir']}/")
print(f" final.html")
print(f" final_context.json")
print(f" steps/")
if __name__ == "__main__":
main()

185
src/block_matcher_tfidf.py Normal file
View File

@@ -0,0 +1,185 @@
"""TF-IDF 기반 블록 매칭 엔진.
texts.md의 원본 텍스트를 직접 사용 — keywords 수동 생성 불필요.
frame_extractor가 텍스트를 추출하고, 여기서 TF-IDF 유사도를 계산.
사용법:
matcher = TfidfBlockMatcher()
result = matcher.match("DX 시행을 위한 필수 요건", ["기술(디지털)", "사람(역량)"])
"""
from __future__ import annotations
import logging
import math
import re
from collections import Counter
from pathlib import Path
from typing import Any
from src.frame_extractor import extract_all_frames
logger = logging.getLogger(__name__)
class TfidfBlockMatcher:
"""TF-IDF 기반 블록 매칭기. texts.md 직접 사용."""
def __init__(
self,
blocks_dir: str | Path = "figma_to_html_agent/blocks",
catalog_path: str | Path = "templates/catalog/blocks.yaml",
):
self.frames: list[dict] = extract_all_frames(blocks_dir)
self.catalog = self._load_catalog(catalog_path)
# 프레임별 전체 텍스트 + catalog 텍스트 합침
self.doc_texts: list[str] = []
self.doc_ids: list[str] = []
for frame in self.frames:
# texts.md 원본 텍스트 사용
text = frame.get("all_text", "")
# catalog에서 추가 정보 (when, description)
cat_entry = self._find_catalog_entry(frame["frame_id"])
if cat_entry:
text += " " + cat_entry.get("when", "")
text += " " + cat_entry.get("description", "")
self.doc_ids.append(cat_entry.get("id", frame["frame_id"]))
else:
self.doc_ids.append(frame["frame_id"])
self.doc_texts.append(text)
# IDF 사전 계산
self.idf = self._compute_idf(self.doc_texts)
logger.info(f"[tfidf] {len(self.frames)}개 프레임 인덱싱 완료 (texts.md 직접 사용)")
def _load_catalog(self, path: Path | str) -> list[dict]:
"""catalog 로드 (있으면)."""
path = Path(path)
if not path.exists():
return []
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
return data if isinstance(data, list) else data.get("blocks", [])
except Exception:
return []
def _find_catalog_entry(self, frame_id: str) -> dict | None:
"""frame_id로 catalog 항목 찾기."""
for entry in self.catalog:
if entry.get("source_frame") == frame_id:
return entry
return None
def _compute_idf(self, documents: list[str]) -> dict[str, float]:
"""IDF 계산."""
N = len(documents)
doc_freq = Counter()
for doc in documents:
words = set(doc.split())
for w in words:
doc_freq[w] += 1
return {w: math.log(N / (freq + 1)) for w, freq in doc_freq.items()}
def _tfidf_vec(self, text: str) -> dict[str, float]:
"""텍스트 → TF-IDF 벡터."""
words = text.split()
tf = Counter(words)
total = len(words) if words else 1
vec = {}
for w in tf:
idf = self.idf.get(w, math.log(len(self.doc_texts) + 1))
vec[w] = (tf[w] / total) * idf
return vec
def _cosine(self, a: dict, b: dict) -> float:
"""cosine similarity."""
keys = set(a) | set(b)
dot = sum(a.get(k, 0) * b.get(k, 0) for k in keys)
mag_a = math.sqrt(sum(v ** 2 for v in a.values())) if a else 0
mag_b = math.sqrt(sum(v ** 2 for v in b.values())) if b else 0
if mag_a == 0 or mag_b == 0:
return 0.0
return dot / (mag_a * mag_b)
def _preprocess_query(self, text: str) -> str:
"""MDX 쿼리 텍스트 전처리 (프레임 전처리와 동일 규칙)."""
text = text.replace("S/W", "SW 소프트웨어")
text = text.replace("H/W", "HW 하드웨어")
text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
text = text.replace("(", " ").replace(")", " ")
text = text.replace("[", " ").replace("]", " ")
text = re.sub(r'[·•→←↔×+/]', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def match(
self,
zone_title: str,
sub_titles: list[str] | None = None,
d1_items: list[str] | None = None,
top_k: int = 3,
) -> list[dict]:
"""중목차/소목차 텍스트로 프레임 매칭.
Returns:
[{"block_id": str, "frame_id": str, "score": float,
"title_text": str, "rough_structure": str}]
"""
if not self.doc_texts:
return []
# 쿼리 구성
parts = [zone_title]
if sub_titles:
parts.extend(sub_titles)
if d1_items:
parts.extend(d1_items)
query = self._preprocess_query(" ".join(parts))
# TF-IDF 유사도 계산
query_vec = self._tfidf_vec(query)
scores = []
for i, doc_text in enumerate(self.doc_texts):
doc_vec = self._tfidf_vec(doc_text)
score = self._cosine(query_vec, doc_vec)
scores.append((i, score))
# 상위 K개
scores.sort(key=lambda x: -x[1])
results = []
for idx, score in scores[:top_k]:
if score <= 0:
continue
frame = self.frames[idx]
results.append({
"block_id": self.doc_ids[idx],
"frame_id": frame["frame_id"],
"score": round(score, 4),
"method": "tfidf",
"title_text": frame.get("title_text", ""),
"rough_structure": frame.get("rough_structure", ""),
"item_count": frame.get("item_count", 0),
})
if results:
logger.info(
f"[tfidf] '{zone_title}' → top: {results[0]['block_id']} "
f"(score={results[0]['score']}, frame={results[0]['frame_id']})"
)
return results
def match_with_threshold(
self,
zone_title: str,
sub_titles: list[str] | None = None,
d1_items: list[str] | None = None,
threshold: float = 0.10,
) -> dict | None:
"""threshold 이상이면 best match, 아니면 None → recipe 경로."""
results = self.match(zone_title, sub_titles, d1_items, top_k=1)
if results and results[0]["score"] >= threshold:
return results[0]
return None

69
src/catalog_blocks.py Normal file
View File

@@ -0,0 +1,69 @@
"""새 catalog 로더.
기존 templates/catalog.yaml과 별도로,
templates/catalog/blocks.yaml을 로드하는 모듈.
기존 코드는 건드리지 않음.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def load_blocks_catalog(
path: str | Path = "templates/catalog/blocks.yaml",
) -> list[dict]:
"""blocks.yaml 로드.
Returns:
[{"id": "prerequisites-3col",
"structure_type": "3col-parallel",
"keywords": ["필수", "요건", ...],
"slots": ["sub_title", "body", "bullets"],
"recipe_compat": ["direct_fit", "parallel_cluster"],
"not_for": ["long_table"],
"template": "blocks/structures/prerequisites-3col.html",
"when": "3개 병렬 비교"}, ...]
"""
import yaml
path = Path(path)
if not path.exists():
logger.warning(f"[catalog] blocks.yaml 없음: {path}")
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
blocks = data if isinstance(data, list) else data.get("blocks", [])
logger.info(f"[catalog] {len(blocks)}개 블록 로드: {path}")
return blocks
except Exception as e:
logger.error(f"[catalog] 로드 실패: {e}")
return []
def find_block_by_id(
blocks: list[dict],
block_id: str,
) -> dict | None:
"""ID로 블록 찾기."""
return next((b for b in blocks if b.get("id") == block_id), None)
def filter_blocks_by_structure(
blocks: list[dict],
structure_type: str,
) -> list[dict]:
"""structure_type으로 필터링."""
return [b for b in blocks if b.get("structure_type") == structure_type]
def filter_blocks_by_recipe(
blocks: list[dict],
recipe: str,
) -> list[dict]:
"""recipe 호환 블록 필터링."""
return [b for b in blocks if recipe in b.get("recipe_compat", [])]

205
src/frame_extractor.py Normal file
View File

@@ -0,0 +1,205 @@
"""프레임별 텍스트 + 메타 추출기.
figma_to_html_agent/blocks/{frame_id}/texts.md를 파싱하여
TF-IDF 매칭용 데이터 구조를 만든다.
keywords 수동 생성 불필요 — texts.md의 원본 텍스트를 직접 사용.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def extract_frame_meta(texts_md_path: Path) -> dict:
"""texts.md에서 프레임 메타 추출.
Returns:
{
"frame_id": "1171281190",
"title_text": "필수조건",
"subtitle_texts": ["기술(디지털)", "사람(역량)", ...],
"body_texts": ["건설단계별 근본적인...", ...],
"all_text": "필수조건 기술 디지털 ...", ← TF-IDF용 전체 텍스트
"item_count": 3,
"rough_structure": "3col",
"sections": [{"heading": "타이틀", "lines": ["필수조건"]}, ...]
}
"""
if not texts_md_path.exists():
return {}
content = texts_md_path.read_text(encoding="utf-8")
frame_id = texts_md_path.parent.name
# 섹션별 파싱 (## 기준)
sections = []
current_heading = ""
current_lines = []
for line in content.split("\n"):
line = line.strip()
if line.startswith("## "):
if current_heading or current_lines:
sections.append({"heading": current_heading, "lines": current_lines})
current_heading = line.lstrip("# ").strip()
current_lines = []
elif line.startswith("### "):
# 서브섹션은 heading에 포함
current_lines.append(line.lstrip("# ").strip())
elif line.startswith("# "):
# 최상위 제목 (프레임 ID) — 건너뜀
continue
elif line.startswith(">"):
continue
elif line and not line.startswith("-"):
current_lines.append(line)
elif line.startswith("- "):
current_lines.append(line.lstrip("- ").strip())
if current_heading or current_lines:
sections.append({"heading": current_heading, "lines": current_lines})
# 층별 텍스트 분류
title_text = ""
subtitle_texts = []
body_texts = []
for sec in sections:
heading = sec["heading"].lower()
lines = sec["lines"]
if "타이틀" in heading or "제목" in heading:
title_text = " ".join(lines)
elif "서브" in heading or "헤더" in heading or "카테고리" in heading:
subtitle_texts.extend(lines)
elif "" in heading or "col" in heading.lower():
# 열별 텍스트 → subtitle + body
for line in lines:
if len(line) < 20:
subtitle_texts.append(line)
else:
body_texts.append(line)
elif "" in heading or "row" in heading.lower():
# 행별 텍스트
for line in lines:
if len(line) < 15:
subtitle_texts.append(line)
else:
body_texts.append(line)
elif "결론" in heading or "요약" in heading:
body_texts.extend(lines)
else:
# 기타 — 길이로 구분
for line in lines:
if len(line) < 20:
subtitle_texts.append(line)
else:
body_texts.append(line)
# rough_structure 추정
rough_structure = _guess_structure(sections, subtitle_texts)
# all_text: TF-IDF용 전체 텍스트 (전처리 적용)
all_parts = [title_text] + subtitle_texts + body_texts
all_text = " ".join(all_parts)
all_text = _preprocess_text(all_text)
return {
"frame_id": frame_id,
"title_text": title_text.strip(),
"subtitle_texts": subtitle_texts,
"body_texts": body_texts,
"all_text": all_text,
"item_count": len(subtitle_texts),
"rough_structure": rough_structure,
"sections": sections,
}
def _guess_structure(sections: list[dict], subtitles: list[str]) -> str:
"""섹션 구조에서 대략적인 블록 유형 추정."""
headings = [s["heading"].lower() for s in sections]
heading_text = " ".join(headings)
# 열 기반
col_count = sum(1 for h in headings if "" in h or "col" in h)
if col_count >= 3:
return "3col"
if col_count >= 2:
return "2col"
# 행 기반
row_count = sum(1 for h in headings if "" in h or "row" in h)
if row_count >= 2:
return "rows"
# 좌/우
if any("" in h or "left" in h for h in headings):
return "2col-compare"
# 표
if any("" in h or "table" in h for h in headings):
return "table"
# 기본
if len(subtitles) >= 3:
return "list"
return "unknown"
def _preprocess_text(text: str) -> str:
"""TF-IDF용 텍스트 전처리.
- 표기 통일
- 괄호/특수문자 정리
- 중복 제거
"""
# 표기 통일
text = text.replace("S/W", "SW 소프트웨어")
text = text.replace("H/W", "HW 하드웨어")
text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
# 괄호 내용 유지하되 괄호 제거
text = text.replace("(", " ").replace(")", " ")
text = text.replace("[", " ").replace("]", " ")
# 특수문자 정리
text = re.sub(r'[·•→←↔×+/]', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def extract_all_frames(
blocks_dir: str | Path = "figma_to_html_agent/blocks",
) -> list[dict]:
"""모든 프레임의 메타 추출.
Returns:
[{"frame_id": ..., "title_text": ..., "all_text": ..., ...}]
"""
blocks_dir = Path(blocks_dir)
if not blocks_dir.exists():
logger.warning(f"[extractor] blocks 폴더 없음: {blocks_dir}")
return []
frames = []
for frame_dir in sorted(blocks_dir.iterdir()):
if not frame_dir.is_dir():
continue
texts_md = frame_dir / "texts.md"
if texts_md.exists():
meta = extract_frame_meta(texts_md)
if meta:
frames.append(meta)
logger.debug(f"[extractor] {meta['frame_id']}: {meta['title_text']} ({meta['rough_structure']})")
logger.info(f"[extractor] {len(frames)}개 프레임 추출 완료")
return frames

356
src/pipeline_v2.py Normal file
View File

@@ -0,0 +1,356 @@
"""Pipeline v2: TF-IDF 기반 블록 매칭 + 렌더링 파이프라인.
기존 pipeline.py를 건드리지 않고, 새 매칭/렌더링 엔진으로 동작하는 별도 파이프라인.
입출력 계약:
입력: MDX 텍스트 + base_path
출력: data/runs/{run_id}/ 에 final.html + 단계별 context
흐름:
1. MDX 정규화
2. zone 구분 (중목차 기준)
3. TF-IDF 블록 매칭 (direct-fit / recipe 분기)
4. 블록 렌더링 (템플릿 로드 + 슬롯 삽입)
5. slide-base 조립
6. 저장
"""
from __future__ import annotations
import json
import logging
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from src.block_matcher_tfidf import TfidfBlockMatcher
from src.catalog_blocks import load_blocks_catalog, find_block_by_id
logger = logging.getLogger(__name__)
def generate_slide_v2(
mdx_content: str,
base_path: str = "",
catalog_path: str = "templates/catalog/blocks.yaml",
threshold: float = 0.15,
) -> dict:
"""v2 파이프라인: MDX → 슬라이드 HTML.
Returns:
{"run_id": str, "run_dir": str, "final_html": str, "steps": dict}
"""
templates_dir = Path("templates")
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
run_dir = Path("data/runs") / run_id
run_dir.mkdir(parents=True, exist_ok=True)
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
steps = {}
# ══ Step 1: MDX 정규화 ══
logger.info("[v2] Step 1: MDX 정규화")
from src.mdx_normalizer import normalize_mdx_content
normalized = normalize_mdx_content(mdx_content)
steps["step1_normalize"] = {
"title": normalized.get("title", ""),
"sections_count": len(normalized.get("sections", [])),
"sections": normalized.get("sections", []),
}
# Step 1 저장
_save_step_html(steps_dir / "step1_normalize.html", "Step 1: MDX 정규화", [
f"<b>title:</b> {normalized.get('title', '')}",
f"<b>sections:</b> {len(normalized.get('sections', []))}",
*[f" level={s['level']} | {s['title']} | {len(s.get('content',''))}"
for s in normalized.get("sections", [])],
])
# ══ Step 2: zone 구분 ══
logger.info("[v2] Step 2: zone 구분")
from src.section_parser import extract_major_sections
sections = normalized.get("sections", [])
major_sections = extract_major_sections(sections)
steps["step2_zones"] = [
{"title": s["title"], "sub_titles": s["sub_titles"], "content_len": len(s.get("content", ""))}
for s in major_sections
]
_save_step_html(steps_dir / "step2_zones.html", "Step 2: zone 구분", [
*[f"<b>zone {i+1}:</b> {s['title']} | sub_titles={s['sub_titles']} | {len(s.get('content',''))}"
for i, s in enumerate(major_sections)],
])
# ══ Step 3: TF-IDF 블록 매칭 ══
logger.info("[v2] Step 3: TF-IDF 블록 매칭")
matcher = TfidfBlockMatcher(catalog_path)
catalog = load_blocks_catalog(catalog_path)
match_results = {}
step3_lines = []
for sec in major_sections:
title = sec["title"]
sub_titles = sec["sub_titles"]
content = sec.get("content", "")
d1_items = [re.sub(r'\*+', '', d).strip()
for d in re.findall(r'^D1:\s*(.*)', content, re.MULTILINE)]
top3 = matcher.match(title, sub_titles, d1_items, top_k=3)
best = matcher.match_with_threshold(title, sub_titles, d1_items, threshold=threshold)
match_results[title] = {
"best": best,
"top3": top3,
"path": "direct-fit" if best else "recipe",
"sub_titles": sub_titles,
"content": content,
"d1_items": d1_items,
}
step3_lines.append(f"<b>zone:</b> {title}")
step3_lines.append(f" sub_titles: {sub_titles}")
if best:
step3_lines.append(f" → direct-fit: {best['block_id']} (score={best['score']})")
else:
step3_lines.append(f" → recipe 경로")
for j, c in enumerate(top3):
step3_lines.append(f" #{j+1}: {c['block_id']} (score={c['score']})")
step3_lines.append("")
steps["step3_matching"] = match_results
_save_step_html(steps_dir / "step3_matching.html", "Step 3: TF-IDF 블록 매칭", step3_lines)
# ══ Step 4: 블록 렌더링 + slide-base 조립 ══
logger.info("[v2] Step 4: 블록 렌더링 + 조립")
slide_title = normalized.get("title", "")
# conclusion 추출
from src.section_parser import extract_conclusion_text
conclusion = extract_conclusion_text(mdx_content)
conclusion = re.sub(r'^[\*•\-]\s*', '', conclusion).strip()
# 각 zone의 블록 HTML 렌더링
zone_htmls = []
zone_csses = []
total_zones = len(major_sections)
for sec in major_sections:
title = sec["title"]
info = match_results[title]
if info["path"] == "direct-fit" and info["best"]:
# direct-fit: 블록 템플릿 로드
block_id = info["best"]["block_id"]
# template 경로: catalog에 없으면 structures/ 에서 찾기
block_meta = find_block_by_id(catalog, block_id)
template_path = ""
if block_meta:
template_path = block_meta.get("template", "")
if not template_path:
template_path = f"blocks/structures/{block_id}.html"
if (templates_dir / template_path).exists():
html, css = _render_block_template(
templates_dir, template_path, title, info
)
zone_htmls.append((title, html))
if css:
zone_csses.append(css)
logger.info(f"[v2] {title} → block '{block_id}' 렌더")
else:
html = _render_fallback(title, info)
zone_htmls.append((title, html))
logger.warning(f"[v2] {title} → block '{block_id}' catalog에 없음, fallback")
else:
# recipe 경로: direct render
html = _render_fallback(title, info)
zone_htmls.append((title, html))
logger.info(f"[v2] {title} → recipe fallback render")
# slide-base 조립
final_html = _assemble_slide(
templates_dir, slide_title, conclusion,
zone_htmls, zone_csses, total_zones,
)
steps["step4_render"] = {
"zones": [{"title": t, "html_len": len(h)} for t, h in zone_htmls],
}
_save_step_html(steps_dir / "step4_render.html", "Step 4: 렌더링", [
*[f"<b>{t}:</b> {len(h)}자 HTML" for t, h in zone_htmls],
])
# ══ Step 5: 저장 ══
logger.info("[v2] Step 5: 저장")
(run_dir / "final.html").write_text(final_html, encoding="utf-8")
context = {
"run_id": run_id,
"title": slide_title,
"conclusion": conclusion,
"steps": {k: _safe_serialize(v) for k, v in steps.items()},
"match_results": {k: _safe_serialize(v) for k, v in match_results.items()},
}
(run_dir / "final_context.json").write_text(
json.dumps(context, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info(f"[v2] 완료: {run_dir}")
return {"run_id": run_id, "run_dir": str(run_dir), "final_html": final_html}
def _render_block_template(
templates_dir: Path,
template_path: str,
zone_title: str,
info: dict,
) -> tuple[str, str]:
"""블록 템플릿을 로드하고 그대로 반환.
현재는 블록 HTML을 그대로 사용 (슬롯 교체는 추후).
CSS는 분리하여 head로 이동.
"""
full_path = templates_dir / template_path
if not full_path.exists():
logger.warning(f"[v2] 템플릿 없음: {full_path}")
return _render_fallback(zone_title, info), ""
raw = full_path.read_text(encoding="utf-8")
# CSS 분리
css_parts = re.findall(r'<style>(.*?)</style>', raw, re.DOTALL)
css = "\n".join(css_parts)
html = re.sub(r'<style>.*?</style>', '', raw, flags=re.DOTALL).strip()
# HTML 주석 제거
html = re.sub(r'<!--[\s\S]*?-->', '', html).strip()
return html, css
def _render_fallback(zone_title: str, info: dict) -> str:
"""매칭 안 됐을 때 기본 렌더링. .bul 구조 사용."""
sub_titles = info.get("sub_titles", [])
content = info.get("content", "")
d1_items = info.get("d1_items", [])
parts = []
parts.append(f'<div class="zone-title" style="font-size:var(--font-zone-title);font-weight:700;color:var(--color-zone-title);margin-bottom:var(--heading-gap);">{zone_title}</div>')
if d1_items:
for item in d1_items:
if ": " in item:
h, d = item.split(": ", 1)
parts.append(f'<div class="bul">• <strong>{h}</strong>: {d}</div>')
else:
parts.append(f'<div class="bul">• {item}</div>')
elif content:
for line in content.split("\n"):
line = line.strip()
if not line or line.startswith("![") or line.startswith("[이미지:"):
continue
d1 = re.match(r'^D1:\s*(.*)', line)
d2 = re.match(r'^D2:\s*(.*)', line)
if d1:
text = re.sub(r'\*+', '', d1.group(1)).strip()
parts.append(f'<div class="bul">• <strong>{text}</strong></div>')
elif d2:
text = re.sub(r'\*+', '', d2.group(1)).strip()
parts.append(f'<div class="bul"> • {text}</div>')
return "\n".join(parts)
def _assemble_slide(
templates_dir: Path,
title: str,
conclusion: str,
zone_htmls: list[tuple[str, str]],
zone_csses: list[str],
total_zones: int,
) -> str:
"""slide-base.html에 zone들을 조립."""
from jinja2 import Environment, FileSystemLoader
# slide-base 로드
slide_base_path = templates_dir / "blocks" / "slide-base.html"
raw = slide_base_path.read_text(encoding="utf-8")
raw = re.sub(r'<!--[\s\S]*?-->', '', raw)
# body HTML 구성
body_parts = []
weight = 1.0 / max(total_zones, 1)
for i, (zone_title, html) in enumerate(zone_htmls):
height_pct = int(weight * 96)
margin = "margin-bottom:2%;" if i < total_zones - 1 else ""
body_parts.append(
f'<div style="height:{height_pct}%;{margin}padding-top:var(--space-xs);">'
f'<div style="height:100%;overflow:hidden;padding:0 var(--zone-padding-right) 0 var(--zone-padding-left);">'
f'{html}</div></div>'
)
body_html = "\n".join(body_parts)
# {% block body %} 치환
raw = raw.replace("{% block body %}{% endblock %}", body_html)
# 블록 CSS 합치기
extra_css = "\n".join(zone_csses)
# Jinja2 렌더 (include 지원)
env = Environment(loader=FileSystemLoader(str(templates_dir)))
template = env.from_string(raw)
result = template.render(
title=title,
footer_text=conclusion,
footer_pill_bg="",
)
# 블록 CSS를 head의 첫 </style> 앞에 삽입
if extra_css and '</style>' in result:
result = result.replace('</style>', f'\n{extra_css}\n</style>', 1)
# body 안에 <style> 잔존하면 head로 이동 (safety net)
body_start = result.find('<body')
if body_start > 0:
head_part = result[:body_start]
body_part = result[body_start:]
body_styles = re.findall(r'<style>([\s\S]*?)</style>', body_part)
if body_styles:
body_part = re.sub(r'<style>[\s\S]*?</style>', '', body_part)
head_part = head_part.replace('</style>', f'\n{chr(10).join(body_styles)}\n</style>', 1)
result = head_part + body_part
# asset 임베딩 (svg/ 경로 → base64)
from src.block_assembler import _embed_slide_assets
result = _embed_slide_assets(result, templates_dir)
return result
def _save_step_html(path: Path, title: str, lines: list[str]):
"""단계별 디버그 HTML 저장."""
content = "\n".join(f"<div>{line}</div>" for line in lines)
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;font-size:12px;}}
div{{margin-bottom:2px;}}</style>
</head><body>
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">{title}</div>
{content}
</body></html>"""
path.write_text(html, encoding="utf-8")
def _safe_serialize(obj):
"""JSON 직렬화 가능하도록 변환."""
if isinstance(obj, dict):
return {k: _safe_serialize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_safe_serialize(v) for v in obj]
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
return str(obj)

File diff suppressed because it is too large Load Diff