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:
208
src/block_search.py
Normal file
208
src/block_search.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""P2-A: FAISS 기반 블록 검색 모듈.
|
||||
|
||||
catalog.yaml 46개 블록 중 콘텐츠에 적합한 후보를 검색하여 반환한다.
|
||||
디자인 팀장(Step B)의 프롬프트에 전체 catalog 대신 관련 블록만 전달하기 위함.
|
||||
|
||||
사용법:
|
||||
from src.block_search import search_blocks_for_topics
|
||||
candidates = search_blocks_for_topics(topics, top_k=8)
|
||||
# → catalog.yaml 형식의 문자열 (팀장 프롬프트에 삽입)
|
||||
|
||||
fallback: 인덱스 없거나 검색 실패 시 → catalog.yaml 전문 반환 (기존 방식)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
INDEX_PATH = PROJECT_ROOT / "data" / "block_index.faiss"
|
||||
META_PATH = PROJECT_ROOT / "data" / "block_metadata.json"
|
||||
CATALOG_PATH = PROJECT_ROOT / "templates" / "catalog.yaml"
|
||||
|
||||
# Kei persona와 동일 모델
|
||||
EMBEDDING_MODEL = "BAAI/bge-m3"
|
||||
|
||||
# 카테고리 목록 (최소 1개 보장용)
|
||||
ALL_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
|
||||
|
||||
# Lazy load
|
||||
_index = None
|
||||
_metadata: list[dict] | None = None
|
||||
_model = None
|
||||
_loaded = False
|
||||
|
||||
|
||||
def _ensure_loaded() -> bool:
|
||||
"""인덱스 + 모델을 lazy load한다. 성공 시 True."""
|
||||
global _index, _metadata, _model, _loaded
|
||||
|
||||
if _loaded:
|
||||
return _index is not None
|
||||
|
||||
_loaded = True # 재시도 방지
|
||||
|
||||
if not INDEX_PATH.exists() or not META_PATH.exists():
|
||||
logger.warning(
|
||||
f"블록 인덱스 없음: {INDEX_PATH}. "
|
||||
f"scripts/build_block_index.py를 실행하세요. "
|
||||
f"catalog 전문 fallback 사용."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import faiss
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
logger.info(f"블록 인덱스 로딩: {INDEX_PATH}")
|
||||
_index = faiss.read_index(str(INDEX_PATH))
|
||||
|
||||
with open(META_PATH, encoding="utf-8") as f:
|
||||
_metadata = json.load(f)
|
||||
|
||||
logger.info(f"임베딩 모델 로딩: {EMBEDDING_MODEL}")
|
||||
_model = SentenceTransformer(EMBEDDING_MODEL)
|
||||
|
||||
logger.info(
|
||||
f"블록 검색 준비 완료: {_index.ntotal}개 벡터, "
|
||||
f"{len(_metadata)}개 메타데이터"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 인덱스 로드 실패: {e}. catalog 전문 fallback.")
|
||||
_index = None
|
||||
_metadata = None
|
||||
_model = None
|
||||
return False
|
||||
|
||||
|
||||
def search_blocks(query: str, top_k: int = 8) -> list[dict]:
|
||||
"""단일 쿼리로 관련 블록을 검색한다.
|
||||
|
||||
Args:
|
||||
query: 검색 쿼리 (꼭지 제목+요약+역할)
|
||||
top_k: 반환할 최대 블록 수
|
||||
|
||||
Returns:
|
||||
관련 블록 메타데이터 목록 (score 포함)
|
||||
"""
|
||||
if not _ensure_loaded():
|
||||
return []
|
||||
|
||||
q_embedding = _model.encode(
|
||||
[query],
|
||||
normalize_embeddings=True,
|
||||
)
|
||||
q_embedding = np.array(q_embedding, dtype=np.float32)
|
||||
|
||||
scores, indices = _index.search(q_embedding, min(top_k, _index.ntotal))
|
||||
|
||||
results = []
|
||||
for score, idx in zip(scores[0], indices[0]):
|
||||
if idx < 0 or idx >= len(_metadata):
|
||||
continue
|
||||
block = dict(_metadata[idx])
|
||||
block["search_score"] = float(score)
|
||||
results.append(block)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_blocks_for_topics(
|
||||
topics: list[dict],
|
||||
top_k_per_topic: int = 3,
|
||||
total_max: int = 10,
|
||||
) -> str:
|
||||
"""여러 꼭지에 대해 검색하고, 중복 제거 + 카테고리 보장 후 문자열로 반환.
|
||||
|
||||
Args:
|
||||
topics: 1단계 실장의 꼭지 분석 결과
|
||||
top_k_per_topic: 꼭지당 검색 수
|
||||
total_max: 최종 반환 최대 수
|
||||
|
||||
Returns:
|
||||
catalog.yaml 형식의 문자열 (팀장 프롬프트에 삽입)
|
||||
검색 실패 시 catalog.yaml 전문 반환 (fallback)
|
||||
"""
|
||||
if not _ensure_loaded():
|
||||
return _fallback_full_catalog()
|
||||
|
||||
# 1. 꼭지별 검색
|
||||
all_results: dict[str, dict] = {} # id → block (중복 제거)
|
||||
for topic in topics:
|
||||
query = _build_query(topic)
|
||||
results = search_blocks(query, top_k=top_k_per_topic)
|
||||
for block in results:
|
||||
bid = block["id"]
|
||||
if bid not in all_results:
|
||||
all_results[bid] = block
|
||||
else:
|
||||
# 이미 있으면 더 높은 점수로 업데이트
|
||||
if block["search_score"] > all_results[bid]["search_score"]:
|
||||
all_results[bid] = block
|
||||
|
||||
# 2. 카테고리별 최소 1개 보장
|
||||
found_categories = {b.get("category", "") for b in all_results.values()}
|
||||
missing_categories = set(ALL_CATEGORIES) - found_categories
|
||||
|
||||
if missing_categories and _metadata:
|
||||
for block in _metadata:
|
||||
cat = block.get("category", "")
|
||||
if cat in missing_categories:
|
||||
if block["id"] not in all_results:
|
||||
block_copy = dict(block)
|
||||
block_copy["search_score"] = 0.0 # 카테고리 보장용
|
||||
all_results[block["id"]] = block_copy
|
||||
missing_categories.discard(cat)
|
||||
if not missing_categories:
|
||||
break
|
||||
|
||||
# 3. 점수순 정렬 + 최대 개수 제한
|
||||
sorted_blocks = sorted(
|
||||
all_results.values(),
|
||||
key=lambda b: b.get("search_score", 0),
|
||||
reverse=True,
|
||||
)[:total_max]
|
||||
|
||||
# 4. 프롬프트용 문자열 생성
|
||||
return _format_for_prompt(sorted_blocks)
|
||||
|
||||
|
||||
def _build_query(topic: dict) -> str:
|
||||
"""꼭지 정보에서 검색 쿼리를 생성한다."""
|
||||
parts = [
|
||||
topic.get("title", ""),
|
||||
topic.get("summary", ""),
|
||||
f"역할: {topic.get('role', 'flow')}",
|
||||
f"레이어: {topic.get('layer', 'core')}",
|
||||
]
|
||||
if topic.get("content_type"):
|
||||
parts.append(f"콘텐츠: {topic['content_type']}")
|
||||
return ". ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _format_for_prompt(blocks: list[dict]) -> str:
|
||||
"""블록 목록을 팀장 프롬프트에 삽입할 문자열로 변환."""
|
||||
lines = [f"# 관련 블록 후보 ({len(blocks)}개)\n"]
|
||||
for block in blocks:
|
||||
lines.append(f"- **{block['id']}** ({block.get('name', '')})")
|
||||
lines.append(f" 시각: {block.get('visual', '')}")
|
||||
lines.append(f" 사용: {block.get('when', '').strip()}")
|
||||
lines.append(f" 금지: {block.get('not_for', '').strip()}")
|
||||
lines.append(f" 높이: {block.get('height_cost', 'medium')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fallback_full_catalog() -> str:
|
||||
"""검색 실패 시 catalog.yaml 전문을 반환한다 (기존 방식)."""
|
||||
if CATALOG_PATH.exists():
|
||||
return CATALOG_PATH.read_text(encoding="utf-8")
|
||||
return "사용 가능한 블록 없음."
|
||||
@@ -1,8 +1,11 @@
|
||||
"""DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리).
|
||||
|
||||
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
|
||||
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
|
||||
Kei API를 통해 도메인 전문가로서 각 슬롯 텍스트를 정리한다.
|
||||
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
|
||||
|
||||
1차: Kei API (persona + RAG + 도메인 지식)
|
||||
fallback: Anthropic API 직접 호출
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +15,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
from src.design_director import BLOCK_SLOTS
|
||||
@@ -30,6 +34,9 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
|
||||
- 전체 컨텍스트와 핵심 용어를 보존한다
|
||||
- 세련된 표현으로 편집한다 (원본 그대로가 아님)
|
||||
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
|
||||
- **불릿 항목은 반드시 각각 별도 줄(\n)로 작성한다.** 한 줄에 여러 항목을 넣지 마라.
|
||||
- 올바른 예: "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입\n• 출처: 국토교통부"
|
||||
- 잘못된 예: "• 추진과제: 건설산업 디지털화 • 실행과제: BIM 전면 도입 • 출처: 국토교통부"
|
||||
- 출처가 있는 내용은 출처를 반드시 보존한다
|
||||
- 출처가 없는 수치나 통계를 만들지 않는다
|
||||
|
||||
@@ -60,8 +67,6 @@ async def fill_content(
|
||||
Returns:
|
||||
슬롯이 채워진 layout_concept
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
for page_idx, page in enumerate(layout_concept.get("pages", [])):
|
||||
blocks = page.get("blocks", [])
|
||||
if not blocks:
|
||||
@@ -74,8 +79,9 @@ async def fill_content(
|
||||
slots = BLOCK_SLOTS.get(block_type, {})
|
||||
char_guide = block.get("char_guide", {})
|
||||
|
||||
topic_id = block.get("topic_id", i + 1)
|
||||
req_text = (
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}):\n"
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n"
|
||||
f" 용도: {block.get('reason', '미지정')}\n"
|
||||
f" 크기: {block.get('size', 'medium')}\n"
|
||||
f" 필수 슬롯: {slots.get('required', [])}\n"
|
||||
@@ -101,26 +107,47 @@ async def fill_content(
|
||||
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
|
||||
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
|
||||
"형식:\n"
|
||||
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
|
||||
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
# 1차: Kei API (도메인 전문가 + RAG)
|
||||
result_text = await _call_kei_editor(user_prompt)
|
||||
|
||||
# fallback: Anthropic 직접
|
||||
if result_text is None:
|
||||
logger.warning("Kei API 편집 실패. Anthropic 직접 호출로 fallback.")
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
result_text = response.content[0].text
|
||||
|
||||
result_text = response.content[0].text
|
||||
filled = _parse_json(result_text)
|
||||
|
||||
if filled and "blocks" in filled:
|
||||
for filled_block in filled["blocks"]:
|
||||
for orig_block in blocks:
|
||||
if orig_block.get("area") == filled_block.get("area"):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
matched = False
|
||||
# 1차: topic_id로 정확 매칭
|
||||
if filled_block.get("topic_id"):
|
||||
for orig_block in blocks:
|
||||
if orig_block.get("topic_id") == filled_block.get("topic_id"):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
matched = True
|
||||
break
|
||||
# 2차: area + type으로 매칭 (topic_id 없을 때)
|
||||
if not matched:
|
||||
for orig_block in blocks:
|
||||
if (
|
||||
orig_block.get("area") == filled_block.get("area")
|
||||
and orig_block.get("type") == filled_block.get("type")
|
||||
and "data" not in orig_block
|
||||
):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"텍스트 정리 완료 (페이지 {page_idx + 1}): "
|
||||
@@ -137,23 +164,129 @@ async def fill_content(
|
||||
return layout_concept
|
||||
|
||||
|
||||
async def _call_kei_editor(prompt: str) -> str | None:
|
||||
"""Kei API를 통해 텍스트 편집을 요청한다.
|
||||
|
||||
Kei persona의 도메인 지식 + RAG를 활용하여
|
||||
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
|
||||
"""
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
full_prompt = EDITOR_PROMPT + "\n\n" + prompt
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
response = await client.post(
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-editor",
|
||||
"mode": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
# SSE 응답에서 텍스트 수집
|
||||
full_text = _extract_sse_text(response.text)
|
||||
if full_text:
|
||||
return full_text
|
||||
|
||||
logger.warning("Kei API (editor) 텍스트 추출 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei API (editor) 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_sse_text(raw: str) -> str:
|
||||
"""SSE 응답에서 토큰 텍스트를 수집한다."""
|
||||
import re as _re
|
||||
tokens = []
|
||||
events = _re.split(r'\r?\n\r?\n', raw)
|
||||
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 not event_data:
|
||||
continue
|
||||
if event_type == 'token':
|
||||
try:
|
||||
token = json.loads(event_data)
|
||||
if isinstance(token, str):
|
||||
tokens.append(token)
|
||||
except json.JSONDecodeError:
|
||||
tokens.append(event_data)
|
||||
elif event_type == 'done':
|
||||
break
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
|
||||
"""실패 시 기본 데이터 적용."""
|
||||
defaults = {
|
||||
"quote-block": {"quote_text": "(텍스트 정리 실패)"},
|
||||
"card-grid": {"cards": []},
|
||||
"conclusion-bar": {"conclusion_text": "(결론 생성 실패)"},
|
||||
"comparison": {
|
||||
"left_title": "항목 A", "left_content": "-",
|
||||
"right_title": "항목 B", "right_content": "-",
|
||||
},
|
||||
"relationship": {
|
||||
"center_label": "관계도", "center_sub": "",
|
||||
"items": [], "description": "",
|
||||
},
|
||||
"process": {"steps": []},
|
||||
"comparison-table": {"headers": [], "rows": []},
|
||||
"image-block": {"src": "", "alt": "이미지"},
|
||||
# headers/
|
||||
"section-title-with-bg": {"title_ko": "(제목)"},
|
||||
"section-header-bar": {"title": "(섹션)"},
|
||||
"topic-left-right": {"title": "(소제목)", "description": ""},
|
||||
"topic-center": {"title": "(제목)"},
|
||||
"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": []},
|
||||
"card-compare-3col": {"cards": []},
|
||||
"card-step-vertical": {"steps": []},
|
||||
"card-image-round": {"cards": []},
|
||||
"card-stat-number": {"stats": []},
|
||||
"card-numbered": {"items": []},
|
||||
# tables/
|
||||
"compare-3col-badge": {"headers": [], "rows": []},
|
||||
"compare-2col-split": {"left_title": "A", "right_title": "B", "rows": []},
|
||||
"table-simple-striped": {"headers": [], "rows": []},
|
||||
# visuals/
|
||||
"venn-diagram": {"center_label": "관계도", "items": [], "center_sub": "", "description": ""},
|
||||
"circle-gradient": {"label": "(라벨)"},
|
||||
"compare-pill-pair": {"left_label": "A", "right_label": "B"},
|
||||
"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": []},
|
||||
"highlight-strip": {"segments": []},
|
||||
"callout-solution": {"title": "(솔루션)", "description": ""},
|
||||
"callout-warning": {"title": "(경고)", "description": ""},
|
||||
"tab-label-row": {"tabs": []},
|
||||
"divider-text": {"text": "구분"},
|
||||
# media/
|
||||
"image-row-2col": {"images": []},
|
||||
"image-grid-2x2": {"images": []},
|
||||
"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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
138
src/image_utils.py
Normal file
138
src/image_utils.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""이미지 크기 측정 + HTML 이미지 삽입 유틸리티.
|
||||
|
||||
MDX 콘텐츠에서 이미지 참조를 추출하고, 로컬 파일의 크기를 Pillow로 측정한다.
|
||||
다운로드 HTML에서 이미지가 보이도록 base64 data URI로 변환한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 웹 표준 이미지 포맷
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
||||
|
||||
MIME_MAP = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
|
||||
def get_image_sizes(content: str, base_path: str) -> list[dict[str, Any]]:
|
||||
"""콘텐츠에서 이미지 참조를 추출하고 로컬 파일 크기를 측정한다.
|
||||
|
||||
Args:
|
||||
content: MDX/텍스트 콘텐츠
|
||||
base_path: 이미지 파일 기준 폴더 경로
|
||||
|
||||
Returns:
|
||||
[{"path": "/assets/images/DX1.png", "width": 800, "height": 600,
|
||||
"ratio": 1.33, "orientation": "landscape"}]
|
||||
"""
|
||||
if not base_path:
|
||||
return []
|
||||
|
||||
base = Path(base_path)
|
||||
images: list[dict[str, Any]] = []
|
||||
|
||||
for match in re.finditer(r"!\[.*?\]\((.*?)\)", content):
|
||||
rel_path = match.group(1).strip()
|
||||
abs_path = base / rel_path.lstrip("/")
|
||||
|
||||
if abs_path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
|
||||
if not abs_path.exists():
|
||||
logger.warning(f"이미지 파일 미발견: {abs_path}")
|
||||
images.append({
|
||||
"path": rel_path,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"ratio": 0,
|
||||
"orientation": "not_found",
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(abs_path) as img:
|
||||
w, h = img.size
|
||||
|
||||
ratio = round(w / h, 2) if h > 0 else 1.0
|
||||
if ratio > 1.2:
|
||||
orientation = "landscape"
|
||||
elif ratio < 0.8:
|
||||
orientation = "portrait"
|
||||
else:
|
||||
orientation = "square"
|
||||
|
||||
images.append({
|
||||
"path": rel_path,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"ratio": ratio,
|
||||
"orientation": orientation,
|
||||
})
|
||||
logger.info(f"이미지 크기: {rel_path} → {w}×{h}px ({orientation})")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"이미지 크기 측정 실패 ({rel_path}): {e}")
|
||||
images.append({
|
||||
"path": rel_path,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"ratio": 0,
|
||||
"orientation": "error",
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def embed_images(html: str, base_path: str) -> str:
|
||||
"""HTML의 이미지 src를 base64 data URI로 변환한다.
|
||||
|
||||
다운로드된 HTML 파일에서 로컬 이미지가 보이도록
|
||||
상대 경로를 base64 인라인으로 교체한다.
|
||||
|
||||
Args:
|
||||
html: 렌더링된 HTML
|
||||
base_path: 이미지 파일 기준 폴더 경로
|
||||
|
||||
Returns:
|
||||
이미지가 base64로 삽입된 HTML
|
||||
"""
|
||||
if not base_path:
|
||||
return html
|
||||
|
||||
base = Path(base_path)
|
||||
|
||||
def replace_src(match: re.Match) -> str:
|
||||
src = match.group(1)
|
||||
abs_path = base / src.lstrip("/")
|
||||
|
||||
if not abs_path.exists():
|
||||
return match.group(0)
|
||||
|
||||
suffix = abs_path.suffix.lower()
|
||||
mime = MIME_MAP.get(suffix, "application/octet-stream")
|
||||
|
||||
try:
|
||||
data = base64.b64encode(abs_path.read_bytes()).decode()
|
||||
return f'src="data:{mime};base64,{data}"'
|
||||
except Exception:
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(
|
||||
r'src="(/[^"]+\.(?:png|jpg|jpeg|gif|webp|svg))"',
|
||||
replace_src,
|
||||
html,
|
||||
)
|
||||
@@ -35,7 +35,9 @@ KEI_PROMPT = (
|
||||
"- 본문 흐름을 뒷받침하는 근거/사례도 페이지 안에 배치한다. popup으로 빼지 않는다.\n"
|
||||
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터(비교표, 상세 스펙)에만 사용한다.\n"
|
||||
"- 근거, 사례, 용어 정의는 detail_target이 아니다.\n"
|
||||
"- 이미지/표가 있으면 그것도 판단해줘\n"
|
||||
"- 이미지가 있으면: 몇 개인지, 어떤 꼭지 소속인지, 핵심(도표/차트)인지 보조(참고 사진)인지, 텍스트가 포함된 이미지인지 판단해줘\n"
|
||||
"- 표가 있으면: 행/열 규모, 1페이지에 전체 표시 가능한지 판단해줘\n"
|
||||
"- 이미지/표 판단 결과를 images[], tables[] 배열에 기록해줘\n"
|
||||
"- 1페이지 적정 꼭지: 5개. 초과 시 2페이지 분리.\n"
|
||||
"- 2페이지로 분리하는 기준: 꼭지 수가 아니라 콘텐츠 분량이 진짜 많을 때만.\n"
|
||||
"- 꼭지가 5~6개라도 각 꼭지의 내용이 적으면 1페이지에 충분히 담을 수 있다.\n"
|
||||
@@ -50,7 +52,9 @@ KEI_PROMPT = (
|
||||
'"role": "flow|reference", '
|
||||
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||
'"content_type": "text|image|table|mixed", '
|
||||
'"detail_target": false, "page": 1}]}\n'
|
||||
'"detail_target": false, "page": 1}], '
|
||||
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
|
||||
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
|
||||
"```\n\n"
|
||||
"## 콘텐츠:\n"
|
||||
)
|
||||
@@ -173,14 +177,21 @@ async def _call_anthropic_direct(content: str) -> dict[str, Any] | None:
|
||||
"- 슬라이드에 맞게 정리하되, 원본이 말하려는 흐름은 유지\n\n"
|
||||
"## 꼭지 추출 규칙\n"
|
||||
"- 본문에서 2~5개의 핵심 꼭지를 추출한다\n"
|
||||
"- 참조 정보는 role: 'reference', 본문 흐름은 role: 'flow'로 표시\n"
|
||||
"- 1페이지 적정 꼭지 수: 5개\n"
|
||||
"- 초과 시 2페이지 분리\n\n"
|
||||
"- 초과 시 2페이지 분리\n"
|
||||
"- 이미지가 있으면 images[]에, 표가 있으면 tables[]에 판단 기록\n\n"
|
||||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||||
'{"title": "제목", "total_pages": 1, "topics": ['
|
||||
'{"title": "제목", "total_pages": 1, '
|
||||
'"info_structure": "정보 구조 설명", '
|
||||
'"topics": ['
|
||||
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
||||
'"layer": "intro|core|supporting|conclusion", '
|
||||
'"role": "flow|reference", '
|
||||
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||
'"content_type": "text", "detail_target": false, "page": 1}]}'
|
||||
'"content_type": "text|image|table|mixed", '
|
||||
'"detail_target": false, "page": 1}], '
|
||||
'"images": [], "tables": []}'
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -227,12 +238,14 @@ def manual_classify(content: str) -> dict[str, Any]:
|
||||
return {
|
||||
"title": "슬라이드",
|
||||
"total_pages": 1,
|
||||
"info_structure": "",
|
||||
"topics": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "핵심 내용",
|
||||
"summary": content[:100],
|
||||
"layer": "core",
|
||||
"role": "flow",
|
||||
"emphasis": False,
|
||||
"direction": "flexible",
|
||||
"content_type": "text",
|
||||
@@ -240,4 +253,6 @@ def manual_classify(content: str) -> dict[str, Any]:
|
||||
"page": 1,
|
||||
},
|
||||
],
|
||||
"images": [],
|
||||
"tables": [],
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ if static_dir.exists():
|
||||
|
||||
class SlideRequest(BaseModel):
|
||||
content: str
|
||||
base_path: str = "" # 이미지 기준 폴더 경로 (선택, 로컬 경로)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -45,7 +46,7 @@ async def health():
|
||||
async def generate(req: SlideRequest):
|
||||
"""콘텐츠 → 슬라이드 생성 (SSE 스트리밍)."""
|
||||
async def event_stream():
|
||||
async for event in generate_slide(req.content):
|
||||
async for event in generate_slide(req.content, base_path=req.base_path):
|
||||
yield {
|
||||
"event": event["event"],
|
||||
"data": json.dumps(event["data"], ensure_ascii=False),
|
||||
|
||||
193
src/pipeline.py
193
src/pipeline.py
@@ -16,9 +16,10 @@ from typing import Any, AsyncIterator
|
||||
import anthropic
|
||||
|
||||
from src.kei_client import classify_content, manual_classify
|
||||
from src.design_director import create_layout_concept
|
||||
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset
|
||||
from src.content_editor import fill_content
|
||||
from src.renderer import render_slide
|
||||
from src.image_utils import get_image_sizes, embed_images
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
manual_layout: dict[str, Any] | None = None,
|
||||
base_path: str = "",
|
||||
) -> AsyncIterator[dict[str, str]]:
|
||||
"""콘텐츠를 슬라이드 HTML로 변환하는 5단계 파이프라인.
|
||||
|
||||
@@ -48,6 +50,12 @@ async def generate_slide(
|
||||
page_count = analysis.get("total_pages", 1)
|
||||
logger.info(f"1단계 완료: {topic_count}개 꼭지, {page_count}페이지")
|
||||
|
||||
# 이미지 크기 측정 (base_path 있을 때만)
|
||||
image_sizes = get_image_sizes(content, base_path)
|
||||
if image_sizes:
|
||||
analysis["image_sizes"] = image_sizes
|
||||
logger.info(f"이미지 측정: {len(image_sizes)}개")
|
||||
|
||||
# 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
|
||||
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
|
||||
@@ -67,29 +75,48 @@ async def generate_slide(
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
logger.info("3단계 완료: 텍스트 정리")
|
||||
|
||||
# 4단계: 디자인 실무자 — HTML 조립
|
||||
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
|
||||
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
|
||||
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("4단계 완료: HTML 조립")
|
||||
|
||||
# 5단계: 디자인 팀장 — 전체 재검토
|
||||
# 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
|
||||
MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
|
||||
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
|
||||
|
||||
review_result = await _review_balance(html, layout_concept, content)
|
||||
for review_round in range(MAX_REVIEW_ROUNDS):
|
||||
review_result = await _review_balance(html, layout_concept, content)
|
||||
|
||||
if review_result and review_result.get("needs_adjustment"):
|
||||
if not review_result or not review_result.get("needs_adjustment"):
|
||||
if review_round == 0:
|
||||
logger.info("5단계 완료: 조정 불필요")
|
||||
else:
|
||||
logger.info(f"5단계 완료: {review_round}차 조정 후 균형 확인")
|
||||
break
|
||||
|
||||
issues = review_result.get("issues", [])
|
||||
logger.info(
|
||||
f"5단계: 조정 필요 — {review_result.get('issues', [])}"
|
||||
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
|
||||
f"조정 필요 — {issues}"
|
||||
)
|
||||
# 조정 지시에 따라 텍스트 재편집 또는 레이아웃 재조정
|
||||
|
||||
layout_concept = await _apply_adjustments(
|
||||
layout_concept, review_result, content
|
||||
)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("5단계 완료: 2차 조정 반영")
|
||||
logger.info(f"5단계: {review_round + 1}차 조정 반영, 재검토 진행")
|
||||
else:
|
||||
logger.info("5단계 완료: 조정 불필요")
|
||||
# MAX_REVIEW_ROUNDS 초과
|
||||
logger.warning(
|
||||
f"5단계: 최대 재조정 횟수({MAX_REVIEW_ROUNDS}) 도달. 현재 결과로 확정."
|
||||
)
|
||||
|
||||
# D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
|
||||
if base_path:
|
||||
html = embed_images(html, base_path)
|
||||
logger.info("이미지 base64 삽입 완료")
|
||||
|
||||
yield {"event": "result", "data": html}
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
|
||||
@@ -99,6 +126,108 @@ async def generate_slide(
|
||||
yield {"event": "error", "data": str(e)}
|
||||
|
||||
|
||||
async def _adjust_design(
|
||||
layout_concept: dict[str, Any],
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""4단계 전반: 디자인 실무자가 텍스트 양에 맞게 CSS를 조정한다.
|
||||
|
||||
각 area별 블록 수, 텍스트 총량, zone 예산을 계산하고,
|
||||
Sonnet이 area별 CSS 변수 override를 결정한다.
|
||||
블록 템플릿이 이미 CSS 변수(var(--font-body) 등)를 사용하므로,
|
||||
area div에서 변수를 override하면 내부 블록이 자동 조정된다.
|
||||
"""
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# 프리셋 정보 가져오기
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
zones = preset.get("zones", {})
|
||||
|
||||
for page in layout_concept.get("pages", []):
|
||||
# area별 블록 수 + 텍스트 총량 집계
|
||||
area_info = {}
|
||||
for block in page.get("blocks", []):
|
||||
area = block.get("area", "body")
|
||||
if area not in area_info:
|
||||
zone = zones.get(area, {})
|
||||
area_info[area] = {
|
||||
"block_count": 0,
|
||||
"total_chars": 0,
|
||||
"budget_px": zone.get("budget_px", 490),
|
||||
"width_pct": zone.get("width_pct", 100),
|
||||
"block_types": [],
|
||||
}
|
||||
data = block.get("data", {})
|
||||
text_len = len(json.dumps(data, ensure_ascii=False))
|
||||
area_info[area]["block_count"] += 1
|
||||
area_info[area]["total_chars"] += text_len
|
||||
area_info[area]["block_types"].append(block.get("type", ""))
|
||||
|
||||
# area 정보 텍스트 구성
|
||||
area_lines = []
|
||||
for area_name, info in area_info.items():
|
||||
area_lines.append(
|
||||
f"- {area_name} (예산 {info['budget_px']}px, 너비 {info['width_pct']}%): "
|
||||
f"{info['block_count']}개 블록, 총 {info['total_chars']}자\n"
|
||||
f" 블록 타입: {', '.join(info['block_types'])}"
|
||||
)
|
||||
|
||||
system = (
|
||||
"당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.\n\n"
|
||||
"## 원칙\n"
|
||||
"- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.\n"
|
||||
"- 빈 공간을 방치하지 않는다.\n"
|
||||
"- 텍스트가 많으면: 폰트/여백을 줄여서 맞춘다.\n"
|
||||
"- 텍스트가 적으면: 폰트/여백을 늘려서 채운다.\n\n"
|
||||
"## 조정 가능한 CSS 변수\n"
|
||||
"- --font-body (기본 0.95rem): 본문 폰트 크기\n"
|
||||
"- --font-subtitle (기본 1.25rem): 소제목 폰트 크기\n"
|
||||
"- --font-caption (기본 0.8rem): 캡션 폰트 크기\n"
|
||||
"- --spacing-inner (기본 16px): 블록 내부 여백\n"
|
||||
"- --spacing-block (기본 20px): 블록 간 간격\n"
|
||||
"- --spacing-small (기본 8px): 작은 여백\n\n"
|
||||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||||
"각 area에 적용할 CSS 변수 override를 inline style 문자열로 반환.\n"
|
||||
"조정 불필요한 area는 빈 문자열.\n"
|
||||
'{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"## 각 영역 현황\n" + "\n".join(area_lines) +
|
||||
f"\n\n위 영역별로 CSS 변수 조정이 필요한지 판단하여 JSON으로 반환해줘."
|
||||
)
|
||||
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
result_text = response.content[0].text
|
||||
result = _parse_json(result_text)
|
||||
|
||||
if result and "area_styles" in result:
|
||||
page["area_styles"] = result["area_styles"]
|
||||
logger.info(
|
||||
f"디자인 조정: {', '.join(f'{k}={bool(v)}' for k, v in result['area_styles'].items())}"
|
||||
)
|
||||
else:
|
||||
page["area_styles"] = {}
|
||||
logger.info("디자인 조정: 조정 불필요 또는 파싱 실패")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"디자인 조정 실패 (기존 스타일로 렌더링): {e}")
|
||||
# 실패 시 area_styles 없음 → 기존과 동일하게 렌더링
|
||||
for page in layout_concept.get("pages", []):
|
||||
if "area_styles" not in page:
|
||||
page["area_styles"] = {}
|
||||
|
||||
return layout_concept
|
||||
|
||||
|
||||
async def _review_balance(
|
||||
html: str,
|
||||
layout_concept: dict[str, Any],
|
||||
@@ -126,24 +255,31 @@ async def _review_balance(
|
||||
)
|
||||
|
||||
system = (
|
||||
"당신은 디자인 팀장이다. 1차 조립 결과를 검토하여 균형을 점검한다.\n\n"
|
||||
"당신은 디자인 팀장이다. 1차 조립 결과(HTML)를 검토하여 균형을 점검한다.\n\n"
|
||||
"## 점검 항목\n"
|
||||
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
|
||||
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
|
||||
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
|
||||
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n\n"
|
||||
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
|
||||
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n\n"
|
||||
"## 조정 action 설명\n"
|
||||
"- expand: 텍스트를 늘린다. target_ratio로 얼마나 늘릴지 지정 (예: 1.3 = 30% 증가)\n"
|
||||
"- shrink: 텍스트를 줄인다. target_ratio로 얼마나 줄일지 지정 (예: 0.7 = 30% 감소)\n"
|
||||
"- rewrite: 텍스트를 완전히 재작성한다. detail에 재작성 방향 명시.\n\n"
|
||||
"## 출력 형식 (JSON만)\n"
|
||||
'{"needs_adjustment": true/false, '
|
||||
'"issues": ["이슈1", "이슈2"], '
|
||||
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", "detail": "..."}]}'
|
||||
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite", '
|
||||
'"target_ratio": 1.3, "detail": "..."}]}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"## 1차 조립 HTML\n{html}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
|
||||
f"\n\n## 레이아웃 구조\n"
|
||||
f"페이지 수: {len(layout_concept.get('pages', []))}\n"
|
||||
f"총 블록 수: {sum(len(p.get('blocks', [])) for p in layout_concept.get('pages', []))}\n\n"
|
||||
f"조정이 필요한가? JSON으로 답해."
|
||||
f"위 HTML과 데이터를 보고 조정이 필요한지 판단해. JSON으로 답해."
|
||||
)
|
||||
|
||||
response = await client.messages.create(
|
||||
@@ -175,18 +311,33 @@ async def _apply_adjustments(
|
||||
for adj in adjustments:
|
||||
area = adj.get("block_area", "")
|
||||
action = adj.get("action", "")
|
||||
ratio = adj.get("target_ratio")
|
||||
detail = adj.get("detail", "")
|
||||
|
||||
for page in layout_concept.get("pages", []):
|
||||
for block in page.get("blocks", []):
|
||||
if block.get("area") == area and action in ("expand", "rewrite"):
|
||||
# 해당 블록의 char_guide를 조정하여 재편집 유도
|
||||
if action == "expand":
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * 1.5
|
||||
)
|
||||
logger.info(f"조정: {area} → {action} ({detail})")
|
||||
if block.get("area") != area:
|
||||
continue
|
||||
|
||||
if action == "expand" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * ratio
|
||||
)
|
||||
logger.info(f"조정: {area} → expand ×{ratio} ({detail})")
|
||||
|
||||
elif action == "shrink" and ratio:
|
||||
for key in block.get("char_guide", {}):
|
||||
block["char_guide"][key] = int(
|
||||
block["char_guide"][key] * ratio
|
||||
)
|
||||
logger.info(f"조정: {area} → shrink ×{ratio} ({detail})")
|
||||
|
||||
elif action == "rewrite":
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
block["reason"] = f"재작성: {detail}"
|
||||
logger.info(f"조정: {area} → rewrite ({detail})")
|
||||
|
||||
# 조정된 가이드로 재편집
|
||||
layout_concept = await fill_content(content, layout_concept)
|
||||
|
||||
278
src/renderer.py
278
src/renderer.py
@@ -1,51 +1,194 @@
|
||||
"""DA-11: 슬라이드 조합 렌더러.
|
||||
"""DA-11 + DA-21: 슬라이드 조합 렌더러.
|
||||
|
||||
블록 배치 명세(JSON)를 받아 Jinja2로 HTML을 생성한다.
|
||||
다중 페이지 지원: pages 배열의 각 페이지를 .slide div로 렌더링.
|
||||
- 다중 페이지 지원
|
||||
- 카테고리 경로 지원 (blocks/{category}/{name}.html)
|
||||
- _legacy fallback (기존 경로 호환)
|
||||
- 같은 area 블록 그룹핑 (겹침 방지)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"
|
||||
|
||||
# 카테고리 검색 순서
|
||||
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
|
||||
|
||||
# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)
|
||||
_CATALOG_MAP: dict[str, str] | None = None
|
||||
_CATALOG_MTIME: float = 0.0
|
||||
|
||||
def _load_catalog_map() -> dict[str, str]:
|
||||
"""catalog.yaml에서 블록 id → template 경로 매핑을 로드한다.
|
||||
|
||||
파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다.
|
||||
"""
|
||||
global _CATALOG_MAP, _CATALOG_MTIME
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0
|
||||
|
||||
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime:
|
||||
return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용
|
||||
|
||||
# 변경 감지 또는 첫 로드 → 새로 읽기
|
||||
_CATALOG_MTIME = current_mtime
|
||||
_CATALOG_MAP = {}
|
||||
if CATALOG_PATH.exists():
|
||||
try:
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
template = block.get("template", "")
|
||||
if block_id and template:
|
||||
_CATALOG_MAP[block_id] = template
|
||||
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog.yaml 로드 실패: {e}")
|
||||
else:
|
||||
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
|
||||
return _CATALOG_MAP
|
||||
|
||||
|
||||
def create_jinja_env() -> Environment:
|
||||
"""Jinja2 환경 생성. templates/ 폴더를 로더로 사용."""
|
||||
"""Jinja2 환경 생성."""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
|
||||
def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다.
|
||||
def _resolve_template_path(env: Environment, block_type: str) -> str | None:
|
||||
"""블록 타입으로 템플릿 경로를 찾는다.
|
||||
|
||||
Args:
|
||||
layout_concept: 디자인 팀장 + 텍스트 편집자가 완성한 구조:
|
||||
{
|
||||
"title": "슬라이드 제목",
|
||||
"pages": [
|
||||
{
|
||||
"grid_areas": "...",
|
||||
"grid_columns": "...",
|
||||
"grid_rows": "...",
|
||||
"blocks": [{"area": "...", "type": "...", "data": {...}}]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns:
|
||||
완성된 HTML 문자열 (다중 페이지 시 .slide div 여러 개).
|
||||
검색 순서:
|
||||
0. catalog.yaml 매핑 (id → template 경로, 최우선)
|
||||
1. 정확한 경로 (blocks/cards/card-text-grid.html 등 — 팀장이 카테고리 포함 지정)
|
||||
2. 카테고리 폴더 검색 (blocks/{category}/{block_type}.html)
|
||||
3. _legacy fallback (blocks/_legacy/{block_type}.html)
|
||||
4. 루트 fallback (blocks/{block_type}.html)
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 0. catalog.yaml에서 id → template 매핑 조회 (최우선)
|
||||
catalog_map = _load_catalog_map()
|
||||
if block_type in catalog_map:
|
||||
catalog_path = catalog_map[block_type]
|
||||
candidates.append(catalog_path)
|
||||
# .html 확장자 없는 경우 대비
|
||||
if not catalog_path.endswith(".html"):
|
||||
candidates.append(f"{catalog_path}.html")
|
||||
|
||||
# 1. 이미 카테고리 경로가 포함된 경우 (예: "cards/card-text-grid")
|
||||
if "/" in block_type:
|
||||
candidates.append(f"blocks/{block_type}.html")
|
||||
candidates.append(f"blocks/{block_type}") # .html 이미 포함된 경우
|
||||
|
||||
# 2. 카테고리 폴더 검색
|
||||
for category in BLOCK_CATEGORIES:
|
||||
candidates.append(f"blocks/{category}/{block_type}.html")
|
||||
|
||||
# 3. _legacy fallback
|
||||
candidates.append(f"blocks/_legacy/{block_type}.html")
|
||||
|
||||
# 4. 루트 fallback
|
||||
candidates.append(f"blocks/{block_type}.html")
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
env.get_template(path)
|
||||
return path
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _preprocess_svg_data(block_type: str, block_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""P2-B: SVG 시각화 블록의 좌표를 사전 계산한다.
|
||||
|
||||
venn-diagram: items[]에 cx, cy, r 좌표 추가 + outer_r 등 레이아웃 데이터
|
||||
다른 블록: 변경 없이 그대로 반환
|
||||
"""
|
||||
SVG_BLOCKS = {"venn-diagram", "relationship"}
|
||||
|
||||
if block_type not in SVG_BLOCKS:
|
||||
return block_data
|
||||
|
||||
items = block_data.get("items", [])
|
||||
if not items:
|
||||
return block_data
|
||||
|
||||
# items에 이미 cx가 있으면 (수동 지정) 그대로 사용
|
||||
if items[0].get("cx") is not None:
|
||||
return block_data
|
||||
|
||||
try:
|
||||
from src.svg_calculator import prepare_venn_data
|
||||
|
||||
prepared = prepare_venn_data(
|
||||
items=items,
|
||||
center_label=block_data.get("center_label", ""),
|
||||
center_sub=block_data.get("center_sub", ""),
|
||||
description=block_data.get("description", ""),
|
||||
)
|
||||
# 기존 block_data에 계산 결과 병합
|
||||
block_data.update(prepared)
|
||||
logger.info(
|
||||
f"SVG 좌표 계산 완료: {block_type}, "
|
||||
f"{len(items)}개 원소, outer_r={prepared.get('outer_r')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"SVG 좌표 계산 실패 ({block_type}): {e}. Phase 1 fallback.")
|
||||
# fallback: 좌표 없이 Jinja2에 전달 → Phase 1 고정 SVG
|
||||
|
||||
return block_data
|
||||
|
||||
|
||||
def _group_blocks_by_area(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""같은 area의 블록들을 하나로 그룹핑한다.
|
||||
|
||||
CSS Grid에서 같은 area에 여러 div가 있으면 겹치므로,
|
||||
같은 area의 블록 HTML을 합쳐서 하나의 div로 만든다.
|
||||
"""
|
||||
grouped = OrderedDict()
|
||||
for block in blocks:
|
||||
area = block["area"]
|
||||
if area not in grouped:
|
||||
grouped[area] = {"area": area, "htmls": []}
|
||||
grouped[area]["htmls"].append(block["html"])
|
||||
|
||||
result = []
|
||||
for area, data in grouped.items():
|
||||
if len(data["htmls"]) == 1:
|
||||
html = data["htmls"][0]
|
||||
else:
|
||||
# 여러 블록을 flex-column으로 세로 쌓기
|
||||
inner = "\n".join(data["htmls"])
|
||||
html = (
|
||||
f'<div style="display:flex; flex-direction:column; '
|
||||
f'gap:var(--spacing-block); height:100%;">\n'
|
||||
f'{inner}\n</div>'
|
||||
)
|
||||
result.append({"area": area, "html": html})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
"""다중 페이지 레이아웃 컨셉으로 완성 HTML을 생성한다."""
|
||||
env = create_jinja_env()
|
||||
title = layout_concept.get("title", "슬라이드")
|
||||
pages = layout_concept.get("pages", [])
|
||||
@@ -54,36 +197,57 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
logger.warning("페이지가 없습니다. 빈 HTML 반환.")
|
||||
return "<html><body><p>페이지가 없습니다.</p></body></html>"
|
||||
|
||||
# 각 페이지의 블록을 개별 렌더링
|
||||
pages_rendered = []
|
||||
for page_idx, page in enumerate(pages):
|
||||
blocks_rendered = []
|
||||
blocks_raw = []
|
||||
for block in page.get("blocks", []):
|
||||
block_type = block.get("type", "")
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
# P2-B: SVG 시각화 블록은 좌표 사전 계산
|
||||
block_data = _preprocess_svg_data(block_type, block_data)
|
||||
|
||||
blocks_rendered.append({
|
||||
# DA-21: 카테고리 경로 검색
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
|
||||
if template_path:
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = (
|
||||
f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
)
|
||||
else:
|
||||
logger.warning(f"블록 템플릿 미발견: {block_type}")
|
||||
rendered_html = (
|
||||
f'<div class="body-text">블록 템플릿 미발견: {block_type}</div>'
|
||||
)
|
||||
|
||||
blocks_raw.append({
|
||||
"area": block.get("area", "main"),
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
# Fix 1: 같은 area 블록 그룹핑
|
||||
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
||||
|
||||
# A-1: area별 CSS 변수 override 주입
|
||||
area_styles = page.get("area_styles", {})
|
||||
for grouped_block in blocks_grouped:
|
||||
grouped_block["style_override"] = area_styles.get(
|
||||
grouped_block["area"], ""
|
||||
)
|
||||
|
||||
pages_rendered.append({
|
||||
"grid_areas": page.get("grid_areas", "'main'"),
|
||||
"grid_columns": page.get("grid_columns", "1fr"),
|
||||
"grid_rows": page.get("grid_rows", "auto"),
|
||||
"blocks": blocks_rendered,
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": page_idx + 1,
|
||||
})
|
||||
|
||||
# base 템플릿 렌더링
|
||||
base_template = env.get_template("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=title,
|
||||
@@ -91,7 +255,7 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
total_pages=len(pages_rendered),
|
||||
)
|
||||
|
||||
# CSS를 인라인으로 삽입
|
||||
# CSS 인라인 삽입
|
||||
tokens_css = (STATIC_DIR / "tokens.css").read_text(encoding="utf-8")
|
||||
base_css = (STATIC_DIR / "base.css").read_text(encoding="utf-8")
|
||||
base_css = base_css.replace("@import url('./tokens.css');", "")
|
||||
@@ -106,37 +270,42 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
return html
|
||||
|
||||
|
||||
# 하위 호환: 기존 render_slide도 유지
|
||||
def render_slide(layout: dict[str, Any]) -> str:
|
||||
"""기존 단일 페이지 렌더링 (하위 호환).
|
||||
|
||||
pages 구조가 있으면 render_multi_page로 위임.
|
||||
없으면 기존 방식으로 단일 페이지 렌더링.
|
||||
"""
|
||||
"""하위 호환 렌더링. pages 구조가 있으면 render_multi_page로 위임."""
|
||||
if "pages" in layout:
|
||||
return render_multi_page(layout)
|
||||
|
||||
# 기존 단일 페이지 로직
|
||||
env = create_jinja_env()
|
||||
|
||||
blocks_rendered = []
|
||||
blocks_raw = []
|
||||
for block in layout.get("blocks", []):
|
||||
block_type = block["type"]
|
||||
block_data = block.get("data", {})
|
||||
template_path = f"blocks/{block_type}.html"
|
||||
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
|
||||
blocks_rendered.append({
|
||||
if template_path:
|
||||
try:
|
||||
block_template = env.get_template(template_path)
|
||||
rendered_html = block_template.render(**block_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 렌더링 실패 ({block_type}): {e}")
|
||||
rendered_html = (
|
||||
f'<div class="body-text">블록 렌더링 실패: {block_type}</div>'
|
||||
)
|
||||
else:
|
||||
logger.warning(f"블록 템플릿 미발견: {block_type}")
|
||||
rendered_html = (
|
||||
f'<div class="body-text">블록 템플릿 미발견: {block_type}</div>'
|
||||
)
|
||||
|
||||
blocks_raw.append({
|
||||
"area": block["area"],
|
||||
"html": rendered_html,
|
||||
})
|
||||
|
||||
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
||||
|
||||
base_template = env.get_template("slide-base.html")
|
||||
html = base_template.render(
|
||||
slide_title=layout.get("title", ""),
|
||||
@@ -144,7 +313,7 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
"grid_areas": layout.get("grid_areas", "'header' 'main' 'footer'"),
|
||||
"grid_columns": layout.get("grid_columns", "1fr"),
|
||||
"grid_rows": layout.get("grid_rows", "auto 1fr auto"),
|
||||
"blocks": blocks_rendered,
|
||||
"blocks": blocks_grouped,
|
||||
"page_number": 1,
|
||||
}],
|
||||
total_pages=1,
|
||||
@@ -167,5 +336,8 @@ def render_slide(layout: dict[str, Any]) -> str:
|
||||
def render_standalone_block(block_type: str, data: dict[str, Any]) -> str:
|
||||
"""단일 블록을 독립 HTML로 렌더링 (테스트/미리보기용)."""
|
||||
env = create_jinja_env()
|
||||
template = env.get_template(f"blocks/{block_type}.html")
|
||||
template_path = _resolve_template_path(env, block_type)
|
||||
if not template_path:
|
||||
return f"<div>블록 미발견: {block_type}</div>"
|
||||
template = env.get_template(template_path)
|
||||
return template.render(**data)
|
||||
|
||||
156
src/svg_calculator.py
Normal file
156
src/svg_calculator.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""P2-B: SVG 시각화 좌표 계산 모듈.
|
||||
|
||||
벤 다이어그램, 원형 배치 등 시각화 블록의 좌표를 수학적으로 계산한다.
|
||||
하드코딩 금지 — 모든 좌표는 N개 원소에 범용으로 계산.
|
||||
|
||||
Phase 1: 3개 고정 SVG (검증 완료)
|
||||
Phase 2: N개 자동 배치 (이 모듈)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def calc_circle_positions(
|
||||
n: int,
|
||||
center_x: float = 300.0,
|
||||
center_y: float = 300.0,
|
||||
radius: float = 120.0,
|
||||
) -> list[dict[str, float]]:
|
||||
"""N개 원소를 원형으로 배치한다. 12시 방향부터 시계방향.
|
||||
|
||||
Args:
|
||||
n: 원소 개수 (2~7)
|
||||
center_x: 큰 원 중심 X
|
||||
center_y: 큰 원 중심 Y
|
||||
radius: 큰 원 중심에서 작은 원 중심까지의 거리
|
||||
|
||||
Returns:
|
||||
[{"cx": float, "cy": float}, ...] 각 원소의 중심 좌표
|
||||
"""
|
||||
if n <= 0:
|
||||
return []
|
||||
|
||||
positions = []
|
||||
for i in range(n):
|
||||
# 12시(상단)부터 시계방향: -π/2에서 시작
|
||||
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_item_radius(n: int, base_radius: float = 75.0) -> float:
|
||||
"""N에 따라 작은 원 반지름을 자동 계산.
|
||||
|
||||
원소가 많아지면 작은 원이 작아져야 겹치지 않는다.
|
||||
공식: base / (1 + max(0, n-3) * 0.2)
|
||||
"""
|
||||
if n <= 3:
|
||||
return base_radius
|
||||
shrink_factor = 1 + (n - 3) * 0.2
|
||||
return round(base_radius / shrink_factor, 1)
|
||||
|
||||
|
||||
def calc_orbit_radius(n: int, base_orbit: float = 120.0) -> float:
|
||||
"""N에 따라 궤도 반지름(큰 원 중심~작은 원 중심)을 자동 계산.
|
||||
|
||||
원소가 많아지면 궤도를 넓혀서 겹침 방지.
|
||||
"""
|
||||
if n <= 3:
|
||||
return base_orbit
|
||||
expand_factor = 1 + (n - 3) * 0.08
|
||||
return round(base_orbit * expand_factor, 1)
|
||||
|
||||
|
||||
def calc_outer_radius(n: int, orbit_radius: float, item_radius: float) -> float:
|
||||
"""큰 원 반지름을 계산. 모든 작은 원이 안에 들어가도록."""
|
||||
# 궤도 + 작은 원 반지름 + 여백
|
||||
margin = 40.0
|
||||
return round(orbit_radius + item_radius + margin, 1)
|
||||
|
||||
|
||||
def prepare_venn_data(
|
||||
items: list[dict[str, Any]],
|
||||
center_label: str = "",
|
||||
center_sub: str = "",
|
||||
description: str = "",
|
||||
viewbox_width: float = 600.0,
|
||||
viewbox_height: float = 550.0,
|
||||
) -> dict[str, Any]:
|
||||
"""벤 다이어그램 렌더링에 필요한 전체 데이터를 준비한다.
|
||||
|
||||
items에 cx, cy, r 좌표를 추가하고, 큰 원/SVG viewBox 크기를 계산.
|
||||
renderer.py에서 이 함수를 호출하여 Jinja2에 전달.
|
||||
|
||||
Args:
|
||||
items: [{"label": "GIS", "color": "#059669", ...}, ...]
|
||||
center_label: 큰 원 중앙 텍스트
|
||||
center_sub: 서브 텍스트
|
||||
description: 하단 설명
|
||||
viewbox_width: SVG viewBox 너비
|
||||
viewbox_height: SVG viewBox 높이
|
||||
|
||||
Returns:
|
||||
Jinja2 템플릿에 전달할 dict
|
||||
"""
|
||||
n = len(items)
|
||||
if n == 0:
|
||||
return {
|
||||
"items": [],
|
||||
"center_label": center_label,
|
||||
"center_sub": center_sub,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
# 중심 좌표 (viewBox 기준)
|
||||
cx = viewbox_width / 2
|
||||
cy = viewbox_height / 2 + 20 # 약간 아래로 (상단에 타이틀 공간)
|
||||
|
||||
# N에 따른 자동 계산
|
||||
orbit_r = calc_orbit_radius(n)
|
||||
item_r = calc_item_radius(n)
|
||||
outer_r = calc_outer_radius(n, orbit_r, item_r)
|
||||
|
||||
# 각 원소 좌표 계산
|
||||
positions = calc_circle_positions(n, center_x=cx, center_y=cy, radius=orbit_r)
|
||||
|
||||
# items에 좌표 + 반지름 추가
|
||||
for i, item in enumerate(items):
|
||||
item["cx"] = positions[i]["cx"]
|
||||
item["cy"] = positions[i]["cy"]
|
||||
item["r"] = item_r
|
||||
|
||||
# 기본 색상 팔레트 (color가 없는 경우)
|
||||
default_colors = [
|
||||
{"color": "#10b981", "color_light": "#6ee7b7"}, # 초록
|
||||
{"color": "#3b82f6", "color_light": "#93c5fd"}, # 파랑
|
||||
{"color": "#8b5cf6", "color_light": "#c4b5fd"}, # 보라
|
||||
{"color": "#f59e0b", "color_light": "#fcd34d"}, # 노랑
|
||||
{"color": "#ef4444", "color_light": "#fca5a5"}, # 빨강
|
||||
{"color": "#06b6d4", "color_light": "#67e8f9"}, # 시안
|
||||
{"color": "#ec4899", "color_light": "#f9a8d4"}, # 핑크
|
||||
]
|
||||
for i, item in enumerate(items):
|
||||
if "color" not in item:
|
||||
palette = default_colors[i % len(default_colors)]
|
||||
item["color"] = palette["color"]
|
||||
if "color_light" not in item:
|
||||
# color에서 밝은 버전 자동 생성 (간단: opacity 낮은 버전)
|
||||
item["color_light"] = item.get("color_light", item["color"] + "80")
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"center_label": center_label,
|
||||
"center_sub": center_sub,
|
||||
"description": description,
|
||||
"outer_r": outer_r,
|
||||
"center_x": cx,
|
||||
"center_y": cy,
|
||||
"viewbox_width": viewbox_width,
|
||||
"viewbox_height": viewbox_height,
|
||||
}
|
||||
Reference in New Issue
Block a user