Files
C.E.L_Slide_test2/docs/PHASE2-TECH-REVIEW.md
kyeongmin 9bd9dad9ac IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D)
- A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade
- A-2: 5단계 HTML 전문 프롬프트 전달
- A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반
- A-4: rewrite action 구현
- A-5: overflow: visible (area 레벨 텍스트 잘림 방지)
- A-6: object-fit cover → contain (이미지 crop 방지)
- A-7: table-layout: fixed
- A-8: container query 폰트 스케일링
- B-1: details-block 템플릿 신규 (CSS 변수만 사용)
- B-2: 인쇄 시 details 자동 펼침 JS
- B-3: catalog에 details-block 등록
- B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화
- B-8: fallback card-grid → topic-header + char_guide 제거
- C-1: CLAUDE.md gradient 원칙 완화
- C-3: border-radius 9개 파일 var(--radius) 통일
- C-4: box-shadow 2레벨 → 1레벨
- D-0: 이미지 경로 입력 UI + API base_path
- D-1: Pillow 의존성 + image_utils.py
- D-2~D-4: 이미지 비율/축소방지 프롬프트 전달
- D-5: HTML에 이미지 base64 삽입

## Phase 2 (다른 Claude 작업)
- P2-A: FAISS 블록 검색 (bge-m3, 46개 블록)
- P2-B: SVG N개 자동 배치 (svg_calculator.py)
- P2-C: Opus 블록 추천 (Kei API 경유)
- P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2)
- P2-E: details-block fallback 연동

## 버그 수정 (BF-8~10)
- BF-8: 컨테이너 예산 기반 블록 배치
- BF-9: grid와 Sonnet 역할 분리
- BF-10: catalog mtime 캐시 자동 갱신

## 블록 라이브러리
- 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화
- 구 블록 제거 (quote-block, card-grid, comparison)
- 13개 _legacy 블록 보존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:40:20 +09:00

14 KiB
Raw Blame History

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회성, 오프라인)

# 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. 검색 (런타임, 매 요청)

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. 좌표 계산 함수 (신규)

# 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 수정 지점

# 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가 블록 후보를 검색+선정하는 함수 추가

# 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 프롬프트 개선

# 현재: 블록별 데이터 양만
# 변경: 블록별 텍스트 길이 + 블록 타입 + 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 구현

# _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. 재조정 횟수 제한

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() 내부

# 콘텐츠에 이미지 경로가 있으면 크기 확인
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() 내부

# 실장이 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개 프리셋 × 테스트 콘텐츠 조합