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>
This commit is contained in:
@@ -21,89 +21,119 @@ logger = logging.getLogger(__name__)
|
||||
# 블록별 슬롯 정의
|
||||
# ──────────────────────────────────────
|
||||
BLOCK_SLOTS = {
|
||||
"comparison": {
|
||||
"required": ["left_title", "left_content", "right_title", "right_content"],
|
||||
"optional": ["left_subtitle", "right_subtitle"],
|
||||
},
|
||||
"card-grid": {
|
||||
"required": ["cards"],
|
||||
"optional": [],
|
||||
},
|
||||
"relationship": {
|
||||
"required": ["center_label", "items"],
|
||||
"optional": ["center_sub", "description"],
|
||||
},
|
||||
"process": {
|
||||
"required": ["steps"],
|
||||
"optional": [],
|
||||
},
|
||||
"quote-block": {
|
||||
"required": ["quote_text"],
|
||||
"optional": ["source"],
|
||||
},
|
||||
"conclusion-bar": {
|
||||
"required": ["conclusion_text"],
|
||||
"optional": ["label"],
|
||||
},
|
||||
"comparison-table": {
|
||||
"required": ["headers", "rows"],
|
||||
"optional": [],
|
||||
},
|
||||
"image-block": {
|
||||
"required": ["src", "alt"],
|
||||
"optional": ["caption", "layout"],
|
||||
},
|
||||
"details-block": {
|
||||
"required": ["summary_text", "detail_content"],
|
||||
"optional": ["label"],
|
||||
},
|
||||
# headers/ (5개)
|
||||
"section-title-with-bg": {"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"]},
|
||||
"section-header-bar": {"required": ["title"], "optional": ["subtitle"]},
|
||||
"topic-left-right": {"required": ["title", "description"], "optional": []},
|
||||
"topic-center": {"required": ["title"], "optional": ["subtitle", "description"]},
|
||||
"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": []},
|
||||
"card-compare-3col": {"required": ["cards"], "optional": []},
|
||||
"card-step-vertical": {"required": ["steps"], "optional": []},
|
||||
"card-image-round": {"required": ["cards"], "optional": []},
|
||||
"card-stat-number": {"required": ["stats"], "optional": []},
|
||||
"card-numbered": {"required": ["items"], "optional": []},
|
||||
# tables/ (3개)
|
||||
"compare-3col-badge": {"required": ["headers", "rows"], "optional": []},
|
||||
"compare-2col-split": {"required": ["left_title", "right_title", "rows"], "optional": []},
|
||||
"table-simple-striped": {"required": ["headers", "rows"], "optional": []},
|
||||
# visuals/ (10개)
|
||||
"venn-diagram": {"required": ["center_label", "items"], "optional": ["center_sub", "description"]},
|
||||
"circle-gradient": {"required": ["label"], "optional": ["sub_label"]},
|
||||
"compare-pill-pair": {"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"]},
|
||||
"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"]},
|
||||
"highlight-strip": {"required": ["segments"], "optional": []},
|
||||
"callout-solution": {"required": ["title", "description"], "optional": ["icon", "source"]},
|
||||
"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": []},
|
||||
"image-side-text": {"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"]},
|
||||
"image-full-caption": {"required": ["src"], "optional": ["alt", "caption"]},
|
||||
"image-before-after": {"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"]},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 슬라이드 물리적 제약
|
||||
# ──────────────────────────────────────
|
||||
# 프레임: 1280×720px, 패딩 40px×4 → 가용 1200×640px
|
||||
# grid gap: 20px, header ~50px, footer ~60px
|
||||
# → 본문 zone 가용 높이 ≈ 490px (640 - 50 - 20 - 60 - 20)
|
||||
FRAME_AVAILABLE_HEIGHT = 490
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 레이아웃 프리셋 정의
|
||||
# zone별 budget_px = 해당 zone에 넣을 수 있는 최대 높이
|
||||
# width_pct = zone의 가로 비율 (블록 선택 시 참고)
|
||||
# ──────────────────────────────────────
|
||||
LAYOUT_PRESETS = {
|
||||
"sidebar-right": {
|
||||
"description": "좌측 본문 흐름 + 우측 참조 사이드바",
|
||||
"grid_areas": "'title title' 'body sidebar' 'footer footer'",
|
||||
"grid_areas": "'header header' 'body sidebar' 'footer footer'",
|
||||
"grid_columns": "65fr 35fr",
|
||||
"grid_rows": "auto 1fr auto",
|
||||
"zones": {
|
||||
"body": "flow 꼭지 배치 (위→아래 순서)",
|
||||
"sidebar": "reference 꼭지 배치 (독립 참조)",
|
||||
"footer": "결론 꼭지",
|
||||
"header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
|
||||
"body": {"desc": "flow 꼭지 배치 (위→아래 순서).", "budget_px": 490, "width_pct": 65},
|
||||
"sidebar": {"desc": "reference 꼭지. 좁으므로 card-grid 1열, 시각화 블록 금지.", "budget_px": 490, "width_pct": 35},
|
||||
"footer": {"desc": "결론 꼭지. 전체 너비.", "budget_px": 60, "width_pct": 100},
|
||||
},
|
||||
},
|
||||
"two-column": {
|
||||
"description": "대등한 2단 비교",
|
||||
"grid_areas": "'title title' 'left right' 'footer footer'",
|
||||
"grid_areas": "'header header' 'left right' 'footer footer'",
|
||||
"grid_columns": "1fr 1fr",
|
||||
"grid_rows": "auto 1fr auto",
|
||||
"zones": {
|
||||
"left": "첫 번째 비교 대상",
|
||||
"right": "두 번째 비교 대상",
|
||||
"footer": "결론 꼭지",
|
||||
"header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
|
||||
"left": {"desc": "첫 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
|
||||
"right": {"desc": "두 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
|
||||
"footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
|
||||
},
|
||||
},
|
||||
"hero-detail": {
|
||||
"description": "고강조 1개 + 보조 상세",
|
||||
"grid_areas": "'title title' 'hero hero' 'detail detail' 'footer footer'",
|
||||
"grid_areas": "'header header' 'hero hero' 'detail detail' 'footer footer'",
|
||||
"grid_columns": "1fr 1fr",
|
||||
"grid_rows": "auto 2fr 1fr auto",
|
||||
"zones": {
|
||||
"hero": "고강조 꼭지 (크게)",
|
||||
"detail": "나머지 보조 꼭지",
|
||||
"footer": "결론 꼭지",
|
||||
"header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
|
||||
"hero": {"desc": "고강조 꼭지 (크게).", "budget_px": 310, "width_pct": 100},
|
||||
"detail": {"desc": "나머지 보조 꼭지.", "budget_px": 155, "width_pct": 100},
|
||||
"footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
|
||||
},
|
||||
},
|
||||
"single-column": {
|
||||
"description": "단일 컬럼 순차 배치",
|
||||
"grid_areas": "'title' 'body' 'footer'",
|
||||
"grid_areas": "'header' 'body' 'footer'",
|
||||
"grid_columns": "1fr",
|
||||
"grid_rows": "auto 1fr auto",
|
||||
"zones": {
|
||||
"body": "모든 꼭지 위→아래 순서",
|
||||
"footer": "결론 꼭지",
|
||||
"header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
|
||||
"body": {"desc": "모든 꼭지 위→아래 순서.", "budget_px": 490, "width_pct": 100},
|
||||
"footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -158,16 +188,26 @@ def _load_catalog() -> str:
|
||||
return catalog_path.read_text(encoding="utf-8")
|
||||
|
||||
return """사용 가능한 블록:
|
||||
- quote-block: 좌측 컬러 라인 + 인용 텍스트. 문제 제기할 때.
|
||||
- card-grid: 2~4열 카드. 용어 정의, 개념 나열할 때.
|
||||
- comparison: 2단 병렬. A vs B 비교할 때.
|
||||
- quote-question: 질문형 강조. 문제 제기, 전환점.
|
||||
- compare-box: 2개 키워드 시각 대비.
|
||||
- comparison-table: 다항목 비교 테이블.
|
||||
- relationship: 벤 다이어그램. 포함/상위-하위 관계할 때.
|
||||
- process: 단계 흐름. 절차할 때.
|
||||
- conclusion-bar: 하단 결론 바."""
|
||||
- card-image: 이미지+텍스트 카드.
|
||||
- card-dark-overlay: 다크 배경 키워드 카드.
|
||||
- relationship: 벤 다이어그램. 포함/상위-하위 관계.
|
||||
- process: 단계 흐름. 절차.
|
||||
- topic-header: 꼭지 제목+설명.
|
||||
- conclusion-bar: 하단 결론 바.
|
||||
- banner-gradient: 섹션 강조 배너."""
|
||||
|
||||
|
||||
STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다. 당신은 프리셋 안에서 블록을 배정하기만 하면 된다.
|
||||
STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다.
|
||||
당신의 핵심 역할: **컨테이너(zone)의 크기 예산 안에서** 블록을 배정하는 것이다.
|
||||
|
||||
## 슬라이드 물리적 제약 (절대 조건)
|
||||
- 프레임: 1280×720px (16:9 고정)
|
||||
- 패딩: 상하좌우 40px → 가용 영역: 1200×640px
|
||||
- 블록 간 간격: 20px
|
||||
- **overflow: hidden** — 넘치는 콘텐츠는 잘려서 보이지 않는다!
|
||||
|
||||
## 선택된 레이아웃 프리셋: {preset_name}
|
||||
{preset_description}
|
||||
@@ -177,46 +217,172 @@ grid-template-areas: {grid_areas}
|
||||
grid-template-columns: {grid_columns}
|
||||
grid-template-rows: {grid_rows}
|
||||
|
||||
### Zone 설명:
|
||||
### Zone별 컨테이너 예산:
|
||||
{zone_descriptions}
|
||||
|
||||
## 역할
|
||||
- 각 꼭지를 위 zone 중 하나에 배정한다
|
||||
- flow 꼭지 → body/main/left/hero zone
|
||||
- reference 꼭지 → sidebar zone
|
||||
- detail_target 꼭지 → 생략 (popup으로 분리, 현재 미구현)
|
||||
- conclusion 꼭지 → footer zone
|
||||
- 각 꼭지에 적합한 블록 타입을 catalog에서 선택한다
|
||||
- 같은 내용이 두 블록에 중복되면 안 된다
|
||||
- 각 블록의 대략적 글자 수 가이드를 제시한다
|
||||
## ★ 사고 순서 (반드시 이 순서로 판단하라)
|
||||
|
||||
## {catalog}
|
||||
### 1단계: 컨테이너 크기 확인
|
||||
위 zone별 높이 예산(px)과 너비(%)를 확인한다. 이것이 절대 제약이다.
|
||||
header/footer는 고정이므로 건드리지 않는다.
|
||||
|
||||
### 2단계: 꼭지 → zone 배정
|
||||
- flow 꼭지 → body / left / hero zone
|
||||
- reference 꼭지 → sidebar zone
|
||||
- detail_target 꼭지 → details-block으로 배치 (해당 zone에 접기/펼치기)
|
||||
- conclusion 꼭지 → footer zone
|
||||
|
||||
### 3단계: zone별 블록 선택 + 높이 예산 계산
|
||||
각 zone에 대해:
|
||||
a) 배정된 꼭지 수를 확인한다
|
||||
b) catalog에서 블록을 선택한다 (각 블록의 height_cost 확인!)
|
||||
c) 총 높이를 계산한다: Σ(블록 height_cost) + 간격(20px × (블록수-1))
|
||||
d) **총 높이 ≤ zone 예산** 인지 반드시 확인한다
|
||||
e) 초과 시: ① 더 작은(compact) 블록으로 교체 ② 꼭지를 다음 페이지로 분리
|
||||
|
||||
### 4단계: 최종 검증
|
||||
모든 zone의 블록 총 높이가 예산 이내인지 재확인한 후 출력한다.
|
||||
|
||||
## 블록 선택 규칙
|
||||
- **텍스트 블록 우선** — 텍스트로 충분히 전달 가능하면 시각화(SVG) 블록 쓰지 마라
|
||||
- **시각화 블록(relationship, process 등)은 높이 비용이 매우 크다** — 한 zone에 시각화 블록은 최대 1개, 다른 블록과 함께 쌓지 마라
|
||||
- 너비 35% 이하 zone(sidebar)에는 카드 1열, 시각화 블록 금지
|
||||
- catalog의 when/not_for와 height_cost를 반드시 읽고 선택
|
||||
- 같은 블록 타입 반복 금지 — 다양한 블록 활용
|
||||
- 같은 내용이 두 블록에 중복되면 안 된다
|
||||
|
||||
## 사용 가능한 블록 (catalog)
|
||||
{catalog}
|
||||
|
||||
## 출력 형식 (반드시 JSON만. 설명 없이.)
|
||||
grid_areas, grid_columns, grid_rows는 위에 정해진 것을 그대로 사용한다.
|
||||
grid는 이미 확정되었으므로 출력하지 마라. blocks 배열만 출력한다.
|
||||
```json
|
||||
{{{{
|
||||
"pages": [
|
||||
"blocks": [
|
||||
{{{{
|
||||
"grid_areas": "{grid_areas}",
|
||||
"grid_columns": "{grid_columns}",
|
||||
"grid_rows": "{grid_rows}",
|
||||
"blocks": [
|
||||
{{{{
|
||||
"area": "zone이름",
|
||||
"type": "블록타입",
|
||||
"topic_id": 1,
|
||||
"reason": "이유",
|
||||
"size": "small|medium|large",
|
||||
"char_guide": {{{{"slot": 글자수}}}}
|
||||
}}}}
|
||||
]
|
||||
"area": "zone이름",
|
||||
"type": "블록타입",
|
||||
"topic_id": 1,
|
||||
"reason": "이유",
|
||||
"size": "small|medium|large",
|
||||
"char_guide": {{{{"slot": 글자수}}}}
|
||||
}}}}
|
||||
]
|
||||
}}}}
|
||||
```"""
|
||||
|
||||
|
||||
async def _opus_block_recommendation(
|
||||
analysis: dict[str, Any],
|
||||
block_candidates: str,
|
||||
preset_name: str,
|
||||
preset: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""P2-C: Opus(Kei API)가 블록 후보에서 최종 블록을 추천한다.
|
||||
|
||||
Kei API를 통해 Opus가 사고하여:
|
||||
- 각 꼭지에 가장 적합한 블록 선정
|
||||
- 배치 방향/크기 가이드 제시
|
||||
- 도메인 지식 기반 판단
|
||||
|
||||
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
|
||||
fallback: None 반환 → Step B(Sonnet)가 직접 선택.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
zone_desc = "\n".join(
|
||||
f"- {name}: {z['desc']} [높이: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
|
||||
for name, z in preset["zones"].items()
|
||||
)
|
||||
|
||||
topics_text = "\n".join(
|
||||
f"- 꼭지 {t.get('id', '?')}: {t.get('title', '')} "
|
||||
f"[{t.get('layer', '?')}, {t.get('role', 'flow')}, 강조:{t.get('emphasis', False)}]"
|
||||
for t in analysis.get("topics", [])
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"슬라이드 디자인 블록 추천을 해줘.\n\n"
|
||||
f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
|
||||
f"## Zone 구조\n{zone_desc}\n\n"
|
||||
f"## 꼭지 목록\n{topics_text}\n\n"
|
||||
f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
|
||||
f"## 요청\n"
|
||||
f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
|
||||
f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택하고,\n"
|
||||
f"zone별 높이 예산을 고려하여 배치 방향과 크기 가이드를 제시해.\n\n"
|
||||
f"## 출력 형식 (JSON만)\n"
|
||||
f'{{"recommendations": ['
|
||||
f'{{"topic_id": 1, "block_type": "...", "area": "...", '
|
||||
f'"reason": "도메인 관점에서 이 블록이 적합한 이유", '
|
||||
f'"size_guide": "compact|medium|large"}}]}}'
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
response = await client.post(
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-opus",
|
||||
"mode": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
# SSE 응답 파싱 (kei_client.py와 동일 패턴)
|
||||
import re
|
||||
tokens = []
|
||||
events = re.split(r'\r?\n\r?\n', response.text)
|
||||
for event in events:
|
||||
if not event.strip():
|
||||
continue
|
||||
event_type = ""
|
||||
event_data = ""
|
||||
for line in event.split('\n'):
|
||||
line = line.strip('\r')
|
||||
if line.startswith('event:'):
|
||||
event_type = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
event_data = line[5:].strip()
|
||||
if event_type == 'token' and event_data:
|
||||
try:
|
||||
import json as _json
|
||||
token = _json.loads(event_data)
|
||||
if isinstance(token, str):
|
||||
tokens.append(token)
|
||||
except Exception:
|
||||
tokens.append(event_data)
|
||||
elif event_type == 'done':
|
||||
break
|
||||
|
||||
full_text = "".join(tokens)
|
||||
if not full_text:
|
||||
logger.warning("[Step A-2] Kei API 응답 텍스트 없음")
|
||||
return None
|
||||
|
||||
result = _parse_json(full_text)
|
||||
if result and "recommendations" in result:
|
||||
logger.info(
|
||||
f"[Step A-2] Opus 블록 추천 완료: "
|
||||
f"{len(result['recommendations'])}개"
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(f"[Step A-2] JSON 파싱 실패: {full_text[:200]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Step A-2] Kei API 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_layout_concept(
|
||||
content: str,
|
||||
analysis: dict[str, Any],
|
||||
@@ -236,11 +402,36 @@ async def create_layout_concept(
|
||||
|
||||
# Step B: 프리셋 내 블록 매핑 (Sonnet)
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
catalog_text = _load_catalog()
|
||||
|
||||
# zone 설명 텍스트
|
||||
# P2-A: FAISS 검색으로 관련 블록만 추출 (fallback: catalog 전문)
|
||||
from src.block_search import search_blocks_for_topics
|
||||
topics = analysis.get("topics", [])
|
||||
catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10)
|
||||
logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)")
|
||||
|
||||
# P2-C: Step A-2 — Opus(Kei API)가 블록 추천
|
||||
opus_recommendation = await _opus_block_recommendation(
|
||||
analysis, catalog_text, preset_name, preset
|
||||
)
|
||||
opus_hint = ""
|
||||
if opus_recommendation and opus_recommendation.get("recommendations"):
|
||||
recs = opus_recommendation["recommendations"]
|
||||
hint_lines = ["## Opus(실장) 블록 추천 (참고, 최종 선택은 팀장 판단)"]
|
||||
for rec in recs:
|
||||
hint_lines.append(
|
||||
f"- 꼭지 {rec.get('topic_id', '?')}: "
|
||||
f"{rec.get('block_type', '?')} ({rec.get('area', '?')}) "
|
||||
f"— {rec.get('reason', '')}"
|
||||
)
|
||||
opus_hint = "\n".join(hint_lines)
|
||||
logger.info(f"[Step A-2] Opus 추천 {len(recs)}개 → Step B에 전달")
|
||||
else:
|
||||
logger.info("[Step A-2] Opus 추천 없음 (Kei API 미연결 또는 실패). Step B가 직접 선택.")
|
||||
|
||||
# zone 설명 텍스트 (높이 예산 + 너비 포함)
|
||||
zone_desc = "\n".join(
|
||||
f"- {name}: {desc}" for name, desc in preset["zones"].items()
|
||||
f"- {name}: {z['desc']} [높이 예산: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
|
||||
for name, z in preset["zones"].items()
|
||||
)
|
||||
|
||||
# 꼭지 요약
|
||||
@@ -253,7 +444,7 @@ async def create_layout_concept(
|
||||
f"강조:{t.get('emphasis', False)}]"
|
||||
)
|
||||
if t.get("detail_target"):
|
||||
line += " → ★detail_target (생략)"
|
||||
line += " → ★detail_target (details-block으로 배치: 요약+상세 접기/펼치기)"
|
||||
topics_summary.append(line)
|
||||
|
||||
system = STEP_B_PROMPT.format(
|
||||
@@ -268,15 +459,40 @@ async def create_layout_concept(
|
||||
|
||||
info_structure = analysis.get("info_structure", "")
|
||||
|
||||
# 이미지 크기 정보 (D-2/D-3: Pillow 측정 결과)
|
||||
image_info = ""
|
||||
image_sizes = analysis.get("image_sizes", [])
|
||||
if image_sizes:
|
||||
image_lines = []
|
||||
for img in image_sizes:
|
||||
line = f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
|
||||
if img.get("has_text"):
|
||||
line += " (텍스트 포함 도표 — 과도한 축소 금지)"
|
||||
image_lines.append(line)
|
||||
image_info = (
|
||||
"\n\n## 이미지 크기 정보\n"
|
||||
"가로형(landscape) → 전체 너비 배치 권장. "
|
||||
"세로형(portrait) → 텍스트 옆 배치 권장. "
|
||||
"텍스트 포함 도표 → 과도한 축소 금지.\n"
|
||||
+ "\n".join(image_lines)
|
||||
)
|
||||
|
||||
# Opus 추천이 있으면 user_prompt에 포함
|
||||
opus_section = ""
|
||||
if opus_hint:
|
||||
opus_section = f"\n\n{opus_hint}\n"
|
||||
|
||||
user_prompt = (
|
||||
f"## 실장 분석 결과\n"
|
||||
f"제목: {analysis.get('title', '')}\n"
|
||||
f"정보 구조: {info_structure}\n\n"
|
||||
f"꼭지 목록:\n" + "\n".join(topics_summary) +
|
||||
image_info +
|
||||
opus_section +
|
||||
f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
|
||||
f"## 요청\n"
|
||||
f"위 꼭지를 프리셋의 zone에 배정하고 블록 타입을 선택해줘.\n"
|
||||
f"grid_areas/columns/rows는 위에 정해진 것을 그대로 써라. 변경하지 마라.\n"
|
||||
f"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
|
||||
f"JSON만."
|
||||
)
|
||||
|
||||
@@ -291,17 +507,41 @@ async def create_layout_concept(
|
||||
result_text = response.content[0].text
|
||||
concept = _parse_json(result_text)
|
||||
|
||||
if concept and "pages" in concept:
|
||||
total_blocks = sum(
|
||||
len(p.get("blocks", [])) for p in concept["pages"]
|
||||
)
|
||||
# BF-9: Sonnet 출력에서 blocks만 추출. grid는 프리셋에서 강제.
|
||||
blocks = None
|
||||
if concept:
|
||||
if "blocks" in concept:
|
||||
# 새 형식: {"blocks": [...]}
|
||||
blocks = concept["blocks"]
|
||||
elif "pages" in concept:
|
||||
# 구 형식 호환: {"pages": [{"blocks": [...]}]}
|
||||
all_blocks = []
|
||||
for p in concept["pages"]:
|
||||
all_blocks.extend(p.get("blocks", []))
|
||||
blocks = all_blocks
|
||||
|
||||
if blocks is not None:
|
||||
# area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
|
||||
valid_zones = {z for z in preset["zones"] if z != "header"}
|
||||
default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
|
||||
for block in blocks:
|
||||
if block.get("area") not in valid_zones:
|
||||
logger.warning(
|
||||
f"zone '{block.get('area')}' → '{default_zone}' 자동 매핑"
|
||||
)
|
||||
block["area"] = default_zone
|
||||
|
||||
logger.info(
|
||||
f"[Step B] 블록 매핑 완료: {preset_name}, "
|
||||
f"{len(concept['pages'])}페이지, {total_blocks}개 블록"
|
||||
f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
|
||||
)
|
||||
return {
|
||||
"title": analysis.get("title", "슬라이드"),
|
||||
**concept,
|
||||
"pages": [{
|
||||
"grid_areas": preset["grid_areas"],
|
||||
"grid_columns": preset["grid_columns"],
|
||||
"grid_rows": preset["grid_rows"],
|
||||
"blocks": blocks,
|
||||
}],
|
||||
}
|
||||
else:
|
||||
logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
|
||||
@@ -321,10 +561,24 @@ def _fallback_layout(
|
||||
"""Step B 실패 시 프리셋 기반 기본 배치."""
|
||||
blocks = []
|
||||
for topic in analysis.get("topics", []):
|
||||
role = topic.get("role", "flow")
|
||||
|
||||
if topic.get("detail_target"):
|
||||
# detail_target → details-block으로 배치
|
||||
if role == "reference" and preset_name == "sidebar-right":
|
||||
area = "sidebar"
|
||||
else:
|
||||
area = "body" if preset_name != "two-column" else "left"
|
||||
blocks.append({
|
||||
"area": area,
|
||||
"type": "details-block",
|
||||
"topic_id": topic.get("id", len(blocks) + 1),
|
||||
"reason": f"detail_target: {topic.get('title', '')}",
|
||||
"size": "medium",
|
||||
"char_guide": {"summary_text": 60, "detail_content": 300},
|
||||
})
|
||||
continue
|
||||
|
||||
role = topic.get("role", "flow")
|
||||
if role == "reference" and preset_name == "sidebar-right":
|
||||
area = "sidebar"
|
||||
elif topic.get("layer") == "conclusion":
|
||||
@@ -334,11 +588,10 @@ def _fallback_layout(
|
||||
|
||||
blocks.append({
|
||||
"area": area,
|
||||
"type": "card-grid",
|
||||
"type": "topic-header",
|
||||
"topic_id": topic.get("id", 0),
|
||||
"reason": topic.get("title", ""),
|
||||
"size": "medium",
|
||||
"char_guide": {"title": 20, "description": 100},
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user