문서 정리: Phase 히스토리 md를 docs/history/로 이동 + 오래된 테스트/에셋 정리
- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동 - docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체) - docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리 - docs/test-*.html 등 초기 테스트 파일 정리 - 참고 페이지/ 스크린샷 정리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
854
docs/history/ARCHITECTURE-PHASE-T.md
Normal file
854
docs/history/ARCHITECTURE-PHASE-T.md
Normal file
@@ -0,0 +1,854 @@
|
||||
# Design Agent Architecture — Phase T
|
||||
|
||||
> MDX 원본 문서 → 고정 크기 HTML 슬라이드(1280×720px) 자동 생성 파이프라인
|
||||
> **폰트 위계가 먼저, 컨테이너가 따라간다** — 텍스트 보존 · 폰트 위계 강제 · 디자인 요소 크기를 수학적으로 역산
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 설계 원칙
|
||||
|
||||
### 1.1 AI vs 코드 역할 분리
|
||||
|
||||
| 역할 | 담당 | 해당 Stage |
|
||||
|---|---|---|
|
||||
| 콘텐츠 판단 · 분류 | AI (Kei Persona API / Opus) | 1A, 1B |
|
||||
| 폰트 위계 확정 + 컨테이너 비율 역산 | 코드 (결정론적 수학) | 1.5a |
|
||||
| 블록 선택 · 변형 결정 | 코드 (키워드 매칭 + 룩업 테이블) | 1.7 |
|
||||
| 블록 schema 기반 디자인 예산 역산 | 코드 (결정론적 수학) | 1.5b |
|
||||
| HTML 생성 | AI (Claude Sonnet 4) | 2 |
|
||||
| 텍스트·구조 검증 (L1~L3) | 코드 (kiwipiepy + regex) | 2 직후 |
|
||||
| 실측 렌더링 (L4) | Selenium (headless Chrome) | 3 직후 |
|
||||
| 시각 품질 평가 (L5) | AI (Opus Vision) | 4 |
|
||||
| HTML 조립 · 서빙 | 코드 | 3, 5 |
|
||||
|
||||
AI가 공간을 볼 수 없는 근본적 한계를 코드(수학적 예산 역산)로 보완하는 구조.
|
||||
LLM이 참고 HTML 구조를 70~90% 복사하는 경향을 장점으로 활용 — "디자인 레퍼런스" 프레이밍.
|
||||
|
||||
### 1.2 폰트 위계 (Phase T 핵심 — 이것이 모든 계산의 출발점)
|
||||
|
||||
Phase S에서 폰트 크기가 중요도와 완전히 역전됨 (sidebar 14px > key-msg 11px).
|
||||
Phase T는 **위계를 먼저 확정하고, 컨테이너가 위계에 맞춰지는** 방향으로 전환.
|
||||
|
||||
| 영역 | 중요도 | 폰트 범위 | 강제 규칙 |
|
||||
|------|--------|----------|----------|
|
||||
| 핵심 (key-msg) | 1위 | **14px** bold | 무조건 한 줄, 슬라이드에서 가장 큰 폰트 |
|
||||
| 본문 (core) | 2위 | **12px** | 본문 텍스트 기본 |
|
||||
| 배경 (bg) | 3위 | **10-12px** | 텍스트 양에 따라 범위 내 조정 |
|
||||
| 첨부 (sidebar) | 4위 | **9-11px** | 참고 자료, 가장 작아도 됨 |
|
||||
|
||||
**검증 기준:** `font_size(핵심) > font_size(본문) ≥ font_size(배경) > font_size(첨부)` — 위반 시 에러.
|
||||
|
||||
### 1.3 파이프라인 운영 패턴
|
||||
|
||||
#### 누적 컨텍스트 객체 (Pydantic BaseModel)
|
||||
|
||||
각 Stage가 독립 JSON을 읽고 쓰는 대신, `PipelineContext` 하나가 파이프라인을 따라가며 점진적으로 확장. T-0 조사 결과 **Pydantic BaseModel** 채택 (dataclass 아님) — `model_dump_json()` 직렬화, `validate_assignment=True` 타입 검증.
|
||||
|
||||
```python
|
||||
context.normalized.clean_text # Stage 0
|
||||
context.normalized.title # Stage 0
|
||||
context.normalized.images # Stage 0
|
||||
context.normalized.popups # Stage 0
|
||||
context.normalized.tables # Stage 0
|
||||
context.analysis.core_message # Stage 1A
|
||||
context.analysis.topics[0].source_hint # Stage 1A
|
||||
context.analysis.page_structure # Stage 1A
|
||||
context.topics[0].relation_type # Stage 1B
|
||||
context.topics[0].expression_hint # Stage 1B
|
||||
context.topics[0].source_data # Stage 1B
|
||||
context.font_hierarchy # Stage 1.5a
|
||||
context.container_ratio # Stage 1.5a (동적 body:sidebar 비율)
|
||||
context.containers["본심"].text_budget # Stage 1.5a
|
||||
context.references["본심"].block_id # Stage 1.7
|
||||
context.references["본심"].design_reference_html # Stage 1.7
|
||||
context.containers["본심"].design_budget # Stage 1.5b (블록 선택 후 재계산)
|
||||
context.generated_html # Stage 2
|
||||
context.rendered_html # Stage 3
|
||||
context.measurement # Stage 4
|
||||
context.quality_score # Stage 4
|
||||
```
|
||||
|
||||
#### 각 Stage 공통 실행 패턴
|
||||
|
||||
```python
|
||||
async def run_stage(stage_fn, context, stage_name, max_retries=1):
|
||||
for attempt in range(max_retries + 1):
|
||||
result = await stage_fn(context)
|
||||
errors = result.get("_errors", [])
|
||||
if not errors:
|
||||
# Pydantic: model_copy(update=...) 사용
|
||||
context = context.model_copy(update=result)
|
||||
context.save_snapshot(stage_name)
|
||||
return context
|
||||
context.errors.append({"stage": stage_name, "attempt": attempt, "errors": errors})
|
||||
if attempt < max_retries:
|
||||
context.retry_feedback = build_retry_feedback(stage_name, errors)
|
||||
raise StageFailure(stage_name, errors)
|
||||
```
|
||||
|
||||
#### 에러 3등급 분류
|
||||
|
||||
| 등급 | 의미 | 대응 |
|
||||
|------|------|------|
|
||||
| **FATAL** | 복구 불가 (원본 문제, JSON 파싱 실패) | 파이프라인 중단 |
|
||||
| **RETRYABLE** | AI 재시도로 해결 가능 (분류 오류, 누락) | Self-Refine 피드백 포함 재요청 (최대 2회) |
|
||||
| **ADJUSTABLE** | 코드로 자동 조정 가능 (높이 부족, 비율 초과) | 자동 조정 후 경고 기록 |
|
||||
|
||||
#### 스냅샷 저장
|
||||
|
||||
`data/runs/{run_id}/step{N}_context.json` — run_id는 `YYYYMMDD_HHMMSS` timestamp.
|
||||
Pydantic `model_dump_json()`으로 직렬화. `diff step1a_context.json step1b_context.json`으로 추적.
|
||||
|
||||
---
|
||||
|
||||
## 2. 파이프라인 (11 Stage)
|
||||
|
||||
### Stage 0: MDX 표준화
|
||||
|
||||
- **담당:** 코드
|
||||
- **신규 파일:** `src/mdx_normalizer.py`
|
||||
- **라이브러리:** `python-frontmatter` + `markdown-it-py` + `mdit-py-plugins` (총 ~1MB)
|
||||
- **입력:** 원본 MDX 텍스트
|
||||
- **처리 (4-Layer 파서):**
|
||||
- **Layer 1:** `python-frontmatter.parse()` → `(metadata_dict, body_str)` 분리. title 추출.
|
||||
- **Layer 2:** 코드블록 보호 (backtick 10→3 순서로 fenced block → placeholder) → MDX 전용 패턴 처리:
|
||||
- Astro `:::directive` → `[핵심요약]...[/핵심요약]` 마커
|
||||
- `<details><summary>제목</summary>내용</details>` → popups[] 추출
|
||||
- JSX `style={{}}`, `import/export` 제거
|
||||
- **Layer 3:** `markdown-it-py` AST 파싱 (`js-default` 프리셋, table 기본 포함):
|
||||
- heading 토큰 → 섹션 구조 추출 (tag, level, content, source line)
|
||||
- image 토큰 → images[] 추출 (alt, src)
|
||||
- table 토큰 → tables[] 추출 (header, rows)
|
||||
- 코드블록 placeholder 복원
|
||||
- **Layer 4:** 텍스트 정리 — 남은 HTML 태그 제거, 빈 줄 정리, 최종 clean_text
|
||||
- **출력:**
|
||||
```python
|
||||
{
|
||||
"clean_text": str, # 정규화된 순수 텍스트
|
||||
"title": str, # frontmatter 제목
|
||||
"images": [{"alt": str, "path": str}],
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"header": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}] # ## 기준 섹션 분리
|
||||
}
|
||||
```
|
||||
- **검증:**
|
||||
- clean_text 비어있지 않음
|
||||
- `##` 섹션 최소 1개
|
||||
- 원본 대비 30% 이상 텍스트 보존 (과도한 제거 방지)
|
||||
- images[] 수 = 원본 `![` 패턴 수
|
||||
- popups[] 수 = 원본 `<details>` 패턴 수
|
||||
- **주의:** 기존 `normalize_mdx()`의 `r"^## \d+\.\s*"` → `r"^## \d+\.\s+"` 수정 (공백 1개 이상 필수)
|
||||
- **저장:** `context.normalized.*`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1A: Kei 꼭지 추출
|
||||
|
||||
- **담당:** AI (Kei Persona API, localhost:8000, Opus — SSE 스트리밍)
|
||||
- **입력:** `context.normalized.clean_text` (Stage 0에서 정규화된 텍스트)
|
||||
- **처리:** Kei가 콘텐츠를 읽고 꼭지 분류 + 스토리라인 설계
|
||||
- **출력:**
|
||||
- `topics[]` — id, title, purpose, role, layer, weight, **source_hint** (원본 MDX 섹션 참조)
|
||||
- `page_structure` — { "본심": {topic_ids, weight}, "배경": {...}, "첨부": {...}, "결론": {...} }
|
||||
- `core_message` — 슬라이드 핵심 메시지 한 줄
|
||||
- **검증 (Pydantic + 코드 대조):**
|
||||
- **형식:** weight 합 0.9~1.1 범위, 본심 weight ≥ 0.3, 필수 필드 존재, topics > 0
|
||||
- **내용 대조:** 원본 `##` 섹션 수 vs topic 수 비교 — 차이가 크면 분류 오류 가능성
|
||||
- **내용 대조:** topic summary 키워드가 원본 해당 섹션에 실제 존재하는지 (kiwipiepy)
|
||||
- 실패 시 RETRYABLE → Self-Refine 피드백 포함 재요청 (최대 2회)
|
||||
- **저장:** `context.analysis`, `context.topics`, `context.page_structure`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1B: 컨셉 구체화
|
||||
|
||||
- **담당:** AI (Kei Persona API, Opus — SSE 스트리밍)
|
||||
- **입력:** `context.normalized.clean_text` + `context.topics` (Stage 1A 결과)
|
||||
- **처리:** 각 꼭지에 관계 유형, 표현 힌트, 원본 텍스트 참조 부여
|
||||
- **출력:** topics에 아래 필드 병합
|
||||
- `relation_type` — **7개 enum:** hierarchy / cause_effect / comparison / sequence / definition / inclusion / **none**
|
||||
- `expression_hint` — 디자인 방향 힌트 (3문장 구조: 관계 선언 + 콘텐츠 설명 + 시각 지침)
|
||||
- `source_data` — 원본 텍스트 참조
|
||||
- **검증 (Pydantic + 코드 대조 + 모순 탐지):**
|
||||
- **형식:** relation_type이 7개 enum 중 하나, expression_hint 비어있지 않음, source_data 비어있지 않음
|
||||
- **모순 결정 테이블:**
|
||||
|
||||
| purpose | 모순인 relation_type | 이유 |
|
||||
|---------|---------------------|------|
|
||||
| 결론강조 | comparison, sequence | 결론은 비교나 순서가 아님 |
|
||||
| 문제제기 | sequence, definition | 문제제기는 순서 나열이나 정의가 아님 |
|
||||
| 용어정의 | hierarchy, cause_effect | 정의 나열은 상하위나 인과가 아님 |
|
||||
| 구조시각화 | none | 시각화할 관계가 없으면 구조시각화가 아님 |
|
||||
|
||||
- **source_data 원본 대조:** source_data 키워드가 원본 clean_text에 실제 존재하는지 (kiwipiepy). 없는 출처 감지 → 할루시네이션
|
||||
- **relation_type 원본 대조:** 한국어 관계 표현 패턴으로 검증
|
||||
|
||||
| relation_type | 원본에 있어야 하는 패턴 (일부) |
|
||||
|---------------|-------------------------------|
|
||||
| comparison | vs, 반면, 차이점, 에 비해, 와 달리, 상이, 구분 |
|
||||
| sequence | →, 이후, 단계, 먼저, 점진적, 과정, 를 거쳐 |
|
||||
| hierarchy | 상위, 하위, 속하, 범주, 구성요소, 체계, 계층 |
|
||||
| inclusion | 포함, 융합, 통합, 결합, 내포, 포괄, 연계 |
|
||||
| cause_effect | 때문에, 따라서, 결과, 로 인해, 초래, 야기, 기인 |
|
||||
| definition | 이란, 정의, 의미, 을 말한다, 라 함은, 용어 |
|
||||
|
||||
- 실패 시 RETRYABLE → 모순/불일치 topic만 피드백 포함 재요청 (최대 2회)
|
||||
- **저장:** `context.topics[].relation_type`, `.expression_hint`, `.source_data`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.5a: 폰트 위계 확정 + 컨테이너 비율 역산
|
||||
|
||||
- **담당:** 코드 (AI 아님, 결정론적 수학)
|
||||
- **입력:** page_structure weight + 각 영역의 source_data 텍스트 양
|
||||
- **핵심 원칙:** **폰트가 먼저, 컨테이너가 따라간다**
|
||||
|
||||
#### (1) 폰트 위계에서 필요 공간 계산
|
||||
|
||||
```python
|
||||
FONT_HIERARCHY = {
|
||||
"핵심": {"min": 14, "max": 14, "weight": "bold"},
|
||||
"본심": {"min": 12, "max": 12},
|
||||
"배경": {"min": 10, "max": 12},
|
||||
"첨부": {"min": 9, "max": 11},
|
||||
}
|
||||
|
||||
def calculate_required_space(role, content, font_size):
|
||||
"""이 폰트 크기로 이 텍스트를 넣으려면 몇 px 필요한가?"""
|
||||
char_width_px = font_size * 0.947 # Pretendard 한글 실측 비율
|
||||
line_height_px = font_size * 1.5 # 본문 기준
|
||||
chars_per_line = available_width // char_width_px
|
||||
total_lines = len(content) // chars_per_line
|
||||
required_height = total_lines * line_height_px + padding
|
||||
return required_height
|
||||
```
|
||||
|
||||
#### (2) 동적 body:sidebar 비율 역산
|
||||
|
||||
고정 65:35가 아니라 텍스트 양에서 역산:
|
||||
|
||||
```python
|
||||
def calculate_container_ratio(roles_text_volume, font_hierarchy):
|
||||
"""폰트 위계를 지키면서 모든 텍스트가 들어가는 비율을 역산"""
|
||||
# 1. 각 역할의 위계 기준 폰트로 필요 공간 계산
|
||||
sidebar_need = calculate_required_space("첨부", sidebar_text, font_hierarchy["첨부"]["max"])
|
||||
body_need = sum(calculate_required_space(r, t, font_hierarchy[r]["max"])
|
||||
for r, t in body_roles)
|
||||
|
||||
# 2. sidebar 충전율로 비율 결정
|
||||
sidebar_capacity_at_35 = estimate_capacity(slide_width * 0.35, font_hierarchy["첨부"]["max"])
|
||||
fill_rate = len(sidebar_text) / sidebar_capacity_at_35
|
||||
|
||||
if fill_rate < 0.5:
|
||||
ratio = (72, 28) # sidebar 텍스트 적음 → body 확대
|
||||
elif fill_rate < 0.8:
|
||||
ratio = (68, 32) # 보통
|
||||
else:
|
||||
ratio = (65, 35) # 현재 유지
|
||||
|
||||
return ratio # (body_pct, sidebar_pct)
|
||||
```
|
||||
|
||||
#### (3) 텍스트 예산 계산
|
||||
|
||||
비율 확정 후, 각 영역의 텍스트 예산:
|
||||
|
||||
```python
|
||||
def calculate_text_budget(container, content, font_size):
|
||||
char_width_px = font_size * 0.947
|
||||
line_height_px = font_size * 1.5
|
||||
inner_width = container.width_px - padding * 2
|
||||
inner_height = container.height_px - padding * 2
|
||||
|
||||
chars_per_line = int(inner_width / char_width_px)
|
||||
max_lines = int(inner_height / line_height_px)
|
||||
max_chars = chars_per_line * max_lines
|
||||
|
||||
source_chars = len(content)
|
||||
needs_compression = source_chars > max_chars
|
||||
|
||||
return TextBudget(
|
||||
font_size=font_size,
|
||||
chars_per_line=chars_per_line,
|
||||
max_lines=max_lines,
|
||||
max_chars=max_chars,
|
||||
source_chars=source_chars,
|
||||
needs_compression=needs_compression,
|
||||
)
|
||||
```
|
||||
|
||||
#### (4) 다단 레이아웃 판단
|
||||
|
||||
위계 범위 내 최소 폰트로도 텍스트가 안 들어가면 구조 변경:
|
||||
|
||||
```
|
||||
1. 위계 기준 폰트(max)로 수용량 계산
|
||||
2. 텍스트 양 > 수용량 → 폰트 1px 축소 (위계 min까지)
|
||||
3. 최소 폰트로도 불가 → 레이아웃 변경 (1단→2단)
|
||||
4. 2단으로도 불가 → 비율 조정 (sidebar 축소 → body 확대)
|
||||
5. 비율 조정으로도 불가 → 텍스트 편집 필요 경고 (context.warnings에 기록)
|
||||
```
|
||||
|
||||
- **검증:** height_px 합 ≤ 전체 높이, 폰트 위계 유지, 음수 없음
|
||||
- **저장:** `context.font_hierarchy`, `context.container_ratio`, `context.containers[].text_budget`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.7: 참고 블록 선택 + 변형 결정
|
||||
|
||||
- **담당:** 코드 (키워드 매칭 + 룩업 테이블, AI 아님)
|
||||
- **입력:** 1B의 relation_type + expression_hint + 1.5a의 컨테이너 스펙 + catalog.yaml
|
||||
- **처리 4단계:**
|
||||
|
||||
#### (1) relation_type → 블록 후보 (1차 필터)
|
||||
|
||||
catalog.yaml의 `relation_types` 필드로 필터:
|
||||
|
||||
```python
|
||||
candidates = [b for b in catalog.blocks
|
||||
if relation_type in b.relation_types or not b.relation_types]
|
||||
```
|
||||
|
||||
#### (2) expression_hint → 블록 세분화 (2차 필터 — 키워드 포함 여부)
|
||||
|
||||
expression_hint는 긴 문장이므로 **정확한 문자열 매칭이 아니라 키워드 포함(substring) 매칭**:
|
||||
|
||||
```python
|
||||
VISUAL_TYPE_KEYWORDS = {
|
||||
"인과": {"keywords": ["인과", "현상->결과", "야기", "원인"], "blocks": ["callout-warning", "dark-bullet-list"]},
|
||||
"나열_병렬": {"keywords": ["독립적 나열", "병렬 나열", "개별 증거"], "blocks": ["dark-bullet-list", "card-icon-desc"]},
|
||||
"나열_정의": {"keywords": ["독립적 정의", "용어", "참조용"], "blocks": ["card-numbered"]},
|
||||
"포함_계층": {"keywords": ["상위-하위", "포함 관계", "계층적"], "blocks": ["venn-diagram", "keyword-circle-row"]},
|
||||
"강조_결론": {"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조"], "blocks": ["banner-gradient", "quote-big-mark"]},
|
||||
"비교": {"keywords": ["대등 비교", "좌우 대조", "vs"], "blocks": ["compare-2col-split", "compare-3col-badge"]},
|
||||
"순서": {"keywords": ["시간 순서", "단계별", "A->B->C"], "blocks": ["flow-arrow-horizontal", "process-horizontal"]},
|
||||
}
|
||||
|
||||
def match_visual_type(expression_hint: str) -> str:
|
||||
"""expression_hint에서 키워드를 찾아 시각적 유형 반환"""
|
||||
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
|
||||
if any(kw in expression_hint for kw in spec["keywords"]):
|
||||
return vtype
|
||||
return "default"
|
||||
```
|
||||
|
||||
시각 매핑 근거 (Gestalt 원칙):
|
||||
- 폐합(Closure) → hierarchy/inclusion → 원형(벤 다이어그램)
|
||||
- 근접(Proximity) → comparison → 좌우 표/비교
|
||||
- 연속(Continuity) → sequence → 화살표 흐름
|
||||
- 유사(Similarity) → definition → 동일 형태 카드 반복
|
||||
- PPTAgent(EMNLP 2025): "참고 기반 생성"의 효과를 학술 입증
|
||||
|
||||
#### (3) 컨테이너 크기 적합성 검사
|
||||
|
||||
```python
|
||||
candidates = [b for b in candidates
|
||||
if b.min_height_px <= container.height_px]
|
||||
```
|
||||
|
||||
#### (4) 블록 변형(variant) + 레이아웃 자동 선택
|
||||
|
||||
```python
|
||||
def select_block_variant(block, container, content):
|
||||
if not block.variants or len(block.variants) <= 1:
|
||||
return block.id, "default"
|
||||
|
||||
for variant in block.variants:
|
||||
if variant.id == "compact" and container.height_px < 150:
|
||||
return block.id, "compact"
|
||||
if variant.id == "wide" and container_ratio[0] >= 70: # body 70% 이상
|
||||
return block.id, "wide"
|
||||
|
||||
return block.id, "default"
|
||||
```
|
||||
|
||||
#### (5) fallback 정의
|
||||
|
||||
모든 필터를 통과하는 후보가 없을 때의 카테고리별 기본 블록:
|
||||
|
||||
| 카테고리 | fallback 블록 | 이유 |
|
||||
|----------|-------------|------|
|
||||
| cards | card-numbered | 가장 범용, compact~xlarge 대응 |
|
||||
| emphasis | dark-bullet-list | 텍스트 중심, 높이 유연 |
|
||||
| visuals | venn-diagram | N개 자동 배치 가능 |
|
||||
| tables | compare-2col-split | 가장 기본적 비교 |
|
||||
| media | image-side-text | 텍스트+이미지 조합 |
|
||||
|
||||
#### 디자인 레퍼런스 HTML 생성
|
||||
|
||||
Jinja 변수를 샘플 데이터로 치환한 완성된 HTML + 구조 의도 주석.
|
||||
LLM이 이 구조를 70~90% 복사 → 레이아웃을 "발명"하지 않고 검증된 구조를 따름.
|
||||
|
||||
```python
|
||||
def generate_design_reference(block, variant, catalog_entry):
|
||||
template = load_template(block.template)
|
||||
sample_data = build_sample_data(catalog_entry.slots)
|
||||
rendered = template.render(**sample_data)
|
||||
|
||||
# 구조 의도 주석 추가 (LLM이 의도를 정확히 파악)
|
||||
annotated = f"<!-- {block.id}: {catalog_entry.visual} -->\n"
|
||||
if catalog_entry.get("visual_diff"):
|
||||
annotated += f"<!-- 차별점: {catalog_entry.visual_diff} -->\n"
|
||||
annotated += rendered
|
||||
|
||||
return annotated
|
||||
```
|
||||
|
||||
- **출력:**
|
||||
|
||||
```json
|
||||
{
|
||||
"block_id": "dark-bullet-list",
|
||||
"variant": "default",
|
||||
"visual_type": "인과",
|
||||
"schema": {
|
||||
"title": {"max_lines": 1, "font_size": 16, "max_chars": 30},
|
||||
"bullet_item": {"max_lines": 1, "font_size": 14, "max_chars": 86},
|
||||
"max_bullets": 5
|
||||
},
|
||||
"design_reference_html": "<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->\n<div ...>..."
|
||||
}
|
||||
```
|
||||
|
||||
- **검증:** 선택된 블록이 catalog.yaml에 실제 존재, min_height_px ≤ container.height_px
|
||||
- **저장:** `context.references["본심"].*`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.5b: 디자인 예산 재계산 (블록 선택 후)
|
||||
|
||||
- **담당:** 코드 (AI 아님)
|
||||
- **입력:** Stage 1.7에서 선택된 블록의 schema + Stage 1.5a의 컨테이너 스펙
|
||||
- **목적:** 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. **텍스트를 줄이는 것이 아니라 도형·이미지·CSS 요소의 크기를 맞추는 방향.**
|
||||
|
||||
```python
|
||||
def calculate_design_budget(container, text_budget, block_schema):
|
||||
# 블록 schema에서 텍스트 슬롯별 높이 합산
|
||||
text_height = 0
|
||||
for slot_name, spec in block_schema.items():
|
||||
if slot_name.startswith("max_"):
|
||||
continue
|
||||
slot_lines = spec.get("max_lines", 1)
|
||||
slot_font = spec.get("font_size", 14)
|
||||
text_height += slot_lines * (slot_font * 1.6)
|
||||
|
||||
remaining_height = container.height_px - text_height - padding
|
||||
remaining_width = container.width_px - padding
|
||||
|
||||
return DesignBudget(
|
||||
available_height_px=remaining_height,
|
||||
available_width_px=remaining_width,
|
||||
max_circle_diameter=min(remaining_height, remaining_width) - 4,
|
||||
max_img_width=remaining_width * 0.4,
|
||||
max_img_height=remaining_height,
|
||||
fits=remaining_height >= 0,
|
||||
)
|
||||
```
|
||||
|
||||
- **검증:** available_height_px ≥ 0 (음수 = 블록이 컨테이너에 안 맞음 → Stage 1.7 재선택 또는 ADJUSTABLE)
|
||||
- **저장:** `context.containers["본심"].design_budget`
|
||||
|
||||
---
|
||||
|
||||
### Stage 2: HTML 생성
|
||||
|
||||
- **담당:** AI (Claude Sonnet 4, Anthropic API 직접, 현재 모델: `claude-sonnet-4-20250514`)
|
||||
- **입력:** 원본 텍스트 + 누적 컨텍스트 전체
|
||||
- **처리:** 영역별(배경/본심/첨부/결론) **각각 개별 호출**로 HTML 생성
|
||||
|
||||
프롬프트 구성 — 모든 수치를 **구체적으로** 전달 (Phase S 교훈: 추상적 프롬프트는 실패):
|
||||
|
||||
| 출처 | 포함 내용 |
|
||||
|------|----------|
|
||||
| Stage 0 | clean_text (원본 텍스트 — "이 텍스트를 그대로 사용하라") |
|
||||
| Stage 1A | core_message |
|
||||
| Stage 1B | expression_hint, relation_type |
|
||||
| Stage 1.5a | 확정된 폰트 크기, 줄 수, 글자 수, 컨테이너 px |
|
||||
| Stage 1.5b | 디자인 요소 크기 제약 (max_circle_px, max_img_width 등) |
|
||||
| Stage 1.7 | 디자인 레퍼런스 HTML + visual_diff 설명 |
|
||||
|
||||
프롬프트 예시:
|
||||
|
||||
```
|
||||
[디자인 레퍼런스]
|
||||
아래 HTML의 구조와 색상 패턴을 따르되 콘텐츠를 교체하세요.
|
||||
<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->
|
||||
<!-- 차별점: 같은 다크 계열 callout-warning과 달리 경고 아이콘 없음. 순수 나열용. -->
|
||||
<div style="background:#1a2332; padding:20px; border-radius:6px;">
|
||||
<!-- SLOT: title (1줄, 16px bold, max 30자) -->
|
||||
<h3 style="color:#4a9eff; font-size:16px; font-weight:700;">샘플 제목</h3>
|
||||
<!-- SLOT: bullets (1줄씩, 14px, max 86자, max 5개) -->
|
||||
<ul style="list-style:none; padding:0;">
|
||||
<li style="color:#e2e8f0; font-size:14px; padding:4px 0;">• 샘플 항목 1</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
[수치 제약 — 반드시 준수]
|
||||
- 컨테이너: 너비 707px, 높이 176px
|
||||
- 폰트: 11px (배경 영역 위계)
|
||||
- 줄당 최대 68자
|
||||
- 최대 10줄
|
||||
- 디자인 요소 예산: 높이 84px, 너비 707px
|
||||
|
||||
[원본 텍스트 — 축약/변형 금지]
|
||||
"DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음..."
|
||||
|
||||
[필수 규칙]
|
||||
- inline style만 사용, <style> 블록 금지
|
||||
- overflow:hidden 금지
|
||||
- 디자인 레퍼런스의 구조를 따르되 콘텐츠에 맞게 커스텀
|
||||
- 개조식 통일 (서술형 ~하다/~이다 → 개조식 ~에 해당/~인식되는 중)
|
||||
```
|
||||
|
||||
- **이미지 처리:** Stage 0에서 추출된 `images[]`의 경로와 크기 정보를 프롬프트에 포함. Stage 5에서 base64 인라인 변환.
|
||||
- **팝업 처리:** Stage 0에서 추출된 `popups[]`를 `<details>/<summary>` HTML로 변환 지시.
|
||||
- **출력:** `{body_html, sidebar_html, footer_html}`
|
||||
- **저장:** `context.generated_html`
|
||||
|
||||
---
|
||||
|
||||
### 분산 검증 시스템
|
||||
|
||||
5층 검증을 한 곳에 집중하지 않고, **각 Layer가 적합한 시점에 분산 실행**.
|
||||
재시도 프롬프트는 Self-Refine(NeurIPS 2023) 패턴: `localization + evidence + instruction`.
|
||||
VASCAR(2024)의 Scorer+Suggester 분리: 점수 매기기와 피드백 생성을 분리.
|
||||
|
||||
#### Stage 2 직후: L1 + L2 + L3 (코드 검증)
|
||||
|
||||
| Layer | 도구 | 검증 내용 |
|
||||
|---|---|---|
|
||||
| L1 | kiwipiepy + regex | 키워드 보존율 80% 이상 |
|
||||
| L2 | regex | Kei 메모("간결한 문제 제기용" 등)가 출력에 포함 안 됐는지 |
|
||||
| L3 | regex | overflow:hidden 없는지, 폰트 위계 위반 없는지, inline style만 사용했는지 |
|
||||
|
||||
실패 시 → Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회):
|
||||
|
||||
```
|
||||
[재생성 요청 - 시도 2/3]
|
||||
이전 생성의 문제:
|
||||
1. L1: 키워드 보존율 65%. 누락: {'BIM', '혼용', '설계오류'}
|
||||
2. L3: overflow:hidden 감지
|
||||
|
||||
수정 지시 (Self-Refine):
|
||||
- localization: 키워드 보존 실패, 구조 위반
|
||||
- evidence: 원본 핵심 키워드 3개 누락, overflow:hidden 존재
|
||||
- instruction: 누락 키워드 포함, overflow:hidden 제거, 나머지 제약 동일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: 렌더링 (조립)
|
||||
|
||||
- **담당:** 코드 (AI 아님)
|
||||
- **입력:** L1~L3 통과한 body/sidebar/footer HTML + 프리셋 grid + **동적 비율**
|
||||
- **처리:**
|
||||
- tokens.css + base.css 인라인 병합
|
||||
- CSS Grid 프레임 구성 — **동적 비율 적용** (예: `72fr 28fr` or `65fr 35fr`)
|
||||
- 각 영역 HTML을 `<div class="area-body">` 등에 삽입
|
||||
- Pretendard 폰트 CDN 링크 포함
|
||||
- **출력:** 완전한 단독 실행 HTML
|
||||
- **저장:** `context.rendered_html`
|
||||
|
||||
#### Stage 3 직후: L4 (Selenium 실측)
|
||||
|
||||
```python
|
||||
def validate_after_stage3(context, rendered_html):
|
||||
measurements = selenium_measure(rendered_html)
|
||||
errors = []
|
||||
for area, m in measurements.items():
|
||||
if m.scroll_height > m.client_height:
|
||||
overflow = m.scroll_height - m.client_height
|
||||
errors.append({
|
||||
"layer": "L4", "severity": "RETRYABLE",
|
||||
"localization": f"{area} overflow {overflow}px",
|
||||
"evidence": f"scrollHeight {m.scroll_height} > clientHeight {m.client_height}",
|
||||
"instruction": f"이 영역의 디자인 요소를 {overflow+10}px 줄이거나 bullet 1개 제거"
|
||||
})
|
||||
return errors # 실패 → 해당 영역만 Stage 2로 (최대 2회)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: 품질 게이트 (L5)
|
||||
|
||||
- **담당:** Selenium (스크린샷 캡처) + Opus Vision (품질 판정, 현재 모델: `claude-opus-4-0-20250514`)
|
||||
- **처리:**
|
||||
- 전체 페이지 스크린샷 캡처 → Opus Vision에 base64 전송
|
||||
- 5가지 평가 기준 (VASCAR 방식):
|
||||
1. 콘텐츠 겹침/잘림 없는가?
|
||||
2. 본심 영역이 시각적으로 가장 두드러지는가?
|
||||
3. 폰트가 읽을 수 있는 크기인가? **폰트 위계가 유지되는가?**
|
||||
4. 한국어 비즈니스 프레젠테이션으로서 적절한가?
|
||||
5. 블록 유형에 다양성이 있는가?
|
||||
- 0~100점 평가:
|
||||
- 30점 미만 → 출력 차단 (FATAL)
|
||||
- 30~60점 → Opus 피드백으로 Stage 2 재실행
|
||||
- 60점 이상 → Stage 5로
|
||||
- **L4와의 차이:** L4는 영역 단위 px 실측(Stage 3 직후), L5는 조립 후 전체 페이지 **시각적** 평가
|
||||
- **저장:** `context.measurement`, `context.quality_score`
|
||||
|
||||
---
|
||||
|
||||
### Stage 5: 서빙
|
||||
|
||||
- **담당:** 코드
|
||||
- **처리:**
|
||||
- 이미지 경로 → base64 인라인 변환 (다운로드 HTML에서도 이미지 표시)
|
||||
- `<details>` 인쇄 시 자동 펼침 JS 삽입 (`window.onbeforeprint`)
|
||||
- final.html 저장
|
||||
- **저장:** `data/runs/{run_id}/final.html`
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 흐름 요약
|
||||
|
||||
```
|
||||
Stage 1A (Kei 분석)
|
||||
↓
|
||||
1A 검증 (Pydantic + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 1B (컨셉 구체화)
|
||||
↓
|
||||
1B 검증 (모순 탐지 + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 1.5a → 1.7 → 1.5b (코드, 결정론적)
|
||||
↓
|
||||
Stage 2 (HTML 생성)
|
||||
↓
|
||||
L1+L2+L3 ──실패──→ Self-Refine → Stage 2 재실행 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 3 (조립)
|
||||
↓
|
||||
L4 ──실패──→ 실패 영역만 Stage 2로 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 4 (L5 최종 판정)
|
||||
↓
|
||||
30점 미만 → 차단 (FATAL)
|
||||
30~60점 → Opus 피드백으로 Stage 2 재실행 (최대 1회)
|
||||
60점 이상 → Stage 5
|
||||
```
|
||||
|
||||
### 재시도 총 예산
|
||||
|
||||
| 지점 | 최대 재시도 | 대상 |
|
||||
|------|-----------|------|
|
||||
| Stage 1A | 2회 | Kei 전체 |
|
||||
| Stage 1B | 2회 | 실패 topic만 |
|
||||
| L1~L3 | 2회 | 실패 영역만 |
|
||||
| L4 | 2회 | 실패 영역만 |
|
||||
| L5 | 1회 | 전체 |
|
||||
| **최악 합계** | **Stage 2 최대 5회** | 영역별 독립이므로 1영역 기준 |
|
||||
|
||||
전체 파이프라인 타임아웃: 300초. 초과 시 최선 결과 반환 + 경고.
|
||||
|
||||
---
|
||||
|
||||
## 4. 카탈로그 시스템 (catalog.yaml)
|
||||
|
||||
### 4.1 블록 구조
|
||||
|
||||
38개 블록, **6개 카테고리:**
|
||||
|
||||
| 카테고리 | 블록 수 | 용도 |
|
||||
|----------|---------|------|
|
||||
| headers | 5 | 꼭지/섹션 제목 |
|
||||
| cards | 9 | 항목 나열/비교 |
|
||||
| **tables** | **3** | 비교 표, 스트라이프 표 |
|
||||
| **visuals** | **6** | 벤 다이어그램, 프로세스, 흐름 |
|
||||
| emphasis | 10 | 강조/콜아웃/배너/불릿 |
|
||||
| media | 5 | 이미지 배치 |
|
||||
|
||||
별도 섹션: **4개 레이아웃 프리셋** (sidebar-right, two-column, hero-detail, single-column)
|
||||
|
||||
### 4.2 블록 메타데이터 (현재 상태 + Phase T 추가)
|
||||
|
||||
```yaml
|
||||
- id: block-id
|
||||
name: 한글 이름
|
||||
category: headers | cards | tables | visuals | emphasis | media
|
||||
template: blocks/category/block-id.html
|
||||
height_cost: compact | medium | large | xlarge
|
||||
min_height_px: 80
|
||||
relation_types: [comparison, cause_effect] # 빈 배열 = 모든 relation에 가능
|
||||
min_items: 2 # 19/38 블록에 존재
|
||||
max_items: 5 # 19/38 블록에 존재
|
||||
visual: "시각적 설명"
|
||||
when: "사용 적합 상황"
|
||||
not_for: "부적합 상황"
|
||||
purpose_fit: [핵심전달, 문제제기]
|
||||
zone: full-width-only # 4/38 블록에 존재 (선택)
|
||||
slots:
|
||||
required: [title, description]
|
||||
optional: [icon, source]
|
||||
# --- Phase T 필수 추가 ---
|
||||
schema: # ★ 현재 19/38 → 38/38 완성 필요
|
||||
title: {max_lines: 1, font_size: 16, ref_chars: {body: 30, sidebar: 20}}
|
||||
description: {max_lines: 3, font_size: 14, ref_chars: {body: 150, sidebar: 90}}
|
||||
visual_diff: | # ★ 신규 (T-4), 유사 블록 20개에 추가
|
||||
유사 블록과의 차이: ...
|
||||
variants: # 현재 4/38 → 필요 시 확장
|
||||
- id: default
|
||||
- id: compact
|
||||
```
|
||||
|
||||
### 4.3 expression_hint → 블록 매핑 (키워드 포함 매칭)
|
||||
|
||||
| 시각적 유형 | 매칭 키워드 | 매핑 블록 |
|
||||
|---|---|---|
|
||||
| 인과 | "인과", "현상->결과", "야기" | callout-warning, dark-bullet-list |
|
||||
| 나열_병렬 | "독립적 나열", "병렬", "개별 증거" | dark-bullet-list, card-icon-desc |
|
||||
| 나열_정의 | "독립적 정의", "참조용", "용어" | card-numbered |
|
||||
| 포함_계층 | "상위-하위", "포함 관계", "계층적" | venn-diagram, keyword-circle-row |
|
||||
| 강조_결론 | "핵심 메시지 강조", "임팩트" | banner-gradient, quote-big-mark |
|
||||
| 비교 | "대등 비교", "좌우 대조", "vs" | compare-2col-split, compare-3col-badge |
|
||||
| 순서 | "시간 순서", "단계별", "A->B->C" | flow-arrow-horizontal, process-horizontal |
|
||||
|
||||
### 4.4 Phase T에서 필요한 개선
|
||||
|
||||
| 항목 | 현재 | 목표 | 우선순위 |
|
||||
|------|------|------|----------|
|
||||
| schema 완성 | 19/38 | 38/38 | 높음 (Stage 1.5b 필수) |
|
||||
| visual_diff 추가 | 0/38 | 20/38 (유사 블록 그룹) | 중간 (T-3 프롬프트 품질) |
|
||||
| ref_chars ↔ 컨테이너 폭 정합성 검증 | 없음 | 시작 시 자동 검증 | 높음 |
|
||||
| 블록 독립 렌더링 스크린샷 | 없음 | 38개 PNG | 중간 (visual_diff 근거) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 흐름 요약
|
||||
|
||||
```
|
||||
[원본 MDX]
|
||||
│
|
||||
▼
|
||||
Stage 0 ─── 코드 ──→ context.normalized
|
||||
│ (python-frontmatter + markdown-it-py)
|
||||
│ {clean_text, title, images[], popups[], tables[], sections[]}
|
||||
▼
|
||||
Stage 1A ── Kei ──→ context.analysis
|
||||
│ {topics[], page_structure, core_message}
|
||||
│ ✓ Pydantic 검증 + 원본 대조
|
||||
▼
|
||||
Stage 1B ── Kei ──→ context.topics[].relation_type, .expression_hint, .source_data
|
||||
│ ✓ 모순 탐지 + 원본 패턴 대조
|
||||
▼
|
||||
Stage 1.5a ─ 코드 ──→ context.font_hierarchy, .container_ratio, .containers[].text_budget
|
||||
│ (폰트 위계 확정 → 비율 역산 → 텍스트 예산)
|
||||
▼
|
||||
Stage 1.7 ── 코드 ──→ context.references[]
|
||||
│ (relation_type 1차 + expression_hint 2차 → 블록+변형)
|
||||
│ (Jinja 치환 → 디자인 레퍼런스 HTML)
|
||||
▼
|
||||
Stage 1.5b ─ 코드 ──→ context.containers[].design_budget
|
||||
│ (블록 schema 기반 디자인 요소 크기 역산)
|
||||
▼
|
||||
Stage 2 ── Sonnet ──→ context.generated_html
|
||||
│ (디자인 레퍼런스 + 구체적 수치 + 원본 텍스트)
|
||||
│ ✓ L1+L2+L3 코드 검증
|
||||
▼
|
||||
Stage 3 ── 코드 ──→ context.rendered_html
|
||||
│ (동적 비율 grid 조립)
|
||||
│ ✓ L4 Selenium 실측
|
||||
▼
|
||||
Stage 4 ── Opus ──→ context.quality_score
|
||||
│ (스크린샷 기반 시각 평가, 30점 미만 차단)
|
||||
▼
|
||||
Stage 5 ── 코드 ──→ final.html
|
||||
(이미지 base64 변환, details JS 삽입)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 핸들링
|
||||
|
||||
| 실패 지점 | 등급 | 복구 전략 |
|
||||
|---|---|---|
|
||||
| Stage 0 검증 실패 | FATAL | 원본 MDX 자체 문제 — 사용자에게 에러 반환 |
|
||||
| Stage 1A Pydantic 실패 | RETRYABLE | Self-Refine 피드백 포함 Kei 재요청 (최대 2회) |
|
||||
| Stage 1B 모순 탐지 | RETRYABLE | 모순 topic만 피드백 포함 재요청 (최대 2회) |
|
||||
| Stage 1.5a 수치 이상 | FATAL | 결정론적이므로 재시도 무의미 — 입력 점검 필요 |
|
||||
| Stage 1.7 적합 블록 없음 | ADJUSTABLE | 카테고리별 fallback 블록 선택 + 경고 기록 |
|
||||
| Stage 1.5b 음수 예산 | ADJUSTABLE | 폰트 1px 축소 or 블록 재선택 |
|
||||
| L1~L3 실패 | RETRYABLE | Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회) |
|
||||
| L4 overflow | RETRYABLE | 실패 영역만 Stage 2로 + 구체적 px 피드백 (최대 2회) |
|
||||
| L5 30점 미만 | FATAL | 출력 차단 + 에러 기록 |
|
||||
| L5 30~60점 | RETRYABLE | Opus 피드백으로 Stage 2 재실행 (최대 1회) |
|
||||
| 타임아웃 (300초) | FATAL | 최선 결과 반환 + 경고 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택 (Phase T)
|
||||
|
||||
| 구성 요소 | 기술 | 비고 |
|
||||
|---|---|---|
|
||||
| 웹 서버 | **FastAPI** + uvicorn | 포트 8001, SSE 스트리밍 |
|
||||
| 파이프라인 런타임 | Python (async) | Pydantic BaseModel (PipelineContext) |
|
||||
| MDX 파싱 | python-frontmatter + markdown-it-py + mdit-py-plugins | ~1MB 추가 |
|
||||
| 콘텐츠 판단 | Kei Persona API (localhost:8000, Opus, SSE) | httpx streaming |
|
||||
| HTML 생성 | Claude Sonnet 4 (Anthropic API) | 영역별 개별 호출 |
|
||||
| 한국어 키워드 추출 | kiwipiepy | L1 검증 + Stage 1A/1B 원본 대조 |
|
||||
| 관계 표현 패턴 | regex 7종 (relation_type별) | Stage 1B 검증 보조 |
|
||||
| 시각 품질 평가 | Opus Vision (Anthropic API) | L5, 스크린샷 기반 |
|
||||
| 실측 렌더링 | Selenium headless Chrome | L4, 1280×920 viewport |
|
||||
| 블록 카탈로그 | catalog.yaml (38개 블록) | schema 38/38 완성 필요 |
|
||||
| 템플릿 엔진 | Jinja2 | 블록 HTML 렌더링 |
|
||||
| 디자인 토큰 | tokens.css + base.css | Pretendard Variable |
|
||||
| HTTP 클라이언트 | httpx | Kei API SSE 통신 |
|
||||
| 스냅샷 저장 | JSON (Pydantic model_dump_json) | `data/runs/{run_id}/` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 마이그레이션 맵 (현재 코드 → Phase T)
|
||||
|
||||
### 신규 생성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/pipeline_context.py` | PipelineContext Pydantic 모델 |
|
||||
| `src/mdx_normalizer.py` | Stage 0 MDX 파서 (4-Layer) |
|
||||
| `src/validators.py` | Stage 1A/1B Pydantic 스키마 + 모순 탐지 + 원본 대조 |
|
||||
| `src/block_reference.py` | Stage 1.7 블록 선택 + 디자인 레퍼런스 생성 |
|
||||
| `scripts/capture_block_screenshots.py` | 38개 블록 독립 렌더링 스크린샷 |
|
||||
|
||||
### 수정
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `src/pipeline.py` | run_stage 패턴 + 11-Stage 러너 + PipelineContext 기반 |
|
||||
| `src/html_generator.py` | 프롬프트에 context 기반 수치+레퍼런스 주입, 하드코딩 CSS 제거 |
|
||||
| `src/space_allocator.py` | 폰트 위계 + 동적 비율 역산 + design_budget 계산 |
|
||||
| `src/content_verifier.py` | L1에 kiwipiepy 추가, L3에 폰트 위계 검증 추가 |
|
||||
| `templates/catalog.yaml` | schema 19개 추가 완성 + visual_diff 20개 추가 |
|
||||
|
||||
### 미사용 (Phase S에서 이미 미사용, 삭제 후보)
|
||||
|
||||
| 파일/함수 | 이유 |
|
||||
|-----------|------|
|
||||
| `src/block_selector.py` | Phase R'에서 제거됨. Stage 1.7의 block_reference.py로 대체 |
|
||||
| `src/content_editor.py` | Phase S에서 별도 텍스트 편집 Stage 제거됨 |
|
||||
| `src/design_director.py` | Step B 프롬프트 제거됨. 프리셋 선택 로직만 space_allocator로 이동 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase T 범위 vs Phase ZZ 예고
|
||||
|
||||
### Phase T (현재) — 폰트 위계 + 파이프라인 안정화
|
||||
|
||||
- 11-Stage 파이프라인 전체 구현 (PipelineContext + run_stage 패턴)
|
||||
- Stage 0: MDX 4-Layer 파서
|
||||
- Stage 1A/1B: Pydantic 검증 + 모순 탐지 + 원본 대조
|
||||
- Stage 1.5a: **폰트 위계 확정 + 동적 비율 역산** (Phase T 핵심)
|
||||
- Stage 1.7: 블록 참고 선택 (키워드 매칭 + fallback)
|
||||
- Stage 1.5b: 블록 schema 기반 디자인 예산 역산
|
||||
- catalog.yaml schema 38/38 완성 + visual_diff 20개
|
||||
- 분산 검증 (L1~L5) + Self-Refine 재시도
|
||||
- **합격 기준:** 어떤 MDX에서도 폰트 위계 유지, overflow 없음, 품질 60점+
|
||||
|
||||
### Phase ZZ (최종 전환) — 판단 체계 전환 + 워크플로우
|
||||
|
||||
- Kei Persona API → Opus 직접 + Gitea 위키 판단 기준 전환 (비교 평가 후 결정)
|
||||
- 청크별 보존율 차등화 (verbatim / summary / core_80)
|
||||
- Stage 1.5a → 1A/1B 역방향 협상 루프 (weight 재조정 요청)
|
||||
- Gitea 이슈 기반 워크플로우 전환
|
||||
- Starlight `.astro` 임베딩
|
||||
- 반응형 전환 여부 판단
|
||||
203
docs/history/BUG_STATUS_VERIFICATION.md
Normal file
203
docs/history/BUG_STATUS_VERIFICATION.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Design Agent — 버그 상태 검증 (2026-03-28)
|
||||
|
||||
## 검증 결과 요약
|
||||
|
||||
| 버그 | PROGRESS.md | 실제 코드 | 검증 결과 |
|
||||
|------|-----------|---------|---------|
|
||||
| **BF-4** | 코드 수정 완료, 테스트 필요 | OrderedDict 그룹핑 구현됨 | ✅ **정확함. 테스트만 필요** |
|
||||
| **BF-5** | sidebar-right 수정, 3개 확인 필요 | header zone 4개 프리셋 모두 적용 | ✅ **정확함. 모두 이미 수정됨** |
|
||||
| **BF-6** | 미수정 | 카드 1열 강제 있지만 너비 가이드 없음 | ✅ **정확함. 여전히 미수정** |
|
||||
| **BF-7** | 미수정 (라고 표기됨) | topic_id 1차 정확 매칭 구현됨 | ❌ **부분 정확. Phase N에서 수정됨** |
|
||||
|
||||
---
|
||||
|
||||
## 상세 검증 (코드 인용)
|
||||
|
||||
### ✅ BF-4: body 블록 겹침 — 수정 확인됨
|
||||
**파일:** `src/renderer.py` 라인 209-238
|
||||
**상태:** 코드 수정 완료 ✅
|
||||
|
||||
```python
|
||||
def _group_blocks_by_area(
|
||||
blocks: list[dict[str, Any]],
|
||||
container_specs: dict | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Phase O: 같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다."""
|
||||
grouped = OrderedDict() # ← 같은 area 겹침 방지
|
||||
for block in blocks:
|
||||
area = block["area"]
|
||||
if area not in grouped:
|
||||
grouped[area] = {"area": area, "blocks": []}
|
||||
grouped[area]["blocks"].append(block)
|
||||
# ...
|
||||
```
|
||||
|
||||
**현황 해석:**
|
||||
- OrderedDict 사용으로 같은 area 블록을 보존 순서대로 그룹핑
|
||||
- 같은 div에 flex-column 배치 → 겹침 해결
|
||||
- **테스트만 남음**: body에 여러 블록 배치 후 렌더링 확인
|
||||
|
||||
---
|
||||
|
||||
### ✅ BF-5: 제목 안 보임 — 모두 수정됨
|
||||
**파일:** `src/design_director.py` 라인 333-372 (LAYOUT_PRESETS)
|
||||
**상태:** 4개 프리셋 모두 수정 ✅
|
||||
|
||||
```python
|
||||
LAYOUT_PRESETS = {
|
||||
"sidebar-right": {
|
||||
"grid_areas": "'header header' 'body sidebar' 'footer footer'",
|
||||
"zones": {
|
||||
"header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, ...},
|
||||
# ↑ title이 아닌 'header' 사용
|
||||
...
|
||||
},
|
||||
},
|
||||
"two-column": {
|
||||
"grid_areas": "'header header' 'left right' 'footer footer'",
|
||||
"zones": {
|
||||
"header": {...},
|
||||
# ↑ 4개 프리셋 모두 동일
|
||||
...
|
||||
},
|
||||
},
|
||||
"hero-detail": { ... "header": {...} ... },
|
||||
"single-column": { ... "header": {...} ... },
|
||||
}
|
||||
```
|
||||
|
||||
**현황 해석:**
|
||||
- PROGRESS.md에 "sidebar-right 수정 완료, 3개 확인 필요"라고 했지만
|
||||
- 실제로 **4개 프리셋 모두 "header" zone을 사용**
|
||||
- 따라서 **모두 이미 수정됨** ✅
|
||||
|
||||
---
|
||||
|
||||
### ❌ BF-6: sidebar 카드 3열 찢어짐 — 여전히 미수정
|
||||
**파일:** `src/design_director.py` 라인 814-821
|
||||
**상태:** 미수정 ❌
|
||||
|
||||
```python
|
||||
# sidebar 카드 블록 1열 강제 (J-6)
|
||||
CARD_BLOCKS = {
|
||||
"card-tag-image", "card-icon-desc", "card-image-3col",
|
||||
"card-dark-overlay", "card-compare-3col", "card-image-round",
|
||||
...
|
||||
}
|
||||
|
||||
for block in blocks:
|
||||
if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
|
||||
# column_override = 1 강제
|
||||
...
|
||||
```
|
||||
|
||||
**현황:**
|
||||
- Code가 `column_override = 1` 강제 설정은 하는 중
|
||||
- **하지만 Kei 프롬프트에 sidebar 너비 제약 설명 없음**
|
||||
- Kei가 sidebar 35% 제약을 모르므로 여전히 3列 카드 선택 가능
|
||||
|
||||
**해결책:**
|
||||
```python
|
||||
# src/design_director.py _opus_block_recommendation() 함수에 추가
|
||||
prompt += (
|
||||
"\n## Sidebar 공간 제약 (추가)\n"
|
||||
"- sidebar 너비 35% (약 380px)\n"
|
||||
"- 3열 카드는 각 열 120px 미만 → 컨텐츠 찢어짐\n"
|
||||
"- **sidebar에는 1열 카드 또는 리스트형 블록만 배치하라**\n"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ BF-7: body 블록 텍스트 비어있음 — 실제로는 Phase N에서 수정됨!
|
||||
**파일:** `src/content_editor.py` 라인 140-149
|
||||
**상태:** Phase N에서 수정됨 (PROGRESS.md 기록 누락)
|
||||
|
||||
```python
|
||||
for filled_block in filled["blocks"]:
|
||||
matched = False
|
||||
# 1차: topic_id로 정확 매칭 ← 새로 추가됨
|
||||
if filled_block.get("topic_id"):
|
||||
for orig_block in blocks:
|
||||
if orig_block.get("topic_id") == filled_block.get("topic_id"):
|
||||
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
|
||||
new_data = filled_block.get("data", {})
|
||||
preserved = {}
|
||||
if "data" in orig_block:
|
||||
for k in ("column_override",):
|
||||
if k in orig_block["data"]:
|
||||
preserved[k] = orig_block["data"][k]
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
matched = True
|
||||
break
|
||||
```
|
||||
|
||||
**현황:**
|
||||
- ✅ **Phase N에서 topic_id 기반 정확 매칭 구현됨**
|
||||
- ✅ 1차 매칭에서 topic_id로 일치 확인 후 data 업데이트
|
||||
- ✅ 2차 fallen back area + type 매칭도 있음
|
||||
- **하지만 PROGRESS.md에 "미수정"이라고 표기 → 기록 오류**
|
||||
|
||||
---
|
||||
|
||||
## 새로운 발견: Phase O 구조 변화
|
||||
|
||||
### Step B (Sonnet) 제거됨
|
||||
**파일:** `src/design_director.py` 라인 410-412
|
||||
|
||||
```python
|
||||
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
|
||||
# STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제.
|
||||
```
|
||||
|
||||
**변화:**
|
||||
- 기존: Step A (프리셋) → Step B (Sonnet 블록 매핑)
|
||||
- 현재: Step A (프리셋) → Phase O (Kei/Opus가 블록 확정)
|
||||
- Kei가 더 강한 도메인 지식으로 블록 선택 → 더 신뢰성 높음
|
||||
|
||||
---
|
||||
|
||||
## 신규 기능 추가 상황
|
||||
|
||||
### Purpose_fit 검증
|
||||
**파일:** `src/design_director.py` 라인 747-763
|
||||
|
||||
```python
|
||||
def _validate_purpose_fit(blocks: list[dict]) -> int:
|
||||
"""각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다."""
|
||||
purpose_fit_map = _load_catalog_purpose_fit()
|
||||
replaced = 0
|
||||
|
||||
for block in blocks:
|
||||
block_type = block.get("type", "")
|
||||
purpose = block.get("purpose", "")
|
||||
...
|
||||
if purpose not in allowed_purposes:
|
||||
logger.warning(...)
|
||||
```
|
||||
|
||||
**현황:** ⚠️ 함수는 있지만 **호출 위치 불명**
|
||||
**필요 조치:** pipeline.py에서 호출점 확인 필요
|
||||
|
||||
### Footer 높이 자동 조정
|
||||
**파일:** 검색 불가. 구현 미확인.
|
||||
**필요 조치:** 코드 위치 확인 필요
|
||||
|
||||
---
|
||||
|
||||
## 권장 조치 (우선순위)
|
||||
|
||||
| 우선순위 | 항목 | 필요 시간 | 비고 |
|
||||
|---------|------|---------|------|
|
||||
| 🔴 P0 | BF-6 수정: Kei 프롬프트에 sidebar 너비 가이드 추가 | 5분 | 1줄 추가 |
|
||||
| 🟡 P1 | BF-4 테스트: body 다중 블록 렌더링 확인 | 15분 | 자동 테스트 또는 수동 |
|
||||
| 🟢 P2 | PROGRESS.md 업데이트: BF-7 "수정됨"으로 변경 | 2분 | 기록 동기화 |
|
||||
| 🔵 P3 | purpose_fit 호출점 추가 또는 삭제 결정 | 10분 | 사용 여부 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 검증자 노트
|
||||
|
||||
- **grep 검색 실패 원인:** 한글 주석/문자열로 인한 패턴 미일치 → 직접 파일 읽기로 해결
|
||||
- **PROGRESS.md 정확도:** 95%+ (오직 BF-7 표기만 오래된 상태)
|
||||
- **코드 품질:** Phase O 구조 개선으로 더 안정화됨 (Sonnet → Kei로 전환)
|
||||
185
docs/history/CLEANUP-AUDIT.md
Normal file
185
docs/history/CLEANUP-AUDIT.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Phase 전체 감사 — 유효/무력화/충돌 정리
|
||||
|
||||
> 작성일: 2026-03-27
|
||||
> 상태: ✅ 감사 완료 + 정리 실행 완료 (Step B 제거, 죽은 코드 9건 삭제, 미해결 3건 해결)
|
||||
> Phase A부터 O까지 쌓인 코드를 전수 검사하여 유효/무력화/충돌 항목을 분류한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase 진화 흐름 요약
|
||||
|
||||
```
|
||||
Phase A~D (초기)
|
||||
"Sonnet이 모든 것을 결정"
|
||||
→ Step B에서 Sonnet이 블록 선택 + zone 배치 + char_guide
|
||||
→ 실패 시 _fallback_layout()
|
||||
↓
|
||||
Phase G (Kei API 연결)
|
||||
"Kei API 통신 정상화"
|
||||
→ SSE 스트리밍, Sonnet fallback 제거 시작
|
||||
↓
|
||||
Phase H (스토리라인)
|
||||
"Kei가 콘텐츠를 설계"
|
||||
→ core_message, purpose, source_hint 도입
|
||||
↓
|
||||
Phase I (정합성)
|
||||
"넘침 처리를 Kei에게"
|
||||
→ _downgrade_fallback() 비상용으로 분리, Kei overflow 판단 도입
|
||||
↓
|
||||
Phase J (권한 재정의)
|
||||
"Kei 추천 존중, 프롬프트로 강제"
|
||||
→ STEP_B_PROMPT에 "Opus 추천 존중" 규칙
|
||||
→ ★ 프롬프트로는 Sonnet을 못 막음 → Phase N에서 코드 강제로 전환
|
||||
↓
|
||||
Phase K (시각적 위계)
|
||||
"purpose별 분량 제약"
|
||||
→ 문제제기 100자, 핵심전달 200-400자 등 가이드
|
||||
→ ★ 하드코딩 글자 수 → Phase O에서 동적 계산으로 전환
|
||||
↓
|
||||
Phase L (렌더링 측정)
|
||||
"Selenium + max-height CSS 제약"
|
||||
→ allocate_height_budget() + _max_height_px + max-height CSS
|
||||
→ ★ max-height CSS 클리핑 → Phase N에서 제거
|
||||
→ ★ allocate_height_budget() → Phase O에서 calculate_container_specs()로 교체
|
||||
↓
|
||||
Phase M (비중 시스템)
|
||||
"Kei가 weight 판단, PURPOSE_WEIGHT는 fallback"
|
||||
→ page_structure + kei_weight_map
|
||||
→ ★ pipeline.py의 Phase M 코드 → Phase O에서 교체됨
|
||||
↓
|
||||
Phase N (4대 문제 해결)
|
||||
"코드 레벨 강제, fallback 전면 제거"
|
||||
→ kei_confirmed_blocks 코드 강제, 무한 재시도
|
||||
→ ★ Step B의 블록 선택이 무력화됨 (Kei 것으로 덮어씌움)
|
||||
↓
|
||||
Phase O (컨테이너)
|
||||
"비중 → px → 블록 제약 → 콘텐츠 제약"
|
||||
→ container_specs, finalize_block_specs
|
||||
→ ★ Step B의 char_guide도 무력화됨 (코드 계산으로 덮어씌움)
|
||||
→ ★ Step B가 완전히 불필요해짐
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 코드 항목별 유효/무력화 분류
|
||||
|
||||
### design_director.py
|
||||
|
||||
| 항목 | 행 | 상태 | 이유 |
|
||||
|------|-----|------|------|
|
||||
| `BLOCK_SLOTS` | 26~320 | **유효** | 편집자 슬롯 정의에 사용 |
|
||||
| `LAYOUT_PRESETS` | 322~370 | **유효** | Step A 프리셋 선택에 사용 |
|
||||
| `select_preset()` | 376~410 | **유효** | 규칙 기반 프리셋 선택 |
|
||||
| `STEP_B_PROMPT` | 449~550 | **무력화** | Step B가 불필요해짐 |
|
||||
| `_opus_block_recommendation()` | 560~648 | **유효** | Kei 블록 확정 |
|
||||
| `create_layout_concept()` 내 Step B Sonnet 호출 | 730~980 | **무력화** | 결과가 전부 덮어씌워짐 |
|
||||
| `_fallback_layout()` | 990~1028 | **무력화** | Step B 제거 시 불필요 |
|
||||
| `HEIGHT_COST_PX` | 1030~1036 | **유효** | 블록 높이 추정에 사용 |
|
||||
| `PURPOSE_FALLBACK` | 1038~1046 | **무력화** | Kei가 블록 확정하므로 불필요 |
|
||||
| `BODY_FORBIDDEN_MAP` | 1048~1053 | **유효** | body 금지 블록 검증 |
|
||||
| `DOWNGRADE_MAP` | 1054~1066 | **무력화** | pipeline에서 import 제거됨 |
|
||||
| `SIDEBAR_FORBIDDEN_BLOCKS` | 1067~1088 | **유효** | sidebar 호환 검증 |
|
||||
| `_validate_height_budget()` | 1154~1295 | **부분 유효** | overflow 감지는 유효, 내부의 PURPOSE_FALLBACK 사용은 무력화 |
|
||||
| `_downgrade_fallback()` | 1297~1330 | **무력화** | pipeline에서 미사용 |
|
||||
|
||||
### content_editor.py
|
||||
|
||||
| 항목 | 행 | 상태 | 이유 |
|
||||
|------|-----|------|------|
|
||||
| `EDITOR_PROMPT` | 26~71 | **유효** | 편집자 시스템 프롬프트 |
|
||||
| `fill_content()` | 74~217 | **유효** | 텍스트 편집 핵심 |
|
||||
| `_call_kei_editor_with_retry()` | 220~263 | **유효** | 무한 재시도 |
|
||||
| `_apply_defaults()` | 267~311 | **무력화** | 호출하는 곳 없음 (죽은 코드) |
|
||||
|
||||
### pipeline.py
|
||||
|
||||
| 항목 | 행 | 상태 | 이유 |
|
||||
|------|-----|------|------|
|
||||
| `_retry_kei()` | 35~54 | **유효** | 무한 재시도 |
|
||||
| Phase O 컨테이너 계산 | 105~127 | **유효** | Phase O |
|
||||
| Phase O 블록 스펙 | 131~151 | **유효** | Phase O |
|
||||
| Phase L 피드백 루프 | 215~295 | **유효** | 측정 → 재편집 |
|
||||
|
||||
### space_allocator.py
|
||||
|
||||
| 항목 | 상태 | 이유 |
|
||||
|------|------|------|
|
||||
| 전체 (Phase O 재작성) | **유효** | ContainerSpec, finalize_block_specs |
|
||||
|
||||
### kei_client.py
|
||||
|
||||
| 항목 | 행 | 상태 | 이유 |
|
||||
|------|-----|------|------|
|
||||
| `call_kei_overflow_judgment()` docstring | 447 | **문구 오류** | "fallback: None → DOWNGRADE 비상" 옛날 문구 |
|
||||
| `# manual_classify 삭제됨` 주석 | 551 | **정리 필요** | 주석만 남음 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 삭제 대상 (죽은 코드)
|
||||
|
||||
| 파일 | 항목 | 행 | 이유 |
|
||||
|------|------|-----|------|
|
||||
| `design_director.py` | `STEP_B_PROMPT` | 449~550 | Step B 제거 |
|
||||
| `design_director.py` | Step B Sonnet 호출 코드 | 730~980 내 Sonnet 부분 | Step B 제거 |
|
||||
| `design_director.py` | `_fallback_layout()` | 990~1028 | Step B 제거 |
|
||||
| `design_director.py` | `PURPOSE_FALLBACK` | 1038~1046 | Kei 확정으로 불필요 |
|
||||
| `design_director.py` | `DOWNGRADE_MAP` | 1054~1066 | 미사용 |
|
||||
| `design_director.py` | `_downgrade_fallback()` | 1297~1330 | 미사용 |
|
||||
| `content_editor.py` | `_apply_defaults()` | 267~311 | 미호출 |
|
||||
| `kei_client.py` | 447행 docstring fallback 문구 | 447 | 옛날 문구 |
|
||||
| `kei_client.py` | 551행 삭제 주석 | 551 | 불필요 주석 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 유효한 핵심 코드 (현재 아키텍처)
|
||||
|
||||
```
|
||||
[유효] pipeline.py
|
||||
└── _retry_kei() 무한 재시도
|
||||
└── Phase O 컨테이너 계산 + 블록 스펙
|
||||
└── Phase L 측정 루프
|
||||
└── Stage 5 스크린샷 검수
|
||||
|
||||
[유효] kei_client.py
|
||||
└── classify_content() → Kei API 1A
|
||||
└── refine_concepts() → Kei API 1B (무한 재시도)
|
||||
└── call_kei_final_review() → Opus 멀티모달 5단계
|
||||
└── call_kei_overflow_judgment() → Kei API 넘침 판단
|
||||
|
||||
[유효] design_director.py
|
||||
└── LAYOUT_PRESETS, select_preset() → Step A
|
||||
└── BLOCK_SLOTS → 편집자 슬롯 정의
|
||||
└── _opus_block_recommendation() → Kei A-2 블록 확정
|
||||
└── BODY_FORBIDDEN_MAP, SIDEBAR_FORBIDDEN_BLOCKS → 블록 검증
|
||||
└── _validate_height_budget() → overflow 감지 (PURPOSE_FALLBACK 부분 제거 필요)
|
||||
|
||||
[유효] space_allocator.py → 전체 (Phase O)
|
||||
[유효] content_editor.py → fill_content(), _call_kei_editor_with_retry()
|
||||
[유효] renderer.py → 전체 (Phase O 컨테이너 그룹핑 포함)
|
||||
[유효] slide_measurer.py → 전체
|
||||
[유효] block_search.py → 전체
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 문서 정리 필요 사항
|
||||
|
||||
| 문서 | 상태 | 필요 조치 |
|
||||
|------|------|---------|
|
||||
| `IMPROVEMENT.md` | Phase A~O 전체 나열 | 유효/무력화 표시 추가 |
|
||||
| `IMPROVEMENT-PHASE-A.md` ~ `M.md` | 역사 기록 | "이 Phase의 일부는 후속 Phase에서 대체됨" 주석 추가 |
|
||||
| `README.md` | Phase O 반영 완료 | Step B 제거 반영 필요 |
|
||||
| `PROGRESS.md` | 현재 상태 | Step B 제거 + 죽은 코드 정리 반영 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 정리 실행 순서
|
||||
|
||||
```
|
||||
1. design_director.py 죽은 코드 제거 (STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback)
|
||||
2. design_director.py Step B Sonnet 호출 제거 → Kei 확정 블록 + 코드 검증만으로 layout_concept 생성
|
||||
3. content_editor.py _apply_defaults() 제거
|
||||
4. kei_client.py docstring/주석 정리
|
||||
5. README.md Step B 제거 반영
|
||||
6. IMPROVEMENT.md 유효/무력화 표시
|
||||
```
|
||||
1011
docs/history/COMPREHENSIVE_AUDIT_REPORT.md
Normal file
1011
docs/history/COMPREHENSIVE_AUDIT_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
539
docs/history/COMPREHENSIVE_VALIDATION_REPORT.md
Normal file
539
docs/history/COMPREHENSIVE_VALIDATION_REPORT.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Design Agent — 3건 수정사항 종합 검증 보고서
|
||||
|
||||
**검증 일시:** 2026-03-28 10:00
|
||||
**검증 범위:** space_allocator.py, slide_measurer.py, catalog.yaml 연계 파이프라인
|
||||
**방법론:** 코드 추적 + 파이프라인 시뮬레이션 + MD 문서 동기화 확인
|
||||
|
||||
---
|
||||
|
||||
## 📊 검증 결과 요약
|
||||
|
||||
| # | 항목 | 구현 | 통합 | 문서 | 종합 |
|
||||
|---|------|------|------|------|------|
|
||||
| **1** | space_allocator.py: topic당 높이 판단 | ✅ | ✅ | 🟡 | ✅ 95% |
|
||||
| **2** | slide_measurer.py: container 감지 | ✅ | ✅ | ✅ | ✅ 100% |
|
||||
| **3** | catalog.yaml: schema 글자수 구조 | ✅ | 🔴 | 🟡 | ⚠️ 60% |
|
||||
|
||||
**전체 평가:** ✅ **수정 의도는 정확하나 #3 catalog schema가 실제로 파이프라인에서 사용되지 않음**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 상세 검증
|
||||
|
||||
### 1️⃣ space_allocator.py: topic당 높이 기반 height_cost 판단
|
||||
|
||||
#### ✅ 구현 상태
|
||||
|
||||
**파일:** `src/space_allocator.py` 라인 51-61
|
||||
|
||||
```python
|
||||
# 블록 내부 제약 계산 — topic당 높이로 판단
|
||||
topic_count = max(1, len(topic_ids))
|
||||
per_topic_px = height_px // topic_count # ← 컨테이너 높이 / topic 개수
|
||||
|
||||
# height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님)
|
||||
max_cost = _max_allowed_height_cost(per_topic_px) # ← 핵심 함수 호출
|
||||
# ...
|
||||
```
|
||||
|
||||
**함수 정의 (라인 129-137):**
|
||||
```python
|
||||
def _max_allowed_height_cost(container_height_px: int) -> str:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost."""
|
||||
if container_height_px >= 350:
|
||||
return "xlarge"
|
||||
elif container_height_px >= 200:
|
||||
return "large"
|
||||
elif container_height_px >= 80:
|
||||
return "medium"
|
||||
else:
|
||||
return "compact"
|
||||
```
|
||||
|
||||
#### ✅ 파이프라인 통합
|
||||
|
||||
**pipeline.py 라인 68-82에서 호출:**
|
||||
```python
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure=page_struct, # Kei 비중 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
|
||||
topics=analysis.get("topics", []), # 5개 topic 정보
|
||||
preset=preset,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**데이터 흐름:**
|
||||
```
|
||||
1. Kei: page_structure 판단
|
||||
↓ (page_structure = {"본심": {"topic_ids": [3], "weight": 0.6}}, ...)
|
||||
2. O-1: calculate_container_specs()
|
||||
- per_topic_px = 180 // 1 = 180px (본심 컨테이너 180px / 1개 topic)
|
||||
- max_cost = _max_allowed_height_cost(180) = "medium" ✅
|
||||
↓
|
||||
3. O-3: finalize_block_specs()
|
||||
- 블록에 _container_height_px=180, _max_items=2, _max_chars_total=320 설정
|
||||
↓
|
||||
4. 편집자 (stage 3):
|
||||
- "최대 글자 수: 320자, 항목 수: 2개" 가이드 전달
|
||||
```
|
||||
|
||||
#### 🟡 문서 동기화 상태
|
||||
|
||||
**ARCHITECTURE_OVERVIEW.md 라인 156-170:**
|
||||
```
|
||||
Phase O-1 과정:
|
||||
3. 비중 비율로 높이 할당 (zone 예산 복분)
|
||||
4. 높이 → height_cost 매핑 (compact/medium/large/xlarge)
|
||||
```
|
||||
|
||||
**문제:**
|
||||
- "height_cost 매핑"만 기술되어 있음
|
||||
- **"topic당 높이로 판단"이 명시되지 않음** ← 개선 필요
|
||||
|
||||
**개선된 설명:**
|
||||
> Phase O-1 과정:
|
||||
> 3. 비중으로 컨테이너 높이 할당 (ex. 180px)
|
||||
> 4. **topic당 높이로 max_height_cost 판단** (180px / 1 topic = 180px → **medium**) ← 중요
|
||||
> 5. 글자 수/항목 수 제약 계산
|
||||
|
||||
#### ✅ 검증 결과
|
||||
|
||||
| 항목 | 상태 | 근거 |
|
||||
|------|------|------|
|
||||
| 코드 구현 | ✅ | _max_allowed_height_cost() 함수 정확 |
|
||||
| 파이프라인 호출 | ✅ | pipeline.py 68-82줄 O-1 통합 |
|
||||
| 데이터 흐름 | ✅ | per_topic_px 계산 후 height_cost 결정 |
|
||||
| 문서 정확도 | 🟡 | "topic당 판단" 명시 필요 |
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ slide_measurer.py: container 감지 및 overflow 체크
|
||||
|
||||
#### ✅ 구현 상태
|
||||
|
||||
**파일:** `src/slide_measurer.py` 라인 14-62
|
||||
|
||||
**JavaScript 측정 스크립트:**
|
||||
```javascript
|
||||
// Phase O: 컨테이너 측정 (container-* 클래스)
|
||||
var containerDivs = slide.querySelectorAll('[class*="container-"]');
|
||||
for (var k = 0; k < containerDivs.length; k++) {
|
||||
var container = containerDivs[k];
|
||||
var containerMatch = container.className.match(/container-(.+)/);
|
||||
if (!containerMatch) continue;
|
||||
var containerName = containerMatch[1];
|
||||
|
||||
result.containers[containerName] = {
|
||||
scrollHeight: Math.round(container.scrollHeight),
|
||||
clientHeight: Math.round(container.clientHeight),
|
||||
allocatedHeight: parseInt(container.style.height) || 0,
|
||||
overflowed: container.scrollHeight > container.clientHeight + 2,
|
||||
excess_px: Math.max(0, Math.round(container.scrollHeight - container.clientHeight)),
|
||||
...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**측정 결과 구조:**
|
||||
```json
|
||||
{
|
||||
"containers": {
|
||||
"본심": {
|
||||
"scrollHeight": 190,
|
||||
"clientHeight": 180,
|
||||
"allocatedHeight": 180,
|
||||
"overflowed": true,
|
||||
"excess_px": 10,
|
||||
"blocks": [
|
||||
{"block_type": "topic-left-right", "scrollHeight": 95, "overflowed": false},
|
||||
{"block_type": "topic-left-right", "scrollHeight": 95, "overflowed": false}
|
||||
]
|
||||
},
|
||||
"배경": {
|
||||
"scrollHeight": 50,
|
||||
"clientHeight": 180,
|
||||
"allocatedHeight": 180,
|
||||
"overflowed": false,
|
||||
"excess_px": 0,
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ 파이프라인 통합
|
||||
|
||||
**pipeline.py 라인 177-202 (Phase L):**
|
||||
```python
|
||||
# Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
|
||||
for measure_round in range(MAX_MEASURE_ROUNDS):
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
|
||||
# overflow 감지 — zone + container 양쪽 체크
|
||||
has_overflow = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
break
|
||||
# Phase O: container 레벨 overflow도 체크 ← 핵심
|
||||
for cont_name, cont_data in measurement.get("containers", {}).items():
|
||||
if cont_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
logger.warning(
|
||||
f"[측정] container-{cont_name}: "
|
||||
f"scroll={cont_data.get('scrollHeight')}px > "
|
||||
f"allocated={cont_data.get('allocatedHeight')}px "
|
||||
f"(+{cont_data.get('excess_px')}px)"
|
||||
)
|
||||
break
|
||||
|
||||
if not has_overflow:
|
||||
logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
|
||||
break
|
||||
```
|
||||
|
||||
**overflow 감지 후 조치 (라인 203-230):**
|
||||
```python
|
||||
# 추출: container overflow 정보
|
||||
for cont_name, cont_data in measurement.get("containers", {}).items():
|
||||
if cont_data.get("overflowed"):
|
||||
for block_m in cont_data.get("blocks", []): # ← container 내 블록 단위
|
||||
if block_m.get("overflowed"):
|
||||
trim_chars = calculate_trim_chars(
|
||||
block_m.get("excess_px", excess),
|
||||
width_px,
|
||||
)
|
||||
# 해당 블록의 _max_chars_total 축소
|
||||
```
|
||||
|
||||
### 📊 시뮬레이션: container overflow 피드백 루프
|
||||
|
||||
**시나리오:**
|
||||
- 본심 컨테이너 할당 높이: 180px
|
||||
- 실제 렌더링 높이: 190px (overflow +10px)
|
||||
- 블록 1: topic-left-right (95px, 정상)
|
||||
- 블록 2: card-icon-desc (120px, +15px 초과)
|
||||
|
||||
**동작 순서:**
|
||||
|
||||
```
|
||||
Round 1: 측정
|
||||
├─ measurement.containers["본심"] = {
|
||||
│ ├─ allocatedHeight: 180,
|
||||
│ ├─ scrollHeight: 190,
|
||||
│ ├─ overflowed: true,
|
||||
│ ├─ excess_px: 10,
|
||||
│ └─ blocks: [{...95px...}, {...120px...}] ← 블록 2가 +15px 초과
|
||||
│
|
||||
├─ Phase L 감지: "container-본심에서 +10px overflow"
|
||||
│
|
||||
├─ 조치: card-icon-desc의
|
||||
│ └─ _max_chars_total: 300 → 285 (15자 축약)
|
||||
|
||||
Round 2: 재렌더링 + 재측정
|
||||
├─ content_editor 재호출 (글자 수 제약 적용)
|
||||
├─ 블록 2 텍스트 축약됨
|
||||
│
|
||||
├─ 재측정 결과:
|
||||
│ └─ container
|
||||
|
||||
-본심: scrollHeight 185px / allocatedHeight 180px
|
||||
│ → 여전히 +5px overflow
|
||||
|
||||
Round 3: 재조치
|
||||
├─ 추가 5자 축약
|
||||
├─ 재렌더링 + 재측정
|
||||
│
|
||||
└─ OK: container-본심 정상 (scrollHeight == allocatedHeight)
|
||||
```
|
||||
|
||||
#### ✅ 문서 동기화 상태
|
||||
|
||||
**PROGRESS.md 라인 xxx (Selenium container 감지):**
|
||||
```
|
||||
Phase L: 렌더링 측정 + feedback loop
|
||||
- zone 레벨 overflow 감지 ✅
|
||||
- container 레벨 overflow 감지 ✅ (NEW)
|
||||
```
|
||||
|
||||
**ARCHITECTURE_OVERVIEW.md 라인 xxx:**
|
||||
```
|
||||
Phase L (Measurement):
|
||||
1. Selenium headless Chrome으로 렌더링
|
||||
2. JavaScript로 zone 높이 측정
|
||||
3. NEW: container-* 클래스로 역할별 높이 측정 ← 개선됨
|
||||
4. 초과분 감지 → 피드백 루프
|
||||
5. 최대 3회 재조정
|
||||
```
|
||||
|
||||
#### ✅ 검증 결과
|
||||
|
||||
| 항목 | 상태 | 근거 |
|
||||
|------|------|------|
|
||||
| 코드 구현 | ✅ | slide_measurer.py 14-62줄 |
|
||||
| JavaScript 정확성 | ✅ | container-* 셀렉터 + overflow 계산 정확 |
|
||||
| 파이프라인 호출 | ✅ | pipeline.py 177-202줄 |
|
||||
| 피드백 루프 | ✅ | 재렌더링 + 재측정 최대 3회 |
|
||||
| 문서 정확도 | ✅ | PROGRESS.md, ARCHITECTURE_OVERVIEW.md 반영됨 |
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ catalog.yaml: schema 글자수 필드 추가
|
||||
|
||||
#### ✅ 구현 상태
|
||||
|
||||
**파일:** `templates/catalog.yaml` 라인 44-46 (예. section-header-bar)
|
||||
|
||||
```yaml
|
||||
- id: section-header-bar
|
||||
name: 섹션 헤더 바
|
||||
height_cost: compact
|
||||
...
|
||||
schema: # ← NEW: 글자수 가이드 구조화
|
||||
title: {max_lines: 1, font_size: 18, ref_chars: {body: 25, sidebar: 20}, note: '18px bold white, 중앙정렬'}
|
||||
subtitle: {max_lines: 1, font_size: 13, ref_chars: {body: 40, sidebar: 30}, note: '13px, 1줄'}
|
||||
|
||||
- id: topic-left-right
|
||||
name: 좌우 꼭지 헤더
|
||||
height_cost: compact
|
||||
...
|
||||
schema:
|
||||
title: {max_lines: 2, font_size: 24, ref_chars: {body: 20}, note: '24px bold, 240px 고정폭'}
|
||||
description: {max_lines: 2, font_size: 16, ref_chars: {body: 100}, note: '16px, 510px 너비'}
|
||||
```
|
||||
|
||||
**schema 필드 구조:**
|
||||
```
|
||||
schema:
|
||||
{slot_name}:
|
||||
max_lines: N # 텍스트 라인 수 (줄바꿈 횟수)
|
||||
font_size: N # 픽셀 단위
|
||||
ref_chars: # zone별 글자 수 가이드
|
||||
body: N # body zone(65% 너비)에서의 한 줄 글자 수
|
||||
sidebar: N # sidebar(35%)에서의 글자 수
|
||||
note: "..." # 추가 설명
|
||||
```
|
||||
|
||||
#### 🔴 파이프라인 통합 **실패**
|
||||
|
||||
**문제 1: catalog 로더가 schema를 읽지 않음**
|
||||
|
||||
`src/block_search.py` 라인 xxx에서:
|
||||
```python
|
||||
with open(META_PATH, encoding="utf-8") as f:
|
||||
_metadata = json.load(f) # ← block_metadata.json에서 로드
|
||||
|
||||
# block_metadata.json 생성 스크립트: scripts/build_block_index.py
|
||||
# 이 스크립트가 catalog.yaml의 schema를 추출하여 metadata에 포함하는가? → 불명
|
||||
```
|
||||
|
||||
**문제 2: metadata가 content_editor에 전달되지 않음**
|
||||
|
||||
`src/content_editor.py` 라인 xxx:
|
||||
```python
|
||||
# BLOCK_SLOTS를 사용 (설계 단계에 정의)
|
||||
slots = BLOCK_SLOTS.get(block_type, {}) # ← catalog.yaml 아님
|
||||
|
||||
# catalog.yaml의 schema는 사용되지 않음!
|
||||
```
|
||||
|
||||
**문제 3: 글자 수 가이드 전달 경로 불명**
|
||||
|
||||
현재 flow:
|
||||
```
|
||||
1. BLOCK_SLOTS (design_director.py에 하드코딩)
|
||||
↓ (slot_desc만 전달, schema 아님)
|
||||
2. content_editor.py (slot requirements 생성)
|
||||
├─ slot_desc 포함
|
||||
├─ char_guide 포함 (block에 설정되어 있으면)
|
||||
└─ schema (catalog에만 있음, 사용 안 됨!)
|
||||
↓
|
||||
3. Kei 프롬프트에 전달
|
||||
```
|
||||
|
||||
#### 🟡 코드 검증: schema 실제 접근 시도
|
||||
|
||||
**전체 grep으로 schema 사용 검색:**
|
||||
```bash
|
||||
grep -r "schema" src/*.py templates/*.yaml
|
||||
|
||||
Result:
|
||||
- templates/catalog.yaml: 37개 블록에 schema 정의 ✅
|
||||
- src/*.py: 검색 결과 0 ❌
|
||||
```
|
||||
|
||||
**결론:** schema 필드가 catalog.yaml에 정의되어 있지만 **코드에서 사용하지 않음**
|
||||
|
||||
#### ⚠️ 문서 동기화 상태
|
||||
|
||||
**PROGRESS.md (라인 xxx):**
|
||||
> Phase O-3: finalize_block_specs()로 블록 내부 제약 계산
|
||||
> - max_items, max_chars_total, font_size 등
|
||||
|
||||
**문제:**
|
||||
- catalog.yaml schema 필드 추가를 기록하지 않음
|
||||
- "schema 글자수 구조 변환"이 완료된 것처럼 보이지만 실제로는 미사용
|
||||
|
||||
**ARCHITECTURE_OVERVIEW.md:**
|
||||
- catalog.yaml schema에 대한 언급 없음
|
||||
- "catalog 37개 블록" 기술만 있음
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 문제점 분석
|
||||
|
||||
### Issue #1: catalog.yaml schema가 파이프라인에서 사용되지 않음
|
||||
|
||||
**근본 원인:**
|
||||
1. BLOCK_SLOTS가 설계 단계 (design_director.py)에서 하드코딩됨
|
||||
2. catalog.yaml은 렌더러에서만 사용 (템플릿 경로 매핑)
|
||||
3. schema 필드: 메타데이터로 정의되었으나 **읽는 함수 없음**
|
||||
|
||||
**현재 상태:**
|
||||
```
|
||||
catalog.yaml (37개 블록)
|
||||
├─ id, name, template ✅ (renderer.py에서 사용)
|
||||
├─ height_cost ✅ (design_director.py에서 사용)
|
||||
├─ visual, when, not_for, purpose_fit ❓ (사용 불명)
|
||||
└─ schema ❌ (완전히 미사용)
|
||||
```
|
||||
|
||||
**개선 필요 사항:**
|
||||
|
||||
#### 옵션 A: catalog 로더 추가 (권장)
|
||||
```python
|
||||
# src/catalog_loader.py (신규)
|
||||
def load_catalog_schema() -> dict[str, dict]:
|
||||
"""catalog.yaml에서 블록별 schema 추출"""
|
||||
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
|
||||
with open(catalog_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return {
|
||||
b["id"]: b.get("schema", {})
|
||||
for b in data.get("blocks", [])
|
||||
}
|
||||
|
||||
# src/content_editor.py에서
|
||||
schema_map = load_catalog_schema()
|
||||
schema = schema_map.get(block_type, {})
|
||||
# schema를 Kei 프롬프트에 전달
|
||||
```
|
||||
|
||||
#### 옵션 B: BLOCK_SLOTS에 schema 병합
|
||||
```python
|
||||
# design_director.py
|
||||
BLOCK_SLOTS = {
|
||||
"topic-left-right": {
|
||||
"required": [...],
|
||||
"optional": [...],
|
||||
"schema": { # ← catalog.yaml과 동기화
|
||||
"title": {"max_lines": 2, "font_size": 24, ...},
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 종합 평가
|
||||
|
||||
### 수정 의도 분석
|
||||
|
||||
| 수정 | 의도 | 실제 | 평가 |
|
||||
|------|------|------|------|
|
||||
| **#1** | topic당 높이로 height_cost 판단하여 블록 선택 정확도↑ | ✅ 정확히 구현됨 | ✅ 완성 |
|
||||
| **#2** | container 레벨 overflow 감지 → 피드백 루프로 정확도↑ | ✅ 정확히 구현됨 | ✅ 완성 |
|
||||
| **#3** | schema로 글자수 메타데이터 구조화 → content_editor 정확도↑ | ✅ 작성됨 / ❌ 미사용 | ⚠️ 불완전 |
|
||||
|
||||
### 수정율 (완성도)
|
||||
|
||||
- **#1 space_allocator:** 100% ✅
|
||||
- **#2 slide_measurer:** 100% ✅
|
||||
- **#3 catalog schema:** 60% ⚠️ (정의만 함, 사용 안 함)
|
||||
|
||||
### 다음 단계
|
||||
|
||||
🔴 **즉시 필요:**
|
||||
```python
|
||||
# src/content_editor.py에 schema 로더 추가
|
||||
def _load_block_schema() -> dict[str, dict]:
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
|
||||
with open(catalog_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return {
|
||||
b["id"]: b.get("schema", {})
|
||||
for b in data.get("blocks", [])
|
||||
}
|
||||
|
||||
# fill_content() 함수에서 schema 전달
|
||||
schema = _load_block_schema().get(block_type, {})
|
||||
if schema:
|
||||
req_text += f"\n 슬롯 상세 스키마:\n"
|
||||
for slot, spec in schema.items():
|
||||
req_text += (
|
||||
f" {slot}: "
|
||||
f"{spec.get('max_lines', '?')}줄, "
|
||||
f"{spec.get('font_size', '?')}px, "
|
||||
f"본심:{spec.get('ref_chars', {}).get('body', '?')}자\n"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 검증 체크리스트
|
||||
|
||||
```
|
||||
[✅] 1. space_allocator.py _max_allowed_height_cost() 함수 구현
|
||||
[✅] 2. pipeline.py에서 O-1로 호출
|
||||
[✅] 3. container_specs 결과가 O-3에 전달
|
||||
[✅] 4. finalize_block_specs()에서 _container_height_px 설정
|
||||
[✅] 5. content_editor에서 char_guide로 사용
|
||||
|
||||
[✅] 6. slide_measurer.py container-* 셀렉터 추가
|
||||
[✅] 7. measure_rendered_heights()에서 containers 반환
|
||||
[✅] 8. pipeline.py Phase L에서 overflow 감지
|
||||
[✅] 9. 피드백 루프: 재렌더링 + 재측정
|
||||
|
||||
[✅] 10. catalog.yaml schema 필드 37개 블록 모두 작성
|
||||
[❌] 11. catalog 로더에서 schema 읽기 ← 미구현
|
||||
[❌] 12. content_editor에서 schema 전달 ← 미구현
|
||||
[❌] 13. Kei 프롬프트에 schema 포함 ← 미구현
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 매트릭스: MD 문서 vs 코드 동기화
|
||||
|
||||
| 파일 | 항목 | MD 기재 | 코드 | 동기화 |
|
||||
|------|------|--------|------|--------|
|
||||
| PROGRESS.md | BF-4 해결 | ✅ | ✅ | ✅ |
|
||||
| ARCHITECTURE_OVERVIEW.md | Phase O-1 설명 | ✅ 기본 | ✅ 상세 | 🟡 |
|
||||
| ARCHITECTURE_OVERVIEW.md | Phase L 측정 | ✅ 기본 | ✅ 상세(container 추가) | 🟡 |
|
||||
| ARCHITECTURE_OVERVIEW.md | catalog schema | ❌ | ✅ | ❌ |
|
||||
| README.md | catalog 필드 | 언급 없음 | 37개 블록 정의 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 권고사항
|
||||
|
||||
### 🔴 우선순위 1 (즉시)
|
||||
**catalog schema를 content_editor에 통합**
|
||||
- 파일: `src/content_editor.py`
|
||||
- 작업: `_load_block_schema()` 함수 추가 + fill_content()에서 호출
|
||||
- 소요시간: 30분
|
||||
- 영향: #3 완성도 60% → 100%
|
||||
|
||||
### 🟡 우선순위 2 (이번 주)
|
||||
**MD 문서 업데이트**
|
||||
- ARCHITECTURE_OVERVIEW.md: Phase O container 로직 상세 기술
|
||||
- PROGRESS.md: catalog schema 활용 추가 기록
|
||||
- 소요시간: 1시간
|
||||
|
||||
### 🟢 우선순위 3 (다음 주)
|
||||
**통합 테스트**
|
||||
- 3개 수정사항 end-to-end 테스트
|
||||
- 컨테이너 overflow 시나리오 검증
|
||||
- catalog schema 가이드 실제 사용 확인
|
||||
49
docs/history/CURRENT-STATUS-20260331.md
Normal file
49
docs/history/CURRENT-STATUS-20260331.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 현재 상태 기록 (2026-03-31 03:00)
|
||||
|
||||
---
|
||||
|
||||
## 확정된 것
|
||||
|
||||
1. **역할 분리:** Kei = 콘텐츠 분석, Claude Sonnet = HTML 생성
|
||||
2. **영역별 개별 호출:** 배경/본심/sidebar/footer 각각 Claude 개별 호출
|
||||
3. **블록 선택 시스템 제거:** block_selector, fill_candidates 미사용
|
||||
4. **검증 합격 결과물 존재:** 각 영역별로 수동 프롬프트로 합격한 결과물이 있음
|
||||
|
||||
## 합격한 결과물 위치
|
||||
|
||||
| 영역 | 파일 | 프롬프트 위치 | 방식 |
|
||||
|------|------|------------|------|
|
||||
| 배경 | verify_claude_20260330_212019/verify1.png | verify_claude_1_2.py prompt_1 | Claude, 수동 텍스트 |
|
||||
| 용어 정의 | verify_v2_20260331_003421/A_definitions.png | verify_definitions_v2.py prompt_a | Claude, 수동 텍스트 |
|
||||
| 본심 | core_c_fix_20260331_015828/core_c_fix.png | verify_core_c_fix.py (하드코딩 HTML) | 직접 작성 |
|
||||
| footer | 이전부터 OK | 간단 | Claude |
|
||||
|
||||
## 해결 못한 핵심 문제
|
||||
|
||||
**검증(수동) → 자동화 전환이 안 됨**
|
||||
|
||||
검증에서 합격한 프롬프트는 텍스트를 **수동으로 직접 타이핑**해서 넣은 것.
|
||||
자동화에서는 원본 MDX/source_data에서 텍스트를 **코드로 추출**해야 하는데,
|
||||
추출 결과가 수동으로 넣은 것과 달라서 Claude가 다른 결과를 생성.
|
||||
|
||||
구체적 문제:
|
||||
1. `_extract_bg_content()` — 배경 텍스트 추출이 부정확
|
||||
2. `_extract_core_content()` — 본심 텍스트 추출이 부정확
|
||||
3. `_extract_definitions()` — 용어 정의 추출이 원본과 다름
|
||||
4. 추출된 텍스트가 프롬프트에 들어가면 Claude가 축약/변형
|
||||
|
||||
## 해결 방향
|
||||
|
||||
**Kei가 1단계에서 이미 정리한 source_data를 그대로 프롬프트에 넣으면 됨.**
|
||||
문제는 source_data가 원본 MDX의 80-95%가 아니라 요약본이라는 것.
|
||||
|
||||
선택지:
|
||||
1. source_data를 더 풍부하게 만들기 (Kei 1단계 프롬프트 수정)
|
||||
2. 원본 MDX를 직접 프롬프트에 넣되, 관련 섹션만 정확히 추출
|
||||
3. 검증 때처럼 수동으로 텍스트를 준비 (확장성 없음)
|
||||
|
||||
## 다음 작업
|
||||
|
||||
1. 실제 프롬프트에 들어가는 텍스트를 로그로 저장하여 검증 때와 비교
|
||||
2. 차이점을 파악하고 추출 로직 수정
|
||||
3. 또는 원본 MDX를 영역별로 정확히 슬라이싱하는 로직 구현
|
||||
364
docs/history/FINAL_STATUS_ASSESSMENT.md
Normal file
364
docs/history/FINAL_STATUS_ASSESSMENT.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 최종 평가: BF-4~10 + Phase L/O 상태 확정
|
||||
|
||||
**평가 일시:** 2026-03-28
|
||||
**평가 범위:** BF-4~10 모든 버그 + Phase L 피드백 루프 + Phase O 컨테이너 시스템
|
||||
**근거:** 코드 추적 + 파이프라인 시뮬레이션 + PROGRESS.md/README.md
|
||||
|
||||
---
|
||||
|
||||
## 📊 최종 평가표
|
||||
|
||||
| 구분 | 항목 | PROGRESS.md 기재 | 실제 코드 상태 | 파이프라인에서 작동 | 종합 평가 |
|
||||
|------|------|-------------------|-----------------|-------------------|----------|
|
||||
| **BF-4** | body 블록 겹침 | "코드 수정 완료, 테스트만" | OrderedDict 그룹핑 ✅ | ✅ pipeline 168-170줄 | ✅ **완성** |
|
||||
| **BF-5** | 제목 않보임 | "sidebar-right 수정, 3개 확인" | **4개 ALL header zone** ✅ | ✅ design_director.py 330-370 | ✅ **완성** (기록 낡음) |
|
||||
| **BF-6** | sidebar 카드 찢어짐 | "미수정" | 1열 강제 있으나 너비 가이드 없음 ⚠️ | ⚠️ partial (Kei 프롬프트에 추가 필요) | ⚠️ **불완전** |
|
||||
| **BF-7** | 블록 텍스트 비어있음 | "미수정" | **topic_id 1차 매칭 구현됨** ✅ | ✅ content_editor.py 152-164 | ✅ **완성** (기록 누락) |
|
||||
| **BF-8** | 컨테이너 예산 초과 | "done" | ✅ STEP_B_PROMPT + catalog.yaml 가이드 | ✅ design_director.py 757-814 | ✅ **완성** |
|
||||
| **BF-9** | grid와 Sonnet 역할 분리 | "done" | ✅ Sonnet grid 출력 제거, 프리셋만 사용 | ✅ design_director.py 620-650 | ✅ **complete** |
|
||||
| **BF-10** | Catalog 캐시 갱신 | "done" | ✅ mtime 체크 후 reload | ✅ renderer.py 31-51줄 | ✅ **완성** |
|
||||
| **Phase L** | 렌더링 측정 + 피드백 | "완료, container 감지 미완" | ✅ container-* 셀렉터, overflow 체크 | ✅ pipeline.py 177-230 | ✅ **완성** |
|
||||
| **Phase O** | 컨테이너 기반 레이아웃 | "진행 중, 코드 완료" | ✅ O-1, O-3 구현, catalog schema 미사용 | 🟡 **95% 작동** (schema 미사용) | 🟡 **거의 완성** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완전히 해결된 버그 (7/10)
|
||||
|
||||
### BF-4 ✅ body 블록 겹침
|
||||
**상태:** 완전 해결
|
||||
```python
|
||||
# renderer.py 라인 209-238
|
||||
grouped = OrderedDict()
|
||||
for block in blocks:
|
||||
area = block["area"]
|
||||
if area not in grouped:
|
||||
grouped[area] = {"area": area, "blocks": []}
|
||||
grouped[area]["blocks"].append(block)
|
||||
```
|
||||
✅ 같은 area 블록을 보존 순서대로 그룹핑 → 겹침 방지
|
||||
|
||||
---
|
||||
|
||||
### BF-5 ✅ 제목 미표시
|
||||
**상태:** 완전 해결 (기록만 낡음)
|
||||
```python
|
||||
# design_director.py 라인 330-372 (LAYOUT_PRESETS)
|
||||
"sidebar-right": { "grid_areas": "'header header' 'body sidebar'", ... }
|
||||
"two-column": { "grid_areas": "'header header' 'left right'", ... }
|
||||
"hero-detail": { "grid_areas": "'header header' 'hero hero'", ... }
|
||||
"single-column": { "grid_areas": "'header' 'body'", ... }
|
||||
```
|
||||
✅ 4개 프리셋 모두 "header" zone 사용 (PROGRESS.md는 "3개 확인필요"라고 했지만 실제로 4개 모두 완료)
|
||||
|
||||
---
|
||||
|
||||
### BF-7 ✅ 블록 텍스트 비어있음
|
||||
**상태:** Phase N에서 완전 해결 (기록 누락)
|
||||
```python
|
||||
# content_editor.py 라인 140-164
|
||||
# 1차: topic_id로 정확 매칭 ← NEW
|
||||
if filled_block.get("topic_id"):
|
||||
for orig_block in blocks:
|
||||
if orig_block.get("topic_id") == filled_block.get("topic_id"):
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
matched = True
|
||||
break
|
||||
|
||||
# 2차: area + type 매칭 (fallback)
|
||||
if not matched:
|
||||
for orig_block in blocks:
|
||||
if (orig_block.get("area") == filled_block.get("area")
|
||||
and orig_block.get("type") == filled_block.get("type")):
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
break
|
||||
```
|
||||
✅ topic_id 1차 정확 매칭으로 같은 area 내 다중 블록도 정확히 매칭
|
||||
|
||||
---
|
||||
|
||||
### BF-8 ✅ 컨테이너 예산 초과
|
||||
**상태:** 완전 해결
|
||||
- ✅ LAYOUT_PRESETS에 zone별 budget_px 정의
|
||||
- ✅ STEP_B_PROMPT에 "컨테이너 예산 확인 → 배정 → 블록+높이 계산" 4단계
|
||||
- ✅ catalog.yaml에 블록별 height_cost (compact/medium/large/xlarge)
|
||||
- ✅ base.css zone div에 overflow:hidden + min-height:0 안전망
|
||||
|
||||
---
|
||||
|
||||
### BF-9 ✅ grid와 Sonnet 역할 분리
|
||||
**상태:** 완전 해결
|
||||
```python
|
||||
# design_director.py 라인 620-650 create_layout_concept()
|
||||
# Step B(Sonnet) 제거됨 — Kei(Opus)가 블록 확정
|
||||
layout_concept["pages"] = [{
|
||||
"grid_areas": preset["grid_areas"], # ← 코드가 설정 (Sonnet 무시)
|
||||
"grid_columns": preset["grid_columns"],
|
||||
"grid_rows": preset["grid_rows"],
|
||||
"blocks": blocks, # ← Kei가 확정한 블록만
|
||||
}]
|
||||
```
|
||||
✅ 프리셋 grid를 코드에서 유지, Sonnet의 grid 지정 완전 제거
|
||||
|
||||
---
|
||||
|
||||
### BF-10 ✅ Catalog 캐시 갱신
|
||||
**상태:** 완전 해결
|
||||
```python
|
||||
# renderer.py 라인 31-51 _load_catalog_map()
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0
|
||||
|
||||
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime:
|
||||
return _CATALOG_MAP # 캐시 재사용
|
||||
|
||||
# 변경 감지 또는 첫 로드 → 새로 읽기
|
||||
_CATALOG_MTIME = current_mtime
|
||||
_CATALOG_MAP = {}
|
||||
# ... 새로 로드
|
||||
```
|
||||
✅ catalog.yaml 파일 수정시간 감지 후 자동 reload
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 부분적으로 해결된 버그 (1/10)
|
||||
|
||||
### BF-6 ⚠️ sidebar 카드 찢어짐
|
||||
**상태:** 불완전 (1차 완화만, 2차 완전 수정 필요)
|
||||
|
||||
**1차 (코드 레벨):** 완료 ✅
|
||||
```python
|
||||
# design_director.py 라인 814-821
|
||||
CARD_BLOCKS = {
|
||||
"card-tag-image", "card-icon-desc", "card-image-3col", ...
|
||||
}
|
||||
|
||||
for block in blocks:
|
||||
if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
|
||||
# column_override = 1 강제
|
||||
if "data" not in block:
|
||||
block["data"] = {}
|
||||
block["data"]["column_override"] = 1
|
||||
```
|
||||
✅ sidebar 카드는 1列로 강제
|
||||
|
||||
**2차 (Kei 레벨):** 미완성 ❌
|
||||
```python
|
||||
# design_director.py _opus_block_recommendation()
|
||||
# Kei 프롬프트에 sidebar 너비 제약이 설명되지 않음!
|
||||
# ⚠️ Kei (Opus)가 sidebar 35% 제약을 모르면 → 3列 카드 선택 가능
|
||||
```
|
||||
⚠️ **즉시 수정 필요:** Kei 프롬프트에 한 줄 추가:
|
||||
```python
|
||||
prompt += (
|
||||
"\n## Sidebar 공간 제약 (중요)\n"
|
||||
"- sidebar 너비: 35% (약 388px)\n"
|
||||
"- 3열 카드: 각 열 130px 미만 → 컨텐츠 찢어짐\n"
|
||||
"- **sidebar에는 1열 카드 또는 리스트형 블록만 배치하라**\n"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완전히 해결된 큰 기능 (2개)
|
||||
|
||||
### Phase L ✅ 렌더링 측정 + 피드백 루프
|
||||
**상태:** 완전 작동
|
||||
|
||||
```python
|
||||
# pipeline.py 라인 177-230 (Phase L)
|
||||
for measure_round in range(MAX_MEASURE_ROUNDS):
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
|
||||
# 1. zone 레벨 overflow 감지
|
||||
has_overflow = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
break
|
||||
|
||||
# 2. 💡NEW: container 레벨 overflow도 감지 ← 3번 수정사항 #2
|
||||
for cont_name, cont_data in measurement.get("containers", {}).items():
|
||||
if cont_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
logger.warning(
|
||||
f"[측정] container-{cont_name}: "
|
||||
f"scroll={cont_data.get('scrollHeight')}px > "
|
||||
f"allocated={cont_data.get('allocatedHeight')}px"
|
||||
)
|
||||
break
|
||||
|
||||
if not has_overflow:
|
||||
logger.info(f"[측정] 모든 zone/container 정상")
|
||||
break
|
||||
|
||||
# 3. 피드백: trim_chars 계산 → 편집자 재호출 → 재렌더링
|
||||
adjusted = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
excess = zone_data.get("excess_px", 0)
|
||||
trim_chars = calculate_trim_chars(excess, width_px)
|
||||
for block in ...:
|
||||
block["_max_chars_total"] = max(20, current_max - trim_chars)
|
||||
adjusted = True
|
||||
|
||||
if not adjusted:
|
||||
break
|
||||
|
||||
# 4. 재조정: fill_content() + _adjust_design() + render_slide()
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
```
|
||||
|
||||
✅ **최대 3회 반복으로 overflow 완화** (컨테이너 레벨 + zone 레벨 양쪽 체크)
|
||||
|
||||
---
|
||||
|
||||
### Phase O 🟡 컨테이너 기반 레이아웃 (95% 완성)
|
||||
**상태:** 95% 작동 (schema 미사용으로 인한 소폭 제약)
|
||||
|
||||
#### O-1: 비중 → px 확정
|
||||
**상태:** ✅ 완전 구현
|
||||
```python
|
||||
# pipeline.py 라인 68-82
|
||||
# space_allocator.py 라인 51-61 (3번 수정사항 #1)
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure={"본심": {"topic_ids": [3], "weight": 0.6}, ...},
|
||||
topics=analysis.get("topics", []),
|
||||
...
|
||||
)
|
||||
|
||||
# 핵심: topic당 높이로 height_cost 판단
|
||||
topic_count = len(topic_ids)
|
||||
per_topic_px = height_px // topic_count # ← 180 // 1 = 180px
|
||||
max_cost = _max_allowed_height_cost(per_topic_px) # → "medium"
|
||||
```
|
||||
|
||||
#### O-3: 컨테이너 크기 → 블록 스펙 확정
|
||||
**상태:** ✅ 완전 구현
|
||||
```python
|
||||
# pipeline.py 라인 88-99
|
||||
for page in layout_concept.get("pages", []):
|
||||
finalize_block_specs(page.get("blocks", []), container_specs)
|
||||
|
||||
# space_allocator.py 라인 178-210
|
||||
# 결과: _container_height_px, _max_items, _max_chars_total 설정
|
||||
```
|
||||
|
||||
#### 파이프라인 통합
|
||||
**상태:** ✅ 완전 작동
|
||||
```
|
||||
1단계: Kei 비중 판단 (page_structure)
|
||||
↓
|
||||
O-1: 역할별 컨테이너 px 확정 + height_cost 제약 결정
|
||||
↓
|
||||
2단계: Kei(Opus)가 컨테이너 제약을 보고 블록 확정
|
||||
↓
|
||||
O-3: 확정된 블록의 내부 스펙 (항목수/글자수/폰트) 계산
|
||||
↓
|
||||
3단계: 편집자가 컨테이너 제약대로 텍스트 편집
|
||||
↓
|
||||
Phase L: 렌더링 측정 → 초과분 다시 축약 (최대 3회)
|
||||
```
|
||||
|
||||
#### ⚠️ 미사용 요소: catalog.yaml schema
|
||||
**상태:** 정의됨 但 미사용
|
||||
- ✅ catalog.yaml에 37개 블록의 schema 필드 정의
|
||||
- ❌ content_editor에서 schema 로드 안 함
|
||||
- ❌ Kei 프롬프트에 schema 정보 전달 안 함
|
||||
|
||||
**영향:** 블록 선택 정확도 90% → (schema 적용시 95%) 차이
|
||||
→ 하지만 이미 95% 작동하므로 우선순위 낮음
|
||||
|
||||
---
|
||||
|
||||
## 📈 종합 평가: BF-4~10 + Phase L/O
|
||||
|
||||
| 평가 항목 | 상태 | 근거 |
|
||||
|----------|------|------|
|
||||
| **BF-4~10 해결율** | 7/10 완전 + 1/10 부분 = **80%** | BF-6만 Kei 프롬프트 추가 필요 |
|
||||
| **Phase L 작동** | **100%** ✅ | pipeline 177-230줄, container 레벨 감지 추가됨 |
|
||||
| **Phase O 작동** | **95%** ✅ | O-1, O-3 완성. schema 미사용이 간소 제약 |
|
||||
| **파이프라인 통합** | **95%** ✅ | 모든 단계가 연결. BF-6 미완성만 예외 |
|
||||
| **문서 정확도** | **90%** 🟡 | PROGRESS.md BF-5, BF-7 기록이 낡음 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 현재 상태: 게 나아간 것들
|
||||
|
||||
### ✅ 이번 수정으로 개선된 것들
|
||||
|
||||
#### 1️⃣ space_allocator.py (3번 수정사항 #1)
|
||||
- ✅ **topic당 높이로 height_cost 판단** (180px / 2 topics = 90px → compact)
|
||||
- ✅ 블록 선택 정확도 ↑ (매우 큰 블록이 작은 컨테이너에 들어가는 실수 방지)
|
||||
- ✅ BF-8 (컨테이너 예산 초과) 근본 해결
|
||||
|
||||
#### 2️⃣ slide_measurer.py (3번 수정사항 #2)
|
||||
- ✅ **container 레벨 overflow 감지** (zone 레벨만으로는 부족)
|
||||
- ✅ Phase L 피드백 루프 정확도 ↑
|
||||
- ✅ 재렌더링 횟수 감소 (더 정확한 감지 → 1회만에 조정 가능)
|
||||
|
||||
#### 3️⃣ catalog.yaml (3번 수정사항 #3)
|
||||
- ✅ 37개 블록의 schema 필드 정의 (max_lines, font_size, ref_chars)
|
||||
- ⚠️ 코드 미사용 (우선순위 낮음)
|
||||
- 🟡 사용시 블록 선택 정확도 90% → 95%
|
||||
|
||||
---
|
||||
|
||||
## 🚀 결론: 개선 효과
|
||||
|
||||
### Before (이전)
|
||||
```
|
||||
BF-4: body 블록 겹침 → OrderedDict 없이 여러 div 생성 → 겹침
|
||||
BF-5: 제목 미표시 → 일부 프리셋만 수정 → 찾기 어려움
|
||||
BF-6: sidebar 카드 찢어짐 → Kei가 sidebar 너비 제약 모름 → 3列 선택
|
||||
BF-7: 블록 텍스트 비어있음 → first-match 매칭 → 같은 area 내 2개 블록 중 첫 번째만 채워짐
|
||||
BF-8: 컨테이너 예산 초과 → 컨테이너 크기 무시 → 블록 크기 제약 없음
|
||||
Phase L: zone 레벨만 감지 → container 내부 블록 overflow 미감지 → 불완전한 조정
|
||||
```
|
||||
|
||||
### After (현재)
|
||||
```
|
||||
✅ BF-4: OrderedDict로 보존 순서 그룹핑 → 겹침 없음
|
||||
✅ BF-5: 4개 프리셋 모두 header zone 사용 → 제목 정상 표시
|
||||
⚠️ BF-6: 1열 강제는 있지만 Kei 프롬프트 추가 필요 (5분 작업)
|
||||
✅ BF-7: topic_id 1차 + area+type 2차 매칭 → 모든 블록 다 채워짐
|
||||
✅ BF-8: 컨테이너 높이(px)로 height_cost 제약 → 예산 초과 방지
|
||||
✅ Phase L: zone + container 양쪽 감지 → 정확한 피드백
|
||||
✅ Phase O: 비중 → px → 블록 제약 → 텍스트 제약 체이닝 완성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 최종 판정
|
||||
|
||||
### 종합 평가: **✅ 95% 완성**
|
||||
|
||||
**완전히 해결:** 7/10 버그 + Phase L + Phase O (core)
|
||||
**부분 완성:** 1/10 버그 (BF-6: 5분 추가 작업)
|
||||
**미사용:** 1개 (catalog schema: 우선순위 낮음)
|
||||
|
||||
### 다음 액션 (우선순위)
|
||||
|
||||
🔴 **P0 (즉시 — 5분)**
|
||||
```python
|
||||
# design_director.py _opus_block_recommendation()에 추가
|
||||
prompt += (
|
||||
"\n## Sidebar 공간 제약\n"
|
||||
"- sidebar 너비 35% 고정: 약 388px\n"
|
||||
"- 3열 카드 사용 금지 (각 열 130px 미만)\n"
|
||||
"- **sidebar는 1열 카드만 배치하라**\n"
|
||||
)
|
||||
```
|
||||
|
||||
🟡 **P1 (이번 주 — 1시간)**
|
||||
- PROGRESS.md BF-5, BF-7 기록 업데이트
|
||||
- ARCHITECTURE_OVERVIEW.md Phase O 상세 기술
|
||||
|
||||
🟢 **P2 (다음 주 — 상시)**
|
||||
- End-to-end 테스트 (overflow 시나리오)
|
||||
- catalog schema 사용 여부 재검토
|
||||
|
||||
---
|
||||
|
||||
**최종 결론:** 예, **모두 개선되었습니다!** 🎉
|
||||
BF-4~10 중 70% 완전 해결, 10% 부분 해결, 20% 미리 해결된 것(BF-8~10 이전에 완료).
|
||||
491
docs/history/IMPROVEMENT-PHASE-A.md
Normal file
491
docs/history/IMPROVEMENT-PHASE-A.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# Phase A: 슬라이드 품질 핵심 — 실행 상세
|
||||
|
||||
> "프레임에 내용이 안 보인다"의 직접 원인 해결. 8개 항목.
|
||||
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
[독립] A-6 (cover→contain), A-7 (table-layout: fixed)
|
||||
→ A-8 (container query, A-7 후)
|
||||
→ A-1 (Sonnet 디자인 조정 — 가장 큰 작업)
|
||||
→ A-2 (HTML 전달), A-3 (shrink), A-4 (rewrite) — 병렬 가능
|
||||
→ A-5 (overflow 재검토, A-1 완료 후)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A-6: object-fit: cover → contain ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- `image-row-2col.html:30` — `object-fit: cover;`
|
||||
- `image-grid-2x2.html:31` — `object-fit: cover;`
|
||||
- cover는 이미지를 crop → CLAUDE.md "이미지를 crop하지 않는다" 위반
|
||||
|
||||
### 작업
|
||||
두 파일에서 `cover` → `contain` 변경 (CSS 1줄씩)
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. CSS 속성값만 변경
|
||||
- 회귀: 없음. CLAUDE.md 원칙 복구
|
||||
- 부작용: contain은 이미지 주변에 빈 공간(letterbox) 가능 → `background: var(--color-bg-subtle)` 추가로 자연스럽게 처리
|
||||
|
||||
### 수정 파일
|
||||
- `templates/blocks/media/image-row-2col.html`
|
||||
- `templates/blocks/media/image-grid-2x2.html`
|
||||
|
||||
### 구현 결과
|
||||
- `image-row-2col.html:29~31` — `object-fit: contain; height: 100%; background: var(--color-bg-subtle, #f8fafc);`
|
||||
- cover → contain, 높이 하드코딩(354px) → 100%(부모 기준), letterbox 배경색 추가
|
||||
- `image-grid-2x2.html:29~31` — 동일 패턴 적용 (200px 하드코딩도 함께 제거)
|
||||
|
||||
---
|
||||
|
||||
## A-7: table-layout: fixed ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- `compare-3col-badge.html`에 table-layout 미지정
|
||||
- 열 너비가 내용 길이에 따라 불안정하게 변동
|
||||
|
||||
### 작업
|
||||
```css
|
||||
.ct-table {
|
||||
table-layout: fixed;
|
||||
width: 100%; /* fixed는 width 필수 */
|
||||
}
|
||||
```
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. 기존 테이블 스타일에 속성 추가만
|
||||
- 회귀: 없음. fixed는 열 너비를 균등하게 고정 — 더 안정적
|
||||
|
||||
### 수정 파일
|
||||
- `templates/blocks/tables/compare-3col-badge.html`
|
||||
|
||||
### 구현 결과
|
||||
- `.block-table-figma table`에 `table-layout: fixed;` 추가 (기존 `width: 100%`는 이미 있었음)
|
||||
|
||||
---
|
||||
|
||||
## A-8: container query 폰트 스케일링 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- 표 셀 폰트 크기 고정 → 좁은 공간(sidebar 35%)에서 텍스트 잘림/넘침
|
||||
- @container 규칙 없음
|
||||
|
||||
### 작업
|
||||
```css
|
||||
.block-compare-table {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 40rem) {
|
||||
.ct-cell, .ct-header {
|
||||
font-size: var(--font-caption); /* 0.8rem */
|
||||
}
|
||||
}
|
||||
@container (max-width: 25rem) {
|
||||
.ct-cell, .ct-header {
|
||||
font-size: var(--font-small); /* 0.7rem */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- `40rem`, `25rem`은 font-size 기반 상대값 (px 고정이 아님)
|
||||
- `var(--font-caption)`, `var(--font-small)`은 디자인 토큰 → 하드코딩 아님
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. 신규 CSS 규칙 추가
|
||||
- 회귀: 없음. @container 미지원 브라우저에서는 무시 → 기존과 동일
|
||||
- 의존성: A-7 (table-layout: fixed) 먼저 적용해야 열 너비가 안정적
|
||||
|
||||
### 수정 파일
|
||||
- `templates/blocks/tables/compare-3col-badge.html`
|
||||
|
||||
### 구현 결과
|
||||
- `.block-table-figma`에 `container-type: inline-size;` 추가
|
||||
- `@container (max-width: 40rem)` — 테이블/헤더/셀 폰트 축소 + 패딩 축소
|
||||
- `@container (max-width: 25rem)` — 추가 축소 + badge 패딩 축소
|
||||
- **추가:** `tr:hover` 제거 (Phase C-2 선행 처리 — CLAUDE.md "호버 효과 금지")
|
||||
|
||||
---
|
||||
|
||||
## A-1: 4단계 Sonnet 디자인 조정 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- pipeline.py:73에서 `render_slide(layout_concept)` 직접 호출
|
||||
- 텍스트 양에 맞는 디자인 조정 과정이 없음 → 텍스트 넘침/빈공간 원인
|
||||
- CLAUDE.md: "디자인 실무자 (Sonnet + Jinja2 + CSS) — 텍스트에 맞게 폰트/여백/박스 조정"
|
||||
|
||||
### API 선택
|
||||
- **Sonnet** (CLAUDE.md "4단계: Anthropic API (Sonnet)")
|
||||
- 디자인 실무자는 Kei가 아님 — Sonnet이 맞음
|
||||
|
||||
### 핵심 아이디어: CSS 변수 cascade
|
||||
블록 템플릿 20개가 이미 CSS 변수(`var(--font-body)`, `var(--spacing-inner)` 등)를 187회 사용 중.
|
||||
area div에서 CSS 변수를 override하면 **템플릿 수정 없이** 모든 블록이 자동 조정됨.
|
||||
|
||||
```html
|
||||
<!-- 예시: Sonnet이 body area의 폰트를 줄이기로 결정 -->
|
||||
<div class="area-body" style="--font-body: 0.85rem; --spacing-inner: 10px;">
|
||||
{{ block.html }} <!-- 내부 블록들이 자동으로 작은 폰트/여백 적용 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 파이프라인 흐름 변경
|
||||
|
||||
```
|
||||
기존:
|
||||
3단계 fill_content → 4단계 render_slide → 5단계 review
|
||||
|
||||
변경:
|
||||
3단계 fill_content → [신규] _adjust_design → 4단계 render_slide → 5단계 review
|
||||
```
|
||||
|
||||
### 신규 함수: _adjust_design()
|
||||
|
||||
**위치:** pipeline.py
|
||||
|
||||
**입력:** layout_concept (data 채워진 상태)
|
||||
|
||||
**처리:**
|
||||
1. 코드가 각 area별 블록 수, 텍스트 총량(글자 수), zone budget_px를 계산
|
||||
2. Sonnet에게 전달: area별 정보 + 사용 가능한 CSS 변수 목록
|
||||
3. Sonnet이 area별 CSS 변수 override를 결정하여 JSON 반환
|
||||
4. layout_concept에 area_styles 저장
|
||||
|
||||
**Sonnet 프롬프트 구성:**
|
||||
```
|
||||
당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.
|
||||
|
||||
## 원칙
|
||||
- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.
|
||||
- 빈 공간을 방치하지 않는다.
|
||||
- 조정 가능한 CSS 변수: --font-body, --font-subtitle, --font-caption, --spacing-inner, --spacing-block, --spacing-small
|
||||
|
||||
## 각 영역 현황
|
||||
- body (예산 490px, 너비 65%): 3개 블록, 총 820자
|
||||
- quote-question: 120자
|
||||
- topic-header: 200자
|
||||
- comparison-table: 500자
|
||||
- sidebar (예산 490px, 너비 35%): 2개 블록, 총 400자
|
||||
- card-image: 250자
|
||||
- card-image: 150자
|
||||
- footer (예산 60px): 1개 블록, 80자
|
||||
|
||||
## 출력 (JSON만)
|
||||
{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}
|
||||
```
|
||||
|
||||
**Sonnet 출력 파싱:**
|
||||
- `area_styles` dict 추출
|
||||
- 각 area별 CSS 문자열 → layout_concept 페이지에 저장
|
||||
|
||||
**실패 시:** area_styles가 빈 dict → style="" → 기존과 동일하게 렌더링 (안전)
|
||||
|
||||
### renderer.py 변경
|
||||
|
||||
**render_multi_page() 192~197행:**
|
||||
|
||||
기존:
|
||||
```python
|
||||
pages_rendered.append({
|
||||
"grid_areas": page.get("grid_areas", "'main'"),
|
||||
...
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
```
|
||||
|
||||
변경:
|
||||
```python
|
||||
# area_styles를 각 grouped block에 주입
|
||||
area_styles = page.get("area_styles", {})
|
||||
for grouped_block in blocks_grouped:
|
||||
grouped_block["style_override"] = area_styles.get(grouped_block["area"], "")
|
||||
|
||||
pages_rendered.append({
|
||||
"grid_areas": page.get("grid_areas", "'main'"),
|
||||
...
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
```
|
||||
|
||||
### slide-base.html 변경
|
||||
|
||||
**45행:**
|
||||
|
||||
기존:
|
||||
```html
|
||||
<div class="area-{{ block.area }}">
|
||||
```
|
||||
|
||||
변경:
|
||||
```html
|
||||
<div class="area-{{ block.area }}" style="{{ block.style_override | default('') }}">
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- CSS 조정값: Sonnet이 결정 → AI 판단 ✅
|
||||
- CSS 변수 목록: 프롬프트에 "조정 가능한 변수" 안내 → 가이드일 뿐 강제 아님 ✅
|
||||
- area별 글자 수: 코드가 계산 → 객관적 수치 ✅
|
||||
- 하드코딩 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- pipeline.py: render_slide() 전에 삽입. 기존 흐름 안 건드림 ✅
|
||||
- renderer.py: blocks_grouped에 style_override 키 추가. 기존 키 영향 없음 ✅
|
||||
- slide-base.html: style 속성 추가. area_styles 없으면 빈 문자열 → 기존 동일 ✅
|
||||
- 템플릿 수정: 불필요 (CSS 변수 cascade로 자동 적용) ✅
|
||||
- 회귀: 없음. 실패 시 기존과 동일 동작 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — _adjust_design() 신규 함수 + generate_slide()에 호출 추가
|
||||
- `src/renderer.py` — render_multi_page()에서 area_styles 주입
|
||||
- `templates/slide-base.html` — area div에 style_override 적용
|
||||
|
||||
### 구현 결과
|
||||
- **pipeline.py** `_adjust_design()` 신규 함수 (약 80행):
|
||||
- 각 area별 block_count, total_chars, budget_px, width_pct, block_types 자동 집계 (코드)
|
||||
- Sonnet(디자인 실무자)에게 area별 현황 전달 → CSS 변수 override를 JSON으로 반환받음
|
||||
- 출력: `page["area_styles"] = {"body": "--font-body: 0.85rem; ...", "sidebar": "", ...}`
|
||||
- 실패 시: `area_styles = {}` → style="" → 기존과 동일 렌더링 (안전)
|
||||
- **pipeline.py** `generate_slide()` 72행: `_adjust_design()` 호출 삽입 (render_slide 직전)
|
||||
- **renderer.py** `render_multi_page()` 192~196행: area_styles를 grouped block의 `style_override`에 주입
|
||||
- **slide-base.html** 45행: `<div class="area-{{ block.area }}" style="{{ block.style_override | default('') }}">`
|
||||
- **CSS 변수 cascade 방식:** 블록 템플릿 수정 불필요 — 이미 `var(--font-body)` 등 187회 사용 중이므로 area div에서 override하면 자동 적용
|
||||
|
||||
---
|
||||
|
||||
## A-2: 5단계 HTML을 프롬프트에 전달 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- pipeline.py:103 `_review_balance(html, ...)` — html 파라미터 있지만 141~146행 프롬프트에서 미사용
|
||||
- 블록별 데이터 길이만 전달 → 시각적 균형 판단 불가
|
||||
|
||||
### 작업
|
||||
`_review_balance()` 프롬프트에 html 전문 포함
|
||||
|
||||
```python
|
||||
user_prompt = (
|
||||
f"## 1차 조립 HTML\n{html}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) + ...
|
||||
)
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- html 전문 전달 (임의 잘라내기 없음) ✅
|
||||
- Sonnet 200K context → 전문 전달 가능 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 프롬프트 텍스트만 변경. 파싱/함수 시그니처 동일 ✅
|
||||
- 회귀: 없음 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — `_review_balance()` 프롬프트 부분
|
||||
|
||||
### 구현 결과
|
||||
- `_review_balance()` user_prompt에 `f"## 1차 조립 HTML\n{html}\n\n"` 추가 — html 전문 전달
|
||||
- 시스템 프롬프트에 "5. HTML 구조: 블록이 영역 안에 잘 배치되었는지" 점검 항목 추가
|
||||
- 출력 형식에 `target_ratio` 필드 추가 (A-3과 연동)
|
||||
|
||||
---
|
||||
|
||||
## A-3: 5단계 shrink action + 기존 expand 하드코딩 수정 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- shrink: 조건에 없어서 무시됨
|
||||
- expand: `char_guide * 1.5` 하드코딩 (pipeline.py:186)
|
||||
- CLAUDE.md: "모든 판단은 사고로. 하드코딩 없음"
|
||||
|
||||
### 작업
|
||||
|
||||
**1) 5단계 프롬프트 출력 형식 변경**
|
||||
|
||||
기존:
|
||||
```json
|
||||
{"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}
|
||||
```
|
||||
|
||||
변경:
|
||||
```json
|
||||
{"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "target_ratio": 1.4, "detail": "..."}]}
|
||||
```
|
||||
|
||||
→ Sonnet(디자인 팀장)이 **얼마나** 조정할지를 `target_ratio`로 결정
|
||||
|
||||
**2) _apply_adjustments() 코드 변경**
|
||||
|
||||
```python
|
||||
for adj in adjustments:
|
||||
area = adj.get("block_area", "")
|
||||
action = adj.get("action", "")
|
||||
ratio = adj.get("target_ratio")
|
||||
detail = adj.get("detail", "")
|
||||
|
||||
for page in layout_concept.get("pages", []):
|
||||
for block in page.get("blocks", []):
|
||||
if block.get("area") == area:
|
||||
if action == "expand" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(block["char_guide"][key] * ratio)
|
||||
elif action == "shrink" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(block["char_guide"][key] * ratio)
|
||||
logger.info(f"조정: {area} → {action} ×{ratio} ({detail})")
|
||||
```
|
||||
|
||||
→ ratio가 없으면(Sonnet 누락) 조정 안 함 (무동작이 안전)
|
||||
→ expand/shrink 모두 Sonnet이 결정한 ratio 사용
|
||||
|
||||
### 하드코딩 점검
|
||||
- ratio: Sonnet이 결정 ✅ (기존 `1.5` 하드코딩 제거)
|
||||
- ratio 없을 때 기본값: 적용 안 함 (하드코딩 기본값 없음) ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 기존 expand `* 1.5` 제거 → **기존 하드코딩을 수정하는 것이므로 회귀 아님, 개선임**
|
||||
- 5단계 프롬프트 출력 형식 변경 → `_parse_json()` 파싱에 영향 없음 (JSON 구조)
|
||||
- Sonnet이 target_ratio를 안 넣으면 → 조정 안 함 → 기존보다 보수적 (안전)
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — `_review_balance()` 프롬프트 + `_apply_adjustments()` 코드
|
||||
|
||||
### 구현 결과
|
||||
- `_apply_adjustments()` 전면 재작성:
|
||||
- `ratio = adj.get("target_ratio")` — Sonnet이 결정한 비율 추출
|
||||
- `action == "expand" and ratio` → `char_guide[key] * ratio`
|
||||
- `action == "shrink" and ratio` → `char_guide[key] * ratio`
|
||||
- ratio 없으면(Sonnet 누락) 조정 안 함 → 안전
|
||||
- 기존 `* 1.5` 하드코딩 완전 제거
|
||||
- `_review_balance()` 프롬프트에 action별 target_ratio 설명 추가
|
||||
|
||||
---
|
||||
|
||||
## A-4: 5단계 rewrite action ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- rewrite가 expand와 같은 조건(`action in ("expand", "rewrite")`)에 들어가지만
|
||||
- `if action == "expand"` 안에만 실제 로직 → rewrite는 로그만 찍고 끝 (no-op)
|
||||
|
||||
### 작업
|
||||
|
||||
A-3에서 변경한 코드에 rewrite 분기 추가:
|
||||
|
||||
```python
|
||||
elif action == "rewrite":
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
block["reason"] = f"재작성: {detail}"
|
||||
logger.info(f"조정: {area} → rewrite ({detail})")
|
||||
```
|
||||
|
||||
- data 삭제 → fill_content() 재호출(192행) 시 재매칭
|
||||
|
||||
### 하드코딩 점검
|
||||
- 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 기존 no-op → 실제 동작으로 변경 (개선, 회귀 아님)
|
||||
- fill_content 재호출 시 topic_id 매칭으로 다른 블록도 재편집될 수 있음 → 기존 expand도 동일 동작
|
||||
- 회귀: 없음 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — `_apply_adjustments()` (A-3과 같은 함수)
|
||||
|
||||
### 구현 결과
|
||||
- `_apply_adjustments()`에 `elif action == "rewrite"` 분기 추가
|
||||
- data 삭제 (`del block["data"]`) → fill_content 재호출 시 topic_id로 재매칭
|
||||
- `block["reason"]` 업데이트 → 편집자에게 재작성 방향 전달
|
||||
|
||||
---
|
||||
|
||||
## A-5: overflow 정책 재검토 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- base.css:16 `.slide { overflow: hidden }` — 프레임 경계
|
||||
- base.css:74 `.slide > div { overflow: hidden }` — BF-8에서 추가한 area별 안전망
|
||||
|
||||
### 작업
|
||||
|
||||
```css
|
||||
/* base.css:74 변경 */
|
||||
.slide > div {
|
||||
overflow: visible; /* hidden → visible: A-1이 사전 조정하므로 잘림 방지 불필요 */
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
```
|
||||
|
||||
`.slide { overflow: hidden }`(16행)은 **유지** — 프레임 바깥은 잘려야 함
|
||||
|
||||
### 하드코딩 점검
|
||||
- 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- BF-8에서 추가한 `.slide > div { overflow: hidden }` 제거 → BF-8과 방향 다름
|
||||
- **그러나 BF-8의 overflow: hidden은 "텍스트를 자르지 않는다" 원칙과 충돌하는 임시 조치였음**
|
||||
- A-1(Sonnet 디자인 조정)이 넘침을 사전 방지 → 안전망 불필요
|
||||
- **반드시 A-1 완료 후 적용**
|
||||
|
||||
### 수정 파일
|
||||
- `static/base.css`
|
||||
|
||||
### 구현 결과
|
||||
- base.css `.slide > div` — `overflow: hidden` → `overflow: visible`
|
||||
- 주석 추가: "A-1(Sonnet 디자인 조정)이 텍스트 양에 맞게 CSS를 사전 조정하므로, area 레벨에서는 overflow: visible로 텍스트 잘림을 방지한다."
|
||||
- `.slide { overflow: hidden }`(16행)은 유지 — 프레임 경계 보호
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `templates/blocks/media/image-row-2col.html` | A-6 | CSS 1줄 변경 |
|
||||
| `templates/blocks/media/image-grid-2x2.html` | A-6 | CSS 1줄 변경 |
|
||||
| `templates/blocks/tables/compare-3col-badge.html` | A-7, A-8 | CSS 추가 |
|
||||
| `src/pipeline.py` | A-1, A-2, A-3, A-4 | 신규 함수 + 기존 함수 수정 |
|
||||
| `src/renderer.py` | A-1 | area_styles 주입 (3줄) |
|
||||
| `templates/slide-base.html` | A-1 | style 속성 추가 (1줄) |
|
||||
| `static/base.css` | A-5 | overflow 변경 (1줄) |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] A-6: image-row, image-grid에서 이미지가 crop 안 됨
|
||||
- [ ] A-7: 테이블 열 너비가 내용과 무관하게 균등
|
||||
- [ ] A-8: sidebar(35%)에 표가 들어가면 폰트 자동 축소
|
||||
- [ ] A-1: Sonnet이 area별 CSS 변수 override 출력 → 렌더링에 반영
|
||||
- [ ] A-1: _adjust_design 실패 시 기존과 동일하게 렌더링 (안전)
|
||||
- [ ] A-2: 5단계 Sonnet이 HTML 구조를 보고 균형 판단
|
||||
- [ ] A-3: shrink 시 Sonnet이 결정한 ratio로 char_guide 축소
|
||||
- [ ] A-3: 기존 expand 1.5 하드코딩 제거됨
|
||||
- [ ] A-4: rewrite 시 해당 블록 data 삭제 후 재편집
|
||||
- [ ] A-5: area div에서 텍스트 잘림 없음
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. 하드코딩 제거 반영 (A-2 html 전문, A-3 target_ratio, A-8 상대값). |
|
||||
| 2026-03-25 | Phase A 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| A-1 | `_adjust_design()` 함수 존재, pipeline에서 호출, renderer에서 area_styles 주입, slide-base에서 style_override 적용 |
|
||||
| A-2 | `_review_balance()` 프롬프트에 html 전문 포함, target_ratio 출력 형식 추가 |
|
||||
| A-3 | shrink action 구현 + expand 하드코딩(×1.5) 제거 → Sonnet target_ratio 기반 |
|
||||
| A-4 | rewrite action 구현 — data 삭제 + reason 업데이트 + fill_content 재호출 |
|
||||
| A-5 | `.slide > div { overflow: visible }` — 프레임(.slide)은 hidden 유지 |
|
||||
| A-6 | image-row, image-grid: cover → contain + 높이 하드코딩 제거 |
|
||||
| A-7 | table-layout: fixed + width: 100% |
|
||||
| A-8 | container-type: inline-size + @container (40rem, 25rem) |
|
||||
| 추가 | compare-3col-badge tr:hover 제거 (Phase C-2 선행 처리) |
|
||||
368
docs/history/IMPROVEMENT-PHASE-B.md
Normal file
368
docs/history/IMPROVEMENT-PHASE-B.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Phase B: 누락 기능 구현 — 실행 상세
|
||||
|
||||
> 누락된 기능 구현. 실작업 5개 (B-6, B-7은 해결됨).
|
||||
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
[독립] B-1 (details 템플릿), B-4+B-5 (이미지/표 판단), B-8 (fallback 교체)
|
||||
→ B-2 (인쇄 JS, B-1 후)
|
||||
→ B-3 (catalog 등록, B-1 후)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B-1: details-block 템플릿 제작 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- BLOCK_SLOTS에 정의 있음 (design_director.py:47~49): `required: ["summary_text", "detail_content"], optional: ["label"]`
|
||||
- _apply_defaults에 기본값 있음 (content_editor.py:248): `{"summary_text": "(상세 내용)", "detail_content": ""}`
|
||||
- **HTML 템플릿 파일이 templates/blocks/ 어디에도 없음** → 렌더링 불가
|
||||
|
||||
### API 선택
|
||||
- AI 호출 없음. HTML/CSS 템플릿만.
|
||||
|
||||
### 작업
|
||||
`templates/blocks/emphasis/details-block.html` 신규 제작
|
||||
|
||||
**구조:**
|
||||
```html
|
||||
<details class="block-details">
|
||||
<summary class="dt-summary">
|
||||
{% if label %}<span class="dt-label">{{ label }}</span>{% endif %}
|
||||
{{ summary_text }}
|
||||
</summary>
|
||||
<div class="dt-content">{{ detail_content }}</div>
|
||||
</details>
|
||||
```
|
||||
|
||||
### 하드코딩 점검 — CSS 규칙
|
||||
- 색상: `var(--color-*)` 만 사용. `#직접값` 금지
|
||||
- 폰트: `var(--font-*)` 사용
|
||||
- 여백: `var(--spacing-*)` 사용
|
||||
- 테두리: `var(--border-width)`, `var(--accent-border)`, `var(--radius)` 사용
|
||||
- **기존 emphasis 블록의 직접값(#1e3a5f 등)을 따라하지 않는다**
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. 신규 파일 추가만
|
||||
- 회귀: 없음. BLOCK_SLOTS/_apply_defaults와 정합
|
||||
- 단발성: 아님. `<details>/<summary>`는 HTML 표준 — 브라우저 내장, 의존성 없음
|
||||
|
||||
### 수정 파일
|
||||
- 신규: `templates/blocks/emphasis/details-block.html`
|
||||
|
||||
### 구현 결과
|
||||
- `templates/blocks/emphasis/details-block.html` 신규 제작 완료
|
||||
- HTML 구조: `<details class="block-details">` → `<summary class="dt-summary">` (label + summary_text) → `<div class="dt-content">` (detail_content)
|
||||
- CSS: **`#직접값` 0개** — 전부 디자인 토큰으로 구현
|
||||
- 배경: `var(--color-bg-subtle)`, 테두리: `var(--color-border)`, 액센트: `var(--color-accent)`
|
||||
- 폰트: `var(--font-body)`, `var(--font-caption)`, 여백: `var(--spacing-inner)`, `var(--spacing-block)`
|
||||
- summary 마커: 기본 브라우저 마커 숨기고(`list-style: none`, `::-webkit-details-marker { display: none }`) 커스텀 ▶/▼ 표시
|
||||
- 좌측 파란 액센트 라인: `border-left: var(--accent-border) solid var(--color-accent)` — quote-left-border와 톤 통일
|
||||
|
||||
---
|
||||
|
||||
## B-2: 인쇄 시 details 자동 펼침 JS ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- slide-base.html의 `@media print`에 page-break만 있음
|
||||
- details 자동 펼침 JS 없음
|
||||
- CLAUDE.md: "인쇄 시 JavaScript 6줄로 자동 펼침"
|
||||
|
||||
### 작업
|
||||
slide-base.html `</body>` 직전에 삽입:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.onbeforeprint = function() {
|
||||
document.querySelectorAll('details').forEach(function(d) { d.open = true; });
|
||||
};
|
||||
window.onafterprint = function() {
|
||||
document.querySelectorAll('details').forEach(function(d) { d.open = false; });
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- 없음. DOM API만 사용. 고정값 없음.
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. `{% endfor %}` 이후, Jinja2 루프 밖에 삽입
|
||||
- 회귀: 없음. 기존 HTML에 `<details>` 없으면 querySelectorAll이 빈 NodeList → 무동작
|
||||
- 다운로드 HTML: renderer.py의 CSS 인라인 삽입은 `<link>` → `<style>` 교체만. `<script>`는 그대로 포함됨 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `templates/slide-base.html`
|
||||
|
||||
### 의존성
|
||||
- B-1 완료 후 의미 있음 (details 태그가 있어야 JS가 동작)
|
||||
|
||||
### 구현 결과
|
||||
- slide-base.html `</body>` 직전에 `<script>` 블록 삽입
|
||||
- `window.onbeforeprint`: 모든 `<details>` 요소에 `open = true` 설정 (인쇄 시 펼침)
|
||||
- `window.onafterprint`: 모든 `<details>` 요소에 `open = false` 복원 (인쇄 후 접힘)
|
||||
- `<details>` 태그가 없으면 `querySelectorAll` 빈 NodeList → 무동작 (기존 슬라이드에 영향 없음)
|
||||
- 다운로드 HTML에도 JS 그대로 포함됨 (renderer의 CSS 인라인 처리는 `<link>` → `<style>` 교체만)
|
||||
|
||||
---
|
||||
|
||||
## B-3: catalog에 details-block 등록 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- catalog.yaml에 미등록 → Sonnet(팀장)이 이 블록을 선택할 수 없음
|
||||
|
||||
### 작업
|
||||
catalog.yaml blocks 배열에 추가:
|
||||
|
||||
```yaml
|
||||
- id: details-block
|
||||
name: 자세히보기 (접기/펼치기)
|
||||
template: blocks/emphasis/details-block.html
|
||||
height_cost: "~60px (compact, 접힌 상태 기준. 펼치면 내용에 따라 가변)"
|
||||
visual: "접힌 요약 1줄 + 클릭하면 상세 내용 펼침. HTML 네이티브 <details> 사용."
|
||||
when: >
|
||||
너무 구체적/세부적인 내용을 접어서 보여줄 때.
|
||||
본문 흐름을 끊지 않으면서 상세 데이터를 제공할 때.
|
||||
비교표, 상세 스펙 등 detail_target 꼭지.
|
||||
not_for: >
|
||||
본문 핵심 내용 (접으면 안 됨).
|
||||
결론이나 강조 메시지 (항상 보여야 함).
|
||||
일반 텍스트 (topic-header 또는 card 사용).
|
||||
slots:
|
||||
required: [summary_text, detail_content]
|
||||
optional: [label]
|
||||
character_limits:
|
||||
summary_text: 60
|
||||
detail_content: 500
|
||||
label: 10
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- height_cost: 접힌 상태의 사실적 높이. AI가 zone 예산 계산에 사용하는 참고값
|
||||
- character_limits: AI 참고용 가이드. 강제 아님 (CLAUDE.md: "의미 우선")
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. catalog에 항목 추가만
|
||||
- _load_catalog_map(): id+template만 추출하므로 정상 로드
|
||||
- 높이 참고표 주석(14행): compact 목록에 details-block 추가 필요
|
||||
|
||||
### 수정 파일
|
||||
- `templates/catalog.yaml`
|
||||
|
||||
### 의존성
|
||||
- B-1 (템플릿이 존재해야 catalog에 등록 의미 있음)
|
||||
|
||||
### 구현 결과
|
||||
- catalog.yaml emphasis 섹션 마지막(divider-text 뒤, media 섹션 전)에 삽입
|
||||
- id: `details-block`, template: `blocks/emphasis/details-block.html`
|
||||
- height_cost: `compact` (접힌 상태 기준)
|
||||
- when: "너무 구체적/세부적인 내용을 접어서 보여줄 때. detail_target 꼭지."
|
||||
- not_for: "본문 핵심 내용 (접으면 안 됨). 결론 → conclusion-accent-bar."
|
||||
- slots: `required: [summary_text, detail_content], optional: [label]`
|
||||
- `_load_catalog_map()` 정상 로드 확인 (총 46개 블록)
|
||||
|
||||
---
|
||||
|
||||
## B-4 + B-5: 1단계 이미지/표 상세 판단 필드 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- KEI_PROMPT 출력 형식(kei_client.py:44~53행)에 `content_type: "text|image|table|mixed"` 한 줄만
|
||||
- 이미지 개수/소속/핵심여부/텍스트포함, 표 행/열 규모 등 상세 필드 없음
|
||||
- CLAUDE.md: "몇 개인지, 어떤 꼭지 소속인지, 핵심/보조인지", "행/열 규모, 전체 표시 가능 여부"
|
||||
|
||||
### API 선택
|
||||
- **Kei API** (1차). KEI_PROMPT가 Kei API로 전달됨 (kei_client.py:96행)
|
||||
- Sonnet 직접이 아님 ✅
|
||||
|
||||
### 작업 — 3곳 동기화 필수
|
||||
|
||||
**1) KEI_PROMPT (kei_client.py:20~56행)**
|
||||
|
||||
프롬프트 본문에 이미지/표 판단 규칙 보강 + 출력 형식에 필드 추가:
|
||||
|
||||
현재 출력 형식:
|
||||
```json
|
||||
{"title": "...", "total_pages": 1, "info_structure": "...",
|
||||
"topics": [{"id": 1, ..., "content_type": "text|image|table|mixed", "detail_target": false, "page": 1}]}
|
||||
```
|
||||
|
||||
변경:
|
||||
```json
|
||||
{"title": "...", "total_pages": 1, "info_structure": "...",
|
||||
"topics": [{"id": 1, ..., "content_type": "text|image|table|mixed", "detail_target": false, "page": 1}],
|
||||
"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}],
|
||||
"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}
|
||||
```
|
||||
|
||||
**2) fallback system_prompt (kei_client.py:168~184행) — 동기화**
|
||||
|
||||
현재 문제:
|
||||
- `role` 필드 누락 → sidebar-right 프리셋 절대 선택 안 됨
|
||||
- `info_structure` 필드 누락
|
||||
- `images[]`, `tables[]` 없음
|
||||
|
||||
→ KEI_PROMPT와 **동일한 출력 스키마**로 전면 동기화.
|
||||
이것은 단발성 패치가 아니라, "같은 역할(1단계 실장)의 두 경로가 동일한 출력 구조를 사용해야 한다"는 구조적 원칙.
|
||||
|
||||
**3) manual_classify (kei_client.py:225~243행) — 기본값 추가**
|
||||
|
||||
```python
|
||||
return {
|
||||
...
|
||||
"images": [],
|
||||
"tables": [],
|
||||
}
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- images[]/tables[] 필드: AI가 판단하여 채움. 스키마 정의일 뿐 고정값 아님 ✅
|
||||
- "key|supporting", "true|false": AI가 선택하는 enum. 하드코딩 아님 ✅
|
||||
|
||||
### 하류 영향 분석 (에이전트 검증 완료)
|
||||
| 모듈 | images[]/tables[] 추가 영향 |
|
||||
|------|---------------------------|
|
||||
| pipeline.py | `.get("topics")`, `.get("total_pages")`만 접근. 무시됨 ✅ |
|
||||
| design_director.py select_preset() | topics의 role/emphasis만 사용. 무시됨 ✅ |
|
||||
| design_director.py create_layout_concept() | user_prompt에 analysis 텍스트로 포함 → 이점 (Sonnet이 참고) ✅ |
|
||||
| content_editor.py fill_content() | analysis 미참조 (인자로만 받음). 완전 무관 ✅ |
|
||||
| pipeline.py _adjust_design() | select_preset()만 호출. 무시됨 ✅ |
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. 기존 필드 변경 없이 필드 추가만
|
||||
- 회귀: 없음. images[]/tables[]가 없어도 하류 `.get()` 패턴으로 안전
|
||||
- **기존 결함 수리 포함**: fallback에 role/info_structure 누락 문제도 함께 해결
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py` — KEI_PROMPT, fallback system_prompt, manual_classify (3곳)
|
||||
|
||||
### 구현 결과 — 3곳 동기화
|
||||
|
||||
**1) KEI_PROMPT (kei_client.py:38~40행 프롬프트 본문, 46~56행 출력 형식)**
|
||||
- 프롬프트 본문: "이미지/표가 있으면 그것도 판단해줘" → 구체화
|
||||
- "이미지가 있으면: 몇 개인지, 어떤 꼭지 소속인지, 핵심인지 보조인지, 텍스트 포함 여부 판단"
|
||||
- "표가 있으면: 행/열 규모, 1페이지 전체 표시 가능 여부 판단"
|
||||
- "이미지/표 판단 결과를 images[], tables[] 배열에 기록"
|
||||
- 출력 형식: topics[] 뒤에 images[], tables[] 배열 추가
|
||||
- images: `[{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "..."}]`
|
||||
- tables: `[{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "..."}]`
|
||||
|
||||
**2) fallback system_prompt (kei_client.py:172~195행)**
|
||||
- 기존 누락 필드 전부 추가: `role: "flow|reference"`, `info_structure`, `images: []`, `tables: []`
|
||||
- 꼭지 추출 규칙에 "참조 정보는 role: 'reference'" 추가
|
||||
- 출력 스키마가 KEI_PROMPT와 동일한 구조로 동기화
|
||||
- **기존 결함 수리**: fallback에서도 sidebar-right 프리셋이 선택 가능해짐 (role 필드 존재)
|
||||
|
||||
**3) manual_classify (kei_client.py:238~258행)**
|
||||
- `info_structure: ""` 추가
|
||||
- topics[0]에 `role: "flow"` 추가
|
||||
- 최상위에 `images: []`, `tables: []` 추가
|
||||
|
||||
---
|
||||
|
||||
## B-6, B-7: 해결됨 (작업 불필요)
|
||||
|
||||
- B-6: quote-left-border → 등록 안 함 확정 (구 블록 제거 방향)
|
||||
- B-7: comparison-2col → 등록 안 함 확정 (구 블록 제거 방향)
|
||||
|
||||
---
|
||||
|
||||
## B-8: fallback_layout에서 card-grid → topic-header 교체 ✅ 완료
|
||||
|
||||
### 현재 상태
|
||||
- `_fallback_layout()` (design_director.py:438행): `"type": "card-grid"`
|
||||
- card-grid는 BLOCK_SLOTS에서 제거됨 (주석 24행), _apply_defaults에서도 제거됨, catalog에도 없음
|
||||
- **현재 fallback 경로가 이미 깨져있음** (정합성 분석으로 확인)
|
||||
|
||||
### 작업
|
||||
```python
|
||||
# 변경 전
|
||||
"type": "card-grid",
|
||||
...
|
||||
"char_guide": {"title": 20, "description": 100},
|
||||
|
||||
# 변경 후
|
||||
"type": "topic-header",
|
||||
...
|
||||
# char_guide 제거 — 편집자가 자체 판단 (하드코딩 방지)
|
||||
```
|
||||
|
||||
### topic-header 정합성 (에이전트 검증 완료)
|
||||
| 체인 | 존재 여부 |
|
||||
|------|:--------:|
|
||||
| BLOCK_SLOTS (design_director.py:77~80) | ✅ `required: ["title", "description"]` |
|
||||
| _apply_defaults (content_editor.py:257) | ✅ `{"title": "(소제목)", "description": ""}` |
|
||||
| catalog.yaml | ✅ `id: topic-header, template: blocks/headers/topic-left-right.html` |
|
||||
| 템플릿 파일 | ✅ `templates/blocks/headers/topic-left-right.html` 존재 |
|
||||
|
||||
### 하드코딩 점검
|
||||
- 기존 `char_guide: {"title": 20, "description": 100}` → **제거**
|
||||
- 편집자(3단계)가 가이드 없이 자체 판단 (CLAUDE.md: "글자 수 가이드는 참고. 의미 우선")
|
||||
- 하드코딩 0개 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. fallback 블록 타입 변경만
|
||||
- 회귀: 아님. **깨진 fallback을 수리하는 변경** (card-grid는 이미 체인에서 제거됨)
|
||||
|
||||
### 수정 파일
|
||||
- `src/design_director.py` — `_fallback_layout()` (438행)
|
||||
|
||||
### 구현 결과
|
||||
- `_fallback_layout()` 436~442행:
|
||||
- `"type": "card-grid"` → `"type": "topic-header"` 변경
|
||||
- `"char_guide": {"title": 20, "description": 100}` **완전 제거** (하드코딩 제거)
|
||||
- `"size": "medium"` 유지 (AI 판단 참고용)
|
||||
- topic-header 정합성 검증 통과:
|
||||
- BLOCK_SLOTS ✅, _apply_defaults ✅, catalog ✅, 템플릿 ✅
|
||||
- **깨진 fallback 수리 완료**: card-grid는 BLOCK_SLOTS/defaults/catalog 모두에서 제거된 블록이었음 → topic-header로 교체하여 전체 체인 정합
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| 신규 `templates/blocks/emphasis/details-block.html` | B-1 | HTML/CSS 템플릿 제작 |
|
||||
| `templates/slide-base.html` | B-2 | `<script>` 6줄 추가 |
|
||||
| `templates/catalog.yaml` | B-3 | details-block 항목 + 높이 참고표 업데이트 |
|
||||
| `src/kei_client.py` | B-4, B-5 | KEI_PROMPT + fallback + manual_classify (3곳 동기화) |
|
||||
| `src/design_director.py` | B-8 | _fallback_layout() 블록 타입 교체 + char_guide 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] B-1: details-block.html이 `<details>/<summary>` 사용. CSS에 `var(--*)` 만 사용. `#직접값` 없음
|
||||
- [ ] B-1: BLOCK_SLOTS 슬롯명(summary_text, detail_content, label)과 템플릿 변수명 일치
|
||||
- [ ] B-2: 인쇄 시 details 자동 펼침. 화면에서는 접힌 상태 유지
|
||||
- [ ] B-2: 다운로드 HTML에 `<script>` 포함
|
||||
- [ ] B-3: catalog에 details-block 등록. _load_catalog_map()에서 정상 로드
|
||||
- [ ] B-4: KEI_PROMPT에 images[] 스키마 추가. Kei API로 전달
|
||||
- [ ] B-5: KEI_PROMPT에 tables[] 스키마 추가. Kei API로 전달
|
||||
- [ ] B-4/B-5: fallback system_prompt에 role, info_structure, images[], tables[] 동기화
|
||||
- [ ] B-4/B-5: manual_classify에 images:[], tables:[] 빈 배열 추가
|
||||
- [ ] B-8: _fallback_layout()에서 "topic-header" 사용. char_guide 없음
|
||||
- [ ] B-8: fallback 경로에서 topic-header 렌더링 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. 하드코딩 제거 반영 (B-8 char_guide 제거, B-1 CSS 토큰 강제). fallback 동기화 추가. |
|
||||
| 2026-03-25 | Phase B 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| B-1 | `details-block.html` 존재. `#직접값` 0개 — CSS 변수만 사용. 슬롯명(summary_text, detail_content, label) BLOCK_SLOTS와 정합 |
|
||||
| B-2 | slide-base.html에 `onbeforeprint`/`onafterprint` JS 포함. `<details>` 없으면 무동작(안전) |
|
||||
| B-3 | catalog에 `details-block` 등록. `_load_catalog_map()` 정상 로드 (총 46개 블록) |
|
||||
| B-4 | KEI_PROMPT에 images[] 스키마 + 판단 규칙 추가 |
|
||||
| B-5 | KEI_PROMPT에 tables[] 스키마 + 판단 규칙 추가 |
|
||||
| B-4/5 동기화 | fallback system_prompt에 role, info_structure, images[], tables[] 동기화 완료. manual_classify에도 동기화 |
|
||||
| B-8 | fallback 블록 `topic-header`. char_guide 없음(편집자 자체 판단). BLOCK_SLOTS/defaults/catalog/템플릿 전부 정합 |
|
||||
177
docs/history/IMPROVEMENT-PHASE-C.md
Normal file
177
docs/history/IMPROVEMENT-PHASE-C.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Phase C: 디자인 원칙 위반 수정 — 실행 상세
|
||||
|
||||
> CLAUDE.md 디자인 원칙 위반 사항 수정. 실작업 3개 (C-2는 이미 완료).
|
||||
> 원칙: 하드코딩 금지. 모든 CSS 값은 디자인 토큰. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
[독립] C-1 (CLAUDE.md 원칙 완화), C-3 (border-radius), C-4 (box-shadow)
|
||||
모두 독립 작업. 병렬 가능.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C-1: CLAUDE.md 원칙 완화 (gradient 허용 범위 확대)
|
||||
|
||||
### 현재 상태
|
||||
- CLAUDE.md 327행: "HTML/CSS 블록의 배경 그라데이션 금지 (SVG 시각화 블록은 예외)"
|
||||
- 실제 코드: banner-gradient, quote-question, card-dark-overlay 등에서 linear-gradient 사용
|
||||
- 사용자 결정 (2026-03-25): banner-gradient의 그라데이션은 디자인의 핵심. 원칙을 완화.
|
||||
|
||||
### 작업
|
||||
CLAUDE.md 327행 문구 변경:
|
||||
|
||||
```
|
||||
현재: "HTML/CSS 블록의 배경 그라데이션 금지 (SVG 시각화 블록은 예외)"
|
||||
변경: "HTML/CSS 블록의 배경 그라데이션 금지 (SVG 시각화 블록, 디자인 의도가 명확한 블록(배너, 오버레이, 콜아웃 등)은 허용)"
|
||||
```
|
||||
|
||||
### 충돌/회귀
|
||||
- 코드 변경 없음. 문서만 업데이트.
|
||||
- 기존 코드(banner-gradient 등)를 원칙에 맞추는 것이므로 회귀 아님.
|
||||
|
||||
### 수정 파일
|
||||
- `CLAUDE.md`
|
||||
|
||||
---
|
||||
|
||||
## C-2: ~~hover 효과 제거~~ → Phase A-8에서 이미 완료
|
||||
|
||||
- compare-3col-badge.html의 `tr:hover` 규칙이 A-8 작업 시 삭제됨
|
||||
- _legacy/comparison-table.html에만 :hover 남아있으나 _legacy이므로 무관
|
||||
- **작업 불필요**
|
||||
|
||||
---
|
||||
|
||||
## C-3: border-radius > 8px → var(--radius) 통일
|
||||
|
||||
### 현재 상태
|
||||
- CLAUDE.md: "둥근 모서리 과다 사용 금지 (border-radius 최대 8px)"
|
||||
- 디자인 토큰: `--radius: 6px` (tokens.css:38)
|
||||
- 9개 파일에서 10~25px 사용 중 (위반)
|
||||
- compare-pill-pair (60px, 55px)는 Phase E-2 SVG 전환 시 해소 → 지금 보류
|
||||
|
||||
### 작업
|
||||
9개 파일의 위반 값을 모두 `var(--radius)` 로 변경. px 직접값 0개.
|
||||
|
||||
| 파일 | 셀렉터 | 현재 | 변경 |
|
||||
|------|--------|------|------|
|
||||
| compare-3col-badge.html:68 | `.th-badge` | 25px | var(--radius) |
|
||||
| card-dark-overlay.html:33 | `.cd-card` | 10px | var(--radius) |
|
||||
| card-text-grid.html:46 | `.card-category` | 12px | var(--radius) |
|
||||
| card-compare-3col.html:38 | `.cc-card` | 10px | var(--radius) |
|
||||
| card-stat-number.html:31 | `.st-card` | 10px | var(--radius) |
|
||||
| quote-question.html:18 | `.block-quote-q` | 12px | var(--radius) |
|
||||
| quote-big-mark.html:21 | `.block-quote-big` | 10px | var(--radius) |
|
||||
| callout-warning.html:21 | `.block-callout-warn` | 12px | var(--radius) |
|
||||
| callout-solution.html:22 | `.block-callout-sol` | 12px | var(--radius) |
|
||||
|
||||
### 하드코딩 점검
|
||||
- 모든 값이 `var(--radius)` → 디자인 토큰 사용 ✅
|
||||
- px 직접값 0개 ✅
|
||||
- th-badge도 예외 없이 `var(--radius)` — badge가 덜 둥글지만 원칙 우선
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. CSS 값만 변경. overflow:hidden 있는 .cd-card에서도 radius 줄여도 무관
|
||||
- 회귀: 없음. CLAUDE.md 원칙 준수 방향
|
||||
- 디자인 영향: 10~12px → 6px은 시각적 차이 미미. 25px → 6px은 badge가 약간 덜 둥글어짐.
|
||||
|
||||
### 수정 파일
|
||||
- 9개 HTML 파일 (각 1줄씩)
|
||||
|
||||
---
|
||||
|
||||
## C-4: circle-gradient box-shadow 2레벨 → 1레벨
|
||||
|
||||
### 현재 상태
|
||||
- circle-gradient.html:31: `box-shadow: 0 0 30px rgba(0, 106, 255, 0.25), 0 0 60px rgba(0, 106, 255, 0.1);`
|
||||
- CLAUDE.md: "그림자(box-shadow) 최소화 (1개 레벨만)"
|
||||
|
||||
### 작업
|
||||
두 번째 shadow(60px, 0.1) 제거:
|
||||
|
||||
```css
|
||||
/* 현재 */
|
||||
box-shadow: 0 0 30px rgba(0, 106, 255, 0.25), 0 0 60px rgba(0, 106, 255, 0.1);
|
||||
|
||||
/* 변경 */
|
||||
box-shadow: 0 0 30px rgba(0, 106, 255, 0.25);
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- rgba 색상값은 그라데이션 원의 글로우 색상. 디자인 토큰에 글로우용 변수는 없음.
|
||||
- Phase E-1(SVG 전환) 시 CSS box-shadow가 SVG filter로 대체되면 이 값 자체가 없어짐.
|
||||
- 현 단계에서는 기존 색상값 유지가 적절.
|
||||
|
||||
### 충돌/회귀
|
||||
- 충돌: 없음. 시각적 변화만 (글로우 범위 축소)
|
||||
- 회귀: 없음
|
||||
|
||||
### 수정 파일
|
||||
- `templates/blocks/visuals/circle-gradient.html`
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `CLAUDE.md` | C-1 | 원칙 문구 완화 (1줄) |
|
||||
| `templates/blocks/tables/compare-3col-badge.html` | C-3 | border-radius 25px → var(--radius) |
|
||||
| `templates/blocks/cards/card-dark-overlay.html` | C-3 | border-radius 10px → var(--radius) |
|
||||
| `templates/blocks/cards/card-text-grid.html` | C-3 | border-radius 12px → var(--radius) |
|
||||
| `templates/blocks/cards/card-compare-3col.html` | C-3 | border-radius 10px → var(--radius) |
|
||||
| `templates/blocks/cards/card-stat-number.html` | C-3 | border-radius 10px → var(--radius) |
|
||||
| `templates/blocks/emphasis/quote-question.html` | C-3 | border-radius 12px → var(--radius) |
|
||||
| `templates/blocks/emphasis/quote-big-mark.html` | C-3 | border-radius 10px → var(--radius) |
|
||||
| `templates/blocks/emphasis/callout-warning.html` | C-3 | border-radius 12px → var(--radius) |
|
||||
| `templates/blocks/emphasis/callout-solution.html` | C-3 | border-radius 12px → var(--radius) |
|
||||
| `templates/blocks/visuals/circle-gradient.html` | C-4 | box-shadow 2레벨 → 1레벨 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] C-1: CLAUDE.md에 gradient 허용 범위 확대 문구 반영
|
||||
- [ ] C-3: 9개 파일에서 border-radius가 모두 var(--radius) 또는 8px 이하
|
||||
- [ ] C-3: px 직접값 10px 이상이 templates/blocks/ (비 _legacy)에 없음
|
||||
- [ ] C-4: circle-gradient.html에 box-shadow가 1개만
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. th-badge도 var(--radius) 통일 (px 직접값 0개). |
|
||||
| 2026-03-25 | Phase C 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| C-1 | CLAUDE.md에 "디자인 의도가 명확한 블록은 허용" 문구 반영 |
|
||||
| C-2 | Phase A-8에서 이미 완료 (작업 없음) |
|
||||
| C-3 | 9개 파일 border-radius → `var(--radius)` 변경. 전수 검증 결과 위반 0건 (compare-pill-pair만 Phase E-2 대기) |
|
||||
| C-4 | circle-gradient.html box-shadow 2레벨 → 1레벨. 쉼표 0개 확인 |
|
||||
|
||||
### C-1 구현 결과
|
||||
- CLAUDE.md 327행: "HTML/CSS 블록의 배경 그라데이션 금지" → "디자인 의도가 명확한 블록(배너, 오버레이, 콜아웃 등)은 허용"으로 완화
|
||||
|
||||
### C-3 구현 결과 (9개 파일)
|
||||
- compare-3col-badge.html `.th-badge`: 25px → `var(--radius)`
|
||||
- card-dark-overlay.html `.cd-card`: 10px → `var(--radius)`
|
||||
- card-text-grid.html `.card-category`: 12px → `var(--radius)`
|
||||
- card-compare-3col.html `.cc-card`: 10px → `var(--radius)`
|
||||
- card-stat-number.html `.st-card`: 10px → `var(--radius)`
|
||||
- quote-question.html `.block-quote-q`: 12px → `var(--radius)`
|
||||
- quote-big-mark.html `.block-quote-big`: 10px → `var(--radius)`
|
||||
- callout-warning.html `.block-callout-warn`: 12px → `var(--radius)`
|
||||
- callout-solution.html `.block-callout-sol`: 12px → `var(--radius)`
|
||||
- px 직접값 > 8px: **0건** (compare-pill-pair는 Phase E-2 대기)
|
||||
|
||||
### C-4 구현 결과
|
||||
- circle-gradient.html:31 — `box-shadow: 0 0 30px rgba(0,106,255,0.25), 0 0 60px rgba(0,106,255,0.1)` → `box-shadow: 0 0 30px rgba(0,106,255,0.25)` (두 번째 제거)
|
||||
354
docs/history/IMPROVEMENT-PHASE-D.md
Normal file
354
docs/history/IMPROVEMENT-PHASE-D.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Phase D: 이미지 처리 — 실행 상세
|
||||
|
||||
> MDX 콘텐츠의 이미지 참조(``)를 감지하고,
|
||||
> 로컬 이미지 파일의 크기를 측정하여 배치 판단에 활용.
|
||||
> 서버가 localhost에서 돌므로 로컬 파일 접근 가능.
|
||||
> 원칙: 하드코딩 금지. 모든 판단은 AI 사고. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
D-0 (이미지 경로 입력 UI + API) ← 선행 필수
|
||||
→ D-1 (Pillow 유틸리티 + 이미지 추출)
|
||||
→ D-2 + D-3 (비율 기반 배치 — 프롬프트에 정보 전달)
|
||||
→ D-4 (텍스트 포함 도표 축소 방지)
|
||||
→ D-5 (HTML에 이미지 삽입 — base64 또는 절대 경로)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D-0: 이미지 경로 입력 UI + API 파라미터 (선행 작업)
|
||||
|
||||
### 현재 상태
|
||||
- static/index.html:176~179 — `fetch('/api/generate', { body: JSON.stringify({ content }) })` → 텍스트만 전송
|
||||
- src/main.py:35~36 — `SlideRequest` 모델에 `content: str`만
|
||||
- src/pipeline.py:27~29 — `generate_slide(content, manual_layout)` → base_path 없음
|
||||
|
||||
### 작업
|
||||
|
||||
**1) static/index.html — generate() 함수 수정**
|
||||
- 텍스트에서 `` 패턴 감지 (정규식)
|
||||
- 발견 시: "이미지가 포함된 콘텐츠입니다. 이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요" 팝업 (prompt)
|
||||
- 미발견 시: base_path 없이 기존처럼 전송
|
||||
- API 요청 body: `{ content, base_path }` (base_path는 선택)
|
||||
|
||||
```javascript
|
||||
// 이미지 참조 감지
|
||||
const imagePattern = /!\[.*?\]\((.*?)\)/g;
|
||||
const hasImages = imagePattern.test(content);
|
||||
let basePath = '';
|
||||
if (hasImages) {
|
||||
basePath = prompt(
|
||||
'이미지가 포함된 콘텐츠입니다.\n' +
|
||||
'이미지 파일이 있는 프로젝트 폴더 경로를 입력해주세요.\n' +
|
||||
'예: D:\\ad-hoc\\kei\\content'
|
||||
) || '';
|
||||
}
|
||||
// API 전송
|
||||
body: JSON.stringify({ content, base_path: basePath })
|
||||
```
|
||||
|
||||
**2) src/main.py — SlideRequest 모델 확장**
|
||||
```python
|
||||
class SlideRequest(BaseModel):
|
||||
content: str
|
||||
base_path: str = "" # 이미지 기준 폴더 (선택)
|
||||
```
|
||||
|
||||
**3) src/main.py — generate 엔드포인트에서 base_path 전달**
|
||||
```python
|
||||
async for event in generate_slide(req.content, base_path=req.base_path):
|
||||
```
|
||||
|
||||
**4) src/pipeline.py — generate_slide() 시그니처 확장**
|
||||
```python
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
base_path: str = "",
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- 없음. 사용자가 경로를 직접 입력. 코드에 경로 고정값 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- SlideRequest에 base_path 추가: 기본값 `""` → 기존 요청(base_path 없는)과 호환 ✅
|
||||
- generate_slide() 시그니처에 base_path 추가: 기본값 `""` → 기존 호출과 호환 ✅
|
||||
- index.html generate() 함수: 이미지 없으면 기존과 동일 동작 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `static/index.html` — generate() 함수
|
||||
- `src/main.py` — SlideRequest + generate 엔드포인트
|
||||
- `src/pipeline.py` — generate_slide() 시그니처
|
||||
|
||||
---
|
||||
|
||||
## D-1: Pillow 이미지 크기 읽기 유틸리티
|
||||
|
||||
### 현재 상태
|
||||
- Pillow import/사용 전무. pyproject.toml에도 없음. src/utils/ 디렉토리 없음.
|
||||
|
||||
### 작업
|
||||
|
||||
**1) pyproject.toml에 Pillow 추가**
|
||||
```toml
|
||||
dependencies = [
|
||||
...
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
```
|
||||
|
||||
**2) src/image_utils.py 신규 제작**
|
||||
```python
|
||||
"""이미지 크기 측정 유틸리티."""
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
def get_image_sizes(content: str, base_path: str) -> list[dict]:
|
||||
"""콘텐츠에서 이미지 참조를 추출하고 로컬 파일 크기를 측정한다.
|
||||
|
||||
Args:
|
||||
content: MDX/텍스트 콘텐츠
|
||||
base_path: 이미지 파일 기준 폴더 경로
|
||||
|
||||
Returns:
|
||||
[{"path": "/assets/images/DX1.png", "width": 800, "height": 600,
|
||||
"ratio": 1.33, "orientation": "landscape"}]
|
||||
"""
|
||||
import re
|
||||
if not base_path:
|
||||
return []
|
||||
|
||||
base = Path(base_path)
|
||||
images = []
|
||||
|
||||
for match in re.finditer(r'!\[.*?\]\((.*?)\)', content):
|
||||
rel_path = match.group(1).strip()
|
||||
# 상대 경로 해석
|
||||
abs_path = base / rel_path.lstrip('/')
|
||||
|
||||
if abs_path.exists() and abs_path.suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
|
||||
try:
|
||||
with Image.open(abs_path) as img:
|
||||
w, h = img.size # 헤더만 읽음
|
||||
ratio = w / h if h > 0 else 1.0
|
||||
orientation = "landscape" if ratio > 1.2 else ("portrait" if ratio < 0.8 else "square")
|
||||
images.append({
|
||||
"path": rel_path,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"ratio": round(ratio, 2),
|
||||
"orientation": orientation,
|
||||
})
|
||||
except Exception:
|
||||
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "unknown"})
|
||||
else:
|
||||
images.append({"path": rel_path, "width": 0, "height": 0, "ratio": 0, "orientation": "not_found"})
|
||||
|
||||
return images
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- ratio 기준 1.2, 0.8: CLAUDE.md 원문 "가로형(ratio > 1.2)", "세로형(ratio < 0.8)" — 문서 기준값이므로 하드코딩 아님 ✅
|
||||
- 이미지 확장자 목록: 웹 표준 이미지 포맷. 변할 일 없음 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 신규 파일 추가만. 기존 코드 변경 없음 ✅
|
||||
- Pillow는 `Image.open()` 시 헤더만 읽음 (전체 디코딩 안 함) → 성능 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `pyproject.toml` — Pillow 의존성 추가
|
||||
- 신규 `src/image_utils.py`
|
||||
|
||||
---
|
||||
|
||||
## D-2 + D-3: 비율 기반 배치 판단
|
||||
|
||||
### 현재 상태
|
||||
- 비율 기반 배치 판단 코드 없음
|
||||
- B-4에서 1단계 images[] 필드 추가했지만, 실제 크기 정보는 없음
|
||||
|
||||
### 작업
|
||||
pipeline.py에서 D-1 유틸리티 호출 → 이미지 크기/비율 정보를 2단계 Step B와 4단계 Sonnet에 전달
|
||||
|
||||
```python
|
||||
# pipeline.py generate_slide() 내, 2단계 전에:
|
||||
from src.image_utils import get_image_sizes
|
||||
|
||||
image_sizes = get_image_sizes(content, base_path)
|
||||
if image_sizes:
|
||||
# analysis에 이미지 크기 정보 추가
|
||||
analysis["image_sizes"] = image_sizes
|
||||
```
|
||||
|
||||
design_director.py `create_layout_concept()`에서 user_prompt에 이미지 정보 포함:
|
||||
```python
|
||||
# 이미지 크기 정보가 있으면 프롬프트에 포함
|
||||
if analysis.get("image_sizes"):
|
||||
image_info = "\n".join(
|
||||
f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
|
||||
for img in analysis["image_sizes"]
|
||||
)
|
||||
user_prompt += f"\n\n## 이미지 크기 정보\n{image_info}\n"
|
||||
```
|
||||
|
||||
→ Sonnet(팀장)이 이 정보를 보고 "가로형 → 전체 너비", "세로형 → 텍스트 옆" 등을 판단.
|
||||
|
||||
### 하드코딩 점검
|
||||
- 비율 판단: AI(팀장)가 orientation 정보를 보고 결정 ✅
|
||||
- 코드는 크기 정보를 전달만. 배치 결정은 AI ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- pipeline.py: 기존 흐름 사이에 이미지 측정 삽입. base_path 없으면 빈 리스트 → 영향 없음 ✅
|
||||
- design_director.py: user_prompt에 텍스트 추가만. 이미지 없으면 추가 안 함 ✅
|
||||
- analysis에 image_sizes 추가: 기존 코드는 `.get()` 패턴 → 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py` — generate_slide()에서 get_image_sizes() 호출
|
||||
- `src/design_director.py` — create_layout_concept()에서 이미지 정보 프롬프트 포함
|
||||
|
||||
---
|
||||
|
||||
## D-4: 텍스트 포함 도표 → 과도한 축소 방지
|
||||
|
||||
### 현재 상태
|
||||
- B-4에서 images[].has_text 필드 추가 (1단계 Kei가 판단)
|
||||
- 이 정보를 기반으로 "축소하지 마라" 가이드 없음
|
||||
|
||||
### 작업
|
||||
D-2/D-3의 이미지 정보 전달 시, has_text 정보도 함께 포함:
|
||||
|
||||
```python
|
||||
# pipeline.py에서 1단계 analysis의 images[]와 D-1의 image_sizes를 결합
|
||||
for img_size in image_sizes:
|
||||
# 1단계에서 판단한 has_text 정보 매칭
|
||||
for kei_img in analysis.get("images", []):
|
||||
if kei_img.get("description", "") in img_size.get("path", ""):
|
||||
img_size["has_text"] = kei_img.get("has_text", False)
|
||||
```
|
||||
|
||||
→ Sonnet 프롬프트에 "has_text: true인 이미지는 텍스트가 포함된 도표이므로 과도하게 축소하지 마라" 가이드
|
||||
|
||||
### 하드코딩 점검
|
||||
- 축소 여부: AI가 판단 ✅
|
||||
- has_text: 1단계 Kei가 판단 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 기존 데이터에 필드 추가만. 없으면 무시됨 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline.py`
|
||||
|
||||
---
|
||||
|
||||
## D-5: 슬라이드 HTML에 이미지 삽입
|
||||
|
||||
### 현재 상태
|
||||
- 이미지 블록(image-row, image-side-text 등)은 `{{ img.src }}` 슬롯에 경로를 넣지만
|
||||
- 다운로드 HTML에서 상대 경로는 깨짐 (로컬 파일이므로)
|
||||
|
||||
### 작업
|
||||
렌더링 완료 후, HTML의 이미지 src를 base64 data URI로 변환:
|
||||
|
||||
```python
|
||||
# renderer.py 또는 pipeline.py에서 최종 HTML 후처리
|
||||
import base64, re
|
||||
|
||||
def embed_images(html: str, base_path: str) -> str:
|
||||
"""HTML의 이미지 src를 base64 data URI로 변환."""
|
||||
if not base_path:
|
||||
return html
|
||||
|
||||
base = Path(base_path)
|
||||
|
||||
def replace_src(match):
|
||||
src = match.group(1)
|
||||
abs_path = base / src.lstrip('/')
|
||||
if abs_path.exists():
|
||||
mime = 'image/png' if abs_path.suffix == '.png' else 'image/jpeg'
|
||||
data = base64.b64encode(abs_path.read_bytes()).decode()
|
||||
return f'src="data:{mime};base64,{data}"'
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r'src="(/[^"]+\.(?:png|jpg|jpeg|gif|webp))"', replace_src, html)
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- MIME 타입: 확장자 기반 표준 매핑. 하드코딩 아님 ✅
|
||||
- 이미지 확장자: D-1과 동일 목록 ✅
|
||||
|
||||
### 충돌/회귀
|
||||
- 최종 HTML 후처리. 이미지 없으면 정규식 매칭 없음 → 기존과 동일 ✅
|
||||
- base_path 없으면 함수 즉시 반환 → 안전 ✅
|
||||
|
||||
### 수정 파일
|
||||
- `src/image_utils.py` (embed_images 함수 추가) 또는 `src/pipeline.py`
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `static/index.html` | D-0 | generate() 함수에 이미지 감지 + 경로 입력 팝업 |
|
||||
| `src/main.py` | D-0 | SlideRequest에 base_path 추가 + 엔드포인트 전달 |
|
||||
| `src/pipeline.py` | D-0, D-2~D-4 | generate_slide() 시그니처 + 이미지 크기 측정 + 프롬프트 전달 |
|
||||
| `pyproject.toml` | D-1 | Pillow 의존성 추가 |
|
||||
| 신규 `src/image_utils.py` | D-1, D-5 | get_image_sizes() + embed_images() |
|
||||
| `src/design_director.py` | D-2, D-3 | user_prompt에 이미지 크기/비율 정보 포함 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] D-0: 이미지 없는 텍스트 → 팝업 안 뜸. 기존과 동일 동작
|
||||
- [ ] D-0: 이미지 있는 MDX → 팝업 뜨고 경로 입력 → API에 base_path 전달
|
||||
- [ ] D-0: 팝업에서 취소 → base_path="" → 이미지 처리 스킵 (에러 없음)
|
||||
- [ ] D-1: Pillow로 이미지 크기 측정. 파일 없으면 width=0, orientation="not_found"
|
||||
- [ ] D-2/D-3: Sonnet 프롬프트에 이미지 크기/orientation 정보 포함
|
||||
- [ ] D-4: has_text=true 이미지에 대해 "축소 금지" 가이드 전달
|
||||
- [ ] D-5: 다운로드 HTML에서 이미지가 base64로 삽입되어 보임
|
||||
- [ ] 이미지 없는 기존 콘텐츠: 전체 파이프라인 기존과 동일하게 동작 (회귀 없음)
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. D-0(선행 UI) 추가. D-5(HTML 이미지 삽입) 추가. 6개 항목. |
|
||||
| 2026-03-25 | Phase D 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| D-0 | SlideRequest에 base_path="" 기본값 추가. generate_slide()에 base_path 파라미터 추가. index.html에 이미지 감지+팝업. 기존 요청(base_path 없는) 호환 ✅ |
|
||||
| D-1 | pyproject.toml에 Pillow>=10.0 추가. src/image_utils.py 신규 (get_image_sizes + embed_images). Pillow 10.4.0 설치 확인. base_path 없으면 빈 리스트 반환(안전) |
|
||||
| D-2+D-3 | pipeline.py에서 get_image_sizes() 호출 → analysis["image_sizes"] 저장. design_director.py user_prompt에 이미지 크기/orientation/배치 가이드 포함. 이미지 없으면 추가 안 함(기존 동일) |
|
||||
| D-4 | design_director.py에서 has_text=true 이미지에 "(텍스트 포함 도표 — 과도한 축소 금지)" 가이드 자동 추가 |
|
||||
| D-5 | pipeline.py에서 최종 HTML 반환 전 embed_images(html, base_path) 호출. base_path 없으면 원본 그대로 반환(안전) |
|
||||
|
||||
### D-0 구현 결과
|
||||
- `src/main.py`: SlideRequest에 `base_path: str = ""` 추가. generate 엔드포인트에서 `base_path=req.base_path` 전달.
|
||||
- `src/pipeline.py`: generate_slide() 시그니처에 `base_path: str = ""` 추가.
|
||||
- `static/index.html`: generate() 함수에서 `` 패턴 감지 → prompt()로 경로 입력 → API에 `{ content, base_path }` 전송. 취소 시 `base_path=""` → 이미지 처리 스킵.
|
||||
|
||||
### D-1 구현 결과
|
||||
- `pyproject.toml`: `"Pillow>=10.0"` 의존성 추가.
|
||||
- `src/image_utils.py` 신규:
|
||||
- `get_image_sizes(content, base_path)`: MDX에서 `` 추출 → base_path + 상대경로 해석 → Pillow `Image.open().size` → {path, width, height, ratio, orientation} 반환
|
||||
- `embed_images(html, base_path)`: HTML의 `src="/...png"` → `src="data:image/png;base64,..."` 변환
|
||||
- 파일 미발견/에러 시 orientation="not_found"/"error" → 에러 없이 계속 진행
|
||||
|
||||
### D-2+D-3+D-4 구현 결과
|
||||
- `src/pipeline.py`: 1단계 완료 직후 `get_image_sizes()` 호출 → `analysis["image_sizes"]` 저장
|
||||
- `src/design_director.py`: user_prompt에 이미지 정보 섹션 추가 — 각 이미지의 크기/orientation + "가로형 → 전체 너비", "세로형 → 텍스트 옆", "텍스트 포함 도표 → 축소 금지" 가이드
|
||||
- has_text 정보는 1단계 Kei의 images[].has_text와 D-1 크기 정보가 결합됨
|
||||
|
||||
### D-5 구현 결과
|
||||
- `src/pipeline.py`: yield result 직전에 `embed_images(html, base_path)` 호출
|
||||
- base_path 없으면 원본 HTML 그대로 반환 (기존과 동일)
|
||||
271
docs/history/IMPROVEMENT-PHASE-G.md
Normal file
271
docs/history/IMPROVEMENT-PHASE-G.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Phase G: Kei API 통신 정상화 — 실행 상세
|
||||
|
||||
> Kei persona_agent와의 통신이 실패하는 근본 원인 해결.
|
||||
> **design_agent만 수정. persona_agent 코드 수정 0건.**
|
||||
> 원칙: Kei API만 사용. Sonnet fallback 금지. 하드코딩 금지. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 문제 진단 요약
|
||||
|
||||
Kei API 호출 시 30분~1시간 무응답 후 BrokenResourceError로 끊김.
|
||||
원인은 design_agent가 SSE 스트리밍 응답을 non-streaming 방식으로 받고 있어서,
|
||||
persona_agent의 전체 파이프라인(RAG + Opus planning + Sonnet 응답)이 끝날 때까지 대기.
|
||||
|
||||
---
|
||||
|
||||
## G-1: httpx non-streaming → streaming 전환 (핵심)
|
||||
|
||||
### 문제
|
||||
```python
|
||||
# 현재 코드 (3개 파일 동일 패턴)
|
||||
response = await client.post(url, json={...}, timeout=None)
|
||||
full_text = response.text # ← 전체 응답 완료까지 대기 (30분+)
|
||||
```
|
||||
|
||||
persona_agent는 `EventSourceResponse`로 SSE 스트리밍 응답을 보냄.
|
||||
httpx `client.post()`는 응답 body 전체가 끝날 때까지 버퍼링.
|
||||
persona_agent 파이프라인(RAG→Opus→Sonnet)이 전부 끝나야 response.text 반환.
|
||||
|
||||
### 해결
|
||||
`httpx.AsyncClient.stream()` 사용하여 SSE 토큰을 실시간 수신:
|
||||
|
||||
```python
|
||||
async with client.stream("POST", url, json={...}) as response:
|
||||
async for line in response.aiter_lines():
|
||||
# SSE 이벤트를 한 줄씩 실시간 처리
|
||||
if line.startswith("event:"):
|
||||
event_type = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
event_data = line[5:].strip()
|
||||
if event_type == "token":
|
||||
tokens.append(parse_token(event_data))
|
||||
elif event_type == "done":
|
||||
break
|
||||
```
|
||||
|
||||
### 수정 파일 (3개)
|
||||
- `src/kei_client.py` — `_call_kei_api()`: 1단계 Kei 실장
|
||||
- `src/content_editor.py` — `_call_kei_editor()`: 3단계 Kei 편집자
|
||||
- `src/design_director.py` — `_opus_block_recommendation()`: 2단계 Opus 추천
|
||||
|
||||
### 충돌/회귀
|
||||
- persona_agent 변경 없음 ✅
|
||||
- SSE 이벤트 형식(event:/data:) 동일 — 파싱 로직만 실시간으로 전환
|
||||
- `_extract_sse_text()` 함수를 `_stream_sse_text()`로 대체 (streaming 버전)
|
||||
|
||||
---
|
||||
|
||||
## G-2: Sonnet fallback 완전 제거
|
||||
|
||||
### 문제
|
||||
사용자 요청: "무조건 늦더라도 persona agent에게 요청해서 답을 받아"
|
||||
현재 코드: Kei API 실패 → Sonnet 직접 호출로 fallback
|
||||
|
||||
### 해결
|
||||
- `kei_client.py`: `_call_anthropic_direct()` 호출 제거. Kei API 실패 시 에러 반환 또는 재시도.
|
||||
- `content_editor.py`: Sonnet fallback 제거. Kei API만 사용.
|
||||
- Kei API 실패 시: 로그에 에러 기록 + SSE로 사용자에게 "Kei API 연결 실패" 알림
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py` — `classify_content()`에서 Sonnet fallback 분기 제거
|
||||
- `src/content_editor.py` — `fill_content()`에서 Sonnet fallback 분기 제거
|
||||
|
||||
### 충돌/회귀
|
||||
- `_call_anthropic_direct()` 함수는 남겨도 됨 (호출만 안 함). 또는 삭제.
|
||||
- manual_classify()는 유지 (Kei API 자체가 완전히 불가능할 때의 최소 안전망)
|
||||
|
||||
---
|
||||
|
||||
## G-3: `_parse_json()` 마크다운 제거 — 3개 파일 동기화
|
||||
|
||||
### 문제
|
||||
`kei_client.py`에만 `- ` 접두사 제거 로직이 있고,
|
||||
`content_editor.py`와 `design_director.py`의 `_parse_json()`에는 없음.
|
||||
Kei가 마크다운으로 JSON을 감싸면 3단계/2단계에서 파싱 실패.
|
||||
|
||||
### 해결
|
||||
`content_editor.py`와 `design_director.py`의 `_parse_json()`에
|
||||
`kei_client.py`와 동일한 마크다운 접두사 제거 전처리 추가.
|
||||
|
||||
### 수정 파일
|
||||
- `src/content_editor.py` — `_parse_json()`
|
||||
- `src/design_director.py` — `_parse_json()`
|
||||
|
||||
### 충돌/회귀
|
||||
- 정상 JSON은 기존과 동일하게 파싱 (원본 먼저 시도 → 클린 버전 시도)
|
||||
- 마크다운 JSON만 추가로 파싱 가능해짐
|
||||
|
||||
---
|
||||
|
||||
## G-4: FAISS를 CPU로 전환 (GPU 메모리 경쟁 해소)
|
||||
|
||||
### 문제
|
||||
persona_agent가 GPU에 bge-m3 + reranker 로딩 (수 GB).
|
||||
design_agent의 FAISS도 bge-m3을 GPU에 로드하려고 시도 → OOM:
|
||||
```
|
||||
not enough memory: you tried to allocate 1024008192 bytes
|
||||
```
|
||||
|
||||
### 해결
|
||||
`src/block_search.py`에서 SentenceTransformer를 CPU로 강제:
|
||||
|
||||
```python
|
||||
_model = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
|
||||
```
|
||||
|
||||
`scripts/build_block_index.py`에서도 동일하게 CPU 지정.
|
||||
|
||||
46개 블록 검색은 CPU로도 충분히 빠름 (< 1초).
|
||||
|
||||
### 수정 파일
|
||||
- `src/block_search.py` — `_ensure_loaded()`에서 device="cpu"
|
||||
- `scripts/build_block_index.py` — SentenceTransformer에 device="cpu"
|
||||
|
||||
### 충돌/회귀
|
||||
- persona_agent 영향 없음 (GPU 독점 사용 가능)
|
||||
- 검색 속도: GPU 0.03초 → CPU 0.1~0.3초 (46개 블록 기준, 무시할 수준)
|
||||
|
||||
---
|
||||
|
||||
## G-5: streaming 파서에 event: error 처리 추가 (정밀 검토에서 발견)
|
||||
|
||||
### 문제
|
||||
persona_agent가 에러 발생 시 `event: error` SSE 이벤트를 보냄.
|
||||
현재 design_agent는 `token`과 `done`만 처리하고 `error`를 무시.
|
||||
→ streaming 전환 후 persona_agent 에러 시 `done`을 기다리며 **무한 대기**.
|
||||
|
||||
### 해결
|
||||
3개 파일의 streaming 파서에서 `event: error` 시 즉시 중단 + 에러 로그:
|
||||
|
||||
```python
|
||||
elif event_type == "error":
|
||||
logger.warning(f"Kei API 에러: {data}")
|
||||
break # 즉시 중단
|
||||
```
|
||||
|
||||
또한 `planning`, `planning_done`, `research_progress`, `warning` 이벤트는 스킵 (기존 동작 유지).
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py`, `src/content_editor.py`, `src/design_director.py` (G-1과 같은 위치)
|
||||
|
||||
---
|
||||
|
||||
## G-6: content_editor.py None 가드 추가 (정밀 검토에서 발견)
|
||||
|
||||
### 문제
|
||||
G-2에서 Sonnet fallback 제거 후, Kei API 실패 시 `result_text = None`.
|
||||
`_parse_json(None)` 호출 → TypeError/AttributeError 발생.
|
||||
except로 잡히지만 불필요한 예외.
|
||||
|
||||
### 해결
|
||||
```python
|
||||
result_text = await _call_kei_editor(user_prompt)
|
||||
if result_text is None:
|
||||
logger.warning("Kei API 편집 실패. 기본값 적용.")
|
||||
_apply_defaults(blocks)
|
||||
continue # 다음 페이지로
|
||||
```
|
||||
|
||||
### 수정 파일
|
||||
- `src/content_editor.py` — `fill_content()` 내
|
||||
|
||||
---
|
||||
|
||||
## G-7: `"mode"` → `"mode_hint"` 필드명 수정 (정밀 검토에서 발견)
|
||||
|
||||
### 문제
|
||||
design_agent가 `"mode": "chat"`을 보내지만, persona_agent의 `UnifiedMessageRequest`는
|
||||
`mode_hint` 필드를 사용. `mode`는 무시됨. 현재 우연히 chat으로 분류되지만 명시적이지 않음.
|
||||
|
||||
### 해결
|
||||
3개 호출처에서 `"mode": "chat"` → `"mode_hint": "chat"` 변경.
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py` — `_call_kei_api()` json body
|
||||
- `src/content_editor.py` — `_call_kei_editor()` json body
|
||||
- `src/design_director.py` — `_opus_block_recommendation()` json body
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `src/kei_client.py` | G-1, G-2, G-5, G-7 | httpx streaming + Sonnet fallback 제거 + error 처리 + mode_hint |
|
||||
| `src/content_editor.py` | G-1, G-2, G-3, G-5, G-6, G-7 | httpx streaming + fallback 제거 + _parse_json 동기화 + error 처리 + None 가드 + mode_hint |
|
||||
| `src/design_director.py` | G-1, G-3, G-5, G-7 | httpx streaming + _parse_json 동기화 + error 처리 + mode_hint |
|
||||
| `src/block_search.py` | G-4 | SentenceTransformer device="cpu" |
|
||||
| `scripts/build_block_index.py` | G-4 | SentenceTransformer device="cpu" |
|
||||
|
||||
**persona_agent 수정: 0건**
|
||||
|
||||
---
|
||||
|
||||
## 예상 효과
|
||||
|
||||
| 항목 | 현재 | 수정 후 |
|
||||
|------|------|--------|
|
||||
| 1단계 Kei API 대기 | 30분+ (전체 완료 대기) → 실패 | 토큰 실시간 수신 → 정상 완료 |
|
||||
| 3단계 Kei API 대기 | 6분+ → 간헐적 실패 | 토큰 실시간 수신 → 안정적 |
|
||||
| 2단계 Opus 추천 | 6분 대기 | 토큰 실시간 수신 → 체감 빨라짐 |
|
||||
| Sonnet fallback | Kei 실패 시 자동 전환 | 제거. Kei만 사용. |
|
||||
| GPU OOM | FAISS + persona 경쟁 | FAISS CPU 전환 → 경쟁 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] G-1: Kei API SSE 토큰이 실시간으로 수신되는지 (로그에 토큰 출력)
|
||||
- [ ] G-1: 30분 무응답 없이 정상 완료
|
||||
- [ ] G-2: Kei API 실패 시 Sonnet fallback이 발동하지 않는지
|
||||
- [ ] G-3: content_editor, design_director에서 마크다운 JSON 파싱 성공
|
||||
- [ ] G-4: FAISS 로드 시 GPU OOM 없음. CPU에서 정상 동작.
|
||||
- [ ] G-5: persona_agent 에러 시 무한 대기 안 하고 즉시 중단
|
||||
- [ ] G-6: Kei API 실패 시 TypeError 없이 _apply_defaults 적용
|
||||
- [ ] G-7: persona_agent 로그에 mode_hint=chat 확인
|
||||
- [ ] persona_agent 코드 변경 0건 확인
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안. Kei API 통신 실패 원인 5개 진단 + 4개 수정 항목 정리. |
|
||||
| 2026-03-25 | 정밀 검토로 G-5/G-6/G-7 추가. 총 7개 항목. |
|
||||
| 2026-03-26 | Phase G 전체 구현 완료. 검증 통과. |
|
||||
|
||||
## 구현 완료 확인
|
||||
|
||||
| 항목 | 검증 결과 |
|
||||
|------|----------|
|
||||
| G-1 | 3개 파일 모두 `client.stream("POST")` + `response.aiter_lines()` 전환. 기존 `client.post()` 0건. `_stream_sse_tokens()` 함수로 SSE 실시간 수신. |
|
||||
| G-2 | kei_client.py: `_call_anthropic_direct()` 호출 제거. content_editor.py: Sonnet fallback 분기 제거. Sonnet 직접 호출 0건. |
|
||||
| G-3 | content_editor.py + design_director.py의 `_parse_json()`에 마크다운 `- ` 접두사 제거 전처리 추가. "원본 먼저 → 클린 버전" 순서 유지. kei_client.py와 동기화. |
|
||||
| G-4 | block_search.py + build_block_index.py: `SentenceTransformer(EMBEDDING_MODEL, device="cpu")`. persona_agent GPU 독점 가능. |
|
||||
| G-5 | 3개 파일의 `_stream_sse_tokens()`에서 `event_type == "error"` 시 로그 + break. 무한 대기 방지. |
|
||||
| G-6 | content_editor.py: Kei API 실패(`result_text is None`) 시 `_apply_defaults(blocks); continue`. `_parse_json(None)` TypeError 방지. |
|
||||
| G-7 | 3개 파일의 API 요청 body에서 `"mode": "chat"` → `"mode_hint": "chat"`. persona_agent의 실제 필드명에 맞춤. |
|
||||
|
||||
### 파일별 구현 상세
|
||||
|
||||
**kei_client.py:**
|
||||
- `classify_content()`: Sonnet fallback 6줄 제거. Kei API 실패 → None 반환 → pipeline.py manual_classify() 안전망.
|
||||
- `_call_kei_api()`: `client.post()` → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
|
||||
- `_stream_sse_tokens()`: 신규 함수. `aiter_lines()`로 SSE 실시간 수신. token/done/error 처리.
|
||||
|
||||
**content_editor.py:**
|
||||
- `fill_content()`: Sonnet fallback 7줄 제거. `result_text is None` 시 `_apply_defaults(blocks); continue`.
|
||||
- `_call_kei_editor()`: `client.post()` → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
|
||||
- `_stream_sse_tokens()`: 신규 함수. kei_client.py와 동일 패턴.
|
||||
- `_parse_json()`: 마크다운 `- ` 접두사 제거 전처리 추가. 원본 먼저 → 클린 버전.
|
||||
|
||||
**design_director.py:**
|
||||
- `_opus_block_recommendation()`: 인라인 SSE 파싱 30줄 → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
|
||||
- `_stream_sse_tokens()`: 신규 함수. 동일 패턴.
|
||||
- `_parse_json()`: 마크다운 제거 전처리 추가. 원본 먼저 → 클린 버전.
|
||||
- `import httpx` 모듈 레벨로 이동 (기존 지역 import → `_stream_sse_tokens`에서도 참조 가능).
|
||||
|
||||
**block_search.py + build_block_index.py:**
|
||||
- `SentenceTransformer(EMBEDDING_MODEL)` → `SentenceTransformer(EMBEDDING_MODEL, device="cpu")`
|
||||
432
docs/history/IMPROVEMENT-PHASE-H.md
Normal file
432
docs/history/IMPROVEMENT-PHASE-H.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Phase H: 스토리라인 설계 + 컨셉 구체화 기반 파이프라인 전환 — 실행 상세
|
||||
|
||||
> 1단계를 A/B로 분리하여 정확도 향상.
|
||||
> 1단계-A: 전체 스토리라인 설계 (큰 그림)
|
||||
> 1단계-B: 각 꼭지 순회하며 컨셉 구체화 (세부 판단)
|
||||
> 원칙: 하드코딩 금지. 원본 텍스트 최대 보존. 회귀 금지.
|
||||
|
||||
---
|
||||
|
||||
## 문제 진단 (Phase H 1차 실행 결과 포함)
|
||||
|
||||
### 문제 1: 1단계가 "꼭지 추출"만 함 — 스토리라인 설계 없음
|
||||
|
||||
현재 KEI_PROMPT: "본문에서 핵심 꼭지 2~5개를 추출해줘"
|
||||
- 꼭지가 개별 덩어리로 나옴
|
||||
- 각 꼭지가 슬라이드 안에서 왜 그 위치에 있어야 하는지 맥락 없음
|
||||
- 결과: 제목 중복, 빈 블록, 맥락 없는 배치
|
||||
|
||||
있어야 하는 것:
|
||||
```
|
||||
핵심 메시지: "BIM은 DX의 일부분이다"
|
||||
스토리 흐름:
|
||||
(1) 문제 제기 → (2) 근거/사례 → (3) 핵심 전달 → (4) 용어 정의 → (5) 결론
|
||||
```
|
||||
|
||||
### 문제 2: 3단계 편집자가 텍스트를 과도하게 재작성
|
||||
|
||||
현재 EDITOR_PROMPT: "세련된 표현으로 편집한다"
|
||||
- 원본이 이미 충분히 정리된 텍스트인데 2줄로 압축
|
||||
- description 슬롯을 비워둠
|
||||
- 원본 분량 수준에서는 약간의 편집만 필요
|
||||
|
||||
### 문제 3: 2단계 팀장이 블록의 "목적"을 모름
|
||||
|
||||
현재: 꼭지 제목만 보고 블록 형태 매칭
|
||||
- "용어 혼용 문제" → callout? quote? 무작위
|
||||
- 블록의 목적(문제제기? 근거? 정의?)을 모르고 형태만 매칭
|
||||
|
||||
---
|
||||
|
||||
### 문제 4: 1차 실행에서 발견 — 실장이 한 번에 너무 많이 함
|
||||
|
||||
H-1~H-4 적용 후 실행 결과:
|
||||
- 스토리 흐름은 생겼지만 **블록 선택이 여전히 잘못됨**
|
||||
- section-title-with-bg(500px)가 body에 들어감 → 아래 블록 안 보임
|
||||
- compare-pill-pair에 비교 내용 없이 라벨만 (BIM VS DX)
|
||||
- **GIS → BIM → 디지털트윈을 flow-arrow로 표현** — 이건 순서/흐름이 아니라 **기술 융합 구조**인데 잘못 판단
|
||||
- 원본의 12행 상세 비교표(DX vs BIM)를 무시
|
||||
|
||||
**근본 원인:** 실장이 한 번의 호출로 정보구조 + 스토리라인 + 꼭지 + purpose + 관계 성격을 전부 판단하려니 **대충 하거나 놓침**
|
||||
|
||||
### 문제 5: 실장이 팀장에게 넘기는 정보가 부족
|
||||
|
||||
현재 실장 → 팀장 전달:
|
||||
```
|
||||
"꼭지 4: DX와 핵심기술간 상호관계, purpose: 구조시각화"
|
||||
```
|
||||
→ 팀장: "구조시각화면... flow-arrow? layer-diagram? venn-diagram?" → 아무거나 선택
|
||||
|
||||
있어야 하는 전달:
|
||||
```
|
||||
"꼭지 4: DX와 핵심기술간 상호관계
|
||||
- 관계 성격: 기술 융합 (순서 아님, 발전 단계 아님)
|
||||
- GIS, BIM, 디지털트윈이 DX의 구성요소
|
||||
- 포함 관계 또는 융합 관계로 표현
|
||||
- flow/순서 표현 금지"
|
||||
```
|
||||
→ 팀장: "포함 관계면 venn-diagram"
|
||||
|
||||
---
|
||||
|
||||
## 개선 방향: 1단계를 A/B로 분리
|
||||
|
||||
### 현재 (1회 호출, 부담 과중)
|
||||
```
|
||||
1단계: 전부 한 번에
|
||||
정보구조 + 핵심메시지 + 스토리라인 + 꼭지추출 + purpose + role + 관계성격 + 이미지/표
|
||||
→ 정확도 낮음. 대충 처리.
|
||||
```
|
||||
|
||||
### 변경 (2회 호출, 각각 집중)
|
||||
```
|
||||
1단계-A: 전체 스토리라인 설계 (큰 그림)
|
||||
- 콘텐츠 전체를 읽고 핵심 메시지 파악
|
||||
- 스토리 흐름 설계 (문제→근거→핵심→정의→결론)
|
||||
- 꼭지 추출 + purpose + role + layer
|
||||
- 출력: topics[] (기존과 동일 구조)
|
||||
|
||||
1단계-B: 각 꼭지 컨셉 구체화 (세부 판단) — 1단계-A 결과를 순회
|
||||
- 각 꼭지의 내용을 원본에서 다시 확인
|
||||
- 관계 성격 판단: 순서? 포함? 비교? 나열? 정의?
|
||||
- 표현 방법 제안: "이건 융합 관계 → venn/포함", "이건 수단-목적 → 비교표"
|
||||
- 원본에 있는 데이터(비교표, 사례, 출처) 누락 없이 확인
|
||||
- 출력: 각 topic에 concept 필드 추가
|
||||
|
||||
→ 이 결과를 디자인 팀장에게 넘김
|
||||
```
|
||||
|
||||
### 장점
|
||||
- 각 호출의 부담 감소 → 정확도 향상
|
||||
- 1단계-B에서 **내용을 깊이 이해**하고 컨셉 판단
|
||||
- 팀장이 받는 정보가 구체적 → 블록 선택 정확도 향상
|
||||
- Kei API 호출 2회이지만, 각각의 응답 시간 단축 가능
|
||||
|
||||
### Kei API 호출 횟수
|
||||
- 현재: 1단계 1회 + 2단계 Opus 1회 + 3단계 1회 = Kei 3회
|
||||
- 변경: 1단계-A 1회 + 1단계-B 1회 + 2단계 Opus 1회 + 3단계 1회 = Kei 4회
|
||||
- 1회 추가. 하지만 각 호출이 더 정확하므로 재시도/재검토 감소.
|
||||
|
||||
---
|
||||
|
||||
## H-1 수정: KEI_PROMPT를 A/B 두 단계로 분리
|
||||
|
||||
### H-1a: 1단계-A — 전체 스토리라인 설계 (KEI_PROMPT_A)
|
||||
|
||||
기존 H-1의 KEI_PROMPT를 "스토리라인 설계" 전용으로 유지.
|
||||
핵심메시지 + 스토리흐름 + 꼭지추출 + purpose + role.
|
||||
관계 성격, 표현 방법 같은 세부 판단은 **하지 않음** (1단계-B에서).
|
||||
|
||||
### H-1b: 1단계-B — 각 꼭지 컨셉 구체화 (KEI_PROMPT_B, 신규)
|
||||
|
||||
1단계-A 결과(topics)를 받아서, 각 꼭지를 원본과 대조하며 구체화:
|
||||
|
||||
```
|
||||
각 꼭지에 대해 다음을 판단해줘:
|
||||
|
||||
1. 관계 성격 (relation_type):
|
||||
- sequence: 시간/단계 순서 (A→B→C)
|
||||
- inclusion: 포함/융합 관계 (A가 B,C를 포함)
|
||||
- comparison: 대등 비교 (A vs B)
|
||||
- hierarchy: 상위-하위 (A > B > C)
|
||||
- definition: 용어 정의 (나열)
|
||||
- cause_effect: 원인-결과
|
||||
|
||||
2. 표현 제안 (expression_hint):
|
||||
- "포함 관계이므로 venn-diagram 또는 layer-diagram"
|
||||
- "대등 비교이므로 comparison-table. 원본에 12행 비교표가 있으니 활용"
|
||||
- "용어 정의 나열이므로 card-text-grid"
|
||||
|
||||
3. 원본 데이터 확인 (source_data):
|
||||
- 원본에 비교표가 있는가? → 행/열 수, 활용 여부
|
||||
- 원본에 사례/증거가 있는가? → 출처 명시
|
||||
- 원본에 이미지가 있는가? → 크기/역할
|
||||
- 놓치면 안 되는 핵심 데이터가 있는가?
|
||||
```
|
||||
|
||||
### pipeline.py 변경
|
||||
|
||||
```python
|
||||
# 현재
|
||||
analysis = await classify_content(content)
|
||||
|
||||
# 변경
|
||||
analysis = await classify_content(content) # 1단계-A: 스토리라인
|
||||
analysis = await refine_concepts(content, analysis) # 1단계-B: 컨셉 구체화
|
||||
```
|
||||
|
||||
`refine_concepts()`는 kei_client.py에 신규 함수로 추가.
|
||||
Kei API 호출 (Sonnet fallback 없음).
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py` — KEI_PROMPT_B 추가, `refine_concepts()` 함수 신규
|
||||
- `src/pipeline.py` — 1단계에 `refine_concepts()` 호출 추가
|
||||
|
||||
---
|
||||
|
||||
## H-5: 1단계-B 컨셉을 2단계 팀장에게 전달
|
||||
|
||||
### 현재 상태
|
||||
팀장이 받는 꼭지 정보: title, purpose, layer, role, emphasis
|
||||
→ 부족. "이 내용이 순서인지 포함인지 비교인지" 모름.
|
||||
|
||||
### 변경
|
||||
1단계-B에서 추가된 `relation_type`, `expression_hint`를 팀장 프롬프트에 포함:
|
||||
|
||||
```python
|
||||
# design_director.py 꼭지 요약 생성 시
|
||||
line = (
|
||||
f"꼭지 {t.get('id')}: {t.get('title')} "
|
||||
f"[{t.get('purpose')}, 관계:{t.get('relation_type', '?')}, "
|
||||
f"표현:{t.get('expression_hint', '?')}]"
|
||||
)
|
||||
```
|
||||
|
||||
### 수정 파일
|
||||
- `src/design_director.py` — 꼭지 요약 생성 부분
|
||||
|
||||
### 현재 상태
|
||||
```
|
||||
"본문에서 핵심 꼭지 2~5개를 추출해줘"
|
||||
→ topics: [{id, title, summary, layer, role, ...}]
|
||||
```
|
||||
|
||||
### 변경
|
||||
```
|
||||
"이 콘텐츠로 슬라이드 1장을 만든다면 어떤 스토리로 구성할지 설계해줘"
|
||||
```
|
||||
|
||||
프롬프트에 추가할 지시:
|
||||
- **핵심 메시지**를 먼저 파악 (이 슬라이드가 전달해야 할 한 줄)
|
||||
- **스토리 흐름**을 설계 (문제→근거→핵심→정의→결론)
|
||||
- 각 위치의 **목적(purpose)** 명시 (문제제기/근거사례/핵심전달/용어정의/결론강조)
|
||||
- 각 위치에 **원본의 어떤 부분**이 가는지 명시
|
||||
- 원본 텍스트는 최대한 보존. 슬라이드에 맞게 약간만 편집.
|
||||
|
||||
출력 형식 (기존 topics 구조 유지 + 필드 추가):
|
||||
```json
|
||||
{
|
||||
"title": "건설산업 DX의 올바른 이해",
|
||||
"core_message": "BIM은 건설산업 DX의 기초 일부분이지 전체가 아니다",
|
||||
"total_pages": 1,
|
||||
"info_structure": "...",
|
||||
"topics": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "용어 혼용 문제",
|
||||
"summary": "DX와 BIM이 혼용되어 사용",
|
||||
"purpose": "문제제기",
|
||||
"source_hint": "용어의 혼용 섹션 전체",
|
||||
"layer": "intro",
|
||||
"role": "flow",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 하류 호환
|
||||
- topics[] 배열 구조 동일 → 2단계/3단계 파싱 깨지지 않음
|
||||
- purpose, core_message, source_hint는 새 필드 → `.get()`으로 접근하면 없어도 안전
|
||||
- 기존 필드(layer, role, emphasis 등) 유지
|
||||
|
||||
### 수정 파일
|
||||
- `src/kei_client.py` — KEI_PROMPT
|
||||
|
||||
---
|
||||
|
||||
## H-2: EDITOR_PROMPT 수정 — 원본 텍스트 최대 보존
|
||||
|
||||
### 현재 상태
|
||||
```
|
||||
"세련된 표현으로 편집한다 (원본 그대로가 아님)"
|
||||
→ 과도한 요약/재작성. description 비움.
|
||||
```
|
||||
|
||||
### 변경
|
||||
```
|
||||
"원본 텍스트를 최대한 보존한다.
|
||||
슬라이드 공간에 맞게 약간만 축약한다.
|
||||
의미를 바꾸거나 완전히 재작성하지 않는다.
|
||||
각 블록의 purpose를 보고 해당 목적에 맞는 텍스트를 원본에서 가져온다.
|
||||
모든 슬롯을 빠짐없이 채운다. 빈 슬롯 금지."
|
||||
```
|
||||
|
||||
### 핵심 변경 포인트
|
||||
- "세련된 표현으로 편집" → "원본 보존, 약간만 축약"
|
||||
- "빈 슬롯 금지" 명시
|
||||
- purpose 필드를 참고하여 "이 위치에 무엇이 들어가야 하는지" 맥락 제공
|
||||
|
||||
### 수정 파일
|
||||
- `src/content_editor.py` — EDITOR_PROMPT
|
||||
|
||||
---
|
||||
|
||||
## H-3: STEP_B_PROMPT 보강 — purpose 기반 블록 선택 + 출력에 purpose 추가
|
||||
|
||||
### 현재 상태
|
||||
```
|
||||
"꼭지에 적합한 블록을 선택해줘"
|
||||
→ 형태만 보고 매칭. 목적 모름.
|
||||
→ 출력 JSON에 purpose 필드 없음.
|
||||
```
|
||||
|
||||
### 변경 1: purpose 기반 블록 선택 가이드 추가
|
||||
STEP_B_PROMPT의 "블록 선택 규칙" 섹션 뒤에 추가:
|
||||
|
||||
```
|
||||
## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
|
||||
- purpose: 문제제기 → callout-warning, quote-big-mark, quote-question 중 선택
|
||||
- purpose: 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)
|
||||
- purpose: 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
|
||||
- purpose: 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)
|
||||
- purpose: 결론강조 → conclusion-accent-bar (footer), banner-gradient
|
||||
- purpose: 구조시각화 → venn-diagram, layer-diagram (단독 배치)
|
||||
```
|
||||
|
||||
### 변경 2: 출력 JSON에 purpose 필드 추가
|
||||
정밀 검토에서 발견: H-4에서 편집자에게 purpose를 전달하려면, Step B 출력에 purpose가 있어야 함.
|
||||
|
||||
```json
|
||||
{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "purpose": "문제제기", "reason": "...", ...}]}
|
||||
```
|
||||
|
||||
### 하드코딩 점검
|
||||
- purpose 가이드는 AI 참고용 추천. 강제 아님. Sonnet이 무시해도 기존과 동일 동작.
|
||||
- purpose 값은 1단계 Kei가 결정한 것을 2단계 Sonnet이 참조. AI 판단 체인.
|
||||
|
||||
### 수정 파일
|
||||
- `src/design_director.py` — STEP_B_PROMPT (가이드 추가 + 출력 형식에 purpose 추가)
|
||||
|
||||
---
|
||||
|
||||
## H-4: 3단계 편집자에게 purpose 전달
|
||||
|
||||
### 현재 상태
|
||||
content_editor.py의 `fill_content()`에서 각 블록의 슬롯 정보는 전달하지만,
|
||||
**이 블록이 왜 여기 있는지 (purpose)** 는 전달하지 않음.
|
||||
|
||||
### 변경
|
||||
블록별 슬롯 요청 생성 시 purpose를 포함:
|
||||
|
||||
```python
|
||||
req_text = (
|
||||
f"블록 {i+1} ({block_type}, 영역: {area}, topic_id: {topic_id}):\n"
|
||||
f" 목적(purpose): {block.get('purpose', '미지정')}\n" # 추가
|
||||
f" 용도: {block.get('reason', '미지정')}\n"
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 수정 파일
|
||||
- `src/content_editor.py` — `fill_content()` 내 slot_requirements 생성 부분
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `src/kei_client.py` | H-1a, H-1b | KEI_PROMPT_A (스토리라인) + KEI_PROMPT_B (컨셉 구체화) + `refine_concepts()` 신규 |
|
||||
| `src/pipeline.py` | H-1b | 1단계에 `refine_concepts()` 호출 추가 |
|
||||
| `src/content_editor.py` | H-2, H-4 | EDITOR_PROMPT 수정 (원본 보존) + purpose 전달 |
|
||||
| `src/design_director.py` | H-3, H-5 | STEP_B_PROMPT purpose 가이드 + 꼭지 요약에 relation_type/expression_hint 포함 |
|
||||
|
||||
코드 구조 변경 없음. 프롬프트만 수정. persona_agent 수정 0건.
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
- [ ] H-1: 1단계 출력에 core_message, purpose, source_hint 포함
|
||||
- [ ] H-1: 기존 topics 구조 유지 (하류 깨지지 않음)
|
||||
- [ ] H-2: 편집자가 원본 텍스트를 과도하게 축약하지 않음
|
||||
- [ ] H-2: 모든 슬롯에 텍스트 채워짐 (빈 슬롯 0건)
|
||||
- [ ] H-3: purpose에 맞는 블록 선택 (문제제기→경고계열, 정의→카드계열)
|
||||
- [ ] H-4: 편집자가 각 블록의 purpose를 인지하고 적절한 텍스트 배치
|
||||
- [ ] 전체: 슬라이드에 스토리 흐름이 있음 (문제→근거→핵심→정의→결론)
|
||||
|
||||
---
|
||||
|
||||
## 정밀 검토 결과
|
||||
|
||||
### 발견 사항
|
||||
|
||||
| # | 발견 | 대응 |
|
||||
|---|------|------|
|
||||
| 1 | H-4가 동작하려면 H-3의 JSON 출력에 purpose 필드 필요 | H-3에 출력 형식 수정 포함 (위에 반영) |
|
||||
| 2 | "약간만 축약" 시 슬라이드 초과 가능 | 기존 안전망 유지 (4단계 CSS 조정 + 5단계 재검토) |
|
||||
| 3 | core_message 현재 미활용 | Kei의 사고 과정 기록용. 향후 5단계에서 활용 가능 |
|
||||
|
||||
### 충돌/회귀/하드코딩 총괄
|
||||
|
||||
| 항목 | 충돌 | 회귀 | 하드코딩 | API | persona 수정 |
|
||||
|------|:----:|:----:|:-------:|:---:|:----------:|
|
||||
| H-1 | 없음 | 없음 | 없음 (Kei 사고) | Kei API | 없음 |
|
||||
| H-2 | 없음 | 없음 | 없음 | Kei API | 없음 |
|
||||
| H-3 | 없음 | 없음 | 가이드일 뿐 강제 아님 | Sonnet | 없음 |
|
||||
| H-4 | 없음 | 없음 | 없음 | Kei API | 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | 초안. 스토리라인 설계 기반 전환. 프롬프트 3개 수정. |
|
||||
| 2026-03-26 | 정밀 검토. H-3 출력 형식에 purpose 추가 필요 발견. 반영. |
|
||||
| 2026-03-26 | 1차 실행 결과 분석. 1단계 A/B 분리 결정. H-1b(컨셉 구체화) + H-5(팀장 전달) 추가. |
|
||||
| 2026-03-26 | 정밀 검토 11개 발견. 아래 보완 사항 반영. |
|
||||
|
||||
## 정밀 검토 보완 사항
|
||||
|
||||
### 필수 보완 (구현 전 반드시 반영)
|
||||
|
||||
**1. refine_concepts() 실패 처리:**
|
||||
- Kei API 실패 시 1단계-A 결과 그대로 사용 (relation_type/expression_hint 없이 진행)
|
||||
- pipeline 멈추지 않음
|
||||
```python
|
||||
analysis = await refine_concepts(content, analysis)
|
||||
# refine_concepts 내부에서 실패 시 analysis를 변경 없이 그대로 반환
|
||||
```
|
||||
|
||||
**2. source_data 하류 전달:**
|
||||
- H-5(팀장 전달)에서 relation_type + expression_hint + **source_data** 모두 포함
|
||||
```python
|
||||
line += f", 원본데이터:{t.get('source_data', '?')}"
|
||||
```
|
||||
|
||||
**3. section-title-with-bg body 배치 금지:**
|
||||
- STEP_B_PROMPT "블록 선택 규칙"에 추가:
|
||||
"section-title-with-bg는 body/sidebar/footer zone에서 사용 금지. header zone 전용."
|
||||
- 또는 `_validate_height_budget()`에서 body zone의 section-title-with-bg를 topic-center로 교체
|
||||
|
||||
**4. 1회 호출 명시:**
|
||||
- 1단계-B는 **1회 Kei API 호출로 모든 꼭지를 한꺼번에 처리**
|
||||
- 꼭지별 개별 호출 아님
|
||||
|
||||
**5. manual_classify() fallback 동기화:**
|
||||
```python
|
||||
return {
|
||||
"title": "슬라이드",
|
||||
"core_message": "",
|
||||
"total_pages": 1,
|
||||
"info_structure": "",
|
||||
"topics": [{"id": 1, ..., "purpose": "핵심전달", "source_hint": ""}],
|
||||
"images": [], "tables": [],
|
||||
}
|
||||
```
|
||||
|
||||
**6. session_id:**
|
||||
- 1단계-B: `"design-agent-refine"` (별도 session. 1단계-A 대화 맥락에 영향받지 않도록)
|
||||
|
||||
**7. 제목 중복 방지:**
|
||||
- KEI_PROMPT_A에 추가: "슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다. 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용."
|
||||
|
||||
**8. expression_hint 역할 재정의 (Opus와 역할 분리):**
|
||||
```
|
||||
expression_hint는 "관계 성격"을 기술한다. 구체 블록 이름을 지정하지 않는다.
|
||||
✅ "기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요."
|
||||
❌ "venn-diagram 추천"
|
||||
블록 이름 결정은 2단계 Opus의 역할이다.
|
||||
```
|
||||
775
docs/history/IMPROVEMENT-PHASE-I.md
Normal file
775
docs/history/IMPROVEMENT-PHASE-I.md
Normal file
@@ -0,0 +1,775 @@
|
||||
# Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 — 실행 상세 (v3 최종)
|
||||
|
||||
> 상태: ✅ 완료 — DOWNGRADE_MAP, PURPOSE_FALLBACK은 Phase O에서 최종 삭제됨.
|
||||
>
|
||||
> 전수 검토에서 발견된 프롬프트 자기모순, 문서-코드 불일치, 코드 안전망 부족을 해결.
|
||||
> **핵심 변경: 넘침 시 기계적 블록 교체(DOWNGRADE_MAP) → Kei 판단 호출로 전환.**
|
||||
> 원칙: 하드코딩 금지. 범용 해결. 회귀 금지. persona_agent 수정 0건.
|
||||
> Sonnet 신규 투입 0건. Kei API를 사용해야 하는 곳에 Sonnet 대체 절대 금지.
|
||||
>
|
||||
> **후속 변경:**
|
||||
> - Phase N: DOWNGRADE_MAP을 pipeline에서 import 제거
|
||||
> - Phase O: DOWNGRADE_MAP, PURPOSE_FALLBACK, _downgrade_fallback() 함수 자체를 삭제
|
||||
> - Phase O: _fallback_layout() 삭제, Step B 제거
|
||||
|
||||
---
|
||||
|
||||
## 문제 진단 총괄
|
||||
|
||||
### 전수 검토에서 발<><EBB09C><EFBFBD>된 근본 원인
|
||||
|
||||
**실제 블록 수: 38개** (문서는 46개로 표기)
|
||||
삭제된 8개: card-text-grid, quote-left-border, conclusion-accent-bar, details-block, layer-diagram, timeline-vertical, timeline-horizontal, pyramid-hierarchy
|
||||
|
||||
이 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 삭제)
|
||||
|
||||
---
|
||||
|
||||
## 그룹 1: 정<><ECA095><EFBFBD>성 복구 — 미존재 블록 참조 차단
|
||||
|
||||
삭제된 8개 블록을 AI가 참조하지 못하도록 모든 참조 지점에서 제거/교체한다.
|
||||
|
||||
### I-1: STEP_B_PROMPT purpose 가이드에서 미존재 블록 제거
|
||||
|
||||
**위치:** `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이 미존재 블록 선택
|
||||
|
||||
**변경 코드:**
|
||||
```python
|
||||
"- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)\n"
|
||||
"- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)\n"
|
||||
"- 구조시각화 → venn-diagram (단독 배치)\n"
|
||||
```
|
||||
|
||||
**영향 범위:** STEP_B_PROMPT 문자열 내부 3행만 수정. 함수 시그니처, 호출 구조, API 호출 로직 변경 없음.
|
||||
**회귀 위험:** 없음. Sonnet이 읽는 참고 가이드 텍스트만 변경.
|
||||
|
||||
---
|
||||
|
||||
### I-2: catalog.yaml의 not_for/when에서 미존재 블록 참조 제거
|
||||
|
||||
**위치:** `templates/catalog.yaml` — 전수 조사 결과 12건
|
||||
|
||||
| 행 | 블록 | 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 |
|
||||
|
||||
**영향 범위:** 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
|
||||
if block_type and block_type not in registered_ids:
|
||||
logger.warning(
|
||||
f"[Step B 검증] 미등<EBAFB8><EB93B1> 블록 '{block_type}' 거부 → "
|
||||
f"'callout-solution'으로 교체"
|
||||
)
|
||||
block["type"] = "callout-solution"
|
||||
```
|
||||
|
||||
**변경 코드:**
|
||||
```python
|
||||
# 모듈 상수 (DOWNGRADE_MAP 근처에 배치)
|
||||
PURPOSE_FALLBACK = {
|
||||
"문제제기": "callout-warning",
|
||||
"근거사례": "quote-big-mark",
|
||||
"핵심전달": "comparison-2col",
|
||||
"용어정의": "card-icon-desc",
|
||||
"결론강조": "banner-gradient",
|
||||
"구조시각화": "card-icon-desc",
|
||||
}
|
||||
|
||||
# 기존 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
|
||||
```
|
||||
|
||||
**영향 범위:** 조건문(`block_type not in registered_ids`) 그대로 유지. 교체 대상만 분기.
|
||||
**회귀 위험:** 없음. purpose가 없으면 `"callout-solution"` (기존과 동일). PURPOSE_FALLBACK 상수는 범용 맵이므로 하드코딩 아님.
|
||||
|
||||
---
|
||||
|
||||
### 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 추가
|
||||
|
||||
**위치:** `src/design_director.py` 25~70행 (BLOCK_SLOTS 딕셔너리)
|
||||
|
||||
**변경:** 38개 블록 각각에 `"slot_desc": {...}` 키 추가. 예:
|
||||
```python
|
||||
"quote-big-mark": {
|
||||
"required": ["quote_text"],
|
||||
"optional": ["source"],
|
||||
"slot_desc": {
|
||||
"quote_text": "인용할 본문 텍스트",
|
||||
"source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!",
|
||||
},
|
||||
},
|
||||
"banner-gradient": {
|
||||
"required": ["text"],
|
||||
"optional": ["sub_text"],
|
||||
"slot_desc": {
|
||||
"text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)",
|
||||
"sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)",
|
||||
},
|
||||
},
|
||||
"compare-2col-split": {
|
||||
"required": ["left_title", "right_title", "rows"],
|
||||
"optional": [],
|
||||
"slot_desc": {
|
||||
"left_title": "왼쪽 열 헤더",
|
||||
"right_title": "오른쪽 열 헤더",
|
||||
"rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**영향 범위:** 기존 `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 전달
|
||||
|
||||
**위치:** `src/content_editor.py` 86~92행 (`fill_content()` 내부)
|
||||
|
||||
**현재 코드:**
|
||||
```python
|
||||
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
|
||||
# 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)
|
||||
```
|
||||
|
||||
**영향 범위:** 기존 `req_text` 구성 로직 변경 없음. 뒤에 추가만. `_call_kei_editor()`로 전달되는 프롬프트에 정보 추가.
|
||||
**Kei vs Sonnet:** 편집자는 **Kei API만 사용** (session_id: `"design-agent-editor"`). Sonnet 전환 없음.
|
||||
**회귀 위험:** 없음. `slot_desc`가 없는 블록은 빈 딕셔너리 → if 통과 안 함 → 기존과 동일.
|
||||
|
||||
---
|
||||
|
||||
## 그룹 4: 코드 안전망
|
||||
|
||||
### I-6: 제목 유사도 검증
|
||||
|
||||
**위치:** `src/pipeline.py` 56행 이후 (1단계-B 완료 후, 이미지 측정 전)
|
||||
|
||||
**추가 코드:**
|
||||
```python
|
||||
# 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%} → 첫 꼭지 제목 변경")
|
||||
```
|
||||
|
||||
**영향 범위:** pipeline.py 1단계~2단계 사이에 삽입. 기존 흐름 변경 없음. analysis 딕셔너리의 topics[0]["title"]만 조건부 수정.
|
||||
**회귀 위험:** 없음. 유사도 70% 이하면 아무 변경 없음. `SequenceMatcher`는 Python 표준 라이브러리.
|
||||
|
||||
---
|
||||
|
||||
## 그룹 5: 넘침 처리 패러다임 전환 — 핵심 변경
|
||||
|
||||
### I-9: DOWNGRADE_MAP → Kei 넘침 판단 호출
|
||||
|
||||
**기존 방식 (폐기 대상):**
|
||||
```
|
||||
높이 초과 감지 → DOWNGRADE_MAP에서 블록 자동 교체
|
||||
```
|
||||
- 콘텐츠 의도 무시 (비교 블록을 다른 타입으로 교체)
|
||||
- 중요도 위계 파괴 (중요한 내용이 작은 블록으로 밀려남)
|
||||
- 정보 손실 (items[] → 단일 text)
|
||||
- 순환 충돌 위험 (I-7과 DOWNGRADE가 서로 되돌림)
|
||||
|
||||
#### 구현 설계
|
||||
|
||||
**설계 결정:** `_validate_height_budget()`는 현재 동기 함수(sync). Kei API 호출은 비동기(async). 함수 자체를 async로 바꾸지 않고, **overflow 정보를 반환하여 pipeline에서 Kei 호출**하는 구조 채택. (기존 함수 구조 최대한 보존)
|
||||
|
||||
**Step 1: `_validate_height_budget()` 변경** (`design_director.py` 711~777행)
|
||||
|
||||
```python
|
||||
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
"""zone별 height_cost 합산을 검증한다.
|
||||
|
||||
초과 시 overflow 정보를 수집하여 반환. 블록 자동 교체는 하지 않음.
|
||||
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존.
|
||||
|
||||
Returns:
|
||||
overflow 정보 리스트. 초과 없으면 빈 리스트.
|
||||
"""
|
||||
# 기존: 금지 블록 교체 (BODY_FORBIDDEN_MAP) — 유지
|
||||
# 기존: pill-pair 단독 검증 (I-7) — 유지
|
||||
|
||||
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)
|
||||
|
||||
if total <= budget:
|
||||
continue
|
||||
|
||||
logger.warning(f"[높이 예산 초과] {area}: {total}px > {budget}px")
|
||||
|
||||
# 기존: 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 직접 호출 절대 <20><>지.
|
||||
"""
|
||||
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 넘침 호출에서도 동일 함수 재사용.
|
||||
|
||||
---
|
||||
|
||||
## 수정 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `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, 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 제거 + 트리 정리 |
|
||||
|
||||
---
|
||||
|
||||
## 최종 검증 매트릭스
|
||||
|
||||
| 항목 | 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+재설계 |
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 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건 확인. |
|
||||
631
docs/history/IMPROVEMENT-PHASE-J.md
Normal file
631
docs/history/IMPROVEMENT-PHASE-J.md
Normal file
@@ -0,0 +1,631 @@
|
||||
# Phase J: 블록 선택 권한 구조 재정의 + 최종 검토 Kei 전환
|
||||
|
||||
> 상태: ✅ 완료 — Phase N에서 코드 레벨 강제로 강화, Phase O에서 Step B 자체를 제거.
|
||||
>
|
||||
> Phase I 실행 후 결과물 3회 비교에서 확인된 근본 문제.
|
||||
> **핵심: Sonnet(팀장)이 Opus(실장) 추천을 엎고, 자기가 만든 문제를 자기가 검토하는 구조.**
|
||||
> 해결: 블록 선택 권한을 실장에게, 최종 검토를 Kei에게.
|
||||
>
|
||||
> **후속 변경:**
|
||||
> - Phase N: 프롬프트 "존중" → 코드 레벨 강제 (kei_confirmed_blocks 덮어쓰기)
|
||||
> - Phase O: Step B(Sonnet) 자체를 제거. Kei(A-2) + 코드로 직접 layout 생성. STEP_B_PROMPT 삭제.
|
||||
|
||||
---
|
||||
|
||||
## 문제 진단 (7건)
|
||||
|
||||
### J-1: Sonnet(팀장)이 Opus(실장) 추천을 엎음
|
||||
|
||||
**현상:**
|
||||
- Opus 추천: 6개 블록 (quote-big-mark, card-tag-image x2, topic-left-right, compare-2col-split, banner-gradient)
|
||||
- Sonnet 실제: `section-header-bar` 추가 (Opus 추천에 없음), `card-tag-image` → `card-icon-desc` 교체
|
||||
- 3번 실행 모두 동일 — Sonnet이 일관되게 Opus를 무시
|
||||
|
||||
**원인:** STEP_B_PROMPT에 "Opus 추천이 있으면 **참고**하되, **최종 선택은 팀장 판단**"이라고 명시 → Sonnet이 자유롭게 변경
|
||||
|
||||
---
|
||||
|
||||
### J-2: section-header-bar가 body에 들어가서 제목 3중 중복
|
||||
|
||||
**현상:**
|
||||
- header zone: "건설산업 DX의 올바른 이해" (slide-title)
|
||||
- body 첫 블록: "건설산업 DX의 올바른 이해" (section-header-bar)
|
||||
- HTML title: "건설산업 DX의 올바른 이해"
|
||||
|
||||
**원인:** Sonnet이 Opus 추천에 없는 `section-header-bar`를 자체 판단으로 추가. body에 section-header-bar를 넣으면 안 되는 규칙이 없음.
|
||||
**영향:** body 높이 +70px 초과의 직접 원인 (600px > 490px)
|
||||
|
||||
---
|
||||
|
||||
### J-3: card-icon-desc(이모지 블록)가 용어 정의에 사용됨
|
||||
|
||||
**현상:** sidebar에 🏗️📐🔄🎯 이모지 카드 → 비즈니스 기획서에 부적절
|
||||
|
||||
**원인 체인:**
|
||||
1. STEP_B_PROMPT purpose 가이드: `용어정의 → card-icon-desc (정의+출처)` ← 이모지 블록 추천
|
||||
2. catalog.yaml keyword-circle-row not_for: `용어 정의 → card-icon-desc 사용` ← catalog도 추천
|
||||
3. Sonnet이 두 가이드를 따라 card-icon-desc 선택
|
||||
4. card-icon-desc 템플릿의 icon 슬롯이 이모지 사용 구조
|
||||
|
||||
---
|
||||
|
||||
### J-4: quote-big-mark의 source에 출처 대신 꼭지 제목
|
||||
|
||||
**현상:** `<div class="qb-source">— 용어의 혼용</div>` — 출처가 아닌 꼭지 주제
|
||||
|
||||
**원인:** slot_desc에 "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!"이라고 명시했으나 Kei 편집자가 무시. 3번 실행 모두 동일.
|
||||
|
||||
---
|
||||
|
||||
### J-5: body 높이 600px > 490px — 매번 초과
|
||||
|
||||
**현상:** section-header-bar(70) + quote-big-mark(150) + topic-left-right(70) + compare-2col-split(250) + gap(60) = 600px
|
||||
|
||||
**원인:** J-2(section-header-bar 불필요 추가)의 직접 결과. 제거하면 530px → 여전히 초과지만 110px → 40px으로 대폭 개선.
|
||||
|
||||
---
|
||||
|
||||
### J-6: sidebar에 3열/4열 카드가 35% 너비에 들어감
|
||||
|
||||
**현상:**
|
||||
- card-tag-image: `--ct-count: 3` (3열) → 35% sidebar에서 읽을 수 없음
|
||||
- card-icon-desc: `--ci-count: 4` (4열) → 더 읽을 수 없음
|
||||
|
||||
**원인:** STEP_B_PROMPT에 "sidebar에는 카드 1열"이라고 했지만 Sonnet이 3열/4열 그대로 선택. 또한 블록 자체가 열 수를 데이터에서 결정하는 구조라 Sonnet의 char_guide로 제어 불가.
|
||||
|
||||
---
|
||||
|
||||
### J-7: Stage 5 재검토(팀장)가 실질적으로 무의미
|
||||
|
||||
**현상:**
|
||||
- 매번 2회 루프 다 돌고 "최대 재조정 횟수 도달. 현재 결과로 확정"
|
||||
- overflow 감지는 하지만 해결 못함
|
||||
- body 600px > 490px 초과인 채로 확정
|
||||
|
||||
**원인:** Sonnet이 자기가 만든 문제를 자기가 검토 → 같은 판단 기준으로 같은 결론. 구조적 문제(잘못된 블록 선택)는 shrink/expand로 해결 불가.
|
||||
|
||||
---
|
||||
|
||||
## 근본 원인 분석
|
||||
|
||||
```
|
||||
Sonnet(팀장)에게 너무 많은 권한:
|
||||
├ 블록 선택 권한 → Opus 추천을 무시하고 자기 판단
|
||||
├ 블록 추가 권한 → 불필요한 section-header-bar 추가
|
||||
├ 최종 검토 권한 → 자기 결과를 자기가 검토 (무의미)
|
||||
└ purpose 가이드 + catalog이 잘못된 블록 추천 → Sonnet이 따름
|
||||
|
||||
실장(Kei/Opus)이 할 수 있는데 안 하는 것:
|
||||
├ 블록 최종 선택 → Opus가 추천했는데 "참고"로만 전달
|
||||
├ 최종 검토 → Kei가 콘텐츠 중요도를 알지만 검토 기회 없음
|
||||
└ sidebar 열 수 판단 → Kei가 콘텐츠 양을 알지만 반영 안 됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 해결 방향
|
||||
|
||||
### 1. 블록 선택: 실장(Opus) 확정, 팀장(Sonnet)은 존중
|
||||
|
||||
**현재:** "Opus 추천 **참고**, 최종 선택은 **팀장 판단**"
|
||||
**변경:** "Opus 추천 블록을 **기본 채택**. 팀장은 **명확한 높이 초과 사유** 없이 변경 금지"
|
||||
|
||||
```
|
||||
STEP_B_PROMPT 변경:
|
||||
현재: "Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단."
|
||||
변경: "Opus 추천 블록을 기본 사용한다. 높이 예산 초과 등 명확한 사유가 없으면 변경하지 마라.
|
||||
변경 시 반드시 reason에 Opus 추천과 다른 이유를 명시하라."
|
||||
```
|
||||
|
||||
### 2. purpose 가이드 + catalog 수정
|
||||
|
||||
**STEP_B_PROMPT purpose 가이드:**
|
||||
```
|
||||
현재: 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
|
||||
변경: 용어정의 → card-numbered (정의 나열), dark-bullet-list (핵심 포인트)
|
||||
```
|
||||
|
||||
**catalog.yaml:**
|
||||
```
|
||||
현재: keyword-circle-row not_for: "용어 정의 → card-icon-desc 사용"
|
||||
변경: keyword-circle-row not_for: "용어 정의 → card-numbered 사용"
|
||||
```
|
||||
|
||||
### 3. section-header-bar body 사용 금지
|
||||
|
||||
body zone에서 section-header-bar 사용을 코드 레벨에서 금지. header zone에 이미 slide-title이 있으므로 body에 중복 제목 블록은 불필요.
|
||||
|
||||
```python
|
||||
# BODY_FORBIDDEN_MAP에 추가
|
||||
BODY_FORBIDDEN_MAP = {
|
||||
"section-title-with-bg": "topic-center",
|
||||
"section-header-bar": None, # body에서 사용 시 제거 (교체 아닌 삭제)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. sidebar 열 수 강제
|
||||
|
||||
sidebar(35% 너비)에 배치되는 카드 블록은 `--ct-count: 1`, `--ci-count: 1`로 강제.
|
||||
|
||||
```python
|
||||
# renderer.py 또는 design_director.py에서
|
||||
if block.get("area") == "sidebar":
|
||||
# 카드 블록의 열 수를 1로 강제
|
||||
if block_type in ("card-tag-image", "card-icon-desc", "card-image-3col", ...):
|
||||
block["data"]["column_count"] = 1
|
||||
```
|
||||
|
||||
### 5. Stage 5 최종 검토: Sonnet → Kei
|
||||
|
||||
**현재:** Sonnet이 검토 → 자기 결과를 자기가 검토 (무의미)
|
||||
**변경:** Kei(Opus)가 최종 검토 → 콘텐츠 중요도 기반 판단
|
||||
|
||||
```
|
||||
Stage 5 변경:
|
||||
현재: _review_balance() → Sonnet이 HTML 보고 판단
|
||||
변경: _review_balance_kei() → Kei API로 HTML + 블록 데이터 보내서 판단
|
||||
|
||||
Kei가 검토하는 항목:
|
||||
1. 콘텐츠 흐름이 맞는가 (오해→사례→정의→관계→결론)
|
||||
2. 각 블록이 해당 콘텐츠에 적합한가
|
||||
3. 중요한 내용이 빠지거나 축소되지 않았는가
|
||||
4. 높이 초과 시: trim/restructure 판단 (이미 I-9에서 구현한 것 재사용)
|
||||
```
|
||||
|
||||
### 6. source 슬롯 편집자 강화
|
||||
|
||||
slot_desc만으로 부족. 편집자 프롬프트에 **금지 규칙** 직접 추가:
|
||||
|
||||
```
|
||||
EDITOR_PROMPT 추가:
|
||||
"## source 슬롯 규칙 (절대 규칙)
|
||||
- source 슬롯에는 반드시 정보원(출처)을 넣는다
|
||||
- 꼭지 제목, 주제어, 섹션명을 source에 넣지 마라
|
||||
- 출처가 원본에 없으면 source 슬롯을 비워라 (빈 문자열)
|
||||
- 올바른 예: '국토교통부, 2020', 'IBM, 2011'
|
||||
- 잘못된 예: '용어의 혼용', 'DX와 BIM 개념'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실행 항목 총괄
|
||||
|
||||
| # | 항목 | 파일 | 변경 성격 |
|
||||
|---|------|------|----------|
|
||||
| J-1 | STEP_B_PROMPT "Opus 추천 존중" 규칙 강화 | design_director.py | 프롬프트 수정 |
|
||||
| J-2 | section-header-bar body 사용 금지 | design_director.py | BODY_FORBIDDEN_MAP 추가 |
|
||||
| J-3a | purpose 가이드 용어정의 매핑 수정 | design_director.py | 프롬프트 수정 |
|
||||
| J-3b | catalog.yaml 용어정의 안내 수정 | catalog.yaml | not_for 수정 |
|
||||
| J-4 | source 슬롯 금지 규칙 추가 | content_editor.py | EDITOR_PROMPT 수정 |
|
||||
| J-5 | (J-2 해결로 자동 개선) | — | — |
|
||||
| J-6 | sidebar 카드 열 수 1열 강제 | design_director.py 또는 renderer.py | 코드 추가 |
|
||||
| J-7 | Stage 5 최종 검토 Kei 전환 | pipeline.py + kei_client.py | 핵심 구조 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### Phase J-A: 팀장 권한 제한 (즉시)
|
||||
1. J-1: STEP_B_PROMPT Opus 존중 규칙
|
||||
2. J-2: section-header-bar body 금지
|
||||
3. J-3a: purpose 가이드 수정
|
||||
4. J-3b: catalog.yaml 수정
|
||||
5. J-6: sidebar 1열 강제
|
||||
|
||||
### Phase J-B: 편집자 강화
|
||||
6. J-4: source 슬롯 금지 규칙
|
||||
|
||||
### Phase J-C: 최종 검토 Kei 전환 (핵심)
|
||||
7. J-7: Stage 5 _review_balance() → Kei API 호출로 전환
|
||||
|
||||
---
|
||||
|
||||
## 예상 효과
|
||||
|
||||
| 문제 | 해결 방안 | 효과 |
|
||||
|------|----------|------|
|
||||
| 제목 3중 중복 | section-header-bar body 금지 | **제거** |
|
||||
| 이모지 블록 | purpose 가이드 수정 + Opus 존중 | **card-numbered로 교체** |
|
||||
| source 오입력 | 편집자 금지 규칙 | **출처 또는 빈칸** |
|
||||
| body 높이 초과 | section-header-bar 제거 → -70px | **대폭 개선** |
|
||||
| sidebar 다열 | 1열 강제 | **가독성 확보** |
|
||||
| 재검토 무의미 | Kei가 검토 | **콘텐츠 기반 실질 검토** |
|
||||
|
||||
---
|
||||
|
||||
## 검증 매트릭스
|
||||
|
||||
| 항목 | Kei API | Sonnet | 하드코딩 | 회귀 |
|
||||
|------|---------|--------|---------|------|
|
||||
| J-1 | — | 프롬프트 수정 | 없음 | 없음 |
|
||||
| J-2 | — | — | BODY_FORBIDDEN_MAP 상수 | 없음 |
|
||||
| J-3 | — | 프롬프트 수정 | 없음 | 없음 |
|
||||
| J-4 | — | — | 없음 (프롬프트) | 없음 |
|
||||
| J-6 | — | — | 코드 규칙 | 없음 |
|
||||
| J-7 | **Kei** (신규 검토) | 제거 | 없음 | Stage 5 구조 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 구현 상세 (기술 조사 + 충돌 검토 반영)
|
||||
|
||||
### J-1: STEP_B_PROMPT Opus 존중 규칙
|
||||
|
||||
**위치:** `design_director.py` 743~744행 (user_prompt)
|
||||
|
||||
**현재:**
|
||||
```python
|
||||
f"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
|
||||
```
|
||||
|
||||
**변경:**
|
||||
```python
|
||||
f"Opus 추천 블록을 기본 사용한다. 높이 초과 등 명확한 사유 없이 변경하지 마라. 변경 시 reason에 사유를 반드시 명시하라.\n"
|
||||
```
|
||||
|
||||
**충돌:** 없음. 문자열 1행 교체.
|
||||
|
||||
---
|
||||
|
||||
### J-2: section-header-bar body 금지
|
||||
|
||||
**위치:** `design_director.py` 898행 (BODY_FORBIDDEN_MAP) + 957~966행 (교체 로직)
|
||||
|
||||
**BODY_FORBIDDEN_MAP 변경:**
|
||||
```python
|
||||
BODY_FORBIDDEN_MAP = {
|
||||
"section-title-with-bg": "topic-center",
|
||||
"section-header-bar": None, # body에서 제거 (교체 아닌 삭제)
|
||||
}
|
||||
```
|
||||
|
||||
**교체 로직 변경 (957~966행):** `None`이면 삭제 처리. 루프 중 리스트 수정 방지를 위해 별도 필터링.
|
||||
|
||||
```python
|
||||
# 금지 블록 처리 (교체 또는 삭제)
|
||||
blocks_to_remove = []
|
||||
for block in blocks:
|
||||
area = block.get("area", "body")
|
||||
block_type = block.get("type", "")
|
||||
if area != "header" and block_type in BODY_FORBIDDEN_MAP:
|
||||
replacement = BODY_FORBIDDEN_MAP[block_type]
|
||||
if replacement is None:
|
||||
blocks_to_remove.append(block)
|
||||
logger.warning(f"[금지 블록 삭제] {block_type} (area={area})")
|
||||
else:
|
||||
block["type"] = replacement
|
||||
logger.warning(f"[금지 블록 교체] {block_type} → {replacement} (area={area})")
|
||||
for block in blocks_to_remove:
|
||||
blocks.remove(block)
|
||||
```
|
||||
|
||||
**충돌 주의:** 루프 중 리스트 삭제 → 별도 `blocks_to_remove` 리스트로 해결.
|
||||
**zone_blocks 재구성 필요:** 삭제 후 zone_blocks도 갱신해야 후속 pill-pair/높이 체크가 정확.
|
||||
|
||||
---
|
||||
|
||||
### J-3a: purpose 가이드 수정
|
||||
|
||||
**위치:** `design_director.py` 504행, 506행
|
||||
|
||||
```
|
||||
504행 현재: "- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)"
|
||||
504행 변경: "- 근거사례 → quote-big-mark (출처 포함), card-numbered (항목 나열)"
|
||||
|
||||
506행 현재: "- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)"
|
||||
506행 변경: "- 용어정의 → card-numbered (정의 나열), dark-bullet-list (핵심 포인트)"
|
||||
```
|
||||
|
||||
**PURPOSE_FALLBACK도 수정 (884~894행):**
|
||||
```python
|
||||
현재: "용어정의": "card-icon-desc",
|
||||
변경: "용어정의": "card-numbered",
|
||||
```
|
||||
|
||||
**회귀 체크:** I-1에서 미존재 블록 제거 목적으로 수정. J-3a는 부적절 블록 교체 목적. 방향이 다르므로 회귀 아님.
|
||||
|
||||
---
|
||||
|
||||
### J-3b: catalog.yaml 수정
|
||||
|
||||
**위치:** `catalog.yaml` 376행
|
||||
|
||||
```
|
||||
현재: not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-icon-desc 사용.'
|
||||
변경: not_for: '아이콘+설명 → card-icon-desc 사용. 용어 정의 → card-numbered 사용.'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### J-4: source 슬롯 금지 규칙
|
||||
|
||||
**위치:** `content_editor.py` EDITOR_PROMPT (55행 이전)
|
||||
|
||||
**추가 위치:** 기존 `## JSON 형식으로만 응답한다.` 바로 앞에 삽입
|
||||
|
||||
```python
|
||||
"## source 슬롯 규칙 (절대 규칙)\n"
|
||||
"- source 슬롯에는 반드시 정보원(출처)을 넣는다\n"
|
||||
"- 꼭지 제목, 주제어, 섹션명을 source에 넣지 마라\n"
|
||||
"- 출처가 원본에 없으면 source 슬롯을 비워라 (빈 문자열)\n"
|
||||
"- 올바른 예: '국토교통부, 2020', 'IBM, 2011'\n"
|
||||
"- 잘못된 예: '용어의 혼용', 'DX와 BIM 개념'\n\n"
|
||||
```
|
||||
|
||||
**Kei vs Sonnet:** 이 프롬프트는 Kei API(편집자, session_id: `design-agent-editor`)에 전달됨. Sonnet 아님.
|
||||
|
||||
---
|
||||
|
||||
### J-6: sidebar 1열 강제
|
||||
|
||||
**방법:** 템플릿에 `column_override` 지원 추가 + design_director에서 sidebar 블록에 값 주입
|
||||
|
||||
**템플릿 변경 (2개):**
|
||||
|
||||
`card-tag-image.html` 9행:
|
||||
```html
|
||||
현재: <div class="block-card-tag" style="--ct-count: {{ cards|length }}">
|
||||
변경: <div class="block-card-tag" style="--ct-count: {{ column_override | default(cards|length) }}">
|
||||
```
|
||||
|
||||
`card-icon-desc.html` 9행:
|
||||
```html
|
||||
현재: <div class="block-card-icon" style="--ci-count: {{ cards|length }}">
|
||||
변경: <div class="block-card-icon" style="--ci-count: {{ column_override | default(cards|length) }}">
|
||||
```
|
||||
|
||||
**design_director.py — sidebar 블록에 column_override 주입:**
|
||||
`_validate_height_budget()` 함수 내, 금지 블록 처리 이후에 삽입:
|
||||
|
||||
```python
|
||||
# sidebar 카드 블록 1열 강제 (J-6)
|
||||
CARD_BLOCKS = {
|
||||
"card-tag-image", "card-icon-desc", "card-image-3col",
|
||||
"card-dark-overlay", "card-compare-3col", "card-image-round",
|
||||
"card-stat-number",
|
||||
}
|
||||
for block in blocks:
|
||||
if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
|
||||
if "data" not in block:
|
||||
block["data"] = {}
|
||||
block["data"]["column_override"] = 1
|
||||
```
|
||||
|
||||
**충돌:** 없음. `column_override`는 새 키. `default(cards|length)`로 body에서는 기존대로.
|
||||
**회귀:** 없음. 기존 렌더링 동작 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
### J-7: Stage 5 최종 검토 Kei 전환
|
||||
|
||||
**방법:** `kei_client.py`에 `call_kei_final_review()` 신규 함수 추가 + `pipeline.py`에서 호출
|
||||
|
||||
**kei_client.py 신규 함수:**
|
||||
|
||||
```python
|
||||
KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.
|
||||
|
||||
## 검수 관점
|
||||
1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
|
||||
2. 콘텐츠 흐름(문제제기→사례→정의→관계→결론)이 블록 배치와 일치하는가?
|
||||
3. 각 블록이 해당 꼭지의 purpose에 적합한가?
|
||||
4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
|
||||
5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
|
||||
- 텍스트 축약으로 해결 가능 → shrink
|
||||
- 콘텐츠가 본질적으로 큼 → overflow_detected
|
||||
|
||||
## 조정 action
|
||||
- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
|
||||
- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
|
||||
- rewrite: 텍스트 재작성 (detail에 방향)
|
||||
- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)
|
||||
|
||||
## 출력 (JSON만)
|
||||
{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "...", "target_ratio": 1.3, "detail": "..."}]}
|
||||
"""
|
||||
|
||||
async def call_kei_final_review(
|
||||
html: str,
|
||||
block_summary: list[str],
|
||||
zone_budget_text: str,
|
||||
overflow_hint_text: str,
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Kei(Opus)가 최종 검수한다.
|
||||
|
||||
반드시 Kei API 경유. Sonnet 사용 절대 금지.
|
||||
session_id: design-agent-final-review
|
||||
"""
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
core_message = analysis.get("core_message", "") if analysis else ""
|
||||
topics_summary = ""
|
||||
if analysis:
|
||||
topics_summary = "\n".join(
|
||||
f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
|
||||
for t in analysis.get("topics", [])
|
||||
)
|
||||
|
||||
prompt = (
|
||||
KEI_REVIEW_PROMPT + "\n\n"
|
||||
f"## 핵심 메시지\n{core_message}\n\n"
|
||||
f"## 꼭지 목록\n{topics_summary}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
|
||||
zone_budget_text +
|
||||
overflow_hint_text +
|
||||
f"\n\n## 조립 HTML (요약)\n{html[:3000]}\n\n"
|
||||
f"위 결과물을 검수하고 조정이 필요한지 판단해. JSON만."
|
||||
)
|
||||
|
||||
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-final-review",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
|
||||
return None
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if full_text:
|
||||
result = _parse_json(full_text)
|
||||
if result and "needs_adjustment" in result:
|
||||
logger.info(f"[Kei 최종 검수] needs_adjustment={result['needs_adjustment']}")
|
||||
return result
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei 최종 검수 실패: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
**pipeline.py 변경:**
|
||||
- import: `from src.kei_client import ... call_kei_final_review`
|
||||
- `_review_balance()` 내부: Sonnet API 호출 → `call_kei_final_review()` 호출로 교체
|
||||
- 기존 `block_summary`, `zone_budget_text`, `overflow_hint_text` 구성 로직은 유지 (pipeline에 남음)
|
||||
- `anthropic.AsyncAnthropic` + `client.messages.create` 코드 제거
|
||||
- `import anthropic`은 Stage 4(`_adjust_design`)에서 아직 사용하므로 유지
|
||||
|
||||
**출력 스키마:** 기존과 100% 동일 → `_apply_adjustments()`, `_convert_kei_judgment()` 변경 불필요.
|
||||
**overflow 처리:** 기존 Stage 5 루프의 overflow_detected → Kei overflow 호출 흐름 그대로 유지.
|
||||
|
||||
---
|
||||
|
||||
## 실행 프로세스 (의존 관계 + 순서)
|
||||
|
||||
```
|
||||
Phase J-A: 팀장 권한 제한 + 가이드 수정
|
||||
├── J-1: STEP_B_PROMPT Opus 존중 규칙 (design_director.py 744행)
|
||||
├── J-2: section-header-bar body 금지 (BODY_FORBIDDEN_MAP + 교체 로직)
|
||||
├── J-3a: purpose 가이드 수정 (504, 506행 + PURPOSE_FALLBACK)
|
||||
├── J-3b: catalog.yaml 수정 (376행)
|
||||
└── J-6: sidebar 1열 강제 (템플릿 2개 + design_director 주입)
|
||||
↓ (J-A 완료 후)
|
||||
Phase J-B: 편집자 강화
|
||||
└── J-4: source 슬롯 금지 규칙 (EDITOR_PROMPT)
|
||||
↓ (J-B 완료 후)
|
||||
Phase J-C: 최종 검토 Kei 전환
|
||||
└── J-7: call_kei_final_review() 신규 + pipeline Stage 5 교체
|
||||
↓
|
||||
검증: import + 서버 기동 + 결과물 비교
|
||||
```
|
||||
|
||||
### Phase J-A 내부 의존 관계
|
||||
- J-2는 `_validate_height_budget()` 수정 → J-6도 같은 함수 안에 삽입 → **J-2 먼저, J-6 이후**
|
||||
- J-1, J-3a, J-3b는 서로 독립 → 순서 무관
|
||||
|
||||
### Phase J-C 의존
|
||||
- J-7은 J-A/J-B와 독립이지만, **J-A 수정된 결과물로 검증해야 의미** → J-A/J-B 완료 후 실행
|
||||
|
||||
---
|
||||
|
||||
## 변경 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `src/design_director.py` | J-1, J-2, J-3a, J-6 | 프롬프트 + BODY_FORBIDDEN_MAP + PURPOSE_FALLBACK + sidebar column_override |
|
||||
| `src/content_editor.py` | J-4 | EDITOR_PROMPT에 source 규칙 추가 |
|
||||
| `src/kei_client.py` | J-7 | KEI_REVIEW_PROMPT + call_kei_final_review() 신규 |
|
||||
| `src/pipeline.py` | J-7 | _review_balance() 내부 Sonnet → Kei 교체 + import 추가 |
|
||||
| `templates/catalog.yaml` | J-3b | not_for 1건 수정 |
|
||||
| `templates/blocks/cards/card-tag-image.html` | J-6 | column_override 지원 |
|
||||
| `templates/blocks/cards/card-icon-desc.html` | J-6 | column_override 지원 |
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀/오류 최종 검증
|
||||
|
||||
| 항목 | 충돌 | 회귀 | Kei/Sonnet | 하드코딩 | 단발성 | 주의 사항 |
|
||||
|------|:---:|:---:|:----------:|:------:|:-----:|----------|
|
||||
| J-1 | 없음 | 없음 | Sonnet(기존) | 없음 | 아님 | — |
|
||||
| J-2 | **주의** | 없음 | — | 상수 | 아님 | 루프 중 삭제 → 별도 필터링 + zone_blocks 재구성 |
|
||||
| J-3a | 없음 | I-1과 다른 목적 | Sonnet(기존) | 없음 | 아님 | PURPOSE_FALLBACK도 같이 수정 |
|
||||
| J-3b | 없음 | I-2와 다른 목적 | — | 없음 | 아님 | — |
|
||||
| J-4 | 없음 | I-5와 보완 | **Kei**(편집자) | 없음 | 아님 | — |
|
||||
| J-6 | **주의** | 없음 | — | 범용 키 | 아님 | 템플릿 2개 수정 + data 주입 |
|
||||
| J-7 | **주의** | 프로세스 재설계 유지 | **Kei**(신규) | 없음 | 아님 | pipeline import + Sonnet 코드 제거 |
|
||||
|
||||
**Sonnet 신규 투입: 0건**
|
||||
**Kei API 사용: J-4(기존 편집자), J-7(신규 최종 검수)**
|
||||
**하드코딩: 0건**
|
||||
**회귀: 0건**
|
||||
**단발성: 0건**
|
||||
|
||||
---
|
||||
|
||||
## 실행 결과 상세
|
||||
|
||||
### Phase J-A: 팀장 권한 제한 + 가이드 수정 (5개) ✅
|
||||
|
||||
| 항목 | 파일 | 반영 내용 |
|
||||
|------|------|----------|
|
||||
| J-1 | `src/design_director.py` 744행 | `"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단"` → `"Opus 추천 블록을 기본 사용한다. 높이 초과 등 명확한 사유 없이 변경하지 마라. 변경 시 reason에 사유를 반드시 명시하라."` |
|
||||
| J-2 | `src/design_director.py` 899행 | `BODY_FORBIDDEN_MAP`에 `"section-header-bar": None` 추가. 금지 블록 처리 로직 변경: `None`이면 교체가 아닌 삭제. `blocks_to_remove` 별도 리스트로 루프 중 삭제 안전 처리. 삭제 후 `zone_blocks` 재구성 추가. |
|
||||
| J-3a | `src/design_director.py` 504행, 506행 | purpose 가이드: `근거사례 → card-icon-desc` → `card-numbered`, `용어정의 → card-icon-desc` → `card-numbered, dark-bullet-list`. `PURPOSE_FALLBACK` 892행: `"용어정의": "card-icon-desc"` → `"card-numbered"` |
|
||||
| J-3b | `templates/catalog.yaml` 376행 | `not_for: '용어 정의 → card-icon-desc 사용'` → `'용어 정의 → card-numbered 사용'` |
|
||||
| J-6 | `templates/blocks/cards/card-tag-image.html` 9행 | `--ct-count: {{ cards\|length }}` → `--ct-count: {{ column_override \| default(cards\|length) }}` |
|
||||
| J-6 | `templates/blocks/cards/card-icon-desc.html` 9행 | `--ci-count: {{ cards\|length }}` → `--ci-count: {{ column_override \| default(cards\|length) }}` |
|
||||
| J-6 | `src/design_director.py` `_validate_height_budget()` 내 | sidebar 카드 블록에 `block["data"]["column_override"] = 1` 주입. CARD_BLOCKS 상수로 대상 블록 정의. |
|
||||
|
||||
### Phase J-B: 편집자 강화 (1개) ✅
|
||||
|
||||
| 항목 | 파일 | 반영 내용 |
|
||||
|------|------|----------|
|
||||
| J-4 | `src/content_editor.py` EDITOR_PROMPT | `## source 슬롯 규칙 (절대 규칙)` 섹션 추가. 출처만 허용, 꼭지 제목/주제어 금지, 없으면 빈 문자열. 올바른/잘못된 예시 포함. Kei API(편집자)에 전달됨. |
|
||||
|
||||
### Phase J-C: 최종 검토 Kei 전환 (1개) ✅
|
||||
|
||||
| 항목 | 파일 | 반영 내용 |
|
||||
|------|------|----------|
|
||||
| J-7 | `src/kei_client.py` | `KEI_REVIEW_PROMPT` 상수 신규: 11년 경력 기획 실장 관점, 핵심 메시지 전달/콘텐츠 흐름/purpose 적합성/높이 초과 검수. `call_kei_final_review()` 함수 신규: session_id `"design-agent-final-review"`, Kei API SSE 스트리밍, 출력 스키마 기존과 100% 동일. |
|
||||
| J-7 | `src/pipeline.py` import | `call_kei_final_review` import 추가 |
|
||||
| J-7 | `src/pipeline.py` `_review_balance()` | Sonnet API(`anthropic.AsyncAnthropic` + `client.messages.create`) 코드 제거. `call_kei_final_review(html, block_summary, zone_budget_text, overflow_hint_text, analysis)` 호출로 교체. block_summary/zone_budget_text/overflow_hint_text 구성 로직은 pipeline에 유지. |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트 (실행 완료)
|
||||
|
||||
### 팀장 권한 제한
|
||||
- [x] J-1: STEP_B_PROMPT에 "Opus 추천 기본 사용, 변경 금지" 명시
|
||||
- [x] J-2: BODY_FORBIDDEN_MAP에 section-header-bar: None. 삭제 로직 + zone_blocks 재구성
|
||||
- [x] J-3a: purpose 가이드 용어정의/근거사례에서 card-icon-desc 제거 → card-numbered
|
||||
- [x] J-3a: PURPOSE_FALLBACK 용어정의 → card-numbered
|
||||
- [x] J-3b: catalog.yaml "용어 정의 → card-numbered"
|
||||
- [x] J-6: 템플릿 2개 column_override 지원 + sidebar 블록에 column_override=1 주입
|
||||
|
||||
### 편집자 강화
|
||||
- [x] J-4: EDITOR_PROMPT에 source 슬롯 금지 규칙 추가 (Kei API 편집자 경유)
|
||||
|
||||
### 최종 검토 Kei 전환
|
||||
- [x] J-7: call_kei_final_review() 함수 신규 (kei_client.py)
|
||||
- [x] J-7: _review_balance() → Sonnet 코드 제거, Kei API 호출로 교체
|
||||
- [x] J-7: Stage 5에 Sonnet 모델 참조 0건 확인
|
||||
|
||||
### 기술 검증
|
||||
- [x] 모든 모듈 import 성공
|
||||
- [x] FastAPI 앱 로드 성공 (8 routes)
|
||||
- [x] BLOCK_SLOTS 38/38, slot_desc 38/38 (Phase I 회귀 없음)
|
||||
- [x] BODY_FORBIDDEN_MAP: section-header-bar=None 확인
|
||||
- [x] PURPOSE_FALLBACK 용어정의=card-numbered 확인
|
||||
|
||||
### 절대 규칙 준수
|
||||
- [x] Sonnet 신규 투입 0건 — Stage 5가 Kei API만 사용
|
||||
- [x] 하드코딩 0건
|
||||
- [x] 단발성 수정 0건
|
||||
- [x] Phase I 회귀 0건
|
||||
- [x] persona_agent 수정 0건
|
||||
|
||||
---
|
||||
|
||||
## 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | Phase I 완료 후 결과물 3회 비교. 7개 문제 진단. Phase J 계획 수립. |
|
||||
| 2026-03-26 | 기술 조사 + 충돌/회귀/오류 검토 완료. 구현 상세 + 실행 프로세스 확정. |
|
||||
| 2026-03-26 | **Phase J 실행 완료.** 7개 항목 전수 구현. 검증 전항목 통과. Stage 5 Kei 전환 확인. |
|
||||
445
docs/history/IMPROVEMENT-PHASE-K.md
Normal file
445
docs/history/IMPROVEMENT-PHASE-K.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Phase K: communicative role 기반 시각적 위계 + 콘텐츠 시퀀싱
|
||||
|
||||
> 상태: ✅ 완료 — purpose별 분량 원칙은 Phase O에서 동적 계산(_max_chars_total)으로 발전.
|
||||
>
|
||||
> Phase I(코드 정합성) + Phase J(블록 선택 권한) 이후에도 결과물 품질이 개선되지 않은 근본 원인.
|
||||
> **핵심: purpose(communicative role)를 분류하고도, 시각적 결과에 반영하지 않았음.**
|
||||
> 사용자가 반복 요청한 콘텐츠 구조 흐름이 Phase J에서 누락됨. 이번에 전부 반영.
|
||||
>
|
||||
> **후속 변경 (Phase O):**
|
||||
> - purpose별 분량 제약(문제제기 100자 등) → 컨테이너 크기 기반 동적 계산으로 대체
|
||||
> - catalog.yaml schema의 body/sidebar 글자수 → ref_chars(참고값) + max_lines/font_size(디자인 스펙)으로 분리
|
||||
|
||||
---
|
||||
|
||||
## 사용자 반복 요청 (Phase I 이전부터)
|
||||
|
||||
```
|
||||
"상단에 오해하고 잘못되었다.
|
||||
→ 그래서 보니 혼용하는 사례들이 있더라.
|
||||
→ 여기랑 여기 등을 구체적 사례들을 봐라.
|
||||
→ 사실은 이런것이다!! (이게 구조화가 되어야 하는것 아닌가?) ← 이게 핵심
|
||||
→ 그리고 해당하는 내용에 대한 개념 정의
|
||||
→ 마지막 핵심 문장 딱 하나!"
|
||||
|
||||
"관련 용어들의 정의만 시각적으로 오른쪽에 배치되고,
|
||||
위에서부터
|
||||
배경 & 증빙 사례 → 그래서 이거다! (더 자세히 보러가기) → 이 슬라이드의 핵심 키워드!!
|
||||
우측에 관련 용어에 대한 정의가 구조화되어 시각적으로 잘되어야지."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 연구
|
||||
|
||||
| 프로젝트 | 핵심 접근 | 우리 적용점 |
|
||||
|---------|----------|-----------|
|
||||
| Presenton | 블록별 JSON 스키마(min/maxLength)로 overflow 원천 차단 | purpose별 분량 제약 (K-4) |
|
||||
| PPTAgent | communicative role 분류 후 레이아웃 매칭 | purpose → 시각적 위계 매핑 (K-1) |
|
||||
| Auto-Slides | 인지 부하 이론 기반 콘텐츠 시퀀싱 | purpose 기반 인지 흐름 순서 (K-2) |
|
||||
|
||||
공통 결론: **"communicative role을 먼저 분류하지 않고 레이아웃부터 선택하는 것이 실패의 근본 원인"**
|
||||
|
||||
우리 파이프라인은 role(purpose)을 분류하지만, **그것이 시각적 결과에 반영되지 않는 것**이 문제.
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷에서 확인된 실제 문제
|
||||
|
||||
1. "용어간 상호관계" 4줄 불릿이 body에서 가장 크게 차지 — 핵심이 아닌데 주인공
|
||||
2. DX vs BIM 비교표가 **화면 밖으로 잘림** — 헤더만 보이고 내용 행 안 보임
|
||||
3. sidebar 혼용 사례 3열 카드가 파랑/초록/주황으로 과도하게 강조
|
||||
4. sidebar 용어 정의가 장황하게 나열
|
||||
5. 비교표에 "왜 비교하는지" 맥락 안내 없음
|
||||
|
||||
---
|
||||
|
||||
## 변경 항목 (8건)
|
||||
|
||||
### K-1: purpose → 시각적 위계 매핑
|
||||
|
||||
**지금:** 모든 purpose가 동일한 크기의 블록으로 렌더링. 핵심전달이든 근거사례든 같은 medium 블록.
|
||||
**변경:** purpose별 시각적 비중 정의.
|
||||
|
||||
| purpose | 시각적 비중 | body 내 공간 비율 |
|
||||
|---------|-----------|----------------|
|
||||
| 핵심전달 | **최대** — body의 주인공 | 40-60% |
|
||||
| 문제제기 | 간결 — compact 블록 | 15-20% |
|
||||
| 근거사례 | 보조 — 간결 요약 또는 sidebar | 10-15% |
|
||||
| 용어정의 | sidebar 참조 — body에서 빠짐 | sidebar 전용 |
|
||||
| 결론강조 | footer 1줄 | footer 전용 |
|
||||
|
||||
**반영 위치:**
|
||||
- KEI_PROMPT (kei_client.py): Kei가 꼭지 설계 시 비중 명시
|
||||
- STEP_B_PROMPT (design_director.py): 팀장이 블록 크기를 purpose 비중에 맞춤
|
||||
|
||||
---
|
||||
|
||||
### K-2: purpose 기반 인지 흐름 순서
|
||||
|
||||
**지금:** Kei가 꼭지를 추출하지만 body 내 배치 순서를 Sonnet이 자유 결정.
|
||||
**변경:** purpose가 인지 흐름 순서를 결정. Kei가 순서를 명시하고 팀장은 존중.
|
||||
|
||||
**인지 흐름 원칙 (하드코딩 아닌 원칙):**
|
||||
- 문제/배경이 먼저 → 왜 이걸 봐야 하는지
|
||||
- 근거/사례가 다음 → 그 문제의 증거
|
||||
- 핵심 내용이 가장 크게 → 그래서 이거다!
|
||||
- 결론이 마지막 → 기억할 한 줄
|
||||
|
||||
**반영 위치:**
|
||||
- KEI_PROMPT: "핵심전달이 body의 중심에 오도록 순서를 설계하라. 문제제기와 근거사례는 핵심전달을 위한 도입부이다."
|
||||
- 콘텐츠 유형에 따라 Kei가 판단 — 모든 콘텐츠에 동일 순서 강제 아님
|
||||
|
||||
---
|
||||
|
||||
### K-3: purpose별 허용/금지 블록
|
||||
|
||||
**지금:** purpose 가이드가 부적절한 블록을 추천하거나, Sonnet이 purpose와 무관하게 선택.
|
||||
**변경:** purpose별 허용 블록 + 금지 블록을 명확히 정의.
|
||||
|
||||
| purpose | 허용 블록 | 금지 블록 |
|
||||
|---------|----------|----------|
|
||||
| 문제제기 | quote-big-mark, callout-warning, quote-question | 비교 블록, 카드 블록 |
|
||||
| 근거사례 | card-tag-image(sidebar), card-numbered, dark-bullet-list | 비교표 (근거에 비교표 쓰면 핵심과 혼동) |
|
||||
| 핵심전달 | compare-2col-split, comparison-2col, compare-3col-badge, topic-left-right | card-icon-desc (이모지), quote 계열 |
|
||||
| 용어정의 | card-numbered, dark-bullet-list (sidebar 전용) | 비교 블록, 시각화 블록 |
|
||||
| 결론강조 | banner-gradient | 나머지 전부 |
|
||||
|
||||
**반영 위치:**
|
||||
- STEP_B_PROMPT purpose 가이드 (design_director.py)
|
||||
- catalog.yaml when/not_for 보강
|
||||
|
||||
---
|
||||
|
||||
### K-4: purpose별 분량 제약 (min/max)
|
||||
|
||||
**지금:** slot_desc에 슬롯 의미만 있고 분량 제약 없음. 편집자가 자유롭게 분량 결정.
|
||||
**변경:** purpose별 분량 가이드.
|
||||
|
||||
| purpose | 분량 가이드 | 이유 |
|
||||
|---------|-----------|------|
|
||||
| 문제제기 | max 100자 (2-3줄) | 간결하게. 도입부. |
|
||||
| 근거사례 | max 150자 (핵심만) | 상세는 자세히보기 또는 sidebar. |
|
||||
| 핵심전달 | 200-400자 (충분히 구조화) | 주인공이니 충분한 공간. |
|
||||
| 용어정의 | 각 용어 max 50자 | sidebar에서 짧게. |
|
||||
| 결론강조 | max 40자 (1문장) | 기억할 한 줄. |
|
||||
|
||||
**반영 위치:**
|
||||
- EDITOR_PROMPT (content_editor.py): purpose별 분량 원칙
|
||||
- char_guide: Kei가 꼭지 설계 시 purpose에 따라 char_guide 제안
|
||||
|
||||
---
|
||||
|
||||
### K-5: sidebar column_override 보존
|
||||
|
||||
**지금:** Stage 3(fill_content)에서 data를 통째로 덮어쓰면서 column_override 소실.
|
||||
**변경:** data 덮어쓸 때 column_override 등 메타 키 보존.
|
||||
|
||||
**반영 위치:** content_editor.py fill_content() 내 data 매칭 로직
|
||||
|
||||
---
|
||||
|
||||
### K-6: sidebar 시각적 무게 조절
|
||||
|
||||
**지금:** card-tag-image가 파랑/초록/주황 태그로 본문보다 눈에 띔. 배경 증빙인데 주인공처럼 보임.
|
||||
**변경:**
|
||||
- sidebar용 블록은 compact + 저채도로 시각적 무게 낮춤
|
||||
- Kei가 "보조 참조"로 분류한 꼭지는 편집자가 분량을 줄이고 팀장이 compact 블록 선택
|
||||
- card-tag-image 대신 card-numbered(세로 리스트)를 sidebar 기본으로
|
||||
|
||||
**반영 위치:**
|
||||
- STEP_B_PROMPT: "sidebar 블록은 본문보다 시각적 무게가 낮아야 한다"
|
||||
- KEI_PROMPT: sidebar 꼭지는 분량을 간결하게
|
||||
|
||||
---
|
||||
|
||||
### K-7: Kei 검수에 구조 흐름 검증 추가
|
||||
|
||||
**지금:** Kei 검수가 높이 초과/채움 균형만 봄. "핵심전달이 주인공인가?"를 안 봄.
|
||||
**변경:** KEI_REVIEW_PROMPT에 추가 검수 항목:
|
||||
|
||||
- 핵심전달 purpose의 꼭지가 body에서 가장 큰 시각적 비중을 차지하는가?
|
||||
- 문제제기가 간결한가? (100자 이내)
|
||||
- 용어정의가 sidebar에 있는가? body를 차지하고 있지 않은가?
|
||||
- 핵심전달 블록이 화면 안에 보이는가? (잘리지 않는가?)
|
||||
|
||||
**반영 위치:** KEI_REVIEW_PROMPT (kei_client.py)
|
||||
|
||||
---
|
||||
|
||||
### K-8: 비교 블록 맥락 안내
|
||||
|
||||
**지금:** 비교표가 "DX 구분 BIM" 헤더만으로 등장 → 왜 비교하는지 모름.
|
||||
**변경:**
|
||||
- 핵심전달로 비교표를 사용할 때, Kei가 "비교 목적"을 summary로 제공
|
||||
- 편집자가 비교표 위에 1줄 안내 텍스트를 배치하거나, compare-pill-pair를 헤더로 선행
|
||||
|
||||
**반영 위치:**
|
||||
- KEI_PROMPT: 핵심전달이 비교 구조일 때 "비교 목적"을 명시하라
|
||||
- EDITOR_PROMPT: 비교 블록의 첫 행에 비교 목적 요약 포함
|
||||
|
||||
---
|
||||
|
||||
## 반영 파일 총괄
|
||||
|
||||
| 파일 | 항목 | 변경 성격 |
|
||||
|------|------|----------|
|
||||
| `src/kei_client.py` KEI_PROMPT | K-1, K-2, K-6, K-8 | purpose별 비중 + 인지 흐름 원칙 + sidebar 간결 + 비교 목적 |
|
||||
| `src/kei_client.py` KEI_REVIEW_PROMPT | K-7 | 구조 흐름 검수 항목 추가 |
|
||||
| `src/design_director.py` STEP_B_PROMPT | K-1, K-3, K-6 | purpose별 시각적 위계 + 허용/금지 블록 + sidebar 무게 |
|
||||
| `src/content_editor.py` EDITOR_PROMPT | K-4, K-8 | purpose별 분량 원칙 + 비교 맥락 안내 |
|
||||
| `src/content_editor.py` fill_content() | K-5 | column_override 보존 |
|
||||
| `templates/catalog.yaml` | K-3 | when/not_for 보강 (선택적) |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### K-Step 1: 콘텐츠 설계 (가장 중요 — 이것만 되면 비교표 잘림 해결)
|
||||
|
||||
1. K-1: KEI_PROMPT에 purpose별 시각적 비중 원칙
|
||||
2. K-2: KEI_PROMPT에 인지 흐름 순서 원칙
|
||||
3. K-4: EDITOR_PROMPT에 purpose별 분량 제약
|
||||
|
||||
### K-Step 2: 블록 선택 정확성
|
||||
|
||||
4. K-3: STEP_B_PROMPT purpose별 허용/금지 블록
|
||||
5. K-6: STEP_B_PROMPT + KEI_PROMPT sidebar 시각적 무게
|
||||
6. K-8: KEI_PROMPT + EDITOR_PROMPT 비교 맥락 안내
|
||||
|
||||
### K-Step 3: 코드 + 검수
|
||||
|
||||
7. K-5: content_editor.py column_override 보존
|
||||
8. K-7: KEI_REVIEW_PROMPT 구조 흐름 검수
|
||||
|
||||
---
|
||||
|
||||
## 이것이 하드코딩이 아닌 이유
|
||||
|
||||
- "문제제기 → 근거 → 핵심 → 결론" 순서를 **강제하지 않음**
|
||||
- Kei에게 **원칙**을 줌: "핵심전달이 주인공이어야 한다", "문제제기는 도입부이므로 간결하게"
|
||||
- 콘텐츠에 따라 Kei가 **순서와 비중을 판단** — 프로세스 설명이면 프로세스 흐름, 비교면 비교 중심
|
||||
- purpose별 분량도 **가이드라인** (절대값 아닌 참고)
|
||||
- Presenton 연구의 min/maxLength처럼 **생성 단계에서 overflow를 예방**하는 원칙
|
||||
|
||||
---
|
||||
|
||||
## 예상 효과
|
||||
|
||||
| 문제 | K 적용 후 |
|
||||
|------|----------|
|
||||
| 비교표 화면 밖 잘림 | 문제제기 간결(compact) → 비교표에 공간 확보 |
|
||||
| 용어간 상호관계가 주인공 | 핵심전달이 주인공, 상호관계는 축약 또는 sidebar |
|
||||
| sidebar 과도한 강조 | 시각적 무게 낮춤 + 분량 간결 |
|
||||
| 비교표 맥락 없음 | 비교 목적 안내 선행 |
|
||||
| 콘텐츠 흐름 반복 무시 | KEI_PROMPT에 원칙 반영 + Kei 검수에서 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 방안 상세
|
||||
|
||||
### K-Step 1: 콘텐츠 설계 — KEI_PROMPT + EDITOR_PROMPT
|
||||
|
||||
**대상 파일:** `src/kei_client.py` KEI_PROMPT (20~70행), `src/content_editor.py` EDITOR_PROMPT (26~63행)
|
||||
|
||||
#### K-1 + K-2: KEI_PROMPT 3단계(스토리라인 설계) 수정
|
||||
|
||||
**현재:** purpose 목록만 나열. 비중/순서 원칙 없음.
|
||||
**변경:** purpose별 시각적 비중 원칙 + 인지 흐름 원칙 추가.
|
||||
|
||||
```
|
||||
변경할 프롬프트 내용:
|
||||
|
||||
## 3단계: 슬라이드 스토리라인 설계
|
||||
|
||||
핵심 메시지를 전달하기 위한 흐름을 설계해줘.
|
||||
|
||||
### purpose별 시각적 비중 원칙
|
||||
- 핵심전달: body의 **주인공**. 가장 큰 공간(40-60%). 구조화된 블록으로.
|
||||
- 문제제기: **도입부**. 간결하게(compact). 2-3줄이면 충분.
|
||||
- 근거사례: **보조**. 핵심만 짧게. 상세는 sidebar 참조 또는 자세히보기.
|
||||
- 용어정의: **sidebar 참조**. body에 넣지 마라. 각 용어 1-2줄.
|
||||
- 결론강조: **footer 1줄**. core_message를 짧고 강하게.
|
||||
|
||||
### 인지 흐름 원칙
|
||||
- 핵심전달이 body의 중심에 오도록 설계하라.
|
||||
- 문제제기와 근거사례는 핵심전달을 위한 도입부이다.
|
||||
- 콘텐츠 유형에 따라 순서를 판단하되,
|
||||
핵심전달이 항상 가장 큰 시각적 비중을 가져야 한다.
|
||||
```
|
||||
|
||||
**충돌:** 없음. KEI_PROMPT 3단계 섹션 교체. 기존 purpose 목록은 위 내용으로 대체.
|
||||
**회귀:** Phase J에서 수정한 KEI_PROMPT를 다시 수정. 방향이 같으므로 회귀 아님.
|
||||
**하드코딩:** 아님. 순서 강제가 아닌 원칙 제공. Kei가 콘텐츠에 맞게 판단.
|
||||
|
||||
#### K-8: KEI_PROMPT에 비교 맥락 원칙 추가
|
||||
|
||||
**변경:** 배치 규칙 섹션에 1줄 추가.
|
||||
|
||||
```
|
||||
- 핵심전달이 비교 구조일 때, 비교 목적(왜 비교하는가)을 summary에 명시하라.
|
||||
```
|
||||
|
||||
#### K-4: EDITOR_PROMPT에 purpose별 분량 가이드 추가
|
||||
|
||||
**현재:** 분량 제약 없음. "글자 수 가이드는 참고"만.
|
||||
**변경:** purpose별 분량 원칙 추가.
|
||||
|
||||
```
|
||||
## purpose별 분량 원칙 (가이드라인)
|
||||
- 문제제기: max 100자 (2-3줄). 간결하게. 도입부.
|
||||
- 근거사례: max 150자. 핵심만 짧게. 상세는 자세히보기.
|
||||
- 핵심전달: 200-400자. 충분히 구조화. 이것이 주인공.
|
||||
- 용어정의: 각 용어 max 50자. sidebar에서 짧게 정의.
|
||||
- 결론강조: max 40자. 기억할 1문장.
|
||||
```
|
||||
|
||||
**충돌:** 없음. EDITOR_PROMPT에 섹션 추가만.
|
||||
**회귀:** Phase J의 source 규칙(J-4)은 유지됨.
|
||||
|
||||
---
|
||||
|
||||
### K-Step 2: 블록 선택 — STEP_B_PROMPT + catalog
|
||||
|
||||
**대상 파일:** `src/design_director.py` STEP_B_PROMPT (501~508행), `templates/catalog.yaml`
|
||||
|
||||
#### K-3: STEP_B_PROMPT purpose 가이드를 허용/금지로 재구성
|
||||
|
||||
**현재:** (Phase J에서 수정한 상태)
|
||||
```
|
||||
- 문제제기 → callout-warning, quote-big-mark, quote-question
|
||||
- 근거사례 → quote-big-mark (출처 포함), card-numbered (항목 나열)
|
||||
- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
|
||||
- 용어정의 → card-numbered (정의 나열), dark-bullet-list (핵심 포인트)
|
||||
- 결론강조 → banner-gradient (footer)
|
||||
- 구조시각화 → venn-diagram (단독 배치)
|
||||
```
|
||||
|
||||
**변경:**
|
||||
```
|
||||
## purpose별 블록 선택 규칙
|
||||
|
||||
### 문제제기 (간결한 도입부)
|
||||
- 허용: callout-warning, quote-big-mark, quote-question
|
||||
- 금지: 비교 블록, 카드 블록, 시각화 블록
|
||||
- 크기: compact (70px 이하)
|
||||
|
||||
### 근거사례 (보조 증빙)
|
||||
- 허용: card-numbered, dark-bullet-list, card-tag-image(sidebar)
|
||||
- 금지: 비교표 (핵심전달과 혼동), quote 계열
|
||||
- 크기: compact~medium
|
||||
|
||||
### 핵심전달 (★ 주인공 — body에서 가장 크게)
|
||||
- 허용: compare-2col-split, comparison-2col, compare-3col-badge, topic-left-right
|
||||
- 금지: card-icon-desc, quote 계열 (주인공에 부적합)
|
||||
- 크기: large 권장
|
||||
|
||||
### 용어정의 (sidebar 전용)
|
||||
- 허용: card-numbered, dark-bullet-list
|
||||
- 금지: 비교 블록, 시각화 블록, card-icon-desc
|
||||
- 배치: 반드시 sidebar. body에 넣지 마라.
|
||||
|
||||
### 결론강조 (footer 1줄)
|
||||
- 허용: banner-gradient
|
||||
- 배치: 반드시 footer.
|
||||
```
|
||||
|
||||
**충돌:** Phase J의 J-3a 수정을 대체. 방향 동일(card-icon-desc 제거), 더 구체화.
|
||||
**회귀:** J-3a보다 상세해진 것이므로 회귀 아님.
|
||||
|
||||
#### K-6: STEP_B_PROMPT에 sidebar 원칙 추가
|
||||
|
||||
**변경:** 블록 선택 규칙 섹션에 추가.
|
||||
|
||||
```
|
||||
- sidebar 블록은 본문보다 시각적 무게가 낮아야 한다.
|
||||
- sidebar에는 compact 블록 우선. large 블록 금지.
|
||||
- sidebar의 카드는 1열 세로 배치. 3열 가로 금지.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### K-Step 3: 코드 + 검수
|
||||
|
||||
**대상 파일:** `src/content_editor.py` fill_content(), `src/kei_client.py` KEI_REVIEW_PROMPT
|
||||
|
||||
#### K-5: column_override 보존
|
||||
|
||||
**현재:** `orig_block["data"] = filled_block.get("data", {})` — 통째 덮어쓰기.
|
||||
**변경:** column_override 키를 보존하고 나머지만 덮어쓰기.
|
||||
|
||||
```python
|
||||
new_data = filled_block.get("data", {})
|
||||
preserved = {}
|
||||
if "data" in orig_block:
|
||||
for k in ("column_override",):
|
||||
if k in orig_block["data"]:
|
||||
preserved[k] = orig_block["data"][k]
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
```
|
||||
|
||||
**주의:** fill_content()에서 data를 덮어쓰는 곳이 2곳 (topic_id 매칭 + area+type 매칭). 둘 다 수정.
|
||||
**충돌:** 없음. 기존 data 덮어쓰기 로직에 보존 로직 추가.
|
||||
|
||||
#### K-7: KEI_REVIEW_PROMPT 구조 흐름 검수
|
||||
|
||||
**현재:** 높이 초과, 채움 균형, 빈 블록만 검수.
|
||||
**변경:** 검수 항목에 추가.
|
||||
|
||||
```
|
||||
6. 핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?
|
||||
- 핵심전달 블록이 다른 블록보다 작거나 같으면 → rewrite로 비중 조정
|
||||
7. 문제제기가 간결한가? (100자 이내)
|
||||
- 초과 시 → shrink
|
||||
8. 용어정의가 sidebar에 있는가?
|
||||
- body에 있으면 → 구조 문제 지적
|
||||
9. 핵심전달 블록이 화면 안에 보이는가?
|
||||
- 잘리면 → overflow_detected
|
||||
```
|
||||
|
||||
**충돌:** Phase J의 J-7에서 추가한 KEI_REVIEW_PROMPT에 항목 추가. 기존 항목 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 실행 프로세스
|
||||
|
||||
```
|
||||
K-Step 1 (콘텐츠 설계)
|
||||
├── K-1 + K-2: KEI_PROMPT 3단계 수정 (purpose 비중 + 인지 흐름)
|
||||
├── K-4: EDITOR_PROMPT 분량 가이드 추가
|
||||
└── K-8: KEI_PROMPT 비교 맥락 원칙 추가
|
||||
↓
|
||||
K-Step 2 (블록 선택)
|
||||
├── K-3: STEP_B_PROMPT purpose별 허용/금지 재구성
|
||||
└── K-6: STEP_B_PROMPT sidebar 원칙 추가
|
||||
↓
|
||||
K-Step 3 (코드 + 검수)
|
||||
├── K-5: content_editor.py column_override 보존
|
||||
└── K-7: KEI_REVIEW_PROMPT 구조 흐름 검수 추가
|
||||
↓
|
||||
검증: import + 서버 기동 + 결과물 비교
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀 검토
|
||||
|
||||
| 항목 | Phase I 영향 | Phase J 영향 | 하드코딩 |
|
||||
|------|:----------:|:----------:|:------:|
|
||||
| K-1 | 없음 | 없음 | 아님 (원칙) |
|
||||
| K-2 | 없음 | 없음 | 아님 (원칙) |
|
||||
| K-3 | I-1 purpose 가이드 → K-3이 대체 | J-3a → K-3이 대체 (더 상세) | 아님 (허용/금지 분류) |
|
||||
| K-4 | 없음 | 없음 | 아님 (가이드라인) |
|
||||
| K-5 | 없음 | J-6 column_override와 연동 | 없음 |
|
||||
| K-6 | 없음 | J-6과 보완 | 아님 (원칙) |
|
||||
| K-7 | 없음 | J-7 KEI_REVIEW_PROMPT에 추가 | 없음 |
|
||||
| K-8 | 없음 | 없음 | 아님 (원칙) |
|
||||
|
||||
---
|
||||
|
||||
## 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | Phase J 완료 후 결과물 확인. 사용자 반복 요청(콘텐츠 구조 흐름)이 미반영 확인. 연구 참고(Presenton/PPTAgent/Auto-Slides). Phase K 계획 수립. |
|
||||
| 2026-03-26 | 실행 방안 상세 정리. Step별 변경 내용 + 적용 위치 + 충돌 검토 확정. |
|
||||
276
docs/history/IMPROVEMENT-PHASE-K1.md
Normal file
276
docs/history/IMPROVEMENT-PHASE-K1.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Phase K-1: 파이프라인 스텝별 중간 산출물 로컬 저장
|
||||
|
||||
> 각 스텝에서 뭘 결정했고 왜 그렇게 했는지를 파일로 저장하여,
|
||||
> 사용자가 확인하고 피드백할 수 있도록 한다.
|
||||
> 당초부터 있어야 했던 기능.
|
||||
|
||||
---
|
||||
|
||||
## 문제
|
||||
|
||||
- 현재 파이프라인 중간 결과는 메모리에만 존재, 파이프라인 끝나면 사라짐
|
||||
- 사용자가 "어디서 잘못됐는지" 확인할 방법이 없음
|
||||
- 로그에 WARNING/INFO 한 줄만 남아서 판단 근거 부족
|
||||
|
||||
---
|
||||
|
||||
## 저장 구조
|
||||
|
||||
```
|
||||
data/runs/{timestamp}/
|
||||
├── step1_analysis.json # Kei 꼭지 추출 (topics, purpose, core_message)
|
||||
├── step1b_concepts.json # Kei 컨셉 구체화 (relation_type, expression_hint)
|
||||
├── step2_opus_recommendation.json # Opus 블록 추천
|
||||
├── step2_sonnet_mapping.json # Sonnet 최종 블록 매핑
|
||||
├── step2_validation.json # 높이 검증, 금지 블록 삭제, overflow 내역
|
||||
├── step3_filled_blocks.json # 편집자가 채운 텍스트 (블록별 data + 글자 수)
|
||||
├── step4_css_adjustment.json # CSS 변수 override 내역
|
||||
├── step4_rendered.html # 렌더링된 HTML
|
||||
├── step5_review_round1.json # Kei 1차 검수 결과 (issues + adjustments)
|
||||
├── step5_review_round2.json # Kei 2차 검수 결과 (있으면)
|
||||
└── final.html # 최종 HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 각 파일 내용 상세
|
||||
|
||||
### step1_analysis.json
|
||||
```json
|
||||
{
|
||||
"title": "건설산업 DX의 올바른 이해",
|
||||
"core_message": "BIM은 DX의 기초적 일부분이다",
|
||||
"total_pages": 1,
|
||||
"info_structure": "...",
|
||||
"topics": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "DX와 BIM의 개념 혼용 현실",
|
||||
"purpose": "문제제기",
|
||||
"layer": "intro",
|
||||
"role": "flow",
|
||||
"emphasis": true,
|
||||
"summary": "...",
|
||||
"source_hint": "..."
|
||||
}
|
||||
],
|
||||
"images": [],
|
||||
"tables": []
|
||||
}
|
||||
```
|
||||
|
||||
### step1b_concepts.json
|
||||
```json
|
||||
{
|
||||
"concepts": [
|
||||
{
|
||||
"topic_id": 1,
|
||||
"relation_type": "cause_effect",
|
||||
"expression_hint": "현상-문제 인과관계",
|
||||
"source_data": "용어 혼용 현상..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### step2_opus_recommendation.json
|
||||
```json
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"topic_id": 1,
|
||||
"block_type": "quote-big-mark",
|
||||
"area": "body",
|
||||
"reason": "문제 제기를 임팩트 있게 강조"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### step2_sonnet_mapping.json
|
||||
```json
|
||||
{
|
||||
"preset": "sidebar-right",
|
||||
"blocks": [
|
||||
{
|
||||
"area": "body",
|
||||
"type": "quote-big-mark",
|
||||
"topic_id": 1,
|
||||
"purpose": "문제제기",
|
||||
"reason": "Opus 추천 유지",
|
||||
"size": "medium",
|
||||
"char_guide": {"quote_text": 150}
|
||||
}
|
||||
],
|
||||
"opus_diff": [
|
||||
"Opus 추천과 동일" 또는 "topic_id 4: card-tag-image → card-numbered (사유: ...)"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### step2_validation.json
|
||||
```json
|
||||
{
|
||||
"forbidden_blocks_removed": ["section-header-bar (body)"],
|
||||
"pill_pair_replaced": [],
|
||||
"sidebar_column_override": [{"topic_id": 4, "column_override": 1}],
|
||||
"overflow": [
|
||||
{"area": "body", "total_px": 510, "budget_px": 490, "overflow_px": 20}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### step3_filled_blocks.json
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"area": "body",
|
||||
"type": "quote-big-mark",
|
||||
"topic_id": 1,
|
||||
"purpose": "문제제기",
|
||||
"data": {"quote_text": "건설산업의 디지털 전환...", "source": ""},
|
||||
"char_count": 95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### step4_css_adjustment.json
|
||||
```json
|
||||
{
|
||||
"area_styles": {
|
||||
"body": "--font-body: 0.85rem; --spacing-inner: 12px;",
|
||||
"sidebar": "--font-body: 0.8rem;",
|
||||
"footer": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### step5_review_round1.json
|
||||
```json
|
||||
{
|
||||
"needs_adjustment": true,
|
||||
"issues": ["body zone 높이 초과 (+20px)"],
|
||||
"adjustments": [
|
||||
{"block_area": "body", "action": "shrink", "target_ratio": 0.8, "detail": "..."}
|
||||
],
|
||||
"kei_overflow_judgment": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 방안
|
||||
|
||||
### 반영 위치
|
||||
|
||||
`src/pipeline.py` — `generate_slide()` 함수에서 각 스텝 완료 시 저장
|
||||
|
||||
### 유틸 함수
|
||||
|
||||
```python
|
||||
# pipeline.py 상단에 추가
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def _save_step(run_dir: Path, filename: str, data: Any) -> None:
|
||||
"""스텝 결과를 JSON 또는 HTML로 저장한다."""
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
filepath = run_dir / filename
|
||||
if filename.endswith(".html"):
|
||||
filepath.write_text(data, encoding="utf-8")
|
||||
else:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"[중간 산출물] {filename} 저장")
|
||||
```
|
||||
|
||||
### 각 스텝 저장 시점
|
||||
|
||||
```python
|
||||
async def generate_slide(content, manual_layout=None, base_path=""):
|
||||
run_id = str(int(time.time() * 1000))
|
||||
run_dir = Path("data/runs") / run_id
|
||||
|
||||
# Step 1-A
|
||||
analysis = await classify_content(content)
|
||||
_save_step(run_dir, "step1_analysis.json", analysis)
|
||||
|
||||
# Step 1-B
|
||||
analysis = await refine_concepts(content, analysis)
|
||||
_save_step(run_dir, "step1b_concepts.json", {
|
||||
"concepts": [
|
||||
{k: t.get(k) for k in ("id", "relation_type", "expression_hint", "source_data")}
|
||||
for t in analysis.get("topics", []) if t.get("relation_type")
|
||||
]
|
||||
})
|
||||
|
||||
# Step 2 (Opus + Sonnet + validation)
|
||||
layout_concept = await create_layout_concept(content, analysis)
|
||||
_save_step(run_dir, "step2_sonnet_mapping.json", layout_concept)
|
||||
|
||||
# Step 3
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
_save_step(run_dir, "step3_filled_blocks.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"area": b.get("area"),
|
||||
"type": b.get("type"),
|
||||
"topic_id": b.get("topic_id"),
|
||||
"purpose": b.get("purpose"),
|
||||
"data": b.get("data", {}),
|
||||
"char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# Step 4
|
||||
html = render_slide(layout_concept)
|
||||
_save_step(run_dir, "step4_rendered.html", html)
|
||||
|
||||
# Step 5 (검수 결과는 루프 안에서)
|
||||
# review_result 저장
|
||||
|
||||
# 최종
|
||||
_save_step(run_dir, "final.html", html)
|
||||
```
|
||||
|
||||
### Opus 추천 저장
|
||||
|
||||
현재 Opus 추천 결과가 `create_layout_concept()` 내부에서 소비되고 사라짐.
|
||||
추천 결과를 반환값에 포함하거나, 별도로 저장하는 로직 필요.
|
||||
|
||||
**방법:** `create_layout_concept()` 반환값에 `"opus_recommendation"` 키 추가
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀 검토
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| pipeline.py | `_save_step()` 함수 추가 + 각 스텝 후 호출 |
|
||||
| design_director.py | `create_layout_concept()` 반환값에 opus 추천 포함 (선택적) |
|
||||
| 기존 기능 | 변경 없음 — 저장은 추가 기능이므로 기존 흐름에 영향 없음 |
|
||||
| Phase I/J/K | 회귀 없음 |
|
||||
| 성능 | JSON 저장은 ms 수준, HTML 저장도 ms 수준 — 영향 미미 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
1. `_save_step()` 유틸 함수 추가 (pipeline.py)
|
||||
2. `data/runs/` 디렉토리 구조 설정
|
||||
3. `generate_slide()` 각 스텝 완료 시점에 저장 호출 추가
|
||||
4. Opus 추천 결과 반환값 포함 (design_director.py, 선택적)
|
||||
5. 검증: 파이프라인 실행 후 `data/runs/{timestamp}/` 파일 확인
|
||||
|
||||
---
|
||||
|
||||
## 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | Phase K 완료 후. 사용자 피드백 확인을 위한 중간 산출물 저장 기능 계획. |
|
||||
618
docs/history/IMPROVEMENT-PHASE-L.md
Normal file
618
docs/history/IMPROVEMENT-PHASE-L.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# Phase L: 렌더링 측정 에이전트 + Purpose 기반 공간 할당 + 수학적 조정
|
||||
|
||||
> 상태: ✅ 완료 — Selenium 측정 + 피드백 루프 구축. Phase O에서 container div 감지 추가.
|
||||
>
|
||||
> Phase I~K에서 프롬프트/규칙/검수를 개선했지만, **실제 렌더링 결과를 측정하지 않아** 미충족 7건 + 부분충족 4건이 해결되지 않음.
|
||||
> **핵심: LLM이 추정하는 것이 아니라, 코드가 정확하게 계산하고 측정하는 구조로 전환.**
|
||||
>
|
||||
> **후속 변경 (Phase O):**
|
||||
> - `allocate_height_budget()` → `calculate_container_specs()`로 교체
|
||||
> - `_max_height_px` → `_container_height_px`로 교체
|
||||
> - max-height CSS 래퍼 → Phase N에서 제거
|
||||
> - `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가
|
||||
|
||||
---
|
||||
|
||||
## 근본 문제
|
||||
|
||||
현재 파이프라인은 **"만들고 나서 맞는지 모른다"** 구조.
|
||||
|
||||
| 시점 | 지금 | 있어야 하는 것 |
|
||||
|------|------|-------------|
|
||||
| 만들기 전 | 블록 타입별 고정값 합산 (compact=70px) | purpose별 비율로 실제 px 예산 할당 |
|
||||
| 만든 후 | LLM이 HTML 텍스트 읽고 추정 | 렌더링 엔진이 실제 px 측정 |
|
||||
| 안 맞을 때 | LLM이 "shrink 0.7" 추정 | 수학 공식으로 정확한 축약량 계산 |
|
||||
|
||||
---
|
||||
|
||||
## 미충족 + 부분충족 전체 목록 (11건)
|
||||
|
||||
### 미충족 7건
|
||||
|
||||
| # | 항목 | 현재 상태 | 원인 |
|
||||
|---|------|---------|------|
|
||||
| 1 | 2단계 높이 검증 | 블록 타입별 고정값 합산 | 실제 텍스트 양 반영 안 됨 |
|
||||
| 2 | 5단계 높이 초과 감지 | 글자 수로 추정 | 실제 px 모름 |
|
||||
| 3 | 5단계 핵심전달 주인공 확인 | 추정 | 실제 크기 비율 모름 |
|
||||
| 4 | 5단계 문제제기 간결 확인 | 추정 | 실제 렌더링 높이 모름 |
|
||||
| 5 | 5단계 비교표 잘림 감지 | 추정 | scrollHeight vs clientHeight 안 봄 |
|
||||
| 6 | 4단계 CSS 조정 효과 검증 | 없음 | 조정 전후 비교 안 함 |
|
||||
| 7 | 5단계 Kei 검수 근거 | 추정 기반 | 실제 수치 없이 검수 |
|
||||
|
||||
### 부분충족 4건
|
||||
|
||||
| # | 항목 | 현재 상태 | 원인 |
|
||||
|---|------|---------|------|
|
||||
| 8 | Step B Sonnet 높이 예산 준수 | 프롬프트 지시만 | 물리적 강제 없음 |
|
||||
| 9 | Step 3 편집자 분량 준수 | 가이드라인만 | 정확한 max 글자 수 계산 안 됨 |
|
||||
| 10 | Step 5 shrink/expand 효과 | 비율로 조정 | 조정 후 재측정 안 함 |
|
||||
| 11 | 5단계 용어정의 sidebar 확인 | 프롬프트 지시만 | 코드 레벨 강제 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법 4가지
|
||||
|
||||
### 방법 1: Purpose 기반 공간 할당 (만들기 전)
|
||||
|
||||
**원리:** purpose의 중요도에 따라 zone 내 각 블록의 max-height를 **코드로 결정론적으로** 할당.
|
||||
|
||||
```
|
||||
body zone = 490px (전체 예산)
|
||||
|
||||
purpose별 비율 할당:
|
||||
핵심전달 = 55% → max 270px
|
||||
문제제기 = 20% → max 98px
|
||||
근거사례 = 25% → max 122px
|
||||
|
||||
→ 블록 수와 purpose에 따라 자동 계산
|
||||
→ AI 추정이 아닌 코드 계산
|
||||
```
|
||||
|
||||
**구현:**
|
||||
```python
|
||||
PURPOSE_WEIGHT = {
|
||||
"핵심전달": 0.55, # 주인공 — 가장 큰 비중
|
||||
"문제제기": 0.20, # 도입부 — 간결
|
||||
"근거사례": 0.25, # 보조 — 짧게
|
||||
"결론강조": 1.0, # footer 전용 (별도 zone)
|
||||
"용어정의": 1.0, # sidebar 전용 (별도 zone)
|
||||
}
|
||||
|
||||
def allocate_height_budget(blocks: list[dict], zone_budget_px: int) -> dict:
|
||||
"""purpose별 비중으로 각 블록의 max-height를 할당한다."""
|
||||
flow_blocks = [b for b in blocks if b.get("role") != "reference"]
|
||||
total_weight = sum(PURPOSE_WEIGHT.get(b.get("purpose", ""), 0.2) for b in flow_blocks)
|
||||
gap_total = 20 * max(0, len(flow_blocks) - 1)
|
||||
available = zone_budget_px - gap_total
|
||||
|
||||
allocation = {}
|
||||
for block in flow_blocks:
|
||||
weight = PURPOSE_WEIGHT.get(block.get("purpose", ""), 0.2)
|
||||
ratio = weight / total_weight
|
||||
allocation[block.get("topic_id")] = int(available * ratio)
|
||||
|
||||
return allocation
|
||||
# 예: {1: 98, 3: 270, 5: 122} (topic_id → max_height_px)
|
||||
```
|
||||
|
||||
**해결하는 미충족:** #1 (높이 검증), #3 (주인공 확인), #8 (예산 강제)
|
||||
|
||||
---
|
||||
|
||||
### 방법 2: 렌더링 측정 에이전트 (만든 후)
|
||||
|
||||
**원리:** HTML을 실제 브라우저에서 렌더링하고 각 zone/block의 px을 정확히 측정.
|
||||
|
||||
**Selenium (이미 설치됨) 사용:**
|
||||
```python
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
def measure_rendered_heights(html: str, slide_width: int, slide_height: int) -> dict:
|
||||
"""렌더링된 HTML의 각 zone/block 실제 px 높이를 측정한다."""
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument(f"--window-size={slide_width},{slide_height}")
|
||||
driver = webdriver.Chrome(options=options)
|
||||
|
||||
try:
|
||||
driver.get("data:text/html;charset=utf-8," + html)
|
||||
|
||||
results = driver.execute_script("""
|
||||
const slide = document.querySelector('.slide');
|
||||
const zones = {};
|
||||
|
||||
// 각 zone (area) 측정
|
||||
slide.querySelectorAll('[class^="area-"]').forEach(zone => {
|
||||
const className = zone.className;
|
||||
const blocks = [];
|
||||
|
||||
zone.querySelectorAll('[class^="block-"]').forEach(block => {
|
||||
blocks.push({
|
||||
className: block.className,
|
||||
scrollHeight: block.scrollHeight,
|
||||
clientHeight: block.clientHeight,
|
||||
overflowed: block.scrollHeight > block.clientHeight,
|
||||
excess_px: Math.max(0, block.scrollHeight - block.clientHeight)
|
||||
});
|
||||
});
|
||||
|
||||
zones[className] = {
|
||||
scrollHeight: zone.scrollHeight,
|
||||
clientHeight: zone.clientHeight,
|
||||
overflowed: zone.scrollHeight > zone.clientHeight,
|
||||
excess_px: Math.max(0, zone.scrollHeight - zone.clientHeight),
|
||||
blocks: blocks
|
||||
};
|
||||
});
|
||||
|
||||
// 슬라이드 전체
|
||||
return {
|
||||
slide: {
|
||||
scrollHeight: slide.scrollHeight,
|
||||
clientHeight: slide.clientHeight,
|
||||
overflowed: slide.scrollHeight > slide.clientHeight
|
||||
},
|
||||
zones: zones
|
||||
};
|
||||
""")
|
||||
return results
|
||||
finally:
|
||||
driver.quit()
|
||||
```
|
||||
|
||||
**측정 결과 예시:**
|
||||
```json
|
||||
{
|
||||
"slide": {"scrollHeight": 750, "clientHeight": 720, "overflowed": true},
|
||||
"zones": {
|
||||
"area-body": {
|
||||
"scrollHeight": 520, "clientHeight": 490, "overflowed": true, "excess_px": 30,
|
||||
"blocks": [
|
||||
{"className": "block-quote-big", "scrollHeight": 160, "clientHeight": 160, "overflowed": false},
|
||||
{"className": "block-topic-header", "scrollHeight": 80, "clientHeight": 80, "overflowed": false},
|
||||
{"className": "block-split-compare", "scrollHeight": 280, "clientHeight": 250, "overflowed": true, "excess_px": 30}
|
||||
]
|
||||
},
|
||||
"area-sidebar": {
|
||||
"scrollHeight": 400, "clientHeight": 490, "overflowed": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**viewport 크기는 config에서 읽음 (하드코딩 아님):**
|
||||
```python
|
||||
from src.config import settings
|
||||
results = measure_rendered_heights(html, settings.slide_width, settings.slide_height)
|
||||
```
|
||||
|
||||
**해결하는 미충족:** #2 (높이 초과 감지), #5 (비교표 잘림), #6 (CSS 효과 검증), #7 (검수 근거), #10 (조정 효과)
|
||||
|
||||
---
|
||||
|
||||
### 방법 3: CSS max-height 제약 (구조적 보장)
|
||||
|
||||
**원리:** 방법 1에서 할당한 max-height를 실제 CSS에 적용하여 물리적으로 넘치지 않게 함.
|
||||
|
||||
**렌더링 시 적용:**
|
||||
```python
|
||||
# renderer.py에서 블록 렌더링 시 max-height 주입
|
||||
for block in blocks:
|
||||
allocated = height_allocation.get(block.get("topic_id"))
|
||||
if allocated:
|
||||
block["_max_height_px"] = allocated
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- 템플릿에서 max-height 적용 -->
|
||||
<div style="max-height: {{ _max_height_px }}px; overflow: hidden;">
|
||||
<!-- 블록 내용 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**측정 에이전트(방법 2)가 overflow 감지:**
|
||||
- `scrollHeight > clientHeight` → 콘텐츠가 잘림 → 축약 필요
|
||||
- 정확한 초과량(excess_px) 제공
|
||||
|
||||
**해결하는 미충족:** #8 (예산 강제), #11 (sidebar 물리적 강제)
|
||||
|
||||
---
|
||||
|
||||
### 방법 4: 조정량 수학적 계산 (AI 추정 → 공식)
|
||||
|
||||
**원리:** 측정 에이전트가 보고한 excess_px에서 삭제할 글자 수를 수학 공식으로 계산.
|
||||
|
||||
```python
|
||||
def calculate_trim_chars(
|
||||
excess_px: int,
|
||||
font_size_px: float,
|
||||
line_height: float,
|
||||
container_width_px: int,
|
||||
avg_char_width_px: float = 16.0, # 한글 Pretendard 기준
|
||||
) -> int:
|
||||
"""초과 px에서 삭제할 글자 수를 수학적으로 계산한다.
|
||||
|
||||
AI 추정이 아닌 결정론적 공식.
|
||||
"""
|
||||
line_height_px = font_size_px * line_height
|
||||
lines_to_remove = math.ceil(excess_px / line_height_px)
|
||||
chars_per_line = int(container_width_px / avg_char_width_px)
|
||||
chars_to_remove = lines_to_remove * chars_per_line
|
||||
return chars_to_remove
|
||||
|
||||
# 예: excess_px=62, font=16px, line-height=1.7, width=700px
|
||||
# → line_height_px = 27.2
|
||||
# → lines_to_remove = ceil(62/27.2) = 3
|
||||
# → chars_per_line = 700/16 = 43
|
||||
# → chars_to_remove = 3 × 43 = 129자
|
||||
```
|
||||
|
||||
**편집자 재호출 시:**
|
||||
```python
|
||||
# 기존: "shrink target_ratio: 0.7" (AI 추정)
|
||||
# 변경: "quote-big-mark의 quote_text를 129자 줄여라" (수학적 계산)
|
||||
```
|
||||
|
||||
**해결하는 미충족:** #4 (간결 확인), #9 (편집자 분량 정확), #10 (shrink 효과)
|
||||
|
||||
---
|
||||
|
||||
## 전체 통합 파이프라인 (Phase L 적용 후)
|
||||
|
||||
```
|
||||
[1단계] Kei 분석
|
||||
→ purpose별 꼭지 + 비중 결정
|
||||
↓
|
||||
[방법 1] Purpose 기반 공간 할당 (코드, 결정론적)
|
||||
→ body 내 각 블록별 max-height 할당 (px)
|
||||
→ max 글자 수 수학적 계산 (방법 4)
|
||||
↓
|
||||
[2단계] 팀장 블록 선택
|
||||
→ 할당된 max-height 안에서 가능한 블록만 선택
|
||||
↓
|
||||
[3단계] 편집자 텍스트 채움
|
||||
→ max 글자 수 제약 (수학적 계산 기반, AI 추정 아님)
|
||||
↓
|
||||
[4단계] CSS 조정 + 렌더링
|
||||
→ max-height CSS 제약 포함 (방법 3)
|
||||
↓
|
||||
[방법 2] 렌더링 측정 에이전트 (Selenium)
|
||||
→ 각 zone/block의 실제 px 측정
|
||||
→ overflow 감지 (scrollHeight > clientHeight)
|
||||
↓
|
||||
├── 맞으면 → [5단계] Kei 검수 (실제 px 수치 전달)
|
||||
│ Kei가 받는 정보:
|
||||
│ "body zone: 실제 480px / 예산 490px — OK"
|
||||
│ "핵심전달 블록: 260px (body의 54%) — 주인공 비중 충족"
|
||||
│ "비교표: 250px, 잘림 없음"
|
||||
│ → 근거 있는 콘텐츠 검수 가능
|
||||
│
|
||||
└── 안 맞으면 → [방법 4] 수학적 축약량 계산
|
||||
"quote-big-mark: 62px 초과 → 129자 삭제 필요"
|
||||
→ 편집자 재호출 (정확한 글자 수)
|
||||
→ 재렌더링 → 재측정 → 반복
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 미충족/부분충족 해결 매핑
|
||||
|
||||
| # | 항목 | 해결 방법 | 근거 |
|
||||
|---|------|----------|------|
|
||||
| 1 | 2단계 높이 검증이 추정 | 방법 1 (할당) + 방법 2 (측정) | purpose별 px 할당 + 실제 렌더링 검증 |
|
||||
| 2 | 5단계 높이 초과 감지가 추정 | 방법 2 (측정) | scrollHeight > clientHeight 정확 감지 |
|
||||
| 3 | 5단계 핵심전달 주인공 확인 불가 | 방법 1 (할당) + 방법 2 (측정) | 할당 비율 55% 대비 실제 비율 비교 |
|
||||
| 4 | 5단계 문제제기 간결 확인 불가 | 방법 2 (측정) + 방법 4 (계산) | 실제 px + 수학적 글자 수 계산 |
|
||||
| 5 | 5단계 비교표 잘림 감지 불가 | 방법 2 (측정) | scrollHeight > clientHeight로 잘림 정확 감지 |
|
||||
| 6 | 4단계 CSS 조정 효과 검증 불가 | 방법 2 (측정) | 조정 전후 실제 px 비교 |
|
||||
| 7 | 5단계 Kei 검수 근거 없음 | 방법 2 (측정) | 실제 px 수치를 Kei에게 전달 |
|
||||
| 8 | Step B 높이 예산 안 지킴 | 방법 1 (할당) + 방법 3 (CSS) | max-height로 물리적 강제 |
|
||||
| 9 | 편집자 분량 안 지킴 | 방법 4 (계산) | 할당 높이에서 max 글자 수 수학적 계산 |
|
||||
| 10 | shrink 효과 검증 불가 | 방법 2 (측정) | 조정 후 재렌더링 → 재측정 |
|
||||
| 11 | 용어정의 sidebar 강제 | 방법 3 (CSS) | sidebar 외 zone에서 용어정의 블록 물리적 차단 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### L-Step 1: 공간 할당 엔진
|
||||
|
||||
1. `PURPOSE_WEIGHT` 상수 + `allocate_height_budget()` 함수
|
||||
2. `calculate_trim_chars()` 수학적 글자 수 계산 함수
|
||||
3. pipeline.py에서 2단계 완료 후 할당 실행
|
||||
|
||||
### L-Step 2: 렌더링 측정 에이전트
|
||||
|
||||
4. `measure_rendered_heights()` 함수 (Selenium headless)
|
||||
5. pipeline.py에서 4단계 완료 후 측정 실행
|
||||
6. 측정 결과를 step4_measurement.json으로 저장 (K-1 연동)
|
||||
|
||||
### L-Step 3: CSS max-height 제약
|
||||
|
||||
7. renderer.py에서 블록별 max-height 적용
|
||||
8. 할당 → CSS 제약 → 렌더링 → 측정 파이프 연결
|
||||
|
||||
### L-Step 4: 피드백 루프
|
||||
|
||||
9. 측정 결과 overflow → 수학적 축약량 계산 → 편집자 재호출
|
||||
10. 재렌더링 → 재측정 → 맞으면 5단계로
|
||||
11. Kei 검수에 실제 px 수치 전달
|
||||
|
||||
---
|
||||
|
||||
## 필요 기술/도구
|
||||
|
||||
| 도구 | 용도 | 설치 상태 |
|
||||
|------|------|----------|
|
||||
| Selenium + Chrome headless | 렌더링 측정 | **설치됨** (4.34.0) |
|
||||
| ChromeDriver | Selenium 구동 | webdriver-manager로 자동 관리 |
|
||||
| math (Python 표준) | 축약량 계산 | 기본 포함 |
|
||||
| config.py settings | viewport 크기 (하드코딩 방지) | 이미 존재 (slide_width, slide_height) |
|
||||
|
||||
---
|
||||
|
||||
## 하드코딩 방지
|
||||
|
||||
- viewport 크기: `settings.slide_width`, `settings.slide_height`에서 읽음
|
||||
- purpose 비율: `PURPOSE_WEIGHT` 상수 (범용, 콘텐츠 무관)
|
||||
- 글자 수 계산: 폰트 크기/line-height를 CSS 변수에서 읽거나 config에서 관리
|
||||
- 반응형 전환 시: config만 바꾸면 측정도 따라감
|
||||
|
||||
---
|
||||
|
||||
## 코드 조사 결과 (정밀 검토)
|
||||
|
||||
### 현재 있는 것
|
||||
|
||||
| 항목 | 위치 | 상태 |
|
||||
|------|------|------|
|
||||
| zone별 budget_px | design_director.py 322~370행 | 4개 프리셋 × 4개 zone |
|
||||
| HEIGHT_COST_PX | design_director.py 906~911행 | compact=70, medium=150, large=250, xlarge=400 |
|
||||
| overflow 수집 함수 | design_director.py 962~1069행 | 블록 타입 기반 추정 (실제 렌더링 아님) |
|
||||
| style_override 주입 경로 | slide-base.html 45행 | max-height 주입 가능 |
|
||||
| Selenium | v4.34.0 | 사용 가능 |
|
||||
| Pillow | 설치됨 | 사용 가능 |
|
||||
| config slide_width/height | config.py | 1280/720 |
|
||||
|
||||
### 없는 것 (Phase L에서 구현)
|
||||
|
||||
| 항목 | 필요 이유 |
|
||||
|------|----------|
|
||||
| PURPOSE_WEIGHT 상수 | purpose → 공간 비율 매핑. 현재 존재하지 않음 |
|
||||
| allocate_height_budget() | zone 내 블록별 max-height 계산. 현재 없음 |
|
||||
| measure_rendered_heights() | 실제 렌더링 px 측정. 현재 없음 |
|
||||
| calculate_trim_chars() | 초과 px → 삭제 글자 수 계산. 현재 없음 |
|
||||
| Pretendard 로컬 폰트 | CDN만 있음. Pillow 계산용으로 다운로드 필요 |
|
||||
| max-height CSS 적용 | 현재 area에 max-height 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀 검토
|
||||
|
||||
### 방법 1 (Purpose 할당)
|
||||
|
||||
- `PURPOSE_WEIGHT` 상수 신규 추가 → 기존 코드와 **충돌 없음**
|
||||
- `allocate_height_budget()` 신규 함수 → `_validate_height_budget()`와 **별개**, 충돌 없음
|
||||
- pipeline.py Stage 2 이후 삽입 → 기존 흐름 **변경 없이 추가**
|
||||
- Phase I~K 회귀 없음
|
||||
|
||||
### 방법 2 (Selenium 측정)
|
||||
|
||||
- `measure_rendered_heights()` 신규 모듈 (`src/slide_measurer.py`) → 기존 코드와 **충돌 없음**
|
||||
- pipeline.py Stage 4 이후 삽입 → 기존 `render_slide()` 결과를 입력으로 사용
|
||||
- **주의:** Selenium 동기식 → `asyncio.to_thread()` 래핑 필요
|
||||
- Kei 검수에 측정 결과 전달 → `call_kei_final_review()` 파라미터 확장
|
||||
- **회귀 없음:** 기존 HTML 렌더링 그대로, 측정은 추가 단계
|
||||
|
||||
### 방법 3 (CSS max-height)
|
||||
|
||||
- style_override에 max-height 주입 → 기존 `area_styles` 구조 활용
|
||||
- **충돌 주의:** Phase A-5에서 `.slide > div { overflow: visible }`로 변경한 이유가 "텍스트 잘림 방지"
|
||||
- max-height 적용 시 overflow: visible과 충돌
|
||||
- **해결:** 측정 시에만 overflow: hidden 임시 적용하거나, 블록 레벨에서만 max-height 적용 (area 레벨이 아닌)
|
||||
- Phase I~K 회귀 없음
|
||||
|
||||
### 방법 4 (수학적 계산)
|
||||
|
||||
- Pretendard 로컬 폰트 필요 → CDN에서 다운로드하여 `data/fonts/`에 캐싱
|
||||
- Pillow `multiline_textbbox()` 사용 → 기존 코드와 **충돌 없음**
|
||||
- `calculate_trim_chars()` 신규 유틸 → 별도 모듈
|
||||
- Phase I~K 회귀 없음
|
||||
|
||||
---
|
||||
|
||||
## Kei vs Sonnet vs 코드 역할 분담
|
||||
|
||||
| 역할 | 담당 | AI/코드 |
|
||||
|------|------|---------|
|
||||
| Purpose 비율 결정 | **코드** (PURPOSE_WEIGHT) | 결정론적 |
|
||||
| max-height 할당 | **코드** (allocate_height_budget) | 결정론적 |
|
||||
| max 글자 수 계산 | **코드** (calculate_trim_chars) | 결정론적 |
|
||||
| 렌더링 측정 | **Selenium** (브라우저 엔진) | 결정론적 |
|
||||
| overflow 감지 | **코드** (scrollHeight > clientHeight) | 결정론적 |
|
||||
| 텍스트 축약 실행 | **Kei** (편집자, Kei API) | AI (도메인 지식) |
|
||||
| 최종 검수 | **Kei** (실장, Kei API) | AI (실제 px 수치 기반) |
|
||||
| CSS 조정 | **Sonnet** (실무자) | AI (Stage 4 기존) |
|
||||
|
||||
**핵심:** 측정/계산/감지는 전부 **코드(결정론적)**. AI는 콘텐츠 판단(축약/검수)만.
|
||||
|
||||
---
|
||||
|
||||
## 주의가 필요한 3곳
|
||||
|
||||
### 1. overflow: visible vs max-height 충돌
|
||||
|
||||
**현재:** `.slide > div { overflow: visible }` (Phase A-5)
|
||||
**Phase L:** 블록에 max-height 적용 시 넘치는 콘텐츠가 visible 상태로 보임
|
||||
**해결 방안:**
|
||||
- (A) 블록 wrapper에 `overflow: hidden` + max-height → 블록 레벨에서 잘림
|
||||
- (B) area 레벨은 visible 유지, 블록 레벨에서만 제약 → Phase A-5 원칙 유지
|
||||
- **권장: (B)** — area는 건드리지 않고, 개별 블록 wrapper에만 max-height 적용
|
||||
|
||||
### 2. Selenium 동기식 → async 파이프라인
|
||||
|
||||
**현재:** pipeline.py 전체가 async
|
||||
**Selenium:** 동기식 API
|
||||
**해결:**
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def measure_async(html: str) -> dict:
|
||||
return await asyncio.to_thread(measure_rendered_heights, html)
|
||||
```
|
||||
|
||||
### 3. Pretendard 로컬 폰트
|
||||
|
||||
**현재:** CDN만 (@import url)
|
||||
**Pillow 계산에 필요:** 로컬 .ttf 파일
|
||||
**해결:**
|
||||
- 첫 실행 시 CDN에서 다운로드 → `data/fonts/Pretendard-Regular.ttf` 캐싱
|
||||
- 또는 프로젝트에 폰트 파일 포함 (라이선스: OFL — 재배포 가능)
|
||||
|
||||
---
|
||||
|
||||
## 실행 방안 상세
|
||||
|
||||
### L-Step 1: 공간 할당 엔진
|
||||
|
||||
**신규 파일:** `src/space_allocator.py`
|
||||
|
||||
```python
|
||||
PURPOSE_WEIGHT = {
|
||||
"핵심전달": 0.55,
|
||||
"문제제기": 0.20,
|
||||
"근거사례": 0.25,
|
||||
"결론강조": 1.0, # footer 전용
|
||||
"용어정의": 1.0, # sidebar 전용
|
||||
}
|
||||
|
||||
def allocate_height_budget(blocks, zone_budget_px, gap_px=20):
|
||||
"""purpose 비중으로 각 블록의 max-height를 할당한다. 결정론적."""
|
||||
...
|
||||
|
||||
def calculate_max_chars(max_height_px, font_size_px, line_height, container_width_px, font_path):
|
||||
"""할당된 높이에서 최대 글자 수를 수학적으로 계산한다."""
|
||||
...
|
||||
|
||||
def calculate_trim_chars(excess_px, font_size_px, line_height, container_width_px, font_path):
|
||||
"""초과 px에서 삭제할 글자 수를 수학적으로 계산한다."""
|
||||
...
|
||||
```
|
||||
|
||||
**반영 위치:** pipeline.py Stage 2 완료 후
|
||||
**충돌:** 없음. 신규 모듈.
|
||||
**회귀:** 없음.
|
||||
|
||||
### L-Step 2: 렌더링 측정 에이전트
|
||||
|
||||
**신규 파일:** `src/slide_measurer.py`
|
||||
|
||||
```python
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from src.config import settings
|
||||
|
||||
def measure_rendered_heights(html: str) -> dict:
|
||||
"""렌더링된 HTML의 각 zone/block 실제 px을 측정한다. 결정론적."""
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument(f"--window-size={settings.slide_width},{settings.slide_height}")
|
||||
driver = webdriver.Chrome(options=options)
|
||||
try:
|
||||
driver.get("data:text/html;charset=utf-8," + html)
|
||||
# 폰트 로딩 대기
|
||||
driver.execute_script("return document.fonts.ready")
|
||||
# 각 zone/block 측정
|
||||
results = driver.execute_script("""...""")
|
||||
return results
|
||||
finally:
|
||||
driver.quit()
|
||||
```
|
||||
|
||||
**반영 위치:** pipeline.py Stage 4 완료 후 (렌더링 직후)
|
||||
**저장:** `step4_measurement.json` (K-1 연동)
|
||||
**충돌:** 없음. 신규 모듈.
|
||||
**회귀:** 없음.
|
||||
|
||||
### L-Step 3: CSS max-height 제약
|
||||
|
||||
**반영 위치:** renderer.py 블록 렌더링 시
|
||||
**방식:** 블록 wrapper에 max-height 적용 (area 레벨 아님 — Phase A-5 원칙 유지)
|
||||
|
||||
```html
|
||||
<!-- 블록별 max-height (area 레벨이 아닌 블록 레벨) -->
|
||||
<div style="max-height: {{ _max_height_px }}px; overflow: hidden;">
|
||||
{{ block_html }}
|
||||
</div>
|
||||
```
|
||||
|
||||
**충돌:** Phase A-5 overflow: visible은 area 레벨 → 블록 레벨 max-height와 충돌 없음
|
||||
**회귀:** 없음.
|
||||
|
||||
### L-Step 4: 피드백 루프
|
||||
|
||||
**반영 위치:** pipeline.py Stage 4~5 사이
|
||||
|
||||
```
|
||||
렌더링 완료 (Stage 4)
|
||||
↓
|
||||
측정 (slide_measurer)
|
||||
↓
|
||||
overflow 있으면:
|
||||
수학적 축약량 계산 (space_allocator)
|
||||
편집자 재호출 (fill_content) — "quote_text를 129자 줄여라"
|
||||
재렌더링 (render_slide)
|
||||
재측정
|
||||
MAX 3회 반복
|
||||
↓
|
||||
overflow 없으면:
|
||||
Kei 검수 (call_kei_final_review) — 실제 px 수치 포함
|
||||
```
|
||||
|
||||
**Kei 검수에 전달할 측정 결과:**
|
||||
```
|
||||
"body zone: 실제 480px / 예산 490px — OK"
|
||||
"핵심전달(compare-2col-split): 260px (body의 54%) — 주인공 비중 충족"
|
||||
"문제제기(quote-big-mark): 90px (body의 19%) — 간결"
|
||||
"비교표: scrollHeight=250, clientHeight=260 — 잘림 없음"
|
||||
```
|
||||
|
||||
**충돌:** 기존 Stage 5 Kei 검수 구조 유지. 파라미터에 measurement 추가만.
|
||||
**회귀:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 하드코딩 방지 확인
|
||||
|
||||
| 항목 | 하드코딩? | 근거 |
|
||||
|------|:--------:|------|
|
||||
| PURPOSE_WEIGHT 비율 | 아님 | 범용 상수. 콘텐츠 유형 무관. |
|
||||
| max-height px | 아님 | budget_px × purpose 비율로 계산. 고정값 아님. |
|
||||
| viewport 크기 | 아님 | settings.slide_width/height에서 읽음. |
|
||||
| 폰트 메트릭 | 아님 | Pillow가 실제 폰트 파일에서 측정. |
|
||||
| 축약 글자 수 | 아님 | excess_px / line_height × chars_per_line 공식 계산. |
|
||||
| CSS max-height | 아님 | allocate_height_budget() 결과를 동적 주입. |
|
||||
| overflow 감지 | 아님 | scrollHeight > clientHeight 브라우저 네이티브. |
|
||||
|
||||
---
|
||||
|
||||
## 예상 효과 (Phase L 적용 전후)
|
||||
|
||||
| 항목 | Phase L 전 | Phase L 후 |
|
||||
|------|-----------|-----------|
|
||||
| 비교표 잘림 | 모름 | **scrollHeight 250 > clientHeight 240 → 10px 잘림 감지** |
|
||||
| 핵심전달 주인공 | 추정 | **260px / 490px = 53% — 주인공 비중 수치로 확인** |
|
||||
| 문제제기 간결 | 추정 | **90px / 98px 할당 — 할당 내 OK** |
|
||||
| shrink 효과 | 모름 | **조정 전 520px → 조정 후 480px — 40px 감소 확인** |
|
||||
| Kei 검수 | 근거 없음 | **실제 px 수치 기반 판단** |
|
||||
| 편집자 분량 | 가이드만 | **max 129자 — 수학적 계산** |
|
||||
|
||||
---
|
||||
|
||||
## 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | Phase K 완료 후 결과물 분석. 미충족 7건 + 부분충족 4건 전수 진단. 4가지 해결 방법 도출. Phase L 계획 수립. |
|
||||
| 2026-03-26 | 코드 전수 조사 + 충돌/회귀 정밀 검토 완료. 주의 사항 3곳 식별. 실행 방안 상세 확정. |
|
||||
605
docs/history/IMPROVEMENT-PHASE-M.md
Normal file
605
docs/history/IMPROVEMENT-PHASE-M.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# Phase M: 비중 시스템 + 역할-블록 매핑 + 블록 안전성 + 원본 보존
|
||||
|
||||
> 상태: ✅ 완료 — Kei 비중 시스템 구축. Phase O에서 컨테이너 시스템으로 발전.
|
||||
>
|
||||
> Phase I~L에서 코드 정합성, 블록 선택 권한, 프롬프트 원칙, 렌더링 측정을 다뤘지만
|
||||
> **근본 문제가 해결되지 않음: "이 페이지의 본심이 뭔지" 판단이 없음.**
|
||||
> Kei가 콘텐츠마다 본심/배경/첨부/결론을 판단하고, 비중(weight)을 결정해야 함.
|
||||
> 코드 상수(하드코딩)가 아닌 **Kei의 매번 판단**.
|
||||
>
|
||||
> **후속 변경 (Phase O):**
|
||||
> - pipeline.py의 Phase M 공간 할당 코드 → Phase O `calculate_container_specs()`로 교체
|
||||
> - `PURPOSE_WEIGHT` 상수 → 삭제 (Kei weight 직접 사용)
|
||||
> - `allocate_height_budget()` → `calculate_container_specs()` + `finalize_block_specs()`로 교체
|
||||
|
||||
---
|
||||
|
||||
## 문제점 전체 리스트 (9건)
|
||||
|
||||
### P-1: 비중(weight) 개념 부재
|
||||
|
||||
**현상:** Kei가 꼭지 5개를 분류하면, 팀장이 5개를 동등하게 1:1 배치. "본심이 60%, 배경이 15%"라는 공간 비중 개념이 파이프라인 어디에도 없음.
|
||||
|
||||
**예시:** DX vs BIM 비교(본심)와 용어 정의(첨부)가 동일한 크기의 블록을 받음.
|
||||
|
||||
**영향:** 핵심 메시지가 묻히고, 보조 정보가 과도한 공간 차지.
|
||||
|
||||
**위치:** 1단계(Kei) 출력 → 2단계(팀장) 입력 사이.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase K에서 PURPOSE_WEIGHT 상수 추가, Phase L에서 allocate_height_budget() 함수 추가.
|
||||
**문제:** 하드코딩된 고정 비율. 콘텐츠마다 다른데 코드가 일괄 적용.
|
||||
|
||||
---
|
||||
|
||||
### P-2: 편집자적 구조 판단 부재
|
||||
|
||||
**현상:** Kei가 꼭지를 "나열"만 함. 아래와 같은 편집 구조를 잡지 못함:
|
||||
|
||||
```
|
||||
(배경/목적) 왜 이 페이지가 필요한가
|
||||
(본심) 이 페이지가 말하려는 핵심
|
||||
(첨부) 본심을 이해하기 위한 보조 정보
|
||||
(잊지마) 절대 잊으면 안 되는 결론
|
||||
```
|
||||
|
||||
**현재:** purpose와 layer가 있지만 "이 페이지의 본심은 꼭지2이고 나머지는 보조다"라는 판단이 없음.
|
||||
|
||||
**영향:** 모든 꼭지가 동등하게 취급됨. 스토리라인은 있으나 강약이 없음.
|
||||
|
||||
**위치:** 1단계 KEI_PROMPT.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase K에서 인지 흐름 원칙 추가.
|
||||
**문제:** 원칙만 줬지 Kei 출력 스키마에 "본심/배경/첨부/결론" 구분이 없음.
|
||||
|
||||
---
|
||||
|
||||
### P-3: 블록 선택이 "콘텐츠 역할"이 아닌 "데이터 타입"으로 결정됨
|
||||
|
||||
**현상:** 팀장(Sonnet)이 블록을 고를 때 "텍스트 → 텍스트 블록, 표 → 표 블록"으로 데이터 형식만 보고 선택. "이것이 본심이니까 정보 밀도 높은 블록" 판단 안 함.
|
||||
|
||||
**올바른 선택 기준:**
|
||||
```
|
||||
본심(핵심전달) → 정보형 블록 (compare-2col-split 등) → 공간 최대
|
||||
배경(문제제기) → 컴팩트 블록 (topic-left-right 등) → 공간 최소
|
||||
첨부(용어정의) → 참조형 블록 (card-numbered 등) → sidebar
|
||||
결론(강조) → 선언형 블록 (banner-gradient) → footer
|
||||
```
|
||||
|
||||
**위치:** 2단계 STEP_B_PROMPT + FAISS 검색.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase K에서 purpose별 허용/금지 블록 규칙 추가.
|
||||
**문제:** purpose 기반이지 "본심/배경" 기반이 아님. Kei가 비중을 출력해야 팀장이 비중대로 블록 크기 결정.
|
||||
|
||||
---
|
||||
|
||||
### P-4: 공간 배분 로직 부재
|
||||
|
||||
**현상:** 팀장이 zone별 height_cost만 검증하고, "이 꼭지에 몇 px를 줘야 하는가"는 계산하지 않음.
|
||||
|
||||
**현재 로직:** 블록 선택 → height_cost 합산 확인 → 초과하면 교체
|
||||
**필요한 로직:** 비중(weight) 확인 → weight에 따라 zone 예산 배분 → 배분된 px에 맞는 블록 선택
|
||||
|
||||
**위치:** 2단계 create_layout_concept().
|
||||
|
||||
**Phase I~L에서 한 것:** Phase L에서 allocate_height_budget() + max-height CSS 적용.
|
||||
**문제:** PURPOSE_WEIGHT가 하드코딩. Kei가 판단한 weight를 사용해야 함.
|
||||
|
||||
---
|
||||
|
||||
### P-5: Figma 비추출 블록 사용
|
||||
|
||||
**현상:** 38개 블록 중 9개가 Figma 디자인 없이 코드로 만든 블록. 디자인 품질 미검증.
|
||||
|
||||
**비-Figma 블록 (9개):**
|
||||
- topic-numbered, card-numbered, table-simple-striped
|
||||
- venn-diagram, process-horizontal
|
||||
- comparison-2col, callout-warning
|
||||
- divider-text, image-before-after
|
||||
|
||||
**영향:** 시각적 통일성 저하.
|
||||
|
||||
**위치:** 2단계 블록 선택 시 필터링 없음.
|
||||
|
||||
**Phase I~L에서 한 것:** 안 다룸.
|
||||
|
||||
---
|
||||
|
||||
### P-6: 블록-zone 적합성 검증 부재
|
||||
|
||||
**현상:** sidebar(35%)에 full-width 전용 블록을 배치하면 찌그러짐. 블록이 어떤 zone에서 작동하는지 검증 없음.
|
||||
|
||||
**full-width 전용 블록 (15개):**
|
||||
- card-icon-desc, card-compare-3col, comparison-2col
|
||||
- topic-left-right, compare-pill-pair, process-horizontal 등
|
||||
|
||||
**영향:** sidebar에서 블록 깨짐, 텍스트 한 글자씩 줄바꿈.
|
||||
|
||||
**위치:** 2단계 블록 선택 후 검증.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase J에서 sidebar 1열 강제(column_override). 불완전.
|
||||
|
||||
---
|
||||
|
||||
### P-7: 블록별 글자 수용량 미정의
|
||||
|
||||
**현상:** 블록에 텍스트를 넣을 때 "얼마나 들어가는지" 기준 없음. char_guide 참고하지만 실제 렌더링과 괴리.
|
||||
|
||||
**결과:** 텍스트 과다 → overflow / 텍스트 과소 → 빈 페이지.
|
||||
|
||||
**위치:** catalog.yaml에 schema 미정의. 3단계 편집자 프롬프트.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase I에서 slot_desc 추가, Phase K에서 분량 가이드라인 추가. 실제 수용량은 미정의.
|
||||
|
||||
---
|
||||
|
||||
### P-8: 내부 스크롤 미감지
|
||||
|
||||
**현상:** 5단계 검수에서 area 레벨 overflow만 체크. 블록 내부의 overflow: auto/hidden으로 인한 내부 스크롤/잘림은 감지 못함.
|
||||
|
||||
**예시:** compare-3col-badge는 overflow: auto여서 area는 OK인데 블록 안에서 스크롤 발생.
|
||||
|
||||
**영향:** "검증 통과"했는데 실제로는 내용 잘림.
|
||||
|
||||
**위치:** 5단계 검수.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase L에서 Selenium 측정 추가. 하지만 블록 내부 overflow까지 체크하는지 미확인.
|
||||
|
||||
---
|
||||
|
||||
### P-9: 원본 텍스트 임의 재작성
|
||||
|
||||
**현상:** 3단계 편집자가 원본을 "편집"이 아닌 "재작성". 원본 문구, 출처, 수치 변경/누락.
|
||||
|
||||
**영향:** 정보 정확도 저하, 출처 누락.
|
||||
|
||||
**위치:** 3단계 편집자 프롬프트 + Kei API 응답 품질.
|
||||
|
||||
**Phase I~L에서 한 것:** Phase J에서 source 슬롯 규칙 추가, EDITOR_PROMPT에 보존 원칙. 강제력 부족.
|
||||
|
||||
---
|
||||
|
||||
## 개선 방향 (4가지)
|
||||
|
||||
### 방향 1: 비중(weight) 시스템 — P-1, P-2, P-4 해결 [긴급]
|
||||
|
||||
**핵심:** Kei가 콘텐츠마다 본심/배경/첨부/결론을 판단하고 weight를 출력.
|
||||
|
||||
**KEI_PROMPT 출력 스키마 변경:**
|
||||
```json
|
||||
{
|
||||
"title": "건설산업 DX의 올바른 이해",
|
||||
"core_message": "BIM은 DX의 기초적 일부분이다",
|
||||
"page_structure": {
|
||||
"본심": {"topic_ids": [2, 3], "weight": 0.60},
|
||||
"배경": {"topic_ids": [1], "weight": 0.15},
|
||||
"첨부": {"topic_ids": [4], "weight": 0.15},
|
||||
"결론": {"topic_ids": [5], "weight": 0.10}
|
||||
},
|
||||
"topics": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**파이프라인 반영:**
|
||||
- 1단계: Kei가 page_structure + weight 출력 (콘텐츠마다 다름, 하드코딩 아님)
|
||||
- 2단계: weight → px 변환 (body 490px × 0.6 = 294px → 본심)
|
||||
- 2단계: 배분된 px에 맞는 블록 선택
|
||||
- 배치: 본심 비중이 결정하면 가로/세로/구조화 방식도 자연스럽게 따라옴
|
||||
- Phase L의 PURPOSE_WEIGHT 하드코딩 제거 → Kei 출력 weight 사용
|
||||
|
||||
---
|
||||
|
||||
### 방향 2: 역할-블록 매핑 체계 — P-3 해결 [중요]
|
||||
|
||||
**콘텐츠 역할 × 콘텐츠 성격 → 블록 결정:**
|
||||
|
||||
```
|
||||
본심 + 비교 → compare-2col-split, compare-3col-badge
|
||||
본심 + 구조 → keyword-circle-row, card-step-vertical
|
||||
본심 + 정의 → card-numbered (large), dark-bullet-list (large)
|
||||
배경 + 문제 → topic-left-right (compact), quote-question (compact)
|
||||
배경 + 사례 → callout-warning (compact), quote-big-mark (compact)
|
||||
첨부 + 정의 → card-numbered (sidebar), dark-bullet-list (sidebar)
|
||||
결론 → banner-gradient (footer)
|
||||
```
|
||||
|
||||
**반영 위치:** STEP_B_PROMPT — 현재 purpose별 허용/금지를 "역할 × 성격" 매트릭스로 확장.
|
||||
|
||||
---
|
||||
|
||||
### 방향 3: 블록 안전성 인프라 — P-5, P-6, P-7, P-8 해결 [중요]
|
||||
|
||||
| 항목 | 내용 | 해결 방법 |
|
||||
|------|------|----------|
|
||||
| P-5 Figma 블록 필터 | 비-Figma 9개 블록 식별 | 블록 선택 시 Figma 블록 우선 또는 비-Figma 경고 |
|
||||
| P-6 블록-zone 적합성 | full-width 15개 블록 식별 | zone별 허용 블록 맵 (코드 검증) |
|
||||
| P-7 글자 수용량 | 블록별 max chars | catalog.yaml에 zone별 max_chars 추가 |
|
||||
| P-8 내부 스크롤 | 블록 내부 overflow 감지 | Selenium 측정 시 블록 내부까지 scrollHeight 체크 |
|
||||
|
||||
---
|
||||
|
||||
### 방향 4: 원본 보존 강화 — P-9 해결 [보통]
|
||||
|
||||
**3단계 편집자에게 source_text 직접 전달:**
|
||||
- 현재: 원본 콘텐츠 전체를 주고 "여기서 가져와라"
|
||||
- 변경: 각 꼭지별로 Kei가 source_hint에 명시한 원본 텍스트를 **직접 추출하여** 편집자에게 전달
|
||||
- "이 텍스트에서 추출하라. 새로 쓰지 마라. 축약만 허용."
|
||||
|
||||
---
|
||||
|
||||
## 우선순위
|
||||
|
||||
```
|
||||
[긴급] P-1 + P-2 + P-4 → 방향 1: 비중 시스템
|
||||
← 이것이 없으면 나머지를 해도 의미 없음
|
||||
← Kei가 판단. 하드코딩 아님.
|
||||
← 비중이 결정되면 배치, 블록 크기, 가로/세로 흐름이 자동으로 따라옴
|
||||
|
||||
[중요] P-3 → 방향 2: 역할-블록 매핑
|
||||
← 비중 시스템 위에서 역할별 블록 정확 매칭
|
||||
|
||||
[중요] P-7 + P-8 → 방향 3-a: 스키마 + 검증
|
||||
← 글자 수용량 정의 + 내부 overflow 감지
|
||||
|
||||
[보통] P-5 + P-6 → 방향 3-b: 필터링
|
||||
← Figma 블록 우선 + zone 적합성 검증
|
||||
|
||||
[보통] P-9 → 방향 4: 편집자 원본 보존
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase I~L과의 관계
|
||||
|
||||
| 기존 Phase | Phase M에서 변경 |
|
||||
|-----------|----------------|
|
||||
| Phase K PURPOSE_WEIGHT 하드코딩 | **제거** → Kei 출력 weight 사용 |
|
||||
| Phase K purpose 가이드 | **유지** + 역할×성격 매트릭스로 확장 |
|
||||
| Phase L allocate_height_budget() | **유지** + 입력을 PURPOSE_WEIGHT 대신 Kei weight로 변경 |
|
||||
| Phase L measure_rendered_heights() | **유지** + 블록 내부 overflow 체크 추가 (P-8) |
|
||||
| Phase L calculate_trim_chars() | **유지** |
|
||||
| Phase J Opus 존중 규칙 | **유지** |
|
||||
| Phase J Kei 최종 검수 | **유지** + 비중 기반 검수 항목 추가 |
|
||||
| Phase I slot_desc | **유지** |
|
||||
| Phase I SSE 공통 유틸 | **유지** |
|
||||
|
||||
**회귀 없음.** 기존 인프라(측정, 계산, 검수) 위에 비중 시스템을 추가.
|
||||
**제거 대상:** PURPOSE_WEIGHT 하드코딩 상수만.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### M-Step 1: Kei 비중 시스템 (P-1 + P-2 + P-4) [긴급]
|
||||
|
||||
1. KEI_PROMPT 출력 스키마에 page_structure 추가
|
||||
2. Kei가 본심/배경/첨부/결론 + weight를 출력하도록 프롬프트 수정
|
||||
3. pipeline.py에서 Kei 출력의 weight를 읽어서 allocate_height_budget()에 전달
|
||||
4. PURPOSE_WEIGHT 하드코딩 제거
|
||||
5. STEP_B_PROMPT에 weight 기반 블록 크기 지시 추가
|
||||
|
||||
### M-Step 2: 역할-블록 매핑 (P-3)
|
||||
|
||||
6. STEP_B_PROMPT purpose 가이드를 역할×성격 매트릭스로 재구성
|
||||
7. Kei 출력의 relation_type + 역할(본심/배경/첨부)로 블록 결정
|
||||
|
||||
### M-Step 3: 블록 안전성 (P-5 + P-6 + P-7 + P-8)
|
||||
|
||||
8. P-5: catalog.yaml에 figma_source 필드 추가 (Figma 블록 식별)
|
||||
9. P-6: 블록-zone 적합성 맵 정의 + 코드 검증 추가
|
||||
10. P-7: catalog.yaml에 zone별 max_chars 추가
|
||||
11. P-8: slide_measurer.py에서 블록 내부 overflow까지 체크
|
||||
|
||||
### M-Step 4: 원본 보존 (P-9)
|
||||
|
||||
12. 편집자에게 꼭지별 source_text 직접 전달
|
||||
13. "추출만. 재작성 금지." 강화
|
||||
|
||||
---
|
||||
|
||||
## 기술 조사 결과
|
||||
|
||||
### M-Step 1에 필요한 것
|
||||
|
||||
| 항목 | 현재 | 변경 | 도구 |
|
||||
|------|------|------|------|
|
||||
| KEI_PROMPT 출력 | topics만 | + page_structure (본심/배경/첨부/결론 + weight) | 프롬프트 수정 |
|
||||
| page_structure 파싱 | 없음 | `analysis.get("page_structure")` | 코드 추가 |
|
||||
| PURPOSE_WEIGHT 상수 | 하드코딩 (space_allocator.py) | **제거** → Kei weight 사용 | 코드 수정 |
|
||||
| allocate_height_budget() | PURPOSE_WEIGHT 참조 | weight_override 파라미터 추가 | 함수 시그니처 변경 |
|
||||
| STEP_B_PROMPT | purpose별 규칙만 | + weight 기반 블록 크기 지시 | 프롬프트 수정 |
|
||||
|
||||
**충돌:** 없음. page_structure는 새 필드. PURPOSE_WEIGHT 제거는 개선.
|
||||
**Kei vs Sonnet:** Kei가 weight 판단. Sonnet은 weight를 **받아서** 블록 크기 결정.
|
||||
|
||||
---
|
||||
|
||||
### M-Step 2에 필요한 것
|
||||
|
||||
| 항목 | 현재 | 변경 | 도구 |
|
||||
|------|------|------|------|
|
||||
| FAISS 쿼리 | title+summary+role+layer | + purpose + relation_type + expression_hint | block_search.py `_build_query()` 수정 |
|
||||
| STEP_B_PROMPT 가이드 | purpose 6종 허용/금지 | 역할(본심/배경/첨부) × 성격(비교/정의/구조) 매트릭스 | 프롬프트 확장 |
|
||||
|
||||
**충돌:** Phase K purpose 가이드 **위에** 매트릭스 확장. 기존 규칙 유지.
|
||||
|
||||
---
|
||||
|
||||
### M-Step 3에 필요한 것
|
||||
|
||||
| 항목 | 현재 | 변경 | 도구 |
|
||||
|------|------|------|------|
|
||||
| P-5 Figma 식별 | 구분 없음 | catalog.yaml에 `figma_source` 필드 | YAML 수정 |
|
||||
| P-6 zone 적합성 | sidebar 1열만 (J-6) | **블록-zone 적합성 맵** 코드 검증 | 신규 상수 + 검증 로직 |
|
||||
| P-7 글자 수용량 | slot_desc 의미만 | catalog.yaml에 **zone별 max_chars** | YAML + 편집자 연동 |
|
||||
| P-8 내부 overflow | zone 레벨만 측정 | **블록 내부** scrollHeight 체크 | slide_measurer.py JS 확인 |
|
||||
|
||||
**P-6 블록-zone 적합성 맵:**
|
||||
```python
|
||||
# 신규 상수 (design_director.py 또는 별도 모듈)
|
||||
SIDEBAR_SAFE_BLOCKS = {
|
||||
"card-numbered", "card-step-vertical",
|
||||
"banner-gradient", "callout-solution", "callout-warning",
|
||||
"dark-bullet-list", "divider-text", "highlight-strip",
|
||||
"quote-question", "tab-label-row",
|
||||
"topic-left-right", "topic-numbered",
|
||||
"table-simple-striped", "process-horizontal",
|
||||
"image-before-after", "image-grid-2x2", "image-row-2col",
|
||||
}
|
||||
|
||||
FULL_WIDTH_ONLY_BLOCKS = {
|
||||
"card-compare-3col", "card-dark-overlay", "card-icon-desc",
|
||||
"card-image-3col", "card-image-round", "card-stat-number", "card-tag-image",
|
||||
"section-title-with-bg", "section-header-bar", "topic-center",
|
||||
"quote-big-mark", "image-full-caption",
|
||||
"compare-2col-split", "compare-pill-pair", "comparison-2col",
|
||||
}
|
||||
```
|
||||
|
||||
**충돌:** Phase J의 sidebar 1열 강제와 **보완 관계.** J-6은 열 수 제한, M-Step 3은 블록 자체 제한.
|
||||
|
||||
---
|
||||
|
||||
### M-Step 4에 필요한 것
|
||||
|
||||
| 항목 | 현재 | 변경 | 도구 |
|
||||
|------|------|------|------|
|
||||
| 원본 전달 | 전체 content 한 번에 | **토픽별 source_text 추출하여 전달** | fill_content() 수정 |
|
||||
| source_hint | 정의됨, 사용 안 됨 | **편집자에게 전달** | 프롬프트 수정 |
|
||||
| source_data | 텍스트 설명만 | **실제 원본 텍스트 추출 참조** | 코드 추가 |
|
||||
| 재작성 방지 | "보존" 원칙만 | **"추출만. 재작성 금지."** 절대 규칙 | 프롬프트 강화 |
|
||||
|
||||
**충돌:** Phase J source 규칙 **유지 + 보강.**
|
||||
|
||||
---
|
||||
|
||||
## 실행 방안 상세
|
||||
|
||||
### M-Step 1: Kei 비중 시스템
|
||||
|
||||
#### M-1a: KEI_PROMPT 출력 스키마 변경
|
||||
|
||||
**위치:** `src/kei_client.py` KEI_PROMPT (20~79행)
|
||||
|
||||
**추가할 출력 필드:**
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"core_message": "...",
|
||||
"page_structure": {
|
||||
"본심": {"topic_ids": [2, 3], "weight": 0.60},
|
||||
"배경": {"topic_ids": [1], "weight": 0.15},
|
||||
"첨부": {"topic_ids": [4], "weight": 0.15},
|
||||
"결론": {"topic_ids": [5], "weight": 0.10}
|
||||
},
|
||||
"topics": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**프롬프트에 추가할 지시:**
|
||||
```
|
||||
## 4단계: 페이지 구조 판단
|
||||
콘텐츠를 분석하여 이 페이지의 구조를 판단하라:
|
||||
- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.
|
||||
- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게.
|
||||
- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.
|
||||
- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.
|
||||
|
||||
각 역할에 해당하는 topic_ids와 공간 비중(weight, 합계 1.0)을 결정하라.
|
||||
콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.
|
||||
```
|
||||
|
||||
**충돌:** 없음. 기존 출력 필드에 page_structure 추가만. `.get()` 방식이라 무시 가능.
|
||||
|
||||
#### M-1b: pipeline.py에서 Kei weight 읽기
|
||||
|
||||
**위치:** `src/pipeline.py` Phase L 공간 할당 부분 (현재 132~165행)
|
||||
|
||||
**변경:** PURPOSE_WEIGHT 대신 Kei 출력 weight 사용
|
||||
```python
|
||||
# 현재 (Phase L 하드코딩):
|
||||
allocation = allocate_height_budget(zone_blocks, zone_info.get("budget_px", 490))
|
||||
|
||||
# 변경 (Phase M Kei 판단):
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
weight_map = {}
|
||||
for role_name, role_info in page_struct.items():
|
||||
for tid in role_info.get("topic_ids", []):
|
||||
weight_map[tid] = role_info.get("weight", 0.25)
|
||||
allocation = allocate_height_budget(
|
||||
zone_blocks, zone_info.get("budget_px", 490),
|
||||
weight_override=weight_map
|
||||
)
|
||||
```
|
||||
|
||||
#### M-1c: allocate_height_budget() 시그니처 변경
|
||||
|
||||
**위치:** `src/space_allocator.py` (42~75행)
|
||||
|
||||
**변경:** `weight_override` 파라미터 추가
|
||||
```python
|
||||
def allocate_height_budget(
|
||||
blocks, zone_budget_px, gap_px=20,
|
||||
weight_override=None, # {topic_id: weight} — Kei 판단 기반
|
||||
):
|
||||
# weight_override 있으면 사용, 없으면 PURPOSE_WEIGHT fallback
|
||||
for block in blocks:
|
||||
tid = block.get("topic_id")
|
||||
if weight_override and tid in weight_override:
|
||||
weight = weight_override[tid]
|
||||
else:
|
||||
purpose = block.get("purpose", "")
|
||||
weight = PURPOSE_WEIGHT.get(purpose, 0.25)
|
||||
weights.append(weight)
|
||||
```
|
||||
|
||||
**PURPOSE_WEIGHT:** fallback으로 유지 (Kei가 page_structure 안 줬을 때). 하드코딩 → fallback 강등.
|
||||
|
||||
#### M-1d: STEP_B_PROMPT에 weight 전달
|
||||
|
||||
**위치:** `src/design_director.py` STEP_B_PROMPT user_prompt 구성부
|
||||
|
||||
**추가:** Kei가 판단한 비중을 팀장에게 전달
|
||||
```
|
||||
## 페이지 구조 (Kei 실장 판단)
|
||||
- 본심 (꼭지 2, 3): 공간 비중 60% — body에서 가장 크게
|
||||
- 배경 (꼭지 1): 공간 비중 15% — compact 도입부
|
||||
- 첨부 (꼭지 4): 공간 비중 15% — sidebar 참조
|
||||
- 결론 (꼭지 5): 공간 비중 10% — footer 한 줄
|
||||
|
||||
본심에 가장 큰 블록을, 배경에 가장 작은 블록을 배정하라.
|
||||
비중을 무시하고 동등하게 배치하지 마라.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M-Step 2: 역할-블록 매핑
|
||||
|
||||
#### M-2a: FAISS 쿼리 강화
|
||||
|
||||
**위치:** `src/block_search.py` `_build_query()` (178~188행)
|
||||
|
||||
**변경:** purpose + relation_type + expression_hint 추가
|
||||
```python
|
||||
def _build_query(topic):
|
||||
parts = [
|
||||
topic.get("title", ""),
|
||||
topic.get("summary", ""),
|
||||
f"역할: {topic.get('role', 'flow')}",
|
||||
f"레이어: {topic.get('layer', 'core')}",
|
||||
f"목적: {topic.get('purpose', '')}", # 추가
|
||||
f"관계: {topic.get('relation_type', '')}", # 추가
|
||||
f"표현: {topic.get('expression_hint', '')}", # 추가
|
||||
]
|
||||
if topic.get("content_type"):
|
||||
parts.append(f"콘텐츠: {topic['content_type']}")
|
||||
return ". ".join(p for p in parts if p)
|
||||
```
|
||||
|
||||
#### M-2b: STEP_B_PROMPT 역할×성격 매트릭스
|
||||
|
||||
**위치:** `src/design_director.py` purpose 가이드 섹션
|
||||
|
||||
**기존 Phase K 규칙 유지 + 아래 매트릭스 추가:**
|
||||
```
|
||||
## 역할 × 콘텐츠 성격 블록 매트릭스
|
||||
|
||||
| 역할 | 비교(comparison) | 구조(hierarchy/inclusion) | 정의(definition) | 흐름(sequence) |
|
||||
|------|-----------------|------------------------|-----------------|---------------|
|
||||
| 본심 | compare-2col-split, compare-3col-badge | keyword-circle-row, venn-diagram | card-numbered(large) | process-horizontal, flow-arrow-horizontal |
|
||||
| 배경 | topic-left-right(compact) | topic-left-right(compact) | quote-question(compact) | topic-left-right(compact) |
|
||||
| 첨부 | card-numbered(sidebar) | card-numbered(sidebar) | card-numbered(sidebar), dark-bullet-list(sidebar) | card-numbered(sidebar) |
|
||||
| 결론 | banner-gradient | banner-gradient | banner-gradient | banner-gradient |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M-Step 3: 블록 안전성
|
||||
|
||||
#### M-3a: catalog.yaml figma_source 필드 (P-5)
|
||||
|
||||
**추가할 필드:** 각 블록에 `figma_source: true/false`
|
||||
|
||||
#### M-3b: zone 적합성 검증 (P-6)
|
||||
|
||||
**위치:** `src/design_director.py` `_validate_height_budget()` 내
|
||||
|
||||
**추가:** sidebar에 FULL_WIDTH_ONLY_BLOCKS 배치 시 교체/경고
|
||||
|
||||
#### M-3c: 글자 수용량 (P-7)
|
||||
|
||||
**위치:** `templates/catalog.yaml`
|
||||
|
||||
**추가:** 각 블록에 zone별 max_chars
|
||||
```yaml
|
||||
- id: compare-2col-split
|
||||
max_chars:
|
||||
body: {left: 200, right: 200, criteria: 30} # 65% 너비 기준
|
||||
sidebar: null # sidebar 사용 불가
|
||||
```
|
||||
|
||||
#### M-3d: 내부 overflow 감지 (P-8)
|
||||
|
||||
**위치:** `src/slide_measurer.py` _MEASURE_SCRIPT
|
||||
|
||||
**확인:** 현재 JS가 블록 내부 `scrollHeight > clientHeight + 2` 이미 체크 중.
|
||||
`overflow: auto` 블록(compare-3col-badge)의 수평 스크롤도 `scrollWidth > clientWidth` 체크 추가.
|
||||
|
||||
---
|
||||
|
||||
### M-Step 4: 원본 보존
|
||||
|
||||
#### M-4a: 토픽별 source_text 추출
|
||||
|
||||
**위치:** `src/pipeline.py` Stage 3 호출 전
|
||||
|
||||
**추가:** Kei가 출력한 source_hint + source_data를 기반으로 원본에서 텍스트 추출
|
||||
```python
|
||||
# 토픽별 원본 텍스트 매핑 구성
|
||||
topic_sources = {}
|
||||
for topic in analysis.get("topics", []):
|
||||
source_hint = topic.get("source_hint", "")
|
||||
source_data = topic.get("source_data", "")
|
||||
topic_sources[topic["id"]] = {
|
||||
"hint": source_hint,
|
||||
"data": source_data,
|
||||
}
|
||||
```
|
||||
|
||||
#### M-4b: fill_content() 프롬프트에 토픽별 source 전달
|
||||
|
||||
**위치:** `src/content_editor.py` fill_content() user_prompt 구성부
|
||||
|
||||
**추가:**
|
||||
```
|
||||
## 토픽별 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)
|
||||
- 토픽 1: [source_hint 내용]
|
||||
- 토픽 2: [source_hint 내용]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀/하드코딩 최종 검증
|
||||
|
||||
| Step | 충돌 | 회귀 | 하드코딩 | Kei/Sonnet |
|
||||
|------|:---:|:---:|:------:|:----------:|
|
||||
| M-1a KEI_PROMPT | 없음 | 없음 | **Kei 판단** | Kei |
|
||||
| M-1b pipeline weight | 없음 | Phase L 개선 | **Kei weight** | — |
|
||||
| M-1c allocate 시그니처 | 없음 | 없음 | fallback만 | — |
|
||||
| M-1d STEP_B weight | 없음 | 없음 | **Kei → 팀장** | Sonnet(기존) |
|
||||
| M-2a FAISS 쿼리 | 없음 | 없음 | 없음 | — |
|
||||
| M-2b 매트릭스 | Phase K 위에 확장 | 없음 | 없음 | Sonnet(기존) |
|
||||
| M-3a Figma | 없음 (신규) | 없음 | 없음 | — |
|
||||
| M-3b zone맵 | Phase J 보강 | 없음 | 상수(범용) | — |
|
||||
| M-3c max_chars | Phase I 보강 | 없음 | 없음 | — |
|
||||
| M-3d 내부overflow | Phase L 확장 | 없음 | 없음 | — |
|
||||
| M-4a source 추출 | 없음 (신규) | 없음 | 없음 | — |
|
||||
| M-4b 편집자 강화 | Phase J 보강 | 없음 | 없음 | Kei(편집자) |
|
||||
|
||||
---
|
||||
|
||||
## 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-26 | Phase I~L 전체 실행 후 결과물 분석. 외부 진단(P-1~P-9) 수용. 비중 시스템(Kei 판단, 하드코딩 아님) 기반 전면 재설계. Phase M 계획 수립. |
|
||||
| 2026-03-26 | 기술 조사 + 충돌/회귀 정밀 검토 완료. M-Step 1~4 실행 방안 상세 확정. |
|
||||
565
docs/history/IMPROVEMENT-PHASE-N.md
Normal file
565
docs/history/IMPROVEMENT-PHASE-N.md
Normal file
@@ -0,0 +1,565 @@
|
||||
# Phase N: 4대 핵심 문제 진단 + 해결 방안
|
||||
|
||||
> 작성일: 2026-03-27
|
||||
> 상태: ✅ 완료 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 체계 구축
|
||||
|
||||
---
|
||||
|
||||
## 오답 노트 (절대 반복 금지)
|
||||
|
||||
아래는 이미 실패가 증명된 접근법이다. **어떤 상황에서도 다시 사용하지 않는다.**
|
||||
|
||||
| # | 실패 패턴 | 왜 실패했나 | 교훈 |
|
||||
|---|----------|-----------|------|
|
||||
| X-1 | Sonnet에게 블록 선택을 맡김 | Kei 추천을 무시하고 자기 맘대로 바꿈. 프롬프트로 제어 불가 | 블록 선택은 Kei 권한. 코드 레벨 강제. |
|
||||
| X-2 | Sonnet fallback (Kei 실패 시 Sonnet 대체) | Sonnet이 대체해봤자 품질이 안 나옴. 결과물이 무의미 | Kei API는 필수 인프라. 실패 시 파이프라인 중단. fallback 자체가 없음. |
|
||||
| X-3 | max-height + overflow:hidden으로 CSS 사후 자르기 | 텍스트가 잘리는데 측정기가 "정상"이라고 판단. 근본적 결함 | 콘텐츠는 렌더링 전에 맞춰야 함. CSS로 사후에 자르지 않음. |
|
||||
| X-4 | HTML 텍스트를 읽고 시각 검수 | Kei가 HTML 소스를 읽어봤자 렌더링 결과를 알 수 없음. 10분 낭비 | 시각 검수는 스크린샷(이미지)으로. |
|
||||
| X-5 | "안전망/fallback"이라는 명목으로 실패 패턴 재도입 | 실패한 방법을 "비상용"이라고 다시 넣으면 결국 그게 돌아감 | 실패한 것은 비상용으로도 안 됨. 오답 노트에 기록하고 근절. |
|
||||
| X-6 | 프롬프트만으로 LLM 행동 강제 | "반드시 존중하라"고 써도 LLM은 안 지킴 | 강제는 코드로. 프롬프트는 가이드일 뿐. |
|
||||
|
||||
---
|
||||
|
||||
## 문제 전체 요약
|
||||
|
||||
| # | 문제 | 원인 위치 | 심각도 |
|
||||
|---|------|----------|--------|
|
||||
| N-1 | 블록 선택이 콘텐츠 전달 방식과 안 맞음 | `design_director.py` Step B | **치명** |
|
||||
| N-2 | 사이드바에 섹션 제목이 없음 | `kei_client.py` + `renderer.py` | 중간 |
|
||||
| N-3 | max-height CSS가 콘텐츠를 잘라먹음 | `renderer.py` 229-235행 | **치명** |
|
||||
| N-4 | Stage 5가 HTML 텍스트를 읽어서 무용지물 | `kei_client.py` + `pipeline.py` | **치명** |
|
||||
|
||||
---
|
||||
|
||||
## N-1. 블록 선택이 콘텐츠 전달 방식과 안 맞음
|
||||
|
||||
### 현상
|
||||
- Kei 실장(Opus)이 1단계에서 `expression_hint`, `relation_type`을 판단함
|
||||
- 2단계 Step A-2에서 Kei가 블록을 추천함 (`_opus_block_recommendation()`)
|
||||
- **그런데 Step B에서 Sonnet이 Kei 추천을 무시하고 자기 맘대로 블록을 바꿈**
|
||||
- 프롬프트에 "Opus 추천 존중" 규칙을 넣어도 Sonnet이 안 지킴
|
||||
|
||||
### 원인 (코드 레벨)
|
||||
|
||||
**`design_director.py` — Step B 흐름:**
|
||||
```
|
||||
Step A: rule-based preset 선택 (sidebar-right 등)
|
||||
Step A-2: Kei API로 블록 추천 받음 → opus_blocks[]
|
||||
Step B: Sonnet이 zone 배치 + char_guide 결정
|
||||
↑ 여기서 Sonnet이 블록 타입을 바꿔버림
|
||||
```
|
||||
|
||||
`STEP_B_PROMPT`에 "Opus가 추천한 블록을 존중하라"고 적어놨지만, **프롬프트는 강제가 아니다.**
|
||||
Sonnet은 "더 적절하다"고 판단하면 얼마든지 다른 블록을 선택한다.
|
||||
|
||||
### 해결 방안: Kei가 블록을 결정, Sonnet은 zone + char_guide만
|
||||
|
||||
**핵심 원칙:** 블록 선택 = Kei 권한. 코드 레벨 강제. 프롬프트 의존 안 함.
|
||||
|
||||
**변경 대상:** `design_director.py`
|
||||
|
||||
```
|
||||
현재 흐름:
|
||||
Step A: preset 선택
|
||||
Step A-2: Kei 블록 추천 (참고용)
|
||||
Step B: Sonnet이 블록 + zone + char_guide 전부 결정
|
||||
|
||||
변경 후:
|
||||
Step A: preset 선택
|
||||
Step A-2: Kei가 블록 확정 (topic_id → block_type 매핑)
|
||||
Step B: Sonnet은 zone 배치 + char_guide만 결정 (block_type 변경 금지)
|
||||
```
|
||||
|
||||
**구체적 변경:**
|
||||
|
||||
1. **Step A-2 (`_opus_block_recommendation`)**: Kei API 응답에서 받은 블록을 "추천"이 아닌 "확정"으로 처리
|
||||
- 반환값: `{topic_id: block_type}` 딕셔너리
|
||||
- 이 딕셔너리를 Step B에 **읽기 전용**으로 전달
|
||||
|
||||
2. **Step B 프롬프트 변경**: `STEP_B_PROMPT`에서 블록 선택 지시 제거
|
||||
- "각 꼭지에 맞는 블록을 선택하라" → 삭제
|
||||
- "아래 확정된 블록의 zone 배치와 글자 수 가이드만 결정하라"로 변경
|
||||
|
||||
3. **Step B 후처리 (코드 강제)**:
|
||||
```python
|
||||
# Sonnet 응답 후, 블록 타입을 Kei 확정값으로 덮어쓰기
|
||||
for block in sonnet_blocks:
|
||||
tid = block.get("topic_id")
|
||||
if tid in kei_confirmed_blocks:
|
||||
block["type"] = kei_confirmed_blocks[tid] # 코드 레벨 강제
|
||||
```
|
||||
- Sonnet이 어떤 블록을 응답하든, topic_id에 매칭되는 Kei 확정 블록으로 강제 교체
|
||||
- Sonnet의 zone, char_guide, reason만 살림
|
||||
|
||||
4. **Kei API는 필수 의존성:** 실패 시 fallback 없음. 파이프라인 중단 + 에러 반환.
|
||||
- Kei API(localhost:8000)는 항상 떠 있어야 하는 로컬 인프라
|
||||
- 안 되면 그건 버그. 대체 경로가 아니라 수정 대상.
|
||||
|
||||
**사용 기술:**
|
||||
- 기존 Kei API (`_opus_block_recommendation`) — 이미 존재
|
||||
- Python dict 매핑으로 코드 레벨 강제 — 새 도구 불필요
|
||||
- `STEP_B_PROMPT` 프롬프트 축소 — zone + char_guide만
|
||||
|
||||
---
|
||||
|
||||
## N-2. 사이드바에 섹션 제목이 없음
|
||||
|
||||
### 현상
|
||||
- 사이드바에 "용어 정의" 같은 콘텐츠가 배치되는데
|
||||
- 그게 뭔지 알려주는 섹션 제목이 없음
|
||||
- 독자가 사이드바가 무엇인지 맥락을 모름
|
||||
|
||||
### 원인 (코드 레벨)
|
||||
|
||||
1. **Kei 1단계 (`KEI_PROMPT`)**: `role: "reference"` + `purpose: "용어정의"`는 출력하지만, **section_title** 필드가 없음
|
||||
2. **design_director.py Step B**: sidebar zone에 블록을 배치할 때 섹션 제목 블록을 안 넣음
|
||||
3. **renderer.py**: area div를 렌더할 때 영역 라벨 없이 바로 블록 HTML만 출력
|
||||
|
||||
### 해결 방안: Kei가 section_title 판단 + 렌더러가 표시
|
||||
|
||||
**변경 대상:** `kei_client.py`, `design_director.py`, `renderer.py`
|
||||
|
||||
1. **Stage 1 Kei 프롬프트 (`KEI_PROMPT`) 확장:**
|
||||
- 기존 topic 필드에 `section_title` 추가
|
||||
- `role: "reference"`인 꼭지에 Kei가 "용어 정의", "참고 자료" 등 섹션 제목을 부여
|
||||
- 출력 JSON 예시:
|
||||
```json
|
||||
{"id": 4, "title": "용어 혼용 정리", "purpose": "용어정의",
|
||||
"role": "reference", "section_title": "용어 정의"}
|
||||
```
|
||||
|
||||
2. **Step B 블록 배치에 section label 블록 자동 삽입:**
|
||||
- sidebar zone에 reference 블록이 배치될 때
|
||||
- 해당 topic의 `section_title`이 있으면 → `topic-center` 또는 `divider-text` 블록을 자동 삽입
|
||||
- 이것은 **코드 레벨** (Sonnet 판단 아님)
|
||||
|
||||
3. **renderer.py `_group_blocks_by_area()`에서 sidebar 처리:**
|
||||
- sidebar area 그룹에 section label이 있으면 최상단에 배치
|
||||
- CSS: 작은 글씨 + 볼드 + 하단 구분선
|
||||
|
||||
**사용 기술:**
|
||||
- KEI_PROMPT JSON 스키마 확장 (section_title 필드 1개)
|
||||
- 기존 블록 (`divider-text` 또는 `topic-center`) 재활용
|
||||
- renderer.py 코드 로직으로 자동 삽입
|
||||
|
||||
---
|
||||
|
||||
## N-3. max-height CSS가 콘텐츠를 잘라먹음
|
||||
|
||||
### 현상
|
||||
- 렌더된 HTML에서 텍스트가 중간에 뚝 잘려 보임
|
||||
- Selenium으로 측정하면 "overflow 없음"이라고 나옴 → 실제로는 잘리고 있는데 감지 못함
|
||||
- 결과: Phase L 피드백 루프가 "정상"으로 판단하고 넘어감 → 잘린 채로 최종 출력
|
||||
|
||||
### 원인 (코드 레벨)
|
||||
|
||||
**`renderer.py` 229-235행:**
|
||||
```python
|
||||
# Phase L: 블록별 max-height 제약
|
||||
max_height = block.get("_max_height_px")
|
||||
if max_height:
|
||||
rendered_html = (
|
||||
f'<div style="max-height:{max_height}px; overflow:hidden;">'
|
||||
f'{rendered_html}</div>'
|
||||
)
|
||||
```
|
||||
|
||||
이게 하는 일:
|
||||
1. 블록에 `max-height: Npx; overflow: hidden` CSS를 씌움
|
||||
2. → 콘텐츠가 N px을 넘으면 **시각적으로 잘림**
|
||||
3. → `overflow: hidden`이므로 `scrollHeight === clientHeight` → **측정기가 "overflow 없음"으로 판단**
|
||||
4. → 피드백 루프가 작동 안 함 → 잘린 채 확정
|
||||
|
||||
**근본 원인:** 텍스트가 공간에 맞는지를 CSS로 사후에 자르는 게 아니라, **편집 단계에서 글자 수를 맞춰야 한다.**
|
||||
|
||||
### 해결 방안: max-height 제거 + 편집자에게 _max_chars 강제 전달
|
||||
|
||||
**핵심 원칙:**
|
||||
- 콘텐츠가 렌더링 전에 공간에 맞아야 한다 (fit before render)
|
||||
- CSS로 사후에 자르지 않는다
|
||||
- overflow는 측정으로 감지하고, 감지되면 편집자를 다시 호출한다
|
||||
|
||||
**변경 대상:** `renderer.py`, `content_editor.py`, `slide_measurer.py`
|
||||
|
||||
### 변경 1: renderer.py에서 max-height 래퍼 제거
|
||||
|
||||
```python
|
||||
# 229-235행 삭제. 아래 코드 완전 제거:
|
||||
max_height = block.get("_max_height_px")
|
||||
if max_height:
|
||||
rendered_html = (
|
||||
f'<div style="max-height:{max_height}px; overflow:hidden;">'
|
||||
f'{rendered_html}</div>'
|
||||
)
|
||||
```
|
||||
|
||||
max-height 없이 렌더링 → overflow가 생기면 `scrollHeight > clientHeight`로 정확히 감지됨.
|
||||
|
||||
### 변경 2: content_editor.py 프롬프트에 _max_chars 강제 명시
|
||||
|
||||
현재 `EDITOR_PROMPT`의 purpose별 분량 원칙이 "가이드라인" 수준.
|
||||
`_max_chars`가 계산되어 있지만 편집자에게 전달이 안 되고 있음.
|
||||
|
||||
```python
|
||||
# fill_content()에서 각 블록의 _max_chars를 프롬프트에 명시
|
||||
req_text += f"\n **최대 글자 수 (절대 제한): {block.get('_max_chars', '없음')}자**"
|
||||
req_text += f"\n 이 글자 수를 넘기면 슬라이드에서 잘린다. 반드시 지켜라."
|
||||
```
|
||||
|
||||
### 변경 3: slide_measurer.py의 overflow 감지 정상화
|
||||
|
||||
max-height + overflow:hidden이 없어지면, 기존 측정 스크립트가 정상 작동:
|
||||
```javascript
|
||||
// scrollHeight > clientHeight → 정확한 overflow 감지
|
||||
overflowed: zone.scrollHeight > zone.clientHeight + 2
|
||||
```
|
||||
|
||||
현재 `_MEASURE_SCRIPT`는 이미 이 로직을 갖고 있음. max-height만 제거하면 됨.
|
||||
|
||||
**추가: overflow:visible 확인**
|
||||
- CSS에서 zone/block 컨테이너에 `overflow: hidden`이 없는지 확인
|
||||
- `base.css`에 혹시 hidden이 있으면 제거
|
||||
- 기본값 `overflow: visible`이면 scrollHeight 측정이 정확
|
||||
|
||||
### 변경 4: Phase L 피드백 루프 강화
|
||||
|
||||
현재 `pipeline.py` 215-275행의 피드백 루프:
|
||||
1. 측정 → overflow 감지 → char_guide 축소 → 편집자 재호출 → 재렌더링
|
||||
2. 최대 3회 반복
|
||||
|
||||
**수정사항:**
|
||||
- char_guide 축소 대신 `_max_chars` 직접 축소 (더 정확)
|
||||
- 축소량: `calculate_trim_chars(excess_px)` 결과를 `_max_chars`에서 차감
|
||||
- 편집자 재호출 시 축소된 `_max_chars`를 프롬프트에 명시
|
||||
|
||||
**사용 기술:**
|
||||
- 기존 Selenium + `scrollHeight > clientHeight` — 이미 존재, max-height만 제거하면 작동
|
||||
- 기존 `calculate_max_chars()`, `calculate_trim_chars()` — 이미 존재
|
||||
- `content_editor.py` 프롬프트 확장 — `_max_chars` 전달만 추가
|
||||
- **새 도구 불필요**
|
||||
|
||||
---
|
||||
|
||||
## N-4. Stage 5가 HTML 텍스트를 읽어서 무용지물
|
||||
|
||||
### 현상
|
||||
- Kei 실장이 최종 검수 (Stage 5)에서 10분 걸리는데 아무것도 안 바뀜
|
||||
- 이유: Kei가 **HTML 소스 텍스트**를 읽고 검수함
|
||||
- HTML 태그 사이에서 실제 렌더링 결과를 상상해야 함 → 불가능
|
||||
- "텍스트가 잘리는지", "비중이 맞는지", "가독성이 괜찮은지" → HTML 텍스트로는 판단 불가
|
||||
|
||||
### 원인 (코드 레벨)
|
||||
|
||||
**`kei_client.py` `call_kei_final_review()` 306-313행:**
|
||||
```python
|
||||
prompt = (
|
||||
KEI_REVIEW_PROMPT + "\n\n"
|
||||
f"## 핵심 메시지\n{core_message}\n\n"
|
||||
...
|
||||
f"\n\n## 조립 HTML (요약)\n{html[:3000]}\n\n" # ← HTML 소스 텍스트 3000자
|
||||
f"위 결과물을 검수하고 조정이 필요한지 판단해. JSON만."
|
||||
)
|
||||
```
|
||||
|
||||
Kei(Opus)는 멀티모달 모델이라 이미지를 볼 수 있는데, **현재는 텍스트만 전달.**
|
||||
|
||||
### 해결 방안: Selenium 스크린샷 → Kei API에 이미지 전달
|
||||
|
||||
**핵심 원칙:**
|
||||
- Stage 5에서 Kei가 **실제 렌더링된 슬라이드 스크린샷**을 보고 검수
|
||||
- HTML 텍스트 읽기 → 이미지 보기로 전환
|
||||
- overflow 없으면 Stage 5 건너뜀 (시간 절약)
|
||||
- 최대 1회만 (현재 2회 → 1회)
|
||||
|
||||
### 기술 조사 결과
|
||||
|
||||
#### Selenium 스크린샷 → base64
|
||||
|
||||
```python
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--window-size=1280,720")
|
||||
options.add_argument("--force-device-scale-factor=1")
|
||||
|
||||
driver = webdriver.Chrome(options=options)
|
||||
driver.get(f"data:text/html;charset=utf-8,{encoded_html}")
|
||||
|
||||
# 슬라이드 요소만 정확히 캡처
|
||||
slide = driver.find_element(By.CSS_SELECTOR, ".slide")
|
||||
screenshot_b64 = slide.screenshot_as_base64 # str, 순수 base64
|
||||
driver.quit()
|
||||
```
|
||||
|
||||
**API 출처:** Selenium 4.x `WebElement.screenshot_as_base64` 프로퍼티
|
||||
- 반환: `str` (순수 base64, data URI prefix 없음)
|
||||
- 형식: PNG
|
||||
- 해당 요소의 bounding box만 캡처 (전체 페이지가 아님)
|
||||
|
||||
#### Anthropic Claude API 이미지 전달 형식
|
||||
|
||||
```python
|
||||
import anthropic
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514", # Opus = 멀티모달 지원
|
||||
max_tokens=4096,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": screenshot_b64, # 순수 base64 문자열
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "이 슬라이드를 검수해줘. ...",
|
||||
},
|
||||
],
|
||||
}],
|
||||
)
|
||||
```
|
||||
|
||||
**API 출처:** Anthropic 공식 Vision 문서
|
||||
- 지원 모델: Claude Opus 4, Sonnet 4, Haiku 3.5 전부 멀티모달 지원
|
||||
- 지원 포맷: PNG, JPEG, GIF, WebP
|
||||
- 이미지 크기 제한: 최대 8000x8000px, 5MB/장
|
||||
- 1280x720 슬라이드: ~1,229 토큰 (비용 미미)
|
||||
|
||||
#### 문제: 현재 Kei API(`/api/message`)는 이미지 미지원
|
||||
|
||||
**Kei persona_agent 조사 결과:**
|
||||
- `ChatRequest` 모델: `message: str` (텍스트만)
|
||||
- 이미지 필드 없음
|
||||
- LLM 호출 시 messages를 `{"role": "user", "content": str}`로 전달
|
||||
|
||||
**필요한 변경 (Kei persona_agent 측):**
|
||||
|
||||
```python
|
||||
# ChatRequest 확장 (persona_agent/backend/main.py)
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
message: str
|
||||
image_data: str | None = None # base64 이미지 (선택)
|
||||
image_media_type: str | None = None # "image/png" 등 (선택)
|
||||
```
|
||||
|
||||
- 4개 파일, ~50줄 변경
|
||||
- 기존 텍스트 요청은 깨지지 않음 (image 필드는 optional)
|
||||
- Anthropic SDK는 이미 이미지 content block 지원 → 그대로 전달만 하면 됨
|
||||
|
||||
### 전체 Stage 5 변경 흐름
|
||||
|
||||
```
|
||||
현재:
|
||||
Phase L 측정 → Stage 5: Kei가 HTML 텍스트 3000자 읽기 → 조정
|
||||
|
||||
변경 후:
|
||||
Phase L 측정 → overflow 없으면 Stage 5 건너뜀 (시간 절약)
|
||||
→ overflow 있으면:
|
||||
1. Selenium으로 슬라이드 스크린샷 (base64 PNG)
|
||||
2. 스크린샷 + 측정 데이터 → Kei API (이미지 포함)
|
||||
3. Kei가 실제 렌더링 보고 판단 → 조정 지시
|
||||
4. 최대 1회 (현재 2회에서 축소)
|
||||
```
|
||||
|
||||
**변경 대상:**
|
||||
- `kei_client.py`: `call_kei_final_review()`에 이미지 전달 추가
|
||||
- `pipeline.py`: Stage 5에 스크린샷 촬영 + overflow 없으면 skip 로직
|
||||
- `slide_measurer.py`: 스크린샷 캡처 함수 추가 (`capture_slide_screenshot()`)
|
||||
- Kei persona_agent: ChatRequest에 image 필드 추가 (4파일 ~50줄)
|
||||
|
||||
**주의:** Kei persona_agent 코드를 수정해야 함 → 사용자 승인 필요
|
||||
|
||||
### 대안: Kei API 변경 없이 Anthropic 직접 호출
|
||||
|
||||
Kei API 수정이 부담스러우면, Stage 5만 Anthropic API 직접 호출 가능:
|
||||
- `anthropic.AsyncAnthropic`으로 Opus 직접 호출
|
||||
- Kei 페르소나 시스템 프롬프트를 `personas/kei.md`에서 로드하여 system으로 전달
|
||||
- **단점:** Kei의 RAG/세션 컨텍스트를 못 씀
|
||||
- **장점:** persona_agent 수정 없음
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서 (의존 관계)
|
||||
|
||||
```
|
||||
N-3 (max-height 제거) ← 가장 먼저. 다른 것과 독립.
|
||||
│
|
||||
├→ N-1 (블록 선택 강제) ← N-3과 독립. 병렬 가능.
|
||||
│
|
||||
├→ N-2 (사이드바 제목) ← N-1 완료 후 (블록 확정 후 제목 삽입)
|
||||
│
|
||||
└→ N-4 (스크린샷 검수) ← N-3 완료 필수 (overflow 감지 정상화 후)
|
||||
```
|
||||
|
||||
**추천 순서:**
|
||||
1. **N-3** — max-height 제거 + _max_chars 편집자 전달 (즉시, 가장 급함)
|
||||
2. **N-1** — 블록 선택 코드 강제 (N-3과 병렬 가능)
|
||||
3. **N-2** — 사이드바 섹션 제목 (N-1 후)
|
||||
4. **N-4** — 스크린샷 기반 검수 (N-3 후, Kei API 수정 필요)
|
||||
|
||||
---
|
||||
|
||||
## 충돌 / 회귀 / 오류 검토
|
||||
|
||||
### 검토 방법
|
||||
- 4개 변경의 모든 수정 대상 파일을 코드 레벨로 읽고 교차 검증
|
||||
- `overflow: hidden` 전수 조사 (`.py`, `.css`, `.html` 전체)
|
||||
- `_max_height_px`, `_max_chars` 참조 전수 조사
|
||||
- 각 변경 간 의존 관계 + 실행 순서에서의 충돌 가능성 점검
|
||||
|
||||
---
|
||||
|
||||
### N-3 (max-height 제거) — 충돌 분석
|
||||
|
||||
**`overflow: hidden`이 존재하는 3개 레이어:**
|
||||
|
||||
| 위치 | 값 | 용도 | 건드리나 |
|
||||
|------|-----|------|---------|
|
||||
| `.slide` (base.css:16) | `overflow: hidden` | 1280x720 프레임 바깥 차단 | **유지 (건드리지 않음)** |
|
||||
| `.slide > div` (base.css:76) | `overflow: visible` | area div (body, sidebar 등) | 이미 visible. 변경 불필요 |
|
||||
| `renderer.py:229-235` | `max-height:Npx; overflow:hidden` | 블록별 래퍼 | **이것만 제거** |
|
||||
|
||||
**개별 블록 템플릿의 `overflow: hidden` (15개+):**
|
||||
- `card-image-3col.html`, `card-dark-overlay.html`, `venn-diagram.html` 등
|
||||
- 이것은 이미지/카드의 `border-radius` 잘림용
|
||||
- **텍스트 clipping과 무관 → 건드리지 않음**
|
||||
|
||||
**Phase L 측정기 영향:**
|
||||
- max-height 래퍼 제거 후, `scrollHeight`가 실제 콘텐츠 높이를 정확 반영
|
||||
- `_MEASURE_SCRIPT`의 `block.scrollHeight > block.clientHeight` → **정상 작동**
|
||||
- 이전에 false-negative(잘렸는데 감지 못함)이던 것이 정상 감지됨
|
||||
- Phase L 루프가 더 자주 트리거될 수 있음 → **의도한 동작** (잘리는 걸 고치는 것)
|
||||
- MAX_MEASURE_ROUNDS = 3이면 충분
|
||||
|
||||
**회귀 위험:** 없음. max-height 래퍼는 Phase L에서 추가된 것이고, 제거해도 기존 블록/CSS에 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
### N-1 (블록 선택 강제) — 충돌 분석
|
||||
|
||||
**기존 Step B 후처리 체인 (design_director.py:819-850):**
|
||||
```
|
||||
현재: Sonnet 응답 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
|
||||
추가: Sonnet 응답 → ★Kei 확정 블록 덮어쓰기 → 미등록 블록 거부 → area명 검증 → conclusion→footer 강제
|
||||
```
|
||||
|
||||
| 시나리오 | 처리 |
|
||||
|----------|------|
|
||||
| Kei가 추천한 블록이 catalog에 없음 | 바로 다음 단계에서 미등록 검증 → PURPOSE_FALLBACK 교체 |
|
||||
| Kei가 추천한 블록이 sidebar 금지 | `_validate_height_budget()`의 SIDEBAR_FORBIDDEN_BLOCKS 체크 |
|
||||
| Kei API 미응답 | **파이프라인 중단 + 에러 반환. fallback 없음.** Kei API는 필수 인프라. |
|
||||
|
||||
**N-3과의 관계:** 독립. N-1은 2단계, N-3은 4단계. 서로 다른 파이프라인 단계.
|
||||
|
||||
**회귀 위험:** 없음. 기존 검증 체인 위에 한 단계 추가할 뿐.
|
||||
|
||||
---
|
||||
|
||||
### N-2 (사이드바 제목) — 충돌 분석
|
||||
|
||||
| 시나리오 | 위험 | 대응 |
|
||||
|----------|------|------|
|
||||
| label 블록 추가 → sidebar 높이 예산 초과 | 낮음 (label ~30px, 예산 490px) | label 블록은 고정 30px, allocate 제외 |
|
||||
| N-1 미완료 상태에서 실행 | Sonnet이 블록을 바꿔서 label 위치 엉뚱 | **실행 순서: N-1 먼저, N-2 나중** |
|
||||
| `_group_blocks_by_area()` 호환 | flex-column 최상단에 자연 배치 | 호환 문제 없음 |
|
||||
|
||||
**회귀 위험:** 없음. 기존 로직에 label 삽입만 추가.
|
||||
|
||||
---
|
||||
|
||||
### N-4 (스크린샷 검수) — 충돌 분석
|
||||
|
||||
| 시나리오 | 위험 | 대응 |
|
||||
|----------|------|------|
|
||||
| N-3 미완료 → overflow 감지 부정확 | "overflow 없으면 skip" 판단이 틀림 | **실행 순서: N-3 먼저, N-4 나중** |
|
||||
| Selenium 인스턴스 충돌 | Phase L에서 quit 후 Stage 5에서 새 생성 | 동시 사용 아님, 충돌 없음 |
|
||||
| Kei persona_agent 미수정 | 이미지 전달 불가 | 대안: Anthropic 직접 호출 (persona 프롬프트 파일에서 로드) |
|
||||
| MAX_REVIEW_ROUNDS 2→1 축소 | 기존보다 조정 기회 줄어듦 | 스크린샷 기반이라 1회로 충분 (텍스트 기반이라 2회 필요했던 것) |
|
||||
|
||||
**N-4 선행 조건 결정 필요:**
|
||||
- **옵션 A:** Kei persona_agent 수정 (ChatRequest에 image 필드 추가, ~50줄)
|
||||
- 장점: Kei의 RAG + 세션 컨텍스트 활용 가능
|
||||
- 단점: persona_agent 코드 수정 필요
|
||||
- **옵션 B:** Anthropic API 직접 호출 (persona_agent 수정 없이)
|
||||
- 장점: design_agent 내에서 완결
|
||||
- 단점: Kei의 RAG/세션 없음, 페르소나 프롬프트만 로드
|
||||
|
||||
**회귀 위험:** 없음. Stage 5가 기존에 거의 무의미했으므로 (아무것도 안 바뀜), 변경해도 기존 품질이 나빠질 수 없음.
|
||||
|
||||
---
|
||||
|
||||
### 상호 작용 매트릭스
|
||||
|
||||
| | N-1 | N-2 | N-3 | N-4 |
|
||||
|--|-----|-----|-----|-----|
|
||||
| **N-1** | — | N-2가 N-1에 의존 | 독립 | 독립 |
|
||||
| **N-2** | N-1 완료 후 실행 | — | 독립 | 독립 |
|
||||
| **N-3** | 독립 | 독립 | — | N-4가 N-3에 의존 |
|
||||
| **N-4** | 독립 | 독립 | N-3 완료 후 실행 | — |
|
||||
|
||||
**충돌 가능 조합: 없음.** 4개 변경이 모두 파이프라인의 서로 다른 단계를 수정하므로 교차 간섭 없음.
|
||||
|
||||
---
|
||||
|
||||
## 최종 실행 계획
|
||||
|
||||
### 실행 순서 (의존 관계 기반)
|
||||
|
||||
```
|
||||
① N-3: max-height 래퍼 제거 + _max_chars 편집자 전달
|
||||
(독립, 즉시 실행 가능)
|
||||
|
||||
② N-1: 블록 선택 코드 강제
|
||||
(N-3과 독립, ①과 병렬 가능)
|
||||
|
||||
③ N-2: 사이드바 섹션 제목
|
||||
(②N-1 완료 후)
|
||||
|
||||
④ N-4: 스크린샷 기반 검수
|
||||
(①N-3 완료 후 + persona_agent 수정 또는 직접호출 결정 후)
|
||||
```
|
||||
|
||||
### 각 항목별 변경 파일 + 예상 규모
|
||||
|
||||
| 항목 | 변경 파일 | 신규 코드 | 삭제 코드 | 프롬프트 변경 |
|
||||
|------|----------|----------|----------|-------------|
|
||||
| **N-3** | renderer.py, content_editor.py, pipeline.py | ~10줄 | ~7줄 | EDITOR_PROMPT에 _max_chars 절대제한 추가 |
|
||||
| **N-1** | design_director.py | ~15줄 (후처리 강제) | ~0줄 | STEP_B_PROMPT에서 블록선택 지시 제거 |
|
||||
| **N-2** | kei_client.py, design_director.py, renderer.py | ~20줄 | ~0줄 | KEI_PROMPT에 section_title 필드 추가 |
|
||||
| **N-4** | slide_measurer.py, kei_client.py, pipeline.py + (persona_agent 4파일) | ~80줄 | ~10줄 | KEI_REVIEW_PROMPT을 이미지 기반으로 변경 |
|
||||
|
||||
### 오류 처리 원칙
|
||||
|
||||
**Kei API는 필수 인프라다. "실패하면 대체"가 아니라, 실패하면 파이프라인 중단이다.**
|
||||
|
||||
| 시나리오 | 처리 | 이유 |
|
||||
|----------|------|------|
|
||||
| Kei API 미응답 (N-1, N-2, N-3, N-4 공통) | **파이프라인 즉시 중단 + 에러 반환** | Kei는 선택이 아닌 필수. 없으면 돌리면 안 됨 |
|
||||
| 편집자(Kei)가 _max_chars 안 지킴 (N-3) | Phase L 루프가 감지 → Kei 편집자 재호출 (최대 3회) | 측정 기반 재시도 |
|
||||
| Selenium 스크린샷 실패 (N-4) | Stage 5를 텍스트 기반으로 수행 (현재 방식) | Selenium은 도구. 도구 실패 시 기존 방식 유지 |
|
||||
| sidebar label이 높이 초과 유발 (N-2) | label을 고정 30px로 처리, allocate에서 제외 | 본문 블록 공간 유지 |
|
||||
|
||||
---
|
||||
|
||||
## 파일별 변경 범위 요약
|
||||
|
||||
| 파일 | N-1 | N-2 | N-3 | N-4 |
|
||||
|------|-----|-----|-----|-----|
|
||||
| `design_director.py` | Step B 프롬프트 축소 + 후처리 강제 | sidebar label 삽입 | - | - |
|
||||
| `kei_client.py` | - | KEI_PROMPT section_title 추가 | - | 이미지 전달 추가 |
|
||||
| `content_editor.py` | - | - | _max_chars 프롬프트 전달 | - |
|
||||
| `renderer.py` | - | sidebar label 렌더 | max-height 래퍼 **삭제** | - |
|
||||
| `pipeline.py` | - | - | Phase L 루프 _max_chars 축소 | Stage 5 스크린샷 + skip 로직 |
|
||||
| `slide_measurer.py` | - | - | - | `capture_slide_screenshot()` 추가 |
|
||||
| `space_allocator.py` | - | - | - | - |
|
||||
| **Kei persona_agent** | - | - | - | ChatRequest 이미지 확장 (~50줄) |
|
||||
708
docs/history/IMPROVEMENT-PHASE-O.md
Normal file
708
docs/history/IMPROVEMENT-PHASE-O.md
Normal file
@@ -0,0 +1,708 @@
|
||||
# Phase O: 컨테이너 기반 레이아웃 시스템
|
||||
|
||||
> 작성일: 2026-03-27
|
||||
> 상태: ✅ 코드 구현 완료 + 후속 정리 완료 (Step B 제거, 죽은 코드 정리, 미해결 3건 해결)
|
||||
> 선행 완료: Phase N (catalog 개선, fallback 제거, topic_id 버그 수정)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**"비중이 컨테이너를 확정하고, 컨테이너가 블록을 제약하고, 블록이 콘텐츠를 제약한다."**
|
||||
|
||||
```
|
||||
Kei 비중 판단 (본심 60%, 배경 20%)
|
||||
↓
|
||||
컨테이너 px 확정 (본심 294px, 배경 98px)
|
||||
↓
|
||||
블록 선택 시 컨테이너 크기 제약 (98px → compact 블록만)
|
||||
↓
|
||||
블록 스펙 확정 (항목 수, 폰트, 패딩, 행 수)
|
||||
↓
|
||||
편집자가 확정 스펙에 맞게 텍스트 작성
|
||||
↓
|
||||
렌더링 (컨테이너 grid로 비중 강제 반영)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 현재 문제 (Phase N 이후에도 남은 것)
|
||||
|
||||
### 문제 1: 비중이 시각에 반영 안 됨
|
||||
- Kei가 본심 60%, 배경 20%로 판단했지만
|
||||
- 실제 렌더링에서 배경이 73%(348px), 본심이 20%(97px)
|
||||
- **원인:** 블록이 자연 높이대로 렌더링되고, 비중 기반 컨테이너가 없음
|
||||
|
||||
### 문제 2: 블록 선택 시 컨테이너 크기를 모름
|
||||
- Kei가 블록을 고를 때 "이 블록이 컨테이너에 들어가는지" 판단 불가
|
||||
- 98px 컨테이너에 height_cost=large 블록이 선택됨
|
||||
|
||||
### 문제 3: 블록이 컨테이너에 맞게 변형되지 않음
|
||||
- 같은 `dark-bullet-list`여도 98px이면 불릿 2개, 294px이면 5개여야 하는데
|
||||
- 현재는 블록이 고정 형태로 렌더링됨
|
||||
|
||||
### 문제 4: 텍스트 분량이 컨테이너와 무관
|
||||
- sidebar 490px인데 용어 정의가 한 줄짜리
|
||||
- body 98px인데 문제제기가 3단 구조
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상 파일 및 역할
|
||||
|
||||
| 파일 | 현재 역할 | Phase O 변경 |
|
||||
|------|----------|------------|
|
||||
| `pipeline.py` | 5단계 오케스트레이션 | 컨테이너 계산을 Step A와 A-2 사이에 삽입 |
|
||||
| `space_allocator.py` | _max_chars만 계산 | **컨테이너 스펙 생성기로 확장** (px, 블록 제약, 항목수, 폰트, 글자수) |
|
||||
| `design_director.py` | Step A-2에서 블록 선택 | 컨테이너 px를 Kei에게 전달 + height_cost 제약 |
|
||||
| `content_editor.py` | _max_chars로 분량 제한 | 블록 스펙(항목수, 글자수/항목)을 프롬프트에 전달 |
|
||||
| `renderer.py` | flex-column으로 블록 나열 | **비중 기반 grid row로 컨테이너 생성** |
|
||||
| `catalog.yaml` | when/not_for 설명 | 각 블록의 height_cost를 px 범위로 구체화 |
|
||||
|
||||
---
|
||||
|
||||
## 단계별 상세 설계
|
||||
|
||||
### O-1. 컨테이너 스펙 계산 (`space_allocator.py` 확장)
|
||||
|
||||
**현재:** `allocate_height_budget()` → `{topic_id: max_height_px}` 딕셔너리만 반환
|
||||
|
||||
**변경:** `calculate_container_specs()` → 각 컨테이너의 완전한 스펙을 반환
|
||||
|
||||
```python
|
||||
def calculate_container_specs(
|
||||
page_structure: dict, # Kei의 비중 판단: {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
|
||||
topics: list[dict], # 각 topic의 purpose, role, layer
|
||||
preset: dict, # 프리셋 zone 정보 (budget_px, width_pct)
|
||||
) -> dict[str, ContainerSpec]:
|
||||
"""Kei 비중 → 컨테이너 스펙 변환.
|
||||
|
||||
Returns:
|
||||
역할별 ContainerSpec 딕셔너리. 예:
|
||||
{
|
||||
"본심": ContainerSpec(
|
||||
role="본심",
|
||||
zone="body",
|
||||
topic_ids=[3],
|
||||
weight=0.6,
|
||||
height_px=294, # zone_budget × weight_ratio
|
||||
width_px=716, # slide_width × zone_width_pct × 0.85 (패딩 제외)
|
||||
max_height_cost="xlarge", # 294px이면 xlarge까지 가능
|
||||
block_constraints={
|
||||
"max_items": 7, # 높이 기반 계산
|
||||
"font_size_px": 15.2, # 기본값 유지 가능
|
||||
"padding_px": 20, # 기본값 유지 가능
|
||||
"max_chars_total": 800, # 높이×너비 기반 총 글자수
|
||||
},
|
||||
),
|
||||
"배경": ContainerSpec(
|
||||
role="배경",
|
||||
zone="body",
|
||||
topic_ids=[1, 2],
|
||||
weight=0.2,
|
||||
height_px=98,
|
||||
width_px=716,
|
||||
max_height_cost="compact", # 98px이면 compact만
|
||||
block_constraints={
|
||||
"max_items": 3,
|
||||
"font_size_px": 13.0, # 줄여야 함
|
||||
"padding_px": 10, # 줄여야 함
|
||||
"max_chars_total": 200,
|
||||
},
|
||||
),
|
||||
...
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
**height_cost → px 매핑:**
|
||||
|
||||
현재 catalog.yaml의 height_cost는 문자열(`compact`, `medium`, `large`, `xlarge`)이다.
|
||||
이것을 px 범위로 매핑해야 Kei가 블록을 고를 때 컨테이너에 맞는지 판단할 수 있다.
|
||||
|
||||
```python
|
||||
HEIGHT_COST_PX_RANGE = {
|
||||
"compact": (30, 80), # 30~80px
|
||||
"medium": (80, 200), # 80~200px
|
||||
"large": (200, 350), # 200~350px
|
||||
"xlarge": (350, 500), # 350~500px
|
||||
}
|
||||
```
|
||||
|
||||
**컨테이너 높이 → 허용 height_cost 결정:**
|
||||
```python
|
||||
def max_allowed_height_cost(container_height_px: int) -> str:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost를 결정."""
|
||||
if container_height_px >= 350:
|
||||
return "xlarge"
|
||||
elif container_height_px >= 200:
|
||||
return "large"
|
||||
elif container_height_px >= 80:
|
||||
return "medium"
|
||||
else:
|
||||
return "compact"
|
||||
```
|
||||
|
||||
**블록 내부 제약 계산:**
|
||||
```python
|
||||
def calculate_block_constraints(
|
||||
height_px: int,
|
||||
width_px: int,
|
||||
topic_count: int, # 이 컨테이너에 들어가는 topic 수
|
||||
font_size_px: float,
|
||||
line_height: float,
|
||||
padding_px: int,
|
||||
) -> dict:
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
|
||||
# 각 topic에 할당되는 높이
|
||||
per_topic_height = (height_px - padding_px * 2) / topic_count
|
||||
|
||||
# 줄 수
|
||||
line_height_px = font_size_px * line_height
|
||||
max_lines = int(per_topic_height / line_height_px)
|
||||
|
||||
# 줄당 글자 수
|
||||
chars_per_line = int((width_px - padding_px * 2) / (font_size_px * 0.95))
|
||||
|
||||
# 불릿/항목 수 (한 항목 = 약 2줄)
|
||||
max_items = max(1, max_lines // 2)
|
||||
|
||||
# 총 글자 수
|
||||
max_chars_total = max_lines * chars_per_line
|
||||
|
||||
return {
|
||||
"max_lines": max_lines,
|
||||
"max_items": max_items,
|
||||
"chars_per_line": chars_per_line,
|
||||
"max_chars_total": max_chars_total,
|
||||
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
|
||||
}
|
||||
```
|
||||
|
||||
**폰트/패딩 조정 기준:**
|
||||
|
||||
| 컨테이너 높이 | 폰트 크기 | 패딩 | line-height |
|
||||
|-------------|---------|------|-----------|
|
||||
| ≥300px | 15.2px (기본) | 20px (기본) | 1.7 (기본) |
|
||||
| 150~299px | 14px | 14px | 1.6 |
|
||||
| 80~149px | 13px | 10px | 1.5 |
|
||||
| <80px | 12px | 8px | 1.4 |
|
||||
|
||||
---
|
||||
|
||||
### O-2. 블록 선택에 컨테이너 제약 전달 (`design_director.py`)
|
||||
|
||||
**현재:** `_opus_block_recommendation()`이 Kei에게 블록 후보 + 꼭지 목록을 보냄. 컨테이너 크기 정보 없음.
|
||||
|
||||
**변경:** 컨테이너 스펙을 Kei에게 함께 전달.
|
||||
|
||||
```python
|
||||
# _opus_block_recommendation 프롬프트에 추가할 내용
|
||||
|
||||
container_text = "\n".join(
|
||||
f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
|
||||
f"허용 height_cost: {spec.max_height_cost} 이하, "
|
||||
f"최대 항목 수: {spec.block_constraints['max_items']}"
|
||||
for role, spec in container_specs.items()
|
||||
for tid in spec.topic_ids
|
||||
)
|
||||
|
||||
prompt += (
|
||||
f"\n\n## 컨테이너 제약 (반드시 준수)\n"
|
||||
f"각 꼭지의 블록은 아래 컨테이너 안에 들어가야 한다.\n"
|
||||
f"height_cost가 컨테이너보다 크면 선택 금지.\n\n"
|
||||
f"{container_text}\n"
|
||||
)
|
||||
```
|
||||
|
||||
**코드 레벨 검증 (Kei 응답 후):**
|
||||
```python
|
||||
# Kei가 선택한 블록의 height_cost가 컨테이너보다 큰지 검증
|
||||
for rec in kei_recommendations:
|
||||
tid = rec.get("topic_id") or rec.get("id")
|
||||
block_type = rec.get("block_type", "")
|
||||
|
||||
# catalog에서 height_cost 조회
|
||||
block_height_cost = catalog_map.get(block_type, {}).get("height_cost", "medium")
|
||||
|
||||
# 컨테이너의 max_height_cost 조회
|
||||
container_spec = find_container_for_topic(tid, container_specs)
|
||||
allowed = container_spec.max_height_cost
|
||||
|
||||
# 제약 위반 체크
|
||||
if HEIGHT_COST_ORDER[block_height_cost] > HEIGHT_COST_ORDER[allowed]:
|
||||
logger.warning(
|
||||
f"[O-2 검증] 꼭지 {tid}: {block_type}({block_height_cost})이 "
|
||||
f"컨테이너({container_spec.height_px}px, {allowed} 이하)에 안 맞음"
|
||||
)
|
||||
# 위반 시 → Kei에게 재선택 요청 (컨테이너 제약 명시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### O-3. 블록 스펙 확정 단계 (신규)
|
||||
|
||||
**현재:** 없음. 블록이 선택되면 바로 편집자에게 전달.
|
||||
|
||||
**변경:** Step A-2 후, Step 3 전에 **블록 스펙 확정** 단계 삽입.
|
||||
|
||||
이 단계는 **코드(결정론적)** — AI 호출 없음.
|
||||
|
||||
```python
|
||||
def finalize_block_specs(
|
||||
blocks: list[dict], # Step A-2에서 확정된 블록 목록
|
||||
container_specs: dict, # O-1에서 계산된 컨테이너 스펙
|
||||
catalog: dict, # catalog.yaml 데이터
|
||||
) -> list[dict]:
|
||||
"""각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
|
||||
|
||||
확정 항목:
|
||||
- _container_height_px: 이 블록이 쓸 수 있는 높이
|
||||
- _container_width_px: 이 블록이 쓸 수 있는 너비
|
||||
- _max_items: 최대 항목/불릿/행 수
|
||||
- _max_chars_per_item: 항목당 최대 글자 수
|
||||
- _max_chars_total: 총 최대 글자 수
|
||||
- _font_size_px: 이 컨테이너에서의 폰트 크기
|
||||
- _padding_px: 이 컨테이너에서의 패딩
|
||||
- _line_height: 이 컨테이너에서의 줄간격
|
||||
"""
|
||||
for block in blocks:
|
||||
tid = block.get("topic_id")
|
||||
spec = find_container_for_topic(tid, container_specs)
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
block_type = block.get("type", "")
|
||||
catalog_info = catalog.get(block_type, {})
|
||||
|
||||
# 이 블록이 쓸 수 있는 높이 (같은 컨테이너 안의 다른 블록과 분배)
|
||||
siblings_in_container = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec]
|
||||
per_block_height = spec.height_px // len(siblings_in_container)
|
||||
|
||||
# 폰트/패딩 결정 (컨테이너 크기 기반)
|
||||
font_size, padding, line_h = determine_typography(per_block_height)
|
||||
|
||||
# 블록별 항목 수 계산
|
||||
constraints = calculate_block_constraints(
|
||||
per_block_height, spec.width_px,
|
||||
topic_count=1, # 이 블록 1개
|
||||
font_size_px=font_size,
|
||||
line_height=line_h,
|
||||
padding_px=padding,
|
||||
)
|
||||
|
||||
# 블록 타입별 세부 조정
|
||||
schema = catalog_info.get("schema", {})
|
||||
if block_type in ("dark-bullet-list",):
|
||||
# 불릿 블록: max_items = 불릿 수
|
||||
block["_max_items"] = min(constraints["max_items"], int(schema.get("max_bullets", {}).get("body", 5)))
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("card-numbered", "card-icon-desc"):
|
||||
# 카드 블록: max_items = 카드 수
|
||||
block["_max_items"] = constraints["max_items"]
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
|
||||
# 표 블록: max_items = 행 수
|
||||
block["_max_items"] = constraints["max_items"]
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("comparison-2col",):
|
||||
# 비교 블록: 좌우 각각의 글자 수
|
||||
block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
|
||||
elif block_type in ("banner-gradient",):
|
||||
# 배너: 한 줄
|
||||
block["_max_chars_total"] = constraints["chars_per_line"]
|
||||
else:
|
||||
block["_max_chars_total"] = constraints["max_chars_total"]
|
||||
|
||||
# 공통
|
||||
block["_container_height_px"] = per_block_height
|
||||
block["_container_width_px"] = spec.width_px
|
||||
block["_font_size_px"] = font_size
|
||||
block["_padding_px"] = padding
|
||||
block["_line_height"] = line_h
|
||||
block["_max_chars_total"] = constraints["max_chars_total"]
|
||||
|
||||
return blocks
|
||||
```
|
||||
|
||||
**typography 결정 함수:**
|
||||
```python
|
||||
def determine_typography(height_px: int) -> tuple[float, int, float]:
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
|
||||
if height_px >= 300:
|
||||
return (15.2, 20, 1.7) # 기본
|
||||
elif height_px >= 150:
|
||||
return (14.0, 14, 1.6) # 약간 축소
|
||||
elif height_px >= 80:
|
||||
return (13.0, 10, 1.5) # 축소
|
||||
else:
|
||||
return (12.0, 8, 1.4) # 최소
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### O-4. 편집자 프롬프트에 블록 스펙 전달 (`content_editor.py`)
|
||||
|
||||
**현재:** `_max_chars`만 전달. 항목 수, 항목당 글자 수, 폰트 크기 정보 없음.
|
||||
|
||||
**변경:** O-3에서 확정된 모든 스펙을 편집자에게 전달.
|
||||
|
||||
```python
|
||||
# fill_content()에서 각 블록의 스펙을 프롬프트에 구체적으로 명시
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
req_text = (
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area')}):\n"
|
||||
f" 목적(purpose): {block.get('purpose')}\n"
|
||||
f" 필수 슬롯: {slots.get('required', [])}\n"
|
||||
)
|
||||
|
||||
# O-4: 블록 스펙 (컨테이너 기반)
|
||||
container_h = block.get("_container_height_px")
|
||||
if container_h:
|
||||
max_items = block.get("_max_items", "제한 없음")
|
||||
max_chars_item = block.get("_max_chars_per_item", "제한 없음")
|
||||
max_chars_total = block.get("_max_chars_total", "제한 없음")
|
||||
font_size = block.get("_font_size_px", 15.2)
|
||||
|
||||
req_text += (
|
||||
f"\n ★ 컨테이너 제약 (절대 준수):\n"
|
||||
f" - 컨테이너 높이: {container_h}px\n"
|
||||
f" - 최대 항목 수: {max_items}개\n"
|
||||
f" - 항목당 최대 글자 수: {max_chars_item}자\n"
|
||||
f" - 총 최대 글자 수: {max_chars_total}자\n"
|
||||
f" - 폰트 크기: {font_size}px\n"
|
||||
f" 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라.\n"
|
||||
)
|
||||
```
|
||||
|
||||
**sidebar 용어 정의 예시:**
|
||||
```
|
||||
블록 5 (card-numbered, 영역: sidebar):
|
||||
목적(purpose): 용어정의
|
||||
★ 컨테이너 제약:
|
||||
- 컨테이너 높이: 450px (sidebar 전체)
|
||||
- 최대 항목 수: 3개
|
||||
- 항목당 최대 글자 수: 120자 ← 출처까지 넣을 수 있는 여유
|
||||
- 폰트 크기: 13px
|
||||
```
|
||||
|
||||
**body 배경(98px) 예시:**
|
||||
```
|
||||
블록 2 (dark-bullet-list, 영역: body):
|
||||
목적(purpose): 근거사례
|
||||
★ 컨테이너 제약:
|
||||
- 컨테이너 높이: 49px (배경 98px / 2 topics)
|
||||
- 최대 항목 수: 2개
|
||||
- 항목당 최대 글자 수: 40자 ← 간결하게
|
||||
- 폰트 크기: 12px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### O-5. 렌더러에서 비중 기반 grid row 생성 (`renderer.py`)
|
||||
|
||||
**현재:** `_group_blocks_by_area()`가 같은 area 블록을 flex-column으로 나열. 높이 비율 없음.
|
||||
|
||||
**변경:** body zone 안에 역할(본심/배경/결론)별 grid row를 생성하고, 각 row의 높이를 비중 기반으로 확정.
|
||||
|
||||
```python
|
||||
def _group_blocks_by_area_with_containers(
|
||||
blocks: list[dict[str, Any]],
|
||||
container_specs: dict | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
|
||||
|
||||
container_specs가 있으면:
|
||||
- body zone 안에서 역할별 컨테이너 div를 생성
|
||||
- 각 컨테이너의 height를 비중 기반 px로 고정
|
||||
- 블록은 해당 컨테이너 안에 배치
|
||||
|
||||
container_specs가 없으면:
|
||||
- 기존 flex-column 방식 (호환)
|
||||
"""
|
||||
grouped = OrderedDict()
|
||||
for block in blocks:
|
||||
area = block["area"]
|
||||
if area not in grouped:
|
||||
grouped[area] = {"area": area, "blocks": []}
|
||||
grouped[area]["blocks"].append(block)
|
||||
|
||||
result = []
|
||||
for area, data in grouped.items():
|
||||
block_list = data["blocks"]
|
||||
|
||||
if container_specs and area == "body":
|
||||
# 비중 기반 컨테이너 생성
|
||||
# container_specs에서 이 area의 역할별 높이를 가져옴
|
||||
container_htmls = []
|
||||
|
||||
# 역할 순서: 배경 → 본심 → (결론은 footer)
|
||||
role_order = ["배경", "본심"]
|
||||
|
||||
for role in role_order:
|
||||
spec = container_specs.get(role)
|
||||
if not spec or spec.zone != area:
|
||||
continue
|
||||
|
||||
# 이 역할에 해당하는 블록들
|
||||
role_blocks = [
|
||||
b for b in block_list
|
||||
if b.get("_topic_id_role") == role or b.get("topic_id") in spec.topic_ids
|
||||
]
|
||||
|
||||
if not role_blocks:
|
||||
continue
|
||||
|
||||
inner_html = "\n".join(b["html"] for b in role_blocks)
|
||||
|
||||
# 컨테이너 div: 높이 고정 + overflow visible (측정용)
|
||||
font_size = spec.block_constraints.get("font_size_px", 15.2)
|
||||
padding = spec.block_constraints.get("padding_px", 20)
|
||||
|
||||
container_htmls.append(
|
||||
f'<div class="container-{role}" style="'
|
||||
f'height:{spec.height_px}px; '
|
||||
f'overflow:visible; '
|
||||
f'font-size:{font_size}px; '
|
||||
f'--spacing-inner:{padding}px; '
|
||||
f'--font-body:{font_size / 16:.3f}rem;">\n'
|
||||
f'{inner_html}\n</div>'
|
||||
)
|
||||
|
||||
html = "\n".join(container_htmls)
|
||||
|
||||
elif len(block_list) == 1:
|
||||
html = block_list[0]["html"]
|
||||
else:
|
||||
inner = "\n".join(b["html"] for b in block_list)
|
||||
html = (
|
||||
f'<div style="display:flex; flex-direction:column; '
|
||||
f'gap:var(--spacing-block); height:100%;">\n'
|
||||
f'{inner}\n</div>'
|
||||
)
|
||||
|
||||
result.append({"area": area, "html": html})
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**CSS 구조 (렌더링 결과):**
|
||||
```html
|
||||
<!-- body zone -->
|
||||
<div class="area-body">
|
||||
<!-- 배경 컨테이너: 98px 고정 -->
|
||||
<div class="container-배경" style="height:98px; overflow:visible; font-size:13px;">
|
||||
<!-- topic 1: comparison-2col -->
|
||||
<!-- topic 2: dark-bullet-list -->
|
||||
</div>
|
||||
|
||||
<!-- 본심 컨테이너: 294px 고정 -->
|
||||
<div class="container-본심" style="height:294px; overflow:visible; font-size:15.2px;">
|
||||
<!-- topic 3: compare-2col-split -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer: 60px -->
|
||||
<div class="area-footer" style="height:60px;">
|
||||
<!-- topic 5: banner-gradient -->
|
||||
</div>
|
||||
|
||||
<!-- sidebar: 490px -->
|
||||
<div class="area-sidebar">
|
||||
<!-- topic 4: card-numbered (여유로운 공간) -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### O-6. 파이프라인 흐름 변경 (`pipeline.py`)
|
||||
|
||||
**현재 흐름:**
|
||||
```
|
||||
1A(Kei 꼭지) → 1B(컨셉) → A-2(블록선택) → B(zone배치) → 공간할당 → 3(편집) → 4(CSS+렌더) → 측정 → 5(검수)
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```
|
||||
1A(Kei 꼭지 + 비중)
|
||||
↓
|
||||
1B(Kei 컨셉)
|
||||
↓
|
||||
★ 컨테이너 스펙 계산 (O-1, 코드/결정론적)
|
||||
↓
|
||||
A-2(Kei 블록선택 — 컨테이너 제약 전달) (O-2)
|
||||
↓
|
||||
B(Sonnet zone + char_guide)
|
||||
↓
|
||||
★ 블록 스펙 확정 (O-3, 코드/결정론적)
|
||||
↓
|
||||
3(Kei 편집 — 블록 스펙 전달) (O-4)
|
||||
↓
|
||||
4(렌더링 — 컨테이너 grid) (O-5)
|
||||
↓
|
||||
측정(Selenium)
|
||||
↓
|
||||
5(Kei 검수)
|
||||
```
|
||||
|
||||
**pipeline.py 변경 위치:**
|
||||
|
||||
```python
|
||||
# 현재 코드 위치: pipeline.py 105행 부근 (2단계 시작 전)
|
||||
|
||||
# ★ O-1: 컨테이너 스펙 계산 (1B 완료 후, Step A-2 전)
|
||||
yield {"event": "progress", "data": "1.8/5 컨테이너 스펙 계산 중..."}
|
||||
|
||||
from src.space_allocator import calculate_container_specs
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure=analysis.get("page_structure", {}),
|
||||
topics=analysis.get("topics", []),
|
||||
preset=preset,
|
||||
)
|
||||
_save_step(run_dir, "step1c_containers.json", {
|
||||
role: {
|
||||
"height_px": spec.height_px,
|
||||
"width_px": spec.width_px,
|
||||
"max_height_cost": spec.max_height_cost,
|
||||
"topic_ids": spec.topic_ids,
|
||||
"block_constraints": spec.block_constraints,
|
||||
}
|
||||
for role, spec in container_specs.items()
|
||||
})
|
||||
|
||||
# 2단계: Step A-2에 container_specs 전달
|
||||
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
|
||||
|
||||
# ★ O-3: 블록 스펙 확정 (Step B 후, Step 3 전)
|
||||
from src.space_allocator import finalize_block_specs
|
||||
for page in layout_concept.get("pages", []):
|
||||
finalize_block_specs(page.get("blocks", []), container_specs, catalog)
|
||||
_save_step(run_dir, "step2c_block_specs.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"type": b.get("type"), "topic_id": b.get("topic_id"),
|
||||
"_container_height_px": b.get("_container_height_px"),
|
||||
"_max_items": b.get("_max_items"),
|
||||
"_max_chars_per_item": b.get("_max_chars_per_item"),
|
||||
"_max_chars_total": b.get("_max_chars_total"),
|
||||
"_font_size_px": b.get("_font_size_px"),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# 3단계: 편집자에게 블록 스펙이 전달됨 (O-4는 content_editor.py에서 자동 적용)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### O-7. 중간 산출물 추가 (리포트 반영)
|
||||
|
||||
**새로 추가되는 중간 산출물:**
|
||||
|
||||
| 파일 | 단계 | 내용 |
|
||||
|------|------|------|
|
||||
| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost, block_constraints) |
|
||||
| `step2c_block_specs.json` | O-3 | 각 블록의 확정 스펙 (_container_height_px, _max_items, _font_size_px 등) |
|
||||
|
||||
`generate_run_report.py`에 이 2개 단계를 추가한다.
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
O-1: space_allocator.py 확장 (ContainerSpec + calculate_container_specs + calculate_block_constraints + determine_typography)
|
||||
↓
|
||||
O-2: design_director.py 변경 (컨테이너 제약을 Kei에게 전달 + 코드 레벨 height_cost 검증)
|
||||
↓
|
||||
O-3: space_allocator.py 추가 (finalize_block_specs)
|
||||
↓
|
||||
O-4: content_editor.py 변경 (블록 스펙을 편집자 프롬프트에 전달)
|
||||
↓
|
||||
O-5: renderer.py 변경 (비중 기반 grid row 컨테이너 생성)
|
||||
↓
|
||||
O-6: pipeline.py 변경 (새 단계 삽입 + 중간 산출물 저장)
|
||||
↓
|
||||
O-7: generate_run_report.py 확장 (새 중간 산출물 표시)
|
||||
```
|
||||
|
||||
**의존 관계:**
|
||||
- O-1이 먼저 (나머지 모두 O-1의 ContainerSpec에 의존)
|
||||
- O-2, O-3은 O-1 완료 후
|
||||
- O-4는 O-3 완료 후
|
||||
- O-5는 O-1 완료 후 (O-3과 병렬 가능)
|
||||
- O-6은 O-1~O-5 전부 완료 후
|
||||
- O-7은 O-6 완료 후
|
||||
|
||||
---
|
||||
|
||||
## 검증 기준
|
||||
|
||||
이 Phase가 완료되면 아래가 반드시 성립해야 한다:
|
||||
|
||||
1. **비중 = 시각 비율**: Kei가 본심 60%로 판단하면, 실제 렌더링에서 body zone의 60%를 본심 블록이 차지한다
|
||||
2. **컨테이너 밖으로 안 넘침**: 각 블록이 자기 컨테이너 높이 안에 들어간다 (overflow:visible이므로 넘치면 Selenium이 감지)
|
||||
3. **블록 크기 적합**: 98px 컨테이너에 height_cost=large 블록이 선택되지 않는다
|
||||
4. **텍스트 분량 적합**: 490px sidebar에서 용어 정의가 출처까지 포함하고, 98px 배경에서 문제제기가 간결하다
|
||||
5. **중간 산출물 확인 가능**: report.html에서 컨테이너 스펙과 블록 스펙을 단계별로 확인할 수 있다
|
||||
|
||||
---
|
||||
|
||||
## 기술 조사 결과 반영
|
||||
|
||||
### 적용하는 것
|
||||
- **fonttools** — `calculate_block_constraints()`에서 Pretendard 한글 실측 폭 사용. 하드코딩 `14.0px` 대체. 한글은 uniform-width이므로 정확.
|
||||
- **CSS Grid 고정 행** — `grid-template-rows: 98px 294px` 형태로 컨테이너 높이 확정. W3C 표준, 모든 브라우저 지원.
|
||||
- **`overflow: visible` + `scrollHeight`** — 컨테이너 높이 고정 + overflow visible → Selenium이 정확히 감지. CSSOM View 스펙 준수.
|
||||
|
||||
### 적용하지 않는 것
|
||||
- **CSS Container Queries** — 38개 블록 템플릿 전부에 `@container` 규칙 추가 필요. Phase O의 핵심 목표(컨테이너 비중 반영)와 무관한 별도 작업. 필요 시 별도 Phase로.
|
||||
- **Playwright** — Selenium으로 이미 작동 중. 성능 문제 체감 시 전환.
|
||||
- **PPTAgent 방식 (절대 좌표)** — 우리는 콘텐츠마다 비중이 동적으로 변하므로 절대 좌표 방식 부적합.
|
||||
|
||||
### 조사에서 확인된 사실
|
||||
- 기존 도구(Slidev, Marp, reveal.js, PPTAgent) 중 비중 기반 컨테이너 시스템을 쓰는 것은 없음. 우리가 직접 구현.
|
||||
- PPTAgent의 `suggested_characters` 개념은 우리 `_max_chars`와 유사하지만, 원본 PPTX 고정값 vs 우리는 동적 계산.
|
||||
|
||||
---
|
||||
|
||||
## 기존 코드 충돌 해결 (6건)
|
||||
|
||||
Phase O 적용 시 기존 코드와 충돌하는 지점과 해결 방법.
|
||||
|
||||
### 충돌 1: `_max_height_px` vs `_container_height_px`
|
||||
- **현재:** pipeline.py:188에서 `block["_max_height_px"]` 설정
|
||||
- **해결:** pipeline.py 155~198행(Phase M 공간 할당) 전체를 O-1 `calculate_container_specs()`로 교체
|
||||
|
||||
### 충돌 2: `allocate_height_budget()` vs `calculate_container_specs()`
|
||||
- **현재:** pipeline.py:179에서 `allocate_height_budget()` 호출
|
||||
- **해결:** 호출부 교체. `allocate_height_budget()` 함수는 제거하지 않고 `calculate_container_specs()` 내부에서 재사용 가능.
|
||||
|
||||
### 충돌 3: `_max_chars` 단일값 vs `_max_items` + `_max_chars_per_item`
|
||||
- **현재:** content_editor.py:121에서 `block.get("_max_chars")` 체크
|
||||
- **해결:** N-3에서 추가한 `_max_chars` 프롬프트 코드를 O-4 블록 스펙으로 교체
|
||||
|
||||
### 충돌 4: Selenium 측정 스크립트가 container div 못 찾음
|
||||
- **현재:** slide_measurer.py:36에서 `[class*="area-"]`만 검색
|
||||
- **해결:** `_MEASURE_SCRIPT`에 `.container-*` 셀렉터 추가. container div의 overflow도 감지.
|
||||
|
||||
### 충돌 5: Phase L 피드백 루프 필드명
|
||||
- **현재:** pipeline.py:276에서 `block.get("_max_chars", 400)` 축소
|
||||
- **해결:** `_max_chars_total` 또는 `_max_items` 축소로 변경
|
||||
|
||||
### 충돌 6: fonttools 의존성
|
||||
- **현재:** pyproject.toml에 fonttools 없음, Pretendard .ttf 로컬 없음
|
||||
- **해결:** `pip install fonttools` + Pretendard .ttf 다운로드 (CDN에서)
|
||||
|
||||
**원칙:** 모든 충돌은 "기존 코드를 Phase O 코드로 교체"하는 형태. 병존이 아닌 대체. 회귀 없음.
|
||||
|
||||
---
|
||||
|
||||
## 변경하지 않는 것
|
||||
|
||||
- catalog.yaml: Phase N에서 이미 개선 완료. 추가 수정 불필요.
|
||||
- kei_client.py: 프롬프트 변경 없음. Kei는 이미 비중을 잘 판단하고 있다.
|
||||
- slide_measurer.py: 측정 로직 기본 구조 변경 없음. container 셀렉터만 추가.
|
||||
- Kei persona_agent: 수정 없음.
|
||||
155
docs/history/IMPROVEMENT-PHASE-P.md
Normal file
155
docs/history/IMPROVEMENT-PHASE-P.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Phase P: 블록 재구성 + 실제 렌더링 비교 선택
|
||||
|
||||
> 작성일: 2026-03-27
|
||||
> 상태: 계획 확정 (사용자 승인 완료, 실행 대기)
|
||||
> 선행 완료: Phase O (컨테이너 기반 레이아웃)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**"블록을 컨테이너에 맞게 재구성하고, 실제 렌더링해보고, Kei가 스크린샷을 보고 목적에 맞는 것을 고른다."**
|
||||
|
||||
```
|
||||
컨테이너 px 확정 (Phase O)
|
||||
↓
|
||||
후보 3개 선택 (FAISS 2개 + Opus 1개)
|
||||
↓
|
||||
3개 블록을 컨테이너 크기에 맞게 재구성 (폰트/패딩/항목수/레이아웃 — 동적 계산)
|
||||
↓
|
||||
Kei가 3개 각각에 맞게 텍스트 편집
|
||||
↓
|
||||
3개 실제 렌더링 (Selenium) + 스크린샷 캡처 (.png)
|
||||
↓
|
||||
Kei가 스크린샷을 보고 "당초 목적에 가장 적합한 것" 선택
|
||||
↓
|
||||
전부 안 맞으면 정확도 가장 높은 것으로 배치
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 해결하는 문제 (14건)
|
||||
|
||||
| # | 문제 | 해결 방법 |
|
||||
|---|------|---------|
|
||||
| P-1 | Kei가 표면 키워드에 반응하여 블록 선택 | 후보 3개 렌더링 → Kei가 스크린샷 보고 목적 기준으로 최종 선택 |
|
||||
| P-2 | purpose_fit 위반 블록 통과 | Kei가 스크린샷으로 판단하므로 부적합 블록 탈락 |
|
||||
| P-3 | 같은 블록 반복 사용 | 같은 컨테이너 topic을 함께 보여주고 "서로 다른 블록 선택" 명시 |
|
||||
| P-4 | 블록이 콘텐츠 의미 왜곡 | 왜곡된 결과를 Kei가 스크린샷으로 보고 탈락 |
|
||||
| P-5 | compare-pill-pair height_cost 부정확 | catalog를 믿지 않음. 실제 렌더링으로 확인. 추가로 Selenium 실측 스크립트로 전체 검증 |
|
||||
| P-6 | banner-gradient height_cost 부정확 | P-5와 동일 |
|
||||
| P-7 | 38개 전체 height_cost 미검증 | Selenium 실측 검증 스크립트 작성 → catalog 갱신 |
|
||||
| P-8 | 배경 58px에 콘텐츠 전달 불가 | ① 블록을 58px에 맞게 재구성 ② Kei가 비중 판단 시 topic 수 고려 ③ 또는 Kei가 topic을 합침 |
|
||||
| P-9 | sidebar 490px에 247px만 사용 | P-10 해결 시 공간 활용도 상승 |
|
||||
| P-10 | 용어 정의 빈약 (출처 누락) | EDITOR_PROMPT 하드코딩 제거 완료. 컨테이너 제약에 맞게 Kei가 편집 |
|
||||
| P-11 | EDITOR_PROMPT 하드코딩 분량 | **해결 완료** |
|
||||
| P-12 | 블록 추천 프롬프트가 의미/논리 구조 미전달 | 후보 3개 렌더링 + Kei 스크린샷 판단으로 대체. 프롬프트 정확도에 의존하지 않음 |
|
||||
| P-13 | 스크린샷 .txt로 저장 | base64 디코딩하여 .png 파일로 저장 |
|
||||
| P-14 | 피드백 루프에 블록 교체 기능 없음 | 처음부터 3개 렌더링해서 맞는 걸 고르므로 피드백 부담 감소 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 파이프라인
|
||||
|
||||
```
|
||||
Step 1: Kei 분석 (기존 그대로)
|
||||
1A: classify_content() → topics, page_structure(비중)
|
||||
1B: refine_concepts() → relation_type, expression_hint
|
||||
컨테이너 계산: calculate_container_specs() → 역할별 px 확정
|
||||
|
||||
Step 2: 후보 선택 (Kei API 1회)
|
||||
FAISS가 topic당 상위 2개 자동 검색
|
||||
+ Opus에게 전체 topic을 한꺼번에 보여주고 각각 1개 추천
|
||||
= topic당 후보 3개 (중복 시 FAISS 3번째로 대체)
|
||||
|
||||
Step 3: 블록 재구성 + 텍스트 편집 (Kei API 5회)
|
||||
각 topic마다:
|
||||
3개 후보를 컨테이너 크기에 맞게 재구성
|
||||
(폰트/패딩/항목수/레이아웃 — Phase O 동적 계산)
|
||||
Kei 편집자에게 "3개 블록 슬롯 각각에 맞게 텍스트 편집" 1회 호출
|
||||
|
||||
Step 4: 실제 렌더링 (Selenium 15회, 병렬)
|
||||
15개 후보 블록을 각각 컨테이너 안에서 렌더링
|
||||
스크린샷 캡처 (base64 → .png 파일 저장)
|
||||
|
||||
Step 5: Kei 최종 선택 (Kei API 3회)
|
||||
같은 컨테이너의 topic을 묶어서 제시:
|
||||
1회차: 배경 (topic 1+2) — 스크린샷 6개, "서로 다른 블록 선택" 명시
|
||||
2회차: 본심 (topic 3) + 첨부 (topic 4) — 스크린샷 6개
|
||||
3회차: 결론 (topic 5) — 스크린샷 3개
|
||||
Kei가 각 topic별 "당초 목적에 가장 적합한 것" 선택
|
||||
전부 안 맞으면 정확도 가장 높은 것으로 배치
|
||||
|
||||
Step 6: 전체 슬라이드 조립 (기존 그대로)
|
||||
선택된 블록으로 4단계 (CSS 조정 + 렌더링)
|
||||
Phase L 측정
|
||||
5단계 Kei 검수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 비용
|
||||
|
||||
| 항목 | 횟수 | 시간 |
|
||||
|------|------|------|
|
||||
| Kei API (기존 1A+1B+3+5) | 4회 | ~4분 |
|
||||
| Step 2: Opus 추천 배치 | 1회 | ~30초 |
|
||||
| Step 3: 텍스트 편집 배치 | 5회 | ~2.5분 |
|
||||
| Step 5: 최종 선택 | 3회 | ~1.5분 |
|
||||
| Step 4: Selenium 렌더링 | 15회 (병렬) | ~0.8초 |
|
||||
| Step 6: CSS + 검수 | 2회 | ~2분 |
|
||||
| **총합** | **~15회** | **~10.5분** |
|
||||
|
||||
---
|
||||
|
||||
## 하드코딩 없음 검증
|
||||
|
||||
| 항목 | 하드코딩? | 근거 |
|
||||
|------|---------|------|
|
||||
| 후보 수 3개 (FAISS 2 + Opus 1) | 구조적 설계 | 블록 추가해도 변경 불필요 |
|
||||
| 블록 재구성 | 동적 계산 | Phase O `_determine_typography()`, `_calculate_block_constraints()` |
|
||||
| 텍스트 분량 | 동적 계산 | 컨테이너 제약에서 자동 산출 |
|
||||
| 최종 선택 | Kei 판단 | 코드가 선택하지 않음 |
|
||||
| 컨테이너 크기 | 동적 계산 | Kei 비중에서 자동 산출 |
|
||||
| 최종 선택 묶음 (2+2+1) | 동적 그룹핑 | 컨테이너 역할별 자동 |
|
||||
| 배경 topic 수 처리 | Kei 판단 | Kei가 비중 판단 시 topic 수 고려. 코드가 비중 덮어쓰지 않음 |
|
||||
|
||||
---
|
||||
|
||||
## 기존 코드 변경 범위
|
||||
|
||||
| 파일 | 변경 | 신규/수정 |
|
||||
|------|------|---------|
|
||||
| `pipeline.py` | Step A-2 단일 선택 → Step 2~5 루프로 교체 | 수정 |
|
||||
| `block_search.py` | topic별 상위 2개 반환 함수 | 추가 |
|
||||
| `design_director.py` | 전체 topic 배치 Opus 추천 함수 | 추가 |
|
||||
| `renderer.py` | 컨테이너 감싼 단독 블록 렌더링 함수 | 추가 |
|
||||
| `slide_measurer.py` | 단독 블록 스크린샷 캡처 함수 + .png 저장 | 추가 |
|
||||
| `kei_client.py` | 3후보 스크린샷 비교 선택 프롬프트 함수 | 추가 |
|
||||
| `content_editor.py` | 3블록 한꺼번에 편집 프롬프트 | 수정 |
|
||||
| `space_allocator.py` | 기존 그대로 (재사용) | 변경 없음 |
|
||||
| `catalog.yaml` | 기존 그대로 | 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 중간 산출물 추가
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `step2_candidates.json` | topic별 후보 3개 (FAISS 2 + Opus 1) |
|
||||
| `step3_edited_variants.json` | topic별 3개 블록의 편집된 텍스트 |
|
||||
| `step4_candidate_screenshots/` | 15개 .png 스크린샷 |
|
||||
| `step5_selection.json` | Kei 최종 선택 결과 (topic별 선택 블록 + 이유) |
|
||||
|
||||
---
|
||||
|
||||
## 충돌/회귀 검증
|
||||
|
||||
| 체크 항목 | 결과 |
|
||||
|----------|------|
|
||||
| 기존 함수 호환 | ✅ render_standalone_block, search_blocks, measure_rendered_heights, capture_slide_screenshot 전부 존재 |
|
||||
| Phase O 컨테이너 시스템 | ✅ 그대로 사용. calculate_container_specs, finalize_block_specs 재사용 |
|
||||
| Phase N fallback 제거 | ✅ 회귀 없음. fallback 코드 재도입 안 함 |
|
||||
| Phase N 무한 재시도 | ✅ 유지. 모든 Kei API 호출에 적용 |
|
||||
| 하드코딩 | ✅ 없음. 모든 수치가 동적 계산 또는 Kei 판단 |
|
||||
| EDITOR_PROMPT | ✅ 하드코딩 분량 이미 제거됨 |
|
||||
189
docs/history/IMPROVEMENT-PHASE-Q-FIX.md
Normal file
189
docs/history/IMPROVEMENT-PHASE-Q-FIX.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Phase Q 수정 계획: 정확한 문제 분석 + 정확한 해법
|
||||
|
||||
> 작성일: 2026-03-30
|
||||
> 상태: 분석 완료, 수정 대기
|
||||
> 근거: Phase Q 5차 테스트 결과 + Phase P/이전 run 비교 분석
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제의 정확한 진단
|
||||
|
||||
### Phase Q에서 바꿔야 했던 것 vs 실제로 바꾼 것
|
||||
|
||||
| 구분 | 바꿔야 했던 것 | 실제로 바꾼 것 | 결과 |
|
||||
|------|-------------|-------------|------|
|
||||
| 블록 선택 | FAISS+Opus 환각 → 제약 기반 | ✅ 제대로 바꿈 | 블록 선택 개선 |
|
||||
| 글자수 예산 | 없음 → 사전 계산 | ✅ 제대로 바꿈 | overflow 감소 |
|
||||
| 텍스트 채우기 | **바꾸면 안 됐음** | ❌ fill_candidates → fill_content로 교체 | **텍스트 품질 파괴** |
|
||||
| overflow 조정 | 피드백 루프 → 수학적 조정 | ✅ 글루 모델 추가 | 작동 |
|
||||
| 품질 게이트 | 없음 → 비전 모델 | ✅ 추가 | 작동 |
|
||||
|
||||
**핵심 오류: 텍스트 채우기 방식을 바꿔서는 안 됐다.**
|
||||
|
||||
### Phase P의 텍스트 채우기 (잘 작동함)
|
||||
|
||||
```
|
||||
fill_candidates(): topic 1개 + 후보 블록 3개 → Kei API 1회 호출
|
||||
↓
|
||||
Kei가 topic의 source_data를 보고 블록 슬롯에 맞게 풍부하게 채움
|
||||
↓
|
||||
결과: 604자 (DX vs BIM 상세 비교), 사례 2건, 출처 포함
|
||||
```
|
||||
|
||||
**Phase P `step3_edited_variants.json` 실제 결과:**
|
||||
- topic 2 (사례): 2건 모두 포함, 불릿 상세 (스마트건설방안 + 제7차 기본계획)
|
||||
- topic 3 (핵심): DX vs BIM 8개 항목 비교, 604자
|
||||
- topic 4 (용어): 3개 용어 풀 정의 + 출처 (국토교통부, 2020 / IBM, 2011)
|
||||
- topic 5 (결론): 원문 그대로 ("BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서...")
|
||||
|
||||
### Phase Q의 텍스트 채우기 (파괴됨)
|
||||
|
||||
```
|
||||
fill_content(): 전체 블록 5-6개를 한 번에 → Kei API 1회 호출
|
||||
↓
|
||||
Kei가 한 번에 5-6개 블록을 처리하느라 각 블록을 축약
|
||||
↓
|
||||
결과: topic당 30-50자 수준으로 과축약
|
||||
```
|
||||
|
||||
**Phase Q `step3_fill_content.json` 실제 결과:**
|
||||
- topic 2 (사례): 1건만 (제7차 기본계획 누락)
|
||||
- topic 3 (핵심): "상위개념" 한 단어 수준 (604자 → ~20자)
|
||||
- topic 4 (용어): 수식어 삭제, 출처 없음
|
||||
- topic 5 (결론): 원문 보존 (이건 OK)
|
||||
|
||||
### 왜 이렇게 됐나
|
||||
|
||||
`fill_content()`는 원래 Phase O 이전부터 있던 함수로, **전체 슬라이드의 모든 블록을 한 번에 처리**한다.
|
||||
한 번의 API 호출에 블록 5-6개의 슬롯 정보를 모두 담으니, 각 블록에 할당되는 응답 분량이 자연스럽게 줄어든다.
|
||||
|
||||
반면 `fill_candidates()`는 **topic 1개씩 개별 호출**이므로, Kei가 해당 topic에 집중하여 풍부한 텍스트를 생성한다.
|
||||
|
||||
**이건 프롬프트 문제가 아니라 호출 구조 문제.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 정확한 해법
|
||||
|
||||
### 원칙
|
||||
|
||||
```
|
||||
Phase Q가 개선한 것: 블록 선택 (FAISS → 제약 기반) ← 유지
|
||||
Phase P에서 가져올 것: 텍스트 채우기 (topic별 개별 호출) ← 복원
|
||||
합치면: 제약 기반 블록 선택 + topic별 풍부한 텍스트 채우기
|
||||
```
|
||||
|
||||
### 수정 대상: pipeline.py의 Step 3
|
||||
|
||||
**현재 (Phase Q — 잘못된 방식):**
|
||||
```python
|
||||
# 전체 블록을 한 번에 fill_content() 호출
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
```
|
||||
|
||||
**수정 (Phase P 방식 복원 + Phase Q 블록 선택 유지):**
|
||||
```python
|
||||
# topic별로 개별 호출 — Phase P의 fill_candidates() 방식
|
||||
for topic in topics:
|
||||
tid = topic.get("id")
|
||||
block = selected_blocks.get(tid)
|
||||
if not block:
|
||||
continue
|
||||
|
||||
# Phase Q에서 선택된 단일 블록을 리스트로 감싸서 fill_candidates 호출
|
||||
await fill_candidates(content, topic, [block], analysis)
|
||||
```
|
||||
|
||||
### 변경 파일 + 범위
|
||||
|
||||
| 파일 | 변경 | 범위 |
|
||||
|------|------|------|
|
||||
| `src/pipeline.py` | Step 3에서 `fill_content()` → topic별 `fill_candidates()` 호출로 교체 | ~15줄 교체 |
|
||||
| `src/content_editor.py` | `fill_candidates()`에 Phase Q 글자수 예산(`_char_budget`) 전달 추가 | ~5줄 추가 |
|
||||
| `src/content_editor.py` | EDITOR_PROMPT 변경 **롤백** — Phase P 원본으로 복원 | 프롬프트 복원 |
|
||||
|
||||
### 건드리지 않는 것
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `src/block_selector.py` | Phase Q 블록 선택 — 잘 작동하고 있음 |
|
||||
| `src/space_allocator.py` | 예산 계산 + 글루 모델 — 잘 작동하고 있음 |
|
||||
| `src/kei_client.py` | Q-4 블록 선택 + Q-6 품질 게이트 — 잘 작동하고 있음 |
|
||||
| `templates/catalog.yaml` | Phase Q 메타데이터 — 잘 작동하고 있음 |
|
||||
| `personas/` | Kei persona — 절대 수정 금지 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구체적 수정 내용
|
||||
|
||||
### 3-A: pipeline.py Step 3 교체
|
||||
|
||||
```python
|
||||
# 현재 (삭제 대상)
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
|
||||
# 수정 (Phase P 방식 복원)
|
||||
from src.content_editor import fill_candidates
|
||||
|
||||
yield {"event": "progress", "data": "3/5 Kei 편집자가 텍스트를 정리 중..."}
|
||||
|
||||
for topic in topics:
|
||||
tid = topic.get("id")
|
||||
block = selected_blocks.get(tid)
|
||||
if not block:
|
||||
continue
|
||||
|
||||
# fill_candidates는 topic 1개 + 블록 리스트를 받으므로 [block]으로 감쌈
|
||||
await fill_candidates(content, topic, [block], analysis)
|
||||
|
||||
logger.info(
|
||||
f"[Q Step 3] topic {tid}: {block['type']} → "
|
||||
f"data={'있음' if block.get('data') else '없음'}"
|
||||
)
|
||||
```
|
||||
|
||||
### 3-B: fill_candidates()에 Phase Q 예산 전달
|
||||
|
||||
`fill_candidates()`의 컨테이너 제약 전달 부분에 `_char_budget`도 포함:
|
||||
|
||||
```python
|
||||
# fill_candidates() 내부 — 이미 _container_height_px 전달하는 부분에 추가
|
||||
char_budget = block.get("_char_budget", {})
|
||||
if char_budget:
|
||||
section += (
|
||||
f"\n ★ 글자수 예산 (하드 제약):"
|
||||
f"\n 총 글자: {char_budget.get('total_chars', '제한 없음')}자"
|
||||
f"\n 최대 항목: {char_budget.get('max_items', '제한 없음')}개"
|
||||
f"\n 항목당 글자: {char_budget.get('chars_per_item', '제한 없음')}자"
|
||||
)
|
||||
```
|
||||
|
||||
### 3-C: EDITOR_PROMPT 롤백
|
||||
|
||||
Phase Q에서 5번 수정한 EDITOR_PROMPT를 **Phase P 원본 기반으로 복원**.
|
||||
단, Phase Q의 핵심 규칙 2개만 추가:
|
||||
1. "글자수 예산(★) 초과 금지"
|
||||
2. "source_data가 있으면 그것을 우선 사용"
|
||||
|
||||
---
|
||||
|
||||
## 4. 기대 효과
|
||||
|
||||
| 지표 | Phase P (20점) | Phase Q 현재 | Phase Q 수정 후 (예상) |
|
||||
|------|---------------|-------------|---------------------|
|
||||
| 블록 선택 | 3종류, 유령 5개 | 5종류, 유령 0개 | 5종류, 유령 0개 (유지) |
|
||||
| 텍스트 품질 | 풍부 (604자) | 축약 (~30자) | **풍부 (Phase P 수준 복원)** |
|
||||
| overflow | 213px | 0~45px | 예산 제약으로 방지 |
|
||||
| 사례 수 | 2건 | 1건 | **2건 (복원)** |
|
||||
| 용어 정의 | 풀 버전 | 축약 | **풀 버전 + 출처 (복원)** |
|
||||
| 의미 왜곡 | 있음 (순차↔포함) | 없음 | 없음 (유지) |
|
||||
| 처리 시간 | ~40분 | ~6분 | ~8분 (topic별 호출 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 교훈
|
||||
|
||||
1. **작동하는 것을 바꾸지 마라.** Phase P의 텍스트 채우기는 잘 작동했다. Phase Q에서 바꿀 이유가 없었다.
|
||||
2. **프롬프트 탓을 하기 전에 호출 구조를 확인하라.** 5번 프롬프트를 수정했지만, 문제는 "한 번에 6개 블록 요청"이라는 호출 구조였다.
|
||||
3. **이전 결과물과 비교하라.** `step3_edited_variants.json`(Phase P)과 `step3_fill_content.json`(Phase Q)을 처음부터 비교했으면 원인을 즉시 찾았을 것이다.
|
||||
4. **조사 결과를 적용할 때, 기존에 잘 작동하는 부분은 보존하라.** "계산 먼저, AI 판단 나중에"는 블록 선택에 적용할 원칙이었지, 텍스트 채우기에 적용할 원칙이 아니었다.
|
||||
531
docs/history/IMPROVEMENT-PHASE-Q.md
Normal file
531
docs/history/IMPROVEMENT-PHASE-Q.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템
|
||||
|
||||
> 작성일: 2026-03-28
|
||||
> 상태: 설계 확정 (사용자 승인 완료, 실행 대기)
|
||||
> 선행 완료: Phase O (컨테이너 기반 레이아웃), Phase P (다후보 렌더링 비교)
|
||||
|
||||
---
|
||||
|
||||
## 배경: Phase P 실행 결과 분석
|
||||
|
||||
Phase P를 실행한 결과(run `1774599277829`) 최종 슬라이드 품질이 **20/100점**으로 평가됨.
|
||||
|
||||
### 발견된 근본 문제 5가지
|
||||
|
||||
| # | 근본 원인 | 증상 |
|
||||
|---|----------|------|
|
||||
| R1 | FAISS 텍스트 임베딩이 시각 블록을 매칭하지 못함 | "hierarchy" 관계인데 venn-diagram 대신 comparison-2col 선택 |
|
||||
| R2 | Opus 추천에 catalog 검증 없음 | 존재하지 않는 블록 5개 환각 (arrow-flow, hierarchy-tree 등) |
|
||||
| R3 | overflow 해소 실패 시 출력 차단/재배치 없음 | 배경 117px에 330px 콘텐츠 → 겹침 상태로 출력 |
|
||||
| R4 | 블록 중복 사용 제한 없음 | 5개 topic에 3종류 블록만 사용 |
|
||||
| R5 | 공간 배분이 일방향 | "안 맞아도 강제" — 배경 20%에 topic 2개 우겨넣기 |
|
||||
|
||||
### Phase P 접근법의 구조적 문제
|
||||
|
||||
```
|
||||
Phase P: 3후보 렌더링 → 스크린샷 비교 → 선택
|
||||
문제점: 15번 렌더링 + 15번 AI 호출 → 40분 소요, 10개 폐기
|
||||
```
|
||||
|
||||
**업계 조사 결과**, 다후보 렌더링 비교 방식은 어떤 상용/오픈소스 도구도 사용하지 않음.
|
||||
- Beautiful.ai: 규칙 엔진이 결정론적으로 배치 (AI는 콘텐츠만)
|
||||
- Canva: 템플릿 검색 1개 → 커스터마이징
|
||||
- PPTAgent: 참조 기반 편집 액션으로 1개 생성
|
||||
|
||||
**핵심 인사이트:** 블록 유형 선택은 렌더링 전에 결정할 수 있는 문제.
|
||||
콘텐츠의 relation_type(계층/비교/정의/프로세스)으로 적합한 블록 카테고리가 결정됨.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**"계산 먼저, AI 판단 나중에, 렌더링은 검증만"**
|
||||
|
||||
```
|
||||
Beautiful.ai에서: AI는 콘텐츠만, 레이아웃은 규칙 엔진이 결정론적으로
|
||||
Napkin.ai에서: relation_type → 시각화 유형 자동 매핑
|
||||
학술 연구에서: 글자수 예산을 사전 계산하여 AI에 제약으로 전달
|
||||
VASCAR에서: 렌더링 → 비전 모델 검증 → 교정 루프
|
||||
```
|
||||
|
||||
### 블록의 정체 재정의
|
||||
|
||||
```
|
||||
블록 = 시각 패턴 (구조) ← 제목+본문이 세로 나열, 원이 겹침, 좌우 비교 등
|
||||
블록 ≠ 고정 크기 컴포넌트 ← "제목 1줄 + 본문 1줄"이 아님
|
||||
|
||||
컨테이너가 크기를 결정:
|
||||
같은 card-numbered라도
|
||||
- 352px 컨테이너 → 항목 5개, 14px, 항목당 120자
|
||||
- 117px 컨테이너 → 항목 2개, 12px, 항목당 40자
|
||||
- 58px 컨테이너 → 항목 1개, 10px, 항목당 20자
|
||||
|
||||
각 블록에는 "최소 생존 크기"가 존재:
|
||||
venn-diagram: 최소 ~150px (원이 의미 있으려면)
|
||||
card-numbered: 최소 ~55px (항목 1개)
|
||||
banner-gradient: 최소 ~40px (텍스트 1줄)
|
||||
divider-text: 최소 ~25px (선 + 텍스트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새 프로세스 vs 현재 프로세스
|
||||
|
||||
```
|
||||
[현재 — Phase P] [Phase Q]
|
||||
|
||||
1. Kei 분석 (topics, weights) 1. Kei 분석 (동일)
|
||||
2. 컨테이너 계산 (weight→px) 2. 컨테이너 계산 (동일)
|
||||
|
||||
3. FAISS 2개 + Opus 1개 = 3후보 3. relation_type → 블록 카테고리 (코드)
|
||||
4. 3후보 × 5topics = 15개 텍스트 편집 → 컨테이너 제약 필터링 (코드)
|
||||
5. 15개 Selenium 렌더링 + 스크린샷 → 글자수 예산 계산 (코드)
|
||||
6. Kei 스크린샷 비교 → 5개 선택 → Kei에게 2-3개 후보 제시 → 1개 선택 (AI 1회)
|
||||
7. 조립 → 렌더링
|
||||
8. Selenium 측정 → overflow 발견 4. 텍스트 편집 (예산 포함, AI 5회)
|
||||
9. 트림 → 재편집 → 재측정 5. 렌더링 1회 + Selenium 검증
|
||||
10. Kei 최종 리뷰 6. 수학적 조정 (overflow 시, AI 없음)
|
||||
7. 비전 모델 품질 게이트
|
||||
|
||||
API 호출: ~25회 API 호출: ~8회
|
||||
Selenium: ~17회 Selenium: ~2회
|
||||
소요: ~40분 소요: ~8-12분
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실행 스텝 상세
|
||||
|
||||
### Q-1: catalog.yaml에 블록 메타데이터 보강
|
||||
|
||||
**현재 catalog.yaml 구조:**
|
||||
```yaml
|
||||
- id: venn-diagram
|
||||
height_cost: large
|
||||
when: "관계, 포함, 교집합"
|
||||
not_for: "순서, 흐름"
|
||||
```
|
||||
|
||||
**추가할 필드:**
|
||||
```yaml
|
||||
- id: venn-diagram
|
||||
height_cost: large
|
||||
min_height_px: 150 # ★ 최소 생존 크기
|
||||
relation_types: # ★ 적합한 관계 유형
|
||||
- hierarchy
|
||||
- inclusion
|
||||
category: visuals # ★ 블록 카테고리 (명시적)
|
||||
max_items: 5 # ★ 최대 항목 수
|
||||
min_items: 2 # ★ 최소 항목 수
|
||||
when: "관계, 포함, 교집합"
|
||||
not_for: "순서, 흐름"
|
||||
```
|
||||
|
||||
**작업 내용:**
|
||||
- 38개 블록 전체에 `min_height_px`, `relation_types`, `category`, `max_items`, `min_items` 추가
|
||||
- `min_height_px`는 Selenium 실측으로 검증 (최소 콘텐츠로 렌더링하여 측정)
|
||||
- **파일:** `templates/catalog.yaml`
|
||||
- **의존성:** 없음
|
||||
- **소요:** 2시간
|
||||
|
||||
---
|
||||
|
||||
### Q-2: relation_type → 블록 카테고리 매핑 엔진
|
||||
|
||||
**구현:**
|
||||
```python
|
||||
# src/block_selector.py (신규)
|
||||
|
||||
RELATION_TO_CATEGORIES: dict[str, list[str]] = {
|
||||
"hierarchy": ["visuals"], # venn, circle, keyword-circle
|
||||
"inclusion": ["visuals"], # venn
|
||||
"comparison": ["tables", "emphasis"], # compare-2col-split, comparison-2col
|
||||
"sequence": ["visuals"], # process-horizontal, flow-arrow
|
||||
"cause_effect": ["emphasis"], # callout-warning, callout-solution
|
||||
"definition": ["cards"], # card-numbered, card-icon-desc
|
||||
"none": ["emphasis", "cards"], # dark-bullet-list, quote-big-mark
|
||||
}
|
||||
|
||||
def select_block_candidates(
|
||||
topic: dict,
|
||||
container_spec: ContainerSpec,
|
||||
catalog: dict,
|
||||
used_blocks: set[str], # 슬라이드 내 이미 사용된 블록
|
||||
) -> list[dict]:
|
||||
"""결정론적으로 블록 후보를 필터링한다. AI 호출 없음."""
|
||||
|
||||
relation = topic.get("relation_type", "none")
|
||||
categories = RELATION_TO_CATEGORIES.get(relation, ["emphasis", "cards"])
|
||||
|
||||
per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))
|
||||
candidates = []
|
||||
|
||||
for block in catalog["blocks"]:
|
||||
# 1. 카테고리 필터
|
||||
if block["category"] not in categories:
|
||||
continue
|
||||
|
||||
# 2. 최소 크기 필터
|
||||
if block["min_height_px"] > per_topic_px:
|
||||
continue
|
||||
|
||||
# 3. height_cost 필터
|
||||
if HEIGHT_COST_RANK[block["height_cost"]] > HEIGHT_COST_RANK[container_spec.max_height_cost]:
|
||||
continue
|
||||
|
||||
# 4. sidebar 시각 블록 제한
|
||||
if container_spec.zone == "sidebar" and block["category"] == "visuals":
|
||||
continue
|
||||
|
||||
# 5. 중복 사용 제한
|
||||
if block["id"] in used_blocks:
|
||||
continue
|
||||
|
||||
candidates.append(block)
|
||||
|
||||
return candidates # 보통 2-4개
|
||||
```
|
||||
|
||||
- **파일:** 신규 `src/block_selector.py`
|
||||
- **의존성:** Q-1 (catalog 메타데이터)
|
||||
- **소요:** 3시간
|
||||
|
||||
---
|
||||
|
||||
### Q-3: 글자수 예산 계산 엔진
|
||||
|
||||
**구현:**
|
||||
```python
|
||||
# src/space_allocator.py에 추가
|
||||
|
||||
def calculate_char_budget(
|
||||
block_type: str,
|
||||
container_spec: ContainerSpec,
|
||||
catalog: dict,
|
||||
) -> dict:
|
||||
"""블록이 컨테이너에서 수용 가능한 최대 글자수를 계산한다."""
|
||||
block_def = catalog["blocks"][block_type]
|
||||
per_topic_px = container_spec.height_px // max(1, len(container_spec.topic_ids))
|
||||
|
||||
# 폰트 크기 결정 (컨테이너 크기에 따라)
|
||||
font_size = _select_font_size(per_topic_px)
|
||||
|
||||
# 구조적 오버헤드 (제목, 패딩, 간격)
|
||||
structural = _estimate_structural_overhead(block_type, font_size)
|
||||
content_height = per_topic_px - structural
|
||||
|
||||
# 한국어 줄당 글자수
|
||||
chars_per_line = int(container_spec.width_px * 0.85 / font_size)
|
||||
line_height_px = font_size * 1.6 # 한국어 line-height
|
||||
available_lines = max(1, int(content_height / line_height_px))
|
||||
|
||||
# 항목 수 제한
|
||||
max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄
|
||||
max_items = min(max_items_by_space, block_def.get("max_items", 10))
|
||||
|
||||
return {
|
||||
"total_chars": available_lines * chars_per_line,
|
||||
"max_items": max_items,
|
||||
"chars_per_item": (available_lines * chars_per_line) // max(1, max_items),
|
||||
"font_size_px": font_size,
|
||||
"available_lines": available_lines,
|
||||
}
|
||||
|
||||
def _select_font_size(container_height_px: int) -> float:
|
||||
"""컨테이너 높이에 따른 적정 폰트 크기."""
|
||||
if container_height_px >= 300:
|
||||
return 15.0
|
||||
elif container_height_px >= 150:
|
||||
return 13.0
|
||||
elif container_height_px >= 80:
|
||||
return 12.0
|
||||
else:
|
||||
return 10.0
|
||||
```
|
||||
|
||||
- **파일:** `src/space_allocator.py`
|
||||
- **의존성:** Q-1 (catalog 메타데이터)
|
||||
- **소요:** 2시간
|
||||
|
||||
---
|
||||
|
||||
### Q-4: Kei 블록 선택 프롬프트 재설계
|
||||
|
||||
**현재:** FAISS 2개 + Opus 1개 = 3후보를 15개 렌더링 후 스크린샷 비교
|
||||
**변경:** 코드가 필터링한 2-3개 후보를 Kei에게 제시, 1개 선택 (AI 1회)
|
||||
|
||||
```python
|
||||
# src/kei_client.py에 추가
|
||||
|
||||
BLOCK_SELECTION_PROMPT = """
|
||||
다음 topic에 가장 적합한 블록을 1개 선택하세요.
|
||||
|
||||
## Topic 정보
|
||||
- 제목: {title}
|
||||
- 목적: {purpose}
|
||||
- 관계 유형: {relation_type}
|
||||
- 핵심 콘텐츠 요약: {summary}
|
||||
|
||||
## 컨테이너 제약
|
||||
- 영역: {zone} ({role}, 비중 {weight}%)
|
||||
- 높이: {height_px}px, 너비: {width_px}px
|
||||
|
||||
## 후보 블록 (모두 이 컨테이너에 물리적으로 들어감)
|
||||
{candidates_description}
|
||||
|
||||
## 선택 기준
|
||||
1. 콘텐츠의 관계 유형({relation_type})을 가장 잘 시각화하는 블록
|
||||
2. 이 topic의 목적({purpose})에 가장 부합하는 표현 방식
|
||||
3. 글자수 예산 내에서 의미 전달이 가능한 블록
|
||||
|
||||
## 출력 (JSON)
|
||||
{{"selected_block": "블록 id", "reason": "선택 근거 1문장"}}
|
||||
"""
|
||||
```
|
||||
|
||||
- **파일:** `src/kei_client.py`
|
||||
- **의존성:** Q-2 (후보 필터링), Q-3 (예산 계산)
|
||||
- **소요:** 2시간
|
||||
|
||||
---
|
||||
|
||||
### Q-5: pipeline.py 재구성 — Phase P 로직 교체
|
||||
|
||||
**핵심 변경:** Phase P의 15-render 루프를 제거하고 Q-2/Q-3/Q-4 기반 단일 경로로 교체.
|
||||
|
||||
```python
|
||||
# pipeline.py 변경 개요
|
||||
|
||||
# Phase P 관련 코드 제거:
|
||||
# - search_candidates_per_topic() 호출
|
||||
# - _opus_batch_recommend() 호출
|
||||
# - fill_candidates() 15회 호출
|
||||
# - render_block_in_container() 15회 호출
|
||||
# - measure_candidate_block() 15회 호출
|
||||
# - select_best_candidate() 호출
|
||||
|
||||
# Phase Q 코드 추가:
|
||||
async def generate_slide(...):
|
||||
# Step 1-2: 동일 (Kei 분석 + 컨테이너 계산)
|
||||
|
||||
# Step 3: 블록 선택 (Phase Q)
|
||||
yield {"event": "progress", "data": "2/5 블록 선택 중..."}
|
||||
used_blocks = set()
|
||||
for topic in topics:
|
||||
# Q-2: 결정론적 후보 필터링
|
||||
candidates = select_block_candidates(topic, container_spec, catalog, used_blocks)
|
||||
|
||||
# Q-3: 각 후보의 글자수 예산 계산
|
||||
for c in candidates:
|
||||
c["budget"] = calculate_char_budget(c["id"], container_spec, catalog)
|
||||
|
||||
# Q-4: Kei 1회 호출로 최종 선택
|
||||
selected = await _retry_kei(select_block_for_topic, topic, candidates, container_spec)
|
||||
used_blocks.add(selected["block_id"])
|
||||
|
||||
# Step 4: 텍스트 편집 (예산 포함)
|
||||
yield {"event": "progress", "data": "3/5 텍스트 편집 중..."}
|
||||
# fill_content()에 budget 전달
|
||||
|
||||
# Step 5: 렌더링 1회 + 검증
|
||||
yield {"event": "progress", "data": "4/5 렌더링 + 검증 중..."}
|
||||
html = render_slide(layout_concept)
|
||||
measurement = measure_rendered_heights(html)
|
||||
|
||||
# Step 6: overflow 시 수학적 조정
|
||||
if has_overflow(measurement):
|
||||
html = apply_glue_compression(html, measurement) # AI 없음
|
||||
# 그래도 overflow면 font-size 축소 (이진 탐색)
|
||||
# 그래도 안 되면 해당 블록 텍스트 압축 (AI 1회)
|
||||
|
||||
# Step 7: 비전 모델 품질 게이트
|
||||
yield {"event": "progress", "data": "5/5 품질 검증 중..."}
|
||||
screenshot = capture_slide_screenshot(html)
|
||||
quality = await vision_quality_gate(screenshot, analysis)
|
||||
if not quality["passed"]:
|
||||
# 문제 블록만 교정 → 재렌더링 (최대 2회)
|
||||
```
|
||||
|
||||
- **파일:** `src/pipeline.py`
|
||||
- **의존성:** Q-2, Q-3, Q-4
|
||||
- **소요:** 4시간
|
||||
|
||||
---
|
||||
|
||||
### Q-6: 비전 모델 품질 게이트
|
||||
|
||||
**VASCAR 논문 기반 — 렌더링 → 스크린샷 → 비전 모델 평가 → 교정**
|
||||
|
||||
```python
|
||||
# src/kei_client.py에 추가
|
||||
|
||||
VISION_QUALITY_PROMPT = """
|
||||
이 슬라이드 스크린샷을 평가하세요.
|
||||
|
||||
## 체크리스트
|
||||
1. 모든 텍스트가 컨테이너 안에 있는가? (겹침/잘림 없음)
|
||||
2. 본심 영역(60%)이 시각적으로 가장 두드러지는가?
|
||||
3. 각 블록의 폰트가 읽을 수 있는 크기인가? (최소 10px)
|
||||
4. 블록 유형에 다양성이 있는가? (같은 블록 반복 아닌가)
|
||||
5. 한국어 비즈니스 프레젠테이션으로서 적절한가?
|
||||
|
||||
## 출력 (JSON)
|
||||
{
|
||||
"passed": true/false,
|
||||
"score": 0-100,
|
||||
"issues": ["문제 설명"],
|
||||
"fix_targets": [{"area": "body", "block_index": 0, "action": "shrink|replace|rewrite"}]
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
- **파일:** `src/kei_client.py`
|
||||
- **의존성:** Q-5 (파이프라인 통합)
|
||||
- **소요:** 2시간
|
||||
|
||||
---
|
||||
|
||||
### Q-7: overflow 수학적 조정 (LaTeX 글루 모델)
|
||||
|
||||
**AI 없이 코드만으로 overflow를 흡수하는 메커니즘.**
|
||||
|
||||
```python
|
||||
# src/space_allocator.py에 추가
|
||||
|
||||
@dataclass
|
||||
class GlueSpec:
|
||||
"""LaTeX 글루 모델 — 유연한 간격."""
|
||||
natural: float # 기본 간격 (px)
|
||||
stretch: float # 늘어날 수 있는 양 (px)
|
||||
shrink: float # 줄어들 수 있는 양 (px)
|
||||
|
||||
SPACING_GLUE = {
|
||||
"block_gap": GlueSpec(natural=20, stretch=4, shrink=12),
|
||||
"inner_gap": GlueSpec(natural=16, stretch=4, shrink=8),
|
||||
"title_gap": GlueSpec(natural=8, stretch=2, shrink=4),
|
||||
"padding": GlueSpec(natural=16, stretch=0, shrink=8),
|
||||
}
|
||||
|
||||
def apply_glue_compression(html: str, measurement: dict) -> str:
|
||||
"""overflow 시 간격을 축소하여 흡수한다. AI 호출 없음."""
|
||||
for container_name, data in measurement["containers"].items():
|
||||
if not data["overflowed"]:
|
||||
continue
|
||||
|
||||
excess = data["excess_px"]
|
||||
total_shrinkable = sum(g.shrink for g in SPACING_GLUE.values()) * len(data["blocks"])
|
||||
|
||||
if excess <= total_shrinkable:
|
||||
# 간격 축소로 해결 가능
|
||||
ratio = excess / total_shrinkable
|
||||
# CSS 변수 오버라이드 삽입
|
||||
html = inject_compressed_spacing(html, container_name, ratio)
|
||||
else:
|
||||
# 간격만으로 불충분 → 폰트 축소 시도
|
||||
html = try_font_reduction(html, container_name, excess - total_shrinkable)
|
||||
|
||||
return html
|
||||
```
|
||||
|
||||
- **파일:** `src/space_allocator.py`
|
||||
- **의존성:** 없음
|
||||
- **소요:** 3시간
|
||||
|
||||
---
|
||||
|
||||
### Q-8: 출력 차단 정책
|
||||
|
||||
**overflow 상태에서 결과를 내보내지 않는 안전장치.**
|
||||
|
||||
```python
|
||||
# src/pipeline.py에 추가
|
||||
|
||||
class SlideQualityError(Exception):
|
||||
"""슬라이드 품질이 최소 기준 미달."""
|
||||
|
||||
def validate_output(measurement: dict, quality_check: dict) -> None:
|
||||
"""최종 출력 전 품질 검증. 미달 시 예외 발생."""
|
||||
|
||||
# 1. 물리적 겹침 검사
|
||||
for name, container in measurement["containers"].items():
|
||||
if container["overflowed"] and container["excess_px"] > 10:
|
||||
raise SlideQualityError(
|
||||
f"컨테이너 '{name}'에서 {container['excess_px']}px overflow 미해결"
|
||||
)
|
||||
|
||||
# 2. 비전 모델 점수 검사
|
||||
if quality_check.get("score", 0) < 40:
|
||||
raise SlideQualityError(
|
||||
f"비전 품질 점수 {quality_check['score']}/100 — 최소 40점 미달"
|
||||
)
|
||||
```
|
||||
|
||||
- **파일:** `src/pipeline.py`
|
||||
- **의존성:** Q-6 (품질 게이트)
|
||||
- **소요:** 1시간
|
||||
|
||||
---
|
||||
|
||||
## 태스크 요약
|
||||
|
||||
| 스텝 | 내용 | 유형 | 파일 | 의존성 | 소요 |
|
||||
|------|------|------|------|--------|------|
|
||||
| Q-1 | catalog.yaml 메타데이터 보강 | 데이터 | `templates/catalog.yaml` | 없음 | 2h |
|
||||
| Q-2 | relation_type → 블록 카테고리 매핑 | 신규 코드 | `src/block_selector.py` | Q-1 | 3h |
|
||||
| Q-3 | 글자수 예산 계산 엔진 | 코드 추가 | `src/space_allocator.py` | Q-1 | 2h |
|
||||
| Q-4 | Kei 블록 선택 프롬프트 재설계 | 코드 수정 | `src/kei_client.py` | Q-2, Q-3 | 2h |
|
||||
| Q-5 | pipeline.py 재구성 (Phase P → Q) | 코드 수정 | `src/pipeline.py` | Q-2, Q-3, Q-4 | 4h |
|
||||
| Q-6 | 비전 모델 품질 게이트 | 신규 코드 | `src/kei_client.py` | Q-5 | 2h |
|
||||
| Q-7 | overflow 수학적 조정 (글루 모델) | 코드 추가 | `src/space_allocator.py` | 없음 | 3h |
|
||||
| Q-8 | 출력 차단 정책 | 코드 추가 | `src/pipeline.py` | Q-6 | 1h |
|
||||
|
||||
**의존 관계:**
|
||||
```
|
||||
Q-1 (catalog) ──┬──→ Q-2 (블록 필터) ──┐
|
||||
└──→ Q-3 (예산 계산) ──┼──→ Q-4 (Kei 선택) ──→ Q-5 (파이프라인) ──→ Q-6 (품질 게이트) ──→ Q-8 (출력 차단)
|
||||
│
|
||||
Q-7 (글루 모델) ←──────────────────────┘ (독립)
|
||||
```
|
||||
|
||||
**총 소요:** ~19시간 (병렬 작업 시 ~12시간)
|
||||
|
||||
---
|
||||
|
||||
## 기대 효과
|
||||
|
||||
| 지표 | Phase P (현재) | Phase Q (목표) |
|
||||
|------|---------------|---------------|
|
||||
| 슬라이드 품질 | 20/100 | 70-80/100 |
|
||||
| 처리 시간 | ~40분 | ~8-12분 |
|
||||
| API 호출 수 | ~25회 | ~8회 |
|
||||
| Selenium 호출 | ~17회 | ~2회 |
|
||||
| 유령 블록 | 발생 (5건) | 불가능 (catalog 검증) |
|
||||
| overflow 출력 | 허용 | 차단 |
|
||||
| 블록 다양성 | 3/38 사용 | relation_type 기반 자동 분산 |
|
||||
|
||||
---
|
||||
|
||||
## Phase Q 이후 방향
|
||||
|
||||
Phase Q가 70-80점을 달성하면, 80점 이상을 위해:
|
||||
|
||||
1. **디자인 참조 DB 구축** — 고품질 슬라이드 레퍼런스 수집 → PPTAgent식 참조 기반 생성
|
||||
2. **시각 임베딩 FAISS** — 블록 스크린샷을 임베딩하여 시각적 유사도 검색
|
||||
3. **LayoutPrompter식 동적 예제** — 과거 성공 슬라이드-콘텐츠 쌍을 few-shot으로 활용
|
||||
|
||||
이 방향들은 디자인 참조 DB가 축적된 후에 검토.
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료 (조사 기반)
|
||||
|
||||
| 출처 | 적용한 인사이트 |
|
||||
|------|---------------|
|
||||
| Beautiful.ai (상용) | AI는 콘텐츠만, 레이아웃은 규칙 엔진 |
|
||||
| Napkin.ai (상용) | NLP → 관계 유형 → 시각화 유형 자동 매핑 |
|
||||
| VASCAR (arXiv 2024) | 생성→렌더링→비전 모델 평가→교정, 훈련 불필요 |
|
||||
| LayoutPrompter (NeurIPS 2023) | 제로 훈련 동적 예제 선택 |
|
||||
| RALF (CVPR 2024 Oral) | 검색 증강 레이아웃 |
|
||||
| Atlassian 디자인 시스템 + LLM | CSS 변수 제약 → "10번째 세션 = 1번째 품질" |
|
||||
| DesignBench (2025) | LLM CSS 공간 추론 한계: 27.1% 정확도 |
|
||||
| LaTeX Box/Glue 모델 | 유연한 간격으로 overflow 흡수 |
|
||||
450
docs/history/IMPROVEMENT-PHASE-R-PRIME.md
Normal file
450
docs/history/IMPROVEMENT-PHASE-R-PRIME.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# Phase R': 접근 C 기반 — 블록 CSS 참고 + AI 구조 결정
|
||||
|
||||
> 작성일: 2026-03-30
|
||||
> 상태: 설계
|
||||
> 선행: Phase P(20점), Q(블록 선택 개선), R(실패 — 기존 구조 위에 패치)
|
||||
> 근거: C_reference.png + hybrid_simulation.png 시뮬레이션 검증
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase P, Q, R에서 무엇이 문제였는가
|
||||
|
||||
**P, Q, R 전부 같은 구조:**
|
||||
```
|
||||
블록을 선택한다 → 그 블록의 슬롯에 텍스트를 채운다
|
||||
```
|
||||
|
||||
이 구조의 근본 한계:
|
||||
- **블록이 구조를 결정한다.** 콘텐츠가 아니라 블록 템플릿이 HTML 구조를 고정.
|
||||
- topic 1개 = 블록 1개. 합침/분리 불가.
|
||||
- 38개 고정 템플릿에 없는 구조(포함 관계, Before→After 등)는 표현 불가.
|
||||
- 콘텐츠 전달 의도(expression_hint)가 HTML 구조에 반영되지 않음.
|
||||
|
||||
Phase Q에서 블록 선택 정확도를 올렸고, Phase R에서 variant를 추가했지만,
|
||||
**근본 구조("블록 선택 → 슬롯 채우기")는 P = Q = R 동일.**
|
||||
결과물도 동일 수준.
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 C: 무엇이 달라지는가
|
||||
|
||||
### 핵심 전환
|
||||
|
||||
```
|
||||
현재 (P=Q=R):
|
||||
블록이 구조를 결정 → 콘텐츠를 슬롯에 채움
|
||||
(블록 중심)
|
||||
|
||||
접근 C:
|
||||
콘텐츠가 구조를 결정 → 블록 CSS를 참고하여 HTML 생성
|
||||
(콘텐츠 중심)
|
||||
```
|
||||
|
||||
### "블록을 참고하여 만든다"의 정확한 의미
|
||||
|
||||
블록을 버리는 것이 아니다. 블록을 "선택"하는 것도 아니다.
|
||||
|
||||
| | 현재 (블록 선택) | 접근 C (블록 참고) |
|
||||
|---|---|---|
|
||||
| 블록의 역할 | HTML 구조를 결정하는 템플릿 | CSS 스타일(색상, 폰트, 배경, radius)의 참고 자료 |
|
||||
| 누가 구조를 결정 | 블록 템플릿 (Jinja2) | AI가 콘텐츠 전달 의도를 보고 결정 |
|
||||
| topic 합침/분리 | 불가 (1 topic = 1 블록) | 가능 (AI가 판단) |
|
||||
| 포함 관계 시각화 | 해당 블록이 없으면 불가 | AI가 큰 박스 안에 카드를 넣는 구조를 직접 생성 |
|
||||
| CSS 일관성 | 블록 템플릿이 보장 | 디자인 토큰 + 블록 CSS 참고로 보장 |
|
||||
|
||||
### C_reference.png에서 증명된 것
|
||||
|
||||
제가 수동으로 했던 판단:
|
||||
1. topic 1(문제제기) + topic 2(사례) → **1영역에 통합**, dark-bullet-list의 CSS 색상 사용
|
||||
2. 사례 2건 → **가로 2열 카드**, 같은 구조로 나란히
|
||||
3. topic 3(핵심) → **DX 큰 박스 안에 GIS/BIM/DT 카드** = 포함 관계 시각화
|
||||
4. "BIM ≠ DX" → **별도 강조 박스** (topic에 없는 요소를 추가)
|
||||
5. sidebar 용어 → **풀 정의 + 출처**, card-numbered의 CSS 스타일 참고
|
||||
|
||||
**이 판단들을 AI가 하는 것이 접근 C.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 프로세스 설계
|
||||
|
||||
### 기존 프로세스 (P=Q=R)
|
||||
|
||||
```
|
||||
1단계: Kei 분석 (topics, relation_type, expression_hint, page_structure)
|
||||
1.5단계: Kei 컨셉 구체화 (source_data)
|
||||
컨테이너 계산 (비중 → px)
|
||||
프리셋 선택 (sidebar-right 등)
|
||||
|
||||
2단계: 블록 선택 (block_selector → catalog → Kei 선택) ← 여기가 문제
|
||||
3단계: 텍스트 채우기 (fill_candidates → 슬롯에 텍스트) ← 여기가 문제
|
||||
|
||||
4단계: CSS 조정 + 렌더링
|
||||
검증: Selenium 측정
|
||||
품질 게이트: 비전 모델
|
||||
```
|
||||
|
||||
### 접근 C 프로세스
|
||||
|
||||
```
|
||||
1단계: Kei 분석 (동일)
|
||||
1.5단계: Kei 컨셉 구체화 (동일)
|
||||
컨테이너 계산 (동일)
|
||||
프리셋 선택 (동일)
|
||||
|
||||
2-3단계 통합: AI가 HTML 구조를 직접 생성 ← 여기가 바뀜
|
||||
입력: Kei 분석 결과 + 원본 텍스트 + 디자인 토큰 + 블록 CSS 참고 + 컨테이너 스펙
|
||||
출력: 각 컨테이너 영역의 HTML (슬라이드 body, sidebar, footer)
|
||||
|
||||
AI가 결정하는 것:
|
||||
- 각 topic을 어떤 구조로 보여줄지 (불릿? 비교? 포함관계? 카드?)
|
||||
- topic을 합칠지 분리할지
|
||||
- 핵심 메시지를 별도 강조할지
|
||||
- 텍스트를 어떻게 배치할지
|
||||
|
||||
AI가 참고하는 것:
|
||||
- 디자인 토큰 (CSS 변수 — 색상, 폰트, 간격)
|
||||
- 기존 블록의 CSS 패턴 (다크 배경, 카드 스타일, 배너 스타일 등)
|
||||
- 컨테이너 크기 (px)
|
||||
- expression_hint ("포함 관계 시각화", "프로세스 변화")
|
||||
- 예시 슬라이드 (few-shot)
|
||||
|
||||
4단계: 렌더링 (AI가 생성한 HTML을 슬라이드 프레임에 삽입)
|
||||
검증: Selenium 측정 (동일)
|
||||
품질 게이트: 비전 모델 (동일)
|
||||
```
|
||||
|
||||
### 변경되는 것 / 변경되지 않는 것
|
||||
|
||||
| 항목 | 변경 여부 |
|
||||
|------|----------|
|
||||
| 1단계 Kei 분석 | 변경 없음 |
|
||||
| 1.5단계 컨셉 구체화 | 변경 없음 |
|
||||
| 컨테이너 계산 (space_allocator) | 변경 없음 |
|
||||
| 프리셋 선택 | 변경 없음 |
|
||||
| **2단계 블록 선택 (block_selector)** | **제거** — AI가 직접 구조 결정 |
|
||||
| **3단계 슬롯 채우기 (fill_candidates)** | **제거** — AI가 HTML에 텍스트 직접 포함 |
|
||||
| 4단계 CSS 조정 | 변경 — AI 생성 HTML을 프레임에 삽입 |
|
||||
| Selenium 측정 | 변경 없음 |
|
||||
| 비전 모델 품질 게이트 | 변경 없음 |
|
||||
| slide-base.html | 변경 없음 |
|
||||
| tokens.css, base.css | 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 4. AI HTML 생성의 구체적 설계
|
||||
|
||||
### 4-1. AI에게 주는 입력
|
||||
|
||||
```
|
||||
1. 원본 콘텐츠 (MDX 텍스트 전체)
|
||||
2. Kei 분석 결과:
|
||||
- topics[] (id, title, purpose, relation_type, expression_hint, source_data)
|
||||
- page_structure (본심/배경/첨부/결론 비중)
|
||||
- core_message
|
||||
3. 컨테이너 스펙:
|
||||
- 각 역할별 높이(px), 너비(px)
|
||||
- 프리셋 (sidebar-right, two-column 등)
|
||||
4. 디자인 토큰 (tokens.css 전문)
|
||||
5. 블록 CSS 패턴 참고 (주요 블록의 CSS만 발췌):
|
||||
- 다크 배경 패턴 (.block-dark-bullets의 색상/배경)
|
||||
- 카드 패턴 (.cid-card의 border/radius/padding)
|
||||
- 배너 패턴 (.block-banner-grad의 gradient)
|
||||
- 비교 패턴 (.block-comparison의 좌우 분할)
|
||||
- 테이블 패턴 (.block-table-striped의 헤더/행)
|
||||
6. 예시 슬라이드 2-3개 (few-shot):
|
||||
- C_reference.png의 HTML
|
||||
- hybrid_simulation의 HTML
|
||||
- (다른 콘텐츠 예시)
|
||||
```
|
||||
|
||||
### 4-2. AI에게 요구하는 출력
|
||||
|
||||
```
|
||||
각 영역(body, sidebar, footer)의 HTML 조각.
|
||||
슬라이드 프레임(slide-base.html)에 삽입할 수 있는 형태.
|
||||
|
||||
{
|
||||
"body_html": "<div class=\"container-배경\">...</div><div class=\"container-본심\">...</div>",
|
||||
"sidebar_html": "<div class=\"sidebar-label\">용어 정의</div><div class=\"def-item\">...</div>...",
|
||||
"footer_html": "<div class=\"block-banner-grad\"><div class=\"bg-text\">...</div></div>"
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. AI HTML 생성 규칙 (프롬프트에 포함)
|
||||
|
||||
```
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 그대로 사용한다 (자유도 15-20). 축약은 공간 부족 시에만.
|
||||
2. 디자인 토큰(CSS 변수)만 사용한다. 하드코딩 색상/폰트 금지.
|
||||
3. 블록 CSS 패턴을 참고하되, 구조는 콘텐츠에 맞게 자유롭게 구성한다.
|
||||
4. 컨테이너 높이를 초과하지 않는다.
|
||||
5. expression_hint를 반드시 반영한다:
|
||||
- "포함 관계" → 큰 박스 안에 작은 카드
|
||||
- "프로세스 변화" → Before→After 구조
|
||||
- "독립 사례 나열" → 가로 카드 비교
|
||||
6. topic을 합치거나 분리할 수 있다 (page_structure의 역할 기준).
|
||||
7. 핵심 메시지(core_message)는 별도 강조 요소로 만들 수 있다.
|
||||
```
|
||||
|
||||
### 4-4. 품질 보장 (5중 방어)
|
||||
|
||||
```
|
||||
Layer 1: 디자인 토큰 제약 (CSS 변수만 사용)
|
||||
Layer 2: 컨테이너 크기 제약 (px 명시)
|
||||
Layer 3: HTML 정화 (nh3 — script 태그 등 제거)
|
||||
Layer 4: Selenium 측정 (overflow 감지)
|
||||
Layer 5: 비전 모델 품질 게이트 (시각적 평가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 스텝
|
||||
|
||||
| 스텝 | 내용 | 파일 | 비고 |
|
||||
|------|------|------|------|
|
||||
| R'-1 | 디자인 토큰 + 블록 CSS 패턴을 프롬프트용 텍스트로 추출 | 신규 `src/design_tokens.py` | tokens.css + 주요 블록 CSS 발췌 |
|
||||
| R'-2 | few-shot 예시 슬라이드 정리 (2-3개) | `data/examples/` | C_reference.html, hybrid_simulation.html |
|
||||
| R'-3 | AI HTML 생성 함수 구현 | 신규 `src/html_generator.py` | Kei 분석 + 토큰 + 예시 → HTML 생성 |
|
||||
| R'-4 | pipeline.py 2-3단계를 html_generator로 교체 | `src/pipeline.py` | block_selector, fill_candidates 호출 제거 |
|
||||
| R'-5 | 렌더러에 AI 생성 HTML 삽입 함수 추가 | `src/renderer.py` | 기존 render_slide와 별도로 render_slide_from_html 추가 |
|
||||
| R'-6 | HTML 정화 + 토큰 위반 검증 | 신규 `src/html_validator.py` | nh3 + tinycss2 |
|
||||
| R'-7 | 테스트 (2개 콘텐츠로 검증) | `scripts/test_phase_r_prime.py` | DX 이해 + DX 목표 |
|
||||
|
||||
### 의존 관계
|
||||
|
||||
```
|
||||
R'-1 (토큰 추출) ──→ R'-3 (HTML 생성)
|
||||
R'-2 (예시 정리) ──→ R'-3
|
||||
R'-3 ──→ R'-4 (파이프라인 교체) ──→ R'-7 (테스트)
|
||||
R'-3 ──→ R'-5 (렌더러 추가)
|
||||
R'-6 (검증) ←── 독립, R'-4와 병렬
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. pipeline.py 변경 상세 — 정확히 어디가 교체되는가
|
||||
|
||||
### 유지되는 코드 (1단계 ~ 컨테이너 계산: 줄 96~165)
|
||||
|
||||
```python
|
||||
# 줄 96~109: 1단계 Kei 분석 — 유지
|
||||
analysis = await _retry_kei(classify_content, content)
|
||||
_save_step(run_dir, "step1_analysis.json", analysis)
|
||||
|
||||
# 줄 111~120: 1.5단계 컨셉 구체화 — 유지
|
||||
analysis = await refine_concepts(content, analysis)
|
||||
_save_step(run_dir, "step1b_concepts.json", ...)
|
||||
|
||||
# 줄 122~140: 제목 중복 검증, 이미지 측정 — 유지
|
||||
|
||||
# 줄 142~164: 컨테이너 스펙 계산 — 유지
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
container_specs = calculate_container_specs(...)
|
||||
_save_step(run_dir, "step1c_containers.json", ...)
|
||||
```
|
||||
|
||||
### 제거되는 코드 (2-3단계: 줄 166~339)
|
||||
|
||||
```python
|
||||
# 줄 166~207: Phase Q 블록 선택 — 전체 제거
|
||||
# block_selector.select_block_candidates()
|
||||
# select_fallback_candidates()
|
||||
# calculate_budgets_for_candidates()
|
||||
|
||||
# 줄 209~257: Kei 블록 선택 — 전체 제거
|
||||
# select_block_for_topics()
|
||||
# selected_blocks 딕셔너리 생성
|
||||
# finalize_block_specs()
|
||||
|
||||
# 줄 259~298: layout_concept 조립 — 전체 제거
|
||||
# final_blocks 리스트, sidebar label, 역할 순서 배치
|
||||
|
||||
# 줄 300~339: fill_candidates 호출 — 전체 제거
|
||||
# topic별 fill_candidates()
|
||||
# 결과 검증
|
||||
```
|
||||
|
||||
### 교체되는 코드 (R'-4에서 신규 작성)
|
||||
|
||||
```python
|
||||
# 2-3단계 통합: AI HTML 생성 (줄 166~ 교체)
|
||||
yield {"event": "progress", "data": "2/5 슬라이드 HTML 생성 중..."}
|
||||
|
||||
from src.html_generator import generate_slide_html
|
||||
from src.html_validator import validate_html
|
||||
|
||||
# AI가 HTML 직접 생성
|
||||
generated = await generate_slide_html(
|
||||
content=content,
|
||||
analysis=analysis,
|
||||
container_specs=container_specs,
|
||||
preset=preset,
|
||||
)
|
||||
|
||||
# HTML 정화 + 토큰 위반 검증
|
||||
validated_html = validate_html(generated)
|
||||
|
||||
_save_step(run_dir, "step2_generated.json", {
|
||||
"body_html_length": len(generated.get("body_html", "")),
|
||||
"sidebar_html_length": len(generated.get("sidebar_html", "")),
|
||||
"footer_html_length": len(generated.get("footer_html", "")),
|
||||
})
|
||||
```
|
||||
|
||||
### 유지되는 코드 (4단계 이후: 줄 341~)
|
||||
|
||||
```python
|
||||
# 줄 341~350: 4단계 렌더링 — 유지하되 render_slide → render_slide_from_html로 변경
|
||||
html = render_slide_from_html(generated, analysis, preset)
|
||||
_save_step(run_dir, "step4_rendered.html", html)
|
||||
|
||||
# 줄 352~446: Selenium 측정, overflow 감지, 수학적 조정 — 유지
|
||||
# 단, overflow 시 텍스트 압축(fill_content 호출, 줄 442)은 제거
|
||||
# 대신 AI HTML 재생성 요청
|
||||
|
||||
# 줄 448~474: 비전 모델 품질 게이트 — 유지
|
||||
|
||||
# 줄 476~483: 이미지 삽입, final.html 저장 — 유지
|
||||
```
|
||||
|
||||
### _adjust_design (줄 490~589) — 제거 또는 변경
|
||||
|
||||
```
|
||||
현재: layout_concept의 블록별 텍스트 양을 보고 CSS 변수 조정
|
||||
R': AI가 HTML 생성 시 이미 CSS를 포함하므로, 별도 CSS 조정 단계 불필요
|
||||
→ 제거하거나, AI 생성 HTML에 대한 보조 조정으로 역할 축소
|
||||
```
|
||||
|
||||
### _review_balance, _apply_adjustments (줄 592~730) — 변경
|
||||
|
||||
```
|
||||
현재: 블록 기반 layout_concept를 검수/조정
|
||||
R': AI 생성 HTML을 검수. 조정 시 block_selector/fill_candidates가 아닌 html_generator 재호출
|
||||
줄 729의 fill_content() 호출 → 제거
|
||||
```
|
||||
|
||||
### _build_overflow_context, _convert_kei_judgment (줄 733~800) — 변경
|
||||
|
||||
```
|
||||
현재: layout_concept의 블록 데이터에서 overflow 컨텍스트 추출
|
||||
R': AI 생성 HTML에서 직접 추출하도록 변경
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 다른 파일에 대한 영향 (충돌/회귀 체크)
|
||||
|
||||
### 영향 없음 (건드리지 않음)
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `src/kei_client.py` — classify_content, refine_concepts | 1단계에서 호출. R'에서 변경 없음 |
|
||||
| `src/kei_client.py` — vision_quality_gate | 품질 게이트. R'에서 변경 없음 |
|
||||
| `src/space_allocator.py` — calculate_container_specs | 컨테이너 계산. R'에서 변경 없음 |
|
||||
| `src/design_director.py` — select_preset, LAYOUT_PRESETS | 프리셋 선택. R'에서 변경 없음 |
|
||||
| `src/slide_measurer.py` | Selenium 측정. R'에서 변경 없음 |
|
||||
| `src/config.py` | 설정. 변경 없음 |
|
||||
| `src/sse_utils.py` | SSE 스트리밍. 변경 없음 |
|
||||
| `src/image_utils.py` | 이미지 크기 측정. 변경 없음 |
|
||||
| `src/main.py` | FastAPI 엔드포인트. generate_slide 호출은 동일 |
|
||||
| `static/index.html` | 프론트엔드. 변경 없음 |
|
||||
| `templates/slide-base.html` | 슬라이드 프레임. 변경 없음 |
|
||||
| `static/tokens.css`, `static/base.css` | 디자인 토큰. 변경 없음 (프롬프트에서 읽기만 함) |
|
||||
| `templates/blocks/*.html` | 38개 블록. 변경 없음 (CSS 참고용으로만 유지) |
|
||||
| `templates/catalog.yaml` | 변경 없음 (프롬프트에서 CSS 패턴 인덱스로 참고만) |
|
||||
|
||||
### 변경되는 파일
|
||||
|
||||
| 파일 | 변경 내용 | 기존 코드 영향 |
|
||||
|------|----------|-------------|
|
||||
| `src/pipeline.py` | 줄 166~339 교체 (블록 선택+채우기 → html_generator 호출) | 2-3단계만 교체, 나머지 유지 |
|
||||
| `src/pipeline.py` | _adjust_design 제거 또는 축소 | 4단계 CSS 조정 불필요 |
|
||||
| `src/pipeline.py` | _apply_adjustments에서 fill_content 호출 제거 | overflow 시 html_generator 재호출 |
|
||||
| `src/renderer.py` | `render_slide_from_html()` 신규 함수 추가 | 기존 `render_slide()` 변경 없음 (하위 호환) |
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/html_generator.py` | 핵심 — Kei 분석 + 토큰 + 예시 → AI HTML 생성 |
|
||||
| `src/design_tokens.py` | tokens.css + 블록 CSS 패턴을 프롬프트용 텍스트로 추출 |
|
||||
| `src/html_validator.py` | nh3 정화 + CSS 토큰 위반 검증 |
|
||||
| `data/examples/C_reference.html` | few-shot 예시 1 |
|
||||
| `data/examples/hybrid_simulation.html` | few-shot 예시 2 |
|
||||
| `scripts/test_phase_r_prime.py` | R' 테스트 스크립트 |
|
||||
|
||||
### 호출하면 안 되는 것 (자기 검증)
|
||||
|
||||
| 함수 | 위치 | 이유 |
|
||||
|------|------|------|
|
||||
| `select_block_candidates()` | `block_selector.py` | 블록 선택 시스템 — R'에서 제거 |
|
||||
| `select_fallback_candidates()` | `block_selector.py` | 동일 |
|
||||
| `select_block_for_topics()` | `kei_client.py` | 블록 선택 AI — R'에서 제거 |
|
||||
| `fill_candidates()` | `content_editor.py` | 슬롯 채우기 — R'에서 제거 |
|
||||
| `fill_content()` | `content_editor.py` | 동일 |
|
||||
| `finalize_block_specs()` | `space_allocator.py` | 블록 스펙 — 블록 없으므로 불필요 |
|
||||
| `calculate_budgets_for_candidates()` | `space_allocator.py` | 블록 예산 — 블록 없으므로 불필요 |
|
||||
|
||||
### 호출해야 하는 것 (유지)
|
||||
|
||||
| 함수 | 위치 | 이유 |
|
||||
|------|------|------|
|
||||
| `classify_content()` | `kei_client.py` | 1단계 Kei 분석 |
|
||||
| `refine_concepts()` | `kei_client.py` | 1.5단계 컨셉 구체화 |
|
||||
| `calculate_container_specs()` | `space_allocator.py` | 컨테이너 px 계산 |
|
||||
| `select_preset()` | `design_director.py` | 프리셋 선택 |
|
||||
| `measure_rendered_heights()` | `slide_measurer.py` | Selenium 측정 |
|
||||
| `capture_slide_screenshot()` | `slide_measurer.py` | 스크린샷 캡처 |
|
||||
| `vision_quality_gate()` | `kei_client.py` | 비전 모델 품질 게이트 |
|
||||
| `generate_slide_html()` | `html_generator.py` (신규) | AI HTML 생성 |
|
||||
| `validate_html()` | `html_validator.py` (신규) | HTML 정화+검증 |
|
||||
| `render_slide_from_html()` | `renderer.py` (신규 함수) | AI HTML → 슬라이드 프레임 삽입 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 자기 검증 체크리스트
|
||||
|
||||
구현 시작 전 반드시 확인:
|
||||
|
||||
- [ ] **block_selector.py에서 블록을 "선택"하는 코드를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **fill_candidates/fill_content로 "슬롯에 텍스트를 채우는" 코드를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **finalize_block_specs/calculate_budgets_for_candidates를 호출하고 있는가?** → 호출하면 안 됨
|
||||
- [ ] **AI가 HTML 구조를 결정하고 있는가, 블록 템플릿이 구조를 결정하고 있는가?** → AI가 결정해야 함
|
||||
- [ ] **기존 코드를 "수정"하는 것인가, "교체"하는 것인가?** → 2-3단계는 교체
|
||||
- [ ] **C_reference.png 수준의 결과가 나올 수 있는 구조인가?** → topic 합침, 포함 관계, 핵심 메시지 분리가 가능해야 함
|
||||
- [ ] **_adjust_design에서 블록 기반 로직을 사용하고 있는가?** → 사용하면 안 됨
|
||||
- [ ] **_apply_adjustments에서 fill_content를 호출하고 있는가?** → 호출하면 안 됨
|
||||
|
||||
---
|
||||
|
||||
## 9. 기대 효과
|
||||
|
||||
| 지표 | Phase R (실패) | Phase R' (목표) |
|
||||
|------|---------------|----------------|
|
||||
| 결과물 수준 | 34점 (블록 선택+슬롯 한계) | C_reference.png 수준 (70점+) |
|
||||
| 구조 결정 주체 | 블록 템플릿 | AI (콘텐츠 기반) |
|
||||
| topic 합침/분리 | 불가 | 가능 |
|
||||
| 포함 관계 시각화 | 해당 블록 없으면 불가 | AI가 직접 구성 |
|
||||
| 원본 텍스트 보존 | 편집자 의존 (축약됨) | AI가 원본 직접 삽입 |
|
||||
| CSS 일관성 | 블록 템플릿 보장 | 디자인 토큰 + 블록 CSS 참고 + 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 회귀 방지 — Phase P=Q=R 반복 금지
|
||||
|
||||
### 이 Phase에서 절대 하면 안 되는 것
|
||||
|
||||
1. **block_selector.py의 select_block_candidates를 호출하면 안 됨** — 블록 "선택" 시스템 회귀
|
||||
2. **content_editor.py의 fill_candidates/fill_content를 호출하면 안 됨** — "슬롯 채우기" 회귀
|
||||
3. **catalog.yaml에서 블록을 매칭하면 안 됨** — 블록 매칭 회귀
|
||||
4. **variant를 추가하면 안 됨** — Phase R의 실패 패턴 회귀
|
||||
5. **"최소 변경"으로 기존 코드 위에 패치하면 안 됨** — P=Q=R 반복의 원인
|
||||
|
||||
### 이 Phase에서 반드시 해야 하는 것
|
||||
|
||||
1. **html_generator.py에서 AI가 HTML 구조를 직접 생성** — 콘텐츠가 구조를 결정
|
||||
2. **블록 CSS는 참고만** — "선택"이 아닌 "참고"
|
||||
3. **pipeline.py 줄 166~339를 교체** — 패치가 아닌 교체
|
||||
4. **C_reference.png와 동일 수준의 결과를 자동으로 생성** — 이것이 합격 기준
|
||||
297
docs/history/IMPROVEMENT-PHASE-R.md
Normal file
297
docs/history/IMPROVEMENT-PHASE-R.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Phase R: 하이브리드 블록 시스템 — 기존 블록 활용 + 변형 + 자유 생성
|
||||
|
||||
> 작성일: 2026-03-30
|
||||
> 상태: 설계 확정, 실행 대기
|
||||
> 선행: Phase Q (제약 기반 블록 선택 + 글자수 예산) 코드 완료
|
||||
> 근거: Phase Q 6차 테스트 + 하이브리드 시뮬레이션 검증
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경: 왜 Phase R이 필요한가
|
||||
|
||||
### Phase Q까지의 성과
|
||||
- ✅ 유령 블록 제거 (catalog 검증)
|
||||
- ✅ relation_type → 카테고리 결정론적 매핑
|
||||
- ✅ 글자수 예산 사전 계산
|
||||
- ✅ fill_candidates topic별 개별 호출 복원
|
||||
- ✅ 비전 모델 품질 게이트
|
||||
- ✅ overflow 수학적 조정 (LaTeX 글루 모델)
|
||||
|
||||
### Phase Q에서 해결 못한 문제
|
||||
- ❌ **블록이 콘텐츠 구조에 안 맞는 경우** — 38개 고정 블록 중 "정확히 맞는 것"이 없을 때 억지로 끼워 맞춤
|
||||
- ❌ **topic 1개 = 블록 1개 고정 규칙** — topic 합침/분리 불가
|
||||
- ❌ **콘텐츠 전달 의도 반영 부족** — "이해시키는 시각화"가 아닌 "텍스트 나열"
|
||||
|
||||
### 하이브리드 시뮬레이션으로 증명된 것
|
||||
DX 시행 목표 콘텐츠로 테스트한 결과:
|
||||
|
||||
| 블록 | 용도 | 활용 방식 | 블록 재사용률 |
|
||||
|------|------|----------|-------------|
|
||||
| `card-icon-desc` | 목표 3카드 | 기존 블록 **100%** | 그대로 |
|
||||
| `dark-bullet-list` | 프로세스 변화 | 기존 색상/구조 + **Before→After 변형** | 80% |
|
||||
| `divider-text` | 섹션 구분 | 기존 블록 **100%** | 그대로 |
|
||||
| `table-simple-striped` | 주체별 효과 | 기존 블록 **100%** | 그대로 |
|
||||
| `banner-gradient` | 결론 | 기존 블록 **100%** | 그대로 |
|
||||
|
||||
**결론: 38개 블록의 80%는 그대로 사용 가능. 빠진 것은 "변형 능력" 1가지.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 원칙
|
||||
|
||||
```
|
||||
블록 선택 → 맞는 블록 있으면 그대로 사용 (기존 Phase Q)
|
||||
→ 80% 맞는 블록 있으면 변형해서 사용 (Phase R 추가)
|
||||
→ 전혀 안 맞으면 디자인 토큰 안에서 자유 생성 (Phase R 추가)
|
||||
```
|
||||
|
||||
### 3단계 렌더링 우선순위
|
||||
|
||||
| 우선순위 | 방식 | 조건 | 품질 안정성 |
|
||||
|---------|------|------|-----------|
|
||||
| **1순위** | 기존 블록 그대로 | catalog에 정확히 맞는 블록이 있을 때 | 가장 높음 (검증된 템플릿) |
|
||||
| **2순위** | 기존 블록 변형 | 80% 맞는 블록 + variant로 보완 | 높음 (기존 CSS 기반) |
|
||||
| **3순위** | 디자인 토큰 기반 자유 생성 | 어떤 블록으로도 안 맞을 때 | 중간 (토큰 제약 + 검증 필요) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구체적 설계
|
||||
|
||||
### R-1: catalog.yaml에 variants 메타데이터 추가
|
||||
|
||||
기존 블록에 "변형 가능한 형태"를 정의한다. 변형은 기존 CSS를 유지하면서 내부 구조만 달라지는 것.
|
||||
|
||||
```yaml
|
||||
- id: dark-bullet-list
|
||||
category: emphasis
|
||||
# ... 기존 필드 유지 ...
|
||||
variants:
|
||||
- id: default
|
||||
description: 기존 불릿 나열
|
||||
template: blocks/emphasis/dark-bullet-list.html
|
||||
- id: before-after
|
||||
description: Before→After 2열 구조 (프로세스 변화)
|
||||
template: blocks/emphasis/dark-bullet-list--before-after.html
|
||||
when: "기존 방식 → 새 방식으로의 전환/변화를 보여줄 때"
|
||||
|
||||
- id: card-icon-desc
|
||||
category: cards
|
||||
variants:
|
||||
- id: default
|
||||
description: 아이콘 + 제목 + 설명 (기본)
|
||||
- id: compact
|
||||
description: 아이콘 축소, 설명 2줄 제한 (높이 부족 시)
|
||||
- id: horizontal
|
||||
description: 아이콘-제목-설명 가로 배치 (좁은 공간)
|
||||
|
||||
- id: comparison-2col
|
||||
category: emphasis
|
||||
variants:
|
||||
- id: default
|
||||
description: 좌우 텍스트 비교
|
||||
- id: cards-in-container
|
||||
description: 큰 박스 안에 카드 N개 (포함 관계 시각화)
|
||||
when: "hierarchy/inclusion — A 안에 B,C,D가 포함됨을 보여줄 때"
|
||||
```
|
||||
|
||||
- **파일:** `templates/catalog.yaml`
|
||||
- **변경:** 기존 블록에 `variants[]` 필드 추가
|
||||
- **변형 템플릿:** `blocks/{category}/{block-id}--{variant-id}.html` 파일 추가
|
||||
|
||||
### R-2: variant 템플릿 제작
|
||||
|
||||
블록별 변형 HTML 템플릿을 추가한다. 기존 블록의 CSS(색상, 배경, radius 등)를 그대로 사용하고 내부 구조만 변경.
|
||||
|
||||
**우선 제작 대상 (시뮬레이션에서 검증된 변형):**
|
||||
|
||||
| 블록 | variant | 용도 |
|
||||
|------|---------|------|
|
||||
| `dark-bullet-list` | `before-after` | 프로세스 변화 (Before→After 2열) |
|
||||
| `comparison-2col` | `cards-in-container` | 포함 관계 (DX ⊃ GIS+BIM+DT) |
|
||||
| `card-icon-desc` | `compact` | 높이 부족 시 축소 |
|
||||
| `card-numbered` | `horizontal` | 사례 가로 비교 |
|
||||
|
||||
- **파일:** `templates/blocks/{category}/` 에 `--{variant}.html` 추가
|
||||
- **원칙:** 기존 블록의 CSS 클래스/색상을 재사용. 새 CSS는 최소한만 추가.
|
||||
|
||||
### R-3: block_selector.py에 variant 선택 로직 추가
|
||||
|
||||
블록 선택 시 variant도 함께 결정. Kei에게 "이 블록의 어떤 변형이 적합한가"를 함께 제시.
|
||||
|
||||
```python
|
||||
# block_selector.py 수정
|
||||
|
||||
def select_block_candidates(topic, container_spec, used_blocks, catalog):
|
||||
# ... 기존 필터링 로직 유지 ...
|
||||
|
||||
# 각 후보 블록의 variants도 함께 반환
|
||||
for block in candidates:
|
||||
variants = block.get("variants", [{"id": "default"}])
|
||||
# expression_hint와 매칭되는 variant 우선 정렬
|
||||
block["_available_variants"] = variants
|
||||
|
||||
return candidates
|
||||
```
|
||||
|
||||
### R-4: Kei 블록 선택 프롬프트에 variant + expression_hint 전달
|
||||
|
||||
Q-4 프롬프트를 확장하여 variant 선택과 expression_hint를 포함.
|
||||
|
||||
```
|
||||
## 후보 블록
|
||||
1. dark-bullet-list (다크 배경 불릿)
|
||||
변형:
|
||||
- default: 기존 불릿 나열
|
||||
- before-after: Before→After 2열 구조
|
||||
|
||||
★ 표현 의도: "기존 방식에서 새 방식으로의 전환을 보여주는 구조"
|
||||
→ before-after 변형이 적합
|
||||
|
||||
## 선택 (JSON)
|
||||
{"block_id": "dark-bullet-list", "variant": "before-after", "reason": "..."}
|
||||
```
|
||||
|
||||
### R-5: renderer.py에 variant 렌더링 지원
|
||||
|
||||
variant가 지정되면 해당 변형 템플릿을 사용하여 렌더링.
|
||||
|
||||
```python
|
||||
# renderer.py 수정
|
||||
|
||||
def _resolve_template_path(env, block_type, variant="default"):
|
||||
if variant and variant != "default":
|
||||
# 변형 템플릿 우선
|
||||
variant_path = f"blocks/{category}/{block_type}--{variant}.html"
|
||||
if template_exists(env, variant_path):
|
||||
return variant_path
|
||||
|
||||
# 기존 템플릿 fallback
|
||||
return f"blocks/{category}/{block_type}.html"
|
||||
```
|
||||
|
||||
### R-6: 3순위 자유 생성 (디자인 토큰 기반)
|
||||
|
||||
어떤 블록+변형으로도 안 맞을 때, AI가 디자인 토큰 안에서 HTML을 직접 생성.
|
||||
|
||||
```python
|
||||
# 자유 생성 조건
|
||||
if not suitable_block_found:
|
||||
# 디자인 토큰 + 3-5개 예시 슬라이드를 프롬프트에 포함
|
||||
# AI가 HTML 생성
|
||||
# Selenium으로 검증
|
||||
html = await generate_free_block_html(topic, container_spec, design_tokens)
|
||||
```
|
||||
|
||||
**제약 사항:**
|
||||
- 디자인 토큰(CSS 변수)만 사용 가능 — 하드코딩 색상/폰트 금지
|
||||
- 감사 스크립트로 토큰 위반 검출 (Atlassian 방식)
|
||||
- Selenium 측정으로 overflow 검증
|
||||
- 비전 모델 품질 게이트 통과 필수
|
||||
|
||||
### R-7: expression_hint를 fill_candidates에 전달
|
||||
|
||||
1단계에서 Kei가 판단한 `expression_hint`를 편집자(fill_candidates)에게 전달하여 텍스트 구성에 반영.
|
||||
|
||||
```python
|
||||
# fill_candidates 프롬프트에 추가
|
||||
section += f"\n ★ 표현 의도: {topic.get('expression_hint', '')}"
|
||||
section += f"\n ★ 변형: {block.get('_variant', 'default')}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 계획
|
||||
|
||||
### 의존 관계
|
||||
|
||||
```
|
||||
R-1 (catalog variants) ──→ R-2 (variant 템플릿) ──→ R-5 (renderer variant 지원)
|
||||
──→ R-3 (selector variant)──→ R-4 (Kei 프롬프트 확장)
|
||||
──→ R-7 (expression_hint 전달)
|
||||
R-6 (자유 생성) ← R-5 완료 후 독립 작업
|
||||
```
|
||||
|
||||
### 우선순위
|
||||
|
||||
| 순서 | 스텝 | 내용 | 효과 |
|
||||
|------|------|------|------|
|
||||
| 1 | R-1 | catalog에 variants 추가 | 데이터 기반 |
|
||||
| 2 | R-2 | before-after, cards-in-container 템플릿 제작 | 시뮬레이션에서 검증된 변형 우선 |
|
||||
| 3 | R-5 | renderer variant 렌더링 | 변형 블록이 실제로 렌더링 |
|
||||
| 4 | R-3 | block_selector variant 필터링 | variant 후보 제시 |
|
||||
| 5 | R-4 | Kei 프롬프트 확장 | variant + expression_hint |
|
||||
| 6 | R-7 | fill_candidates에 expression_hint 전달 | 텍스트 구성 개선 |
|
||||
| 7 | R-6 | 자유 생성 (3순위) | 블록으로 안 맞을 때 대비 |
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `templates/catalog.yaml` | variants[] 필드 추가 |
|
||||
| `templates/blocks/emphasis/dark-bullet-list--before-after.html` | 신규 |
|
||||
| `templates/blocks/emphasis/comparison-2col--cards-in-container.html` | 신규 |
|
||||
| `templates/blocks/cards/card-icon-desc--compact.html` | 신규 |
|
||||
| `templates/blocks/cards/card-numbered--horizontal.html` | 신규 |
|
||||
| `src/block_selector.py` | variant 필터링 로직 추가 |
|
||||
| `src/kei_client.py` | Q-4 프롬프트에 variant + expression_hint |
|
||||
| `src/renderer.py` | variant 템플릿 해석 |
|
||||
| `src/content_editor.py` | fill_candidates에 expression_hint 전달 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 기대 효과
|
||||
|
||||
| 지표 | Phase Q (현재) | Phase R (목표) |
|
||||
|------|---------------|---------------|
|
||||
| 블록 적합도 | 60% (억지로 끼워 맞춤) | 90%+ (변형으로 맞춤) |
|
||||
| 콘텐츠 구조 반영 | 낮음 (텍스트 나열) | 높음 (Before→After, 포함관계 등) |
|
||||
| 블록 재사용률 | 38개 중 5-6개 사용 | 38개 + variants로 실질 50+ |
|
||||
| 자유 생성 비율 | 0% | 5-10% (안 맞을 때만) |
|
||||
| 텍스트 보존도 | Phase P 수준 (fill_candidates) | 동일 유지 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase P → Q → R 전체 흐름 정리
|
||||
|
||||
```
|
||||
Phase P (20점): FAISS+Opus → 블록 선택 → 다후보 렌더링 비교 → 느리고 부정확
|
||||
↓
|
||||
Phase Q (77점): relation_type → 결정론적 필터링 → 예산 사전 계산 → 빠르고 정확
|
||||
→ 하지만 "맞는 블록이 없으면 억지로 끼워 맞춤"
|
||||
↓
|
||||
Phase R (목표): Phase Q 유지 + variant 변형 + expression_hint 전달
|
||||
→ 기존 블록 80% 활용 + 20% 변형/자유 생성
|
||||
→ 콘텐츠 전달 의도에 맞는 시각화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증된 시뮬레이션 결과
|
||||
|
||||
### 콘텐츠 1: 건설산업 DX의 올바른 이해 (포함 관계)
|
||||
- `ideal_v2` + `3approaches` 폴더: 접근 A, C가 우수
|
||||
- `comparison-2col--cards-in-container` 변형이 핵심 (DX ⊃ GIS+BIM+DT)
|
||||
|
||||
### 콘텐츠 2: DX 시행 목표 및 기대 효과 (목표/프로세스)
|
||||
- `hybrid_simulation` 폴더: 블록 80% 활용 확인
|
||||
- `dark-bullet-list--before-after` 변형이 핵심 (프로세스 변화)
|
||||
- `card-icon-desc` 기존 블록 그대로 사용 (목표 3카드)
|
||||
- `table-simple-striped` 기존 블록 그대로 사용 (주체별 효과)
|
||||
|
||||
### 공통 확인
|
||||
- 720px overflow 없음
|
||||
- 디자인 토큰(색상, 폰트, 간격) 일관성 유지
|
||||
- sidebar 프리셋 적절히 작동
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고: 조사 기반 근거
|
||||
|
||||
| 출처 | 적용한 인사이트 |
|
||||
|------|---------------|
|
||||
| Atlassian + LLM | CSS 변수로 제한 → "10번째도 1번째와 동일 품질" |
|
||||
| frontend-slides (11.5K stars) | 디자인 토큰 + 예시 기반 → 프로덕션 품질 HTML 생성 |
|
||||
| TechGrid 인터뷰 | "모델의 자유도를 줄이고 모든 것을 검증" |
|
||||
| VASCAR | 생성 → 렌더링 → 비전 모델 평가 → 교정 |
|
||||
| Beautiful.ai | 템플릿 + 제약 엔진 (자동 조정 규칙) |
|
||||
| AutoPresent (CVPR 2025) | 코드 API 기반 조합 생성 |
|
||||
224
docs/history/IMPROVEMENT-PHASE-S-ISSUES.md
Normal file
224
docs/history/IMPROVEMENT-PHASE-S-ISSUES.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Phase S 실행 결과 문제점 + 해결 방향
|
||||
|
||||
> 작성일: 2026-03-31
|
||||
> 상태: 문제 기록. 수정 대기.
|
||||
> 근거: 1774736083771_phaseS_20260331_024142 결과물 검토
|
||||
|
||||
---
|
||||
|
||||
## 1. 근본 원인
|
||||
|
||||
**검증 때 성공한 것과 실제 실행에서 실패한 것의 차이:**
|
||||
|
||||
| 항목 | 검증 (성공) | 실행 (실패) |
|
||||
|------|-----------|-----------|
|
||||
| 프롬프트 | 실제 텍스트를 한 글자씩 프롬프트에 포함 | "source_data를 그대로 써라"만 지시 |
|
||||
| CSS | 구체적 수치까지 지정 (13px, #93c5fd 등) | 추상적 ("디자인 토큰 참고") |
|
||||
| 레이아웃 | 구체적 구조 지시 (flex, float, margin-top 등) | "여백 최소화"만 지시 |
|
||||
| 결과 | 합격 | 전부 불합격 |
|
||||
|
||||
**즉, 프롬프트가 구체적이면 합격, 추상적이면 실패.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 문제 목록 (전체)
|
||||
|
||||
### 2-1. 텍스트
|
||||
- 원본 MDX 80-95% 보존 안 됨 — 다시 축약/변형
|
||||
- source_data가 프롬프트에 직접 포함되지 않고 "참고하라"만 지시
|
||||
|
||||
### 2-2. 레이아웃
|
||||
- 배경과 본심 사이 간격이 없음
|
||||
- 본심 가로 크기가 배경에 비해 좁음 — 같은 body인데 너비가 다름
|
||||
- 전체 컨테이너 크기/간격이 검증 때와 다름
|
||||
|
||||
### 2-3. 이미지
|
||||
- float으로 어우러지지 않고 본문에 붙어있음
|
||||
- 검증(core_c_fix)에서는 margin-top으로 위치 조정, 텍스트 감싸기 작동 — 지금은 안 됨
|
||||
- 이미지 캡션이 이상함
|
||||
|
||||
### 2-4. 들여쓰기
|
||||
- 불릿(•) 다음 줄 들여쓰기 전혀 적용 안 됨
|
||||
- padding-left + text-indent CSS가 생성된 HTML에 없음
|
||||
|
||||
### 2-5. 색상/강조
|
||||
- 배경(다크 박스)이 너무 강해서 본심보다 배경에 시선이 감
|
||||
- 배경은 "배경"이지 핵심이 아닌데, 시각적으로 가장 강조됨
|
||||
|
||||
### 2-6. 핵심 메시지
|
||||
- 하단 강조 박스("BIM ≠ DX")가 잘림
|
||||
|
||||
### 2-7. 용어 정의 (sidebar)
|
||||
- 텍스트 축약 — 검증(verify_v2/A_definitions)에서는 풀 텍스트였음
|
||||
- overflow +15px — 490px 안에 안 맞음
|
||||
|
||||
### 2-8. 전체 균형
|
||||
- 배경/본심/sidebar/footer의 시각적 비중이 안 맞음
|
||||
- 배경이 가장 강조되고 본심이 약함
|
||||
|
||||
---
|
||||
|
||||
## 3. 해결 방향 — 5단계 프로세스
|
||||
|
||||
### Step 1: 원본 텍스트 매핑
|
||||
- Kei 분석 결과(topics, purpose, expression_hint)에 따라 원본 MDX에서 해당 텍스트를 매핑
|
||||
- source_data의 Kei 메모("간결한 문제 제기용" 등)는 제외
|
||||
- **원본 MDX 텍스트만** 가져옴
|
||||
- MDX를 ## 기준으로 섹션 슬라이싱 → topics의 source_hint로 섹션 매칭
|
||||
|
||||
### Step 2: 검증 합격 프롬프트와 결합
|
||||
- 검증에서 합격한 프롬프트의 **디자인/구조 부분은 고정**
|
||||
- Step 1에서 매핑한 **원본 텍스트를 동적으로 삽입**
|
||||
- 영역별 개별 호출 (배경/본심/sidebar/footer)
|
||||
|
||||
| 영역 | 검증 합격 프롬프트 | 텍스트 부분 |
|
||||
|------|-----------------|-----------|
|
||||
| 배경 | verify_claude_1_2.py prompt_1의 디자인 지시 | Step 1에서 매핑한 혼용+사례 텍스트 |
|
||||
| 본심 | core_c_fix.py의 CSS 구조 | Step 1에서 매핑한 핵심기술 텍스트 |
|
||||
| sidebar | verify_definitions_v2.py prompt_a의 디자인 지시 | Step 1에서 매핑한 용어 정의 텍스트 |
|
||||
| footer | 간단 프롬프트 | Step 1에서 매핑한 결론 텍스트 |
|
||||
|
||||
### Step 3: Claude 생성 HTML 자체 검증 (제출 전)
|
||||
- 원본 텍스트가 축약/변형 없이 들어갔는가
|
||||
- 들여쓰기 CSS(padding-left + text-indent)가 적용되었는가
|
||||
- 이미지가 정확히 배치되었는가
|
||||
- 컨테이너 크기를 초과하지 않는가
|
||||
- Kei 메모("간결한 문제 제기용" 등)가 슬라이드에 포함되지 않았는가
|
||||
- **검증 실패 시 사용자에게 제출하지 않고 재생성**
|
||||
|
||||
### Step 4: 전체 슬라이드 조립 + 전체 균형 검증
|
||||
- 각 영역 HTML을 슬라이드 프레임에 조립
|
||||
- 전체 균형 검증:
|
||||
- 배경이 본심보다 강조되지 않는가
|
||||
- 영역 간 간격/여백이 적절한가
|
||||
- 전체 720px 안에 들어가는가
|
||||
- 시각적 비중이 page_structure의 비중과 맞는가
|
||||
|
||||
### Step 5: 결과물 저장
|
||||
- runs 폴더에 스텝별로 정리
|
||||
- 각 스텝의 입력/출력을 JSON + PNG로 저장
|
||||
- 최종 실행 환경: localhost:8001 (Design Agent 서버, pipeline.py 경유)
|
||||
|
||||
---
|
||||
|
||||
## 4. 세부 수정 사항 10개 (2026-03-31 03:30 추가)
|
||||
|
||||
Phase S 실행 결과에서 발견된 세부 문제 10개와 각각의 해결 방법.
|
||||
|
||||
### 1-1. 배경 다크 박스가 시각적으로 너무 강함
|
||||
- **문제:** linear-gradient(135deg, #1e293b, #0f172a) — 거의 검정. 본심보다 배경이 더 눈에 띔.
|
||||
- **해결:** 배경의 opacity를 낮추거나, 다크 대신 연한 회색 배경 사용. 또는 본심에 시각적 강조 추가하여 상대적 균형.
|
||||
- **프롬프트 수정:** BG_PROMPT의 배경색을 연한 톤으로 변경하거나, 본심에 배경색/border 추가.
|
||||
- **충돌/회귀:** 없음. 색상만 변경이고, 검증 합격 배경도 같은 다크였으므로 톤만 조정.
|
||||
|
||||
### 1-2. 배경 사례 카드가 배경 영역 안에 다 들어가는가
|
||||
- **문제:** height:176px + overflow:hidden이면 사례 카드가 잘릴 수 있음.
|
||||
- **해결:** 프롬프트에 "폰트를 줄여서라도 height 안에 맞출 것" 이미 포함. 실제 잘림 여부는 브라우저에서 확인.
|
||||
- **충돌/회귀:** 없음. 기존 프롬프트에 이미 있는 지시.
|
||||
|
||||
### 2-1. 본심 가로 크기가 배경과 다름
|
||||
- **문제:** 배경 width:100%, 본심 width:707px. 같은 body인데 너비가 다름.
|
||||
- **해결:** CORE_PROMPT의 CSS에서 `.core { width: 707px; }` → `.core { width: 100%; }` 변경.
|
||||
- **충돌/회귀:** 없음. 본심이 body 전체 너비를 사용하는 것이 자연스러움. 검증(core_c_fix)에서 707px이었던 것은 테스트 컨테이너 크기였을 뿐.
|
||||
|
||||
### 3-1. 본심 이미지가 텍스트와 어우러지지 않음
|
||||
- **문제:** float이 적용 안 되거나 이미지가 본문에 붙어있음.
|
||||
- **해결:** CORE_PROMPT의 참고 CSS에 `.fi { float:right; margin:60px 0 8px 12px; width:250px; }` 이미 포함되어 있으나, Claude가 무시. → 프롬프트에 "이 CSS를 반드시 그대로 사용하라. float:right를 반드시 적용하라." 강조 추가.
|
||||
- **충돌/회귀:** 없음. CSS 강조만 추가.
|
||||
|
||||
### 3-2. 이미지 캡션이 이상함
|
||||
- **문제:** 캡션 텍스트가 검증 때와 다를 수 있음.
|
||||
- **해결:** CORE_PROMPT의 참고 HTML에서 캡션을 명시: `<div class="cap">건설산업의 DX</div>`. "이 캡션을 그대로 사용하라" 추가.
|
||||
- **충돌/회귀:** 없음. 캡션 텍스트 고정.
|
||||
|
||||
### 4-1. 본심 우상단 "상세비교" 팝업 링크
|
||||
- **문제:** 검증(core_c_fix)에서는 `📊 DX와 BIM의 상세 비교` 텍스트 링크였는데 다르게 나옴.
|
||||
- **해결:** CORE_PROMPT의 참고 HTML에서 `<span class="popup-link">📊 DX와 BIM의 상세 비교</span>` 명시. "이 텍스트를 그대로 사용하라" 추가.
|
||||
- **충돌/회귀:** 없음. 텍스트 명시만 추가.
|
||||
|
||||
### 4-2. 본심 하단 핵심 메시지 박스
|
||||
- **문제:** "상위-하위 포함관계" 같은 임의 텍스트가 들어감.
|
||||
- **해결:** CORE_PROMPT에서 핵심 메시지를 명시:
|
||||
`<div class="key-msg"><em>BIM ≠ DX</em> — BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다</div>`
|
||||
"이 텍스트를 그대로 사용하라. '상위개념', '하위기술', '포함관계' 같은 임의 라벨 금지." 추가.
|
||||
- **충돌/회귀:** 없음. 텍스트 명시 + 금지어 추가.
|
||||
|
||||
### 5-1. 용어 정의 들여쓰기 안 됨
|
||||
- **문제:** 불릿 2줄째가 왼쪽 끝에서 시작. padding-left + text-indent 미적용.
|
||||
- **해결:** SIDEBAR_PROMPT에 CSS 코드를 직접 포함하고 "이 CSS를 반드시 적용하라" 명시:
|
||||
```css
|
||||
.def-bullet { padding-left: 14px; text-indent: -14px; }
|
||||
.def-bullet::before { content: '•'; display: inline-block; width: 14px; text-indent: 0; }
|
||||
```
|
||||
- **충돌/회귀:** 없음. CSS 추가만.
|
||||
|
||||
### 6-1. 용어 정의에서 원본의 2줄을 1점에 합침
|
||||
- **문제:** BIM 정의 "형상정보와... 정보 관리 도구. 건설 정보와... 핵심 인프라 기술"이 하나의 불릿. 검증(verify_definitions_v2)에서는 2개 불릿으로 분리.
|
||||
- **해결:** SIDEBAR_PROMPT에 "마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리하라" 명시. 또는 프롬프트에 넣는 텍스트를 미리 줄 단위로 분리.
|
||||
- **충돌/회귀:** 없음. 분리 규칙 추가만. 단, 원본 MDX가 1줄에 2문장인 경우에만 적용 — 다른 MDX에서 1문장 1줄인 경우는 영향 없음.
|
||||
|
||||
### 7-1. 배경 블록이 다크 디자인 — 보조 영역인데 시각적 주인공
|
||||
- **문제:** BG_PROMPT가 "다크 배경 박스"로 설계. 전체 슬라이드에서 흰색 바탕에 남색 블록 하나만 있으면 거기에 시선이 먼저 감. 배경은 보조인데 시각적으로는 주인공이 되는 근본적 모순.
|
||||
- **해결:** BG_PROMPT 자체를 라이트 디자인으로 근본 교체. background: #f8fafc (연회색), border: 1px solid #e2e8f0. 사례 카드도 padding/gap 축소 (6px 8px, gap 8px).
|
||||
- **충돌/회귀:** 없음. 디자인 변경이고 기능 영향 없음.
|
||||
|
||||
### 7-2. 배경 텍스트가 개조식이 아닌 서술형 원문 그대로
|
||||
- **문제:** 배경 영역의 문장이 "~인식되고 있다", "~해당한다", "~빈번하다" 등 서술형으로 끝남. 슬라이드는 개조식이어야 하고, 마침표로 끝나는 2-3문장이 불릿 없이 한 덩어리로 들어감.
|
||||
- **해결:**
|
||||
1. 모든 프롬프트에 개조식 통일 규칙 추가: 서술형(~하다/~이다/~있다/~된다) → 개조식(~에 해당/~인식되는 중/~발생 등)
|
||||
2. 마침표(.)로 끝나는 문장이 2개 이상이면 별도 불릿(•)으로 분리 (sidebar에만 있던 규칙을 전체로 확대)
|
||||
3. 슬라이드 전체에서 문장 끝 형식이 통일되어야 함
|
||||
- **충돌/회귀:** 없음. 원본 "의미"는 보존하되 "형식"만 슬라이드에 맞게 변환. 범용적 규칙.
|
||||
|
||||
---
|
||||
|
||||
## 5. 충돌/회귀 종합 검토
|
||||
|
||||
| 수정 | 기존 코드 영향 | 다른 MDX 영향 | 판정 |
|
||||
|------|-------------|-------------|------|
|
||||
| 1-1 배경 톤 조정 | 색상 값만 변경 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 1-2 사례 잘림 확인 | 프롬프트 지시 기존과 동일 | 영향 없음 | ✅ 안전 |
|
||||
| 2-1 본심 width 100% | CSS 값 변경 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 3-1 float 강조 | 프롬프트 강조 추가 | 영향 없음 | ✅ 안전 |
|
||||
| 3-2 캡션 명시 | 프롬프트 텍스트 추가 | 다른 MDX에서는 해당 topic의 title로 동적 생성 | ✅ 안전 |
|
||||
| 4-1 팝업 링크 명시 | 프롬프트 텍스트 추가 | 팝업이 없는 MDX에서는 생성 안 됨 | ✅ 안전 |
|
||||
| 4-2 핵심 메시지 명시 | 프롬프트 텍스트 추가 + 금지어 | core_message에서 동적 생성 | ✅ 안전 |
|
||||
| 5-1 들여쓰기 CSS | CSS 추가 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 6-1 문장 분리 | 분리 규칙 추가 | 1문장 1줄인 MDX에는 영향 없음 | ✅ 안전 |
|
||||
| 7-1 배경 라이트 디자인 | BG_PROMPT 전면 교체 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
| 7-2 개조식 통일 | 전체 프롬프트에 규칙 추가 | 모든 MDX에 동일 적용 | ✅ 안전 |
|
||||
|
||||
**회귀 위험: 없음.**
|
||||
**충돌 위험: 없음.**
|
||||
**하드코딩: 4-2의 핵심 메시지 텍스트는 analysis.core_message에서 동적으로 가져오도록 해야 함 — 하드코딩 아님.**
|
||||
|
||||
---
|
||||
|
||||
## 6. 자기 검증 체크리스트
|
||||
|
||||
결과물 제출 전 반드시 확인:
|
||||
|
||||
- [ ] 배경이 본심보다 시각적으로 강조되지 않는가?
|
||||
- [ ] 본심 가로 크기가 배경과 동일한가? (둘 다 100%)
|
||||
- [ ] 본심 이미지가 float:right로 텍스트와 어우러지는가?
|
||||
- [ ] 본심 이미지 캡션이 적절한가?
|
||||
- [ ] 본심 우상단에 팝업 링크가 있는가?
|
||||
- [ ] 본심 하단 핵심 메시지에 임의 텍스트("상위개념", "하위기술", "포함관계")가 없는가?
|
||||
- [ ] 용어 정의 들여쓰기(padding-left + text-indent)가 적용되어 있는가?
|
||||
- [ ] 용어 정의에서 2문장이 별도 불릿으로 분리되어 있는가?
|
||||
- [ ] 텍스트가 원본 MDX의 80-95%를 보존하는가?
|
||||
- [ ] Kei 메모가 슬라이드에 포함되지 않았는가?
|
||||
- [ ] 배경이 라이트 디자인(연회색)인가? (다크 배경 아닌가?)
|
||||
- [ ] 문장 끝이 개조식으로 통일되어 있는가? (서술형 "~하다/~이다" 혼재 없는가?)
|
||||
- [ ] 마침표로 끝나는 2문장 이상이 별도 불릿으로 분리되었는가?
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 합격 결과물 참조
|
||||
|
||||
| 영역 | 파일 | 프롬프트 위치 |
|
||||
|------|------|------------|
|
||||
| 배경 | data/runs/verify_claude_20260330_212019/verify1.png | scripts/verify_claude_1_2.py prompt_1 |
|
||||
| 용어 정의 | data/runs/verify_v2_20260331_003421/A_definitions.png | scripts/verify_definitions_v2.py prompt_a |
|
||||
| 본심 | data/runs/core_c_fix_20260331_015828/core_c_fix.png | scripts/verify_core_c_fix.py (직접 작성 HTML) |
|
||||
| footer | 이전부터 OK | 간단 프롬프트 |
|
||||
175
docs/history/IMPROVEMENT-PHASE-S.md
Normal file
175
docs/history/IMPROVEMENT-PHASE-S.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Phase S: 검증 기반 확정 — Claude HTML 생성 + 검증된 프롬프트 규칙
|
||||
|
||||
> 작성일: 2026-03-31
|
||||
> 상태: 설계 확정 + 문제 발견 → 5단계 프로세스로 재정리
|
||||
> 근거: 영역별 검증 합격 결과물 존재. 자동화 전환 시 품질 저하 문제 발견.
|
||||
> 최종 실행 환경: localhost:8001 (Design Agent 서버, pipeline.py 경유)
|
||||
|
||||
---
|
||||
|
||||
## 1. 역할 분리 (확정)
|
||||
|
||||
```
|
||||
Kei (1단계): 콘텐츠 분석 → topics, relation_type, expression_hint, source_hint, page_structure
|
||||
Kei는 HTML을 만들지 않는다. 콘텐츠를 분석하고 판단한다.
|
||||
source_data에는 Kei 메모가 포함될 수 있으므로, 슬라이드 텍스트로 직접 사용하지 않는다.
|
||||
|
||||
Claude Sonnet: 원본 MDX 텍스트(Kei가 매핑한) + 검증된 디자인 규칙
|
||||
→ 각 영역(body, sidebar, footer)의 HTML을 직접 생성
|
||||
|
||||
코드: 원본 텍스트 매핑, 프롬프트 조립, HTML 검증, 슬라이드 조립, Selenium 측정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 5단계 프로세스
|
||||
|
||||
### Step 1: 원본 텍스트 매핑
|
||||
|
||||
Kei 분석 결과에 따라 **원본 MDX에서 해당 텍스트를 정확히 매핑**한다.
|
||||
|
||||
- MDX를 `##` 기준으로 섹션 슬라이싱
|
||||
- Kei가 판단한 topics의 `source_hint`로 섹션 매칭
|
||||
- **source_data는 사용하지 않는다** — Kei 메모("간결한 문제 제기용 핵심 메시지만 추출" 등)가 포함되어 있으므로
|
||||
- **원본 MDX 텍스트만 가져온다** — 80-95% 보존
|
||||
- 매핑 결과를 JSON으로 저장하여 검증 가능하게
|
||||
|
||||
매핑 규칙:
|
||||
```
|
||||
page_structure의 각 역할 → topics → source_hint → 원본 MDX 섹션 매칭
|
||||
|
||||
배경 역할의 topics → "용어의 혼용" 섹션 + "혼용 대표 사례" 섹션
|
||||
본심 역할의 topics → "DX와 핵심기술의 올바른 관계" 섹션
|
||||
첨부 역할의 topics → "용어별 정의" 섹션
|
||||
결론 역할의 topics → "핵심 요약" 섹션
|
||||
```
|
||||
|
||||
### Step 2: 검증 합격 프롬프트와 결합
|
||||
|
||||
검증에서 합격한 프롬프트의 **디자인/구조 부분은 고정**, Step 1에서 매핑한 **원본 텍스트를 동적으로 삽입**.
|
||||
영역별 Claude Sonnet 개별 호출.
|
||||
|
||||
| 영역 | 디자인 규칙 (고정) | 텍스트 (동적) |
|
||||
|------|-----------------|------------|
|
||||
| 배경 | verify_claude_1_2.py prompt_1의 디자인 지시 (다크 박스, 사례 카드, 색상, 폰트) | Step 1에서 매핑한 "용어의 혼용" + "혼용 대표 사례" 원본 텍스트 |
|
||||
| 본심 | core_c_fix.py의 CSS 구조 (float 이미지, 들여쓰기, 핵심 메시지 박스) | Step 1에서 매핑한 "DX와 핵심기술" 원본 텍스트 |
|
||||
| sidebar | verify_definitions_v2.py prompt_a의 디자인 지시 (카드, 부제, 불릿, 출처) | Step 1에서 매핑한 "용어별 정의" 원본 텍스트 |
|
||||
| footer | 배너 디자인 | Step 1에서 매핑한 "핵심 요약" 원본 텍스트 |
|
||||
|
||||
프롬프트 구성 원칙:
|
||||
- 디자인 규칙은 CSS 수치(px, 색상코드)까지 명시적으로 포함
|
||||
- 텍스트는 원본을 따옴표로 감싸서 "이 텍스트를 그대로 사용하라" 명시
|
||||
- 들여쓰기 CSS 코드를 프롬프트에 직접 포함
|
||||
- "축약/요약/재작성 금지" 명시
|
||||
|
||||
### Step 3: Claude 생성 HTML 자체 검증 (제출 전)
|
||||
|
||||
Claude가 생성한 HTML을 **사용자에게 제출하기 전에** 자동 검증.
|
||||
|
||||
검증 항목:
|
||||
1. 원본 텍스트가 축약/변형 없이 들어갔는가 (원본과 비교)
|
||||
2. 들여쓰기 CSS(padding-left + text-indent)가 HTML에 존재하는가
|
||||
3. 이미지 태그(id="slide-img-*")가 존재하는가 (이미지가 있을 때)
|
||||
4. Kei 메모("간결한 문제 제기용", "핵심 메시지만 추출" 등)가 HTML에 포함되지 않았는가
|
||||
5. 각 영역의 HTML이 비어있지 않은가
|
||||
|
||||
**검증 실패 시 사용자에게 제출하지 않고 재생성.**
|
||||
**검증 통과 시에만 다음 단계로 진행.**
|
||||
|
||||
### Step 4: 전체 슬라이드 조립 + 전체 균형 검증
|
||||
|
||||
각 영역 HTML을 슬라이드 프레임에 조립한 후 전체 검증.
|
||||
|
||||
검증 항목:
|
||||
1. 배경이 본심보다 시각적으로 강조되지 않는가
|
||||
2. 영역 간 간격/여백이 적절한가
|
||||
3. 본심의 가로 크기가 배경과 동일한가 (같은 body 영역)
|
||||
4. 전체 720px 안에 들어가는가 (Selenium 측정)
|
||||
5. sidebar가 overflow 없이 맞는가
|
||||
6. 핵심 메시지 박스가 잘리지 않는가
|
||||
7. 시각적 비중이 page_structure의 비중과 맞는가
|
||||
|
||||
### Step 5: 결과물 저장
|
||||
|
||||
runs 폴더에 스텝별로 정리하여 저장.
|
||||
|
||||
```
|
||||
{run_id}_phaseS_{timestamp}/
|
||||
├── step1_text_mapping/
|
||||
│ ├── mdx_sections.json ← 원본 MDX 섹션 슬라이싱 결과
|
||||
│ ├── topic_mapping.json ← topic → 섹션 매핑 결과
|
||||
│ └── mapped_texts.json ← 각 영역에 들어갈 텍스트
|
||||
├── step2_html_generation/
|
||||
│ ├── bg_prompt.txt ← 배경 프롬프트 (검증용)
|
||||
│ ├── bg.html + bg.png ← 배경 결과
|
||||
│ ├── core_prompt.txt ← 본심 프롬프트
|
||||
│ ├── core.html + core.png ← 본심 결과
|
||||
│ ├── sidebar_prompt.txt ← sidebar 프롬프트
|
||||
│ ├── sidebar.html + sidebar.png
|
||||
│ ├── footer.html + footer.png
|
||||
│ └── generation_meta.json
|
||||
├── step3_validation/
|
||||
│ ├── validation_result.json ← 각 항목 PASS/FAIL
|
||||
│ └── issues.json ← 실패 항목 상세
|
||||
├── step4_assembly/
|
||||
│ ├── slide.html + slide.png ← 전체 슬라이드
|
||||
│ ├── measurement.json ← Selenium 측정
|
||||
│ └── balance_check.json ← 균형 검증 결과
|
||||
├── final.html ← 최종 결과물
|
||||
└── screenshot.png ← 최종 스크린샷
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 최종 실행 환경
|
||||
|
||||
지금 테스트는 `scripts/test_phase_s.py`로 직접 실행하지만,
|
||||
**최종적으로는 localhost:8001 (Design Agent 서버)에서 pipeline.py를 통해 실행.**
|
||||
|
||||
```
|
||||
사용자 → localhost:8001 POST /api/generate
|
||||
→ pipeline.py generate_slide()
|
||||
→ html_generator.py generate_slide_html() (Phase S)
|
||||
→ render_slide_from_html()
|
||||
→ Selenium 측정 + 품질 게이트
|
||||
→ SSE로 결과 전달
|
||||
```
|
||||
|
||||
pipeline.py의 2-3단계가 html_generator로 교체된 상태.
|
||||
테스트 완료 후 서버에서 동일하게 작동해야 함.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 합격 결과물 참조
|
||||
|
||||
| 영역 | 합격 결과물 | 합격 프롬프트 위치 | 합격 방식 |
|
||||
|------|-----------|-----------------|---------|
|
||||
| 배경 | verify_claude_20260330_212019/verify1.png | scripts/verify_claude_1_2.py prompt_1 | Claude Sonnet, 수동 텍스트 |
|
||||
| 용어 정의 | verify_v2_20260331_003421/A_definitions.png | scripts/verify_definitions_v2.py prompt_a | Claude Sonnet, 수동 텍스트 |
|
||||
| 본심 | core_c_fix_20260331_015828/core_c_fix.png | scripts/verify_core_c_fix.py (직접 작성 HTML) | 하드코딩 HTML |
|
||||
| footer | 이전부터 OK | 간단 프롬프트 | Claude Sonnet |
|
||||
|
||||
---
|
||||
|
||||
## 5. 절대 규칙
|
||||
|
||||
1. **하드코딩 금지** — 이 콘텐츠에만 작동하는 코드 금지. 어떤 MDX가 와도 동일 프로세스.
|
||||
2. **회귀 금지** — block_selector, fill_candidates, fill_content 호출 금지.
|
||||
3. **source_data를 슬라이드 텍스트로 직접 사용 금지** — Kei 메모 포함 가능성.
|
||||
4. **실행은 사용자 요청 시에만**.
|
||||
5. **제출 전 자체 검증 필수** — 검증 실패 시 사용자에게 제출하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 6. 자기 검증 체크리스트
|
||||
|
||||
구현 완료 후, 테스트 실행 전 반드시 확인:
|
||||
|
||||
- [ ] source_data를 프롬프트에 직접 넣고 있지 않은가?
|
||||
- [ ] 원본 MDX 텍스트를 매핑하여 넣고 있는가?
|
||||
- [ ] 프롬프트에 디자인 규칙이 CSS 수치까지 명시적으로 포함되어 있는가?
|
||||
- [ ] 들여쓰기 CSS 코드가 프롬프트에 포함되어 있는가?
|
||||
- [ ] 각 영역이 개별 Claude 호출인가?
|
||||
- [ ] block_selector, fill_candidates를 호출하지 않는가?
|
||||
- [ ] Step 3 자체 검증이 구현되어 있는가?
|
||||
- [ ] 결과물이 하드코딩이 아니라 범용적인가?
|
||||
1086
docs/history/IMPROVEMENT-PHASE-T.md
Normal file
1086
docs/history/IMPROVEMENT-PHASE-T.md
Normal file
File diff suppressed because it is too large
Load Diff
464
docs/history/IMPROVEMENT-PROCESS-REVIEW.md
Normal file
464
docs/history/IMPROVEMENT-PROCESS-REVIEW.md
Normal file
@@ -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 산출물 전부 재사용. |
|
||||
632
docs/history/IMPROVEMENT.md
Normal file
632
docs/history/IMPROVEMENT.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# Design Agent — 개선 계획
|
||||
|
||||
CLAUDE.md 요구사항 전수검토 결과 발견된 미구현/부분구현/위반 사항 33개의 개선 계획.
|
||||
2026-03-25 기준 코드 감사 결과에 기반.
|
||||
|
||||
---
|
||||
|
||||
## Phase A: 슬라이드 품질 핵심 (8개)
|
||||
|
||||
> "프레임에 내용이 안 보인다"의 직접 원인. 최우선.
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-A.md](IMPROVEMENT-PHASE-A.md)
|
||||
|
||||
### A-1: 4단계 Sonnet 디자인 조정 추가
|
||||
- **현재:** Jinja2 렌더링만 수행. 텍스트 길이에 맞는 디자인 조정 없음.
|
||||
- **CLAUDE.md:** "디자인 실무자 (Sonnet + Jinja2 + CSS) — 편집자가 정리한 텍스트에 맞게 폰트/여백/박스 조정"
|
||||
- **작업:** pipeline.py 4단계에서 render_slide() 호출 전, Sonnet이 블록별 텍스트 길이를 보고 CSS 조정값(font-size, padding, gap 등)을 결정하는 호출 추가
|
||||
- **파일:** pipeline.py, 새 함수 또는 renderer.py 확장
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-2: 5단계 HTML을 프롬프트에 전달
|
||||
- **현재:** `_review_balance(html, ...)` 시그니처에 html 있지만 프롬프트에 미포함. 데이터 길이만 전달.
|
||||
- **CLAUDE.md:** "1차 조립 결과의 전체 균형 확인"
|
||||
- **작업:** _review_balance() 프롬프트에 HTML 구조 요약 또는 전문 포함
|
||||
- **파일:** pipeline.py `_review_balance()`
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-3: 5단계 shrink action 구현
|
||||
- **현재:** `_apply_adjustments()`에서 `action in ("expand", "rewrite")` 조건만 처리. shrink 무시.
|
||||
- **작업:** shrink 시 char_guide 값을 0.7배로 축소하는 분기 추가
|
||||
- **파일:** pipeline.py `_apply_adjustments()`
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-4: 5단계 rewrite action 구현
|
||||
- **현재:** rewrite가 expand와 같은 조건에 들어가지만 실제 동작 없음 (no-op).
|
||||
- **작업:** rewrite 시 해당 블록의 data를 초기화하고 fill_content()로 재편집
|
||||
- **파일:** pipeline.py `_apply_adjustments()`
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-5: overflow:hidden vs "텍스트 자르지 않는다" 원칙 해소
|
||||
- **현재:** base.css에 `.slide { overflow: hidden }` + `.slide > div { overflow: hidden }`. 텍스트 넘치면 잘림.
|
||||
- **CLAUDE.md:** "텍스트를 자르지 않는다 (디자인이 텍스트에 맞춘다)"
|
||||
- **작업:** A-1(Sonnet 디자인 조정)으로 넘침을 사전 방지. `.slide > div`의 overflow를 재검토. 최소한 텍스트 블록은 overflow: visible 또는 auto 허용.
|
||||
- **파일:** static/base.css
|
||||
- **의존성:** A-1 완료 후 정책 결정
|
||||
|
||||
### A-6: object-fit: cover → contain 수정
|
||||
- **현재:** image-row-2col.html, image-grid-2x2.html에서 `object-fit: cover` 사용 → 이미지 crop 발생
|
||||
- **CLAUDE.md:** "이미지를 crop하지 않는다", "object-fit: contain"
|
||||
- **작업:** cover → contain으로 변경
|
||||
- **파일:** templates/blocks/media/image-row-2col.html, templates/blocks/media/image-grid-2x2.html
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-7: table-layout: fixed 적용
|
||||
- **현재:** compare-3col-badge.html에 table-layout 미지정
|
||||
- **CLAUDE.md:** "table-layout: fixed"
|
||||
- **작업:** 테이블 CSS에 table-layout: fixed 추가
|
||||
- **파일:** templates/blocks/tables/compare-3col-badge.html
|
||||
- **의존성:** 없음
|
||||
|
||||
### A-8: container query 폰트 스케일링
|
||||
- **현재:** 표 셀 폰트 크기 고정
|
||||
- **CLAUDE.md:** "container query 폰트 스케일링"
|
||||
- **작업:** @container 규칙으로 표 크기에 따른 폰트 자동 축소
|
||||
- **파일:** templates/blocks/tables/compare-3col-badge.html
|
||||
- **의존성:** A-7
|
||||
|
||||
---
|
||||
|
||||
## Phase B: 누락 기능 구현 (8개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-B.md](IMPROVEMENT-PHASE-B.md)
|
||||
|
||||
### B-1: details-block 템플릿 제작
|
||||
- **현재:** BLOCK_SLOTS에 정의만 있고 HTML 템플릿 파일 없음. 렌더링 불가.
|
||||
- **CLAUDE.md:** "HTML 네이티브 `<details>/<summary>` 사용"
|
||||
- **작업:** `<details>/<summary>` 기반 접기/펼치기 블록 템플릿 제작
|
||||
- **파일:** 신규 templates/blocks/emphasis/details-block.html
|
||||
- **의존성:** 없음
|
||||
|
||||
### B-2: 인쇄 시 details 자동 펼침 JS
|
||||
- **현재:** 미구현
|
||||
- **CLAUDE.md:** "인쇄 시 JavaScript 6줄로 자동 펼침"
|
||||
- **작업:** slide-base.html에 `window.onbeforeprint` 핸들러 추가
|
||||
- **파일:** templates/slide-base.html
|
||||
- **의존성:** B-1
|
||||
|
||||
### B-3: catalog에 details-block 등록
|
||||
- **현재:** catalog.yaml에 미등록 → 팀장이 선택 불가
|
||||
- **작업:** id, when, not_for, slots, height_cost 정의하여 등록
|
||||
- **파일:** templates/catalog.yaml
|
||||
- **의존성:** B-1
|
||||
|
||||
### B-4: 1단계 이미지 상세 판단 필드
|
||||
- **현재:** `content_type: "image"` 한 줄만. 개수/소속/핵심여부/텍스트포함 없음.
|
||||
- **CLAUDE.md:** "몇 개인지, 어떤 꼭지 소속인지, 핵심/보조인지, 텍스트 포함 이미지인지"
|
||||
- **작업:** KEI_PROMPT 출력 형식에 images[] 배열 추가 (count, topic_id, role, has_text)
|
||||
- **파일:** src/kei_client.py KEI_PROMPT
|
||||
- **의존성:** 없음
|
||||
|
||||
### B-5: 1단계 표 상세 판단 필드
|
||||
- **현재:** `content_type: "table"` 한 줄만. 행/열 규모 없음.
|
||||
- **CLAUDE.md:** "행/열 규모, 전체 표시 가능 여부"
|
||||
- **작업:** KEI_PROMPT 출력 형식에 tables[] 배열 추가 (rows, cols, fits_single_page)
|
||||
- **파일:** src/kei_client.py KEI_PROMPT
|
||||
- **의존성:** 없음
|
||||
|
||||
### B-6: ~~catalog에 quote-left-border 등록 여부~~ → 제외 확정
|
||||
- **결정 (2026-03-25):** 등록 안 함. 구 블록 제거 방향 유지. 신규 블록(quote-question)만 사용.
|
||||
- **상태:** 해결됨 (작업 불필요)
|
||||
|
||||
### B-7: ~~catalog에 comparison-2col 등록 여부~~ → 제외 확정
|
||||
- **결정 (2026-03-25):** 등록 안 함. 구 블록 제거 방향 유지. 신규 블록(compare-box, comparison-table)만 사용.
|
||||
- **상태:** 해결됨 (작업 불필요)
|
||||
|
||||
### B-8: fallback_layout에서 card-grid → 신규 블록 교체
|
||||
- **현재:** `_fallback_layout()`에서 삭제된 `"card-grid"` 타입 사용 (design_director.py:438)
|
||||
- **작업:** card-image 또는 topic-header 등 신규 블록으로 교체
|
||||
- **파일:** src/design_director.py `_fallback_layout()`
|
||||
- **의존성:** 없음
|
||||
|
||||
---
|
||||
|
||||
## Phase C: 디자인 원칙 위반 수정 (4개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-C.md](IMPROVEMENT-PHASE-C.md)
|
||||
|
||||
### C-1: ~~HTML/CSS 블록 배경 그라데이션 제거~~ → CLAUDE.md 원칙 완화
|
||||
- **결정 (2026-03-25):** banner-gradient의 그라데이션은 디자인의 핵심. 코드 수정 대신 CLAUDE.md 원칙을 완화.
|
||||
- **작업:** CLAUDE.md의 "HTML/CSS 블록의 배경 그라데이션 금지" → "디자인 의도가 명확한 블록(배너, 오버레이 등)은 허용" 으로 업데이트
|
||||
- **파일:** CLAUDE.md
|
||||
- **상태:** CLAUDE.md 업데이트만 필요
|
||||
|
||||
### C-2: hover 효과 제거
|
||||
- **현재:** compare-3col-badge.html에 `tr:hover` 배경색 변경
|
||||
- **CLAUDE.md:** "호버 효과 금지"
|
||||
- **작업:** :hover 규칙 삭제
|
||||
- **파일:** templates/blocks/tables/compare-3col-badge.html
|
||||
- **의존성:** 없음
|
||||
|
||||
### C-3: border-radius > 8px 수정
|
||||
- **현재:** quote-question(12px), compare-pill-pair(60px), card-dark-overlay(10px), card-text-grid(12px), compare-3col-badge(25px)
|
||||
- **CLAUDE.md:** "둥근 모서리 과다 사용 금지 (border-radius 최대 8px)"
|
||||
- **작업:** 모두 var(--radius)(6px) 또는 최대 8px로 조정
|
||||
- **파일:** 5개 html 파일
|
||||
- **의존성:** 없음. 단, compare-pill-pair(60px)는 "pill" 모양이 디자인 의도 — 이 블록은 SVG 전환(E-2) 시 해결될 수 있음
|
||||
|
||||
### C-4: circle-gradient box-shadow 2레벨 → 1레벨
|
||||
- **현재:** 2개 box-shadow (0 0 30px + 0 0 60px)
|
||||
- **CLAUDE.md:** "그림자 최소화 (1개 레벨만)"
|
||||
- **작업:** shadow 1개로 축소. 또는 SVG 전환(E-1) 시 filter로 대체
|
||||
- **파일:** templates/blocks/visuals/circle-gradient.html
|
||||
- **의존성:** 없음
|
||||
|
||||
---
|
||||
|
||||
## Phase D: 이미지 처리 — Pillow 도입 (6개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-D.md](IMPROVEMENT-PHASE-D.md)
|
||||
> MDX 콘텐츠에 `` 같은 이미지 참조가 포함됨.
|
||||
> 이미지 파일은 로컬 디스크에 존재 (MDX 프로젝트 폴더 기준 상대 경로).
|
||||
> 서버가 localhost에서 돌므로 로컬 파일 접근 가능.
|
||||
|
||||
### D-0: 이미지 경로 입력 UI + API 파라미터 (선행 작업)
|
||||
- **현재:** 프론트엔드에서 텍스트만 전송. 이미지 기준 경로 전달 방법 없음.
|
||||
- **필요 이유:** 이미지 상대 경로(`/assets/images/DX1.png`)를 절대 경로로 해석하려면 base_path 필요.
|
||||
- **작업:**
|
||||
- 프론트엔드(static/index.html): 텍스트에서 `` 패턴 감지 → 발견 시 "이미지 폴더 위치" 입력 팝업 표시
|
||||
- API(src/main.py): `/api/generate` 엔드포인트에 `base_path` 선택 파라미터 추가
|
||||
- 파이프라인(src/pipeline.py): `generate_slide()`에 `base_path` 전달
|
||||
- **파일:** static/index.html, src/main.py, src/pipeline.py
|
||||
- **의존성:** 없음
|
||||
|
||||
### D-1: Pillow 이미지 크기 읽기 유틸리티
|
||||
- **현재:** Pillow import/사용 전무. pyproject.toml에도 없음. src/utils/ 디렉토리 없음.
|
||||
- **CLAUDE.md:** "Pillow Image.open().size (헤더만 읽음)"
|
||||
- **작업:**
|
||||
- pyproject.toml에 `Pillow>=10.0` 추가
|
||||
- 유틸리티 함수: base_path + 상대 경로 → Pillow로 (width, height) 반환
|
||||
- 콘텐츠 텍스트에서 `` 패턴 추출 → 각 이미지 크기 측정
|
||||
- **파일:** pyproject.toml, 신규 src/image_utils.py
|
||||
- **의존성:** D-0 (base_path 전달 체계)
|
||||
|
||||
### D-2: 가로형 이미지(ratio > 1.2) → 전체 너비 배치
|
||||
- **현재:** 비율 기반 배치 판단 없음
|
||||
- **작업:** 파이프라인에서 이미지 크기/비율 정보를 2단계 Step B와 4단계 Sonnet에 전달. 팀장/실무자가 배치 판단.
|
||||
- **파일:** src/pipeline.py 또는 src/design_director.py
|
||||
- **의존성:** D-1
|
||||
|
||||
### D-3: 세로형 이미지(ratio < 0.8) → 텍스트 옆 배치
|
||||
- **현재:** 미구현
|
||||
- **작업:** D-2와 함께 구현. 비율 정보를 프롬프트에 포함하면 AI가 배치 판단.
|
||||
- **파일:** src/design_director.py
|
||||
- **의존성:** D-1
|
||||
|
||||
### D-4: 텍스트 포함 도표 → 과도한 축소 방지
|
||||
- **현재:** 미구현
|
||||
- **작업:** B-4에서 추가한 images[].has_text 정보와 D-1의 크기 정보를 결합. has_text=true이면 "이 이미지는 축소하지 마라" 프롬프트 가이드.
|
||||
- **파일:** src/design_director.py
|
||||
- **의존성:** D-1, B-4 (이미지 상세 판단)
|
||||
|
||||
### D-5: 슬라이드 HTML에 이미지 경로 삽입
|
||||
- **현재:** 이미지 블록(image-row, image-side-text 등)은 src 슬롯에 URL/경로를 넣지만, 실제 이미지 경로가 연결 안 됨.
|
||||
- **작업:** 렌더링된 HTML에서 이미지 상대 경로를 절대 경로 또는 data URI(base64)로 변환하여 다운로드 HTML에서도 이미지 표시.
|
||||
- **파일:** src/renderer.py 또는 src/pipeline.py
|
||||
- **의존성:** D-0, D-1
|
||||
|
||||
---
|
||||
|
||||
## Phase E: visuals 블록 SVG 전환 (3개) — Phase 2 이후 진행
|
||||
|
||||
> **Phase 2 이후로 연기.**
|
||||
> 다른 Claude가 Phase 2에서 `svg_calculator.py`(좌표 계산 모듈) + `renderer.py`에 `_preprocess_svg_data()` + `venn-diagram.html` 동적 템플릿 작업 중.
|
||||
> Phase E는 이 인프라 위에서 나머지 3개 블록을 SVG로 전환하는 작업이므로, Phase 2 완료 후 진행해야 함.
|
||||
>
|
||||
> **활용 방식:** `svg_calculator.py`에 각 블록용 좌표 함수 추가 + `_preprocess_svg_data()`에 블록 등록.
|
||||
>
|
||||
> **P2-D(shrink/rewrite)는 Phase A(A-3/A-4)에서 이미 구현 완료.** 다른 Claude에게 중복 방지 알림 필요.
|
||||
|
||||
### E-1: circle-gradient → SVG
|
||||
- **현재:** CSS border-radius + linear-gradient
|
||||
- **작업:** SVG `<circle>` + `<radialGradient>` + `<text>`로 재제작. `svg_calculator.py`에 함수 추가.
|
||||
- **파일:** templates/blocks/visuals/circle-gradient.html, src/svg_calculator.py
|
||||
- **의존성:** Phase 2 P2-B 완료
|
||||
|
||||
### E-2: compare-pill-pair → SVG
|
||||
- **현재:** HTML div + CSS border-radius: 60px
|
||||
- **작업:** SVG `<rect rx="30">` + `<text>`로 재제작. C-3 border-radius 위반도 해소.
|
||||
- **파일:** templates/blocks/visuals/compare-pill-pair.html
|
||||
- **의존성:** Phase 2 P2-B 완료
|
||||
|
||||
### E-3: process-horizontal → SVG
|
||||
- **현재:** HTML/CSS flexbox + 가상 화살표
|
||||
- **작업:** SVG `<circle>` + `<line>` + `<polygon>` + `<text>`로 재제작. `svg_calculator.py`에 함수 추가.
|
||||
- **파일:** templates/blocks/visuals/process-horizontal.html, src/svg_calculator.py
|
||||
- **의존성:** Phase 2 P2-B 완료
|
||||
|
||||
---
|
||||
|
||||
## Phase F: 향후 — Phase 2 이후 (6개)
|
||||
|
||||
### F-1: Step A를 AI 선택으로 전환
|
||||
- **현재:** 규칙 기반 4줄 코드 (CLAUDE.md에는 "규칙 기반"으로 명시)
|
||||
- **대화에서 요청됨:** AI가 프리셋을 선택하도록 변경
|
||||
- **작업:** select_preset()을 Sonnet 호출로 전환. CLAUDE.md 업데이트.
|
||||
- **의존성:** CLAUDE.md 원칙 변경 합의
|
||||
|
||||
### F-2: Gemini API 배경 생성
|
||||
- **현재:** 미구현
|
||||
- **CLAUDE.md:** "실사 배경: Gemini API (배경 텍스처 전용)"
|
||||
- **작업:** section-title-with-bg 등의 배경 이미지를 Gemini로 생성
|
||||
- **의존성:** Gemini API 키
|
||||
|
||||
### F-3: FAISS 블록 검색
|
||||
- **현재:** 미구현 (PLAN.md DA-20)
|
||||
- **CLAUDE.md:** "변형 40개 이상부터 FAISS 도입 검토"
|
||||
- **작업:** 블록 HTML 구조/용도 임베딩 → FAISS 인덱스 → 검색
|
||||
- **의존성:** DA-19 (변형 40개+)
|
||||
|
||||
### F-4: venn-diagram N개 자동 배치 (cos/sin)
|
||||
- **현재:** Phase 1 — 3개 고정 SVG
|
||||
- **CLAUDE.md:** "Phase 2: N개 자동 배치 (360/N 간격, cos/sin)"
|
||||
- **작업:** renderer에서 items 개수에 따라 좌표 계산 후 템플릿에 전달
|
||||
- **의존성:** 없음
|
||||
|
||||
### F-5: Figma REST API 연동
|
||||
- **현재:** 수동 에셋만 (docs/figma-assets/)
|
||||
- **작업:** Figma API로 에셋 자동 추출
|
||||
- **의존성:** Figma API 키
|
||||
|
||||
### F-6: .astro (Starlight) 출력
|
||||
- **현재:** HTML 다운로드만
|
||||
- **작업:** HTML → .astro 변환 출력 옵션
|
||||
- **의존성:** Starlight 연동 설계
|
||||
|
||||
---
|
||||
|
||||
## Phase G: Kei API 통신 정상화 (4개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-G.md](IMPROVEMENT-PHASE-G.md)
|
||||
> design_agent만 수정. persona_agent 코드 수정 0건.
|
||||
|
||||
### G-1: httpx non-streaming → streaming 전환 (핵심)
|
||||
- **문제:** httpx `client.post()`가 SSE 전체 응답 완료까지 대기 (30분+)
|
||||
- **해결:** `client.stream("POST", ...)`로 전환. SSE 토큰 실시간 수신.
|
||||
- **파일:** kei_client.py, content_editor.py, design_director.py
|
||||
|
||||
### G-2: Sonnet fallback 완전 제거
|
||||
- **문제:** 사용자 요청 "Kei API만 사용"인데 Sonnet fallback이 남아있음
|
||||
- **해결:** fallback 분기 제거. Kei API 실패 시 에러 반환.
|
||||
- **파일:** kei_client.py, content_editor.py
|
||||
|
||||
### G-3: `_parse_json()` 마크다운 제거 3파일 동기화
|
||||
- **문제:** kei_client.py에만 `- ` 제거 있고, content_editor/design_director에 없음
|
||||
- **해결:** 3개 파일의 `_parse_json()` 동기화
|
||||
- **파일:** content_editor.py, design_director.py
|
||||
|
||||
### G-4: FAISS를 CPU로 전환 (GPU 메모리 경쟁 해소)
|
||||
- **문제:** persona_agent + design_agent가 같은 GPU 경쟁 → OOM
|
||||
- **해결:** design_agent의 FAISS를 CPU로 전환 (46개 블록이므로 충분히 빠름)
|
||||
- **파일:** block_search.py, build_block_index.py
|
||||
|
||||
---
|
||||
|
||||
## Phase H: 스토리라인 설계 기반 파이프라인 전환 (4개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-H.md](IMPROVEMENT-PHASE-H.md)
|
||||
> 코드 구조 변경 없음. 프롬프트 3개만 수정. 원본 텍스트 최대 보존.
|
||||
|
||||
### H-1: KEI_PROMPT 재설계 — "꼭지 추출" → "스토리라인 설계"
|
||||
- **문제:** 꼭지만 추출하고 전체 스토리 흐름 없음
|
||||
- **해결:** "이 슬라이드의 스토리를 설계해줘" + core_message + purpose + source_hint
|
||||
- **파일:** kei_client.py
|
||||
|
||||
### H-2: EDITOR_PROMPT 수정 — 원본 텍스트 최대 보존
|
||||
- **문제:** "세련된 편집"으로 과도한 재작성
|
||||
- **해결:** "원본 보존, 약간만 축약, 빈 슬롯 금지"
|
||||
- **파일:** content_editor.py
|
||||
|
||||
### H-3: STEP_B_PROMPT 보강 — purpose 기반 블록 선택
|
||||
- **문제:** 형태만 보고 블록 매칭. 목적 모름.
|
||||
- **해결:** purpose별 블록 선택 가이드 (문제제기→경고, 정의→카드 등)
|
||||
- **파일:** design_director.py
|
||||
|
||||
### H-4: 3단계 편집자에게 purpose 전달
|
||||
- **문제:** 편집자가 "이 블록이 왜 여기 있는지" 모름
|
||||
- **해결:** slot_requirements에 purpose 포함
|
||||
- **파일:** content_editor.py
|
||||
|
||||
---
|
||||
|
||||
## Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개) ✅ 완료
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-I.md](IMPROVEMENT-PHASE-I.md)
|
||||
> 전수 검토에서 발견된 프롬프트 자기모순 + 슬롯 의미 미전달 + 코드 안전망 부족 해결.
|
||||
> **핵심 변경: 넘침 시 DOWNGRADE_MAP 자동 교체 → Kei 판단 호출로 전환.**
|
||||
|
||||
### 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 제거, 트리/개수 정리)
|
||||
|
||||
### 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`)
|
||||
|
||||
### 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 프롬프트에 포함
|
||||
|
||||
---
|
||||
|
||||
## 프로세스 재검토: 검증 시점 문제 (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` 전부 재사용. 호출 위치만 이동.
|
||||
|
||||
---
|
||||
|
||||
## Phase J: 블록 선택 권한 구조 재정의 + 최종 검토 Kei 전환 (7개) ✅ 완료
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-J.md](IMPROVEMENT-PHASE-J.md)
|
||||
> Phase I 완료 후 결과물 3회 비교에서 확인. Sonnet(팀장)이 Opus(실장) 추천을 엎고, 자기가 만든 문제를 자기가 검토하는 구조적 문제.
|
||||
|
||||
### Phase J-A: 팀장 권한 제한 + 가이드 수정 (5개)
|
||||
- J-1: STEP_B_PROMPT "Opus 추천 존중" 규칙 강화 — "참고" → "기본 사용, 변경 금지"
|
||||
- J-2: section-header-bar body 사용 금지 — BODY_FORBIDDEN_MAP에 추가 (삭제 처리)
|
||||
- J-3a: purpose 가이드 수정 — 용어정의/근거사례에서 card-icon-desc 제거 → card-numbered
|
||||
- J-3b: catalog.yaml 수정 — "용어 정의 → card-icon-desc" → "card-numbered"
|
||||
- J-6: sidebar 카드 1열 강제 — 템플릿 column_override + design_director 주입
|
||||
|
||||
### Phase J-B: 편집자 강화 (1개)
|
||||
- J-4: source 슬롯 금지 규칙 — EDITOR_PROMPT에 출처 규칙 추가 (Kei 편집자 경유)
|
||||
|
||||
### Phase J-C: 최종 검토 Kei 전환 (1개)
|
||||
- J-7: Stage 5 _review_balance() → Kei API 호출로 전환 — KEI_REVIEW_PROMPT + call_kei_final_review() 신규
|
||||
|
||||
---
|
||||
|
||||
## Phase K: communicative role 기반 시각적 위계 + 콘텐츠 시퀀싱 (8개)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-K.md](IMPROVEMENT-PHASE-K.md)
|
||||
> Phase J 이후에도 결과물 품질 미개선. purpose를 분류하고도 시각적 결과에 반영하지 않은 것이 근본 원인.
|
||||
> 사용자 반복 요청(콘텐츠 구조 흐름)을 이번에 전부 반영.
|
||||
|
||||
### K-Step 1: 콘텐츠 설계 (가장 중요)
|
||||
- K-1: purpose → 시각적 위계 매핑 (핵심전달=주인공, 문제제기=compact)
|
||||
- K-2: purpose 기반 인지 흐름 순서 원칙 (하드코딩 아닌 원칙)
|
||||
- K-4: purpose별 분량 제약 (문제제기 max 100자, 핵심전달 200-400자 등)
|
||||
|
||||
### K-Step 2: 블록 선택 정확성
|
||||
- K-3: purpose별 허용/금지 블록 매핑
|
||||
- K-6: sidebar 시각적 무게 조절
|
||||
- K-8: 비교 블록 맥락 안내
|
||||
|
||||
### K-Step 3: 코드 + 검수
|
||||
- K-5: column_override 보존 (content_editor.py)
|
||||
- K-7: Kei 검수에 구조 흐름 검증 추가
|
||||
|
||||
---
|
||||
|
||||
## Phase K-1: 파이프라인 스텝별 중간 산출물 로컬 저장
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-K1.md](IMPROVEMENT-PHASE-K1.md)
|
||||
> 각 스텝에서 뭘 결정했고 왜 그렇게 했는지를 파일로 저장. 사용자가 확인하고 피드백 가능.
|
||||
|
||||
- `data/runs/{timestamp}/` 폴더에 step별 JSON + HTML 저장
|
||||
- step1 (Kei 분석) → step2 (블록 매핑) → step3 (텍스트) → step4 (렌더링) → step5 (검수) → final
|
||||
|
||||
---
|
||||
|
||||
## Phase L: 렌더링 측정 에이전트 + Purpose 기반 공간 할당 + 수학적 조정 (11건)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-L.md](IMPROVEMENT-PHASE-L.md)
|
||||
> Phase I~K에서 미충족 7건 + 부분충족 4건의 근본 원인: 실제 렌더링 px 측정 없음.
|
||||
> LLM 추정이 아닌 코드 계산 + 브라우저 측정으로 전환.
|
||||
|
||||
### L-Step 1: 공간 할당 엔진
|
||||
- PURPOSE_WEIGHT 비율 할당 + allocate_height_budget() 함수
|
||||
- calculate_trim_chars() 수학적 글자 수 계산
|
||||
|
||||
### L-Step 2: 렌더링 측정 에이전트
|
||||
- measure_rendered_heights() — Selenium headless
|
||||
- 각 zone/block의 scrollHeight, clientHeight, overflow 정확 측정
|
||||
|
||||
### L-Step 3: CSS max-height 제약
|
||||
- purpose별 할당 높이를 CSS max-height로 적용
|
||||
- 물리적으로 넘치지 않게 구조적 보장
|
||||
|
||||
### L-Step 4: 피드백 루프
|
||||
- 측정 → 초과 시 수학적 축약량 계산 → 편집자 재호출 → 재측정
|
||||
- Kei 검수에 실제 px 수치 전달 → 근거 있는 검수
|
||||
|
||||
---
|
||||
|
||||
## Phase M: 비중 시스템 + 역할-블록 매핑 + 블록 안전성 + 원본 보존 (9건)
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-M.md](IMPROVEMENT-PHASE-M.md)
|
||||
> P-1~P-9 문제점 전수 진단. 비중 시스템(Kei 판단, 하드코딩 아님) 기반 전면 재설계.
|
||||
|
||||
### M-Step 1: [긴급] Kei 비중 시스템 (P-1 + P-2 + P-4)
|
||||
- Kei가 콘텐츠마다 본심/배경/첨부/결론 + weight 판단
|
||||
- PURPOSE_WEIGHT 하드코딩 제거 → Kei 출력 weight 사용
|
||||
- weight → px 변환 → 블록 크기/배치 자동 결정
|
||||
|
||||
### M-Step 2: [중요] 역할-블록 매핑 (P-3)
|
||||
- 역할 × 콘텐츠 성격 → 블록 결정 매트릭스
|
||||
|
||||
---
|
||||
|
||||
## Phase N: 4대 핵심 문제 해결 ✅ 완료
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-N.md](IMPROVEMENT-PHASE-N.md)
|
||||
> catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 체계.
|
||||
|
||||
- N-1: 블록 선택 코드 레벨 강제 — Kei 확정 블록을 Sonnet이 변경 불가 + topic_id/id 양쪽 체크
|
||||
- N-2: 사이드바 섹션 제목 — Kei가 section_title 출력 + divider-text 자동 삽입
|
||||
- N-3: max-height CSS 래퍼 제거 — 콘텐츠는 _max_chars로 사전 조절, CSS로 사후 자르기 금지
|
||||
- N-4: Stage 5 스크린샷 검수 — Selenium 스크린샷 → Opus 멀티모달로 실제 렌더링 보고 검수
|
||||
- **Kei API 무한 재시도** — 모든 Kei API 호출을 성공할 때까지 무한 재시도. fallback/기본값/rule-based 대체 전면 제거
|
||||
- **catalog.yaml 전면 개선** — 38개 블록의 when/not_for/purpose_fit 재작성 + FAISS 인덱스 재빌드
|
||||
- **삭제:** manual_classify(), _apply_defaults(), _downgrade_fallback(), PURPOSE_FALLBACK 대체용 코드
|
||||
|
||||
---
|
||||
|
||||
## Phase O: 컨테이너 기반 레이아웃 시스템 ✅ 완료
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-O.md](IMPROVEMENT-PHASE-O.md)
|
||||
> Phase N 완료 후 여전히 비중이 시각에 반영 안 되는 근본 문제 해결.
|
||||
|
||||
**핵심 원칙:** "비중이 컨테이너를 확정 → 컨테이너가 블록을 제약 → 블록이 콘텐츠를 제약"
|
||||
|
||||
- O-1: 컨테이너 스펙 계산 — ✅ 완료 (calculate_container_specs)
|
||||
- O-2: 블록 선택에 컨테이너 제약 전달 — ✅ 완료 (Kei 프롬프트 + height_cost 검증)
|
||||
- O-3: 블록 스펙 확정 — ✅ 완료 (finalize_block_specs)
|
||||
- O-4: 편집자에 블록 스펙 전달 — ✅ 완료 (_container_height_px, _max_items 등)
|
||||
- O-5: 렌더러 비중 기반 grid row — ✅ 완료 (container div 생성)
|
||||
- O-6: 파이프라인 흐름 변경 — ✅ 완료 (Phase M 코드 교체)
|
||||
- O-7: 리포트 확장 — 🟡 미완 (새 중간 산출물 표시 추가 필요)
|
||||
- **미세 조정 필요:** 배경 117px / topic 2개 = 58px에 medium 블록 안 맞는 문제
|
||||
- **Selenium 측정:** container div 셀렉터 추가 필요
|
||||
|
||||
### Step B 제거 + 죽은 코드 정리 ✅ 완료
|
||||
|
||||
Phase O에서 Kei(A-2) + 코드가 모든 것을 결정하면서 Step B(Sonnet)가 완전히 무력화됨 → 제거.
|
||||
|
||||
**삭제된 코드:**
|
||||
- `STEP_B_PROMPT` (~100줄 프롬프트)
|
||||
- Step B Sonnet API 호출 코드 (~250줄)
|
||||
- `_fallback_layout()` (Step B 실패 시 rule-based)
|
||||
- `PURPOSE_FALLBACK` (미등록 블록 대체)
|
||||
- `DOWNGRADE_MAP` (블록 다운그레이드)
|
||||
- `_downgrade_fallback()` (비상 교체)
|
||||
- `_apply_defaults()` (편집 실패 시 기본값)
|
||||
- `import anthropic` (design_director.py에서)
|
||||
- O-6: 파이프라인 흐름 변경 — 1B 후 컨테이너 계산, Step B 후 블록 스펙 확정
|
||||
- O-7: 리포트에 컨테이너/블록 스펙 표시
|
||||
|
||||
**기존 코드 교체 (충돌 해결):**
|
||||
- `_max_height_px` → `_container_height_px` (pipeline.py 155~198행 교체)
|
||||
- `allocate_height_budget()` → `calculate_container_specs()` (호출부 교체)
|
||||
- `_max_chars` 단일값 → `_max_items` + `_max_chars_per_item` (content_editor.py 교체)
|
||||
- Selenium `_MEASURE_SCRIPT` — container div 셀렉터 추가
|
||||
- Phase L 축소 로직 — `_max_chars_total` 축소로 변경
|
||||
- fonttools 의존성 + Pretendard .ttf 파일 추가
|
||||
|
||||
---
|
||||
|
||||
## Phase P: 블록 재구성 + 실제 렌더링 비교 선택 ✅ 실행 완료 → Phase Q로 전환
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-P.md](IMPROVEMENT-PHASE-P.md)
|
||||
> **실행 결과:** `data/runs/1774599277829/` — 최종 품질 20/100점
|
||||
> **결론:** 다후보 렌더링 비교 방식은 비효율적 (15렌더링 40분, 10개 폐기). 업계 조사 결과 어떤 도구도 이 방식을 사용하지 않음. Phase Q로 방향 전환.
|
||||
|
||||
---
|
||||
|
||||
## Phase Q: 제약 기반 블록 선택 + 글자수 예산 시스템 ✅ 코드 완료
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-Q.md](IMPROVEMENT-PHASE-Q.md)
|
||||
> Phase P 결과 분석 + 업계 조사(Beautiful.ai, Napkin.ai, VASCAR, PPTAgent 등) 기반 재설계.
|
||||
|
||||
**핵심 원칙:** "계산 먼저, AI 판단 나중에, 렌더링은 검증만"
|
||||
|
||||
**실행 스텝 (8개):**
|
||||
- Q-1: catalog.yaml 메타데이터 보강 (min_height_px, relation_types, category, min/max_items)
|
||||
- Q-2: relation_type → 블록 카테고리 결정론적 매핑 엔진 (신규 `src/block_selector.py`)
|
||||
- Q-3: 글자수 예산 계산 엔진 (`src/space_allocator.py` 추가)
|
||||
- Q-4: Kei 블록 선택 프롬프트 재설계 — 필터링된 2-3개만 제시 (`src/kei_client.py`)
|
||||
- Q-5: pipeline.py 재구성 — Phase P 15-render 루프 → Phase Q 단일 경로
|
||||
- Q-6: 비전 모델 품질 게이트 (VASCAR식, `src/kei_client.py`)
|
||||
- Q-7: overflow 수학적 조정 (LaTeX 글루 모델, `src/space_allocator.py`)
|
||||
- Q-8: 출력 차단 정책 (overflow/품질 미달 시 출력 금지)
|
||||
|
||||
**기대 효과:** 품질 20→70-80점, 시간 40분→8-12분, API 25→8회, 유령블록 불가능
|
||||
|
||||
**해결하는 근본 문제 5가지:**
|
||||
| # | 근본 원인 | Phase Q 해결 방법 |
|
||||
|---|----------|-----------------|
|
||||
| R1 | FAISS 텍스트 매칭 → 시각 블록 무시 | relation_type → 블록 카테고리 결정론적 매핑 (Q-2) |
|
||||
| R2 | Opus 유령 블록 환각 | catalog 존재 검증 + 필터링된 후보만 제시 (Q-2, Q-4) |
|
||||
| R3 | overflow 해결 못하고 출력 | 글자수 예산 사전 계산 + 글루 모델 + 출력 차단 (Q-3, Q-7, Q-8) |
|
||||
| R4 | 블록 중복 사용 | used_blocks 집합으로 중복 차단 (Q-2) |
|
||||
| R5 | 공간 배분 일방향 강제 | min_height_px 필터 + 비중 재조정 요청 (Q-1, Q-2) |
|
||||
|
||||
---
|
||||
|
||||
## Phase별 의존 관계
|
||||
|
||||
```
|
||||
Phase A (슬라이드 품질)
|
||||
├── A-1~A-4: 독립 작업 가능
|
||||
├── A-5: A-1 완료 후
|
||||
├── A-6, A-7: 독립
|
||||
└── A-8: A-7 완료 후
|
||||
|
||||
Phase B (누락 기능)
|
||||
├── B-1~B-5, B-8: 독립
|
||||
├── B-2, B-3: B-1 완료 후
|
||||
└── B-6, B-7: 사용자 결정 대기
|
||||
|
||||
Phase C (디자인 원칙) → 독립. A/B와 병렬 가능.
|
||||
└── C-1: 사용자 결정 대기 (원칙 변경 vs 코드 수정)
|
||||
|
||||
Phase D (Pillow) → D-1 선행, 나머지 순차
|
||||
└── D-4: B-4 완료 후
|
||||
|
||||
Phase E (SVG 전환) → 독립. A/B/C와 병렬 가능.
|
||||
|
||||
Phase F (향후) → Phase A~E 완료 후.
|
||||
└── F-1: CLAUDE.md 합의 후
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase R: 하이브리드 블록 시스템 ❌ 실패
|
||||
|
||||
> **기록:** [IMPROVEMENT-PHASE-R.md](IMPROVEMENT-PHASE-R.md)
|
||||
> 접근 C로 가기로 합의했으나, 구현에서 기존 블록 선택 시스템 위에 variant 패치만 추가.
|
||||
> **P = Q = R 동일 구조.** 결과물 34점.
|
||||
|
||||
---
|
||||
|
||||
## Phase R': 접근 C — 블록 CSS 참고 + AI 구조 결정 📋 설계 확정
|
||||
|
||||
> **실행 상세:** [IMPROVEMENT-PHASE-R-PRIME.md](IMPROVEMENT-PHASE-R-PRIME.md)
|
||||
|
||||
**핵심 전환:** 블록이 구조를 결정 → **콘텐츠가 구조를 결정, 블록 CSS는 참고만**
|
||||
|
||||
**2-3단계 교체:**
|
||||
- 제거: block_selector(블록 선택), fill_candidates(슬롯 채우기)
|
||||
- 추가: html_generator(AI가 HTML 구조 직접 생성)
|
||||
|
||||
**실행 스텝 (7개):**
|
||||
- R'-1: 디자인 토큰 + 블록 CSS 패턴을 프롬프트용으로 추출 (`src/design_tokens.py`)
|
||||
- R'-2: few-shot 예시 슬라이드 정리 (`data/examples/`)
|
||||
- R'-3: AI HTML 생성 함수 구현 (`src/html_generator.py`)
|
||||
- R'-4: pipeline.py 2-3단계 교체 (블록 선택+채우기 → html_generator)
|
||||
- R'-5: 렌더러에 AI HTML 삽입 함수 추가 (`src/renderer.py`)
|
||||
- R'-6: HTML 정화 + 토큰 위반 검증 (`src/html_validator.py`)
|
||||
- R'-7: 테스트 2개 콘텐츠 검증 (`scripts/test_phase_r_prime.py`)
|
||||
|
||||
**합격 기준:** C_reference.png 수준 자동 생성 (topic 합침, 포함 관계, 핵심 메시지, 원본 보존)
|
||||
|
||||
**회귀 방지:** block_selector, fill_candidates, fill_content, finalize_block_specs 호출 금지
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-25 | 초안 작성. CLAUDE.md 전수검토 기반 33개 항목 도출. |
|
||||
| 2026-03-28 | Phase P 실행 완료(20/100점). 업계 조사 기반 Phase Q 설계 확정. |
|
||||
| 2026-03-30 | Phase Q 코드 완료. Phase R 설계+구현 → 실패(기존 구조 회귀). Phase R' 설계 확정. |
|
||||
150
docs/history/PHASE-T-PRIME.md
Normal file
150
docs/history/PHASE-T-PRIME.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Phase T' (T-Prime) — 결과물 품질 개선
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 근거: Phase T 파이프라인 실행 결과물(20260402_083722) 시각 검토에서 발견된 6건
|
||||
> 선행: Phase T 파이프라인 구조 완성 (Stage 0~5 동작, 프롬프트 동적 생성 교체 완료)
|
||||
|
||||
---
|
||||
|
||||
## 발견된 문제 6건
|
||||
|
||||
### TP-1. 배경 영역이 다크로 가장 눈에 띔
|
||||
|
||||
**현상:** 배경(보조 영역)이 dark-bullet-list의 다크 배경(#1a2332)을 사용하여 슬라이드에서 가장 강조됨. 본심(핵심)보다 배경에 시선이 먼저 감.
|
||||
|
||||
**원인:** Stage 1.7(block_reference.py)에서 배경 역할에 cause_effect → dark-bullet-list를 선택. dark-bullet-list는 다크 배경 블록이므로 배경 역할에 부적합.
|
||||
|
||||
**해결 방향:**
|
||||
- block_reference.py에서 **배경 역할은 다크 계열 블록 제외** 규칙 추가
|
||||
- 배경용 블록 후보: 라이트 계열만 (card-numbered, card-icon-desc, callout-solution 등)
|
||||
- 또는 배경 역할 전용 매핑 추가: cause_effect + 배경 → callout-solution (라이트 파란 배경)
|
||||
|
||||
**수정 파일:** `src/block_reference.py`
|
||||
|
||||
---
|
||||
|
||||
### TP-2. 본심 이미지만 크고 메시지 전달 불명확
|
||||
|
||||
**현상:** 본심 영역에 벤 다이어그램 이미지가 크게 차지하고, 텍스트가 아래에 밀려있어서 무슨 메시지를 전달하려는지 불명확.
|
||||
|
||||
**원인:** 본심 프롬프트(build_area_prompt)에서 "이미지와 텍스트의 배치 관계", "핵심 메시지를 어떻게 시각적으로 강조할지" 지시가 부족.
|
||||
|
||||
**해결 방향:**
|
||||
- 본심 프롬프트에 추가:
|
||||
- "텍스트가 주인공. 이미지는 텍스트를 보조하는 역할"
|
||||
- "이미지는 float:right 또는 텍스트 옆에 배치. 이미지가 전체 폭을 차지하면 안 됨"
|
||||
- "핵심 메시지(key-msg)가 시각적으로 가장 눈에 띄어야 함 — 배경색 + 큰 폰트"
|
||||
- 이미지가 있을 때 레이아웃: 텍스트 좌측 + 이미지 우측 float, 또는 2단 구성
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 본심 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-3. 용어정의(sidebar) 오른쪽 잘림
|
||||
|
||||
**현상:** sidebar 카드의 텍스트가 오른쪽에서 잘려서 안 보임.
|
||||
|
||||
**원인 후보:**
|
||||
1. Sonnet이 생성한 HTML의 width가 sidebar 컨테이너(380px)를 초과
|
||||
2. 카드 내부 padding + 텍스트가 너비를 넘침
|
||||
3. word-break: keep-all이 긴 영문(Building Information Modeling)을 줄바꿈하지 않음
|
||||
|
||||
**해결 방향:**
|
||||
- build_area_prompt 첨부 섹션에 추가:
|
||||
- "word-break: break-word (긴 영문 줄바꿈)"
|
||||
- "각 카드 width: 100%. 카드 내부 padding 포함하여 컨테이너 안에 맞출 것"
|
||||
- "텍스트가 잘리면 안 됨. 넘치면 폰트를 줄여서 맞출 것"
|
||||
- sidebar 폰트가 10px인데, 긴 영문 제목이 있으면 더 줄여야 할 수 있음
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 첨부 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-4. 불릿 2줄째 들여쓰기 불일치
|
||||
|
||||
**현상:** 불릿(•) 첫줄 텍스트 시작점과 2줄째 시작점이 일직선이 아님. 여러 영역에서 공통.
|
||||
|
||||
**원인:** build_area_prompt에서 `padding-left/text-indent` 지시가 있지만 Sonnet이 일관되게 안 따름.
|
||||
|
||||
**해결 방향:**
|
||||
- 프롬프트에 **구체적 HTML 예시**를 포함하여 강제:
|
||||
```
|
||||
불릿 예시 (이 HTML을 정확히 따라라):
|
||||
<div style="padding-left:14px; text-indent:-14px;">• 첫줄 텍스트가 여기서 시작하고
|
||||
둘째줄도 정확히 같은 위치에서 시작해야 한다</div>
|
||||
```
|
||||
- 들여쓰기 CSS를 프롬프트가 아니라 **후처리(Stage 3)에서 강제 적용**하는 것도 고려
|
||||
- 생성된 HTML에서 `• ` 로 시작하는 텍스트를 찾아 padding-left/text-indent를 코드로 주입
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 공통) + 선택적으로 `src/renderer.py` (후처리)
|
||||
|
||||
---
|
||||
|
||||
### TP-5. 팝업 링크 위치 부적절
|
||||
|
||||
**현상:** "[DX와 BIM의 구분 상세보기]" 링크가 본문 하단에 한 줄로 떡하니 놓여있음. 본문의 흐름을 방해.
|
||||
|
||||
**원인:** build_area_prompt에서 "상세보기 링크를 어디에 배치하라"는 위치 지시가 없음.
|
||||
|
||||
**해결 방향:**
|
||||
- 본심 프롬프트에 추가:
|
||||
- "상세보기 링크는 관련 내용의 우측 상단에 작게 배치 (font-size: 10px, color: #2563eb, 우측 정렬)"
|
||||
- "본문 흐름 중간에 넣지 마라. 해당 섹션의 헤더 옆에 배치"
|
||||
- 예시:
|
||||
```html
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3>DX와 핵심기술의 올바른 관계</h3>
|
||||
<a style="font-size:10px; color:#2563eb;">상세보기 →</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 본심 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-6. 첨부 HTML 디자인 없음
|
||||
|
||||
**현상:** 첨부1_혼용 대표 사례.html, 첨부2_DX와 BIM의 구분.html이 raw MDX content를 그냥 HTML로 감싼 것. 테이블 스타일은 있지만 전체 디자인이 없음.
|
||||
|
||||
**원인:** Stage 5에서 popup.content를 `<body>` 안에 그대로 넣음. Sonnet에게 디자인을 시키지 않음.
|
||||
|
||||
**해결 방향:**
|
||||
- 첨부 HTML도 Sonnet에게 디자인 요청
|
||||
- 또는 슬라이드와 동일한 디자인 토큰(tokens.css + base.css)을 적용한 템플릿 사용
|
||||
- 첨부 HTML은 슬라이드(1280x720)가 아니라 **A4 세로 문서 형태** (읽기 쉬운 형태)
|
||||
|
||||
**수정 파일:** `src/pipeline.py` (Stage 5 팝업 HTML 생성 부분)
|
||||
|
||||
---
|
||||
|
||||
## 수정 분류
|
||||
|
||||
| 분류 | 관련 문제 | 수정 파일 | 규모 |
|
||||
|------|----------|----------|------|
|
||||
| **A. 블록 선택 규칙** | TP-1 | block_reference.py | 작음 |
|
||||
| **B. 프롬프트 강화** | TP-2, TP-3, TP-4, TP-5 | html_generator.py | 중간 |
|
||||
| **C. 들여쓰기 후처리** | TP-4 | renderer.py (선택) | 작음 |
|
||||
| **D. 첨부 HTML 디자인** | TP-6 | pipeline.py | 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
TP-1 (블록 선택 규칙) → 작음, 독립
|
||||
TP-2~5 (프롬프트 강화) → 중간, build_area_prompt 한 곳에서 처리
|
||||
TP-4 추가 (들여쓰기 후처리) → 작음, 프롬프트로 안 되면 코드로 강제
|
||||
TP-6 (첨부 HTML 디자인) → 중간, 독립
|
||||
→ 전체 재실행 + 시각 검토
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 검증 기준
|
||||
|
||||
- [ ] 배경이 라이트 톤. 본심이 가장 눈에 띄는가
|
||||
- [ ] 본심에서 텍스트가 주인공이고 이미지가 보조인가
|
||||
- [ ] 용어정의가 잘리지 않고 전부 보이는가
|
||||
- [ ] 모든 영역에서 불릿 2줄째가 첫줄과 일직선인가
|
||||
- [ ] 팝업 링크가 우측 상단에 작게 있는가
|
||||
- [ ] 첨부 HTML이 디자인된 문서 형태인가
|
||||
135
docs/history/PHASE-T-REMAINING.md
Normal file
135
docs/history/PHASE-T-REMAINING.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Phase T 잔여 작업 — 프롬프트 동적 생성 + 미반영 사항
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 상태: Phase T 구조(Stage 0~5)는 완성. 하지만 핵심인 프롬프트가 Phase S 하드코딩 그대로.
|
||||
> 이 문서: 프롬프트 교체 + 미반영 사항 전체 리스트 + 실행 계획.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 문제 리스트
|
||||
|
||||
### 1-1. 프롬프트 하드코딩 (근본 문제)
|
||||
|
||||
html_generator.py의 BG_PROMPT, CORE_PROMPT, SIDEBAR_PROMPT, FOOTER_PROMPT 4개가
|
||||
Phase S 때 만든 고정 CSS 값(9px, 10px, #f8fafc, padding:14px 등)으로 박혀있음.
|
||||
Phase T에서 계산한 폰트 위계, 디자인 예산, 블록 레퍼런스가 결과에 반영되지 않는 근본 원인.
|
||||
|
||||
### 1-2. 폰트 크기 위계 미반영
|
||||
|
||||
Phase T Stage 1.5a에서 계산: 핵심=14px, 본심=12px, 배경=11px, 첨부=10px.
|
||||
하지만 프롬프트가 배경=9px, 첨부=10px 등 다른 값을 하드코딩.
|
||||
→ 프롬프트가 Phase T 위계 값을 사용해야 함.
|
||||
|
||||
### 1-3. 배경-본심 가로 길이 불일치
|
||||
|
||||
sidebar-right 구조에서 배경과 본심은 같은 body zone에 있으므로 가로 폭이 동일해야 함.
|
||||
현재 프롬프트가 각각 다른 width를 지정할 수 있어서 Sonnet이 다르게 생성.
|
||||
→ "body 영역 전체 폭 100%" 강제.
|
||||
|
||||
### 1-4. 들여쓰기 불일치
|
||||
|
||||
불릿(•) 첫째줄 텍스트 시작점과 둘째줄 시작점이 정확히 일직선이어야 함.
|
||||
글씨 크기가 영역마다 다르므로(배경 11px, 본심 12px, 첨부 10px) 들여쓰기 px도 각 폰트에 맞게.
|
||||
padding-left와 text-indent를 폰트 크기 기준으로 계산.
|
||||
|
||||
계산 방식:
|
||||
- 불릿 마커 "• " 폭 ≈ font_size × 1.2 (한글 기준)
|
||||
- padding-left = ceil(font_size × 1.2)
|
||||
- text-indent = -padding-left
|
||||
|
||||
| 영역 | 폰트 | padding-left | text-indent |
|
||||
|------|------|-------------|-------------|
|
||||
| 배경 (11px) | 11px | 14px | -14px |
|
||||
| 본심 (12px) | 12px | 15px | -15px |
|
||||
| 첨부 (10px) | 10px | 12px | -12px |
|
||||
|
||||
### 1-5. 블록 선택 → 컨테이너 맞춤 재구성 미반영
|
||||
|
||||
Phase T의 핵심 목적:
|
||||
- Stage 1.7에서 relation_type + expression_hint → 참고 블록 선택
|
||||
- 선택된 블록의 구조(색상, 레이아웃, 패턴)를 따르되 컨테이너 크기에 맞게 재구성
|
||||
- AI가 "발명"하지 않고 검증된 블록 구조를 따르게
|
||||
|
||||
현재: Stage 1.7이 블록을 선택하고 레퍼런스 HTML을 생성하지만,
|
||||
프롬프트가 이것을 무시하고 하드코딩 CSS를 따름.
|
||||
→ 프롬프트가 레퍼런스 HTML을 "따르라"고 지시해야 함.
|
||||
|
||||
### 1-6. 팝업(상세 내용) 별도 HTML 분리
|
||||
|
||||
본문에 다 넣을 수 없는 상세 내용(DX-BIM 비교표 12행 등)은:
|
||||
1. final.html에는 "상세보기" 링크만
|
||||
2. 상세 내용은 별도 첨부 HTML 파일로 생성
|
||||
|
||||
출력 구조:
|
||||
```
|
||||
data/runs/{run_id}/
|
||||
├── final.html ← 슬라이드 본문
|
||||
├── 첨부1_DX_BIM_비교표.html ← details에서 분리된 상세
|
||||
├── 첨부2_xxx.html ← 필요 시 추가
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 1-7. 동일 내용 중복 금지
|
||||
|
||||
같은 내용이 본문에 2번 나오면 안 됨.
|
||||
예: "DX와 BIM 비교표 보기" 링크 + 본문에 비교표 전체 → 중복.
|
||||
비교표는 첨부 HTML로 분리하고, 본문에는 링크만.
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 완료 항목 (이미 처리됨)
|
||||
|
||||
| # | 항목 | 파일 | 상태 |
|
||||
|---|------|------|------|
|
||||
| A | 동적 비율 72:28 grid 반영 | renderer.py | ✅ 완료 |
|
||||
| B | body-footer 공란 제거 | renderer.py | ✅ 완료 |
|
||||
| C | L4 overflow 시 재생성 트리거 | pipeline.py | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행 계획
|
||||
|
||||
### Step R-1: 프롬프트 동적 생성 함수 (1-1, 1-2, 1-3, 1-4, 1-5, 1-7 해결)
|
||||
|
||||
**파일:** html_generator.py
|
||||
|
||||
하드코딩 BG_PROMPT/CORE_PROMPT/SIDEBAR_PROMPT/FOOTER_PROMPT를 삭제하고,
|
||||
`build_area_prompt(role, context)` 함수로 교체.
|
||||
|
||||
이 함수가 context에서 가져오는 것:
|
||||
- font_size ← context.font_hierarchy (1-2)
|
||||
- width ← context.containers[role].width_px (1-3)
|
||||
- height ← context.containers[role].height_px
|
||||
- indent_px ← font_size 기반 계산 (1-4)
|
||||
- reference_html ← context.references[role].design_reference_html (1-5)
|
||||
- design_budget ← context.containers[role].design_budget
|
||||
- "중복 금지" 규칙 (1-7)
|
||||
|
||||
### Step R-2: 팝업 별도 HTML 생성 (1-6 해결)
|
||||
|
||||
**파일:** pipeline.py (Stage 5), html_generator.py
|
||||
|
||||
Stage 0에서 추출된 popups[]를 별도 HTML 파일로 생성.
|
||||
final.html에는 "상세보기" 링크만 남기고, 상세 내용은 첨부N_제목.html로 저장.
|
||||
|
||||
### Step R-3: 검증 + 시뮬레이션
|
||||
|
||||
**파일:** scripts/test_phase_t_audit.py 확장
|
||||
|
||||
- 프롬프트에 하드코딩 px 값이 없는지 검사
|
||||
- font_hierarchy 값이 프롬프트에 반영되는지 확인
|
||||
- 들여쓰기 CSS가 폰트 크기 기반인지 확인
|
||||
- 레퍼런스 HTML이 프롬프트에 포함되는지 확인
|
||||
- 팝업 별도 HTML 생성 확인
|
||||
- 실제 데이터로 전체 시뮬레이션
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 순서
|
||||
|
||||
```
|
||||
Step R-1 (프롬프트 동적 생성) → Step R-3 (검증)
|
||||
Step R-2 (팝업 분리) → Step R-3 (검증)
|
||||
```
|
||||
|
||||
R-1이 가장 크고 핵심. R-2는 독립 작업.
|
||||
36
docs/history/PHASE-V-PRIME.md
Normal file
36
docs/history/PHASE-V-PRIME.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Phase V' — 조립 로직 수정 4건
|
||||
|
||||
> 작성일: 2026-04-06
|
||||
> 상태: 정리 완료, 미착수
|
||||
|
||||
---
|
||||
|
||||
## V'-1: 팝업 링크 위치
|
||||
|
||||
**현재:** 팝업 링크가 텍스트에 인라인으로 붙어있어 눈에 잘 안 보임
|
||||
**변경:** 컨테이너의 빈 공간에 배치. 표가 있으면 표 우측상단에 배치.
|
||||
**대상 파일:** `src/block_assembler.py`, `scripts/assemble_stage2.py`
|
||||
|
||||
---
|
||||
|
||||
## V'-2: 표 내용 Kei 판단
|
||||
|
||||
**현재:** 팝업 원본 콘텐츠의 마크다운 표를 그대로 compact 변환하여 삽입
|
||||
**변경:** Kei가 핵심 내용을 판단하여 표 내용을 채움. 행/열 크기가 결정된 후 Kei가 해당 공간에 맞는 요약을 생성.
|
||||
**대상 파일:** `scripts/assemble_stage2.py`, `src/kei_client.py` (새 함수)
|
||||
|
||||
---
|
||||
|
||||
## V'-3: 출처 라벨 삭제
|
||||
|
||||
**현재:** `출처: [그림 1] DX와 핵심기술간 상호관계` — "출처:" 라벨 포함
|
||||
**변경:** 이미지 아래에 텍스트를 넣되 "출처:" 라벨 삭제. 예: `[그림 1] DX와 핵심기술간 상호관계`
|
||||
**대상 파일:** `src/block_assembler.py`, `scripts/assemble_stage2.py`
|
||||
|
||||
---
|
||||
|
||||
## V'-4: after 공란 제거
|
||||
|
||||
**현재:** code_assembled에만 적용됨 (결론 바로 위까지 body/sidebar 채움). block_assembler의 after(assemble_slide_html)에는 미적용 — body와 sidebar 높이 차이로 공란 발생 가능.
|
||||
**변경:** `assemble_slide_html()`에서도 after 컨테이너 조립 시 결론 바로 위까지 body/sidebar 높이를 맞춤.
|
||||
**대상 파일:** `src/block_assembler.py` — `assemble_slide_html()`
|
||||
317
docs/history/PHASE-V.md
Normal file
317
docs/history/PHASE-V.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Phase V (Verification) — 콘텐츠-컨테이너 적합성 검증 + Kei 에스컬레이션
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 근거: Phase T' 디버깅 과정에서 발견된 파이프라인 구조적 결함
|
||||
> 선행: Phase T (파이프라인 구조), Phase T' (시각 품질)
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
### 발견된 구조적 문제
|
||||
|
||||
Phase T' 디버깅 중 step-by-step 시각 검토를 진행하면서 다음이 드러남:
|
||||
|
||||
1. **컨테이너 크기가 콘텐츠 분량과 무관하게 결정됨**
|
||||
- Stage 1.5a에서 weight(0.6, 0.2, 0.1, 0.1) 고정 배분
|
||||
- 배경에 꼭지 2개(220자)가 배정되었으나 117px밖에 안 됨 → 넘침
|
||||
- 본심은 345px인데 실제 필요 260px → 85px 남음
|
||||
|
||||
2. **"들어가는지" 검증 단계가 없음**
|
||||
- 블록 선택(Stage 1.7) 후 바로 HTML 생성(Stage 2)으로 넘어감
|
||||
- 콘텐츠가 컨테이너에 실제로 들어가는지 아무도 확인하지 않음
|
||||
- Sonnet이 넘치는 내용을 받아서 overflow/스크롤/잘림 발생
|
||||
|
||||
3. **안 될 때 판단하는 주체가 없음**
|
||||
- 공간 부족 시 옵션(합치기, 축약, 팝업 이동, 구조 변경)을 생성하고
|
||||
- Kei 페르소나에게 결정을 요청하는 프로세스가 없음
|
||||
- 현재는 그냥 Sonnet에게 "넣어라"만 함
|
||||
|
||||
4. **영역당 블록 1개만 선택됨**
|
||||
- 배경에 꼭지 2개가 있어도 블록 1개(callout-warning)만 선택
|
||||
- 1꼭지 = 1블록 원칙이 지켜지지 않음
|
||||
|
||||
---
|
||||
|
||||
## 절대 원칙
|
||||
|
||||
1. **하드코딩 금지** — font-size 외 모든 수치는 동적 계산. 어떤 MDX가 들어와도 동일하게 동작
|
||||
2. **스크롤 절대 금지** — overflow:auto/scroll 어떤 영역에서도 불허
|
||||
3. **1꼭지 = 1블록** — 컨테이너에 꼭지 N개면 블록 N개가 개별 선택
|
||||
4. **콘텐츠 분량 → 컨테이너 크기** — weight 고정이 아니라 콘텐츠 필요 높이 기반 배분
|
||||
5. **AI가 옵션 생성, Kei가 결정** — 안 될 때 하드코딩 대응이 아니라 Kei 판단 요청
|
||||
|
||||
---
|
||||
|
||||
## 개선된 파이프라인
|
||||
|
||||
```
|
||||
기존:
|
||||
1A → 1B → 1.5a(weight고정) → 1.5b → 1.7(영역당1블록) → 2(HTML) → 3 → 4
|
||||
↑
|
||||
여기서 넘치거나 잘림
|
||||
|
||||
개선:
|
||||
1A → 1B → 1.7(꼭지별1블록) → 1.8★(적합성검증) → 2(HTML) → 3 → 4
|
||||
│
|
||||
├ 필요 높이 계산
|
||||
├ 컨테이너 재배분
|
||||
└ 안 되면 → Kei 에스컬레이션
|
||||
```
|
||||
|
||||
### 변경 사항 요약
|
||||
|
||||
| Stage | 기존 | 개선 |
|
||||
|-------|------|------|
|
||||
| 1.5a | weight 고정 배분 | 삭제 — 1.8에서 콘텐츠 기반 계산 |
|
||||
| 1.7 | 영역당 블록 1개 | **꼭지당 블록 1개** |
|
||||
| **1.8 (신규)** | 없음 | **적합성 검증 + 재배분 + Kei 에스컬레이션** |
|
||||
|
||||
---
|
||||
|
||||
## Stage 1.8: 적합성 검증 (신규)
|
||||
|
||||
### 입력
|
||||
|
||||
- 꼭지 목록 + 영역 배정 (Stage 1A/1B)
|
||||
- 꼭지별 선택된 블록 + 블록 최소 높이 (Stage 1.7)
|
||||
- 슬라이드 크기 (1280×720), padding, gap 등 고정 스펙
|
||||
|
||||
### 처리 흐름 (AI가 자동 — 하드코딩 아님)
|
||||
|
||||
```
|
||||
Step 1: 필요 높이 계산
|
||||
각 컨테이너별로:
|
||||
- 배정된 꼭지들의 텍스트 분량(자수) 파악
|
||||
- 해당 영역 font-size + line-height로 필요 줄 수 계산
|
||||
- 선택된 블록의 padding, 제목, 마진 등 오버헤드 합산
|
||||
- → 필요 최소 높이(px) 산출
|
||||
|
||||
Step 2: 슬라이드 공간 배분
|
||||
720px 에서:
|
||||
- header(고정) + footer(고정) + gap 빼기
|
||||
- 남은 공간을 각 영역의 필요 높이 비율로 배분
|
||||
- sidebar는 body와 같은 row이므로 sidebar 높이 = body 영역 합계
|
||||
|
||||
Step 3: 적합성 검증
|
||||
각 컨테이너별로:
|
||||
- 배분된 높이 ≥ 필요 높이 → 통과
|
||||
- 배분된 높이 < 필요 높이 → Step 4로
|
||||
|
||||
Step 4: 재배분 시도
|
||||
여유 있는 영역에서 부족한 영역으로 공간 이동:
|
||||
- 각 영역의 (배분 높이 - 필요 높이) = 여유분 계산
|
||||
- 여유분 > 0인 영역에서 부족 영역으로 재분배
|
||||
- 재배분 후 모든 영역이 필요 높이 이상 → 통과
|
||||
- 아직 부족 → Step 5로
|
||||
|
||||
Step 5: Kei 에스컬레이션
|
||||
AI가 현황 + 시도 결과 + 옵션을 정리하여 Kei에게 요청:
|
||||
|
||||
[현황]
|
||||
- 어떤 영역이 몇 px 부족한지
|
||||
- 어떤 영역에 여유가 있는지
|
||||
|
||||
[시도 결과]
|
||||
- 재배분으로 해결 가능한지/불가능한지
|
||||
- 해결 가능하면 어떤 영역에서 얼마를 가져오는지
|
||||
|
||||
[옵션]
|
||||
A. 꼭지 합치기 — 여러 꼭지를 하나의 블록 안에서 흐름으로 연결
|
||||
B. 인라인 축약 — 사례 등을 괄호 한 줄로 축약
|
||||
C. 팝업 이동 — 상세 내용을 팝업으로 빼고 링크만 남김
|
||||
D. 컨테이너 재조정 — 다른 영역에서 공간을 가져옴
|
||||
E. 그리드 구조 변경 — 배경 전체폭 등 레이아웃 자체 변경
|
||||
F. 기타 (Kei 판단)
|
||||
|
||||
[결정 요청]
|
||||
위 옵션 중 선택하거나 다른 방향을 제시해주세요.
|
||||
```
|
||||
|
||||
### Stage 1.8 내부 루프
|
||||
|
||||
```
|
||||
Step 1: 부족/여유 검증 (calculate_fit)
|
||||
Step 2: 재배분 시도 (redistribute)
|
||||
Step 3: 부족 시 → Kei 에스컬레이션 (call_kei_fit_escalation)
|
||||
Step 4: 여유 시 → 보충 콘텐츠 탐색 (analyze_enhancements)
|
||||
├ 관련 팝업에 구조화 콘텐츠(표/비교) 있으면 제안
|
||||
├ 영역 핵심 결론 → 강조 블록 제안
|
||||
└ 텍스트 핵심 키워드 → bold 목록 생성
|
||||
Step 5: Kei 확인 (AI가 제안, Kei가 승인/수정)
|
||||
Step 6: 보충 블록 선택 + fit 재검증
|
||||
├ Kei가 승인한 보충 콘텐츠에 맞는 블록을 catalog에서 선택
|
||||
├ 추가 블록의 높이가 여유 공간에 들어가는지 재검증
|
||||
└ 안 들어가면 축소 (행 수 줄이기) 또는 제외
|
||||
Step 7: 세부 컨테이너 배치 계산
|
||||
├ 메인 컨테이너 안에서 세부 컨테이너 배치 (SVG/텍스트/표/key-msg)
|
||||
├ 각 세부 컨테이너 크기를 콘텐츠에서 동적 계산
|
||||
├ 빈 공간 측정 → 보충 콘텐츠 크기 결정 (표 행 수 등)
|
||||
├ 세부 컨테이너 간 정렬 (좌우 높이 다르면 짧은 쪽 중앙맞춤)
|
||||
└ 최종 overflow 검증
|
||||
Step 8: 확정 출력
|
||||
```
|
||||
|
||||
### 출력
|
||||
|
||||
- 확정된 컨테이너 크기 (재배분 반영)
|
||||
- 각 컨테이너별 꼭지-블록 매핑 (Kei 결정 반영)
|
||||
- 보충 블록 목록 (여유 공간에 추가된 블록)
|
||||
- 강조 블록 목록 (핵심 결론용)
|
||||
- bold 키워드 목록 (Stage 2 프롬프트에 전달)
|
||||
- 콘텐츠 정리 방향 (합치기/축약/팝업 등 — Kei 결정 반영)
|
||||
|
||||
---
|
||||
|
||||
## 태스크 목록
|
||||
|
||||
### V-1: Stage 1.7 수정 — 꼭지별 블록 선택
|
||||
|
||||
- **현재:** `select_reference_block()`이 영역(배경/본심/첨부/결론) 단위로 1개 블록 선택
|
||||
- **변경:** 각 영역 내 꼭지마다 개별적으로 블록 선택
|
||||
- **파일:** `src/block_reference.py`, `src/pipeline.py` (Stage 1.7 호출부)
|
||||
- **완료 기준:** 배경에 꼭지 2개 → 블록 2개 선택. 꼭지 1개면 블록 1개.
|
||||
|
||||
### V-2: Stage 1.8 신규 구현 — 적합성 검증
|
||||
|
||||
- **파일:** `src/fit_verifier.py` (신규)
|
||||
- **내용:**
|
||||
- Step 1~3: 필요 높이 계산 + 공간 배분 + 적합성 검증
|
||||
- 모든 계산은 동적 (font-size, line-height, 블록 padding 등에서 도출)
|
||||
- **완료 기준:** 배경 117px → 부족 감지 → 재배분 시도
|
||||
|
||||
### V-3: Stage 1.8 재배분 로직
|
||||
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
- **내용:**
|
||||
- Step 4: 여유 영역 → 부족 영역으로 공간 재분배
|
||||
- 재배분 후 결과를 Stage 2에 전달
|
||||
- **완료 기준:** 배경 117→151px, 본심 345→311px 자동 재배분
|
||||
|
||||
### V-4: Stage 1.8 Kei 에스컬레이션
|
||||
|
||||
- **파일:** `src/fit_verifier.py`, `src/kei_client.py`
|
||||
- **내용:**
|
||||
- Step 5: 재배분으로도 안 될 때 옵션 생성 + Kei API 호출
|
||||
- Kei 응답 파싱 → 결정에 따라 컨테이너/콘텐츠 조정
|
||||
- **의존성:** V-2, V-3
|
||||
- **완료 기준:** Kei에게 옵션 전달 → Kei 결정 수신 → 파이프라인 계속
|
||||
|
||||
### V-5: Stage 1.5a 리팩터
|
||||
|
||||
- **파일:** `src/space_allocator.py`, `src/pipeline.py`
|
||||
- **내용:**
|
||||
- 기존 weight 고정 배분 로직을 V-2의 콘텐츠 기반 계산으로 교체
|
||||
- 또는 1.5a를 삭제하고 1.8이 컨테이너 계산을 전담
|
||||
- **완료 기준:** weight 하드코딩 제거. 콘텐츠 분량 기반 동적 배분.
|
||||
|
||||
### V-6: 통합 검증 — 완료
|
||||
|
||||
- 전수 하드코딩 스캔: 레이아웃 하드코딩 0개
|
||||
- 통합 테스트: 31/31 통과
|
||||
- step-by-step HTML: step1~step3 생성 + 시각 검토
|
||||
|
||||
---
|
||||
|
||||
## Phase V-2: 콘텐츠 품질 강화 (Step 3 디버깅에서 발견)
|
||||
|
||||
> Step 3 시각 검토에서 발견된 4가지 개선 사항.
|
||||
> 모두 "AI가 분석, Kei가 확인"하는 동일 프로세스.
|
||||
|
||||
### V-7: 주종 관계 블록 내 종속 꼭지 처리
|
||||
|
||||
- **발견:** 배경에 꼭지1(intro)+꼭지2(supporting) → 블록 1개로 합쳤지만, 종속 꼭지를 어떻게 표현할지 미정
|
||||
- **규칙 (동적):**
|
||||
- 종속 꼭지 콘텐츠 분량 확인 (fit_verifier의 텍스트 분량 계산 활용)
|
||||
- 짧으면 (팝업 참조 1~2줄) → 인라인 (주 블록 안에 한 줄)
|
||||
- 길거나 구조 있으면 → 하위 블록 (블록 안의 블록)
|
||||
- 독립성 있으면 → 보조 블록 (나란히)
|
||||
- **판단 기준:** 종속 꼭지의 source_data 길이 + 팝업 참조 여부 + purpose
|
||||
- **Kei 확인:** AI가 "인라인/하위블록/보조블록" 중 제안 → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
|
||||
### V-8: 여유 공간 콘텐츠 보충
|
||||
|
||||
- **발견:** 본심 컨테이너에 ~53px 여유. 관련 팝업(DX vs BIM 비교표 1135자)이 있는데 활용 안 됨
|
||||
- **규칙 (동적):**
|
||||
- 재배분 후 여유 공간 감지 (shortfall < -threshold)
|
||||
- 해당 영역의 꼭지에 관련 팝업이 있는지 확인 (source_data에 [팝업:] 참조)
|
||||
- 팝업에 구조화 콘텐츠(표, 비교, 목록)가 있으면
|
||||
- 여유 공간에 맞는 블록 추가 선택 (catalog에서)
|
||||
- 팝업 핵심만 요약하여 배치 제안
|
||||
- **Kei 확인:** "53px 여유. 비교표 핵심 3행을 넣을까요?" → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
|
||||
### V-9: 영역 핵심 결론 강조 블록
|
||||
|
||||
- **발견:** 배경의 "체계적 정립 필요"가 단순 불릿과 동급. 시각적 강조 없음
|
||||
- **규칙 (동적):**
|
||||
- 각 영역의 꼭지 purpose에서 핵심 결론 추출
|
||||
- purpose=문제제기 → 마지막 문장이 결론적 패턴("~필요", "~해야")이면 강조 블록
|
||||
- purpose=핵심전달 → core_message와 관련된 문장이면 강조 블록
|
||||
- 강조 블록: highlight-strip, callout 내 강조 div 등 catalog에서 선택
|
||||
- **Kei 확인:** "이 문장을 강조 블록으로 처리할까요?" → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
|
||||
### V-10: 텍스트 핵심 키워드 bold
|
||||
|
||||
- **규칙 (동적):**
|
||||
- source_data에서 핵심 용어 추출 (꼭지 title에 포함된 키워드, **bold** 마크된 텍스트)
|
||||
- 해당 키워드를 HTML 생성 시 bold 처리하도록 Sonnet에게 전달
|
||||
- **파일:** `src/html_generator.py` (Stage 2 프롬프트에 키워드 목록 포함)
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
Phase V-1 (완료):
|
||||
V-1 → V-2 → V-3 → V-4 → V-5 → V-6 ✅
|
||||
|
||||
Phase V-2 (신규):
|
||||
V-7 (종속 꼭지 처리) ← fit_verifier 활용
|
||||
V-8 (여유 공간 보충) ← fit_verifier 확장
|
||||
V-9 (강조 블록) ← purpose 분석
|
||||
V-10 (bold 키워드) ← source_data 분석
|
||||
→ 전체 통합 검증 (step-by-step HTML 재생성)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 하드코딩 전수 점검 결과
|
||||
|
||||
### 반드시 제거 (Phase V에서 동적 계산으로 교체)
|
||||
|
||||
| # | 파일 | 라인 | 값 | 문제 | 교체 방향 |
|
||||
|---|------|------|-----|------|----------|
|
||||
| 1 | `space_allocator.py` | 161 | `474` | body zone 높이 고정 | 슬라이드에서 header+footer+gap 빼고 동적 계산 |
|
||||
| 2 | `space_allocator.py` | 160 | `0.35*0.85` | sidebar 비율+패딩 고정 | container_ratio + 실제 padding에서 계산 |
|
||||
| 3 | `html_generator.py` | 543 | `720-80-66-footer_h-40` | body zone 높이 산술 하드코딩 | containers에서 받은 height_px 사용 |
|
||||
| 4 | `html_generator.py` | 604,921 | `380` | sidebar width fallback | containers에서 받은 width_px 사용 (fallback 제거) |
|
||||
| 5 | `html_generator.py` | 621 | `1200` | footer width fallback | containers에서 받은 width_px 사용 |
|
||||
| 6 | `design_director.py` | 314 | `490` | FRAME_AVAILABLE_HEIGHT 고정 | 슬라이드 스펙에서 동적 계산 |
|
||||
| 7 | `design_director.py` | 328-366 | `budget_px` 다수 | 프리셋별 zone budget 고정 | Stage 1.8에서 콘텐츠 기반 재계산 |
|
||||
| 8 | `space_allocator.py` | 301,583 | `0.85` | 패딩 비율 고정 | `(slide_width - padding*2) / slide_width` 로 계산 |
|
||||
|
||||
### fallback 값 (정상 흐름에서는 도달하면 안 됨)
|
||||
|
||||
| # | 파일 | 라인 | 값 | 비고 |
|
||||
|---|------|------|-----|------|
|
||||
| 9 | `space_allocator.py` | 299 | `490` | zone_budget 기본값 — preset에서 반드시 와야 함 |
|
||||
| 10 | `space_allocator.py` | 304,313 | `0.25` | weight 기본값 — Kei가 반드시 제공해야 함 |
|
||||
| 11 | `pipeline.py` | 960 | `490` | budget_px fallback |
|
||||
|
||||
### 교체 원칙
|
||||
|
||||
- **모든 px 값:** 이전 Stage의 계산 결과(containers, font_hierarchy 등)에서 받아 사용
|
||||
- **비율 값(0.85 등):** 실제 padding/gap에서 역산하여 계산
|
||||
- **fallback:** 정상 흐름에서 절대 도달하지 않도록 이전 Stage에서 반드시 값을 제공
|
||||
- **font-size만 예외:** 디자인 토큰으로 정의된 텍스트 크기는 하드코딩 허용
|
||||
|
||||
---
|
||||
|
||||
## 이전 Phase와의 관계
|
||||
|
||||
- **Phase T:** 파이프라인 Stage 0~5 구조 완성 → Phase V는 Stage 1.7~1.8 개선
|
||||
- **Phase T' (TP-1~6):** 시각 품질 개선 (프롬프트, 후처리) → Phase V 적용 후 재검증 필요
|
||||
- **Phase T' 후처리:** sidebar width:100%, overflow 제거, bold 변환, 폰트 캡 → 유지
|
||||
273
docs/history/PHASE-W-PLAN.md
Normal file
273
docs/history/PHASE-W-PLAN.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Phase W — 실행 계획 (Task별 방향 + 방법)
|
||||
|
||||
> 작성일: 2026-04-03
|
||||
> 상위 문서: PHASE-W.md
|
||||
|
||||
---
|
||||
|
||||
## W-1: space_allocator — weight 비율 초기 배정
|
||||
|
||||
### W-1-1: zone_budget을 weight 비율로 계산
|
||||
|
||||
**현재:** `zone_budget = zone_info.get("budget_px")` → 프리셋 490px 고정
|
||||
**방향:** `zone_budget = total_available × zone_weight_sum / all_weight_sum`
|
||||
**파일:** `src/space_allocator.py` — `calculate_container_specs()` 내부
|
||||
**방법:**
|
||||
- 전체 가용 높이 = slide_height - padding×2 - gap×2 - header
|
||||
- 각 zone의 weight 합을 구함 (body zone = 배경+본심 weight, sidebar = 첨부 weight 등)
|
||||
- 전체 weight 합 대비 비율로 zone_budget 계산
|
||||
- 이전에 구현했던 코드를 다시 적용 (173113 run에서 동작 확인됨)
|
||||
**검증:** weight 합 1.0일 때 모든 컨테이너 높이 합 출력하여 전체 가용의 95% 이상인지 확인
|
||||
|
||||
### W-1-2: 전체 공간 100% 사용
|
||||
|
||||
**방향:** W-1-1이 해결되면 자동으로 해결
|
||||
**검증:** `stage_1_5a_context.json`에서 모든 컨테이너 height_px 합산 ≥ 전체 가용 × 0.95
|
||||
|
||||
### W-1-3: 시선 흐름 배치 좌표
|
||||
|
||||
**현재:** `_calc_coords()`가 배경→상단좌, 본심→중앙좌, 첨부→우측, 결론→하단으로 배치
|
||||
**방향:** 현재 코드 유지 (이미 올바름)
|
||||
**검증:** `stage_1_8_filled.html`에서 배경이 상단, 본심이 중앙, 첨부가 우측, 결론이 하단에 위치
|
||||
|
||||
---
|
||||
|
||||
## W-2: block_assembler — 공통 조립 함수 완성
|
||||
|
||||
### W-2-1: font_hierarchy override
|
||||
|
||||
**현재:** `_override_font()` 함수가 블록 CSS의 font-size를 font_hierarchy로 조정
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 첨부 영역의 font-size가 sidebar 값(9-11px)을 초과하지 않음
|
||||
|
||||
### W-2-2: 팝업 링크 인접 배치
|
||||
|
||||
**현재:** `_parse_structured_text()`에서 `[팝업: 제목]`을 이전 불릿 텍스트에 `[제목→]`으로 붙임
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `[혼용 대표 사례→]`가 별도 줄이 아니라 텍스트 옆에 붙어있음
|
||||
|
||||
### W-2-3: sidebar 상단 라벨
|
||||
|
||||
**현재:** `_assemble_card_numbered()`에서 `topic.title`을 라벨로 추가
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML의 첨부 영역 상단에 꼭지 title이 보임
|
||||
|
||||
### W-2-4: 카드 indent 파싱
|
||||
|
||||
**현재:** `_assemble_card_numbered()`에서 indent=0만 카드 제목, indent=1은 설명
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 첨부에 건설산업/BIM/DX 3개 카드가 분리되고, 하위 설명이 각 카드 안에 있음
|
||||
|
||||
### W-2-5: 카드 불릿 간격
|
||||
|
||||
**현재:** CSS override에서 `white-space: pre-line → normal` 변환
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 첨부 카드 내 불릿과 불릿 사이에 빈 줄(엔터)이 없음
|
||||
|
||||
### W-2-6: 실제 이미지 사용
|
||||
|
||||
**현재:** `has_real_image` 분기로 실제 이미지 있으면 SVG 레이아웃, 없으면 텍스트만
|
||||
**방향:** 수정 필요 — 현재 `_assemble_svg_layout()`이 `design_reference_html`에서 SVG를 추출. 이걸 `ctx.slide_images`의 실제 이미지(base64)로 교체
|
||||
**파일:** `src/block_assembler.py` — `_assemble_svg_layout()`
|
||||
**방법:**
|
||||
- `ctx.slide_images`에서 해당 이미지의 base64 데이터를 가져옴
|
||||
- `<img src="data:image/png;base64,{b64}" />` 형태로 삽입
|
||||
- SVG viewBox/gradient 하드코딩 대신 실제 이미지 사용
|
||||
**검증:** filled HTML에 `<img src="data:image/png;base64,` 패턴이 있고, SVG 태그가 없음
|
||||
|
||||
### W-2-7: filled/assembled 통일
|
||||
|
||||
**현재:** `_gen_stage_1_8_filled()`가 `assemble_slide_html()` 호출
|
||||
**방향:** assembled(stage_2_code_assembled)도 같은 함수 호출하도록 `assemble_stage2.py` 수정 또는 제거
|
||||
**검증:** filled와 assembled가 같은 HTML 구조를 가짐 (diff로 확인)
|
||||
|
||||
### W-2-8: 팝업 마크다운 테이블 변환
|
||||
|
||||
**현재:** `mdx_normalizer.py`의 `_extract_popup()`에서 `_convert_md_table_to_html()` 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `stage_0_context.json`의 popups에서 "DX와 BIM의 구분" 팝업에 `<table>` 태그가 있고 `|` 마크다운이 없음
|
||||
|
||||
---
|
||||
|
||||
## W-3: filled → Selenium 측정 연결
|
||||
|
||||
### W-3-1: .slide 클래스
|
||||
|
||||
**현재:** `assemble_slide_html()`의 최외곽 div에 `class="slide"` 있음
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `class="slide"` 존재 확인
|
||||
|
||||
### W-3-2: area-* 클래스
|
||||
|
||||
**현재:** 각 역할 컨테이너에 `area-body`, `area-sidebar`, `area-footer` 클래스 있음
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `area-body`, `area-sidebar`, `area-footer` 존재 확인
|
||||
|
||||
### W-3-3: Selenium 측정 정상 동작
|
||||
|
||||
**현재:** 173113 run에서 `{'error': 'slide not found'}` 발생 (당시 .slide 클래스 없었음)
|
||||
**방향:** W-3-1, W-3-2가 해결되면 자동 해결
|
||||
**방법:** filled HTML을 `measure_rendered_heights()`에 넣고 정상 결과 반환 확인
|
||||
**검증:** 반환값에 `zones.sidebar.scrollHeight`, `zones.body.scrollHeight` 등이 있고 `error` 키가 없음
|
||||
|
||||
### W-3-4: 시각화 순서 (before → filled → after)
|
||||
|
||||
**현재:** `step_visualizer.py`의 dispatch에서 blocks → filled → fit_before → fit_after 순서
|
||||
**방향:** before(빈 컨테이너 크기) → filled(블록+텍스트 채운 상태) → after(조정된 크기) 순서
|
||||
**파일:** `src/step_visualizer.py` — `generate_step_html()`
|
||||
**방법:**
|
||||
- `stage_1_8` dispatch 순서를 `fit_before → filled → fit_after`로 변경
|
||||
- fit_before는 빈 컨테이너 크기만 보여줌 (부족/여유 판단 없이)
|
||||
- filled는 블록+텍스트 채운 상태
|
||||
- fit_after는 조정 후 컨테이너 크기
|
||||
**검증:** steps 폴더에 3개 파일이 순서대로 있고, before의 크기 → filled의 넘침 → after의 변경이 시각적으로 확인 가능
|
||||
|
||||
---
|
||||
|
||||
## W-4: 측정 결과 기반 조정 판단
|
||||
|
||||
### W-4-1: sidebar overflow → 확장
|
||||
|
||||
**현재:** pipeline.py Stage 1.8에 sidebar 확장 코드 있음
|
||||
**방향:** 현재 코드 유지 (Selenium 측정이 동작하면 자동으로 발동)
|
||||
**검증:** sidebar scrollHeight > clientHeight일 때 `stage_1_8_context.json`의 첨부 height_px가 scrollHeight 이상으로 증가
|
||||
|
||||
### W-4-2: body overflow → 재배분
|
||||
|
||||
**현재:** `redistribute()` 함수가 body zone 내에서 배경↔본심 재배분
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** body overflow 시 배경 또는 본심의 height_px가 변경됨
|
||||
|
||||
### W-4-3: 재배분 후에도 overflow → Kei 에스컬레이션
|
||||
|
||||
**현재:** `needs_escalation=True`일 때 `call_kei_fit_escalation()` 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `enhancement_result.kei_decisions`에 Kei 응답이 저장됨
|
||||
|
||||
### W-4-4: Kei trim/popup 결정 실제 적용
|
||||
|
||||
**현재:** Kei 결정을 받지만 실제 반영 안 됨
|
||||
**방향:** 새로 구현
|
||||
**파일:** `src/pipeline.py` Stage 1.8 내부 + 새 함수
|
||||
|
||||
**trim 구현 방법:**
|
||||
- Kei가 `{"action": "trim", "detail": "150자로 축약"}`을 반환하면
|
||||
- 해당 role의 topic structured_text를 **Kei/Sonnet에게 축약 요청** (AI 판단 — 어떤 문장이 덜 중요한지는 AI만 알 수 있음)
|
||||
- 프롬프트: "다음 텍스트를 N자 이내로 축약하라. 불릿 구조 유지. 핵심 85% 보존."
|
||||
- 축약된 텍스트로 structured_text 교체
|
||||
- 하드코딩 없음 — 어떤 콘텐츠든 AI가 판단
|
||||
- **도구:** anthropic SDK (이미 있음), Kei API /api/direct
|
||||
|
||||
**popup 구현 방법:**
|
||||
- Kei가 `{"action": "popup", "detail": "상세 정의를 팝업으로"}`를 반환하면
|
||||
- 해당 role의 structured_text를 **Kei/Sonnet에게 분리 요청** ("요약 vs 상세" 판단)
|
||||
- 프롬프트: "다음 콘텐츠를 슬라이드 요약(2-3줄)과 팝업 상세로 분리하라."
|
||||
- 요약은 structured_text에, 상세는 별도 팝업 HTML로 저장
|
||||
- 슬라이드에는 요약 + `[상세보기→]` 링크
|
||||
- 하드코딩 없음 — 어떤 콘텐츠든 AI가 요약/상세를 판단
|
||||
- **도구:** anthropic SDK, 팝업 HTML 템플릿 (pipeline.py Stage 5에 이미 있음)
|
||||
|
||||
**검증:** trim 후 structured_text 길이가 줄어들고, popup 후 팝업 HTML 파일이 생성됨
|
||||
|
||||
### W-4-5: Kei restructure → 컨테이너 직접 변경
|
||||
|
||||
**현재:** `redistribute()` 재실행만 됨
|
||||
**방향:** Kei가 "본심에 363px 보장"하면 직접 height_px 변경
|
||||
**파일:** `src/pipeline.py` Stage 1.8 내부
|
||||
**방법:**
|
||||
- Kei 결정에서 구체적 px 값을 파싱 (정규식으로 숫자 추출)
|
||||
- 해당 role의 height_px를 직접 설정
|
||||
- 다른 role에서 부족분을 **weight 역비례**로 차감 (중요도 낮은 곳에서 더 많이)
|
||||
- 최소 높이(60px) 보장
|
||||
- 총합이 전체 가용 초과하지 않도록 검증
|
||||
- 하드코딩 없음 — 순수 산술, 어떤 role이든 동작
|
||||
- **도구:** Python 산술 (외부 라이브러리 불필요)
|
||||
|
||||
**검증:** restructure 후 해당 role의 height_px가 Kei가 지정한 값으로 변경되고, 총합이 전체 가용 이하
|
||||
|
||||
### W-4-6: after 컨테이너 저장
|
||||
|
||||
**현재:** `stage_1_8_context.json`에 containers 저장됨
|
||||
**방향:** 현재 코드 유지 (W-4-1~5의 결과가 containers에 반영되면 자동 저장)
|
||||
**검증:** `stage_1_8_context.json`의 containers가 before와 다름
|
||||
|
||||
### W-4-7: Kei 보강 검토 호출
|
||||
|
||||
**현재:** `call_kei_enhancement_review()` 함수 있고 pipeline.py에서 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `enhancement_result`에 Kei 보강 검토 결과가 저장됨 (approve/modify/reject)
|
||||
|
||||
---
|
||||
|
||||
## W-5: after 기반 최종 조립 + 검증
|
||||
|
||||
### W-5-1: stage_2가 after 컨테이너 사용
|
||||
|
||||
**현재:** stage_2_context.json의 containers == stage_1_8_context.json의 containers (확인됨)
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 두 JSON의 containers 비교 — 일치
|
||||
|
||||
### W-5-2: overflow 없음 확인
|
||||
|
||||
**현재:** Stage 4에서 Selenium 측정. Vision 모델 ID 404 에러
|
||||
**방향:** Vision 모델 ID를 `claude-sonnet-4-20250514`로 변경 (vision 지원, 비용 효율)
|
||||
**파일:** `src/kei_client.py` — 3곳
|
||||
**방법:** 모델 ID 문자열 교체
|
||||
**검증:** Stage 4에서 모든 zone의 excess_px ≤ 0
|
||||
|
||||
### W-5-3: 텍스트 85% 보존 검증
|
||||
|
||||
**현재:** 검증 로직 없음
|
||||
**방향:** 새로 구현
|
||||
**파일:** `src/pipeline.py` Stage 4 또는 Stage 5
|
||||
**방법:**
|
||||
- final.html에서 HTML 태그 제거하여 순수 텍스트 추출 (Python stdlib `html.parser`)
|
||||
- 각 role의 structured_text와 문자 3-gram 겹침 비교
|
||||
- 85% 이상이면 PASS
|
||||
- 하드코딩 없음 — 문자열 비교만, 어떤 콘텐츠든 동작
|
||||
- **도구:** Python stdlib만 (html.parser, re). 외부 NLP 불필요
|
||||
|
||||
**검증:** 검증 함수가 각 role별 보존율을 반환하고, 모든 role이 85% 이상
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
W-1-1 → W-1-2 (자동)
|
||||
W-1 + W-2 → W-3 (filled 생성 + 측정)
|
||||
W-3 → W-4 (측정 결과로 판단)
|
||||
W-4 → W-5 (after 기반 최종)
|
||||
|
||||
W-2 내부: 1~8 독립적으로 병행 가능
|
||||
W-4 내부: 1→2→3→4/5 순차, 6/7 독립
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필요 도구/라이브러리
|
||||
|
||||
| 도구 | 용도 | 상태 |
|
||||
|------|------|------|
|
||||
| Selenium + Chrome headless | filled 측정 (W-3) | ✅ 설치됨, 동작 확인 |
|
||||
| anthropic SDK | Kei trim/popup (W-4-4), Vision (W-5-2) | ✅ 설치됨 |
|
||||
| httpx | Kei API 호출 | ✅ 설치됨 |
|
||||
| Kei API (localhost:8000) | 에스컬레이션, 보강 검토 | ✅ 동작 확인 |
|
||||
| Python stdlib (html.parser, re) | 텍스트 보존 검증 (W-5-3) | ✅ 내장 |
|
||||
| Jinja2 | 블록 템플릿 렌더링 | ✅ 설치됨 |
|
||||
|
||||
**추가 설치 필요 없음.**
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
Phase 1: W-1 (weight 비율) — 기반
|
||||
Phase 2: W-2 (공통 조립 함수) — W-1과 병행 가능
|
||||
Phase 3: W-3 (Selenium 연결) — W-1 + W-2 필요
|
||||
Phase 4: W-4 (판단 로직) — W-3 필요
|
||||
Phase 5: W-5 (최종 검증) — W-4 필요
|
||||
|
||||
각 Phase 완료 후 파이프라인 실행하여 검증.
|
||||
```
|
||||
162
docs/history/PHASE-W.md
Normal file
162
docs/history/PHASE-W.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Phase W — Stage 1.8 before→filled→after 파이프라인 완성
|
||||
|
||||
> 작성일: 2026-04-03
|
||||
> 근거: Phase V 이후 코드 조립/연결 과정에서 발견된 문제 8건
|
||||
> 선행: Phase V (적합성 검증 + Kei 에스컬레이션)
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
Phase V에서 설계한 before→filled→after 프로세스가 실제로 동작하지 않음.
|
||||
코드는 부분적으로 존재하지만 연결이 안 되어 있고, 각 단계가 따로 놀고 있음.
|
||||
|
||||
### 발견된 문제 8건
|
||||
|
||||
1. **before→filled→after 파이프라인 연결 안 됨** — filled Selenium 측정 실패, 판단 미동작
|
||||
2. **space_allocator 불안정** — weight 비율로 전체 공간 100% 사용해야 하는데 안 됨
|
||||
3. **공통 조립 함수 불완전** — filled/assembled/stage_2가 각각 다른 코드 사용
|
||||
4. **Kei 에스컬레이션 결정 미반영** — trim/popup/restructure 결정이 컨테이너에 반영 안 됨
|
||||
5. **본심 OVERFLOW 미해소** — 재배분이 충분하지 않음
|
||||
6. **filled 시각화 품질** — font_hierarchy, 카드 간격, 팝업 링크 등 미반영
|
||||
7. **이미지 SVG 샘플 사용** — 실제 이미지(samples/images/) 대신 하드코딩 샘플
|
||||
8. **Sonnet/코드 조립 경로 분리** — 두 경로가 각각 존재. Phase W 완성 후 판단
|
||||
|
||||
---
|
||||
|
||||
## 핵심 프로세스
|
||||
|
||||
```
|
||||
before: weight 비중대로 전체 가용 공간 100% 배정 (초기 컨테이너)
|
||||
↓
|
||||
filled: before 컨테이너에 블록+텍스트 채움 (block_assembler 공통 함수)
|
||||
↓
|
||||
측정: Selenium으로 실제 넘침 확인
|
||||
↓
|
||||
판단:
|
||||
1. 다른 블록으로 바꿀 수 있나? (font_hierarchy 유지)
|
||||
2. sidebar 넘침 → 세로 확장 (예외)
|
||||
3. body 넘침 → 배경↔본심 재배분
|
||||
4. 그래도 안 되면 → Kei 에스컬레이션 (trim/popup/restructure)
|
||||
5. Kei 결정을 컨테이너에 실제 반영
|
||||
6. 텍스트 85% 보존 우선
|
||||
↓
|
||||
after: 조정된 컨테이너
|
||||
↓
|
||||
assembled = after 기준으로 block_assembler 공통 함수로 최종 조립
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 절대 원칙
|
||||
|
||||
1. **하드코딩 금지** — 어떤 MDX가 들어와도 동일하게 동작
|
||||
2. **결과물 HTML 직접 수정 안 함** — 파이프라인 프로세스를 수정
|
||||
3. **이미지는 실제 파일만** — samples/images/에서 가져옴. SVG 샘플 금지
|
||||
4. **텍스트 85% 보존** — 공간 부족 시 컨테이너를 늘림. 그래도 안 되면 Kei가 팝업 분리
|
||||
5. **weight = 초기 배정 비율 + 충돌 시 우선순위** — 최종 높이가 아님
|
||||
6. **배치는 시선 흐름** — 좌→우, 상→하 (배경 상단좌, 본심 중앙좌, 첨부 우측, 결론 하단)
|
||||
7. **font_hierarchy 준수** — 핵심 14px, 본심 12px, 배경 10-12px, 첨부 9-11px
|
||||
8. **조립 로직은 한 곳** — block_assembler.py. filled/assembled/stage_2 모두 이 함수 사용
|
||||
9. **임의로 코드 되돌리지 않음** — 이해 안 되면 물어보고, 제안하고, 허락받고 실행
|
||||
|
||||
---
|
||||
|
||||
## W-1: space_allocator — weight 비율 초기 배정
|
||||
|
||||
> 파일: `src/space_allocator.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-1-1 | `calculate_container_specs()`에서 zone_budget을 `전체가용 × zone_weight_sum / all_weight_sum`으로 계산하는 코드가 있음 |
|
||||
| W-1-2 | weight 합 1.0일 때 모든 컨테이너 높이 합 ≥ 전체 가용 공간의 95% |
|
||||
| W-1-3 | 배경이 본심보다 위에, 첨부가 우측에, 결론이 하단에 배치되는 좌표 계산이 `_calc_coords()`에서 동작함 |
|
||||
|
||||
---
|
||||
|
||||
## W-2: block_assembler — 공통 조립 함수 완성
|
||||
|
||||
> 파일: `src/block_assembler.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-2-1 | `assemble_role_html()`이 블록 CSS의 font-size를 font_hierarchy 값으로 override한 HTML을 반환함 |
|
||||
| W-2-2 | 팝업 `[팝업: 제목]` 마커가 별도 줄이 아니라 이전 불릿 텍스트 옆에 `[제목→]` 형태로 붙어있음 |
|
||||
| W-2-3 | sidebar 역할일 때 상단에 꼭지 title 라벨이 있음 |
|
||||
| W-2-4 | card-numbered에서 주불릿(indent=0)만 카드 제목, 하위불릿(indent=1)은 카드 설명으로 분리됨 |
|
||||
| W-2-5 | 카드 내 불릿 사이에 빈 줄(엔터) 없음 — white-space: normal 적용 |
|
||||
| W-2-6 | 이미지는 `ctx.slide_images`의 실제 파일(base64)만 사용, design_reference_html의 SVG 샘플 미사용 |
|
||||
| W-2-7 | `_gen_stage_1_8_filled()`와 assembled가 모두 `assemble_slide_html()`을 호출함 |
|
||||
| W-2-8 | Stage 0에서 팝업 콘텐츠의 마크다운 테이블이 HTML `<table>`로 변환되어 `stage_0_context.json`의 popups에 저장됨 |
|
||||
|
||||
---
|
||||
|
||||
## W-3: filled → Selenium 측정 연결
|
||||
|
||||
> 파일: `src/block_assembler.py`, `src/pipeline.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-3-1 | `assemble_slide_html()` 출력 HTML에 `.slide` 클래스가 최외곽 div에 있음 |
|
||||
| W-3-2 | 각 역할 컨테이너에 `.area-body`, `.area-sidebar`, `.area-footer` 클래스가 있음 |
|
||||
| W-3-3 | filled HTML을 `measure_rendered_heights()`에 넣으면 `{'error': 'slide not found'}`가 아닌 정상 측정 결과가 반환됨 |
|
||||
| W-3-4 | steps 폴더에 `stage_1_8_fit_before.html` → `stage_1_8_filled.html` → `stage_1_8_fit_after.html` 순서로 생성되고, before의 컨테이너 크기 → filled의 넘침 → after의 조정된 크기가 시각적으로 확인 가능 |
|
||||
|
||||
---
|
||||
|
||||
## W-4: 측정 결과 기반 조정 판단
|
||||
|
||||
> 파일: `src/pipeline.py`, `src/fit_verifier.py`, `src/kei_client.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-4-1 | pipeline.py Stage 1.8에서 filled 측정 후 sidebar overflow 감지 시 해당 role의 컨테이너 height_px가 scrollHeight로 확장됨 |
|
||||
| W-4-2 | body overflow 감지 시 `redistribute()`가 호출되어 배경↔본심 간 높이가 재배분됨 |
|
||||
| W-4-3 | 재배분 후에도 overflow이면 `call_kei_fit_escalation()`이 호출됨 |
|
||||
| W-4-4 | Kei가 trim 결정 시 해당 role의 structured_text가 축약되거나, popup 결정 시 해당 콘텐츠가 팝업으로 분리됨 |
|
||||
| W-4-5 | Kei가 restructure 결정 시 해당 role의 컨테이너 height_px가 변경됨 |
|
||||
| W-4-6 | after의 컨테이너 크기가 stage_1_8_context.json에 저장됨 |
|
||||
| W-4-7 | Stage 1.8에서 `call_kei_enhancement_review()`가 호출되고, Kei 응답이 `enhancement_result`에 저장됨 |
|
||||
|
||||
---
|
||||
|
||||
## W-5: after 기반 최종 조립 + 검증
|
||||
|
||||
> 파일: `src/pipeline.py`, `src/block_assembler.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-5-1 | stage_2의 `generated_html`이 after 컨테이너 크기를 기준으로 생성됨 (stage_2_context.json의 containers == stage_1_8_context.json의 containers) |
|
||||
| W-5-2 | stage_3_rendered.html에서 overflow가 없음 (Stage 4 측정에서 모든 zone excess ≤ 0) |
|
||||
| W-5-3 | final.html에 모든 역할의 structured_text가 85% 이상 포함됨 |
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
W-1 (weight 초기 배정)
|
||||
↓
|
||||
W-2 (공통 조립 함수) ← 독립적으로 병행 가능
|
||||
↓
|
||||
W-3 (filled → Selenium 측정) ← W-1 + W-2 필요
|
||||
↓
|
||||
W-4 (측정 기반 판단) ← W-3 필요
|
||||
↓
|
||||
W-5 (after 기반 최종) ← W-4 필요
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 미결정 사항
|
||||
|
||||
- **Sonnet vs 코드 조립**: Stage 2를 Sonnet으로 유지할지 block_assembler 코드 조립으로 교체할지는 W-1~W-5 완성 후 결과를 보고 판단.
|
||||
|
||||
---
|
||||
|
||||
## 입력 데이터
|
||||
|
||||
- MDX 파일: `samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx`
|
||||
- 이미지: `samples/images/` (DX1.png 등)
|
||||
- 테스트: `scripts/run_from_stage1b.py` — Stage 1B 데이터 고정 실행
|
||||
- 좋았던 Kei 데이터: `data/runs/20260403_133746` (또는 `20260403_163508`)
|
||||
101
docs/history/PHASE-X-B.md
Normal file
101
docs/history/PHASE-X-B.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Phase X-B: 유형 B 템플릿 추가
|
||||
|
||||
> 최종 업데이트: 2026-04-06
|
||||
> 전제: 유형 A(배경+본심+첨부+결론) 기존 코드 건드리지 않음
|
||||
|
||||
---
|
||||
|
||||
## 유형 B 구조
|
||||
|
||||
02번 MDX (DX의 시행 목표 및 기대효과) 기준.
|
||||
MDX 원본 구조:
|
||||
```
|
||||
title: DX의 시행 목표 및 기대효과 ← 슬라이드 제목 (frontmatter)
|
||||
## 1. DX의 궁극적 목표 ← 상단 (level=2)
|
||||
- 안전과 품질 / 생산성 향상 / 소통과 신뢰 ← 소제목 카드
|
||||
 ← 상단 우측 이미지
|
||||
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과 ← 하단 대목차 (level=2)
|
||||
### 2.1 업무 수행 과정(Process)의 변화 ← 하단 좌측 (level=3)
|
||||
### 2.2 DX 시행 주체별 기대효과 ← 하단 우측 (level=3) — 표 데이터
|
||||
:::note[핵심 요약]
|
||||
* 고품질의 성과품, 비용 절감... ← 결론 (원본 그대로)
|
||||
:::
|
||||
```
|
||||
|
||||
슬라이드 레이아웃:
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ DX의 시행 목표 및 기대효과 (원본 title) │
|
||||
├───────────────────────┬──────────────────┤
|
||||
│ DX의 궁극적 목표 │ │
|
||||
│ ┌안전과 품질──────────┐│ [이미지] │
|
||||
│ │• 불릿 ││ DX의 궁극적 │
|
||||
│ ├생산성 향상──────────┤││ 목표 │
|
||||
│ │• 불릿 ││ │
|
||||
│ ├소통과 신뢰──────────┤│ │
|
||||
│ │• 불릿 ││ │
|
||||
│ └────────────────────┘│ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ DX 기반 Process 혁신에 따른 주체별 기대효과 │ ← 대목차
|
||||
├───────────┬──────────────────────────────┤
|
||||
│ 2.1 업무 │ 2.2 DX 시행 주체별 기대효과 │
|
||||
│ 수행 과정 │ [바로가기 →] (팝업 링크) │
|
||||
│ 변화 │ ┌ Kei 요약 표 ──────────┐ │
|
||||
│ • 생산방식 │ │ 구분│발주자│시공자│설계자│ │
|
||||
│ • 인지검토 │ │ ...│ ...│ ...│ ...│ │
|
||||
│ • 협업구조 │ └──────────────────────┘ │
|
||||
│ • 검증대응 │ │
|
||||
├───────────┴──────────────────────────────┤
|
||||
│ 결론: 고품질의 성과품, 비용 절감... (원본) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 진행 현황
|
||||
|
||||
### X-B-1: KEI_PROMPT 유형 B 옵션 추가 — ✅ 완료
|
||||
### X-B-2: 검증기 완화 — ✅ 완료
|
||||
### X-B-3: space_allocator 유형 B 컨테이너 생성 — ✅ 완료
|
||||
### X-B-4: assemble_stage2 유형 B 조립 — ✅ 완료 (code_assembled)
|
||||
### X-B-5: pipeline.py 분기 — ✅ 완료
|
||||
|
||||
### X-B-6: 검증 — ❌ 미완료
|
||||
|
||||
**code_assembled(assemble_stage2):**
|
||||
- 제목/대목차/소목차/텍스트: MDX 원본에서 직접 가져옴 ✅
|
||||
- 팝업 링크 + Kei 요약 표 ✅
|
||||
- 이미지 + 캡션 ✅
|
||||
- 카드형 소제목 ✅
|
||||
- **하지만 렌더링에서 잘림** — 컨테이너 크기 vs 내용 크기 불일치
|
||||
|
||||
**파이프라인(before→filled→after):**
|
||||
- **유형 B에서 동작 안 함** — block_assembler가 고정 4역할만 처리
|
||||
- filled가 거의 빈 HTML (2997bytes)
|
||||
- 이걸 해결해야 Selenium 측정 → 재배분이 가능
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 핵심 작업
|
||||
|
||||
**1. block_assembler 유형 B 지원**
|
||||
- `assemble_slide_html()`이 유형 B 역할도 처리
|
||||
- 또는 유형 B 전용 함수 추가
|
||||
- filled/after가 제대로 생성되어야 Selenium 측정 가능
|
||||
|
||||
**2. 컨테이너 크기 맞춤**
|
||||
- 현재 렌더링 잘림 → Selenium 측정 후 재배분으로 해결
|
||||
- 이건 1번이 해결되면 자동으로 동작
|
||||
|
||||
**3. 01번(유형 A) 깨지지 않는지 확인**
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- 하드코딩 절대 금지
|
||||
- HTML 결과물 고치지 말고 파이프라인 프로세스 고칠 것
|
||||
- 제목/텍스트는 원본 MDX에서 그대로 (Kei가 바꾸지 않음)
|
||||
- Kei가 재구성하는 건 빈 공간 채우기(표 요약)만
|
||||
- 유형 A 코드 건드리지 않고 유형 B 추가
|
||||
- normalized.sections에서 직접 텍스트 가져옴 (Kei structured_text 대신)
|
||||
309
docs/history/PHASE-X-BX.md
Normal file
309
docs/history/PHASE-X-BX.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Phase X-BX': 유형 B 미완료 사항 정리
|
||||
|
||||
> 최종 업데이트: 2026-04-07
|
||||
> 전제: **유형 A 코드 절대 건드리지 않음.** A는 완벽하게 동작 중. 수정도 재검증도 하지 않음.
|
||||
> 유형 B의 code_assembled + 파이프라인만 수정.
|
||||
> **02번 MDX 먼저 → 03번 확장** 순서로 진행.
|
||||
|
||||
---
|
||||
|
||||
## MDX 원본 위치
|
||||
|
||||
`D:\ad-hoc\cel\src\content\docs\Civil DX\BIM과 DX의 이해\`
|
||||
|
||||
---
|
||||
|
||||
## 근본 원인
|
||||
|
||||
Type A는 Kei가 역할명을 `"배경"`, `"본심"`, `"첨부"`, `"결론"`으로 내려주고,
|
||||
하류 코드가 `containers["배경"]` 처럼 **역할명 글자**로 매칭한다. → 동작함.
|
||||
|
||||
Type B는 Kei가 역할명을 `"필수요건"`, `"과정혁신"` 등으로 내려주는데,
|
||||
하류 코드가 여전히 `containers["배경"]`을 찾는다. → **키가 없어서 빈 것.**
|
||||
|
||||
**해결:** Type B일 때는 역할명 글자가 아니라 `containers`에 있는 키를 순회하고,
|
||||
zone 정보(`top`, `bottom_left` 등)로 위치를 결정한다.
|
||||
|
||||
```python
|
||||
# Type A (기존 그대로):
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
container = containers[role]
|
||||
|
||||
# Type B (분기 추가):
|
||||
for role in containers:
|
||||
zone = containers[role].zone # top, bottom_left, bottom_right, footer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XBX-1: 들여쓰기 계층
|
||||
|
||||
### 현상
|
||||
MDX의 2단 계층(`* > *`)이 동일 레벨로 평탄화됨.
|
||||
|
||||
```
|
||||
MDX 원본: 현재 HTML:
|
||||
- 안전과 품질 (소제목) → • 안전과 품질 ← 소제목인데 불릿과 동일
|
||||
- 시설물의 요구 성능을... → • 시설물의 요구 성능을... ← 구분 없음
|
||||
```
|
||||
|
||||
### 목표
|
||||
```
|
||||
■ 안전과 품질 ← 소제목 (bold, 색상 구분)
|
||||
• 시설물의 요구 성능... ← 본문 불릿 (들여쓰기)
|
||||
```
|
||||
|
||||
### 수행 방향
|
||||
|
||||
**1단계: normalizer에서 불릿 depth 보존**
|
||||
|
||||
현재 `src/mdx_normalizer.py`의 section content:
|
||||
```
|
||||
"**안전과 품질**\n시설물의 요구 성능을..." ← flat, depth 정보 없음
|
||||
```
|
||||
|
||||
수정 후:
|
||||
```
|
||||
"- **안전과 품질**\n - 시설물의 요구 성능을..." ← depth 마커 보존
|
||||
```
|
||||
|
||||
markdown-it의 `list_item_open` 토큰에 이미 indent 정보 있음 (88번째 줄).
|
||||
section content 수집 시 indent level을 보존하면 됨.
|
||||
|
||||
**2단계: 조립 로직에서 depth별 스타일 분기**
|
||||
|
||||
`scripts/assemble_stage2.py` `_assemble_type_b` + `src/block_assembler.py` `_assemble_slide_html_type_b`:
|
||||
- depth 1 (`- `) → 소제목 스타일 (bold, 색상 구분, 카드)
|
||||
- depth 2 (` - `) → 본문 불릿 (들여쓰기, normal weight)
|
||||
|
||||
### 검증
|
||||
02번 상단 "안전과 품질/생산성 향상/소통과 신뢰" 3개 소제목이 카드로 분리,
|
||||
각각의 하위 불릿 2줄이 들여쓰기되어 보임.
|
||||
|
||||
---
|
||||
|
||||
## XBX-2: overflow → 콘텐츠 맞춤 프로세스
|
||||
|
||||
### 현상
|
||||
상단 zone(255px)에 소제목 3개 + 불릿 6줄 + 이미지 → overflow.
|
||||
하단 우측에 표 데이터가 너무 많아서 overflow.
|
||||
|
||||
### 프로세스 (네가 말한 것)
|
||||
```
|
||||
넘침 감지 → 최대 몇 줄까지 가능? → 몇 자 이내로 정리 → Kei에게 요약 요청 → 재수취 후 정리
|
||||
```
|
||||
|
||||
### 왜 안 되는가 (원인 3개)
|
||||
|
||||
**원인 1: Selenium 측정 실패**
|
||||
- `slide_measurer.py` 144줄: 2.2MB HTML을 `data:` URI로 로드 → 브라우저 크기 제한
|
||||
- Type A는 ~214KB라 동작, Type B는 이미지 base64 포함 2.2MB라 실패
|
||||
- **수정:** 임시 파일로 저장 후 `file://` URI로 로드 (크기 제한 없음)
|
||||
|
||||
**원인 2: overflow 분기에 Type B zone 없음**
|
||||
- `pipeline.py` 538-553줄: `sidebar`와 `body`만 처리
|
||||
- Type B zone(`top`, `bottom`)은 분기 없음 → overflow 감지돼도 무시됨
|
||||
- **수정:** `if layout_template == "B":` 분기 추가. top/bottom overflow 시 처리
|
||||
|
||||
**원인 3: calculate_fit에서 Type B 역할 인식 불가**
|
||||
- `fit_verifier.py` 307줄: `role_font_map = {"본심": "core", "배경": "bg", ...}`
|
||||
- Type B 역할명이 이 dict에 없어서 항상 `"core"` fallback
|
||||
- overflow 계산이 부정확 → `needs_escalation`이 항상 `False`
|
||||
- **수정:** `if layout_template == "B":` 분기. zone 기반 font 매핑
|
||||
|
||||
### 수행 순서
|
||||
|
||||
1. **Selenium 측정 수정** — data URI → 임시파일 방식 (slide_measurer.py)
|
||||
2. **overflow 분기 추가** — Type B zone 처리 (pipeline.py)
|
||||
3. **calculate_fit Type B 지원** — zone 기반 font 매핑 (fit_verifier.py)
|
||||
4. **에스컬레이션 → Kei 요약 요청** — 이미 있는 코드 활용 (pipeline.py 584-606)
|
||||
5. **검증** — 02번 파이프라인 돌려서 상단 overflow 해소 확인
|
||||
|
||||
### 검증
|
||||
- Selenium 측정에서 상단/하단 zone overflow 감지
|
||||
- overflow 시 Kei에게 요약 요청 → 줄어든 콘텐츠로 재조립
|
||||
- 결과 스크린샷에서 overflow 없음
|
||||
|
||||
---
|
||||
|
||||
## XBX-3: 하단 구조 — 중제목 별도 행
|
||||
|
||||
### 현상
|
||||
"DX 기반 Process 혁신에 따른 주체별 기대효과"가 별도 행 → 공간 낭비.
|
||||
|
||||
```
|
||||
현재: 목표:
|
||||
┌──────────────────────────────┐ ┌─────────────┬──────────────┐
|
||||
│ DX 기반 Process 혁신에 따른...│ ← 별도행 │ 2.1 업무 수행│ 2.2 DX 시행 │
|
||||
├──────────────┬───────────────┤ │ 과정의 변화 │ 주체별 기대효과│
|
||||
│ 2.1 업무 수행 │ 2.2 DX 시행 │ │ (불릿) │ (표) │
|
||||
│ 과정의 변화 │ 주체별 기대효과│ └─────────────┴──────────────┘
|
||||
└──────────────┴───────────────┘ 중제목은 2분할 상단에 작게 표시
|
||||
```
|
||||
|
||||
### 수행 방향
|
||||
|
||||
`scripts/assemble_stage2.py` `_assemble_type_b` + `src/block_assembler.py` `_assemble_slide_html_type_b`:
|
||||
- 하단 대목차(level=2)를 별도 행으로 배치하지 않음
|
||||
- 2분할 각 칸의 상단에 작은 라벨로 표시하거나, 2분할 위에 한 줄 라벨로 통합
|
||||
- 절약된 높이를 2분할 콘텐츠에 할당
|
||||
|
||||
### 검증
|
||||
하단 영역 전체가 2분할 콘텐츠로 사용됨. 중제목이 별도 행을 차지하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## XBX-4: 하단 좌/우 높이 불균형
|
||||
|
||||
### 현상
|
||||
02번 컨테이너:
|
||||
- bottom_left (업무 프로세스 변화): **124px**
|
||||
- bottom_right (주체별 기대효과): **321px**
|
||||
|
||||
높이가 2.5배 차이.
|
||||
|
||||
### 수행 방향
|
||||
|
||||
`src/space_allocator.py`의 `build_containers_type_b` (544-556줄):
|
||||
- 현재 코드에서 `height_px=bottom_h`로 동일하게 주고 있음
|
||||
- **문제는 Kei가 준 weight가 다른 것** → weight에 의해 top/bottom 비율이 달라지고,
|
||||
그 결과 bottom_h 자체가 줄어드는 구조인지 추적 필요
|
||||
- 하단 좌/우는 무조건 **동일 높이**(`bottom_h`)로 고정
|
||||
|
||||
### 검증
|
||||
하단 좌/우 컨테이너가 동일 높이로 나옴.
|
||||
|
||||
---
|
||||
|
||||
## XBX-5: before→filled→after 파이프라인 연결
|
||||
|
||||
### 현상
|
||||
Type B의 filled HTML이 2,742 bytes (거의 빈 HTML). Type A는 214KB.
|
||||
|
||||
### 원인
|
||||
하류 코드가 `containers["배경"]`, `containers["본심"]` 처럼 **Type A 역할명 글자**로 매칭.
|
||||
Type B의 역할명(`"필수요건"`, `"과정혁신"` 등)은 이 키에 없어서 빈 것.
|
||||
|
||||
### 수행 방향
|
||||
|
||||
**원칙: Type A 코드 그대로 두고, `if layout_template == "B":` 분기만 추가.**
|
||||
|
||||
#### 5-1. `src/step_visualizer.py` (9곳+)
|
||||
|
||||
현재:
|
||||
```python
|
||||
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
container = containers[role]
|
||||
```
|
||||
|
||||
수정: Type B 분기 추가. 기존 Type A 코드는 **한 글자도 안 건드림.**
|
||||
```python
|
||||
if layout_template == "B":
|
||||
for role in containers:
|
||||
zone = containers[role].zone
|
||||
color = ZONE_COLORS.get(zone, "#333") # zone 기반 색상
|
||||
# ... Type B 시각화
|
||||
else:
|
||||
# 기존 Type A 코드 그대로
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
...
|
||||
```
|
||||
|
||||
수정 대상 함수 (9곳):
|
||||
- `_gen_stage_1_5a` (271줄)
|
||||
- `_gen_stage_1_5a_content` (297줄)
|
||||
- `_gen_stage_1_5b` (334줄)
|
||||
- `_gen_stage_1_7` (371줄)
|
||||
- `_gen_stage_1_8_fit_before` (419줄)
|
||||
- `_gen_stage_1_8_fit_after` (465줄)
|
||||
- `_gen_stage_1_8_blocks` (534줄)
|
||||
- `_gen_stage_2` (650줄, 683줄)
|
||||
|
||||
#### 5-2. `src/fit_verifier.py`
|
||||
|
||||
- `ROLE_ZONE_MAP` (488-493줄) — 이미 부분 수정됨. containers에 zone 있으면 그걸 사용.
|
||||
- `role_font_map` (307줄) `{"본심": "core", ...}` — Type B 분기 추가:
|
||||
zone 기반 매핑 (`"top" → "core"`, `"bottom_left" → "core"` 등)
|
||||
- `role_line_height` (308줄) — 동일하게 분기
|
||||
- **Type A 코드 안 건드림.** `ROLE_ZONE_MAP`, `role_font_map`은 그대로 두고 fallback으로만 사용.
|
||||
|
||||
#### 5-3. `src/renderer.py`
|
||||
|
||||
- `_find_h` fallback 이미 추가됨. Type A는 `_find_h("배경")` 그대로 동작.
|
||||
- Type B에서 `body_row_h` 계산이 맞는지 확인 필요 — Type B는 body_row가 없고 top+bottom 구조.
|
||||
- 필요시 `if layout_template == "B":` 분기 추가.
|
||||
|
||||
#### 5-4. `src/slide_measurer.py`
|
||||
|
||||
- CSS 클래스 `area-*`로 zone 탐색 → **역할명 하드코딩 없음. 수정 불필요.**
|
||||
- `_assemble_slide_html_type_b`가 `area-top`, `area-bottom`, `area-footer` 클래스를 생성하므로
|
||||
Selenium 측정이 그대로 동작.
|
||||
|
||||
### 검증
|
||||
02번 MDX로 파이프라인 실행 → filled HTML이 10KB+ → Selenium 측정 정상 → after HTML 생성.
|
||||
|
||||
---
|
||||
|
||||
## XBX-6: Sonnet HTML 재구성 프로세스 분리
|
||||
|
||||
### 현상
|
||||
Stage 2(`src/pipeline.py` 901-957줄)에서 Sonnet(`generate_with_retry`)이 HTML 재구성.
|
||||
Type B에서는 품질 불안정.
|
||||
|
||||
### 수행 방향
|
||||
|
||||
`src/pipeline.py` stage_2 함수에 Type B 분기 추가:
|
||||
```python
|
||||
async def stage_2(context: PipelineContext) -> dict:
|
||||
if context.analysis.layout_template == "B":
|
||||
# Type B: code_assembled 결과를 직접 사용, Sonnet 재구성 스킵
|
||||
from src.block_assembler import assemble_slide_html
|
||||
generated = assemble_slide_html(context)
|
||||
return {"generated_html": generated}
|
||||
|
||||
# Type A: 기존 Sonnet 재구성 코드 그대로
|
||||
from src.content_verifier import generate_with_retry
|
||||
...
|
||||
```
|
||||
|
||||
- Sonnet 코드 삭제하지 않음
|
||||
- Type B일 때만 스킵
|
||||
- code_assembled HTML은 `assemble_slide_html(context)`로 생성 (이미 동작 확인됨)
|
||||
|
||||
### 검증
|
||||
- Type A: 기존대로 Sonnet 재구성 → 결과 동일
|
||||
- Type B: code_assembled 직접 사용 → 결과가 스크린샷에서 정상
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
**02번 먼저 완성 → 03번 확장:**
|
||||
|
||||
1. **XBX-1** (들여쓰기) — normalizer depth 보존 + 조립 로직 분기
|
||||
2. **XBX-3** (하단 구조) — 중제목 별도행 → 라벨로 통합
|
||||
3. **XBX-4** (하단 높이) — 좌/우 균등 확인 + 수정
|
||||
4. **XBX-5** (파이프라인 연결) — step_visualizer/fit_verifier/renderer Type B 분기
|
||||
5. **XBX-2** (overflow) — 파이프라인 연결 후 Selenium으로 자동 확인
|
||||
6. **XBX-6** (Sonnet 분리) — pipeline.py Type B 분기
|
||||
7. **03번 확장** — 03번 MDX에서도 동작 확인 (표 보존, 3단 계층 등)
|
||||
|
||||
---
|
||||
|
||||
## 02번 run 정보
|
||||
|
||||
- 최신 run: `data/runs/20260406_121405`
|
||||
- 스크린샷: `steps/code_assembled_02_2x.png`
|
||||
- containers: top=255px(w=847px), bottom_left=124px, bottom_right=321px, footer=83px
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- 하드코딩 절대 금지
|
||||
- HTML 결과물 고치지 말고 파이프라인 프로세스 고칠 것
|
||||
- 제목/텍스트는 원본 MDX에서 그대로
|
||||
- **유형 A 코드 절대 건드리지 않음** — A는 완벽하게 동작 중. 수정도 재검증도 하지 않음.
|
||||
- Type B 코드는 기존 코드에 분기(`if layout_template == "B"`) 추가로만 구현
|
||||
- 검증은 반드시 렌더링(스크린샷)으로
|
||||
142
docs/history/PHASE-X-C.md
Normal file
142
docs/history/PHASE-X-C.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Phase X-C: 서브존 프리셋 기반 범용 레이아웃
|
||||
|
||||
> 최종 업데이트: 2026-04-07
|
||||
> 전제: Type A, Type B 건드리지 않음. Type C로 새 접근.
|
||||
> 의존: Phase X-BX' 완료 후 시작 (zone 기반 코드 전환 완료 필요)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 아이디어
|
||||
|
||||
**AI는 "고르는 것"에만 쓰고, "만드는 것"은 코드가 한다.**
|
||||
|
||||
### 고정 구조
|
||||
|
||||
모든 슬라이드는 3개 zone:
|
||||
```
|
||||
┌───────────────────────────┐
|
||||
│ header (제목) │ ← 고정
|
||||
├───────────────────────────┤
|
||||
│ body (본문) │ ← 서브존 프리셋 적용
|
||||
├───────────────────────────┤
|
||||
│ footer (핵심 요약) │ ← 고정
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
### 서브존 프리셋
|
||||
|
||||
body 안의 배치를 row 조합으로 정의:
|
||||
|
||||
```
|
||||
row 유형:
|
||||
F = 전체폭 (1칸)
|
||||
H = 2분할
|
||||
T = 3분할
|
||||
Q = 4분할
|
||||
|
||||
프리셋 예시:
|
||||
S1: F → 1단 전체폭
|
||||
S2: H → 2단 (좌/우)
|
||||
S3: T → 3단 균등
|
||||
S4: F+H → 상단 전체폭 + 하단 2분할 (현재 Type B에 해당)
|
||||
S5: H+F → 상단 2분할 + 하단 전체폭
|
||||
S6: H+H → 상하 각 2분할 (2x2)
|
||||
S7: F+T → 상단 전체폭 + 하단 3분할
|
||||
S8: T+F → 상단 3분할 + 하단 전체폭
|
||||
S9: F+F → 전체폭 2단 (현재 Type A에 가까움)
|
||||
```
|
||||
|
||||
### Kei 역할 (최소화)
|
||||
|
||||
1. **꼭지 추출** — 콘텐츠를 몇 개 덩어리로 나눌지
|
||||
2. **꼭지 간 관계** — 비교/나열/종속/독립
|
||||
3. **프리셋 선택** — 번호로 고르기 (또는 코드가 자동 매핑)
|
||||
|
||||
### 코드 역할
|
||||
|
||||
1. 프리셋 → zone/sub-zone px 계산 (사칙연산)
|
||||
2. 텍스트량 기반 비율 계산
|
||||
3. before→filled→after 파이프라인 (Selenium 측정 → 재배분)
|
||||
4. MDX 원본 텍스트 배치
|
||||
5. 블록 선택 (프리셋별 적합 블록 매핑)
|
||||
|
||||
---
|
||||
|
||||
## 질문: Kei가 프리셋을 고를 수 있는가?
|
||||
|
||||
### 자동 매핑 (코드가 결정) — 안정적이지만 제한적
|
||||
```python
|
||||
if len(topics) == 1: preset = "S1" # 1단
|
||||
if len(topics) == 2: preset = "S2" # 2분할
|
||||
if len(topics) == 3: preset = "S3" # 3단
|
||||
if len(topics) == 4: preset = "S6" # 2x2
|
||||
```
|
||||
→ 꼭지 관계 무시. 비교 3개 + 정리 1개 같은 경우 대응 못 함.
|
||||
|
||||
### Kei 선택 — 유연하지만 불안정 위험
|
||||
```json
|
||||
{"topics": [...], "preset": "S4", "reason": "상단 3개 비교 + 하단 종합"}
|
||||
```
|
||||
→ 블록 선택도 불안한데 프리셋 선택이 안정적일지 미지수.
|
||||
|
||||
### 하이브리드 (유력) — 코드가 후보 제시, Kei가 선택
|
||||
```
|
||||
코드: "꼭지 4개이므로 후보: S4, S6, S7"
|
||||
Kei: "비교 관계이므로 S4"
|
||||
```
|
||||
→ 선택지를 3개 이하로 좁히면 Kei가 잘 고를 가능성 높음.
|
||||
|
||||
---
|
||||
|
||||
## 기술적 구현 방향
|
||||
|
||||
### sub-zone px 계산
|
||||
|
||||
```python
|
||||
def calculate_sub_zones(preset: str, body_width: int, body_height: int, gap: int):
|
||||
rows = parse_preset(preset) # "F+H" → [F, H]
|
||||
row_count = len(rows)
|
||||
row_height = (body_height - gap * (row_count - 1)) // row_count
|
||||
|
||||
zones = []
|
||||
for i, row_type in enumerate(rows):
|
||||
col_count = {"F": 1, "H": 2, "T": 3, "Q": 4}[row_type]
|
||||
col_width = (body_width - gap * (col_count - 1)) // col_count
|
||||
for j in range(col_count):
|
||||
zones.append({
|
||||
"row": i, "col": j,
|
||||
"width": col_width, "height": row_height,
|
||||
})
|
||||
return zones
|
||||
```
|
||||
|
||||
### 비율 조정
|
||||
```python
|
||||
# 텍스트량 기반 비율
|
||||
text_lengths = [len(topic.text) for topic in row_topics]
|
||||
total = sum(text_lengths)
|
||||
ratios = [l / total for l in text_lengths]
|
||||
# 최소 20%, 최대 60% 제한
|
||||
ratios = [max(0.2, min(0.6, r)) for r in ratios]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 단계별 진행 계획
|
||||
|
||||
1. **X-C-1: 프리셋 정의** — S1~S9 구조 + px 계산 함수
|
||||
2. **X-C-2: 자동 매핑 먼저** — 꼭지 수 → 프리셋 (코드만으로)
|
||||
3. **X-C-3: 조립 범용화** — zone 기반으로 어떤 프리셋이든 조립
|
||||
4. **X-C-4: Kei 선택 실험** — 하이브리드 방식 테스트
|
||||
5. **X-C-5: before→filled→after 연결** — X-BX'의 zone 기반 코드 활용
|
||||
6. **X-C-6: 검증** — 01/02/03번 + 새 MDX로 범용성 테스트
|
||||
|
||||
---
|
||||
|
||||
## Type A/B와의 관계
|
||||
|
||||
- Type A, B는 기존 코드 그대로 유지
|
||||
- Type C는 별도 경로로 동작
|
||||
- 추후 Type C가 안정화되면 A/B를 C의 프리셋으로 흡수 가능:
|
||||
- Type A ≈ S9 (F+F) + sidebar
|
||||
- Type B ≈ S4 (F+H)
|
||||
111
docs/history/PHASE-X-PRIME.md
Normal file
111
docs/history/PHASE-X-PRIME.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Phase X': 유형 B 파이프라인 개선
|
||||
|
||||
> 최종 업데이트: 2026-04-06
|
||||
> 전제: 유형 A 건드리지 않음. 유형 B 파이프라인 프로세스 수정.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- 유형 A (배경+본심+첨부+결론): ✅ 동작 (01번 MDX)
|
||||
- 유형 B (상단+하단2분할+결론): **code_assembled만 동작, 파이프라인(before→filled→after) 미연결**
|
||||
|
||||
## 완료된 것
|
||||
|
||||
### X'-1: 제목 원본 MDX에서 가져오기 ✅
|
||||
- `context.normalized.title` 사용 (Kei title 대신)
|
||||
- 파일: `src/pipeline.py` Stage 1A
|
||||
|
||||
### X'-2: 들여쓰기 계층 ✅ (code_assembled에서만)
|
||||
- `###` 소제목 → 카드형 분리
|
||||
- 본문 불릿 indent 적용
|
||||
- 파일: `scripts/assemble_stage2.py`
|
||||
|
||||
### X'-3: 이미지 캡션 ✅
|
||||
- `normalized.images` alt text에서 추출
|
||||
- 파일: `scripts/assemble_stage2.py`
|
||||
|
||||
### X'-4: 상단 균등배분 ✅
|
||||
- `justify-content:space-between`
|
||||
- 파일: `scripts/assemble_stage2.py`
|
||||
|
||||
### X'-5: 카드 디자인 ✅
|
||||
- 다크 그라데이션 + 밝은 텍스트
|
||||
- 파일: `scripts/assemble_stage2.py`
|
||||
|
||||
### X'-6: 표 요약 ✅ (code_assembled에서만)
|
||||
- `normalized.tables` → pipeline V'-2에서 Kei 요약 → context 저장
|
||||
- `_assemble_type_b` 하단 우측에 표출
|
||||
- 파일: `src/pipeline.py`, `scripts/assemble_stage2.py`
|
||||
|
||||
### MDX sections 계층 ✅
|
||||
- `mdx_normalizer`: `###` (h3) 소목차도 section으로 분리
|
||||
- `_assemble_type_b`: `normalized.sections`에서 직접 텍스트 가져오기
|
||||
- 대목차/소목차 계층 반영
|
||||
|
||||
---
|
||||
|
||||
## 핵심 미해결 문제
|
||||
|
||||
### 유형 B의 before→filled→after 파이프라인이 연결 안 됨
|
||||
|
||||
**증거:**
|
||||
- FILLED: 2997bytes, 한글 80자 (유형 A는 214KB)
|
||||
- `block_assembler.assemble_slide_html()`이 고정 4역할(배경/본심/첨부/결론)만 처리
|
||||
- 유형 B의 자유 역할명(DX_궁극적_목표, 프로세스_변화 등)을 처리 못 함
|
||||
- 결과: filled/after가 거의 빈 HTML
|
||||
|
||||
**해결 방향:**
|
||||
- `block_assembler.assemble_slide_html()`이 유형 B 역할도 처리하도록
|
||||
- 또는 유형 B 전용 filled/after 함수 추가
|
||||
- `_assemble_type_b`(assemble_stage2)는 code_assembled 전용이므로, 파이프라인의 filled/after에는 별도 로직 필요
|
||||
|
||||
### 렌더링에서 잘림
|
||||
|
||||
**증거:**
|
||||
- code_assembled에 모든 내용이 HTML로 있지만 브라우저에서 보면 잘림
|
||||
- overflow:hidden + 컨테이너 크기 < 내용 크기
|
||||
- 상단 카드가 잘림, 결론이 안 보임
|
||||
|
||||
**해결 방향:**
|
||||
- 컨테이너 크기 계산에서 내용 크기를 고려
|
||||
- 또는 Selenium 측정 후 재배분 (이건 filled→after 파이프라인이 동작해야 가능)
|
||||
|
||||
---
|
||||
|
||||
## Kei가 하는 일 (명확히 정리)
|
||||
|
||||
1. **꼭지 찾기 + 그루핑** — MDX 구조 분석
|
||||
2. **유형 선택 (A/B)** — 콘텐츠에 맞는 레이아웃
|
||||
3. **블록 선택** — 컨테이너에 맞는 블록 타입
|
||||
4. **공란에 표/팝업 요약** — 원문 최대 유지
|
||||
5. **bold 키워드 판단** — 문맥 기반
|
||||
|
||||
**나머지는 전부 MDX 원본에서 가져옴:**
|
||||
- 제목, 대목차, 중목차, 소목차, 텍스트 — 원본 그대로
|
||||
- 핵심 요약 — 원본 그대로
|
||||
- Kei가 텍스트를 재작성하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 작업 순서
|
||||
|
||||
1. **유형 B filled/after 파이프라인 연결** — block_assembler 또는 별도 함수
|
||||
2. **컨테이너 크기 vs 내용 크기 맞춤** — Selenium 측정 기반 재배분
|
||||
3. **렌더링 잘림 해결** — overflow 처리
|
||||
4. **01번(유형 A) 깨지지 않는지 확인**
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
| 파일 | 역할 | 유형 B 상태 |
|
||||
|------|------|------------|
|
||||
| `src/kei_client.py` | KEI_PROMPT (유형 A/B 선택) | ✅ |
|
||||
| `src/validators.py` | 검증기 (유형 B 완화) | ✅ |
|
||||
| `src/space_allocator.py` | 컨테이너 생성 (build_containers_type_b) | ✅ |
|
||||
| `src/pipeline.py` | 파이프라인 분기 (layout_template) | ✅ 분기만 |
|
||||
| `src/pipeline_context.py` | Analysis.layout_template | ✅ |
|
||||
| `src/mdx_normalizer.py` | ### 소목차 section 분리 | ✅ |
|
||||
| `scripts/assemble_stage2.py` | _assemble_type_b (code_assembled) | ✅ |
|
||||
| `src/block_assembler.py` | assemble_slide_html (filled/after) | ❌ 유형 B 미지원 |
|
||||
39
docs/history/PHASE-X.md
Normal file
39
docs/history/PHASE-X.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Phase X: 콘텐츠 기반 레이아웃 판단 프로세스
|
||||
|
||||
## 배경
|
||||
|
||||
현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다.
|
||||
|
||||
## 현재 프로세스 (문제)
|
||||
|
||||
```
|
||||
Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분
|
||||
```
|
||||
|
||||
- `reference` 있으면 → `sidebar-right` (무조건)
|
||||
- 콘텐츠 양/특성 기반 판단 없음
|
||||
- 레이아웃이 콘텐츠에 맞는지 검증 없음
|
||||
|
||||
## 목표 프로세스
|
||||
|
||||
```
|
||||
1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음)
|
||||
2. FILLED: 콘텐츠 채움
|
||||
3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등)
|
||||
4. 판단2: 결정된 레이아웃에서 크기 재배분
|
||||
5. AFTER: 최종 레이아웃 + 크기
|
||||
```
|
||||
|
||||
- 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과**
|
||||
- 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨
|
||||
|
||||
## 관련 코드
|
||||
|
||||
- `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()`
|
||||
- `src/pipeline.py`: Stage 1.5a에서 preset 선택
|
||||
- `src/kei_client.py`: Stage 1A에서 role 태그 부여
|
||||
- `src/space_allocator.py`: zone 기반 컨테이너 배분
|
||||
|
||||
## 상태
|
||||
|
||||
Phase W (before→filled→after 파이프라인) 완료 후 착수.
|
||||
245
docs/history/PHASE2-PLAN.md
Normal file
245
docs/history/PHASE2-PLAN.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Phase 2 계획 — 파이프라인 고도화 + 검색 + 시각화 자동화
|
||||
|
||||
## Phase 1 완료 현황 (2026-03-25)
|
||||
|
||||
| 항목 | 상태 | 수량 |
|
||||
|------|------|------|
|
||||
| 블록 라이브러리 | ✅ | 46개 (6 카테고리) |
|
||||
| catalog.yaml | ✅ | 46개 등록, when/not_for/slots/height_cost |
|
||||
| BLOCK_SLOTS | ✅ | 46개 동기화 |
|
||||
| _apply_defaults | ✅ | 46개 동기화 |
|
||||
| SVG premium | ✅ | venn-diagram 검증 (3개 고정) |
|
||||
| Figma 에셋 | ✅ | 스크린샷 16장, 에셋 15개+ |
|
||||
| 5단계 파이프라인 | ✅ | 코드 동작 (BF-4~10 수정 완료/진행중) |
|
||||
| Kei API 연동 | ✅ | 1단계(실장) + 3단계(편집자) |
|
||||
| catalog→renderer 매핑 | ✅ | mtime 캐시 (BF-10) |
|
||||
| grid 역할 분리 | ✅ | BF-9 (코드가 grid, Sonnet은 blocks만) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 목표
|
||||
|
||||
**파이프라인을 실제 사용 가능한 수준으로 고도화한다.**
|
||||
|
||||
Phase 1은 "기반 구축 + 블록 라이브러리"였고,
|
||||
Phase 2는 "AI가 블록을 정확히 선택 + 고품질 결과물 생성"이다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-A: FAISS 블록 검색 인덱스
|
||||
|
||||
### 목적
|
||||
디자인 팀장(Step B)이 콘텐츠를 보고 46개 블록 중 적합한 것을 **검색**으로 찾는다.
|
||||
현재는 catalog.yaml 전문이 프롬프트에 들어가는데, 46개가 넘으면 토큰 낭비 + 선택 정확도 저하.
|
||||
|
||||
### 구현
|
||||
```
|
||||
1. 각 블록의 (id + visual + when + not_for)를 임베딩
|
||||
2. FAISS 인덱스 구축 (46개 벡터)
|
||||
3. 콘텐츠 꼭지 분석 결과를 쿼리 → 상위 5~8개 후보 반환
|
||||
4. 팀장 프롬프트에 후보 블록만 포함 (전체 46개 대신)
|
||||
```
|
||||
|
||||
### 효과
|
||||
- 프롬프트 토큰 절약 (46개 전문 → 5~8개만)
|
||||
- 선택 정확도 향상 (관련 블록만 보여주니까)
|
||||
- 블록 100개+까지 확장 가능
|
||||
|
||||
### 파일
|
||||
- `src/block_search.py` (신규)
|
||||
- `data/block_index.faiss` (생성)
|
||||
- `src/design_director.py` (catalog 전문 → 검색 결과로 교체)
|
||||
|
||||
### 의존성
|
||||
- sentence-transformers 또는 Anthropic embeddings
|
||||
- FAISS (faiss-cpu)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-B: SVG N개 자동 배치
|
||||
|
||||
### 목적
|
||||
현재 venn-diagram은 3개 원 고정. 콘텐츠에 따라 2~7개 원이 필요할 수 있음.
|
||||
수학적 계산(cos/sin)으로 N개를 자동 배치한다.
|
||||
|
||||
### 구현
|
||||
```
|
||||
1. renderer.py에 SVG 좌표 계산 함수 추가
|
||||
- calc_circle_positions(n, center, radius) → [(cx, cy), ...]
|
||||
- 360/N 간격, 12시 방향부터 시계방향
|
||||
|
||||
2. venn-diagram.html 템플릿을 동적으로 변경
|
||||
- items[]에 사전 계산된 cx, cy가 포함
|
||||
- 원 크기도 N에 따라 자동 조정 (N=3: r=120, N=5: r=80, N=7: r=60)
|
||||
|
||||
3. Gemini 참고 디자인 흐름 (선택적)
|
||||
- SVG 초안 → Gemini에게 "이 구조로 고급 디자인" 요청
|
||||
- 생성 이미지의 색감/그라데이션을 참고하여 SVG tokens 업데이트
|
||||
- 최종은 항상 SVG (AI 이미지는 참고만)
|
||||
```
|
||||
|
||||
### 파일
|
||||
- `src/svg_calculator.py` (신규)
|
||||
- `templates/blocks/visuals/venn-diagram.html` (동적 좌표 지원)
|
||||
- `src/renderer.py` (SVG 계산 호출 추가)
|
||||
|
||||
### 검증 완료 사항
|
||||
- 3/4/5개 수학적 계산: ✅ (Phase 1에서 테스트)
|
||||
- SVG premium 디자인 (radialGradient+filter): ✅
|
||||
- AI 이미지 방식 폐기: ✅ (텍스트 위치 불일치로 사용 불가)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-C: 2단계 Step A 고도화 (Opus + FAISS)
|
||||
|
||||
### 목적
|
||||
현재 Step A는 규칙 4줄(프리셋 4개 중 선택)인데,
|
||||
원래 의도는 **Opus가 FAISS로 적합한 구조/블록을 검색해서 배치와 크기까지 정하는 것**.
|
||||
|
||||
### 구현
|
||||
```
|
||||
현재 (Phase 1):
|
||||
Step A: 규칙 기반 프리셋 선택 (코드)
|
||||
Step B: Sonnet이 블록 매핑
|
||||
|
||||
Phase 2:
|
||||
Step A: Opus + FAISS로 구조/블록 검색 + 배치/크기 결정
|
||||
Step B: Sonnet이 Step A 결과를 grid에 매칭 + 글자수 가이드
|
||||
```
|
||||
|
||||
### 세부
|
||||
1. Opus가 콘텐츠를 보고 "이 콘텐츠에는 비교형+정의형+관계도가 적합" 판단
|
||||
2. FAISS에서 각 유형에 맞는 블록 후보 검색
|
||||
3. 후보 중 배치/크기를 결정 (좌 65% 비교표, 우 35% 정의 카드, 하단 관계도)
|
||||
4. Step B(Sonnet)에게 이 배치 구조 + 후보 블록을 전달
|
||||
|
||||
### 의존성
|
||||
- Phase 2-A (FAISS 인덱스) 완료 필요
|
||||
- Kei API (Opus) 안정 동작
|
||||
|
||||
### 파일
|
||||
- `src/design_director.py` (Step A 전면 재작성)
|
||||
- `src/block_search.py` (Phase 2-A에서 생성)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-D: 5단계 재검토 강화
|
||||
|
||||
### 현재 문제
|
||||
```
|
||||
IMPROVEMENT 분석에서 발견된 문제:
|
||||
- HTML을 프롬프트에 실제 전달하지 않음 (블록 데이터 양만 전달)
|
||||
- shrink action이 no-op
|
||||
- rewrite action이 no-op
|
||||
- 조정 후 재편집이 정확하지 않음
|
||||
```
|
||||
|
||||
### 구현
|
||||
```
|
||||
1. HTML 코드 요약을 프롬프트에 전달
|
||||
- 전체 HTML은 너무 크니까, 블록별 텍스트 길이 + 구조 요약
|
||||
|
||||
2. shrink action 구현
|
||||
- char_guide를 줄여서 재편집 유도
|
||||
|
||||
3. rewrite action 구현
|
||||
- 특정 블록의 텍스트를 완전히 재작성
|
||||
|
||||
4. 조정 횟수 제한
|
||||
- 무한 루프 방지: 최대 2회 재조정
|
||||
```
|
||||
|
||||
### 파일
|
||||
- `src/pipeline.py` (_review_balance, _apply_adjustments 개선)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-E: 누락 기능 구현
|
||||
|
||||
### E-1: Pillow 이미지 크기 읽기
|
||||
```
|
||||
콘텐츠에 이미지가 포함될 때:
|
||||
- Pillow로 원본 크기 확인
|
||||
- 가로/세로 비율에 따라 블록 선택 (image-full vs image-side-text)
|
||||
- 팀장에게 이미지 크기 정보 전달
|
||||
```
|
||||
- 파일: `src/design_director.py` (Step B에 이미지 정보 추가)
|
||||
|
||||
### E-2: `<details>/<summary>` 완성
|
||||
```
|
||||
현재 details-block.html은 있지만 파이프라인에서 활용 안 됨:
|
||||
- 실장이 detail_target으로 판단한 꼭지를 details-block으로 연결
|
||||
- 편집자가 summary + detail 두 버전 작성
|
||||
- 인쇄 시 자동 펼침 JavaScript
|
||||
```
|
||||
- 파일: `src/pipeline.py`, `templates/blocks/emphasis/details-block.html`
|
||||
|
||||
### E-3: 디자인 실무자 AI 조정
|
||||
```
|
||||
현재 4단계는 순수 코드(Jinja2)만.
|
||||
CLAUDE.md에는 "텍스트에 맞게 폰트/여백/박스 조정"이라고 되어있음.
|
||||
- Sonnet이 렌더링된 HTML을 보고 CSS 미세 조정 제안
|
||||
- 또는 CSS 변수를 동적으로 조정하는 코드
|
||||
```
|
||||
- 파일: `src/renderer.py` 또는 `src/pipeline.py`
|
||||
- 우선순위: 낮음 (다른 것이 더 급함)
|
||||
|
||||
---
|
||||
|
||||
## ~~Phase 2-F: 출력 확장~~ (design_agent 범위 밖)
|
||||
|
||||
**글벗에서 처리:**
|
||||
- design_agent는 HTML 생성까지만 담당
|
||||
- .astro 변환 → 글벗이 design_agent API 호출 후 HTML → .astro 래핑
|
||||
- 글벗 연동 → 글벗 쪽에서 design_agent의 `/api/generate` 호출
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서 (의존 관계)
|
||||
|
||||
```
|
||||
Phase 2-A (FAISS) ─────────────────────┐
|
||||
├→ Phase 2-C (Step A: Opus+FAISS)
|
||||
Phase 2-B (SVG N개) ── 독립, 병렬 가능 │
|
||||
│
|
||||
Phase 2-D (5단계 강화) ── 독립, 병렬 가능 │
|
||||
│
|
||||
Phase 2-E (누락 기능) ── 독립, 병렬 가능 │
|
||||
│
|
||||
Phase 2-F (출력 확장) ─── 글벗에서 처리 (design_agent 범위 밖)
|
||||
```
|
||||
|
||||
### 추천 실행 순서
|
||||
```
|
||||
1순위: Phase 2-A (FAISS) — 블록 선택 정확도의 핵심
|
||||
2순위: Phase 2-B (SVG N개) — 시각화 자동화
|
||||
3순위: Phase 2-D (5단계 강화) — 결과물 품질
|
||||
4순위: Phase 2-E (누락 기능) — 완성도
|
||||
5순위: Phase 2-C (Step A: Opus) — Phase 2-A 완료 필요
|
||||
※ Phase 2-F (출력 확장) — 글벗에서 처리, design_agent 범위 밖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택 추가
|
||||
|
||||
| 역할 | 도구 | Phase |
|
||||
|------|------|-------|
|
||||
| 블록 검색 | FAISS + sentence-transformers | 2-A |
|
||||
| SVG 계산 | Python math (cos/sin) | 2-B |
|
||||
| Step A | Opus via Kei API + FAISS | 2-C |
|
||||
| 이미지 크기 | Pillow | 2-E |
|
||||
| .astro 출력 | Jinja2 + StarlightPage 템플릿 | 2-F |
|
||||
|
||||
---
|
||||
|
||||
## 성공 기준
|
||||
|
||||
```
|
||||
Phase 2 완료 시:
|
||||
✅ 텍스트 원고 입력 → 85점 슬라이드 HTML 자동 생성
|
||||
✅ 블록 선택이 콘텐츠에 정확히 매칭 (FAISS 검색)
|
||||
✅ 관계도/다이어그램 N개 자동 배치 (SVG)
|
||||
✅ 재검토 루프가 실질적으로 동작
|
||||
✅ Starlight에서 바로 볼 수 있는 .astro 출력
|
||||
```
|
||||
321
docs/history/PHASE2-PROCESS.md
Normal file
321
docs/history/PHASE2-PROCESS.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Phase 2 실행 프로세스
|
||||
|
||||
## 절대 규칙 (모든 작업에 적용)
|
||||
|
||||
```
|
||||
🔴 단발성/하드코딩 금지 — 모든 구현은 N개, M종류에 범용 동작
|
||||
🔴 회귀 금지 — Phase 1 확정 구조(catalog 매핑, grid 분리, Kei API 우선) 되돌리지 않음
|
||||
🔴 Opus→Sonnet 대체 금지 — Kei API가 기본, Sonnet은 fallback만
|
||||
🔴 "일단 돌아가게" 금지 — 설계대로 구현하거나 설계를 먼저 변경
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 완료 자산
|
||||
|
||||
| 항목 | 수량/상태 |
|
||||
|------|----------|
|
||||
| 블록 라이브러리 | 46개 (6 카테고리) |
|
||||
| catalog.yaml | 46개 (when/not_for/slots/height_cost) |
|
||||
| BLOCK_SLOTS + _apply_defaults | 46개 동기화 |
|
||||
| SVG premium | venn-diagram 3개 고정 검증 |
|
||||
| 5단계 파이프라인 | 동작 (BF-4~10 수정) |
|
||||
| Kei API 연동 | 1단계(실장) + 3단계(편집자) |
|
||||
| grid 역할 분리 | BF-9 (코드가 grid, Sonnet은 blocks만) |
|
||||
| catalog→renderer 매핑 | mtime 캐시 (BF-10) |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
Phase 2-A (FAISS 블록 검색)
|
||||
↓
|
||||
Phase 2-B (SVG N개 자동 배치) ← 2-A와 병렬 가능
|
||||
↓
|
||||
Phase 2-D (5단계 재검토 강화) ← 2-A/2-B와 병렬 가능
|
||||
↓
|
||||
Phase 2-E (누락 기능: Pillow, details-block)
|
||||
↓
|
||||
Phase 2-C (Step A: Opus + FAISS) ← 2-A 완료 필수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-A: FAISS 블록 검색 인덱스
|
||||
|
||||
### 목적
|
||||
팀장(Step B) 프롬프트에 46개 catalog 전문 대신, FAISS 검색으로 **관련 블록 5~8개만** 전달.
|
||||
|
||||
### 수정 파일
|
||||
| 파일 | 변경 | 신규/수정 |
|
||||
|------|------|---------|
|
||||
| `src/block_search.py` | FAISS 인덱스 구축 + 검색 함수 | **신규** |
|
||||
| `src/design_director.py` | `_load_catalog()` → 검색 결과로 교체 | 수정 (line 294) |
|
||||
| `data/block_index.faiss` | 인덱스 파일 | **신규** |
|
||||
| `data/block_metadata.json` | id→블록 매핑 | **신규** |
|
||||
| `pyproject.toml` | faiss-cpu, sentence-transformers 의존성 | 수정 |
|
||||
|
||||
### 기술 상세
|
||||
```
|
||||
임베딩 모델: BAAI/bge-m3 (1024차원)
|
||||
→ Kei persona에서 검증됨 (retriever.py line 49)
|
||||
→ 한국어 최적화
|
||||
|
||||
인덱스 방식: faiss.IndexFlatIP (Inner Product = 코사인 유사도)
|
||||
→ Kei와 동일 패턴 (retriever.py line 88)
|
||||
|
||||
검색 입력: 꼭지별 title + summary + layer + role
|
||||
검색 출력: 상위 8개 블록 (id + visual + when + not_for + slots)
|
||||
|
||||
fallback: FAISS 인덱스 없거나 검색 실패 시 → catalog.yaml 전문 (기존 방식)
|
||||
```
|
||||
|
||||
### 프로세스
|
||||
```
|
||||
1. scripts/build_block_index.py 실행 (1회성)
|
||||
→ catalog.yaml 읽기
|
||||
→ 각 블록의 (name + visual + when) 임베딩
|
||||
→ data/block_index.faiss + data/block_metadata.json 생성
|
||||
|
||||
2. src/block_search.py
|
||||
→ 서버 시작 시 인덱스 로드
|
||||
→ search_blocks(query, top_k=8) → 관련 블록 목록 반환
|
||||
|
||||
3. src/design_director.py 수정
|
||||
→ _load_catalog() 대신 search_blocks() 호출
|
||||
→ 꼭지별 검색 → 카테고리별 최소 1개 보장 → 프롬프트에 삽입
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
```
|
||||
design_director.py _load_catalog(): 문자열 반환 → 문자열 반환 (인터페이스 동일) ✅
|
||||
renderer.py _load_catalog_map(): 별도 함수, 영향 없음 ✅
|
||||
content_editor.py: BLOCK_SLOTS만 참조, 영향 없음 ✅
|
||||
pipeline.py: create_layout_concept() 인터페이스 동일 ✅
|
||||
```
|
||||
|
||||
### 점검
|
||||
- [ ] FAISS 실패 시 catalog 전문 fallback 동작하는가?
|
||||
- [ ] 검색 결과에 카테고리별 최소 1개 보장되는가?
|
||||
- [ ] 블록 60개로 늘어나도 인덱스 재구축만으로 동작하는가?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-B: SVG N개 자동 배치
|
||||
|
||||
### 목적
|
||||
venn-diagram의 원 3개 고정 → N개(2~7) 자동 배치. cos/sin 수학적 계산.
|
||||
|
||||
### 수정 파일
|
||||
| 파일 | 변경 | 신규/수정 |
|
||||
|------|------|---------|
|
||||
| `src/svg_calculator.py` | 좌표 계산 함수 | **신규** |
|
||||
| `src/renderer.py` | venn-diagram 렌더링 전 좌표 전처리 | 수정 (render_multi_page 내) |
|
||||
| `templates/blocks/visuals/venn-diagram.html` | 하드코딩 좌표 → 동적 `{{ item.cx }}` | 수정 |
|
||||
|
||||
### 기술 상세
|
||||
```
|
||||
src/svg_calculator.py:
|
||||
calc_circle_positions(n, center_x, center_y, radius) → [{cx, cy}, ...]
|
||||
→ angle = (2π × i / n) - π/2 (12시부터 시계방향)
|
||||
→ 의존성: Python math (내장)
|
||||
|
||||
calc_circle_radius(n) → int
|
||||
→ n≤3: 120, n≤5: 80, n≤7: 60
|
||||
→ 하드코딩 아님: base_radius / (1 + (n-3)*0.15) 공식
|
||||
|
||||
calc_outer_circle(n) → int
|
||||
→ 큰 원 반지름도 N에 따라 조정
|
||||
|
||||
renderer.py 수정:
|
||||
render_multi_page() 안에서 block_type == "venn-diagram" 일 때:
|
||||
1. items = block_data.get("items", [])
|
||||
2. positions = calc_circle_positions(len(items))
|
||||
3. for i, item in enumerate(items): item["cx"] = positions[i]["cx"]
|
||||
4. 나머지는 Jinja2가 처리
|
||||
|
||||
venn-diagram.html 수정:
|
||||
현재: cx="265" (하드코딩)
|
||||
변경: cx="{{ item.cx }}" (동적)
|
||||
fallback: items에 cx가 없으면 기존 3개 고정 좌표 사용
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
```
|
||||
renderer.py: render_multi_page()에 if 분기 추가 — 기존 흐름에 영향 없음 ✅
|
||||
(venn-diagram 아닌 블록은 그대로 통과)
|
||||
venn-diagram.html: Phase 1 고정 SVG → 동적으로 변경
|
||||
→ fallback(cx 없으면 기존 좌표) 필수 ✅
|
||||
pipeline.py: 변경 없음 ✅
|
||||
content_editor.py: items[].cx는 renderer에서 추가, 편집자는 모름 ✅
|
||||
```
|
||||
|
||||
### 점검
|
||||
- [ ] N=2, 3, 4, 5, 6, 7 각각 렌더링 테스트
|
||||
- [ ] items에 cx/cy가 없을 때 Phase 1 고정 SVG로 fallback
|
||||
- [ ] 원끼리 겹침 없이 배치되는가? (N=7 특히)
|
||||
- [ ] 큰 원 안에 모든 작은 원이 들어가는가?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-D: 5단계 재검토 강화
|
||||
|
||||
### 목적
|
||||
_review_balance가 실질적으로 동작하도록 강화. shrink/rewrite 구현.
|
||||
|
||||
### 수정 파일
|
||||
| 파일 | 변경 | 신규/수정 |
|
||||
|------|------|---------|
|
||||
| `src/pipeline.py` | _review_balance 프롬프트 + _apply_adjustments 3개 action | 수정 |
|
||||
|
||||
### 기술 상세
|
||||
```
|
||||
_review_balance 개선:
|
||||
현재: 블록별 데이터 양(글자수)만 전달
|
||||
변경: 블록별 (area + type + 데이터 양 + height_cost) 전달
|
||||
+ 전체 zone 예산 대비 사용량
|
||||
|
||||
_apply_adjustments 개선:
|
||||
현재: expand만 동작 (char_guide * 1.5)
|
||||
변경:
|
||||
expand: char_guide * 1.5 (현재와 동일)
|
||||
shrink: char_guide * 0.7 (신규)
|
||||
rewrite: block["data"] 제거 → fill_content 재호출 시 재작성 (신규)
|
||||
|
||||
재조정 루프:
|
||||
MAX_ADJUSTMENTS = 2 (상수, 하드코딩 아닌 설정값)
|
||||
for attempt in range(MAX_ADJUSTMENTS): ...
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
```
|
||||
pipeline.py 내부 함수만 수정 ✅
|
||||
fill_content 재호출: Kei API 우선 (Phase 1에서 수정됨) ✅
|
||||
renderer.py: 변경 없음 ✅
|
||||
```
|
||||
|
||||
### 점검
|
||||
- [ ] expand/shrink/rewrite 3개 action 모두 동작하는가?
|
||||
- [ ] MAX_ADJUSTMENTS 초과 시 루프 종료되는가?
|
||||
- [ ] fill_content 재호출이 Kei API를 거치는가? (Sonnet 직접 아닌지)
|
||||
- [ ] rewrite 후 _apply_defaults로 빈 데이터가 처리되는가?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-E: 누락 기능
|
||||
|
||||
### E-1: Pillow 이미지 크기
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| `src/design_director.py` | create_layout_concept() 내 이미지 크기 확인 |
|
||||
|
||||
```
|
||||
수정 위치: topics 순회할 때 content_type=="image" 확인
|
||||
→ Pillow Image.open().size로 width, height 읽기
|
||||
→ topic에 image_width, image_height, image_ratio 추가
|
||||
→ Step B 프롬프트에 이미지 크기 정보 포함
|
||||
→ 팀장이 가로형→image-full, 세로형→image-side-text 판단 가능
|
||||
|
||||
fallback: 이미지 파일 없으면 → 기본 비율 1.5 (가로형 가정)
|
||||
⚠️ 이것은 하드코딩이 아닌 "정보 부재 시 안전한 기본값"
|
||||
```
|
||||
|
||||
### E-2: details-block 연결
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| `src/design_director.py` | detail_target 꼭지를 "생략" → "details-block 배치"로 |
|
||||
| `src/content_editor.py` | detail_target 꼭지에 summary + detail 두 버전 작성 |
|
||||
|
||||
```
|
||||
현재: design_director.py에서 detail_target 꼭지를 "생략 (미구현)"으로 처리
|
||||
변경: detail_target 꼭지를 details-block으로 body/sidebar에 배치
|
||||
→ 편집자가 summary(3줄) + detail(전체) 작성
|
||||
→ renderer가 <details>/<summary>로 조립
|
||||
```
|
||||
|
||||
### 점검
|
||||
- [ ] 이미지 없는 콘텐츠에서 Pillow 에러 안 나는가?
|
||||
- [ ] detail_target 꼭지가 details-block으로 렌더링되는가?
|
||||
- [ ] <details> 접기/펼치기가 브라우저에서 동작하는가?
|
||||
- [ ] 인쇄 시 자동 펼침 JavaScript가 동작하는가?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-C: Step A Opus+FAISS
|
||||
|
||||
### 목적
|
||||
규칙 4줄 → Opus가 FAISS 검색으로 구조/블록 선정 + 배치/크기 결정.
|
||||
|
||||
### 수정 파일
|
||||
| 파일 | 변경 | 신규/수정 |
|
||||
|------|------|---------|
|
||||
| `src/design_director.py` | select_preset() 유지 + _opus_block_selection() 추가 | 수정 |
|
||||
|
||||
### 기술 상세
|
||||
```
|
||||
현재 흐름:
|
||||
Step A: select_preset() → 규칙 기반 (코드)
|
||||
Step B: Sonnet → 블록 매핑
|
||||
|
||||
Phase 2 흐름:
|
||||
Step A-1: select_preset() → 프리셋 선택 (유지, 안정적)
|
||||
Step A-2: _opus_block_selection() → Kei API(Opus)로 블록 후보 선정
|
||||
입력: 꼭지 분석 + FAISS 검색 결과
|
||||
출력: 각 꼭지에 추천 블록 + 배치 방향 + 크기 가이드
|
||||
Step B: Sonnet → Opus 추천 기반으로 최종 매핑 + 글자수 가이드
|
||||
|
||||
핵심: Opus 호출은 반드시 Kei API 경유
|
||||
→ kei_client.py의 _call_kei_api() 패턴 재사용
|
||||
→ anthropic.AsyncAnthropic 직접 호출 절대 금지
|
||||
```
|
||||
|
||||
### 의존성
|
||||
```
|
||||
Phase 2-A 완료 필수 (FAISS 인덱스 + search_blocks 함수)
|
||||
Kei API(localhost:8000) 안정 동작 필요
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
```
|
||||
select_preset(): 유지 (삭제하지 않음) ✅
|
||||
create_layout_concept(): Step A-2 결과를 Step B에 전달하는 구조 추가
|
||||
→ 기존 인터페이스(return {"title": ..., "pages": [...]}) 동일 ✅
|
||||
pipeline.py: create_layout_concept() 호출 방식 동일 ✅
|
||||
```
|
||||
|
||||
### 점검
|
||||
- [ ] Opus 호출이 Kei API를 거치는가? (`grep "AsyncAnthropic" → fallback만`)
|
||||
- [ ] Kei API 실패 시 현재 방식(규칙+Sonnet)으로 fallback
|
||||
- [ ] FAISS 검색 결과가 Opus에게 전달되는가?
|
||||
- [ ] select_preset()이 삭제되지 않았는가? (안정적 규칙은 유지)
|
||||
|
||||
---
|
||||
|
||||
## 산출물 체크리스트
|
||||
|
||||
### 코드 파일
|
||||
```
|
||||
신규:
|
||||
src/block_search.py ← 2-A
|
||||
src/svg_calculator.py ← 2-B
|
||||
scripts/build_block_index.py ← 2-A
|
||||
data/block_index.faiss ← 2-A
|
||||
data/block_metadata.json ← 2-A
|
||||
|
||||
수정:
|
||||
src/design_director.py ← 2-A, 2-C, 2-E
|
||||
src/renderer.py ← 2-B
|
||||
src/pipeline.py ← 2-D, 2-E
|
||||
templates/blocks/visuals/venn-diagram.html ← 2-B
|
||||
pyproject.toml ← 2-A (의존성)
|
||||
```
|
||||
|
||||
### 문서
|
||||
```
|
||||
docs/PHASE2-PLAN.md ← 완료
|
||||
docs/PHASE2-PROCESS.md ← 이 파일
|
||||
docs/PHASE2-TECH-REVIEW.md ← 완료
|
||||
PLAN.md ← Phase 2 태스크 추가 필요
|
||||
PROGRESS.md ← Phase 2 진행 상황 추적
|
||||
```
|
||||
396
docs/history/PHASE2-TECH-REVIEW.md
Normal file
396
docs/history/PHASE2-TECH-REVIEW.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# Phase 2 기술 검토 보고서
|
||||
|
||||
각 항목별로 **정확한 구현 방법, 기존 코드 충돌 여부, 회귀 위험, 대충 처리 위험**을 검토한다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-A: FAISS 블록 검색
|
||||
|
||||
### 현재 코드 상태
|
||||
```
|
||||
design_director.py line 184~188: _load_catalog()
|
||||
→ catalog.yaml 전문을 문자열로 읽어서 프롬프트에 통째로 넣음
|
||||
→ 46개 블록 전체 설명 = 약 8,000~10,000 토큰
|
||||
|
||||
design_director.py line 294: catalog_text = _load_catalog()
|
||||
design_director.py line 322: catalog=catalog_text # 프롬프트에 삽입
|
||||
```
|
||||
|
||||
### 정확한 구현 방법
|
||||
|
||||
**1. 임베딩 모델 선택**
|
||||
```
|
||||
Kei persona가 사용하는 모델: BAAI/bge-m3 (1024차원)
|
||||
위치: D:\ad-hoc\kei\persona_agent\backend\llm\retriever.py line 49
|
||||
|
||||
design_agent에서도 동일 모델 사용:
|
||||
→ 한국어 지원 ✅
|
||||
→ Kei에서 검증됨 ✅
|
||||
→ 1024차원으로 46개 벡터 = 약 184KB (가벼움)
|
||||
```
|
||||
|
||||
**2. 인덱스 구축 (1회성, 오프라인)**
|
||||
```python
|
||||
# src/block_search.py (신규 파일)
|
||||
import faiss
|
||||
import yaml
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
def build_block_index():
|
||||
# 1. catalog.yaml 로드
|
||||
with open("templates/catalog.yaml") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
|
||||
# 2. 각 블록의 검색용 텍스트 생성
|
||||
texts = []
|
||||
ids = []
|
||||
for block in catalog["blocks"]:
|
||||
text = f"{block['name']}. {block['visual']}. {block['when']}"
|
||||
texts.append(text)
|
||||
ids.append(block["id"])
|
||||
|
||||
# 3. 임베딩
|
||||
model = SentenceTransformer("BAAI/bge-m3")
|
||||
embeddings = model.encode(texts, normalize_embeddings=True)
|
||||
|
||||
# 4. FAISS 인덱스 생성
|
||||
dim = embeddings.shape[1]
|
||||
index = faiss.IndexFlatIP(dim) # Inner Product (코사인 유사도)
|
||||
index.add(embeddings)
|
||||
|
||||
# 5. 저장
|
||||
faiss.write_index(index, "data/block_index.faiss")
|
||||
# ids 매핑도 저장
|
||||
```
|
||||
|
||||
**3. 검색 (런타임, 매 요청)**
|
||||
```python
|
||||
def search_blocks(query: str, top_k: int = 8) -> list[dict]:
|
||||
"""콘텐츠 꼭지 설명으로 적합한 블록 검색"""
|
||||
embedding = model.encode([query], normalize_embeddings=True)
|
||||
scores, indices = index.search(embedding, top_k)
|
||||
return [catalog_blocks[i] for i in indices[0]]
|
||||
```
|
||||
|
||||
**4. design_director.py 수정 지점**
|
||||
```
|
||||
현재 line 294: catalog_text = _load_catalog() # 전문
|
||||
변경: catalog_text = search_blocks(topics_summary, top_k=8) # 관련 8개만
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
|
||||
| 파일 | 영향 | 충돌? |
|
||||
|------|------|-------|
|
||||
| design_director.py | _load_catalog() 반환값이 문자열 → 문자열(검색결과) | ❌ (인터페이스 동일) |
|
||||
| pipeline.py | 호출하지 않음 | ❌ |
|
||||
| renderer.py | _load_catalog_map()은 별도 함수 (경로 매핑용) | ❌ (다른 함수) |
|
||||
| content_editor.py | BLOCK_SLOTS만 참조 | ❌ |
|
||||
|
||||
### 회귀 위험
|
||||
- _load_catalog()를 교체하므로, 검색이 실패하면 catalog 전문을 fallback으로 넘겨야 함
|
||||
- FAISS 인덱스 파일이 없으면 기존 방식(전문)으로 동작해야 함
|
||||
|
||||
### 대충 처리 위험
|
||||
- ⚠️ "검색 결과 8개만 넣으면 되지" → 검색 품질이 낮으면 적합한 블록이 빠질 수 있음
|
||||
- 대응: 검색 결과 + 카테고리별 최소 1개 보장 (8개 중 카테고리 커버 확인)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-B: SVG N개 자동 배치
|
||||
|
||||
### 현재 코드 상태
|
||||
```
|
||||
templates/blocks/visuals/venn-diagram.html:
|
||||
→ 3개 원 좌표가 하드코딩 (cx="265" cy="300", cx="370" cy="230", cx="365" cy="355")
|
||||
→ items[0], items[1], items[2]로 직접 인덱싱
|
||||
|
||||
renderer.py:
|
||||
→ render_standalone_block()에서 block_data를 Jinja2에 **kwargs로 전달
|
||||
→ 별도 전처리 없음
|
||||
```
|
||||
|
||||
### 정확한 구현 방법
|
||||
|
||||
**1. 좌표 계산 함수 (신규)**
|
||||
```python
|
||||
# src/svg_calculator.py (신규 파일)
|
||||
import math
|
||||
|
||||
def calc_circle_positions(
|
||||
n: int,
|
||||
center_x: float = 300,
|
||||
center_y: float = 300,
|
||||
radius: float = 120,
|
||||
) -> list[dict]:
|
||||
"""N개 원소를 원형으로 배치. 12시부터 시계방향."""
|
||||
positions = []
|
||||
for i in range(n):
|
||||
angle = (2 * math.pi * i / n) - math.pi / 2
|
||||
positions.append({
|
||||
"cx": round(center_x + radius * math.cos(angle), 1),
|
||||
"cy": round(center_y + radius * math.sin(angle), 1),
|
||||
})
|
||||
return positions
|
||||
|
||||
def calc_circle_radius(n: int, base_radius: int = 120) -> int:
|
||||
"""N에 따라 작은 원 크기 자동 조정."""
|
||||
if n <= 3: return base_radius
|
||||
if n <= 5: return int(base_radius * 0.7)
|
||||
return int(base_radius * 0.5)
|
||||
```
|
||||
|
||||
**2. renderer.py 수정 지점**
|
||||
```python
|
||||
# render_multi_page() 또는 render_slide() 안에서:
|
||||
if block_type in ("venn-diagram", "relationship"):
|
||||
items = block_data.get("items", [])
|
||||
if items:
|
||||
from src.svg_calculator import calc_circle_positions, calc_circle_radius
|
||||
positions = calc_circle_positions(len(items))
|
||||
small_r = calc_circle_radius(len(items))
|
||||
for i, item in enumerate(items):
|
||||
item["cx"] = positions[i]["cx"]
|
||||
item["cy"] = positions[i]["cy"]
|
||||
item["r"] = small_r
|
||||
```
|
||||
|
||||
**3. venn-diagram.html 수정**
|
||||
```
|
||||
현재: cx="265" (하드코딩)
|
||||
변경: cx="{{ items[0].cx }}" (동적)
|
||||
+ items 개수에 따라 for 루프로 생성
|
||||
+ 큰 원 크기도 N에 따라 조정
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
|
||||
| 파일 | 영향 | 충돌? |
|
||||
|------|------|-------|
|
||||
| renderer.py | block_data 전처리 추가 | ⚠️ 주의: 기존 render 흐름에 if 분기 추가 |
|
||||
| venn-diagram.html | 하드코딩 → 동적 좌표 | ⚠️ Phase 1 고정 SVG가 깨짐 → fallback 필요 |
|
||||
| pipeline.py | 변경 없음 | ❌ |
|
||||
| content_editor.py | items[].cx/cy는 편집자가 생성하지 않음 | ❌ (renderer에서 추가) |
|
||||
|
||||
### 회귀 위험
|
||||
- **venn-diagram.html 변경 시 Phase 1 고정 SVG가 깨질 수 있음**
|
||||
- 대응: items에 cx/cy가 없으면 기존 하드코딩 좌표 사용 (fallback)
|
||||
|
||||
### 대충 처리 위험
|
||||
- ⚠️ 원 크기 자동 조정을 대충 하면 7개 원이 겹침
|
||||
- 대응: N별 최적 반지름/큰원 크기 테이블 사전 정의
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-C: Step A Opus+FAISS
|
||||
|
||||
### 현재 코드 상태
|
||||
```
|
||||
design_director.py line 145~178: select_preset()
|
||||
→ 규칙 4줄: reference→sidebar, 대등비교→two-column, 고강조→hero, 나머지→single
|
||||
→ LLM 호출 없음, 코드만
|
||||
|
||||
의도: Opus가 FAISS로 적합한 구조/블록 검색 + 배치/크기 결정
|
||||
```
|
||||
|
||||
### 정확한 구현 방법
|
||||
|
||||
**1단계: select_preset()은 유지 (규칙 기반 프리셋은 안정적)**
|
||||
**2단계: Opus가 블록 후보를 검색+선정하는 함수 추가**
|
||||
|
||||
```python
|
||||
# design_director.py에 추가
|
||||
async def _opus_block_selection(
|
||||
content: str,
|
||||
analysis: dict,
|
||||
block_candidates: list[dict], # FAISS 검색 결과
|
||||
) -> list[dict]:
|
||||
"""Opus가 FAISS 후보에서 최종 블록을 선정하고 배치를 결정."""
|
||||
# Kei API를 통해 Opus 호출
|
||||
kei_url = settings.kei_api_url
|
||||
prompt = f"""
|
||||
콘텐츠 분석 결과와 블록 후보를 보고,
|
||||
각 꼭지에 가장 적합한 블록을 선택하고 배치를 결정해줘.
|
||||
후보 블록: {block_candidates}
|
||||
꼭지: {analysis['topics']}
|
||||
"""
|
||||
# Kei API 호출 (실장과 동일 패턴)
|
||||
...
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
|
||||
| 파일 | 영향 | 충돌? |
|
||||
|------|------|-------|
|
||||
| design_director.py | select_preset() 유지 + _opus_block_selection() 추가 | ❌ (추가만) |
|
||||
| kei_client.py | Kei API 호출 패턴 재사용 | ❌ (참조만) |
|
||||
| pipeline.py | create_layout_concept() 인터페이스 동일 | ❌ |
|
||||
|
||||
### 회귀 위험
|
||||
- ⚠️ Opus가 Kei API를 통해 호출되어야 하는데, **Sonnet을 직접 호출하면 안 됨**
|
||||
- 대응: _call_kei_api() 패턴 그대로 복제. Anthropic 직접 호출 금지.
|
||||
- ⚠️ Kei API 실패 시 fallback = 현재 규칙 기반 방식 (select_preset + Sonnet Step B)
|
||||
|
||||
### 대충 처리 위험
|
||||
- ⚠️ "Opus 대신 Sonnet 직접 호출" → **절대 금지**. 3단계에서 이미 이 실수 했음.
|
||||
- ⚠️ FAISS 없이 catalog 전문 넣기 → Phase 2-A가 선행 안 되면 의미 없음
|
||||
- 대응: Phase 2-A 완료 후에만 시작
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-D: 5단계 재검토 강화
|
||||
|
||||
### 현재 코드 상태
|
||||
```
|
||||
pipeline.py line 102~161: _review_balance()
|
||||
→ Sonnet에게 블록별 데이터 양(글자수)만 전달
|
||||
→ HTML 자체는 전달하지 않음
|
||||
→ shrink/rewrite action이 실질적으로 no-op
|
||||
|
||||
pipeline.py line 164~193: _apply_adjustments()
|
||||
→ expand만 동작 (char_guide * 1.5)
|
||||
→ shrink: 조건 매칭 안 됨 (expand만 if 처리)
|
||||
→ rewrite: 아예 동작 없음
|
||||
```
|
||||
|
||||
### 정확한 구현 방법
|
||||
|
||||
**1. _review_balance 프롬프트 개선**
|
||||
```python
|
||||
# 현재: 블록별 데이터 양만
|
||||
# 변경: 블록별 텍스트 길이 + 블록 타입 + zone + height_cost
|
||||
block_summary = []
|
||||
for block in blocks:
|
||||
data_len = len(json.dumps(block.get("data", {}), ensure_ascii=False))
|
||||
block_summary.append(
|
||||
f" {block['area']}/{block['type']}: "
|
||||
f"데이터 {data_len}자, height_cost={block.get('height_cost', '?')}"
|
||||
)
|
||||
```
|
||||
|
||||
**2. shrink/rewrite 구현**
|
||||
```python
|
||||
# _apply_adjustments 수정
|
||||
for adj in adjustments:
|
||||
action = adj.get("action", "")
|
||||
if action == "expand":
|
||||
# 현재 동작: char_guide * 1.5
|
||||
...
|
||||
elif action == "shrink":
|
||||
# 신규: char_guide * 0.7
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(block["char_guide"][key] * 0.7)
|
||||
elif action == "rewrite":
|
||||
# 신규: data를 비우고 재편집 유도
|
||||
block.pop("data", None)
|
||||
```
|
||||
|
||||
**3. 재조정 횟수 제한**
|
||||
```python
|
||||
MAX_ADJUSTMENTS = 2
|
||||
for attempt in range(MAX_ADJUSTMENTS):
|
||||
review = await _review_balance(...)
|
||||
if not review or not review.get("needs_adjustment"):
|
||||
break
|
||||
layout_concept = await _apply_adjustments(...)
|
||||
html = render_slide(layout_concept)
|
||||
```
|
||||
|
||||
### 충돌 검토
|
||||
|
||||
| 파일 | 영향 | 충돌? |
|
||||
|------|------|-------|
|
||||
| pipeline.py | _review_balance, _apply_adjustments 수정 | ❌ (내부 함수만) |
|
||||
| content_editor.py | fill_content() 재호출됨 | ⚠️ data가 비워진 블록 → _apply_defaults로 fallback |
|
||||
| renderer.py | 변경 없음 | ❌ |
|
||||
|
||||
### 회귀 위험
|
||||
- ⚠️ 재조정 루프가 무한 반복되면 API 비용 폭증
|
||||
- 대응: MAX_ADJUSTMENTS = 2로 하드 제한
|
||||
- ⚠️ fill_content 재호출 시 Kei API가 아닌 Sonnet으로 빠질 수 있음
|
||||
- 대응: fill_content는 이미 Kei API 1순위로 수정됨 ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-E: 누락 기능
|
||||
|
||||
### E-1: Pillow 이미지 크기
|
||||
|
||||
**수정 지점:** design_director.py create_layout_concept() 내부
|
||||
```python
|
||||
# 콘텐츠에 이미지 경로가 있으면 크기 확인
|
||||
from PIL import Image
|
||||
for topic in analysis.get("topics", []):
|
||||
if topic.get("content_type") == "image":
|
||||
img_path = topic.get("image_path")
|
||||
if img_path and Path(img_path).exists():
|
||||
w, h = Image.open(img_path).size
|
||||
topic["image_width"] = w
|
||||
topic["image_height"] = h
|
||||
topic["image_ratio"] = w / h # >1.2 가로, <0.8 세로
|
||||
```
|
||||
|
||||
**충돌:** 없음 (analysis dict에 필드 추가만)
|
||||
**회귀:** 없음 (이미지가 없으면 기존 흐름 그대로)
|
||||
|
||||
### E-2: details-block 연결
|
||||
|
||||
**수정 지점:** pipeline.py generate_slide() 내부
|
||||
```python
|
||||
# 실장이 detail_target=True로 판단한 꼭지를 details-block으로 변환
|
||||
# 현재 "생략"으로 처리 → details-block으로 연결
|
||||
```
|
||||
|
||||
**충돌:** design_director.py에서 detail_target 꼭지를 "생략"으로 처리 중 → 이것을 "details-block으로 배치"로 변경 필요
|
||||
**회귀:** detail_target 로직이 변경되므로 기존 테스트 영향
|
||||
|
||||
---
|
||||
|
||||
## 전체 충돌 매트릭스
|
||||
|
||||
```
|
||||
director editor renderer pipeline kei_client config
|
||||
2-A FAISS 수정 - - - - -
|
||||
2-B SVG - - 수정 - - -
|
||||
2-C Opus 수정 - - - 참조 -
|
||||
2-D 재검토 - 호출 - 수정 - -
|
||||
2-E Pillow 수정 - - 수정 - -
|
||||
```
|
||||
|
||||
**동시 수정 파일이 겹치는 경우:**
|
||||
- design_director.py: 2-A + 2-C + 2-E → **순서대로 진행 (2-A 먼저)**
|
||||
- pipeline.py: 2-D + 2-E → **독립적 함수라 병렬 가능**
|
||||
|
||||
---
|
||||
|
||||
## 절대 규칙 (모든 Phase 2 작업에 적용)
|
||||
|
||||
### 🔴 절대 금지
|
||||
1. **단발성/하드코딩 금지** — 특정 상황만 해결하는 if문, 매직넘버, 고정값 절대 금지. 모든 구현은 N개, M종류에 범용으로 동작해야 한다.
|
||||
2. **회귀 금지** — Phase 1에서 확정한 구조(catalog 매핑, 카테고리 경로, BF-9 grid 분리, Kei API 우선)를 절대 되돌리지 않는다.
|
||||
3. **Opus 대신 Sonnet 직접 호출 금지** — Kei API가 필요한 곳에 anthropic.AsyncAnthropic 직접 호출로 대체하지 않는다. fallback은 fallback이지 기본 경로가 아니다.
|
||||
4. **"일단 돌아가게" 금지** — 동작하지만 원래 설계와 다른 구현은 기술 부채다. 설계대로 구현하거나 설계를 먼저 변경한다.
|
||||
|
||||
### 자가 점검 질문 (구현 전 반드시 확인)
|
||||
- [ ] 이 코드가 블록 100개가 되어도 동작하는가?
|
||||
- [ ] 이 코드가 원소 7개가 되어도 동작하는가?
|
||||
- [ ] 이 코드에 하드코딩된 값이 있는가? 있다면 설정/계산으로 대체 가능한가?
|
||||
- [ ] Phase 1에서 확정한 인터페이스(catalog 매핑, grid 프리셋 분리)를 변경하는가?
|
||||
- [ ] Kei API가 아닌 Sonnet을 직접 호출하는 코드가 있는가? (fallback 제외)
|
||||
- [ ] 이 수정이 다른 모듈의 기존 동작을 깨뜨리는가?
|
||||
|
||||
---
|
||||
|
||||
## "대충 처리" 방지 체크리스트
|
||||
|
||||
| # | 위험 | 방지책 | 점검 방법 |
|
||||
|---|------|-------|----------|
|
||||
| 1 | Opus 대신 Sonnet 직접 호출 | Kei API 패턴만 사용 | `grep "AsyncAnthropic" src/*.py` → fallback 위치만 허용 |
|
||||
| 2 | FAISS 없이 catalog 전문 유지 | _load_catalog() 교체 | FAISS 실패 시에만 fallback, 기본은 검색 |
|
||||
| 3 | SVG 좌표를 하드코딩 | calc_circle_positions() 계산 | `grep "cx=\"[0-9]" templates/blocks/visuals/` → 0건이어야 함 |
|
||||
| 4 | 재검토 루프 무한 반복 | MAX_ADJUSTMENTS = 2 | 코드에 상수 존재 확인 |
|
||||
| 5 | shrink/rewrite 미구현 | 3개 action 모두 if 분기 | _apply_adjustments에서 action별 동작 확인 |
|
||||
| 6 | 이미지 크기 하드코딩 | Pillow로 실측 | 고정 비율(예: 1.5) 사용 금지 |
|
||||
| 7 | details-block "생략" 유지 | detail_target → details-block 배치 | design_director에서 "생략" 문자열 제거 확인 |
|
||||
| 8 | 특정 블록 수에만 동작 | N개 범용 루프 | `for i in range(n)` 패턴 확인, `items[0]` 직접 인덱싱 금지 |
|
||||
| 9 | 특정 프리셋에만 동작 | 모든 프리셋에서 테스트 | 4개 프리셋 × 테스트 콘텐츠 조합 |
|
||||
Reference in New Issue
Block a user