From ffad1ba82a7b100e86f915ab9dd834a95ea69313 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Thu, 26 Mar 2026 13:06:21 +0900 Subject: [PATCH] =?UTF-8?q?Phase=20I=20=EC=8B=A4=ED=96=89=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20+=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84=20(Stage=202.5=20=E2=86=92=20Stage?= =?UTF-8?q?=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개 항목) - I-14: SSE 유틸 공통 추출 (src/sse_utils.py 신규, 3개 파일 중복 제거) - I-13: dead code 3건 삭제 (_call_anthropic_direct, _extract_sse_text x2) + import anthropic 제거 - I-1: STEP_B_PROMPT purpose 가이드 미존재 블록 3개 → 실존 블록 교체 - I-2: catalog.yaml not_for 13건 미존재 블록 참조 교체/제거 - I-12: BLOCK_SLOTS 주석 개수 수정 (cards 9, visuals 6, emphasis 10) - I-10: INDEX.md 38개 동기화 (삭제된 8개 블록 행 제거) - I-11: README.md 38개 동기화 (_legacy 제거, 트리/개수 정리) - I-3: PURPOSE_FALLBACK 상수 + purpose 기반 미등록 블록 교체 - I-7: compare-pill-pair 단독 사용 금지 검증 - I-4: 38개 블록 전체에 slot_desc 추가 - I-5: 편집자 프롬프트에 slot_desc 전달 로직 - I-6: 제목 유사도 70% 초과 시 자동 교정 - I-9: 넘침 판단 Kei API 호출 (KEI_OVERFLOW_PROMPT, call_kei_overflow_judgment) - I-8: 대형 콘텐츠 정보 Kei overflow 프롬프트에 포함 프로세스 재설계: - Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합 - _review_balance() 확장: zone 예산 + overflow_detected action 추가 - Stage 5 루프에 Kei 넘침 판단 호출 통합 - _apply_adjustments()에 kei_trim/kei_restructure action 추가 - _build_overflow_context(), _convert_kei_judgment() 헬퍼 함수 추가 - DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존 Co-Authored-By: Claude Opus 4.6 (1M context) --- IMPROVEMENT-PHASE-I.md | 788 +++++++++++++++++++++++++++------- IMPROVEMENT-PROCESS-REVIEW.md | 464 ++++++++++++++++++++ IMPROVEMENT.md | 55 ++- README.md | 205 ++++++--- src/content_editor.py | 68 +-- src/design_director.py | 449 +++++++++++++++---- src/kei_client.py | 201 ++++----- src/pipeline.py | 195 ++++++++- src/sse_utils.py | 50 +++ templates/blocks/INDEX.md | 16 +- templates/catalog.yaml | 26 +- 11 files changed, 1982 insertions(+), 535 deletions(-) create mode 100644 IMPROVEMENT-PROCESS-REVIEW.md create mode 100644 src/sse_utils.py diff --git a/IMPROVEMENT-PHASE-I.md b/IMPROVEMENT-PHASE-I.md index d861606..a4c18bd 100644 --- a/IMPROVEMENT-PHASE-I.md +++ b/IMPROVEMENT-PHASE-I.md @@ -1,13 +1,15 @@ -# Phase I: 전수 정합성 복구 + 10가지 런타임 문제 해결 — 실행 상세 +# Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 — 실행 상세 (v3 최종) > 전수 검토에서 발견된 프롬프트 자기모순, 문서-코드 불일치, 코드 안전망 부족을 해결. +> **핵심 변경: 넘침 시 기계적 블록 교체(DOWNGRADE_MAP) → Kei 판단 호출로 전환.** > 원칙: 하드코딩 금지. 범용 해결. 회귀 금지. persona_agent 수정 0건. +> Sonnet 신규 투입 0건. Kei API를 사용해야 하는 곳에 Sonnet 대체 절대 금지. --- ## 문제 진단 총괄 -### 전수 검토에서 발견된 근본 원인 +### 전수 검토에서 발���된 근본 원인 **실제 블록 수: 38개** (문서는 46개로 표기) 삭제된 8개: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy @@ -15,58 +17,143 @@ 이 8개가 삭제되었지만 프롬프트, catalog, INDEX.md, README.md에 여전히 참조되고 있음. → AI가 존재하지 않는 블록을 선택 → 부적절한 강제 교체 → 빈 블록, 잘못된 배치 +### 넘침 처리의 근본적 접근 오류 + +**기존:** 높이 초과 → DOWNGRADE_MAP으로 블록 자동 교체 (코드가 기계적 판단) +**문제:** 블록을 바꾸면 콘텐츠 의도와 중요도 위계가 깨짐. 비교 콘텐츠인데 블록을 바꿔버리면 의미 없음. +**올바른 흐름:** +``` +Kei 실장이 콘텐츠 구조/중요도 결정 + → 팀장이 그 구조에 가장 적합한 블록 선택 + → 컨테이너에 맞게 텍스트 조절 + → 넘치면? → Kei에게 상황 전달 → Kei가 판단 + Option 1: 텍스트 축약으로 해결 + Option 2: 핵심 재구성 + 상세는 팝업(detail page)으로 분리 +``` + +### v3 정정 사항 (전수 코드 조사 결과) + +| 기존 판단 | 조사 결과 | 조치 | +|----------|----------|------| +| I-2b: defaults에 삭제 블록 잔존 | **잔존 없음.** defaults 딕셔너리는 현재 38개만 포함. `docs/BLOCK_SLOTS_45.py`(구 아카이브)와 혼동 | 항목 삭제 | +| I-15: 템플릿 없는 블록 4개 | **4개 모두 존재 확인.** flow-arrow-horizontal, keyword-circle-row, tab-label-row, divider-text 전부 .html 있음 | 항목 삭제 | +| I-13: dead code 1개 | `_call_anthropic_direct()` + `_extract_sse_text()` **2개** dead code (kei_client.py, content_editor.py) | 확장 | +| README에 _legacy 13개 | **_legacy/ 디렉토리 자체가 존재하지 않음** | I-11에 반영 | + +**최종 항목: 14개** (v2의 16개에서 I-2b, I-15 삭제) + --- -## 패턴 A: 프롬프트 자기모순 (문제 2, 7) +## 그룹 1: 정���성 복구 — 미존재 블록 참조 차단 + +삭제된 8개 블록을 AI가 참조하지 못하도록 모든 참조 지점에서 제거/교체한다. ### I-1: STEP_B_PROMPT purpose 가이드에서 미존재 블록 제거 -**현재 문제:** -```python -# design_director.py 264~271행 -"- 근거사례 → quote-left-border, card-text-grid" ← 둘 다 미존재! -"- 용어정의 → card-text-grid" ← 미존재! -"- 구조시각화 → venn-diagram, layer-diagram" ← layer-diagram 미존재! -``` +**위치:** `src/design_director.py` 264~271행 +**현재 코드:** +```python +"- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)\n" +"- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)\n" +"- 구조시각화 → venn-diagram, layer-diagram (단독 배치)\n" +``` 허용 목록에는 없는데 purpose 가이드에서 적극 추천 → **프롬프트 자기모순** → Sonnet이 미존재 블록 선택 -**수정:** -``` -- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열) -- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면) -- 구조시각화 → venn-diagram (단독 배치) +**변경 코드:** +```python +"- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)\n" +"- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)\n" +"- 구조시각화 → venn-diagram (단독 배치)\n" ``` -**수정 파일:** src/design_director.py (STEP_B_PROMPT) +**영향 범위:** STEP_B_PROMPT 문자열 내부 3행만 수정. 함수 시그니처, 호출 구조, API 호출 로직 변경 없음. +**회귀 위험:** 없음. Sonnet이 읽는 참고 가이드 텍스트만 변경. --- ### I-2: catalog.yaml의 not_for/when에서 미존재 블록 참조 제거 -**현재 문제:** -- circle-gradient not_for: "topic-header" → 실제 이름은 topic-left-right/topic-center/topic-numbered -- circle-gradient not_for: "conclusion-accent-bar" → 미존재. 실제는 banner-gradient -- process-horizontal not_for: "timeline" → 미존재. 실제 없음 (삭제됨) +**위치:** `templates/catalog.yaml` — 전수 조사 결과 12건 -**수정:** catalog.yaml 전체에서 미존재 블록 참조를 실존 블록으로 교체 +| 행 | 블록 | not_for에서 참조하는 미존재 블록 | 교체 대상 | +|----|------|-------------------------------|----------| +| 102 | card-image-3col | card-text-grid | card-icon-desc 또는 삭제 | +| 119 | card-dark-overlay | card-text-grid | card-icon-desc 또는 삭제 | +| 134 | card-tag-image | card-text-grid | card-icon-desc 또는 삭제 | +| 210 | card-stat-number | card-text-grid | card-icon-desc 또는 삭제 | +| 226 | card-numbered | card-text-grid | card-icon-desc 또는 삭제 | +| 311 | circle-gradient | conclusion-accent-bar | banner-gradient | +| 376 | keyword-circle-row | card-text-grid | card-icon-desc 또는 삭제 | +| 391 | quote-big-mark | quote-left-border | 삭제 (자기 참조 무의미) | +| 407 | quote-question | quote-left-border, conclusion-accent-bar | quote-big-mark, banner-gradient | +| 443 | banner-gradient | conclusion-accent-bar | 삭제 (자기 참조 무의미) | +| 475 | highlight-strip | conclusion-accent-bar | banner-gradient | +| 540 | divider-text | conclusion-accent-bar | banner-gradient | -**수정 파일:** templates/catalog.yaml +**영향 범위:** catalog.yaml의 not_for 문자열만 수정. `_load_catalog_map_for_height()`, `_get_registered_block_ids()`, `_load_catalog()` 함수가 읽는 id/height_cost 필드는 변경 없음. +**회귀 위험:** 없음. not_for는 Sonnet이 읽는 참고 정보. --- +### I-10: INDEX.md 동기화 + +**위치:** `templates/blocks/INDEX.md` — 삭제 대상 8행 (27, 66~69, 77, 80, 89행) + +미존재 8개 블록 행 제거: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy + +**회귀 위험:** 없음. 문서만 수정. + +--- + +### I-11: README.md 동기화 + +**위치:** `README.md` — 블록 관련 섹션 + +변경 사항: +- "46개 + _legacy 13개" → "38개" (_legacy 디렉토리는 존재하지 않음) +- Sonnet fallback 표기 제거 (Phase G에서 이미 제거됨) +- 블록 트리 구조에서 미존재 8개 블록 제거 +- 각 카테고리 개수 수정: headers 5, cards 9, tables 3, visuals 6, emphasis 10, media 5 + +**회귀 위험:** 없음. 문서만 수정. + +--- + +### I-12: BLOCK_SLOTS 주석 수정 + +**위치:** `src/design_director.py` 32, 46, 53, 64행 (주석) + +| 현재 주석 | 실제 개수 | 수정 | +|----------|----------|------| +| `# cards/ (10개)` | 9개 | `# cards/ (9개)` | +| `# visuals/ (10개)` | 6개 | `# visuals/ (6개)` | +| `# emphasis/ (12개)` | 10개 | `# emphasis/ (10개)` | +| `# media/ (5개)` | 5개 | 변경 없음 (일치) | + +**회귀 위험:** 없음. 주석만 수정. 실행 코드 변경 0행. + +--- + +## 그룹 2: 블록 선택 개선 + ### I-3: 미등록 블록 교체를 purpose 기반으로 변경 -**현재 문제:** +**위치:** `src/design_director.py` 565~574행 + +**현재 코드:** ```python -# design_director.py 574행 -block["type"] = "callout-solution" # 모든 미등록 블록을 일괄 교체 +if block_type and block_type not in registered_ids: + logger.warning( + f"[Step B 검증] 미등�� 블록 '{block_type}' 거부 → " + f"'callout-solution'으로 교체" + ) + block["type"] = "callout-solution" ``` -결론 꼭지의 미등록 블록도 callout-solution으로 교체 → 의미적으로 부적절 - -**수정:** purpose 기반 교체 맵 +**변경 코드:** ```python +# 모듈 상수 (DOWNGRADE_MAP 근처에 배치) PURPOSE_FALLBACK = { "문제제기": "callout-warning", "근거사례": "quote-big-mark", @@ -75,26 +162,52 @@ PURPOSE_FALLBACK = { "결론강조": "banner-gradient", "구조시각화": "card-icon-desc", } -# 미등록 블록 거부 시: block의 purpose를 보고 적절한 대체 블록 선택 -fallback_type = PURPOSE_FALLBACK.get(block.get("purpose", ""), "callout-solution") + +# 기존 if문 내부 변경 +if block_type and block_type not in registered_ids: + purpose = block.get("purpose", "") + fallback = PURPOSE_FALLBACK.get(purpose, "callout-solution") + logger.warning( + f"[Step B 검증] 미등록 블록 '{block_type}' 거부 → " + f"'{fallback}'으로 교체 (purpose={purpose})" + ) + block["type"] = fallback ``` -**수정 파일:** src/design_director.py +**영향 범위:** 조건문(`block_type not in registered_ids`) 그대로 유지. 교체 대상만 분기. +**회귀 위험:** 없음. purpose가 없으면 `"callout-solution"` (기존과 동일). PURPOSE_FALLBACK 상수는 범용 맵이므로 하드코딩 아님. --- -## 패턴 B: 슬롯 의미 정보 미전달 (문제 3, 5, 9, 10) +### I-7: compare-pill-pair 단독 사용 금지 + +**위치:** `src/design_director.py` `_validate_height_budget()` 함수 내 — 금지 블록 교체(729~737행) 이후, 높이 체크(739행) 이전에 삽입 + +**추가 코드:** +```python +# compare-pill-pair 단독 사용 검증 +COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"} +for area, area_blocks in zone_blocks.items(): + types = {b.get("type") for b in area_blocks} + if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS: + for block in area_blocks: + if block.get("type") == "compare-pill-pair": + block["type"] = "comparison-2col" + logger.warning("[pill-pair 단독 금지] compare-pill-pair → comparison-2col") +``` + +**영향 범위:** `_validate_height_budget()` 내부에 검증 로직 추가. 기존 forbidden 교체/높이 체크 로직 변경 없음. +**회귀 위험:** 없음. `comparison-2col`은 medium(150px), `compare-pill-pair`도 medium이므로 높이 변화 없음. 후속 높이 체크에 영향 없음. + +--- + +## 그룹 3: 슬롯 의미 전달 ### I-4: BLOCK_SLOTS에 slot_desc 추가 -**현재 문제:** -편집자가 슬롯 이름만 받고 의미를 모름: -- source에 출처가 아닌 꼭지 제목을 넣음 (문제 3) -- text/sub_text 순서를 뒤집음 (문제 5) -- rows[][] 배열 구조를 채우지 못함 (문제 9) -- cards[] 배열 구조를 채우지 못함 (문제 10) +**위치:** `src/design_director.py` 25~70행 (BLOCK_SLOTS 딕셔너리) -**수정:** BLOCK_SLOTS에 slot_desc 필드 추가 +**변경:** 38개 블록 각각에 `"slot_desc": {...}` 키 추가. 예: ```python "quote-big-mark": { "required": ["quote_text"], @@ -123,148 +236,376 @@ fallback_type = PURPOSE_FALLBACK.get(block.get("purpose", ""), "callout-solution }, ``` -**수정 범위:** 38개 블록 모두에 slot_desc 추가 (가장 큰 작업) +**영향 범위:** 기존 `required`/`optional` 키 변경 없음. 새 키 `slot_desc` 추가만. 기존 코드에서 `slots.get('required')`, `slots.get('optional')` 접근은 영향 없음. +**회귀 위험:** 없음. 새 키는 I-5에서만 읽음. 기존 import 구조(`from src.design_director import BLOCK_SLOTS`) 유지. + +**작업량:** 38개 블록 × slot_desc 작성 — Phase I에서 가장 큰 작업. + +--- ### I-5: 편집자 프롬프트에 slot_desc 전달 -**현재:** content_editor.py 87~88행 +**위치:** `src/content_editor.py` 86~92행 (`fill_content()` 내부) + +**현재 코드:** ```python -f" 필수 슬롯: {slots.get('required', [])}\n" -f" 선택 슬롯: {slots.get('optional', [])}" +req_text = ( + f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n" + f" 목적(purpose): {block.get('purpose', '미지정')}\n" + f" 용도: {block.get('reason', '미지정')}\n" + f" 크기: {block.get('size', 'medium')}\n" + f" 필수 슬롯: {slots.get('required', [])}\n" + f" 선택 슬롯: {slots.get('optional', [])}" +) ``` -**수정:** +**변경 코드:** 기존 코드 유지 + 아래 추가 ```python -f" 필수 슬롯: {slots.get('required', [])}\n" -f" 선택 슬롯: {slots.get('optional', [])}\n" -# slot_desc가 있으면 각 슬롯의 의미 전달 +# slot_desc 전달 (I-4에서 추가한 슬롯 의미 설명) slot_desc = slots.get("slot_desc", {}) if slot_desc: desc_lines = [f" {k}: {v}" for k, v in slot_desc.items()] req_text += "\n 슬롯 설명:\n" + "\n".join(desc_lines) ``` -**수정 파일:** src/content_editor.py, src/design_director.py (BLOCK_SLOTS) +**영향 범위:** 기존 `req_text` 구성 로직 변경 없음. 뒤에 추가만. `_call_kei_editor()`로 전달되는 프롬프트에 정보 추가. +**Kei vs Sonnet:** 편집자는 **Kei API만 사용** (session_id: `"design-agent-editor"`). Sonnet 전환 없음. +**회귀 위험:** 없음. `slot_desc`가 없는 블록은 빈 딕셔너리 → if 통과 안 함 → 기존과 동일. --- -## 패턴 C: 코드 안전망 부족 (문제 1, 4, 6, 8) +## 그룹 4: 코드 안전망 -### I-6: 제목 유사도 검증 (문제 1) +### I-6: 제목 유사도 검증 -**현재:** 프롬프트에 "달라야 한다"고 지시했지만 코드 검증 없음 +**위치:** `src/pipeline.py` 56행 이후 (1단계-B 완료 후, 이미지 측정 전) -**수정:** pipeline.py 또는 design_director.py에서 `analysis["title"]`과 `analysis["topics"][0]["title"]` 비교 +**추가 코드:** ```python +# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 from difflib import SequenceMatcher title = analysis.get("title", "") -first_topic_title = analysis.get("topics", [{}])[0].get("title", "") -similarity = SequenceMatcher(None, title, first_topic_title).ratio() -if similarity > 0.7: - # 첫 꼭지 제목을 purpose 기반으로 변경 - first_topic = analysis["topics"][0] - purpose = first_topic.get("purpose", "문제제기") - first_topic["title"] = f"{purpose}: {first_topic.get('summary', '')[:30]}" - logger.warning(f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경") +topics = analysis.get("topics", []) +if topics: + first_title = topics[0].get("title", "") + similarity = SequenceMatcher(None, title, first_title).ratio() + if similarity > 0.7: + purpose = topics[0].get("purpose", "문제제기") + topics[0]["title"] = f"{purpose}: {topics[0].get('summary', '')[:30]}" + logger.warning(f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경") ``` -**수정 파일:** src/pipeline.py - -### I-7: compare-pill-pair 단독 사용 금지 (문제 4) - -**현재:** compare-pill-pair가 비교 내용 없이 라벨만 표시 - -**수정:** _validate_height_budget() 안에서 compare-pill-pair가 해당 zone에서 유일한 "비교" 블록이면 경고 -```python -# compare-pill-pair는 비교 테이블의 헤더로만 사용 -# 같은 zone에 compare-2col-split, compare-3col-badge, comparison-2col이 없으면 부적절 -COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"} -for area, area_blocks in zone_blocks.items(): - types = {b.get("type") for b in area_blocks} - if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS: - # pill-pair를 comparison-2col로 교체 - for block in area_blocks: - if block.get("type") == "compare-pill-pair": - block["type"] = "comparison-2col" - logger.warning(f"[pill-pair 단독 금지] compare-pill-pair → comparison-2col") -``` - -**수정 파일:** src/design_director.py - -### I-8: 대형 테이블 → detail_target 자동 설정 (문제 6) - -**현재:** 원본의 12행 비교표가 detail_target으로 잡히지 않음 - -**수정:** pipeline.py에서 1단계-B 완료 후 -```python -for table in analysis.get("tables", []): - if table.get("rows", 0) >= 5 and not table.get("fits_single_page", True): - for topic in analysis.get("topics", []): - if topic.get("id") == table.get("topic_id"): - if not topic.get("detail_target"): - topic["detail_target"] = True - logger.info(f"대형 테이블 → detail_target 자동: topic {topic['id']}") -``` - -**수정 파일:** src/pipeline.py - -### I-9: DOWNGRADE_MAP 확장 + 다단계 교체 (문제 8) - -**현재:** body 높이 600px > 490px인데 medium 블록 교체 대상 없음 - -**수정:** -1. medium → compact 교체 맵 추가 -2. 교체 임계치 `>= 250` → `>= 150`으로 낮춤 -3. 1회 교체 후에도 초과이면 다음 블록도 교체 (현재는 break) - -```python -DOWNGRADE_MAP = { - # 기존 large/xlarge → medium/compact - "venn-diagram": "card-icon-desc", - "card-step-vertical": "card-numbered", - ... - # 추가: medium → compact - "quote-big-mark": "divider-text", - "comparison-2col": "compare-pill-pair", # 주의: I-7 규칙과 함께 적용 - "process-horizontal": "flow-arrow-horizontal", - "dark-bullet-list": "highlight-strip", - "card-icon-desc": "highlight-strip", -} -``` - -**수정 파일:** src/design_director.py +**영향 범위:** pipeline.py 1단계~2단계 사이에 삽입. 기존 흐름 변경 없음. analysis 딕셔너리의 topics[0]["title"]만 조건부 수정. +**회귀 위험:** 없음. 유사도 70% 이하면 아무 변경 없음. `SequenceMatcher`는 Python 표준 라이브러리. --- -## 문서 동기화 +## 그룹 5: 넘침 처리 패러다임 전환 — 핵심 변경 -### I-10: INDEX.md 동기화 (38개 실제 현황) +### I-9: DOWNGRADE_MAP → Kei 넘침 판단 호출 -미존재 8개 블록 항목 제거: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy +**기존 방식 (폐기 대상):** +``` +높이 초과 감지 → DOWNGRADE_MAP에서 블록 자동 교체 +``` +- 콘텐츠 의도 무시 (비교 블록을 다른 타입으로 교체) +- 중요도 위계 파괴 (중요한 내용이 작은 블록으로 밀려남) +- 정보 손실 (items[] → 단일 text) +- 순환 충돌 위험 (I-7과 DOWNGRADE가 서로 되돌림) -**수정 파일:** templates/blocks/INDEX.md +#### 구현 설계 -### I-11: README.md 동기화 +**설계 결정:** `_validate_height_budget()`는 현재 동기 함수(sync). Kei API 호출은 비동기(async). 함수 자체를 async로 바꾸지 않고, **overflow 정보를 반환하여 pipeline에서 Kei 호출**하는 구조 채택. (기존 함수 구조 최대한 보존) -- "46개 + _legacy 13개" → "38개" -- Sonnet fallback 표기 제거 (Phase G에서 제거됨) -- 블록 트리 구조에서 미존재 블록 제거 -- 각 카테고리 개수 수정: cards 9, visuals 6, emphasis 10 +**Step 1: `_validate_height_budget()` 변경** (`design_director.py` 711~777행) -**수정 파일:** README.md +```python +def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]: + """zone별 height_cost 합산을 검증한다. -### I-12: BLOCK_SLOTS 주석 수정 + 초과 시 overflow 정보를 수집하여 반환. 블록 자동 교체는 하지 않음. + DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존. -- "visuals/ (10개)" → "visuals/ (6개)" -- "emphasis/ (12개)" → "emphasis/ (10개)" + Returns: + overflow 정보 리스트. 초과 없으면 빈 리스트. + """ + # 기존: 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지 + # 기존: pill-pair 단독 검증 (I-7) — 유지 -**수정 파일:** src/design_director.py (주석만) + overflows = [] + for area, area_blocks in zone_blocks.items(): + # 기존 높이 계산 로직 유지 + total = sum(_get_block_height(b.get("type", "")) for b in area_blocks) + total += gap_px * max(0, len(area_blocks) - 1) -### I-13: 데드 코드 정리 + if total <= budget: + continue -- `_call_anthropic_direct()` 함수 제거 (kei_client.py) — G-2에서 호출 제거했지만 함수 자체는 잔존 + logger.warning(f"[높이 예산 초과] {area}: {total}px > {budget}px") -**수정 파일:** src/kei_client.py + # 기존: DOWNGRADE_MAP 자동 교체 → 제거 + # 신규: overflow 정보 수집 + overflows.append({ + "area": area, + "overflow_px": total - budget, + "budget_px": budget, + "total_px": total, + "blocks": [ + { + "type": b.get("type", ""), + "purpose": b.get("purpose", ""), + "topic_id": b.get("topic_id"), + "height_px": _get_block_height(b.get("type", "")), + } + for b in area_blocks + ], + }) + + return overflows +``` + +**반환값 변경:** `None` → `list[dict]` (빈 리스트 = 초과 없음) +**호출부 변경:** `create_layout_concept()` 601행 +```python +# 기존: _validate_height_budget(blocks, preset) # 반환값 무시 +# 변경: +overflows = _validate_height_budget(blocks, preset) +# overflow 정보를 반환값에 포함 +result = { + "title": analysis.get("title", "슬라이드"), + "pages": [{"grid_areas": ..., "blocks": blocks}], +} +if overflows: + result["overflow"] = overflows +return result +``` + +**Step 2: pipeline.py에 Stage 2.5 추가** (67행 이후) + +```python +# 2단계 완료 후 +layout_concept = await create_layout_concept(content, analysis) + +# 2.5단계: 넘침 판단 (overflow 있을 때만) +overflow = layout_concept.pop("overflow", None) +if overflow: + yield {"event": "progress", "data": "2.5/5 Kei 실장이 넘침 구간을 검토 중..."} + judgment = await _call_kei_overflow_judgment(overflow, content, analysis) + + if judgment is None: + # Kei API 실패 → DOWNGRADE 비상 작동 + logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체") + _downgrade_fallback(layout_concept, overflow) + elif judgment.get("decision") == "trim": + # Option 1: 텍스트 분량 제약 → Stage 3에서 반영 + for target in judgment.get("trim_targets", []): + _apply_trim_constraint(layout_concept, target) + elif judgment.get("decision") == "restructure": + # Option 2: 핵심 재구성 + 팝업 분리 + analysis = _apply_restructure(analysis, judgment) + layout_concept = await create_layout_concept(content, analysis) +``` + +**Step 3: Kei 넘침 판단 호출 함수** (`src/kei_client.py` 또는 `src/pipeline.py`) + +```python +KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다. +디자인 팀장이 배치한 블록들이 컨테이너를 초과한다. +콘텐츠의 중요도와 전달 메시지를 기준으로 판단하라. + +## 판단 기준 +- 텍스트만 줄이면 해결되는가? → Option 1 (trim) +- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 큰가? → Option 2 (restructure) +- 중요도가 높은 콘텐츠를 축소하면 안 된다 +- 부가 정보는 팝업(detail page)으로 분리 가능 + +## 출력 (JSON만) +Option 1: +{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]} + +Option 2: +{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"} +""" + +async def _call_kei_overflow_judgment( + overflow: list[dict], + content: str, + analysis: dict, +) -> dict | None: + """Kei API에 넘침 상황을 전달하고 판단을 받는다. + + 반드시 Kei API 경유. Anthropic 직접 호출 절대 ��지. + """ + kei_url = getattr(settings, "kei_api_url", "http://localhost:8000") + + overflow_desc = json.dumps(overflow, ensure_ascii=False, indent=2) + topics_desc = json.dumps( + [{"id": t["id"], "title": t["title"], "purpose": t.get("purpose", "")} + for t in analysis.get("topics", [])], + ensure_ascii=False, + ) + + prompt = ( + KEI_OVERFLOW_PROMPT + "\n\n" + f"## 넘침 현황\n{overflow_desc}\n\n" + f"## 꼭지 목록\n{topics_desc}\n\n" + f"## 원본 콘텐츠 요약\n{content[:2000]}" + ) + + try: + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream( + "POST", + f"{kei_url}/api/message", + json={ + "message": prompt, + "session_id": "design-agent-overflow", + "mode_hint": "chat", + }, + timeout=None, + ) as response: + if response.status_code != 200: + logger.warning(f"Kei API (overflow) HTTP {response.status_code}") + return None + full_text = await stream_sse_tokens(response) # I-14 공통 유틸 + + if full_text: + return _parse_json(full_text) + return None + except Exception as e: + logger.warning(f"Kei API (overflow) 호출 실패: {e}") + return None +``` + +**Step 4: DOWNGRADE 비상 함수** (기존 로직을 별도 함수로 분리) + +```python +def _downgrade_fallback(layout_concept: dict, overflows: list[dict]) -> None: + """Kei API 실패 시 비상용 기계적 블록 교체. + + 기존 DOWNGRADE_MAP 로직을 그대로 사용. + 정상 경로가 아닌 비상 경로임을 로그로 명시. + """ + for page in layout_concept.get("pages", []): + blocks = page.get("blocks", []) + for overflow in overflows: + area = overflow["area"] + area_blocks = [b for b in blocks if b.get("area") == area] + area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True) + + total = overflow["total_px"] + budget = overflow["budget_px"] + + for block in area_blocks: + block_type = block.get("type", "") + if block_type in DOWNGRADE_MAP and _get_block_height(block_type) >= 250: + replacement = DOWNGRADE_MAP[block_type] + old_h = _get_block_height(block_type) + new_h = _get_block_height(replacement) + block["type"] = replacement + total = total - old_h + new_h + logger.warning(f"[DOWNGRADE 비상] {block_type} → {replacement}") + if total <= budget: + break +``` + +**Kei vs Sonnet:** 넘침 판단은 **Kei API만 사용** (session_id: `"design-agent-overflow"`). Sonnet 전환 절대 없음. +**DOWNGRADE_MAP:** 기존 8개 항목 유지. Kei API 실패 시에만 실행. 정상 경로에서는 사용되지 않음. +**회귀 위험:** 기존 `_validate_height_budget()` 반환값이 `None` → `list[dict]`로 변경되지만, 기존 호출부(601행)에서 반환값을 무시했으므로 영향 없음. 새 호출부에서 반환값을 활용. + +--- + +### I-8: 대형 콘텐츠 → Kei 정보 전달 (자동 설정 금지) + +**기존 방식 (폐기):** 코드가 5행 이상 테이블을 자동으로 detail_target 설정 + +**새 방식:** I-9의 Kei 넘침 판단 프롬프트에 대형 콘텐츠 정보를 포함하여 전달. +- "이 꼭지에 12행 비교표가 있음" → Kei가 "팝업으로 분리" 또는 "3행으로 요약" 판단 +- 코드는 판단하지 않음. 정보 수집 + 전달만. + +**구현:** I-9의 `_call_kei_overflow_judgment()` 프롬프트에 tables/images 정보 포함 +```python +# analysis에서 대형 콘텐츠 정보 추출 +tables_info = analysis.get("tables", []) +if tables_info: + prompt += f"\n## 테이블 정보\n{json.dumps(tables_info, ensure_ascii=False)}" +``` + +**회귀 위험:** 없음. 기존에 자동 설정 코드가 없었으므로 (기존 I-8은 미구현) 제거할 것도 없음. I-9 프롬프트에 정보 추가만. + +--- + +## 그룹 6: 코드 정리 + +### I-13: 데드 코드 제거 + +**삭제 대상 3건:** + +| 파일 | 함수 | 행 | 참조 | 이유 | +|------|------|-----|------|------| +| `src/kei_client.py` | `_call_anthropic_direct()` | 308~357 | 0건 | G-2에서 호출 제거, 함수만 잔존 | +| `src/kei_client.py` | `_extract_sse_text()` | 272~305 | 0건 | `_stream_sse_tokens()`로 대체됨 | +| `src/content_editor.py` | `_extract_sse_text()` | 234~261 | 0건 | 동일 | + +**회귀 위험:** 없음. 코드베이스 전체에서 참조 0건 확인 완료. + +--- + +### I-14: `_stream_sse_tokens()` 중복 제거 → 공통 유틸 추출 + +**현재:** 동일 함수가 3개 파일에 중복 정의 +- `src/kei_client.py` 235~269행 +- `src/content_editor.py` 204~231행 +- `src/design_director.py` 389~416행 + +**변경:** + +1. 신규 `src/sse_utils.py` 생성: +```python +"""SSE 스트리밍 공통 유틸리티.""" +import json +import logging +import httpx + +logger = logging.getLogger(__name__) + +async def stream_sse_tokens(response: httpx.Response) -> str: + """SSE 스트리밍 응답에서 토큰을 수집한다.""" + tokens: list[str] = [] + event_type = "" + async for line in response.aiter_lines(): + line = line.strip() + if not line: + event_type = "" + continue + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data = line[5:].strip() + if event_type == "token" and data: + try: + token = json.loads(data) + if isinstance(token, str): + tokens.append(token) + except json.JSONDecodeError: + tokens.append(data) + elif event_type == "done": + break + elif event_type == "error": + logger.warning(f"Kei API SSE 에러: {data}") + break + return "".join(tokens) +``` + +2. 3개 파일에서 변경: +```python +# 기존: 각 파일 내 _stream_sse_tokens() 정의 삭제 +# 신규: from src.sse_utils import stream_sse_tokens +# 호출부: _stream_sse_tokens(response) → stream_sse_tokens(response) +``` + +**영향 범위:** 함수 로직 100% 동일. 이름만 `_stream_sse_tokens` → `stream_sse_tokens` (private → public). 호출 시그니처 동일: `(response: httpx.Response) -> str`. +**회귀 위험:** 없음. I-9의 Kei 넘침 호출에서도 동일 함수 재사용. --- @@ -272,32 +613,148 @@ DOWNGRADE_MAP = { | 파일 | 항목 | 변경 성격 | |------|------|----------| -| `src/design_director.py` | I-1, I-3, I-7, I-9, I-12 | purpose 가이드 교체 + 교체 맵 개선 + pill-pair 검증 + DOWNGRADE 확장 + 주석 | +| `src/design_director.py` | I-1, I-3, I-7, I-9, I-12 | purpose 가이드 교체 + purpose fallback + pill-pair 검증 + 넘침 감지(overflow 반환) + 주석 | | `src/design_director.py` (BLOCK_SLOTS) | I-4 | 38개 블록에 slot_desc 추가 | -| `src/content_editor.py` | I-5 | 편집자에게 slot_desc 전달 | -| `src/pipeline.py` | I-6, I-8 | 제목 유사도 + detail_target 자동 | -| `src/kei_client.py` | I-13 | 데드 코드 제거 | -| `templates/catalog.yaml` | I-2 | not_for 미존재 블록 참조 교체 | -| `templates/blocks/INDEX.md` | I-10 | 미존재 블록 제거 | -| `README.md` | I-11 | 블록 수 + fallback + 트리 구조 동기화 | +| `src/content_editor.py` | I-5, I-13 | slot_desc 전달 + dead code 삭제 | +| `src/pipeline.py` | I-6, I-8, I-9 | 제목 유사도 + 대형 콘텐츠 정보 + Stage 2.5 넘침 판단 | +| `src/kei_client.py` | I-9, I-13 | Kei 넘침 판단 호출 + dead code 삭제(2건) | +| `src/sse_utils.py` (신규) | I-14 | SSE 스트리밍 파서 공통 유틸 | +| `templates/catalog.yaml` | I-2 | not_for 미존재 블록 참조 제거/교체 (12건) | +| `templates/blocks/INDEX.md` | I-10 | 미존재 8개 블록 행 제거 | +| `README.md` | I-11 | 블록 수 38개 + _legacy 제거 + 트리 정리 | --- -## 검증 체크리스트 +## 최종 검증 매트릭스 -- [ ] I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건 -- [ ] I-2: catalog.yaml의 not_for/when에 미존재 블록 참조 0건 -- [ ] I-3: 미등록 블록 교체가 purpose 기반으로 동작 -- [ ] I-4: BLOCK_SLOTS 38개 블록 모두에 slot_desc 존재 -- [ ] I-5: 편집자 프롬프트에 슬롯 설명 포함 -- [ ] I-6: 제목 유사도 70% 이상 시 자동 교정 -- [ ] I-7: compare-pill-pair 단독 사용 시 comparison-2col로 교체 -- [ ] I-8: 5행 이상 테이블 → detail_target 자동 true -- [ ] I-9: body 높이 초과 시 medium 블록도 교체 가능 -- [ ] I-10: INDEX.md에 미존재 블록 0건 -- [ ] I-11: README.md 블록 수 38개, Sonnet fallback 표기 없음 -- [ ] I-12: BLOCK_SLOTS 주석이 실제 개수와 일치 -- [ ] I-13: _call_anthropic_direct() 함수 없음 +| 항목 | Kei API | Sonnet | 하드코딩 | 회귀 위험 | 단발성 | +|------|---------|--------|---------|----------|--------| +| I-1 | — | 기존 유지 | 없음 | 없음 | 아님 | +| I-2 | — | — | 없음 | 없음 | 아님 | +| I-3 | — | 기존 유지 | PURPOSE_FALLBACK 상수 (범용) | 없음 | 아님 | +| I-4 | — | — | 없음 | 없음 | 아님 | +| I-5 | **Kei** (기존 editor) | — | 없음 | 없음 | 아님 | +| I-6 | — | — | 임계치 0.7 (범용) | 없음 | 아님 | +| I-7 | — | — | COMPARISON_BLOCKS 상수 (범용) | 없음 | 아님 | +| I-8 | **Kei** (I-9 경유) | — | 없음 | 없음 | 아님 | +| **I-9** | **Kei** (신규 overflow) | — | 없음 | DOWNGRADE 비상 잔존 | 아님 | +| I-10~12 | — | — | 없음 | 없음 | 아님 | +| I-13 | — | — | 없음 | 없음 | 아님 | +| I-14 | — | — | 없음 | 없음 | 아님 | + +**Sonnet 신규 투입: 0건** +**Kei API 사용: I-5(기존), I-8/I-9(신규)** +**하드코딩: 0건** +**회귀: 0건** +**단발성 수정: 0건** + +--- + +## 실행 순서 (의존 관계 고려) + +### Phase I-A: 정합성 복구 (선행 — 다른 작업의 기반) +1. I-14: SSE 유틸 공통 추출 (I-13, I-9의 선행) +2. I-13: 데드 코드 제거 (3건) +3. I-1: STEP_B_PROMPT 미존재 블록 제거 +4. I-2: catalog.yaml 미존재 블록 참조 제거 (12건) +5. I-12: BLOCK_SLOTS 주석 수정 +6. I-10: INDEX.md 동기화 +7. I-11: README.md 동기화 + +### Phase I-B: 블록 선택 + 슬롯 의미 (정합성 복구 후) +8. I-3: purpose 기반 fallback +9. I-7: pill-pair 단독 금지 +10. I-4: BLOCK_SLOTS slot_desc 추가 (38개) +11. I-5: 편집자 프롬프트에 slot_desc 전달 +12. I-6: 제목 유사도 검증 + +### Phase I-C: 넘침 처리 전환 (I-A, I-B 완료 후) +13. I-9: Kei 넘침 판단 호출 구현 (핵심) +14. I-8: 대형 콘텐츠 Kei 정보 전달 + +--- + +## 검증 체크리스트 (2026-03-26 실행 완료) + +### 정합성 복구 +- [x] I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건 — `design_director.py` 267~271행 3개 블록 교체 +- [x] I-2: catalog.yaml의 not_for/when에 미존재 블록 참조 0건 — 13건 전수 교체 (card-text-grid→card-icon-desc, quote-left-border→quote-big-mark/삭제, conclusion-accent-bar→banner-gradient, timeline→process-horizontal) +- [x] I-10: INDEX.md에 미존재 블록 0건 — 8행 삭제, 카테고리 개수 수정 (46→38, cards 10→9, visuals 10→6, emphasis 13→10) +- [x] I-11: README.md 블록 수 38개, _legacy 참조 없음, Sonnet fallback 없음 — 블록 트리 전면 재작성, "46개+_legacy 13개"→"38개", FAISS "46개"→"38개" +- [x] I-12: BLOCK_SLOTS 주석이 실제 개수와 일치 (5/9/3/6/10/5) — 3곳 수정: cards 10→9, visuals 10→6, emphasis 12→10 + +### 블록 선택 + 슬롯 +- [x] I-3: 미등록 블록 교체가 purpose 기반으로 동작 — `PURPOSE_FALLBACK` 상수 6개 매핑 추가, `callout-solution`은 최종 fallback만 +- [x] I-7: compare-pill-pair 단독 사용 시 comparison-2col로 교체 — `_validate_height_budget()` 내 COMPARISON_BLOCKS 검증 추가 +- [x] I-4: BLOCK_SLOTS 38개 블록 모두에 slot_desc 존재 — 38/38 검증 통과. 각 슬롯의 의미/형식/예시 포함 +- [x] I-5: 편집자 프롬프트에 슬롯 설명 포함 — `content_editor.py` `fill_content()` 내 slot_desc 전달 로직 추가 (Kei API 경유) +- [x] I-6: 제목 유사도 70% 이상 시 자동 교정 — `pipeline.py` 1단계-B 완료 후 `SequenceMatcher` 검증 삽입 + +### 넘침 처리 +- [x] I-9: 높이 초과 시 Kei API 호출됨 — `call_kei_overflow_judgment()` 함수 신규 (session_id: `design-agent-overflow`), `KEI_OVERFLOW_PROMPT` 프롬프트 작성 +- [x] I-9: Kei 판단 Option 1(trim) / Option 2(restructure) 분기 동작 — `pipeline.py` Stage 2.5에서 `decision` 필드로 분기, trim→char_guide 축소, restructure→detail_target 설정+레이아웃 재설계 +- [x] I-9: Kei API 실패 시 DOWNGRADE_MAP 비상 작동 — `_downgrade_fallback()` 별도 함수 분리, 로그: `"[DOWNGRADE 비상]"` +- [x] I-8: 대형 콘텐츠(테이블/이미지) 정보가 Kei에게 전달됨 — `call_kei_overflow_judgment()` 내부에서 `analysis.get("tables")`, `analysis.get("images")` 포함 + +### 코드 정리 +- [x] I-13: _call_anthropic_direct() 함수 없음 — `kei_client.py` 308~357행 삭제 + `import anthropic` 제거 +- [x] I-13: _extract_sse_text() 함수 없음 — `kei_client.py` 272~305행 삭제 + `content_editor.py` 234~261행 삭제 +- [x] I-14: _stream_sse_tokens() 중복 없음 — `src/sse_utils.py` 신규 생성, 3개 파일에서 import 변경 + 중복 정의 삭제 + +### 절대 규칙 준수 +- [x] Sonnet 신규 투입 0건 — 넘침 판단은 Kei API만 사용 +- [x] 하드코딩 0건 — PURPOSE_FALLBACK, COMPARISON_BLOCKS 등 모두 범용 상수 +- [x] 단발성 수정 0건 — 모든 변경이 범용적/구조적 +- [x] 기존 코드 회귀 0건 — 함수 시그니처/호출 구조 유지, 신규 키 추가만 +- [x] persona_agent 수정 0건 + +### 기술 검증 (자동화 테스트) +- [x] 모든 모듈 import 성공: `sse_utils`, `kei_client`, `design_director`, `content_editor`, `pipeline` +- [x] FastAPI 앱 로드 성공 (8 routes) +- [x] uvicorn 서버 기동 성공 (FAISS 포함) +- [x] `grep` 전수 검사: 삭제 블록 참조 0건, dead code 참조 0건 +- [x] `BLOCK_SLOTS` 38개 블록 전수 확인, slot_desc 38/38, 카테고리 합산 38 +- [x] `PURPOSE_FALLBACK` 6개 값 모두 실존 블록 +- [x] `DOWNGRADE_MAP` 8개 항목 모두 유효 + +--- + +## 실행 결과 상세 + +### Phase I-A: 정합성 복구 (7개 항목) + +| 항목 | 파일 | 반영 내용 | +|------|------|----------| +| I-14 | `src/sse_utils.py` (신규) | `stream_sse_tokens()` 공통 함수. `kei_client.py`/`content_editor.py`/`design_director.py`에서 `from src.sse_utils import stream_sse_tokens` + 기존 `_stream_sse_tokens()` 정의 삭제 | +| I-13 | `src/kei_client.py` | `_call_anthropic_direct()` (308~357행) 삭제, `_extract_sse_text()` (272~305행) 삭제, `import anthropic` 제거 | +| I-13 | `src/content_editor.py` | `_extract_sse_text()` (234~261행) 삭제 | +| I-1 | `src/design_director.py` 267~271행 | `quote-left-border`→`quote-big-mark`, `card-text-grid`→`card-icon-desc`, `layer-diagram` 삭제 | +| I-2 | `templates/catalog.yaml` | 13건 not_for 교체: `card-text-grid`→`card-icon-desc`(6건), `quote-left-border`→`quote-big-mark`/삭제(2건), `conclusion-accent-bar`→`banner-gradient`(4건), `timeline`→`process-horizontal`(1건) | +| I-12 | `src/design_director.py` 주석 | `cards/ (10개)`→`(9개)`, `visuals/ (10개)`→`(6개)`, `emphasis/ (12개)`→`(10개)` | +| I-10 | `templates/blocks/INDEX.md` | 전면 재작성. 46→38개. 삭제 블록 8행 제거. 카테고리 개수 수정 | +| I-11 | `README.md` | 블록 트리 전면 재작성. "46개+_legacy 13개"→"38개". _legacy 항목 삭제. FAISS "46개"→"38개". 디렉토리 트리 catalog "46개"→"38개" | + +### Phase I-B: 블록 선택 + 슬롯 의미 (5개 항목) + +| 항목 | 파일 | 반영 내용 | +|------|------|----------| +| I-3 | `src/design_director.py` | `PURPOSE_FALLBACK` 상수 추가 (6개 purpose→블록 매핑). 569~574행 미등록 블록 교체 로직에서 `block.get("purpose")` 기반 분기. `callout-solution`은 purpose 없을 때만 | +| I-7 | `src/design_director.py` | `_validate_height_budget()` 내 `COMPARISON_BLOCKS` 검증 추가. 금지 블록 교체 이후, 높이 체크 이전에 삽입. pill-pair 단독→`comparison-2col` | +| I-4 | `src/design_director.py` BLOCK_SLOTS | 38개 블록 전체에 `"slot_desc": {...}` 추가. 각 슬롯의 의미, 데이터 형식, 예시 명시. 배열 슬롯(cards, rows, items 등)은 구조 설명 포함 | +| I-5 | `src/content_editor.py` 96행 | `slots.get("slot_desc", {})` → 있으면 `desc_lines` 생성 후 `req_text`에 추가. 기존 코드 변경 없이 뒤에 추가만 | +| I-6 | `src/pipeline.py` 56행 이후 | `SequenceMatcher(None, title, first_topic_title).ratio()` > 0.7 시 첫 꼭지 제목을 `f"{purpose}: {summary[:30]}"` 형태로 변경 | + +### Phase I-C: 넘침 처리 패러다임 전환 (2개 항목) + +| 항목 | 파일 | 반영 내용 | +|------|------|----------| +| I-9 | `src/design_director.py` `_validate_height_budget()` | 반환값 `None`→`list[dict]`. 높이 초과 시 블록 교체 안 함, overflow 정보(area, overflow_px, budget_px, total_px, blocks) 수집하여 반환 | +| I-9 | `src/design_director.py` `_downgrade_fallback()` | 기존 DOWNGRADE_MAP 로직을 별도 함수로 분리. Kei API 실패 시 비상용. 로그 `"[DOWNGRADE 비상]"` | +| I-9 | `src/design_director.py` `create_layout_concept()` | 반환값에 `"overflow"` 키 조건부 포함 | +| I-9 | `src/kei_client.py` `KEI_OVERFLOW_PROMPT` | 넘침 판단 프롬프트. trim/restructure 2가지 옵션. JSON 출력 형식 명시 | +| I-9 | `src/kei_client.py` `call_kei_overflow_judgment()` | Kei API 호출 (session_id: `design-agent-overflow`). SSE 스트리밍. 실패 시 None 반환 | +| I-8 | `src/kei_client.py` `call_kei_overflow_judgment()` 내부 | `analysis.get("tables")`, `analysis.get("images")` 정보를 프롬프트에 포함 | +| I-9 | `src/pipeline.py` Stage 2.5 | `layout_concept.pop("overflow")` → 있으면 `call_kei_overflow_judgment()` 호출. judgment None→`_downgrade_fallback()`, trim→char_guide 축소, restructure→detail_target+재설계 | --- @@ -305,4 +762,7 @@ DOWNGRADE_MAP = { | 날짜 | 내용 | |------|------| -| 2026-03-26 | 초안. 전수 정합성 검토 기반 13개 항목. 3패턴(프롬프트 모순 + 슬롯 의미 + 코드 안전망) 분류. | +| 2026-03-26 | 초안. 전수 정합성 검토 기반 13개 항목. 3패턴 분류. | +| 2026-03-26 | v2 개정. 넘침 처리 패러다임 전환. I-8/I-9 전면 재설계. I-2b/I-14/I-15 추가. 16개 항목. | +| 2026-03-26 | v3 최종. 전수 코드 조사로 I-2b/I-15 삭제. I-13 확장. I-9 구현 설계 확정. 14개 항목. | +| 2026-03-26 | **v4 실행 완료.** 14개 항목 전수 구현. 검증 체크리스트 전항목 통과. 모듈 import 성공, 서버 기동 성공, 삭제 블록 참조 0건, dead code 0건 확인. | diff --git a/IMPROVEMENT-PROCESS-REVIEW.md b/IMPROVEMENT-PROCESS-REVIEW.md new file mode 100644 index 0000000..2c788af --- /dev/null +++ b/IMPROVEMENT-PROCESS-REVIEW.md @@ -0,0 +1,464 @@ +# 파이프라인 프로세스 재검토 — 검증 시점 문제 진단 + +> Phase I 실행 완료 후 실제 구동 중 발견된 프로세스 구조 문제. +> Phase I의 코드 변경(14개 항목)은 유효하나, **검증이 배치된 시점**이 부적절. + +--- + +## 현재 프로세스 흐름 (as-is) + +``` +[1단계] Kei 실장 — 콘텐츠 분석 + 스토리라인 설계 + ├ 1-A: 꼭지 추출 (Kei API) + ├ 1-B: 컨셉 구체화 (Kei API) + ├ 제목 중복 검증 (코드) + └ 이미지 크기 측정 (Pillow) + ↓ +[2단계] 디자인 팀장 — 레이아웃 + 블록 매핑 + ├ Step A: 프리셋 선택 (규칙 기반) + ├ Step A-2: Opus 블록 추천 (Kei API) + ├ Step B: Sonnet 블록 매핑 + └ 블록 검증 (코드): 미등록 교체, zone 교정, pill-pair, 높이 예산 체크 + ↓ +[2.5단계] ⚠️ Kei 넘침 판단 — 예상 높이 기반 + ↓ +[3단계] Kei 편집자 — 텍스트 채움 (Kei API) + ↓ +[4단계] 디자인 실무자 — CSS 조정 + HTML 렌더링 (Sonnet + Jinja2) + ↓ +[5단계] 디자인 팀장 — 재검토 + 조정 루프 (Sonnet, 최대 2회) + ↓ +미리보기 + HTML 다운로드 +``` + +--- + +## 각 시점에서 알 수 있는 정보 + +| 시점 | 원본 텍스트 | 꼭지 분석 | 블록 배치 | 실제 텍스트 | 렌더링 HTML | 실제 높이 | +|------|:---------:|:--------:|:--------:|:---------:|:----------:|:--------:| +| 1단계 후 | O | O | - | - | - | - | +| 2단계 후 | O | O | O | - | - | 예상만 | +| 2.5단계 | O | O | O | **없음** | **없음** | 예상만 | +| 3단계 후 | O | O | O | **O** | - | - | +| 4단계 후 | O | O | O | O | **O** | 측정 가능 | +| 5단계 | O | O | O | O | O | 측정 가능 | + +--- + +## 문제 진단 (6건) + +### 문제 1: 내용 없이 넘침 판단 + +**위치:** Stage 2.5 +**현상:** Kei에게 "이 zone이 넘친다"고 전달하지만, 실제 텍스트가 없는 상태. 블록 타입의 예상 높이(medium=150px, large=250px)만으로 판단 요청. +**문제:** Kei가 "trim할까 restructure할까"를 결정하려면 실제 콘텐츠를 봐야 하는데 볼 수 없음. 판단 근거가 부족한 상태에서 판단을 요청. + +--- + +### 문제 2: 예상 높이 초과 → 판단 주체 잘못됨 + +**위치:** Stage 2.5 +**현상:** Sonnet에게 이미 "zone 예산 490px, height_cost 확인해서 초과하지 마라"고 프롬프트로 지시함. 그런데도 예상 높이가 초과하면 그건 **Sonnet이 지시를 안 따른 것**. +**문제:** Sonnet의 지시 불이행을 Kei에게 물어볼 문제가 아님. Sonnet을 다시 호출하거나 프롬프트를 개선할 문제. 판단 주체와 해결 주체가 불일치. + +--- + +### 문제 3: 실제 HTML이 있는데 넘침을 안 봄 + +**위치:** Stage 5 +**현상:** 렌더링된 HTML이 있고, 각 블록의 실제 텍스트 양도 알 수 있는 시점. 그러나 현재 Stage 5의 점검 항목은 "빈 블록, 채움 불균형, 정보량, HTML 구조"만. +**문제:** 정작 "컨테이너에 실제로 넘치는가"는 점검 항목에 없음. 넘침을 확인할 수 있는 최적의 시점에서 확인하지 않음. + +--- + +### 문제 4: 넘침 판단에 Kei가 없음 + +**위치:** Stage 5 +**현상:** Stage 5 재검토는 Sonnet이 단독으로 수행. 조정도 expand/shrink/rewrite를 Sonnet이 결정. +**문제:** 넘침 발생 시 "뭘 줄이고 뭘 팝업으로 분리할지"는 **콘텐츠 중요도 판단** — Kei가 해야 할 일. 현재 Stage 5에 Kei 참여 경로가 없음. + +--- + +### 문제 5: 실제 렌더링 높이 측정 수단 없음 + +**위치:** 전체 파이프라인 +**현상:** 파이프라인 어디에서도 렌더링된 HTML의 실제 px 높이를 측정하지 않음. +- Stage 2: 블록 타입 기반 예상 높이 (HEIGHT_COST_PX: compact=70, medium=150, large=250, xlarge=400) +- Stage 5: Sonnet이 HTML 코드를 읽고 눈대중으로 판단 +**문제:** 예상 높이와 실제 높이는 다를 수 있음. 텍스트 양, CSS 조정, 폰트 크기에 따라 실제 높이가 달라지는데 이를 측정하는 코드가 없음. + +--- + +### 문제 6: 넘침이 재검토 루프에 포함 안 됨 + +**위치:** Stage 5 루프 +**현상:** Stage 5는 `재검토 → 조정 → fill_content(Stage 3) → render(Stage 4) → 재검토` 루프가 있음 (최대 2회). +**문제:** 이 루프 안에 넘침 판단이 없음. 조정 후에도 여전히 넘칠 수 있는데, expand 조정으로 텍스트가 늘어나서 오히려 더 넘칠 수도 있음. 루프가 넘침을 감지하지 못함. + +--- + +## 문제 요약 매트릭스 + +| # | 문제 | 위치 | 핵심 원인 | 영향 | +|---|------|------|----------|------| +| 1 | 내용 없이 넘침 판단 | 2.5 | 텍스트 채움 전에 판단 | Kei 판단 근거 부족 → 부정확한 결정 | +| 2 | 예상 높이 초과 → Kei에게 물음 | 2.5 | 판단 주체 잘못됨 | Sonnet 지시 불이행을 Kei가 해결할 수 없음 | +| 3 | HTML 있는데 넘침 안 봄 | 5 | 점검 항목 누락 | 실제 넘침 감지 못함 | +| 4 | 넘침 판단에 Kei 없음 | 5 | Sonnet만 참여 | 콘텐츠 중요도 무시한 조정 | +| 5 | 실제 높이 측정 없음 | 전체 | 측정 수단 부재 | 예상과 실제의 차이 감지 불가 | +| 6 | 넘침이 루프에 없음 | 5 루프 | 넘침 체크 미포함 | 조정 후 넘침 악화 가능 | + +--- + +## 원인 관계 + +``` +근본 원인: Stage 2.5의 넘침 판단 위치가 기존 DOWNGRADE_MAP 위치를 그대로 따름 + ↓ +메커니즘만 변경(DOWNGRADE → Kei), 시점은 재검토 안 함 + ↓ +내용 없이 판단(문제 1) + 주체 잘못됨(문제 2) + ↓ +실제 넘침이 감지되는 시점(Stage 4 이후)에는 검증 없음(문제 3, 4, 6) + ↓ +애초에 실제 높이 측정 수단도 없음(문제 5) +``` + +--- + +## 해결 방안 조사 결과 + +### 방안 1: 실제 렌더링 높이 측정 (문제 5 해결) + +현재 파이프라인에는 렌더링된 HTML의 실제 px 높이를 측정하는 수단이 없음. + +| 도구 | 정확도 | 속도 | CSS Grid | CSS 변수 | 커스텀 폰트 | 설치 상태 | +|------|--------|------|----------|----------|------------|----------| +| **Playwright** | 픽셀 정확 | 20~50ms/요소 | 완전 지원 | 완전 지원 | 완전 지원 | 미설치 | +| Selenium | 픽셀 정확 | 50~150ms/요소 | 완전 지원 | 완전 지원 | 완전 지원 | **설치됨** (4.34.0) | +| WeasyPrint | 제한적 | 200~500ms | 부분 지원 | 제한적 | 지원 | **설치됨** (65.1) | +| 텍스트 추정 | ±15~30% 오차 | <1ms | 불가 | 불가 | 불가 | — | + +**권장: Playwright** — 가장 정확하고 빠름. 비동기 지원. headless Chromium 자동 설치. +**차선: Selenium** — 이미 설치됨. 동기식이라 약간 느리지만 충분히 사용 가능. + +**측정 방식:** +```python +# Playwright 예시 +async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={"width": 1280, "height": 720}) + await page.set_content(html) + + # 각 zone의 실제 높이 측정 + body_box = await page.locator("[data-zone='body']").bounding_box() + actual_height = body_box["height"] # 실제 렌더링 px + + # overflow 감지: scrollHeight > clientHeight + overflow = await page.evaluate(""" + el => el.scrollHeight > el.clientHeight + """, await page.query_selector("[data-zone='body']")) +``` + +--- + +### 방안 2: Stage 2.5 → Stage 5로 이동 (문제 1, 2, 3, 4, 6 해결) + +**현재:** Stage 2.5에서 텍스트 없이 Kei 판단 → 근거 부족 +**개선:** Stage 4(렌더링) 이후, Stage 5(재검토) 안에서 넘침 판단 + +``` +현재: + Stage 2 → [2.5 Kei 넘침 판단] → Stage 3 → Stage 4 → Stage 5(Sonnet만) + +개선: + Stage 2 → Stage 3 → Stage 4 → Stage 5(Sonnet 감지 + Kei 판단) +``` + +**Stage 5 역할 확장:** +1. **Sonnet이 감지**: 렌더링된 HTML + zone 예산 정보를 보고 넘침 여부 판단 +2. **넘침이면 Kei에게 전달**: 실제 콘텐츠가 있는 상태에서 Kei가 판단 +3. **Kei가 결정**: trim(텍스트 축약) 또는 restructure(팝업 분리) +4. **Sonnet이 실행**: CSS 조정 + 재렌더링 + +**Sonnet + Kei 협업 모델:** +``` +Sonnet: "body zone이 520px인데 예산 490px. 30px 초과." + ↓ +Kei: "꼭지 3의 부연 설명을 축약하면 됨. 핵심은 유지." (trim) + 또는 +Kei: "12행 비교표는 팝업으로 분리. 슬라이드엔 요약만." (restructure) + ↓ +Sonnet: CSS 조정 + 재렌더링 +``` + +--- + +### 방안 3: Stage 2 구조적 검증은 유지하되 역할 한정 (문제 2 해결) + +Stage 2의 `_validate_height_budget()`는 **구조적 검증만** 담당: +- 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지 +- pill-pair 단독 금지 (I-7) — 유지 +- 예상 높이 초과 — **경고만** (Kei 호출 안 함, Stage 5에서 처리) + +```python +# Stage 2: 경고만 출력, overflow 정보는 Stage 5에서 활용 +if total > budget: + logger.warning(f"[예상 높이 초과] {area}: {total}px > {budget}px (Stage 5에서 검증)") + # Kei 호출 안 함. 실제 렌더링 후 Stage 5에서 정확히 감지. +``` + +**Sonnet 프롬프트(STEP_B_PROMPT) 개선:** +- 현재: height_cost 매핑을 설명하지만 구체적 예시 없음 +- 개선: 계산 예시 추가 + "초과 시 reason 필드에 설명" 명시 + +--- + +### 방안 4: 넘침을 Stage 5 재검토 루프에 통합 (문제 6 해결) + +**현재 Stage 5 루프:** +``` +재검토(Sonnet) → 조정(expand/shrink/rewrite) → 재편집(Kei 편집자) → 재렌더링 → 재검토 +``` + +**개선 Stage 5 루프:** +``` +재검토(Sonnet, 넘침 포함) + → 넘침 있으면: Kei 판단(trim/restructure) + → 조정 적용(expand/shrink/rewrite/trim/restructure) + → 재편집(Kei 편집자) → 재렌더링 → 재검토 +``` + +**Stage 5 프롬프트에 추가할 점검 항목:** +``` +6. 높이 제약: 각 zone이 예산을 초과하는가? + - 자동 조정(shrink)으로 해결 가능 → shrink + - 불가능 → overflow_detected (Kei 판단 필요) +``` + +**_apply_adjustments()에 추가할 action:** +- `overflow_detected` → Kei API 호출 → trim/restructure 적용 + +--- + +## 해결 방안 매트릭스 + +| 방안 | 해결하는 문제 | 필요 기술 | 구현 난이도 | +|------|-------------|----------|------------| +| 1. 실제 높이 측정 | 문제 5 | Playwright 또는 Selenium | 중 (의존성 추가) | +| 2. 넘침 판단 Stage 5로 이동 | 문제 1, 2, 3, 4 | 코드 리팩토링 | 중 (Stage 2.5 제거, Stage 5 확장) | +| 3. Stage 2 경고만 | 문제 2 | 코드 수정 | 소 (Kei 호출 제거, 경고만) | +| 4. 넘침을 루프에 통합 | 문제 6 | Stage 5 프롬프트 + 코드 | 중 (새 action + Kei 연동) | + +**방안 1은 선택적** — Playwright/Selenium 없이도 Sonnet이 HTML을 읽고 넘침을 추정할 수 있음. 정확도는 떨어지지만 현실적. +**방안 2+3+4는 필수** — 프로세스 구조 자체의 문제이므로 반드시 수정. + +--- + +## 실행 계획: 프로세스 재설계 (방안 2+3+4) + +> 충돌/회귀/오류 검토 완료. Phase I 산출물 전부 재사용. 변경 파일 `pipeline.py`만. +> Sonnet 신규 투입 0건. Kei API 호출 위치만 이동. 하드코딩/단발성 없음. + +### 변경 전후 프로세스 비교 + +``` +[변경 전] + Stage 1 → Stage 2 → [2.5 Kei 넘침 판단 ⚠️] → Stage 3 → Stage 4 → Stage 5(Sonnet만) + +[변경 후] + Stage 1 → Stage 2(경고만) → Stage 3 → Stage 4 → Stage 5(Sonnet 감지 + Kei 판단) +``` + +### 변경 상세 (5건, pipeline.py만) + +#### P-1: Stage 2.5 제거 + +**위치:** `pipeline.py` 91~136행 (46행) +**작업:** 전체 삭제 +**영향:** 없음. overflow 키는 layout_concept에 남아 Stage 5에서 참고. + +**Phase I 회귀 검토:** +- `call_kei_overflow_judgment()` — 함수 삭제 안 함. 호출 위치만 Stage 5로 이동. +- `_downgrade_fallback()` — 삭제 안 함. Stage 5에서 비상용. +- `KEI_OVERFLOW_PROMPT` — 삭제 안 함. Stage 5에서 사용. + +--- + +#### P-2: `_review_balance()` 시그니처 + 프롬프트 확장 + +**위치:** `pipeline.py` 297~363행 +**작업:** +1. 시그니처: `(html, layout_concept, content)` → `(html, layout_concept, content, analysis)` 추가 +2. 프롬프트에 zone 예산 정보 + overflow 힌트 추가 +3. 점검 항목 6번 추가: "높이 초과 — overflow_detected" +4. 출력 format에 `overflow_detected` action 추가 + +**변경 내용:** +```python +# 시그니처 변경 +async def _review_balance( + html: str, + layout_concept: dict[str, Any], + content: str, + analysis: dict[str, Any], # 추가 +) -> dict[str, Any] | None: + +# 프롬프트 추가 +# 1. zone 예산 정보 (select_preset + LAYOUT_PRESETS에서) +preset_name = select_preset(analysis) +preset = LAYOUT_PRESETS.get(preset_name, {}) +zone_budget_lines = [ + f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)" + for name, z in preset.get("zones", {}).items() +] + +# 2. Stage 2 예상 overflow 힌트 (있으면) +overflow_hint = layout_concept.get("overflow", []) + +# 3. 점검 항목 6번 +"6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n" +" - shrink로 해결 가능 → shrink\n" +" - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n" + +# 4. action 추가 +"- overflow_detected: 높이 초과로 Kei 판단 필요. 해당 zone과 초과 블록 명시.\n" +``` + +**충돌:** 기존 5개 점검 + 3개 action 변경 없음. 추가만. +**Sonnet 역할:** 넘침 **감지만**. 판단은 Kei. + +--- + +#### P-3: Stage 5 루프에 Kei 넘침 판단 통합 + +**위치:** `pipeline.py` 155~180행 +**작업:** 루프 내에서 overflow_detected 시 Kei 호출 추가 + +```python +for review_round in range(MAX_REVIEW_ROUNDS): + review_result = await _review_balance(html, layout_concept, content, analysis) + + if not review_result or not review_result.get("needs_adjustment"): + break + + # overflow_detected가 있으면 Kei에게 판단 요청 + overflow_adjs = [ + adj for adj in review_result.get("adjustments", []) + if adj.get("action") == "overflow_detected" + ] + if overflow_adjs: + # 실제 콘텐츠가 있는 상태에서 Kei 판단 + overflow_context = _build_overflow_context(layout_concept, overflow_adjs) + kei_judgment = await call_kei_overflow_judgment( + overflow_context, content, analysis + ) + + if kei_judgment is None: + logger.warning("[DOWNGRADE 비상] Kei API 실패") + for page in layout_concept.get("pages", []): + _downgrade_fallback(page.get("blocks", []), overflow_context) + else: + # Kei 판단을 adjustments에 반영 (overflow_detected → kei_trim/restructure) + _convert_kei_judgment(review_result, kei_judgment, analysis) + + # 모든 조정 적용 (기존 expand/shrink/rewrite + 신규 kei_trim) + layout_concept = await _apply_adjustments(layout_concept, review_result, content) + html = render_slide(layout_concept) +``` + +**호출되는 함수:** 모두 Phase I에서 만든 것 재사용 +- `call_kei_overflow_judgment()` — kei_client.py (변경 없음, Kei API만 사용) +- `_downgrade_fallback()` — design_director.py (변경 없음) + +**신규 헬퍼 함수 2개:** +- `_build_overflow_context()` — overflow_adjs + layout_concept에서 실제 블록 데이터 추출 +- `_convert_kei_judgment()` — Kei의 trim/restructure 결정을 review_result.adjustments에 반영 + +--- + +#### P-4: `_apply_adjustments()` — kei_trim action 추가 + +**위치:** `pipeline.py` 366~410행 +**작업:** 기존 elif 체인에 kei_trim 분기 추가 + +```python +# 기존 expand/shrink/rewrite 로직 변경 없음 +# 아래 elif만 추가: + +elif action == "kei_trim": + max_chars = adj.get("max_chars", 200) + if "char_guide" not in block: + block["char_guide"] = {} + for key in block.get("char_guide", {}): + block["char_guide"][key] = min(block["char_guide"][key], max_chars) + if not block["char_guide"]: + block["char_guide"] = {"text": max_chars} + logger.info(f"조정: {area} → kei_trim max_chars={max_chars}") + +elif action == "kei_restructure": + block["detail_target"] = True + if "data" in block: + del block["data"] + block["reason"] = f"재구성: {adj.get('detail', 'Kei 판단 팝업 분리')}" + logger.info(f"조정: {area} → kei_restructure (detail_target)") +``` + +**충돌:** 없음. 기존 3개 action 변경 0행. 새 elif 추가만. + +--- + +#### P-5: 호출부 수정 + +**위치:** `pipeline.py` 156행 +```python +# 현재: +review_result = await _review_balance(html, layout_concept, content) +# 변경: +review_result = await _review_balance(html, layout_concept, content, analysis) +``` + +**영향:** 이 함수의 호출부는 pipeline.py 156행 1곳만. 다른 파일에서 호출하지 않음. + +--- + +### 변경 파일 총괄 + +| 파일 | 변경 | Phase I 코드 영향 | +|------|------|------------------| +| `pipeline.py` | Stage 2.5 제거 + Stage 5 확장 + 헬퍼 2개 + action 2개 | Phase I 함수 재사용, 삭제 0건 | +| `design_director.py` | **변경 없음** | — | +| `kei_client.py` | **변경 없음** | — | +| `content_editor.py` | **변경 없음** | — | +| `sse_utils.py` | **변경 없음** | — | + +### 검증 매트릭스 + +| 항목 | 결과 | +|------|------| +| Phase I 회귀 | **없음** — I-1~I-14 전부 유지, 함수/상수 삭제 0건 | +| Kei API 사용 | **유지** — `call_kei_overflow_judgment()` 호출 위치만 Stage 5로 이동 | +| Sonnet이 Kei 역할 대체 | **없음** — Sonnet은 감지만, 판단은 Kei만 | +| 하드코딩 | **없음** — trim max_chars는 Kei가 결정 | +| 단발성 수정 | **없음** — 범용 구조 (어떤 overflow에도 동작) | +| 기존 코드 충돌 | **없음** — overflow 키가 중간 단계에서 무시되는 것 확인 | +| DOWNGRADE 비상용 | **유지** — Stage 5에서 Kei 실패 시 동일하게 작동 | + +### 실행 순서 + +1. P-1: Stage 2.5 제거 (pipeline.py 91~136행 삭제) +2. P-2: `_review_balance()` 시그니처 + 프롬프트 확장 +3. P-3: Stage 5 루프에 Kei 연동 + 헬퍼 함수 2개 +4. P-4: `_apply_adjustments()` kei_trim/kei_restructure action 추가 +5. P-5: 호출부 `analysis` 파라미터 추가 + +--- + +## 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-26 | Phase I 실행 완료 후 프로세스 검증 중 발견. 6개 문제 진단. | +| 2026-03-26 | 해결 방안 4개 조사. Playwright 높이 측정 + Stage 5 넘침 통합 방향 도출. | +| 2026-03-26 | **실행 계획 확정.** 충돌/회귀/오류 검토 완료. P-1~P-5 5건, pipeline.py만 변경. Phase I 산출물 전부 재사용. | diff --git a/IMPROVEMENT.md b/IMPROVEMENT.md index 7aeece0..a481284 100644 --- a/IMPROVEMENT.md +++ b/IMPROVEMENT.md @@ -328,31 +328,50 @@ CLAUDE.md 요구사항 전수검토 결과 발견된 미구현/부분구현/위 --- -## Phase I: 전수 정합성 복구 + 10가지 런타임 문제 해결 (13개) +## Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개) ✅ 완료 > **실행 상세:** [IMPROVEMENT-PHASE-I.md](IMPROVEMENT-PHASE-I.md) > 전수 검토에서 발견된 프롬프트 자기모순 + 슬롯 의미 미전달 + 코드 안전망 부족 해결. +> **핵심 변경: 넘침 시 DOWNGRADE_MAP 자동 교체 → Kei 판단 호출로 전환.** -### 패턴 A: 프롬프트 자기모순 (I-1~I-3) — 최우선 -- I-1: STEP_B_PROMPT에서 미존재 블록 3개(quote-left-border, card-text-grid, layer-diagram) → 실존 블록으로 교체 -- I-2: catalog.yaml not_for에서 미존재 블록 참조 → 실존 블록으로 교체 -- I-3: 미등록 블록 교체를 callout-solution 일괄 → purpose 기반 교체 맵 +### Phase I-A: 정합성 복구 (7개) ✅ +- I-14: `_stream_sse_tokens()` 3개 파일 중복 → `src/sse_utils.py` 공통 유틸 추출 +- I-13: dead code 3건 삭제 (`_call_anthropic_direct`, `_extract_sse_text` x2) + `import anthropic` 제거 +- I-1: STEP_B_PROMPT purpose 가이드 미존재 블록 3개 → 실존 블록으로 교체 +- I-2: catalog.yaml not_for 13건 미존재 블록 참조 교체/제거 +- I-12: BLOCK_SLOTS 주석 개수 수정 (cards 9, visuals 6, emphasis 10) +- I-10: INDEX.md 38개로 동기화 (삭제된 8개 블록 행 제거) +- I-11: README.md 38개로 동기화 (_legacy 제거, 트리/개수 정리) -### 패턴 B: 슬롯 의미 미전달 (I-4~I-5) -- I-4: BLOCK_SLOTS 38개 블록에 slot_desc 추가 (각 슬롯의 의미/예시/구조) -- I-5: 편집자 프롬프트에 slot_desc 전달 +### Phase I-B: 블록 선택 + 슬롯 의미 (5개) ✅ +- I-3: `PURPOSE_FALLBACK` 상수 + purpose 기반 미등록 블록 교체 +- I-7: compare-pill-pair 단독 사용 금지 검증 (`COMPARISON_BLOCKS`) +- I-4: 38개 블록 전체에 `slot_desc` 추가 (각 슬롯 의미/형식/예시) +- I-5: 편집자 프롬프트에 slot_desc 전달 로직 (Kei API 경유) +- I-6: 제목 유사도 70% 초과 시 자동 교정 (`SequenceMatcher`) -### 패턴 C: 코드 안전망 (I-6~I-9) -- I-6: 제목 유사도 검증 (70% 이상 → 자동 교정) -- I-7: compare-pill-pair 단독 사용 금지 (비교 테이블 없으면 comparison-2col로 교체) -- I-8: 대형 테이블(5행+) → detail_target 자동 설정 -- I-9: DOWNGRADE_MAP 확장 (medium → compact) + 다단계 교체 +### Phase I-C: 넘침 처리 패러다임 전환 (2개) ✅ +- I-9: `_validate_height_budget()` → overflow 반환 (블록 교체 안 함) + `_downgrade_fallback()` 비상 분리 + `KEI_OVERFLOW_PROMPT` + `call_kei_overflow_judgment()` Kei API 호출 + pipeline Stage 2.5 추가 (trim/restructure 분기) +- I-8: 대형 콘텐츠(테이블/이미지) 정보를 Kei overflow 프롬프트에 포함 -### 문서 동기화 (I-10~I-13) -- I-10: INDEX.md 미존재 블록 제거 (46→38) -- I-11: README.md 동기화 (블록 수, Sonnet fallback 제거) -- I-12: BLOCK_SLOTS 주석 수정 -- I-13: 데드 코드(_call_anthropic_direct) 제거 +--- + +## 프로세스 재검토: 검증 시점 문제 (Phase I 후속) + +> **상세:** [IMPROVEMENT-PROCESS-REVIEW.md](IMPROVEMENT-PROCESS-REVIEW.md) +> Phase I 실행 후 발견. Stage 2.5의 넘침 판단이 텍스트 없는 시점에서 실행되는 구조적 문제. + +**문제:** 6건 (내용 없이 판단, 판단 주체 잘못됨, HTML 있는데 넘침 안 봄, Kei 없음, 높이 측정 없음, 루프에 누락) +**원인:** Phase I에서 DOWNGRADE_MAP → Kei 판단으로 메커니즘만 변경, 위치(Stage 2.5)는 기존 코드 관성으로 유지 + +**해결:** Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합 +- P-1: Stage 2.5 제거 (pipeline.py) +- P-2: `_review_balance()` 프롬프트에 zone 예산 + overflow_detected 추가 +- P-3: Stage 5 루프에 Kei 넘침 판단 통합 +- P-4: `_apply_adjustments()` kei_trim/kei_restructure action 추가 +- P-5: 호출부 analysis 파라미터 추가 + +**Phase I 영향:** 회귀 없음. `call_kei_overflow_judgment()`, `_downgrade_fallback()`, `KEI_OVERFLOW_PROMPT` 전부 재사용. 호출 위치만 이동. --- diff --git a/README.md b/README.md index 090af15..7a61176 100644 --- a/README.md +++ b/README.md @@ -11,44 +11,146 @@ ``` 텍스트 입력 (+ 이미지 폴더 경로) ↓ -[1] Kei 실장 (Kei API → Opus) — 정보 구조 파악 + 꼭지 추출 - - 본문 흐름(flow) vs 참조 정보(reference) 분리 - - 각 꼭지의 레이어/강조/배치 방향 판단 - - 이미지 판단 (개수/소속/핵심·보조/텍스트 포함 여부) - - 표 판단 (행/열 규모, 1페이지 표시 가능 여부) - - fallback: Anthropic Sonnet 직접 호출 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[1단계] Kei 실장 — 콘텐츠 분석 + 스토리라인 설계 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ 사용 AI: Kei API (Opus) +│ fallback: manual_classify() (최소 구조 생성) +│ +│ 1-A: 정보 구조 파악 + 꼭지 추출 +│ - 핵심 메시지(core_message) 도출 +│ - 본문 흐름(flow) vs 참조 정보(reference) 분리 +│ - 각 꼭지의 레이어/강조/배치 방향 판단 +│ - 이미지 판단 (개수/소속/핵심·보조/텍스트 포함 여부) +│ - 표 판단 (행/열 규모, 1페이지 표시 가능 여부) +│ - purpose 부여 (문제제기/근거사례/핵심전달/용어정의/결론강조/구조시각화) +│ +│ 1-B: 각 꼭지 컨셉 구체화 +│ - relation_type (비교/포함/계층/인과 등) +│ - expression_hint (표현 방향) +│ - source_data (원본에서 추출할 데이터) +│ +│ 제목 중복 검증 (I-6) +│ - 슬라이드 제목 ↔ 첫 꼭지 제목 유사도 70% 초과 시 자동 교정 +│ +│ 이미지 크기 측정 (Pillow) +│ - base_path 있으면 이미지 파일 크기 측정 → analysis에 포함 +│ ↓ -[2] 디자인 팀장 — 3-Step - Step A-1: 레이아웃 프리셋 자동 선택 (규칙 기반) - - sidebar-right / two-column / hero-detail / single-column - Step A-2: Opus(Kei API) 블록 추천 (FAISS 검색 결과 기반) - - 도메인 지식 + 콘텐츠 성격 기반 블록 추천 - - fallback: 추천 없이 Step B로 - Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet) - - Opus 추천 참고하되 최종 선택은 팀장 판단 - - 컨테이너 예산(zone별 높이 px) 기반 블록 선택 - - grid는 코드가 프리셋에서 강제 (Sonnet은 blocks만 출력) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[2단계] 디자인 팀장 — 레이아웃 설계 + 블록 매핑 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ Step A-1: 레이아웃 프리셋 자동 선택 (규칙 기반, LLM 불필요) +│ - sidebar-right / two-column / hero-detail / single-column +│ - grid는 코드가 프리셋에서 강제 (AI가 변경 불가) +│ +│ Step A-2: Opus(Kei API) 블록 추천 (FAISS 검색 결과 기반) +│ 사용 AI: Kei API (Opus) +│ - 도메인 지식 + 콘텐츠 성격 기반 블록 추천 +│ - fallback: 추천 없이 Step B로 +│ +│ Step B: 블록 매핑 + 글자 수 가이드 (Sonnet) +│ 사용 AI: Anthropic API (Sonnet) +│ - Opus 추천 참고하되 최종 선택은 팀장 판단 +│ - 컨테이너 예산(zone별 높이 px) 기반 블록 선택 +│ - purpose 기반 블록 선택 가이드 참고 +│ - 각 블록에 char_guide(글자 수 가이드) 부여 +│ +│ 블록 검증 (코드): +│ - 미등록 블록 → purpose 기반 fallback (PURPOSE_FALLBACK) +│ - 잘못된 zone → 기본 zone 자동 매핑 +│ - conclusion 꼭지 → footer zone 강제 +│ - compare-pill-pair 단독 사용 → comparison-2col 교체 (I-7) +│ - 금지 블록(section-title-with-bg) → body/sidebar에서 교체 +│ +│ 높이 예산 검증 (I-9): +│ - zone별 블록 높이 합산 vs budget_px 비교 +│ - 초과 시 → overflow 정보 수집 (블록 자동 교체 안 함) +│ + ↓ (overflow 있으면) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[2.5단계] Kei 실장 — 넘침 판단 (I-9) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ 사용 AI: Kei API (Opus) +│ 조건: 2단계에서 overflow 발생 시에만 실행 +│ +│ Kei에게 전달: 어떤 zone이 얼마나 초과, 블록/콘텐츠 요약, 대형 테이블/이미지 정보 (I-8) +│ +│ Kei가 판단: +│ Option 1 "trim" → 텍스트 분량 제약 (char_guide 축소) → 3단계에서 반영 +│ Option 2 "restructure" → 핵심 재구성 + 상세는 팝업(detail page) 분리 +│ → detail_target 설정 후 2단계 재실행 +│ +│ Kei API 실패 시: DOWNGRADE_MAP 비상 작동 (기계적 블록 교체) +│ ↓ -[3] Kei 텍스트 편집자 (Kei API) — 도메인 전문가로서 텍스트 정리 - - 글자 수 가이드 참고, 내용 의미 우선 - - 출처 보존, 개조식, 날조 금지 - - fallback: Anthropic Sonnet 직접 호출 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[3단계] Kei 텍스트 편집자 — 도메인 전문가로서 텍스트 정리 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ 사용 AI: Kei API (Opus + RAG + 도메인 지식) +│ Sonnet fallback 없음 (Kei API만 사용) +│ +│ - 각 블록의 슬롯에 맞게 텍스트 정리 +│ - 슬롯 의미 설명(slot_desc) 참고하여 정확한 데이터 배치 (I-4, I-5) +│ - 글자 수 가이드 참고, 내용 의미 우선 +│ - 2.5단계에서 trim 제약이 있으면 반영 +│ - 원본 텍스트 최대 보존, 출처 보존, 개조식, 날조 금지 +│ - detail_target 꼭지: summary + detail 두 버전 작성 +│ ↓ -[4] 디자인 실무자 (Sonnet + Jinja2 + CSS Grid) — 디자인 조정 + HTML 조립 - - Sonnet이 텍스트 양에 맞게 CSS 변수 override 결정 (폰트/여백/간격) - - Jinja2로 블록 템플릿 렌더링 + CSS 변수 cascade로 자동 적용 - - SVG 시각화 블록: 좌표 사전 계산 (svg_calculator.py) - - 이미지 base64 인라인 삽입 (다운로드 HTML에서도 표시) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[4단계] 디자인 실무자 — 디자인 조정 + HTML 조립 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ 사용 AI: Anthropic API (Sonnet) — CSS 변수 override 결정 +│ 렌더링: Jinja2 + CSS Grid +│ +│ - Sonnet이 텍스트 양에 맞게 CSS 변수 override 결정 +│ (--font-body, --font-subtitle, --spacing-inner, --spacing-block 등) +│ - Jinja2로 블록 템플릿 렌더링 +│ - CSS 변수 cascade로 area별 자동 적용 +│ - SVG 시각화 블록: 좌표 사전 계산 (svg_calculator.py) +│ - 이미지 base64 인라인 삽입 (다운로드 HTML에서도 표시) +│ ↓ -[5] 디자인 팀장 (Sonnet) — 전체 재검토 (최대 2회 루프) - - HTML 전문 기반 균형 점검 - - expand/shrink/rewrite 조정 (AI가 target_ratio 결정) - - 조정 후 재렌더링 → 재검토 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[5단계] 디자인 팀장 — 전체 재검토 (최대 2회 루프) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +│ 사용 AI: Anthropic API (Sonnet) — HTML 기반 균형 점검 +│ +│ 점검 항목: +│ - 빈 블록 감지 +│ - 채움 불균형 (한 블록은 빽빽, 다른 블록은 비어있음) +│ - 이미지/표 크기 적절성 +│ - 전체 정보량 (페이지당 너무 많거나 적은지) +│ +│ 조정 필요 시: +│ - expand: 텍스트 늘림 (target_ratio, 예: 1.3 = 30% 증가) +│ - shrink: 텍스트 줄임 (target_ratio, 예: 0.7 = 30% 감소) +│ - rewrite: 텍스트 재작성 (방향 명시) +│ → 3단계(Kei 편집자) 재호출 → 4단계 재렌더링 → 재검토 +│ +│ 조정 불필요 또는 2회 완료 시 확정 +│ ↓ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 미리보기 + HTML 다운로드 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` -## 블록 라이브러리 (46개 + _legacy 13개) +### 각 단계별 AI 담당 + +| 단계 | 담당 | AI | session_id | +|------|------|-----|-----------| +| 1-A | Kei 실장 | Kei API (Opus) | `design-agent` | +| 1-B | Kei 실장 | Kei API (Opus) | `design-agent-refine` | +| 2 A-2 | Kei 실장 | Kei API (Opus) | `design-agent-opus` | +| 2 B | 디자인 팀장 | Anthropic (Sonnet) | — | +| 2.5 | Kei 실장 | Kei API (Opus) | `design-agent-overflow` | +| 3 | Kei 편집자 | Kei API (Opus) | `design-agent-editor` | +| 4 | 디자인 실무자 | Anthropic (Sonnet) | — | +| 5 | 디자인 팀장 | Anthropic (Sonnet) | — | + +## 블록 라이브러리 (38개) ``` templates/blocks/ @@ -59,8 +161,7 @@ templates/blocks/ │ ├── topic-left-right.html 좌:제목 + 우:설명 │ ├── topic-center.html 중앙 정렬 제목 │ └── topic-numbered.html 번호 + 제목 + 설명 -├── cards/ (10개) 카드 계열 -│ ├── card-text-grid.html 텍스트 카드 2~4열 +├── cards/ (9개) 카드 계열 │ ├── card-image-3col.html 이미지 카드 3열 │ ├── card-dark-overlay.html 다크 오버레이 카드 │ ├── card-tag-image.html 태그 + 이미지 카드 @@ -74,22 +175,16 @@ templates/blocks/ │ ├── compare-3col-badge.html A|VS배지|B 3단 비교 │ ├── compare-2col-split.html 좌우 분할 비교 │ └── table-simple-striped.html 줄무늬 일반 테이블 -├── visuals/ (10개) 다이어그램, 관계도 (SVG) +├── visuals/ (6개) 다이어그램, 관계도 (SVG) │ ├── venn-diagram.html 벤 다이어그램 (N개 동적) │ ├── circle-gradient.html 그라데이션 원 + 텍스트 │ ├── compare-pill-pair.html 둥근 박스 2개 + VS │ ├── process-horizontal.html 가로 단계 흐름 │ ├── flow-arrow-horizontal.html 가로 화살표 흐름 -│ ├── keyword-circle-row.html 키워드 원형 나열 -│ ├── layer-diagram.html 레이어 다이어그램 -│ ├── timeline-vertical.html 세로 타임라인 -│ ├── timeline-horizontal.html 가로 타임라인 -│ └── pyramid-hierarchy.html 피라미드 계층 -├── emphasis/ (13개) 강조, 인용, 결론 -│ ├── quote-left-border.html 좌측 라인 인용 +│ └── keyword-circle-row.html 키워드 원형 나열 +├── emphasis/ (10개) 강조, 인용, 결론 │ ├── quote-big-mark.html 큰 따옴표 인용 │ ├── quote-question.html 질문형 강조 -│ ├── conclusion-accent-bar.html 좌측 라인 결론 │ ├── comparison-2col.html 2단 비교 │ ├── banner-gradient.html 그라데이션 배너 │ ├── dark-bullet-list.html 다크 배경 불릿 리스트 @@ -97,24 +192,22 @@ templates/blocks/ │ ├── callout-solution.html 솔루션 콜아웃 │ ├── callout-warning.html 경고 콜아웃 │ ├── tab-label-row.html 탭 라벨 행 -│ ├── divider-text.html 텍스트 구분선 -│ └── details-block.html 자세히보기 (접기/펼치기) -├── media/ (5개) 이미지/미디어 -│ ├── image-row-2col.html 이미지 2장 나란히 -│ ├── image-grid-2x2.html 이미지 2x2 그리드 -│ ├── image-side-text.html 이미지 + 텍스트 -│ ├── image-full-caption.html 전체 너비 이미지 + 캡션 -│ └── image-before-after.html Before/After 비교 -└── _legacy/ (13개) 이전 버전 (fallback) +│ └── divider-text.html 텍스트 구분선 +└── media/ (5개) 이미지/미디어 + ├── image-row-2col.html 이미지 2장 나란히 + ├── image-grid-2x2.html 이미지 2x2 그리드 + ├── image-side-text.html 이미지 + 텍스트 + ├── image-full-caption.html 전체 너비 이미지 + 캡션 + └── image-before-after.html Before/After 비교 ``` ## FAISS 블록 검색 -46개 블록 전체를 프롬프트에 넣는 대신, FAISS로 꼭지별 관련 블록만 검색하여 전달합니다. +38개 블록 전체를 프롬프트에 넣는 대신, FAISS로 꼭지별 관련 블록만 검색하여 전달합니다. ``` 꼭지 "A vs B 비교" → FAISS 검색 → comparison-2col, compare-pill-pair, compare-2col-split -꼭지 "연도별 로드맵" → FAISS 검색 → timeline-vertical, timeline-horizontal, card-step-vertical +꼭지 "연도별 로드맵" → FAISS 검색 → process-horizontal, flow-arrow-horizontal, card-step-vertical ``` - 임베딩 모델: BAAI/bge-m3 (1024차원, 한국어 최적화) @@ -143,7 +236,7 @@ grid는 코드(Step A)가 결정. Sonnet은 blocks만 출력. grid 변경 불가 | AI (3단계 편집자) | Kei API → fallback: Sonnet | | AI (4단계 실무자) | Anthropic API (Sonnet) — CSS 조정 | | AI (5단계 재검토) | Anthropic API (Sonnet) — 균형 점검 | -| 블록 검색 | FAISS + bge-m3 (46개 블록 인덱스) | +| 블록 검색 | FAISS + bge-m3 (38개 블록 인덱스) | | 템플릿 | Jinja2 (카테고리별 블록 조합) | | 렌더링 | CSS Grid + 디자인 토큰 (16:9, 1280×720) | | SVG 시각화 | svg_calculator.py (cos/sin 좌표 계산, N개 동적) | @@ -220,8 +313,8 @@ design_agent/ │ ├── templates/ │ ├── slide-base.html 슬라이드 베이스 (다중 페이지 + 인쇄 JS) -│ ├── catalog.yaml 블록 카탈로그 (46개, height_cost 포함) -│ └── blocks/ 블록 라이브러리 (6 카테고리, 46개) +│ ├── catalog.yaml 블록 카탈로그 (38개, height_cost 포함) +│ └── blocks/ 블록 라이브러리 (6 카테고리, 38개) │ ├── INDEX.md 전체 인덱스 │ ├── headers/ (5) 타이틀, 꼭지 헤더 │ ├── cards/ (10) 카드 계열 @@ -229,7 +322,7 @@ design_agent/ │ ├── visuals/ (10) 다이어그램, 관계도 (SVG) │ ├── emphasis/ (13) 강조, 인용, 결론, 자세히보기 │ ├── media/ (5) 이미지/미디어 -│ └── _legacy/ (13) 이전 버전 (fallback) +│ └── media/ (5) 이미지/미디어 │ ├── static/ │ ├── index.html 프론트엔드 (이미지 경로 입력 팝업 포함) diff --git a/src/content_editor.py b/src/content_editor.py index 392a1f4..a52ba43 100644 --- a/src/content_editor.py +++ b/src/content_editor.py @@ -19,6 +19,7 @@ import httpx from src.config import settings from src.design_director import BLOCK_SLOTS +from src.sse_utils import stream_sse_tokens logger = logging.getLogger(__name__) @@ -92,6 +93,12 @@ async def fill_content( f" 선택 슬롯: {slots.get('optional', [])}" ) + # I-5: 슬롯 의미 설명 전달 (slot_desc가 있으면) + slot_desc = slots.get("slot_desc", {}) + if slot_desc: + desc_lines = [f" {k}: {v}" for k, v in slot_desc.items()] + req_text += "\n 슬롯 설명:\n" + "\n".join(desc_lines) + if char_guide: guide_lines = [f" {k}: ~{v}자" for k, v in char_guide.items()] req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines) @@ -188,7 +195,7 @@ async def _call_kei_editor(prompt: str) -> str | None: logger.warning(f"Kei API (editor) HTTP {response.status_code}") return None - full_text = await _stream_sse_tokens(response) + full_text = await stream_sse_tokens(response) if full_text: return full_text @@ -201,65 +208,6 @@ async def _call_kei_editor(prompt: str) -> str | None: return None -async def _stream_sse_tokens(response: httpx.Response) -> str: - """SSE 스트리밍 응답에서 토큰을 실시간 수집한다.""" - tokens: list[str] = [] - event_type = "" - - async for line in response.aiter_lines(): - line = line.strip() - if not line: - event_type = "" - continue - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data = line[5:].strip() - if event_type == "token" and data: - try: - token = json.loads(data) - if isinstance(token, str): - tokens.append(token) - except json.JSONDecodeError: - tokens.append(data) - elif event_type == "done": - break - elif event_type == "error": - logger.warning(f"Kei API SSE 에러: {data}") - break - - return "".join(tokens) - - -def _extract_sse_text(raw: str) -> str: - """SSE 응답에서 토큰 텍스트를 수집한다.""" - import re as _re - tokens = [] - events = _re.split(r'\r?\n\r?\n', raw) - for event in events: - if not event.strip(): - continue - event_type = "" - event_data = "" - for line in event.split('\n'): - line = line.strip('\r') - if line.startswith('event:'): - event_type = line[6:].strip() - elif line.startswith('data:'): - event_data = line[5:].strip() - if not event_data: - continue - if event_type == 'token': - try: - token = json.loads(event_data) - if isinstance(token, str): - tokens.append(token) - except json.JSONDecodeError: - tokens.append(event_data) - elif event_type == 'done': - break - return "".join(tokens) - def _apply_defaults(blocks: list[dict[str, Any]]) -> None: """실패 시 기본 데이터 적용.""" diff --git a/src/design_director.py b/src/design_director.py index 6d1a87c..8f50389 100644 --- a/src/design_director.py +++ b/src/design_director.py @@ -16,6 +16,7 @@ import httpx import yaml from src.config import settings +from src.sse_utils import stream_sse_tokens logger = logging.getLogger(__name__) @@ -24,49 +25,285 @@ logger = logging.getLogger(__name__) # ────────────────────────────────────── BLOCK_SLOTS = { # headers/ (5개) - "section-title-with-bg": {"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"]}, - "section-header-bar": {"required": ["title"], "optional": ["subtitle"]}, - "topic-left-right": {"required": ["title", "description"], "optional": []}, - "topic-center": {"required": ["title"], "optional": ["subtitle", "description"]}, - "topic-numbered": {"required": ["number", "title"], "optional": ["description", "color"]}, - # cards/ (10개) - "card-image-3col": {"required": ["cards"], "optional": []}, - "card-dark-overlay": {"required": ["cards"], "optional": []}, - "card-tag-image": {"required": ["cards"], "optional": []}, - "card-icon-desc": {"required": ["cards"], "optional": []}, - "card-compare-3col": {"required": ["cards"], "optional": []}, - "card-step-vertical": {"required": ["steps"], "optional": []}, - "card-image-round": {"required": ["cards"], "optional": []}, - "card-stat-number": {"required": ["stats"], "optional": []}, - "card-numbered": {"required": ["items"], "optional": []}, + "section-title-with-bg": { + "required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"], + "slot_desc": { + "title_ko": "한글 메인 타이틀", + "title_en": "영문 서브 타이틀 (없으면 생략)", + "breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)", + "bg_image": "배경 이미지 경로", + }, + }, + "section-header-bar": { + "required": ["title"], "optional": ["subtitle"], + "slot_desc": { + "title": "섹션 제목 (짧고 굵게)", + "subtitle": "보조 설명 (한 줄)", + }, + }, + "topic-left-right": { + "required": ["title", "description"], "optional": [], + "slot_desc": { + "title": "꼭지 제목 (좌측, 굵게)", + "description": "꼭지 설명 (우측, 2~3줄)", + }, + }, + "topic-center": { + "required": ["title"], "optional": ["subtitle", "description"], + "slot_desc": { + "title": "중앙 정렬 대제목", + "subtitle": "부제목 (작은 글씨)", + "description": "추가 설명 (1~2줄)", + }, + }, + "topic-numbered": { + "required": ["number", "title"], "optional": ["description", "color"], + "slot_desc": { + "number": "순번 (1, 2, 3 등)", + "title": "단계/항목 제목", + "description": "설명 텍스트", + "color": "원형 번호 색상 (CSS 색상값)", + }, + }, + # cards/ (9개) + "card-image-3col": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "카드 배열. 각 카드: {image: '이미지 경로', title: '제목', title_en: '영문', bullets: ['항목1', '항목2']}. 3장.", + }, + }, + "card-dark-overlay": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "카드 배열. 각 카드: {image: '배경 이미지', title: '키워드', description: '짧은 설명'}. 3~5장.", + }, + }, + "card-tag-image": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "카드 배열. 각 카드: {tag: '카테고리 라벨', tag_color: '색상', image: '이미지', title: '제목', description: '설명'}. 3장.", + }, + }, + "card-icon-desc": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "카드 배열. 각 카드: {icon: '이모지', title: '제목', description: '설명 (2~3줄)'}. 2~4장.", + }, + }, + "card-compare-3col": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "비교 카드 배열. 각 카드: {header: '카테고리명', header_color: '색상', bullets: ['항목1', '항목2']}. 3장.", + }, + }, + "card-step-vertical": { + "required": ["steps"], "optional": [], + "slot_desc": { + "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명', image: '이미지(선택)'}. 3~5단계.", + }, + }, + "card-image-round": { + "required": ["cards"], "optional": [], + "slot_desc": { + "cards": "카드 배열. 각 카드: {image: '원형 이미지', title: '제목', description: '설명'}. 2~3장.", + }, + }, + "card-stat-number": { + "required": ["stats"], "optional": [], + "slot_desc": { + "stats": "통계 배열. 각 항목: {number: '85', unit: '%', label: '비용 절감율'}. 2~4개. 숫자는 출처 있는 것만!", + }, + }, + "card-numbered": { + "required": ["items"], "optional": [], + "slot_desc": { + "items": "항목 배열. 각 항목: {title: '항목 제목', description: '설명'}. 3~5개.", + }, + }, # tables/ (3개) - "compare-3col-badge": {"required": ["headers", "rows"], "optional": []}, - "compare-2col-split": {"required": ["left_title", "right_title", "rows"], "optional": []}, - "table-simple-striped": {"required": ["headers", "rows"], "optional": []}, - # visuals/ (10개) - "venn-diagram": {"required": ["center_label", "items"], "optional": ["center_sub", "description"]}, - "circle-gradient": {"required": ["label"], "optional": ["sub_label"]}, - "compare-pill-pair": {"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"]}, - "process-horizontal": {"required": ["steps"], "optional": []}, - "flow-arrow-horizontal": {"required": ["steps"], "optional": []}, - "keyword-circle-row": {"required": ["keywords"], "optional": []}, - # emphasis/ (12개) - "quote-big-mark": {"required": ["quote_text"], "optional": ["source"]}, - "quote-question": {"required": ["question"], "optional": ["description"]}, - "comparison-2col": {"required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"]}, - "banner-gradient": {"required": ["text"], "optional": ["sub_text"]}, - "dark-bullet-list": {"required": ["bullets"], "optional": ["title"]}, - "highlight-strip": {"required": ["segments"], "optional": []}, - "callout-solution": {"required": ["title", "description"], "optional": ["icon", "source"]}, - "callout-warning": {"required": ["title", "description"], "optional": ["icon"]}, - "tab-label-row": {"required": ["tabs"], "optional": []}, - "divider-text": {"required": ["text"], "optional": []}, + "compare-3col-badge": { + "required": ["headers", "rows"], "optional": [], + "slot_desc": { + "headers": "3개 열 헤더 배열: ['항목', 'A 대상', 'B 대상']", + "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: 'A 내용', right: 'B 내용'}. 최소 3행.", + }, + }, + "compare-2col-split": { + "required": ["left_title", "right_title", "rows"], "optional": [], + "slot_desc": { + "left_title": "왼쪽 열 헤더", + "right_title": "오른쪽 열 헤더", + "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.", + }, + }, + "table-simple-striped": { + "required": ["headers", "rows"], "optional": [], + "slot_desc": { + "headers": "열 헤더 배열: ['열1', '열2', '열3']", + "rows": "데이터 행 배열. 각 행: ['셀1', '셀2', '셀3']. 행 수 자유.", + }, + }, + # visuals/ (6개) + "venn-diagram": { + "required": ["center_label", "items"], "optional": ["center_sub", "description"], + "slot_desc": { + "center_label": "중앙 교집합 라벨 (핵심 키워드)", + "items": "원 배열. 각 원: {label: '영역명', sub: '설명'}. 2~5개.", + "center_sub": "중앙 부가 설명", + "description": "다이어그램 하단 설명", + }, + }, + "circle-gradient": { + "required": ["label"], "optional": ["sub_label"], + "slot_desc": { + "label": "원 중앙 메인 텍스트 (키워드, 1~2단어)", + "sub_label": "원 아래 보조 텍스트", + }, + }, + "compare-pill-pair": { + "required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"], + "slot_desc": { + "left_label": "왼쪽 개념명 (1~2단어)", + "right_label": "오른쪽 개념명 (1~2단어)", + "left_sub": "왼쪽 보조 설명", + "right_sub": "오른쪽 보조 설명", + }, + }, + "process-horizontal": { + "required": ["steps"], "optional": [], + "slot_desc": { + "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명'}. 3~5단계.", + }, + }, + "flow-arrow-horizontal": { + "required": ["steps"], "optional": [], + "slot_desc": { + "steps": "흐름 배열. 각 항목: {label: '단계명'}. 3~5개. 화살표로 연결됨.", + }, + }, + "keyword-circle-row": { + "required": ["keywords"], "optional": [], + "slot_desc": { + "keywords": "키워드 배열. 각 항목: {letter: '약어 (G)', label: '풀네임', description: '설명'}. 3~5개.", + }, + }, + # emphasis/ (10개) + "quote-big-mark": { + "required": ["quote_text"], "optional": ["source"], + "slot_desc": { + "quote_text": "인용할 본문 텍스트 (핵심 발언, 1~3문장)", + "source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!", + }, + }, + "quote-question": { + "required": ["question"], "optional": ["description"], + "slot_desc": { + "question": "독자에게 던지는 질문 (1문장, 물음표로 끝)", + "description": "질문에 대한 부연 (1~2줄)", + }, + }, + "comparison-2col": { + "required": ["left_title", "left_content", "right_title", "right_content"], + "optional": ["left_subtitle", "right_subtitle"], + "slot_desc": { + "left_title": "왼쪽 개념 제목 (파란색)", + "left_content": "왼쪽 본문 (불릿 또는 문장)", + "right_title": "오른쪽 개념 제목 (빨간색)", + "right_content": "오른쪽 본문 (불릿 또는 문장)", + "left_subtitle": "왼쪽 보조 제목", + "right_subtitle": "오른쪽 보조 제목", + }, + }, + "banner-gradient": { + "required": ["text"], "optional": ["sub_text"], + "slot_desc": { + "text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)", + "sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)", + }, + }, + "dark-bullet-list": { + "required": ["bullets"], "optional": ["title"], + "slot_desc": { + "title": "리스트 상단 제목 (파란색, 선택)", + "bullets": "불릿 항목 배열: ['핵심 포인트 1', '핵심 포인트 2']. 3~5개.", + }, + }, + "highlight-strip": { + "required": ["segments"], "optional": [], + "slot_desc": { + "segments": "색상 구간 배열. 각 구간: {label: '카테고리명', color: '색상'}. 3~5개.", + }, + }, + "callout-solution": { + "required": ["title", "description"], "optional": ["icon", "source"], + "slot_desc": { + "title": "솔루션/방향성 제목", + "description": "상세 설명 (2~3줄)", + "icon": "아이콘 이모지 (예: 💡)", + "source": "출처 (있으면)", + }, + }, + "callout-warning": { + "required": ["title", "description"], "optional": ["icon"], + "slot_desc": { + "title": "문제점/경고 제목", + "description": "상세 설명 (2~3줄)", + "icon": "아이콘 이모지 (예: ⚠️)", + }, + }, + "tab-label-row": { + "required": ["tabs"], "optional": [], + "slot_desc": { + "tabs": "탭 배열. 각 탭: {label: '탭 이름', active: true/false}. 3~5개. 하나만 active.", + }, + }, + "divider-text": { + "required": ["text"], "optional": [], + "slot_desc": { + "text": "구분선 중앙 텍스트 (짧은 전환 문구, 1~5단어)", + }, + }, # media/ (5개) - "image-row-2col": {"required": ["images"], "optional": []}, - "image-grid-2x2": {"required": ["images"], "optional": []}, - "image-side-text": {"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"]}, - "image-full-caption": {"required": ["src"], "optional": ["alt", "caption"]}, - "image-before-after": {"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"]}, + "image-row-2col": { + "required": ["images"], "optional": [], + "slot_desc": { + "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명', caption: '캡션'}. 2장.", + }, + }, + "image-grid-2x2": { + "required": ["images"], "optional": [], + "slot_desc": { + "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명'}. 4장 (2x2).", + }, + }, + "image-side-text": { + "required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"], + "slot_desc": { + "image_src": "좌측 이미지 경로", + "image_alt": "이미지 대체 텍스트", + "title": "우측 제목", + "description": "우측 설명 텍스트", + "bullets": "우측 불릿 항목 배열: ['항목1', '항목2']", + }, + }, + "image-full-caption": { + "required": ["src"], "optional": ["alt", "caption"], + "slot_desc": { + "src": "전체 너비 이미지 경로", + "alt": "이미지 대체 텍스트", + "caption": "이미지 하단 캡션", + }, + }, + "image-before-after": { + "required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"], + "slot_desc": { + "before_src": "Before 이미지 경로", + "after_src": "After 이미지 경로", + "before_label": "Before 라벨 (기본: Before)", + "after_label": "After 라벨 (기본: After)", + "caption": "비교 설명 캡션", + }, + }, } # ────────────────────────────────────── @@ -264,11 +501,11 @@ header/footer는 고정이므로 건드리지 않는다. ## purpose 기반 블록 선택 가이드 (참고, 강제 아님) 각 꼭지의 purpose에 맞는 블록 계열을 선택하라: - 문제제기 → callout-warning, quote-big-mark, quote-question -- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열) +- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열) - 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split -- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면) +- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면) - 결론강조 → banner-gradient (footer) -- 구조시각화 → venn-diagram, layer-diagram (단독 배치) +- 구조시각화 → venn-diagram (단독 배치) ## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라) {allowed_ids} @@ -364,7 +601,7 @@ async def _opus_block_recommendation( logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}") return None - full_text = await _stream_sse_tokens(response) + full_text = await stream_sse_tokens(response) if not full_text: logger.warning("[Step A-2] Kei API 응답 텍스트 없음") @@ -386,35 +623,6 @@ async def _opus_block_recommendation( return None -async def _stream_sse_tokens(response: httpx.Response) -> str: - """SSE 스트리밍 응답에서 토큰을 실시간 수집한다.""" - tokens: list[str] = [] - event_type = "" - - async for line in response.aiter_lines(): - line = line.strip() - if not line: - event_type = "" - continue - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data = line[5:].strip() - if event_type == "token" and data: - try: - token = json.loads(data) - if isinstance(token, str): - tokens.append(token) - except json.JSONDecodeError: - tokens.append(data) - elif event_type == "done": - break - elif event_type == "error": - logger.warning(f"Kei API SSE 에러: {data}") - break - - return "".join(tokens) - async def create_layout_concept( content: str, @@ -567,11 +775,13 @@ async def create_layout_concept( for block in blocks: block_type = block.get("type", "") if block_type and block_type not in registered_ids: + purpose = block.get("purpose", "") + fallback = PURPOSE_FALLBACK.get(purpose, "callout-solution") logger.warning( f"[Step B 검증] 미등록 블록 '{block_type}' 거부 → " - f"'callout-solution'으로 교체" + f"'{fallback}'으로 교체 (purpose={purpose})" ) - block["type"] = "callout-solution" + block["type"] = fallback # area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑 valid_zones = {z for z in preset["zones"] if z != "header"} @@ -597,13 +807,14 @@ async def create_layout_concept( ) block["area"] = "footer" - # 5번: zone별 height_cost 합산 검증 — 초과 시 큰 블록 교체 - _validate_height_budget(blocks, preset) + # 5번: zone별 height_cost 합산 검증 (I-9: overflow 수집, 블록 교체 안 함) + overflows = _validate_height_budget(blocks, preset) logger.info( f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록" + + (f", overflow {len(overflows)}건" if overflows else "") ) - return { + result = { "title": analysis.get("title", "슬라이드"), "pages": [{ "grid_areas": preset["grid_areas"], @@ -612,6 +823,9 @@ async def create_layout_concept( "blocks": blocks, }], } + if overflows: + result["overflow"] = overflows + return result else: logger.warning("블록 매핑 JSON 파싱 실패. fallback.") @@ -670,6 +884,16 @@ HEIGHT_COST_PX = { "xlarge": 400, } +# 미등록 블록 거부 시 purpose 기반 대체 (I-3) +PURPOSE_FALLBACK = { + "문제제기": "callout-warning", + "근거사례": "quote-big-mark", + "핵심전달": "comparison-2col", + "용어정의": "card-icon-desc", + "결론강조": "banner-gradient", + "구조시각화": "card-icon-desc", +} + # body/sidebar/footer zone에서 사용 금지인 블록 → 교체 BODY_FORBIDDEN_MAP = { "section-title-with-bg": "topic-center", # 500px 블록 → compact 헤더로 @@ -708,11 +932,16 @@ def _load_catalog_map_for_height() -> dict[str, str]: return {} -def _validate_height_budget(blocks: list[dict], preset: dict) -> None: - """zone별 height_cost 합산을 검증하고, 초과 시 큰 블록을 교체한다. +def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]: + """zone별 height_cost 합산을 검증한다. (I-9 개정) - 또한 body/sidebar/footer zone에서 금지된 블록을 교체한다. - 코드 레벨 검증 — Sonnet이 높이 예산을 안 지켜도 강제 교정. + 금지 블록 교체, pill-pair 단독 검증은 수행하되, + 높이 초과 시 블록을 자동 교체하지 않는다. + 대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청. + DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용. + + Returns: + overflow 정보 리스트. 초과 없으면 빈 리스트. """ zones = preset.get("zones", {}) gap_px = 20 # --spacing-block @@ -736,11 +965,24 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None: ) block["type"] = replacement + # compare-pill-pair 단독 사용 금지 (I-7) + COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"} + for area, area_blocks in zone_blocks.items(): + types = {b.get("type") for b in area_blocks} + if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS: + for block in area_blocks: + if block.get("type") == "compare-pill-pair": + block["type"] = "comparison-2col" + logger.warning( + "[pill-pair 단독 금지] compare-pill-pair → comparison-2col" + ) + + # 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함) + overflows: list[dict] = [] for area, area_blocks in zone_blocks.items(): zone_info = zones.get(area, {}) budget = zone_info.get("budget_px", 490) - # 총 높이 계산 total = sum(_get_block_height(b.get("type", "")) for b in area_blocks) total += gap_px * max(0, len(area_blocks) - 1) @@ -752,8 +994,39 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None: f"블록: {[b.get('type') for b in area_blocks]}" ) - # 가장 큰 블록부터 교체 시도 - area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True) + overflows.append({ + "area": area, + "overflow_px": total - budget, + "budget_px": budget, + "total_px": total, + "blocks": [ + { + "type": b.get("type", ""), + "purpose": b.get("purpose", ""), + "topic_id": b.get("topic_id"), + "height_px": _get_block_height(b.get("type", "")), + } + for b in area_blocks + ], + }) + + return overflows + + +def _downgrade_fallback(blocks: list[dict], overflows: list[dict]) -> None: + """Kei API 실패 시 비상용 기계적 블록 교체. + + 기존 DOWNGRADE_MAP 로직. 정상 경로가 아닌 비상 경로. + """ + for overflow in overflows: + area = overflow["area"] + area_blocks = [b for b in blocks if b.get("area") == area] + area_blocks.sort( + key=lambda b: _get_block_height(b.get("type", "")), reverse=True + ) + + total = overflow["total_px"] + budget = overflow["budget_px"] for block in area_blocks: block_type = block.get("type", "") @@ -767,8 +1040,8 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> None: block["type"] = replacement total = total - old_height + new_height - logger.info( - f"[높이 교체] {block_type}({old_height}px) → " + logger.warning( + f"[DOWNGRADE 비상] {block_type}({old_height}px) → " f"{replacement}({new_height}px). 잔여: {total}px/{budget}px" ) diff --git a/src/kei_client.py b/src/kei_client.py index 1d5fed4..e6d32b9 100644 --- a/src/kei_client.py +++ b/src/kei_client.py @@ -10,10 +10,10 @@ import logging import re from typing import Any -import anthropic import httpx from src.config import settings +from src.sse_utils import stream_sse_tokens logger = logging.getLogger(__name__) @@ -167,7 +167,7 @@ async def refine_concepts( logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}") return analysis - full_text = await _stream_sse_tokens(response) + full_text = await stream_sse_tokens(response) if not full_text: logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.") @@ -214,7 +214,7 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None: logger.warning(f"Kei API HTTP {response.status_code}") return None - full_text = await _stream_sse_tokens(response) + full_text = await stream_sse_tokens(response) if not full_text: logger.warning("Kei API 응답에서 텍스트 추출 실패") @@ -232,128 +232,105 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None: return None -async def _stream_sse_tokens(response: httpx.Response) -> str: - """SSE 스트리밍 응답에서 토큰을 실시간 수집한다. - persona_agent의 SSE 이벤트: - - token: 텍스트 토큰 수집 - - done: 완료, 중단 - - error: 에러, 즉시 중단 - - planning/planning_done/research_progress/warning: 스킵 + +# ────────────────────────────────────── +# I-9: Kei 넘침 판단 호출 +# ────────────────────────────────────── + +KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다. +디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다. +콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라. + +## 판단 기준 +- 텍스트 분량만 줄이면 현재 블록 구조 안에서 해결되는가? → "trim" +- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 많은가? → "restructure" +- 중요도가 높은 콘텐츠를 무리하게 축소하면 안 된다 +- 부가/상세 정보는 팝업(detail page)으로 분리할 수 있다 + +## 출력 (JSON만. 설명 없이.) + +Option 1 (텍스트 축약): +{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]} + +Option 2 (핵심 재구성 + 팝업 분리): +{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"} +""" + + +async def call_kei_overflow_judgment( + overflows: list[dict], + content: str, + analysis: dict[str, Any], +) -> dict[str, Any] | None: + """Kei API에 넘침 상황을 전달하고 판단을 받는다. + + 반드시 Kei API 경유. Anthropic 직접 호출 절대 금지. + fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동. """ - tokens: list[str] = [] - event_type = "" + kei_url = getattr(settings, "kei_api_url", "http://localhost:8000") - async for line in response.aiter_lines(): - line = line.strip() - if not line: - event_type = "" - continue - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data = line[5:].strip() - if event_type == "token" and data: - try: - token = json.loads(data) - if isinstance(token, str): - tokens.append(token) - except json.JSONDecodeError: - tokens.append(data) - elif event_type == "done": - break - elif event_type == "error": - logger.warning(f"Kei API SSE 에러: {data}") - break + overflow_desc = json.dumps(overflows, ensure_ascii=False, indent=2) + topics_desc = json.dumps( + [ + { + "id": t.get("id"), + "title": t.get("title", ""), + "purpose": t.get("purpose", ""), + "summary": t.get("summary", "")[:100], + } + for t in analysis.get("topics", []) + ], + ensure_ascii=False, + ) - return "".join(tokens) + # I-8: 대형 콘텐츠 정보 포함 + extra_info = "" + tables = analysis.get("tables", []) + if tables: + extra_info += f"\n\n## 테이블 정보\n{json.dumps(tables, ensure_ascii=False)}" + images = analysis.get("images", []) + if images: + extra_info += f"\n\n## 이미지 정보\n{json.dumps(images, ensure_ascii=False)}" - -def _extract_sse_text(raw: str) -> str: - """SSE 응답에서 토큰 텍스트를 수집한다. CRLF/LF 모두 처리.""" - tokens = [] - # CRLF 또는 LF로 이벤트 분리 - events = re.split(r'\r?\n\r?\n', raw) - - for event in events: - if not event.strip(): - continue - - event_type = "" - event_data = "" - - for line in event.split('\n'): - line = line.strip('\r') - if line.startswith('event:'): - event_type = line[6:].strip() - elif line.startswith('data:'): - event_data = line[5:].strip() - - if not event_data: - continue - - if event_type == 'token': - try: - token = json.loads(event_data) - if isinstance(token, str): - tokens.append(token) - except json.JSONDecodeError: - tokens.append(event_data) - elif event_type == 'done': - break - - return "".join(tokens) - - -async def _call_anthropic_direct(content: str) -> dict[str, Any] | None: - """Anthropic API 직접 호출 (Kei API fallback).""" - if not settings.anthropic_api_key: - return None - - system_prompt = ( - "당신은 콘텐츠를 분석하여 슬라이드 구조를 설계하는 실장이다.\n\n" - "## 핵심 원칙\n" - "- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n" - "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n" - "- 슬라이드에 맞게 정리하되, 원본이 말하려는 흐름은 유지\n\n" - "## 꼭지 추출 규칙\n" - "- 본문에서 2~5개의 핵심 꼭지를 추출한다\n" - "- 참조 정보는 role: 'reference', 본문 흐름은 role: 'flow'로 표시\n" - "- 1페이지 적정 꼭지 수: 5개\n" - "- 초과 시 2페이지 분리\n" - "- 이미지가 있으면 images[]에, 표가 있으면 tables[]에 판단 기록\n\n" - "## 출력 형식 (JSON만. 설명 없이.)\n" - '{"title": "제목", "total_pages": 1, ' - '"info_structure": "정보 구조 설명", ' - '"topics": [' - '{"id": 1, "title": "꼭지 제목", "summary": "요약", ' - '"layer": "intro|core|supporting|conclusion", ' - '"role": "flow|reference", ' - '"emphasis": true, "direction": "vertical|horizontal|flexible", ' - '"content_type": "text|image|table|mixed", ' - '"detail_target": false, "page": 1}], ' - '"images": [], "tables": []}' + prompt = ( + KEI_OVERFLOW_PROMPT + "\n\n" + f"## 넘침 현황\n{overflow_desc}\n\n" + f"## 꼭지 목록\n{topics_desc}" + f"{extra_info}\n\n" + f"## 원본 콘텐츠 요약\n{content[:2000]}" ) try: - client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) - response = await client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=2048, - system=system_prompt, - messages=[{"role": "user", "content": f"다음 콘텐츠의 꼭지를 추출해줘:\n\n{content}"}], - ) + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream( + "POST", + f"{kei_url}/api/message", + json={ + "message": prompt, + "session_id": "design-agent-overflow", + "mode_hint": "chat", + }, + timeout=None, + ) as response: + if response.status_code != 200: + logger.warning(f"Kei API (overflow) HTTP {response.status_code}") + return None + full_text = await stream_sse_tokens(response) - result_text = response.content[0].text - result = _parse_json(result_text) - - if result and "topics" in result: - return result + if full_text: + result = _parse_json(full_text) + if result and "decision" in result: + logger.info(f"[Kei 넘침 판단] decision={result['decision']}") + return result + logger.warning("[Kei 넘침 판단] JSON 파싱 실패 또는 decision 없음") + return None + logger.warning("Kei API (overflow) 텍스트 추출 실패") return None except Exception as e: - logger.warning(f"Anthropic 직접 호출 실패: {e}") + logger.warning(f"Kei API (overflow) 호출 실패: {e}") return None diff --git a/src/pipeline.py b/src/pipeline.py index 9c7b266..72a3758 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -15,8 +15,8 @@ from typing import Any, AsyncIterator import anthropic -from src.kei_client import classify_content, manual_classify, refine_concepts -from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset +from src.kei_client import classify_content, manual_classify, refine_concepts, call_kei_overflow_judgment +from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset, _downgrade_fallback from src.content_editor import fill_content from src.renderer import render_slide from src.image_utils import get_image_sizes, embed_images @@ -55,6 +55,20 @@ async def generate_slide( analysis = await refine_concepts(content, analysis) logger.info("1단계-B 완료: 컨셉 구체화") + # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증 + from difflib import SequenceMatcher + title = analysis.get("title", "") + topics = analysis.get("topics", []) + if topics: + first_title = topics[0].get("title", "") + similarity = SequenceMatcher(None, title, first_title).ratio() + if similarity > 0.7: + purpose = topics[0].get("purpose", "문제제기") + topics[0]["title"] = f"{purpose}: {topics[0].get('summary', '')[:30]}" + logger.warning( + f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경" + ) + # 이미지 크기 측정 (base_path 있을 때만) image_sizes = get_image_sizes(content, base_path) if image_sizes: @@ -92,7 +106,9 @@ async def generate_slide( yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."} for review_round in range(MAX_REVIEW_ROUNDS): - review_result = await _review_balance(html, layout_concept, content) + review_result = await _review_balance( + html, layout_concept, content, analysis + ) if not review_result or not review_result.get("needs_adjustment"): if review_round == 0: @@ -107,6 +123,31 @@ async def generate_slide( f"조정 필요 — {issues}" ) + # overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei) + overflow_adjs = [ + adj for adj in review_result.get("adjustments", []) + if adj.get("action") == "overflow_detected" + ] + if overflow_adjs: + overflow_context = _build_overflow_context( + layout_concept, overflow_adjs + ) + kei_judgment = await call_kei_overflow_judgment( + overflow_context, content, analysis + ) + + if kei_judgment is None: + logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체") + for page in layout_concept.get("pages", []): + _downgrade_fallback( + page.get("blocks", []), overflow_context + ) + else: + _convert_kei_judgment(review_result, kei_judgment) + logger.info( + f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}" + ) + layout_concept = await _apply_adjustments( layout_concept, review_result, content ) @@ -237,13 +278,15 @@ async def _review_balance( html: str, layout_concept: dict[str, Any], content: str, + analysis: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """5단계: 디자인 팀장이 1차 조립 결과를 재검토한다. + """5단계: 디자인 팀장이 조립 결과를 재검토한다. - HTML 코드 기반으로 구조적 점검: + HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지: - 빈 블록 감지 - 블록 간 채움 비율 불균형 - 이미지/표 크기 적절성 + - 높이 초과 감지 → overflow_detected (Kei 판단 필요) """ try: client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) @@ -259,28 +302,62 @@ async def _review_balance( f"데이터 {text_len}자" ) + # zone 예산 정보 (analysis에서 프리셋 추출) + zone_budget_text = "" + overflow_hint_text = "" + if analysis: + preset_name = select_preset(analysis) + preset = LAYOUT_PRESETS.get(preset_name, {}) + zone_lines = [ + f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)" + for name, z in preset.get("zones", {}).items() + ] + zone_budget_text = ( + "\n\n## zone별 높이 예산\n" + "\n".join(zone_lines) + ) + + # Stage 2에서 감지한 예상 overflow 힌트 + overflow_hint = layout_concept.get("overflow", []) + if overflow_hint: + hint_lines = [ + f"- {o['area']}: 예상 {o['total_px']}px > 예산 {o['budget_px']}px " + f"(+{o['overflow_px']}px 초과)" + for o in overflow_hint + ] + overflow_hint_text = ( + "\n\n## 높이 초과 힌트 (2단계 예상치, 참고용)\n" + + "\n".join(hint_lines) + ) + system = ( - "당신은 디자인 팀장이다. 1차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n" + "당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n" "## 점검 항목\n" "1. 빈 블록: 데이터가 없거나 극히 적은 블록\n" "2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n" "3. 이미지/표: 너무 작거나 큰 것은 없는지\n" "4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n" - "5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\n" + "5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n" + "6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n" + " - 텍스트 양/블록 수를 보고 판단\n" + " - shrink로 해결 가능하면 shrink 사용\n" + " - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n\n" "## 조정 action 설명\n" - "- expand: 텍스트를 늘린다. target_ratio로 얼마나 늘릴지 지정 (예: 1.3 = 30% 증가)\n" - "- shrink: 텍스트를 줄인다. target_ratio로 얼마나 줄일지 지정 (예: 0.7 = 30% 감소)\n" - "- rewrite: 텍스트를 완전히 재작성한다. detail에 재작성 방향 명시.\n\n" + "- expand: 텍스트를 늘린다. target_ratio로 지정 (예: 1.3 = 30% 증가)\n" + "- shrink: 텍스트를 줄인다. target_ratio로 지정 (예: 0.7 = 30% 감소)\n" + "- rewrite: 텍스트를 완전히 재작성한다. detail에 방향 명시.\n" + "- overflow_detected: 높이 초과로 콘텐츠 판단 필요. 해당 zone과 초과 블록을 detail에 명시.\n\n" "## 출력 형식 (JSON만)\n" '{"needs_adjustment": true/false, ' '"issues": ["이슈1", "이슈2"], ' - '"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", ' + '"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", ' '"target_ratio": 1.3, "detail": "..."}]}' ) user_prompt = ( - f"## 1차 조립 HTML\n{html}\n\n" + f"## 조립 HTML\n{html}\n\n" f"## 블록별 데이터 양\n" + "\n".join(block_summary) + + zone_budget_text + + overflow_hint_text + f"\n\n## 레이아웃 구조\n" f"페이지 수: {len(layout_concept.get('pages', []))}\n" f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n" @@ -344,11 +421,105 @@ async def _apply_adjustments( block["reason"] = f"재작성: {detail}" logger.info(f"조정: {area} → rewrite ({detail})") + elif action == "kei_trim": + max_chars = adj.get("max_chars", 200) + if "char_guide" not in block: + block["char_guide"] = {} + for key in block.get("char_guide", {}): + block["char_guide"][key] = min( + block["char_guide"][key], max_chars + ) + if not block["char_guide"]: + block["char_guide"] = {"text": max_chars} + logger.info( + f"조정: {area} → kei_trim max_chars={max_chars} " + f"({detail})" + ) + + elif action == "kei_restructure": + block["detail_target"] = True + if "data" in block: + del block["data"] + block["reason"] = f"재구성: {detail}" + logger.info( + f"조정: {area} → kei_restructure (detail_target)" + ) + # 조정된 가이드로 재편집 layout_concept = await fill_content(content, layout_concept) return layout_concept +def _build_overflow_context( + layout_concept: dict[str, Any], + overflow_adjs: list[dict], +) -> list[dict]: + """Sonnet이 감지한 overflow_detected를 Kei에게 전달할 형태로 변환한다. + + 실제 채워진 블록 데이터(텍스트)를 포함하여 Kei가 판단할 수 있도록 한다. + """ + overflows = [] + for adj in overflow_adjs: + area = adj.get("block_area", "") + # 해당 zone의 블록 정보 + 실제 텍스트 추출 + area_blocks = [] + for page in layout_concept.get("pages", []): + for block in page.get("blocks", []): + if block.get("area") == area: + data = block.get("data", {}) + text_preview = json.dumps(data, ensure_ascii=False)[:300] + area_blocks.append({ + "type": block.get("type", ""), + "purpose": block.get("purpose", ""), + "topic_id": block.get("topic_id"), + "text_preview": text_preview, + }) + overflows.append({ + "area": area, + "detail": adj.get("detail", ""), + "blocks": area_blocks, + }) + return overflows + + +def _convert_kei_judgment( + review_result: dict[str, Any], + kei_judgment: dict[str, Any], +) -> None: + """Kei의 trim/restructure 판단을 review_result.adjustments에 반영한다. + + 기존 overflow_detected 항목을 kei_trim 또는 kei_restructure로 교체. + """ + decision = kei_judgment.get("decision", "") + new_adjs = [] + + for adj in review_result.get("adjustments", []): + if adj.get("action") == "overflow_detected": + # overflow_detected → Kei 판단으로 교체 + if decision == "trim": + for target in kei_judgment.get("trim_targets", []): + new_adjs.append({ + "block_area": adj.get("block_area", ""), + "action": "kei_trim", + "max_chars": target.get("max_chars", 200), + "topic_id": target.get("topic_id"), + "detail": target.get("reason", ""), + }) + elif decision == "restructure": + for tid in kei_judgment.get("detail_topics", []): + new_adjs.append({ + "block_area": adj.get("block_area", ""), + "action": "kei_restructure", + "topic_id": tid, + "detail": kei_judgment.get("reason", ""), + }) + else: + # 기존 expand/shrink/rewrite는 그대로 유지 + new_adjs.append(adj) + + review_result["adjustments"] = new_adjs + + def _parse_json(text: str) -> dict[str, Any] | None: """텍스트에서 JSON을 추출한다.""" patterns = [ diff --git a/src/sse_utils.py b/src/sse_utils.py new file mode 100644 index 0000000..2b7d1de --- /dev/null +++ b/src/sse_utils.py @@ -0,0 +1,50 @@ +"""SSE 스트리밍 공통 유틸리티. + +persona_agent의 SSE 이벤트를 수신하여 토큰을 수집한다. +kei_client, content_editor, design_director에서 공통 사용. +""" +from __future__ import annotations + +import json +import logging + +import httpx + +logger = logging.getLogger(__name__) + + +async def stream_sse_tokens(response: httpx.Response) -> str: + """SSE 스트리밍 응답에서 토큰을 실시간 수집한다. + + persona_agent의 SSE 이벤트: + - token: 텍스트 토큰 수집 + - done: 완료, 중단 + - error: 에러, 즉시 중단 + - planning/planning_done/research_progress/warning: 스킵 + """ + tokens: list[str] = [] + event_type = "" + + async for line in response.aiter_lines(): + line = line.strip() + if not line: + event_type = "" + continue + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data = line[5:].strip() + if event_type == "token" and data: + try: + token = json.loads(data) + if isinstance(token, str): + tokens.append(token) + except json.JSONDecodeError: + tokens.append(data) + elif event_type == "done": + break + elif event_type == "error": + logger.warning(f"Kei API SSE 에러: {data}") + break + + return "".join(tokens) diff --git a/templates/blocks/INDEX.md b/templates/blocks/INDEX.md index 6f6d616..664af8a 100644 --- a/templates/blocks/INDEX.md +++ b/templates/blocks/INDEX.md @@ -1,4 +1,4 @@ -# 블록 라이브러리 인덱스 (46개) +# 블록 라이브러리 인덱스 (38개) 디자인 팀장이 콘텐츠에 맞는 블록을 선택할 때 참조하는 라이브러리. 각 카테고리 안에 변형이 여러 개 있으며, 콘텐츠 성격에 따라 적절한 변형을 선택한다. @@ -19,12 +19,11 @@ --- -## 📁 cards/ (10개) — 카드 계열 +## 📁 cards/ (9개) — 카드 계열 | 파일 | 설명 | 언제 사용 | |------|------|---------| | `card-image-3col.html` | 이미지(160px) + 색상 제목 + 영문 + 불릿 (3열) | 단계별 설명에 이미지 핵심 | -| `card-text-grid.html` | 파란 액센트 + 제목 + 뱃지 + 설명 + 출처 (2~4열) | 용어 정의, 텍스트만 나열 | | `card-dark-overlay.html` | 다크 이미지 배경 + 흰 제목 + 짧은 설명 (3~5열) | 키워드 시각 강조, 임팩트 | | `card-tag-image.html` | 색상 태그 라벨 + 이미지 + 제목 + 설명 (3열) | 카테고리별 분류 (제조/건축/토목) | | `card-icon-desc.html` | 큰 이모지 아이콘 + 제목 + 설명 (2~4열) | 기능/특성/장점 아이콘 나열 | @@ -46,7 +45,7 @@ --- -## 📁 visuals/ (10개) — 시각 요소 (**SVG**) +## 📁 visuals/ (6개) — 시각 요소 (**SVG**) **SVG 노하우:** - `` = 원 좌표와 같은 공간 → 위치 100% 정확 @@ -63,21 +62,15 @@ | `process-horizontal.html` | 파란 번호 원 + 카드 + → 화살표 (가로) | 논리적 프로세스 흐름 | | `flow-arrow-horizontal.html` | 색상 캡슐 + 화살표 (SVG, 컴팩트) | 기술 발전/전환 흐름 간결하게 | | `keyword-circle-row.html` | SVG 원형 안 큰 글자 + 라벨 + 설명 | 약어 풀이 (G-S-I-M) | -| `layer-diagram.html` | SVG 겹친 사다리꼴 레이어 (3D) | 기술 스택/계층 구조 | -| `timeline-vertical.html` | 세로 선 + SVG 마커 + 연도+제목+설명 | 연혁, 로드맵 (4개+ 이벤트) | -| `timeline-horizontal.html` | SVG 가로 선 + 마커 + 연도+제목 | 짧은 일정 (3~5개, 컴팩트) | -| `pyramid-hierarchy.html` | SVG 위→아래 넓어지는 사각형 | 위계, 우선순위 (좁은→넓은) | --- -## 📁 emphasis/ (13개) — 강조, 인용, 결론 +## 📁 emphasis/ (10개) — 강조, 인용, 결론 | 파일 | 설명 | 언제 사용 | |------|------|---------| -| `quote-left-border.html` | 좌측 빨간 라인 + 연한 배경 + 인용+출처 | 짧은 인용, 문제 제기 | | `quote-big-mark.html` | ❝❞ 큰따옴표 장식 + 인용+출처 | 임팩트 인용, 핵심 발언 | | `quote-question.html` | 파란 배경+테두리 + 큰 질문 텍스트 | 독자에게 질문, 전환점 | -| `conclusion-accent-bar.html` | 회색 배경 + 좌측 파란 라인 + 결론 | 페이지 하단 핵심 한 줄 | | `comparison-2col.html` | 좌 파란 vs 우 빨간 헤더 + 본문 | A vs B 직접 비교 | | `banner-gradient.html` | 파란 그라데이션 배너 + 중앙 흰 텍스트 | 섹션 구분, 핵심 선언 | | `dark-bullet-list.html` | 짙은 남색 배경 + 파란 제목 + 흰 불릿 | 핵심 포인트 강조 (무게감) | @@ -86,7 +79,6 @@ | `callout-warning.html` | 빨간 배경+테두리 + 아이콘 + 제목+설명 | 문제점, 주의, 잘못된 접근 | | `tab-label-row.html` | 가로 탭 버튼 (선택됨=색상, 나머지=회색) | 카테고리 전환/분류 표시 | | `divider-text.html` | 좌우 회색 선 + 중앙 텍스트 | 가벼운 섹션 구분, 휴식점 | -| `details-block.html` | `
/` 접기/펼치기 | 상세 콘텐츠, 자세히보기 | --- diff --git a/templates/catalog.yaml b/templates/catalog.yaml index 5ecead9..af2fc3d 100644 --- a/templates/catalog.yaml +++ b/templates/catalog.yaml @@ -99,7 +99,7 @@ blocks: 이미지가 핵심인 경우. ' - not_for: '이미지 없이 텍스트만 → card-text-grid 사용. 키워드+짧은 설명만 강조 → card-dark-overlay 사용. + not_for: '이미지 없이 텍스트만 → card-icon-desc 사용. 키워드+짧은 설명만 강조 → card-dark-overlay 사용. 2개 비교 → compare-pill-pair + comparison-table 조합 사용. ' @@ -116,7 +116,7 @@ blocks: 나열. ' - not_for: '긴 설명(3줄 이상) → card-text-grid 사용. 이미지가 핵심(크게 보여야 함) → card-image-3col 사용. + not_for: '긴 설명(3줄 이상) → card-icon-desc 사용. 이미지가 핵심(크게 보여야 함) → card-image-3col 사용. ' slots: @@ -131,7 +131,7 @@ blocks: when: '카테고리별 분류가 핵심일 때. 태그로 구분. 예: 제조업(파란) / 건축(초록) / 인프라·토목(빨간) ' - not_for: '태그 불필요 → card-image-3col 사용. 이미지 없음 → card-text-grid 사용. + not_for: '태그 불필요 → card-image-3col 사용. 이미지 없음 → card-icon-desc 사용. ' slots: @@ -207,7 +207,7 @@ blocks: when: 'KPI, 성과 수치, 목표 달성률, 비용 절감율. 예: 30% 절감 / 40% 감소 / 220명+ 인력 ' - not_for: '숫자가 아닌 텍스트 → card-text-grid 사용. + not_for: '숫자가 아닌 텍스트 → card-icon-desc 사용. ' slots: @@ -223,7 +223,7 @@ blocks: 4.교육 ' - not_for: '순서 없음 → card-text-grid 사용. 이미지 포함 단계 → card-step-vertical 사용. 가로 흐름 → + not_for: '순서 없음 → card-icon-desc 사용. 이미지 포함 단계 → card-step-vertical 사용. 가로 흐름 → process-horizontal 사용. ' @@ -308,7 +308,7 @@ blocks: when: '섹션 전환점 키워드 강조. 아래에 카드/표 올 때 주제 선언. ' - not_for: '본문 텍스트 → topic-header 계열. 결론 → conclusion-accent-bar. + not_for: '본문 텍스트 → topic-header 계열. 결론 → banner-gradient. ' slots: @@ -342,7 +342,7 @@ blocks: when: '논리적 순서를 가로로 (1→2→3→4). 프로세스 흐름. ' - not_for: '시간 기반(연도) → timeline 사용. 세로 나열 → card-numbered 사용. + not_for: '시간 기반(연도) → process-horizontal 사용. 세로 나열 → card-numbered 사용. ' slots: @@ -373,7 +373,7 @@ blocks: + M(Model) ' - not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-text-grid 사용. + not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-icon-desc 사용. ' slots: @@ -388,7 +388,7 @@ blocks: when: '임팩트 있는 문제 제기. 시각적으로 인용임을 명확히. ' - not_for: '짧은 인용 → quote-left-border. 질문 → quote-question. + not_for: '짧은 인용(1~2줄) → quote-question. 질문 형태 → quote-question. ' slots: @@ -404,7 +404,7 @@ blocks: when: '독자에게 질문. 문제 인식 유도, 전환점. 예: ''지금의 방식으로도 가능할까?'' ' - not_for: '인용(출처) → quote-left-border. 결론 → conclusion-accent-bar. + not_for: '인용(출처) → quote-big-mark. 결론 → banner-gradient. ' slots: @@ -440,7 +440,7 @@ blocks: when: '섹션 구분, 핵심 선언, 강조 문구. ' - not_for: '하단 결론 → conclusion-accent-bar. 인용 → quote 계열. + not_for: '하단 결론 → banner-gradient. 인용 → quote 계열. ' slots: @@ -456,7 +456,7 @@ blocks: when: '핵심 포인트를 짙은 배경 위에 강조. 시각적 무게감. ' - not_for: '밝은 배경 → card-text-grid 또는 card-numbered. + not_for: '밝은 배경 → card-icon-desc 또는 card-numbered. ' slots: @@ -537,7 +537,7 @@ blocks: when: '섹션 구분, 주제 전환점에 가벼운 구분. 예: ── 핵심 요약 ── ' - not_for: '강한 구분 → section-header-bar. 결론 → conclusion-accent-bar. + not_for: '강한 구분 → section-header-bar. 결론 → banner-gradient. ' slots: