Files
C.E.L_Slide_test2/IMPROVEMENT-PHASE-I.md
kyeongmin b0bcffc0f6 Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리
- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정
- Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div
- Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제
- Selenium: container div 감지 추가
- catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드
- 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md

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

35 KiB
Raw Blame History

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행

현재 코드:

"- 근거사례 → quote-left-border (출처 포함), card-text-grid (항목 나열)\n"
"- 용어정의 → card-text-grid (정의+출처), card-numbered (순서 있으면)\n"
"- 구조시각화 → venn-diagram, layer-diagram (단독 배치)\n"

허용 목록에는 없는데 purpose 가이드에서 적극 추천 → 프롬프트 자기모순 → Sonnet이 미존재 블록 선택

변경 코드:

"- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)\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행

현재 코드:

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"

변경 코드:

# 모듈 상수 (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행) 이전에 삽입

추가 코드:

# 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": {...} 키 추가. 예:

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

현재 코드:

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', [])}"
)

변경 코드: 기존 코드 유지 + 아래 추가

# 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 완료 후, 이미지 측정 전)

추가 코드:

# 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행)

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

반환값 변경: Nonelist[dict] (빈 리스트 = 초과 없음) 호출부 변경: create_layout_concept() 601행

# 기존: _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행 이후)

# 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)

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 비상 함수 (기존 로직을 별도 함수로 분리)

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() 반환값이 Nonelist[dict]로 변경되지만, 기존 호출부(601행)에서 반환값을 무시했으므로 영향 없음. 새 호출부에서 반환값을 활용.


I-8: 대형 콘텐츠 → Kei 정보 전달 (자동 설정 금지)

기존 방식 (폐기): 코드가 5행 이상 테이블을 자동으로 detail_target 설정

새 방식: I-9의 Kei 넘침 판단 프롬프트에 대형 콘텐츠 정보를 포함하여 전달.

  • "이 꼭지에 12행 비교표가 있음" → Kei가 "팝업으로 분리" 또는 "3행으로 요약" 판단
  • 코드는 판단하지 않음. 정보 수집 + 전달만.

구현: I-9의 _call_kei_overflow_judgment() 프롬프트에 tables/images 정보 포함

# 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 생성:
"""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)
  1. 3개 파일에서 변경:
# 기존: 각 파일 내 _stream_sse_tokens() 정의 삭제
# 신규: from src.sse_utils import stream_sse_tokens
# 호출부: _stream_sse_tokens(response) → stream_sse_tokens(response)

영향 범위: 함수 로직 100% 동일. 이름만 _stream_sse_tokensstream_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: 블록 선택 + 슬롯 의미 (정합성 복구 후)

  1. I-3: purpose 기반 fallback
  2. I-7: pill-pair 단독 금지
  3. I-4: BLOCK_SLOTS slot_desc 추가 (38개)
  4. I-5: 편집자 프롬프트에 slot_desc 전달
  5. I-6: 제목 유사도 검증

Phase I-C: 넘침 처리 전환 (I-A, I-B 완료 후)

  1. I-9: Kei 넘침 판단 호출 구현 (핵심)
  2. I-8: 대형 콘텐츠 Kei 정보 전달

검증 체크리스트 (2026-03-26 실행 완료)

정합성 복구

  • I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건 — design_director.py 267~271행 3개 블록 교체
  • 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)
  • I-10: INDEX.md에 미존재 블록 0건 — 8행 삭제, 카테고리 개수 수정 (46→38, cards 10→9, visuals 10→6, emphasis 13→10)
  • I-11: README.md 블록 수 38개, _legacy 참조 없음, Sonnet fallback 없음 — 블록 트리 전면 재작성, "46개+_legacy 13개"→"38개", FAISS "46개"→"38개"
  • I-12: BLOCK_SLOTS 주석이 실제 개수와 일치 (5/9/3/6/10/5) — 3곳 수정: cards 10→9, visuals 10→6, emphasis 12→10

블록 선택 + 슬롯

  • I-3: 미등록 블록 교체가 purpose 기반으로 동작 — PURPOSE_FALLBACK 상수 6개 매핑 추가, callout-solution은 최종 fallback만
  • I-7: compare-pill-pair 단독 사용 시 comparison-2col로 교체 — _validate_height_budget() 내 COMPARISON_BLOCKS 검증 추가
  • I-4: BLOCK_SLOTS 38개 블록 모두에 slot_desc 존재 — 38/38 검증 통과. 각 슬롯의 의미/형식/예시 포함
  • I-5: 편집자 프롬프트에 슬롯 설명 포함 — content_editor.py fill_content() 내 slot_desc 전달 로직 추가 (Kei API 경유)
  • I-6: 제목 유사도 70% 이상 시 자동 교정 — pipeline.py 1단계-B 완료 후 SequenceMatcher 검증 삽입

넘침 처리

  • I-9: 높이 초과 시 Kei API 호출됨 — call_kei_overflow_judgment() 함수 신규 (session_id: design-agent-overflow), KEI_OVERFLOW_PROMPT 프롬프트 작성
  • I-9: Kei 판단 Option 1(trim) / Option 2(restructure) 분기 동작 — pipeline.py Stage 2.5에서 decision 필드로 분기, trim→char_guide 축소, restructure→detail_target 설정+레이아웃 재설계
  • I-9: Kei API 실패 시 DOWNGRADE_MAP 비상 작동 — _downgrade_fallback() 별도 함수 분리, 로그: "[DOWNGRADE 비상]"
  • I-8: 대형 콘텐츠(테이블/이미지) 정보가 Kei에게 전달됨 — call_kei_overflow_judgment() 내부에서 analysis.get("tables"), analysis.get("images") 포함

코드 정리

  • I-13: _call_anthropic_direct() 함수 없음 — kei_client.py 308~357행 삭제 + import anthropic 제거
  • I-13: _extract_sse_text() 함수 없음 — kei_client.py 272305행 삭제 + content_editor.py 234261행 삭제
  • I-14: _stream_sse_tokens() 중복 없음 — src/sse_utils.py 신규 생성, 3개 파일에서 import 변경 + 중복 정의 삭제

절대 규칙 준수

  • Sonnet 신규 투입 0건 — 넘침 판단은 Kei API만 사용
  • 하드코딩 0건 — PURPOSE_FALLBACK, COMPARISON_BLOCKS 등 모두 범용 상수
  • 단발성 수정 0건 — 모든 변경이 범용적/구조적
  • 기존 코드 회귀 0건 — 함수 시그니처/호출 구조 유지, 신규 키 추가만
  • persona_agent 수정 0건

기술 검증 (자동화 테스트)

  • 모든 모듈 import 성공: sse_utils, kei_client, design_director, content_editor, pipeline
  • FastAPI 앱 로드 성공 (8 routes)
  • uvicorn 서버 기동 성공 (FAISS 포함)
  • grep 전수 검사: 삭제 블록 참조 0건, dead code 참조 0건
  • BLOCK_SLOTS 38개 블록 전수 확인, slot_desc 38/38, 카테고리 합산 38
  • PURPOSE_FALLBACK 6개 값 모두 실존 블록
  • 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() (308357행) 삭제, _extract_sse_text() (272305행) 삭제, import anthropic 제거
I-13 src/content_editor.py _extract_sse_text() (234~261행) 삭제
I-1 src/design_director.py 267~271행 quote-left-borderquote-big-mark, card-text-gridcard-icon-desc, layer-diagram 삭제
I-2 templates/catalog.yaml 13건 not_for 교체: card-text-gridcard-icon-desc(6건), quote-left-borderquote-big-mark/삭제(2건), conclusion-accent-barbanner-gradient(4건), timelineprocess-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() 반환값 Nonelist[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건 확인.