- 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>
35 KiB
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
반환값 변경: None → list[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() 반환값이 None → list[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.py235~269행src/content_editor.py204~231행src/design_director.py389~416행
변경:
- 신규
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)
- 3개 파일에서 변경:
# 기존: 각 파일 내 _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: 정합성 복구 (선행 — 다른 작업의 기반)
- I-14: SSE 유틸 공통 추출 (I-13, I-9의 선행)
- I-13: 데드 코드 제거 (3건)
- I-1: STEP_B_PROMPT 미존재 블록 제거
- I-2: catalog.yaml 미존재 블록 참조 제거 (12건)
- I-12: BLOCK_SLOTS 주석 수정
- I-10: INDEX.md 동기화
- I-11: README.md 동기화
Phase I-B: 블록 선택 + 슬롯 의미 (정합성 복구 후)
- I-3: purpose 기반 fallback
- I-7: pill-pair 단독 금지
- I-4: BLOCK_SLOTS slot_desc 추가 (38개)
- I-5: 편집자 프롬프트에 slot_desc 전달
- I-6: 제목 유사도 검증
Phase I-C: 넘침 처리 전환 (I-A, I-B 완료 후)
- I-9: Kei 넘침 판단 호출 구현 (핵심)
- I-8: 대형 콘텐츠 Kei 정보 전달
검증 체크리스트 (2026-03-26 실행 완료)
정합성 복구
- I-1: STEP_B_PROMPT의 purpose 가이드에 미존재 블록 0건 —
design_director.py267~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.pyfill_content()내 slot_desc 전달 로직 추가 (Kei API 경유) - I-6: 제목 유사도 70% 이상 시 자동 교정 —
pipeline.py1단계-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.pyStage 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.py308~357행 삭제 +import anthropic제거 - I-13: _extract_sse_text() 함수 없음 —
kei_client.py272305행 삭제 +261행 삭제content_editor.py234 - 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_SLOTS38개 블록 전수 확인, slot_desc 38/38, 카테고리 합산 38PURPOSE_FALLBACK6개 값 모두 실존 블록DOWNGRADE_MAP8개 항목 모두 유효
실행 결과 상세
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_extract_sse_text() (272import 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건 확인. |