런타임 품질 개선: Kei JSON 파싱 + 높이 예산 강제 + conclusion 강제 + FAISS 프리로드

1. kei_client.py: Kei API가 마크다운 리스트(- ) 접두사로 JSON 응답 시 전처리하여 파싱
2. image_utils.py: base_path+상대경로 이중 시 파일명 rglob 재탐색
3. design_director.py:
   - conclusion 꼭지 → footer zone + conclusion-accent-bar 코드 레벨 강제
   - _validate_height_budget(): zone별 height_cost 합산 검증, 초과 시 큰 블록 자동 교체
   - Opus 추천 프롬프트에 zone 배정 규칙 명시 (conclusion→footer 등)
4. main.py: 서버 startup 시 FAISS 인덱스 + bge-m3 모델 미리 로드

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 19:15:28 +09:00
parent fb67f221f4
commit 7ac9eea21a
25 changed files with 741 additions and 1896 deletions

View File

@@ -244,7 +244,6 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"topic-numbered": {"number": "1", "title": "(단계)"},
# cards/
"card-image-3col": {"cards": []},
"card-text-grid": {"cards": []},
"card-dark-overlay": {"cards": []},
"card-tag-image": {"cards": []},
"card-icon-desc": {"cards": []},
@@ -264,15 +263,9 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"process-horizontal": {"steps": []},
"flow-arrow-horizontal": {"steps": []},
"keyword-circle-row": {"keywords": []},
"layer-diagram": {"layers": []},
"timeline-vertical": {"events": []},
"timeline-horizontal": {"events": []},
"pyramid-hierarchy": {"levels": []},
# emphasis/
"quote-left-border": {"quote_text": "(인용)"},
"quote-big-mark": {"quote_text": "(인용)"},
"quote-question": {"question": "(질문)"},
"conclusion-accent-bar": {"conclusion_text": "(결론)"},
"comparison-2col": {"left_title": "A", "left_content": "-", "right_title": "B", "right_content": "-"},
"banner-gradient": {"text": "(배너)"},
"dark-bullet-list": {"bullets": []},
@@ -287,7 +280,6 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"image-side-text": {"image_src": ""},
"image-full-caption": {"src": ""},
"image-before-after": {"before_src": "", "after_src": ""},
"details-block": {"summary_text": "(상세 내용)", "detail_content": ""},
}
for block in blocks:
if "data" not in block:

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any
import anthropic
import yaml
from src.config import settings
@@ -29,7 +30,6 @@ BLOCK_SLOTS = {
"topic-numbered": {"required": ["number", "title"], "optional": ["description", "color"]},
# cards/ (10개)
"card-image-3col": {"required": ["cards"], "optional": []},
"card-text-grid": {"required": ["cards"], "optional": []},
"card-dark-overlay": {"required": ["cards"], "optional": []},
"card-tag-image": {"required": ["cards"], "optional": []},
"card-icon-desc": {"required": ["cards"], "optional": []},
@@ -49,15 +49,9 @@ BLOCK_SLOTS = {
"process-horizontal": {"required": ["steps"], "optional": []},
"flow-arrow-horizontal": {"required": ["steps"], "optional": []},
"keyword-circle-row": {"required": ["keywords"], "optional": []},
"layer-diagram": {"required": ["layers"], "optional": ["title"]},
"timeline-vertical": {"required": ["events"], "optional": []},
"timeline-horizontal": {"required": ["events"], "optional": []},
"pyramid-hierarchy": {"required": ["levels"], "optional": []},
# emphasis/ (12개)
"quote-left-border": {"required": ["quote_text"], "optional": ["source"]},
"quote-big-mark": {"required": ["quote_text"], "optional": ["source"]},
"quote-question": {"required": ["question"], "optional": ["description"]},
"conclusion-accent-bar": {"required": ["conclusion_text"], "optional": ["label"]},
"comparison-2col": {"required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"]},
"banner-gradient": {"required": ["text"], "optional": ["sub_text"]},
"dark-bullet-list": {"required": ["bullets"], "optional": ["title"]},
@@ -66,7 +60,6 @@ BLOCK_SLOTS = {
"callout-warning": {"required": ["title", "description"], "optional": ["icon"]},
"tab-label-row": {"required": ["tabs"], "optional": []},
"divider-text": {"required": ["text"], "optional": []},
"details-block": {"required": ["summary_text", "detail_content"], "optional": ["label"]},
# media/ (5개)
"image-row-2col": {"required": ["images"], "optional": []},
"image-grid-2x2": {"required": ["images"], "optional": []},
@@ -306,7 +299,13 @@ async def _opus_block_recommendation(
prompt = (
f"슬라이드 디자인 블록 추천을 해줘.\n\n"
f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
f"## Zone 구조\n{zone_desc}\n\n"
f"## Zone 구조 (반드시 이 zone에 배정하라)\n{zone_desc}\n\n"
f"## Zone 배정 규칙 (절대 규칙)\n"
f"- flow 꼭지 → body / left / hero zone\n"
f"- reference 꼭지 → sidebar zone\n"
f"- conclusion 꼭지 → **반드시 footer zone** + block_type은 **conclusion-accent-bar**\n"
f"- detail_target 꼭지 → details-block\n"
f"- sidebar(35%)에는 시각화 블록 금지\n\n"
f"## 꼭지 목록\n{topics_text}\n\n"
f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
f"## 요청\n"
@@ -531,6 +530,28 @@ async def create_layout_concept(
)
block["area"] = default_zone
# 6번: conclusion 꼭지 → footer zone + conclusion-accent-bar 강제
for block in blocks:
topic = next(
(t for t in analysis.get("topics", [])
if t.get("id") == block.get("topic_id")),
None,
)
if topic and topic.get("layer") == "conclusion":
if block.get("area") != "footer":
logger.warning(
f"conclusion 꼭지 {block.get('topic_id')} → footer 강제 이동"
)
block["area"] = "footer"
if block.get("type") != "conclusion-accent-bar":
logger.warning(
f"conclusion 블록 {block.get('type')} → conclusion-accent-bar 강제"
)
block["type"] = "conclusion-accent-bar"
# 5번: zone별 height_cost 합산 검증 — 초과 시 큰 블록 교체
_validate_height_budget(blocks, preset)
logger.info(
f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
)
@@ -550,6 +571,7 @@ async def create_layout_concept(
logger.error(f"Step B 호출 실패: {e}", exc_info=True)
# fallback: 프리셋 기반 기본 배치
# (검증 함수는 아래에 정의)
return _fallback_layout(analysis, preset_name, preset)
@@ -605,6 +627,104 @@ def _fallback_layout(
}
# height_cost → px 변환 (결정론적)
HEIGHT_COST_PX = {
"compact": 70,
"medium": 150,
"large": 250,
"xlarge": 400,
}
# xlarge/large → medium/compact 교체 후보
DOWNGRADE_MAP = {
"venn-diagram": "card-text-grid",
"pyramid-hierarchy": "card-numbered",
"card-step-vertical": "card-numbered",
"image-grid-2x2": "image-row-2col",
"compare-3col-badge": "comparison-2col",
"card-image-3col": "card-text-grid",
"card-tag-image": "card-text-grid",
"card-compare-3col": "comparison-2col",
"card-image-round": "card-icon-desc",
}
def _get_block_height(block_type: str) -> int:
"""블록 타입의 height_cost를 px로 반환."""
catalog_map = _load_catalog_map_for_height()
cost_label = catalog_map.get(block_type, "medium")
return HEIGHT_COST_PX.get(cost_label, 150)
def _load_catalog_map_for_height() -> dict[str, str]:
"""catalog.yaml에서 id → height_cost 매핑을 로드."""
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
if not catalog_path.exists():
return {}
try:
with open(catalog_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return {b["id"]: b.get("height_cost", "medium") for b in data.get("blocks", [])}
except Exception:
return {}
def _validate_height_budget(blocks: list[dict], preset: dict) -> None:
"""zone별 height_cost 합산을 검증하고, 초과 시 큰 블록을 교체한다.
코드 레벨 검증 — Sonnet이 높이 예산을 안 지켜도 강제 교정.
"""
zones = preset.get("zones", {})
gap_px = 20 # --spacing-block
# zone별 블록 그룹핑
zone_blocks: dict[str, list[dict]] = {}
for block in blocks:
area = block.get("area", "body")
if area not in zone_blocks:
zone_blocks[area] = []
zone_blocks[area].append(block)
for area, area_blocks in zone_blocks.items():
zone_info = zones.get(area, {})
budget = zone_info.get("budget_px", 490)
# 총 높이 계산
total = sum(_get_block_height(b.get("type", "")) for b in area_blocks)
total += gap_px * max(0, len(area_blocks) - 1)
if total <= budget:
continue
logger.warning(
f"[높이 예산 초과] {area}: {total}px > {budget}px. "
f"블록: {[b.get('type') for b in area_blocks]}"
)
# 가장 큰 블록부터 교체 시도
area_blocks.sort(key=lambda b: _get_block_height(b.get("type", "")), reverse=True)
for block in area_blocks:
block_type = block.get("type", "")
block_height = _get_block_height(block_type)
if block_type in DOWNGRADE_MAP and block_height >= 250:
replacement = DOWNGRADE_MAP[block_type]
old_height = block_height
new_height = _get_block_height(replacement)
block["type"] = replacement
total = total - old_height + new_height
logger.info(
f"[높이 교체] {block_type}({old_height}px) → "
f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
)
if total <= budget:
break
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [

View File

@@ -19,6 +19,17 @@ logger = logging.getLogger(__name__)
app = FastAPI(title="Design Agent", version="0.1.0")
@app.on_event("startup")
async def startup_preload():
"""서버 시작 시 FAISS 인덱스 + 임베딩 모델 미리 로드."""
try:
from src.block_search import _ensure_loaded
_ensure_loaded()
logger.info("FAISS 인덱스 + bge-m3 모델 미리 로드 완료")
except Exception as e:
logger.warning(f"FAISS 미리 로드 실패 (첫 요청 시 로드): {e}")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5174", "http://localhost:5173"],