Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리
- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정 - Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div - Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제 - Selenium: container div 감지 추가 - catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드 - 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -176,13 +176,20 @@ def search_blocks_for_topics(
|
||||
|
||||
|
||||
def _build_query(topic: dict) -> str:
|
||||
"""꼭지 정보에서 검색 쿼리를 생성한다."""
|
||||
"""꼭지 정보에서 검색 쿼리를 생성한다. (Phase M: 역할+관계+표현 추가)"""
|
||||
parts = [
|
||||
topic.get("title", ""),
|
||||
topic.get("summary", ""),
|
||||
f"역할: {topic.get('role', 'flow')}",
|
||||
f"레이어: {topic.get('layer', 'core')}",
|
||||
]
|
||||
# Phase M: purpose, relation_type, expression_hint 추가
|
||||
if topic.get("purpose"):
|
||||
parts.append(f"목적: {topic['purpose']}")
|
||||
if topic.get("relation_type"):
|
||||
parts.append(f"관계: {topic['relation_type']}")
|
||||
if topic.get("expression_hint"):
|
||||
parts.append(f"표현: {topic['expression_hint']}")
|
||||
if topic.get("content_type"):
|
||||
parts.append(f"콘텐츠: {topic['content_type']}")
|
||||
return ". ".join(p for p in parts if p)
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
Kei API를 통해 도메인 전문가로서 각 슬롯 텍스트를 정리한다.
|
||||
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
|
||||
|
||||
1차: Kei API (persona + RAG + 도메인 지식)
|
||||
fallback: Anthropic API 직접 호출
|
||||
Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -53,6 +52,21 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
|
||||
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
|
||||
- detail: 펼치면 보일 전체 내용
|
||||
|
||||
## purpose별 분량 원칙 (가이드라인)
|
||||
- 문제제기: max 100자 (2-3줄). 간결한 도입부. 장황하지 않게.
|
||||
- 근거사례: max 150자. 핵심만 짧게. 상세는 자세히보기.
|
||||
- 핵심전달: 200-400자. 충분히 구조화. 이것이 슬라이드의 주인공.
|
||||
- 용어정의: 각 용어 max 50자. sidebar에서 짧게 정의.
|
||||
- 결론강조: max 40자. 기억할 1문장.
|
||||
- 비교 블록 사용 시: 비교 목적(왜 비교하는가)을 첫 행 또는 상단에 요약.
|
||||
|
||||
## source 슬롯 규칙 (절대 규칙)
|
||||
- source 슬롯에는 반드시 정보원(출처)을 넣는다
|
||||
- 꼭지 제목, 주제어, 섹션명을 source에 넣지 마라
|
||||
- 출처가 원본에 없으면 source 슬롯을 비워라 (빈 문자열)
|
||||
- 올바른 예: '국토교통부, 2020', 'IBM, 2011'
|
||||
- 잘못된 예: '용어의 혼용', 'DX와 BIM 개념'
|
||||
|
||||
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
|
||||
|
||||
|
||||
@@ -103,33 +117,64 @@ async def fill_content(
|
||||
guide_lines = [f" {k}: ~{v}자" for k, v in char_guide.items()]
|
||||
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
|
||||
|
||||
# Phase O-4: 컨테이너 기반 블록 스펙 전달
|
||||
container_h = block.get("_container_height_px")
|
||||
if container_h:
|
||||
max_items = block.get("_max_items", "제한 없음")
|
||||
max_chars_item = block.get("_max_chars_per_item", "제한 없음")
|
||||
max_chars_total = block.get("_max_chars_total", "제한 없음")
|
||||
font_size = block.get("_font_size_px", 15.2)
|
||||
req_text += (
|
||||
f"\n ★ 컨테이너 제약 (절대 준수):"
|
||||
f"\n - 컨테이너 높이: {container_h}px"
|
||||
f"\n - 최대 항목 수: {max_items}개"
|
||||
f"\n - 항목당 최대 글자 수: {max_chars_item}자"
|
||||
f"\n - 총 최대 글자 수: {max_chars_total}자"
|
||||
f"\n - 폰트 크기: {font_size}px"
|
||||
f"\n 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라."
|
||||
)
|
||||
|
||||
slot_requirements.append(req_text)
|
||||
|
||||
page_label = ""
|
||||
if len(layout_concept.get("pages", [])) > 1:
|
||||
page_label = f" (페이지 {page_idx + 1}/{len(layout_concept['pages'])})"
|
||||
|
||||
# Phase M: 토픽별 source 정보 추출 (P-9 원본 보존 강화)
|
||||
source_section = ""
|
||||
if analysis:
|
||||
source_lines = []
|
||||
for topic in analysis.get("topics", []):
|
||||
tid = topic.get("id")
|
||||
hint = topic.get("source_hint", "")
|
||||
data = topic.get("source_data", "")
|
||||
if hint or data:
|
||||
source_lines.append(
|
||||
f"- 토픽 {tid} ({topic.get('purpose', '')}): "
|
||||
f"{hint}{' / ' + data if data else ''}"
|
||||
)
|
||||
if source_lines:
|
||||
source_section = (
|
||||
"\n\n## 토픽별 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)\n"
|
||||
+ "\n".join(source_lines)
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"## 원본 콘텐츠\n{content}\n\n"
|
||||
f"## 블록 배치{page_label}\n"
|
||||
+ "\n".join(slot_requirements)
|
||||
+ source_section
|
||||
+ "\n\n## 요청\n"
|
||||
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
|
||||
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
|
||||
"원본에서 추출하라. 재작성하지 마라. 축약만 허용.\n"
|
||||
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
|
||||
"형식:\n"
|
||||
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
|
||||
)
|
||||
|
||||
try:
|
||||
# Kei API만 사용. Sonnet fallback 없음.
|
||||
result_text = await _call_kei_editor(user_prompt)
|
||||
|
||||
# G-6: Kei API 실패 시 None 가드
|
||||
if result_text is None:
|
||||
logger.warning("Kei API 편집 실패. 기본값 적용.")
|
||||
_apply_defaults(blocks)
|
||||
continue
|
||||
# Kei API만 사용. fallback 없음. 성공할 때까지 무한 재시도.
|
||||
result_text = await _call_kei_editor_with_retry(user_prompt)
|
||||
|
||||
filled = _parse_json(result_text)
|
||||
|
||||
@@ -140,7 +185,14 @@ async def fill_content(
|
||||
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", {})
|
||||
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
|
||||
new_data = filled_block.get("data", {})
|
||||
preserved = {}
|
||||
if "data" in orig_block:
|
||||
for k in ("column_override",):
|
||||
if k in orig_block["data"]:
|
||||
preserved[k] = orig_block["data"][k]
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
matched = True
|
||||
break
|
||||
# 2차: area + type으로 매칭 (topic_id 없을 때)
|
||||
@@ -151,7 +203,14 @@ async def fill_content(
|
||||
and orig_block.get("type") == filled_block.get("type")
|
||||
and "data" not in orig_block
|
||||
):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
|
||||
new_data = filled_block.get("data", {})
|
||||
preserved = {}
|
||||
if "data" in orig_block:
|
||||
for k in ("column_override",):
|
||||
if k in orig_block["data"]:
|
||||
preserved[k] = orig_block["data"][k]
|
||||
orig_block["data"] = {**new_data, **preserved}
|
||||
break
|
||||
|
||||
logger.info(
|
||||
@@ -159,107 +218,63 @@ async def fill_content(
|
||||
f"{len(filled['blocks'])}개 블록"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
|
||||
_apply_defaults(blocks)
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 재시도 필요하지만 텍스트는 받았으므로 진행.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"텍스트 편집자 호출 실패: {e}", exc_info=True)
|
||||
_apply_defaults(blocks)
|
||||
raise
|
||||
|
||||
return layout_concept
|
||||
|
||||
|
||||
async def _call_kei_editor(prompt: str) -> str | None:
|
||||
"""Kei API를 통해 텍스트 편집을 요청한다. SSE 스트리밍으로 실시간 수신.
|
||||
async def _call_kei_editor_with_retry(prompt: str) -> str:
|
||||
"""Kei API를 통해 텍스트 편집을 요청한다. 성공할 때까지 무한 재시도.
|
||||
|
||||
Kei persona의 도메인 지식 + RAG를 활용하여
|
||||
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
|
||||
fallback 없음. Kei API가 응답할 때까지 기다린다.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
full_prompt = EDITOR_PROMPT + "\n\n" + prompt
|
||||
RETRY_INTERVAL = 10
|
||||
attempt = 0
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-editor",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
|
||||
return None
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-editor",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei API (editor) HTTP {response.status_code} (시도 {attempt})")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
full_text = await stream_sse_tokens(response)
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if full_text:
|
||||
return full_text
|
||||
if full_text:
|
||||
return full_text
|
||||
|
||||
logger.warning("Kei API (editor) 텍스트 추출 실패")
|
||||
return None
|
||||
logger.warning(f"Kei API (editor) 텍스트 추출 실패 (시도 {attempt})")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei API (editor) 호출 실패: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei API (editor) 호출 실패 (시도 {attempt}): {e}")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
|
||||
|
||||
|
||||
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
|
||||
"""실패 시 기본 데이터 적용."""
|
||||
defaults = {
|
||||
# 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-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": []},
|
||||
# emphasis/
|
||||
"quote-big-mark": {"quote_text": "(인용)"},
|
||||
"quote-question": {"question": "(질문)"},
|
||||
"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": ""},
|
||||
}
|
||||
for block in blocks:
|
||||
if "data" not in block:
|
||||
block["data"] = defaults.get(block.get("type", ""), {})
|
||||
# _apply_defaults 삭제됨 — Kei API 무한 재시도로 fallback 불필요.
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
|
||||
@@ -11,7 +11,6 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
@@ -446,90 +445,9 @@ def _load_catalog() -> str:
|
||||
- banner-gradient: 섹션 강조 배너."""
|
||||
|
||||
|
||||
STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다.
|
||||
당신의 핵심 역할: **컨테이너(zone)의 크기 예산 안에서** 블록을 배정하는 것이다.
|
||||
|
||||
## 슬라이드 물리적 제약 (절대 조건)
|
||||
- 프레임: 1280×720px (16:9 고정)
|
||||
- 패딩: 상하좌우 40px → 가용 영역: 1200×640px
|
||||
- 블록 간 간격: 20px
|
||||
- **overflow: hidden** — 넘치는 콘텐츠는 잘려서 보이지 않는다!
|
||||
|
||||
## 선택된 레이아웃 프리셋: {preset_name}
|
||||
{preset_description}
|
||||
|
||||
### CSS Grid (변경하지 마라):
|
||||
grid-template-areas: {grid_areas}
|
||||
grid-template-columns: {grid_columns}
|
||||
grid-template-rows: {grid_rows}
|
||||
|
||||
### Zone별 컨테이너 예산:
|
||||
{zone_descriptions}
|
||||
|
||||
## ★ 사고 순서 (반드시 이 순서로 판단하라)
|
||||
|
||||
### 1단계: 컨테이너 크기 확인
|
||||
위 zone별 높이 예산(px)과 너비(%)를 확인한다. 이것이 절대 제약이다.
|
||||
header/footer는 고정이므로 건드리지 않는다.
|
||||
|
||||
### 2단계: 꼭지 → zone 배정
|
||||
- flow 꼭지 → body / left / hero zone
|
||||
- reference 꼭지 → sidebar zone
|
||||
- conclusion 꼭지 → footer zone (banner-gradient 권장)
|
||||
|
||||
### 3단계: zone별 블록 선택 + 높이 예산 계산
|
||||
각 zone에 대해:
|
||||
a) 배정된 꼭지 수를 확인한다
|
||||
b) catalog에서 블록을 선택한다 (각 블록의 height_cost 확인!)
|
||||
c) 총 높이를 계산한다: Σ(블록 height_cost) + 간격(20px × (블록수-1))
|
||||
d) **총 높이 ≤ zone 예산** 인지 반드시 확인한다
|
||||
e) 초과 시: ① 더 작은(compact) 블록으로 교체 ② 꼭지를 다음 페이지로 분리
|
||||
|
||||
### 4단계: 최종 검증
|
||||
모든 zone의 블록 총 높이가 예산 이내인지 재확인한 후 출력한다.
|
||||
|
||||
## 블록 선택 규칙 (절대 규칙)
|
||||
- **아래 허용 목록에 있는 블록만 선택하라. 목록에 없는 블록은 절대 사용 금지.**
|
||||
- **텍스트 블록 우선** — 텍스트로 충분히 전달 가능하면 시각화(SVG) 블록 쓰지 마라
|
||||
- **시각화 블록은 높이 비용이 크다** — 한 zone에 시각화 블록은 최대 1개
|
||||
- 너비 35% 이하 zone(sidebar)에는 카드 1열, 시각화 블록 금지
|
||||
- catalog의 when/not_for와 height_cost를 반드시 읽고 선택
|
||||
- 같은 블록 타입 반복 금지 — 다양한 블록 활용
|
||||
- **section-title-with-bg는 body/sidebar/footer zone에서 사용 금지.** 이 블록은 자세히보기 전용 페이지 상단에만 사용.
|
||||
- 각 꼭지의 relation_type과 expression_hint를 보고 적합한 블록을 선택하라
|
||||
|
||||
## purpose 기반 블록 선택 가이드 (참고, 강제 아님)
|
||||
각 꼭지의 purpose에 맞는 블록 계열을 선택하라:
|
||||
- 문제제기 → callout-warning, quote-big-mark, quote-question
|
||||
- 근거사례 → quote-big-mark (출처 포함), card-icon-desc (항목 나열)
|
||||
- 핵심전달 → comparison-2col, compare-pill-pair, compare-2col-split
|
||||
- 용어정의 → card-icon-desc (정의+출처), card-numbered (순서 있으면)
|
||||
- 결론강조 → banner-gradient (footer)
|
||||
- 구조시각화 → venn-diagram (단독 배치)
|
||||
|
||||
## 허용된 블록 id 목록 (이 목록에 없는 블록은 절대 선택하지 마라)
|
||||
{allowed_ids}
|
||||
|
||||
## 블록 상세 설명 (위 목록의 when/not_for 참고)
|
||||
{catalog}
|
||||
|
||||
## 출력 형식 (반드시 JSON만. 설명 없이.)
|
||||
grid는 이미 확정되었으므로 출력하지 마라. blocks 배열만 출력한다.
|
||||
```json
|
||||
{{{{
|
||||
"blocks": [
|
||||
{{{{
|
||||
"area": "zone이름",
|
||||
"type": "블록타입",
|
||||
"topic_id": 1,
|
||||
"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화",
|
||||
"reason": "이유",
|
||||
"size": "small|medium|large",
|
||||
"char_guide": {{{{"slot": 글자수}}}}
|
||||
}}}}
|
||||
]
|
||||
}}}}
|
||||
```"""
|
||||
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
|
||||
# STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제.
|
||||
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
|
||||
|
||||
|
||||
async def _opus_block_recommendation(
|
||||
@@ -537,16 +455,16 @@ async def _opus_block_recommendation(
|
||||
block_candidates: str,
|
||||
preset_name: str,
|
||||
preset: dict[str, Any],
|
||||
container_specs: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""P2-C: Opus(Kei API)가 블록 후보에서 최종 블록을 추천한다.
|
||||
"""Phase O: Kei(Opus)가 컨테이너 제약을 보고 블록을 확정한다.
|
||||
|
||||
Kei API를 통해 Opus가 사고하여:
|
||||
- 각 꼭지에 가장 적합한 블록 선정
|
||||
- 배치 방향/크기 가이드 제시
|
||||
- 컨테이너 크기(px)에 맞는 블록 선정
|
||||
- height_cost가 컨테이너보다 큰 블록은 선택 금지
|
||||
- 도메인 지식 기반 판단
|
||||
|
||||
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
|
||||
fallback: None 반환 → Step B(Sonnet)가 직접 선택.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
@@ -563,6 +481,20 @@ async def _opus_block_recommendation(
|
||||
for t in analysis.get("topics", [])
|
||||
)
|
||||
|
||||
# Phase O: 컨테이너 제약 텍스트
|
||||
container_text = ""
|
||||
if container_specs:
|
||||
from src.space_allocator import ContainerSpec
|
||||
lines = ["## 컨테이너 제약 (반드시 준수)\n각 꼭지는 아래 컨테이너 안에 들어가야 한다. height_cost가 허용 범위를 초과하면 선택 금지.\n"]
|
||||
for role, spec in container_specs.items():
|
||||
for tid in spec.topic_ids:
|
||||
lines.append(
|
||||
f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
|
||||
f"허용 height_cost: **{spec.max_height_cost} 이하**, "
|
||||
f"최대 항목 수: {spec.block_constraints.get('max_items', '?')}개"
|
||||
)
|
||||
container_text = "\n".join(lines) + "\n\n"
|
||||
|
||||
prompt = (
|
||||
f"슬라이드 디자인 블록 추천을 해줘.\n\n"
|
||||
f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
|
||||
@@ -572,12 +504,13 @@ async def _opus_block_recommendation(
|
||||
f"- reference 꼭지 → sidebar zone\n"
|
||||
f"- conclusion 꼭지 → **반드시 footer zone** (banner-gradient 권장)\n"
|
||||
f"- sidebar(35%)에는 시각화 블록 금지\n\n"
|
||||
f"{container_text}"
|
||||
f"## 꼭지 목록\n{topics_text}\n\n"
|
||||
f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
|
||||
f"## 요청\n"
|
||||
f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
|
||||
f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택하고,\n"
|
||||
f"zone별 높이 예산을 고려하여 배치 방향과 크기 가이드를 제시해.\n\n"
|
||||
f"컨테이너 높이(px)와 허용 height_cost를 반드시 확인하고,\n"
|
||||
f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택해.\n\n"
|
||||
f"## 출력 형식 (JSON만)\n"
|
||||
f'{{"recommendations": ['
|
||||
f'{{"topic_id": 1, "block_type": "...", "area": "...", '
|
||||
@@ -627,6 +560,7 @@ async def _opus_block_recommendation(
|
||||
async def create_layout_concept(
|
||||
content: str,
|
||||
analysis: dict[str, Any],
|
||||
container_specs: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""2단계: Step A(프리셋) + Step B(블록 매핑).
|
||||
|
||||
@@ -641,231 +575,153 @@ async def create_layout_concept(
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS[preset_name]
|
||||
|
||||
# Step B: 프리셋 내 블록 매핑 (Sonnet)
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# P2-A: FAISS 검색으로 관련 블록만 추출 (fallback: catalog 전문)
|
||||
# P2-A: FAISS 검색으로 관련 블록만 추출
|
||||
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)가 블록 추천
|
||||
# Phase N-1: Step A-2 — Kei(Opus)가 블록 확정. Sonnet은 zone + char_guide만.
|
||||
opus_recommendation = await _opus_block_recommendation(
|
||||
analysis, catalog_text, preset_name, preset
|
||||
analysis, catalog_text, preset_name, preset,
|
||||
container_specs=container_specs,
|
||||
)
|
||||
opus_hint = ""
|
||||
|
||||
# Kei 확정 블록 매핑 (topic_id → block_type)
|
||||
kei_confirmed_blocks: dict[int, str] = {}
|
||||
kei_confirmed_areas: dict[int, str] = {}
|
||||
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에 전달")
|
||||
# Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 체크
|
||||
tid = rec.get("topic_id") or rec.get("id")
|
||||
if tid is not None:
|
||||
kei_confirmed_blocks[tid] = rec.get("block_type", "")
|
||||
kei_confirmed_areas[tid] = rec.get("area", "")
|
||||
logger.info(f"[Step A-2] Kei 블록 확정: {kei_confirmed_blocks}")
|
||||
else:
|
||||
logger.info("[Step A-2] Opus 추천 없음 (Kei API 미연결 또는 실패). Step B가 직접 선택.")
|
||||
|
||||
# zone 설명 텍스트 (높이 예산 + 너비 포함)
|
||||
zone_desc = "\n".join(
|
||||
f"- {name}: {z['desc']} [높이 예산: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
|
||||
for name, z in preset["zones"].items()
|
||||
)
|
||||
|
||||
# 꼭지 요약
|
||||
topics_summary = []
|
||||
for t in analysis.get("topics", []):
|
||||
role = t.get("role", "flow")
|
||||
line = (
|
||||
f"꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
|
||||
f"[{t.get('layer', '?')}, ROLE:{role}, "
|
||||
f"강조:{t.get('emphasis', False)}, "
|
||||
f"관계:{t.get('relation_type', '?')}, "
|
||||
f"표현:{t.get('expression_hint', '?')}, "
|
||||
f"원본데이터:{t.get('source_data', '?')}]"
|
||||
)
|
||||
if t.get("detail_target"):
|
||||
line += " → ★detail_target (callout-solution으로 요약 배치 권장)"
|
||||
topics_summary.append(line)
|
||||
|
||||
# 허용 블록 ID 목록 생성 (catalog.yaml에 등록된 블록만)
|
||||
allowed_ids_list = _get_registered_block_ids()
|
||||
allowed_ids_str = ", ".join(sorted(allowed_ids_list))
|
||||
|
||||
system = STEP_B_PROMPT.format(
|
||||
preset_name=preset_name,
|
||||
preset_description=preset["description"],
|
||||
grid_areas=preset["grid_areas"],
|
||||
grid_columns=preset["grid_columns"],
|
||||
grid_rows=preset["grid_rows"],
|
||||
zone_descriptions=zone_desc,
|
||||
allowed_ids=allowed_ids_str,
|
||||
catalog=catalog_text,
|
||||
)
|
||||
|
||||
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"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
|
||||
f"JSON만."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2048,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
result_text = response.content[0].text
|
||||
concept = _parse_json(result_text)
|
||||
|
||||
# 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:
|
||||
# 블록 ID 검증: catalog에 없는 블록은 거부하고 안전한 대체 블록 사용
|
||||
registered_ids = _get_registered_block_ids()
|
||||
for block in blocks:
|
||||
block_type = block.get("type", "")
|
||||
if block_type and block_type not in registered_ids:
|
||||
purpose = block.get("purpose", "")
|
||||
fallback = PURPOSE_FALLBACK.get(purpose, "callout-solution")
|
||||
logger.warning(
|
||||
f"[Step B 검증] 미등록 블록 '{block_type}' 거부 → "
|
||||
f"'{fallback}'으로 교체 (purpose={purpose})"
|
||||
)
|
||||
block["type"] = fallback
|
||||
|
||||
# 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
|
||||
|
||||
# 6번: conclusion 꼭지 → footer zone 강제
|
||||
for block in blocks:
|
||||
topic = next(
|
||||
(t for t in analysis.get("topics", [])
|
||||
if t.get("id") == block.get("topic_id")),
|
||||
None,
|
||||
)
|
||||
if topic and topic.get("layer") == "conclusion":
|
||||
if block.get("area") != "footer":
|
||||
logger.warning(
|
||||
f"conclusion 꼭지 {block.get('topic_id')} → footer 강제 이동"
|
||||
)
|
||||
block["area"] = "footer"
|
||||
|
||||
# 5번: zone별 height_cost 합산 검증 (I-9: overflow 수집, 블록 교체 안 함)
|
||||
overflows = _validate_height_budget(blocks, preset)
|
||||
|
||||
logger.info(
|
||||
f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
|
||||
+ (f", overflow {len(overflows)}건" if overflows else "")
|
||||
# Kei API 필수. 응답 없으면 성공할 때까지 무한 재시도.
|
||||
import asyncio
|
||||
RETRY_INTERVAL = 10
|
||||
attempt = 0
|
||||
while not opus_recommendation or not opus_recommendation.get("recommendations"):
|
||||
attempt += 1
|
||||
logger.warning(f"[Step A-2] Kei API 응답 없음 (시도 {attempt}). {RETRY_INTERVAL}초 후 재시도...")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
opus_recommendation = await _opus_block_recommendation(
|
||||
analysis, catalog_text, preset_name, preset
|
||||
)
|
||||
result = {
|
||||
"title": analysis.get("title", "슬라이드"),
|
||||
"pages": [{
|
||||
"grid_areas": preset["grid_areas"],
|
||||
"grid_columns": preset["grid_columns"],
|
||||
"grid_rows": preset["grid_rows"],
|
||||
"blocks": blocks,
|
||||
}],
|
||||
}
|
||||
if overflows:
|
||||
result["overflow"] = overflows
|
||||
return result
|
||||
else:
|
||||
logger.warning("블록 매핑 JSON 파싱 실패. fallback.")
|
||||
# 재시도 성공 → 확정 블록 매핑
|
||||
for rec in opus_recommendation["recommendations"]:
|
||||
tid = rec.get("topic_id") or rec.get("id")
|
||||
if tid is not None:
|
||||
kei_confirmed_blocks[tid] = rec.get("block_type", "")
|
||||
kei_confirmed_areas[tid] = rec.get("area", "")
|
||||
logger.info(f"[Step A-2] Kei 블록 확정 (재시도 후): {kei_confirmed_blocks}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Step B 호출 실패: {e}", exc_info=True)
|
||||
# Phase O: Kei 확정 블록 + 코드 검증으로 직접 layout_concept 생성
|
||||
# Step B(Sonnet) 제거됨 — Kei가 블록/zone을 확정, 코드가 스펙 계산
|
||||
|
||||
# fallback: 프리셋 기반 기본 배치
|
||||
# (검증 함수는 아래에 정의)
|
||||
return _fallback_layout(analysis, preset_name, preset)
|
||||
|
||||
|
||||
def _fallback_layout(
|
||||
analysis: dict[str, Any],
|
||||
preset_name: str,
|
||||
preset: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Step B 실패 시 프리셋 기반 기본 배치."""
|
||||
blocks = []
|
||||
for topic in analysis.get("topics", []):
|
||||
registered_ids = _get_registered_block_ids()
|
||||
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 topic in topics:
|
||||
tid = topic.get("id")
|
||||
role = topic.get("role", "flow")
|
||||
|
||||
if role == "reference" and preset_name == "sidebar-right":
|
||||
area = "sidebar"
|
||||
elif topic.get("layer") == "conclusion":
|
||||
area = "footer"
|
||||
else:
|
||||
area = "body" if preset_name != "two-column" else "left"
|
||||
# 블록 타입: Kei 확정값
|
||||
block_type = kei_confirmed_blocks.get(tid, "topic-left-right")
|
||||
|
||||
# conclusion → banner-gradient, 그 외 → topic-left-right
|
||||
block_type = "banner-gradient" if topic.get("layer") == "conclusion" else "topic-left-right"
|
||||
# 블록 ID 검증: catalog에 없으면 에러 로그 (fallback 없음)
|
||||
if block_type not in registered_ids:
|
||||
logger.error(f"[블록 검증] Kei 확정 블록 '{block_type}'이 catalog에 없음. topic {tid}")
|
||||
block_type = "topic-left-right" # 최소 안전 블록
|
||||
|
||||
# zone 배치: Kei 확정값 → 검증
|
||||
area = kei_confirmed_areas.get(tid, "")
|
||||
if not area or area not in valid_zones:
|
||||
# Kei가 area를 안 줬으면 role에서 결정
|
||||
if role == "reference" and "sidebar" in valid_zones:
|
||||
area = "sidebar"
|
||||
elif topic.get("layer") == "conclusion" and "footer" in valid_zones:
|
||||
area = "footer"
|
||||
else:
|
||||
area = default_zone
|
||||
|
||||
# conclusion 꼭지 → footer 강제
|
||||
if topic.get("layer") == "conclusion" and "footer" in valid_zones:
|
||||
area = "footer"
|
||||
|
||||
# body/sidebar 금지 블록 검증
|
||||
if area in ("body", "left", "right", "hero", "detail") and block_type in BODY_FORBIDDEN_MAP:
|
||||
replacement = BODY_FORBIDDEN_MAP[block_type]
|
||||
if replacement:
|
||||
logger.warning(f"[블록 검증] body 금지 '{block_type}' → '{replacement}'")
|
||||
block_type = replacement
|
||||
else:
|
||||
continue # None이면 삭제
|
||||
|
||||
if area == "sidebar" and block_type in SIDEBAR_FORBIDDEN_BLOCKS:
|
||||
replacement = SIDEBAR_FORBIDDEN_BLOCKS[block_type]
|
||||
if replacement:
|
||||
logger.warning(f"[블록 검증] sidebar 금지 '{block_type}' → '{replacement}'")
|
||||
block_type = replacement
|
||||
else:
|
||||
continue
|
||||
|
||||
blocks.append({
|
||||
"area": area,
|
||||
"type": block_type,
|
||||
"topic_id": topic.get("id", 0),
|
||||
"reason": topic.get("title", ""),
|
||||
"topic_id": tid,
|
||||
"purpose": topic.get("purpose", ""),
|
||||
"reason": kei_confirmed_blocks.get(tid, ""),
|
||||
"size": "medium",
|
||||
})
|
||||
|
||||
return {
|
||||
# Phase N-2: sidebar에 reference 블록이 있으면 section label 자동 삽입
|
||||
sidebar_blocks = [b for b in blocks if b.get("area") == "sidebar"]
|
||||
if sidebar_blocks:
|
||||
first_sidebar = sidebar_blocks[0]
|
||||
sidebar_topic = next(
|
||||
(t for t in topics if t.get("id") == first_sidebar.get("topic_id")),
|
||||
None,
|
||||
)
|
||||
section_title = ""
|
||||
if sidebar_topic:
|
||||
section_title = sidebar_topic.get("section_title", "")
|
||||
if not section_title:
|
||||
purpose = first_sidebar.get("purpose", "")
|
||||
section_title = {
|
||||
"용어정의": "용어 정의",
|
||||
"근거사례": "참고 자료",
|
||||
}.get(purpose, "")
|
||||
|
||||
if section_title:
|
||||
first_sidebar_idx = next(
|
||||
i for i, b in enumerate(blocks) if b.get("area") == "sidebar"
|
||||
)
|
||||
blocks.insert(first_sidebar_idx, {
|
||||
"area": "sidebar",
|
||||
"type": "divider-text",
|
||||
"topic_id": None,
|
||||
"purpose": "_label",
|
||||
"data": {"text": section_title},
|
||||
"size": "compact",
|
||||
"_is_label": True,
|
||||
})
|
||||
logger.info(f"[N-2] sidebar 섹션 제목 삽입: '{section_title}'")
|
||||
|
||||
# zone별 height_cost 합산 검증
|
||||
overflows = _validate_height_budget(blocks, preset)
|
||||
|
||||
logger.info(
|
||||
f"[레이아웃] 블록 배치 완료: {preset_name}, {len(blocks)}개 블록"
|
||||
+ (f", overflow {len(overflows)}건" if overflows else "")
|
||||
)
|
||||
|
||||
result = {
|
||||
"title": analysis.get("title", "슬라이드"),
|
||||
"pages": [{
|
||||
"grid_areas": preset["grid_areas"],
|
||||
@@ -874,6 +730,9 @@ def _fallback_layout(
|
||||
"blocks": blocks,
|
||||
}],
|
||||
}
|
||||
if overflows:
|
||||
result["overflow"] = overflows
|
||||
return result
|
||||
|
||||
|
||||
# height_cost → px 변환 (결정론적)
|
||||
@@ -884,31 +743,30 @@ HEIGHT_COST_PX = {
|
||||
"xlarge": 400,
|
||||
}
|
||||
|
||||
# 미등록 블록 거부 시 purpose 기반 대체 (I-3)
|
||||
PURPOSE_FALLBACK = {
|
||||
"문제제기": "callout-warning",
|
||||
"근거사례": "quote-big-mark",
|
||||
"핵심전달": "comparison-2col",
|
||||
"용어정의": "card-icon-desc",
|
||||
"결론강조": "banner-gradient",
|
||||
"구조시각화": "card-icon-desc",
|
||||
}
|
||||
|
||||
# body/sidebar/footer zone에서 사용 금지인 블록 → 교체
|
||||
BODY_FORBIDDEN_MAP = {
|
||||
"section-title-with-bg": "topic-center", # 500px 블록 → compact 헤더로
|
||||
"section-header-bar": None, # body에서 제거 — header에 이미 slide-title 있음 (J-2)
|
||||
}
|
||||
|
||||
# xlarge/large → medium/compact 교체 후보
|
||||
DOWNGRADE_MAP = {
|
||||
"venn-diagram": "card-icon-desc",
|
||||
"card-step-vertical": "card-numbered",
|
||||
"image-grid-2x2": "image-row-2col",
|
||||
"compare-3col-badge": "comparison-2col",
|
||||
"card-image-3col": "card-icon-desc",
|
||||
"card-tag-image": "card-icon-desc",
|
||||
"card-compare-3col": "comparison-2col",
|
||||
"card-image-round": "card-icon-desc",
|
||||
# Phase M: 블록-zone 적합성 맵
|
||||
# sidebar(35% 너비)에서 사용 불가한 블록 → 대체 블록
|
||||
SIDEBAR_FORBIDDEN_BLOCKS = {
|
||||
"card-compare-3col": "card-numbered",
|
||||
"card-dark-overlay": "card-numbered",
|
||||
"card-icon-desc": "card-numbered",
|
||||
"card-image-3col": "card-numbered",
|
||||
"card-image-round": "card-numbered",
|
||||
"card-stat-number": "card-numbered",
|
||||
"card-tag-image": "card-numbered",
|
||||
"comparison-2col": "dark-bullet-list",
|
||||
"compare-2col-split": "dark-bullet-list",
|
||||
"compare-pill-pair": "dark-bullet-list",
|
||||
"section-title-with-bg": None,
|
||||
"section-header-bar": None,
|
||||
"topic-center": "topic-left-right",
|
||||
"quote-big-mark": "quote-question",
|
||||
"image-full-caption": "image-row-2col",
|
||||
}
|
||||
|
||||
|
||||
@@ -932,14 +790,58 @@ def _load_catalog_map_for_height() -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_catalog_purpose_fit() -> dict[str, list[str]]:
|
||||
"""catalog.yaml에서 id → purpose_fit 매핑을 로드."""
|
||||
catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
|
||||
if not catalog_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(catalog_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return {
|
||||
b["id"]: b.get("purpose_fit", [])
|
||||
for b in data.get("blocks", [])
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _validate_purpose_fit(blocks: list[dict]) -> int:
|
||||
"""각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다.
|
||||
|
||||
Returns:
|
||||
교체된 블록 수.
|
||||
"""
|
||||
purpose_fit_map = _load_catalog_purpose_fit()
|
||||
replaced = 0
|
||||
|
||||
for block in blocks:
|
||||
block_type = block.get("type", "")
|
||||
purpose = block.get("purpose", "")
|
||||
if not block_type or not purpose:
|
||||
continue
|
||||
|
||||
allowed_purposes = purpose_fit_map.get(block_type, [])
|
||||
# purpose_fit이 빈 리스트면 범용 블록 → 검증 스킵
|
||||
if not allowed_purposes:
|
||||
continue
|
||||
|
||||
if purpose not in allowed_purposes:
|
||||
# Kei가 확정한 블록이므로 경고만 출력. 강제 교체 안 함.
|
||||
logger.warning(
|
||||
f"[purpose_fit 검증] '{block_type}'의 purpose_fit={allowed_purposes}에 "
|
||||
f"'{purpose}' 없음 — Kei 확정이므로 유지"
|
||||
)
|
||||
|
||||
return replaced
|
||||
|
||||
|
||||
def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
"""zone별 height_cost 합산을 검증한다. (I-9 개정)
|
||||
|
||||
금지 블록 교체, pill-pair 단독 검증은 수행하되,
|
||||
높이 초과 시 블록을 자동 교체하지 않는다.
|
||||
대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
|
||||
DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 사용.
|
||||
|
||||
Returns:
|
||||
overflow 정보 리스트. 초과 없으면 빈 리스트.
|
||||
"""
|
||||
@@ -954,16 +856,55 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
zone_blocks[area] = []
|
||||
zone_blocks[area].append(block)
|
||||
|
||||
# 금지 블록 교체 (body/sidebar/footer에서 사용 불가한 블록)
|
||||
# 금지 블록 처리: 교체 또는 삭제 (J-2: None이면 삭제)
|
||||
blocks_to_remove = []
|
||||
for block in blocks:
|
||||
area = block.get("area", "body")
|
||||
block_type = block.get("type", "")
|
||||
if area != "header" and block_type in BODY_FORBIDDEN_MAP:
|
||||
replacement = BODY_FORBIDDEN_MAP[block_type]
|
||||
logger.warning(
|
||||
f"[금지 블록 교체] {block_type} → {replacement} (area={area})"
|
||||
)
|
||||
block["type"] = replacement
|
||||
if replacement is None:
|
||||
blocks_to_remove.append(block)
|
||||
logger.warning(
|
||||
f"[금지 블록 삭제] {block_type} (area={area})"
|
||||
)
|
||||
else:
|
||||
block["type"] = replacement
|
||||
logger.warning(
|
||||
f"[금지 블록 교체] {block_type} → {replacement} (area={area})"
|
||||
)
|
||||
for block in blocks_to_remove:
|
||||
blocks.remove(block)
|
||||
|
||||
# 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
|
||||
zone_blocks.clear()
|
||||
for block in blocks:
|
||||
area = block.get("area", "body")
|
||||
if area not in zone_blocks:
|
||||
zone_blocks[area] = []
|
||||
zone_blocks[area].append(block)
|
||||
|
||||
# Phase M: sidebar 블록-zone 적합성 검증 (P-6)
|
||||
for block in blocks:
|
||||
if block.get("area") == "sidebar" and block.get("type") in SIDEBAR_FORBIDDEN_BLOCKS:
|
||||
replacement = SIDEBAR_FORBIDDEN_BLOCKS[block["type"]]
|
||||
if replacement is None:
|
||||
logger.warning(f"[zone 적합성] sidebar에서 {block['type']} 삭제")
|
||||
else:
|
||||
logger.warning(f"[zone 적합성] sidebar: {block['type']} → {replacement}")
|
||||
block["type"] = replacement
|
||||
|
||||
# sidebar 카드 블록 1열 강제 (J-6)
|
||||
CARD_BLOCKS = {
|
||||
"card-tag-image", "card-icon-desc", "card-image-3col",
|
||||
"card-dark-overlay", "card-compare-3col", "card-image-round",
|
||||
"card-stat-number",
|
||||
}
|
||||
for block in blocks:
|
||||
if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
|
||||
if "data" not in block:
|
||||
block["data"] = {}
|
||||
block["data"]["column_override"] = 1
|
||||
|
||||
# compare-pill-pair 단독 사용 금지 (I-7)
|
||||
COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
|
||||
@@ -977,7 +918,7 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
"[pill-pair 단독 금지] compare-pill-pair → comparison-2col"
|
||||
)
|
||||
|
||||
# 높이 예산 검증 — 초과 시 overflow 정보 수집 (블록 교체 안 함)
|
||||
# 높이 예산 검증 — 초과 시 자동 조치 + overflow 정보 수집
|
||||
overflows: list[dict] = []
|
||||
for area, area_blocks in zone_blocks.items():
|
||||
zone_info = zones.get(area, {})
|
||||
@@ -989,6 +930,29 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
if total <= budget:
|
||||
continue
|
||||
|
||||
overflow_px = total - budget
|
||||
|
||||
# footer 초과 자동 조치: banner-gradient의 sub_text 제거로 높이 축소
|
||||
if area == "footer" and overflow_px <= 30:
|
||||
for block in area_blocks:
|
||||
if block.get("type") == "banner-gradient":
|
||||
if "data" not in block:
|
||||
block["data"] = {}
|
||||
block["data"]["_strip_sub_text"] = True
|
||||
logger.info(
|
||||
f"[높이 자동 조치] footer 초과 {overflow_px}px → "
|
||||
f"banner-gradient sub_text 제거"
|
||||
)
|
||||
# sub_text 제거 시 compact(50px)로 줄어들므로 재계산
|
||||
total_after = sum(
|
||||
50 if (b.get("type") == "banner-gradient" and b.get("data", {}).get("_strip_sub_text"))
|
||||
else _get_block_height(b.get("type", ""))
|
||||
for b in area_blocks
|
||||
)
|
||||
total_after += gap_px * max(0, len(area_blocks) - 1)
|
||||
if total_after <= budget:
|
||||
continue # 조치 후 예산 이내 → overflow 아님
|
||||
|
||||
logger.warning(
|
||||
f"[높이 예산 초과] {area}: {total}px > {budget}px. "
|
||||
f"블록: {[b.get('type') for b in area_blocks]}"
|
||||
@@ -1013,42 +977,6 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
return overflows
|
||||
|
||||
|
||||
def _downgrade_fallback(blocks: list[dict], overflows: list[dict]) -> None:
|
||||
"""Kei API 실패 시 비상용 기계적 블록 교체.
|
||||
|
||||
기존 DOWNGRADE_MAP 로직. 정상 경로가 아닌 비상 경로.
|
||||
"""
|
||||
for overflow in overflows:
|
||||
area = overflow["area"]
|
||||
area_blocks = [b for b in blocks if b.get("area") == area]
|
||||
area_blocks.sort(
|
||||
key=lambda b: _get_block_height(b.get("type", "")), reverse=True
|
||||
)
|
||||
|
||||
total = overflow["total_px"]
|
||||
budget = overflow["budget_px"]
|
||||
|
||||
for block in area_blocks:
|
||||
block_type = block.get("type", "")
|
||||
block_height = _get_block_height(block_type)
|
||||
|
||||
if block_type in DOWNGRADE_MAP and block_height >= 250:
|
||||
replacement = DOWNGRADE_MAP[block_type]
|
||||
old_height = block_height
|
||||
new_height = _get_block_height(replacement)
|
||||
|
||||
block["type"] = replacement
|
||||
total = total - old_height + new_height
|
||||
|
||||
logger.warning(
|
||||
f"[DOWNGRADE 비상] {block_type}({old_height}px) → "
|
||||
f"{replacement}({new_height}px). 잔여: {total}px/{budget}px"
|
||||
)
|
||||
|
||||
if total <= budget:
|
||||
break
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).
|
||||
|
||||
1차: Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
|
||||
fallback: Kei API 실패 시 Anthropic API 직접 호출.
|
||||
Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
|
||||
Kei API는 필수. fallback 없음. 성공할 때까지 무한 재시도.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,13 +28,20 @@ KEI_PROMPT = (
|
||||
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
|
||||
"- info_structure 필드에 기술.\n\n"
|
||||
"## 3단계: 슬라이드 스토리라인 설계\n"
|
||||
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘. 각 위치에 **목적(purpose)**을 부여:\n"
|
||||
"- 문제제기: 왜 이 주제가 중요한가? 현재 무엇이 잘못되고 있는가?\n"
|
||||
"- 근거사례: 문제의 근거, 사례, 증거 (출처 포함)\n"
|
||||
"- 핵심전달: 그래서 사실은 이거다. 핵심 내용 전달.\n"
|
||||
"- 용어정의: 사용된 용어를 구체적으로 설명 (보조 참조, sidebar 배치)\n"
|
||||
"- 결론강조: 핵심 메시지 강조. 슬라이드 하단.\n"
|
||||
"- 구조시각화: 관계도, 프로세스 등 시각화가 필요한 경우\n\n"
|
||||
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
||||
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
||||
"## 4단계: 페이지 구조 판단 (비중 시스템)\n"
|
||||
"콘텐츠를 분석하여 이 페이지의 **구조와 비중**을 판단하라:\n\n"
|
||||
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.\n"
|
||||
" 비교라면 비교표, 관계라면 관계도, 프로세스라면 흐름도로 구조화.\n"
|
||||
" 비교 구조일 때 비교 목적(왜 비교하는가)을 summary에 명시.\n"
|
||||
"- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게. 2-3줄이면 충분.\n"
|
||||
"- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.\n"
|
||||
" role: 'reference'로 표시. 본문 흐름을 방해하지 않도록.\n"
|
||||
"- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.\n\n"
|
||||
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
|
||||
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
||||
"page_structure 필드에 기록.\n\n"
|
||||
"## 원본 텍스트 보존 원칙\n"
|
||||
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
|
||||
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
|
||||
@@ -54,12 +61,18 @@ KEI_PROMPT = (
|
||||
'"core_message": "이 슬라이드의 핵심 메시지 한 줄", '
|
||||
'"total_pages": 1, '
|
||||
'"info_structure": "정보 구조 설명", '
|
||||
'"page_structure": {'
|
||||
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
|
||||
'"배경": {"topic_ids": [1], "weight": 0.15}, '
|
||||
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
|
||||
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
|
||||
'"topics": ['
|
||||
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
||||
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
|
||||
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
|
||||
'"layer": "intro|core|supporting|conclusion", '
|
||||
'"role": "flow|reference", '
|
||||
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
|
||||
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||
'"content_type": "text|image|table|mixed", '
|
||||
'"detail_target": false, "page": 1}], '
|
||||
@@ -73,8 +86,7 @@ KEI_PROMPT = (
|
||||
async def classify_content(content: str) -> dict[str, Any] | None:
|
||||
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
|
||||
|
||||
Kei API만 사용. Sonnet fallback 없음.
|
||||
Kei API 실패 시 None 반환 → pipeline.py에서 manual_classify() 안전망.
|
||||
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
|
||||
"""
|
||||
result = await _call_kei_api(content)
|
||||
if result:
|
||||
@@ -84,7 +96,7 @@ async def classify_content(content: str) -> dict[str, Any] | None:
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning("[Kei API] 꼭지 추출 실패. manual_classify로 안전망 적용.")
|
||||
logger.error("[Kei API] 꼭지 추출 실패. Kei API(localhost:8000) 확인 필요.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -127,9 +139,11 @@ async def refine_concepts(
|
||||
"""1단계-B: 각 꼭지의 컨셉을 구체화한다.
|
||||
|
||||
1단계-A 결과(topics)를 받아서, 각 꼭지의 관계 성격/표현 방법/원본 데이터를 판단.
|
||||
Kei API만 사용. 실패 시 1단계-A 결과를 그대로 반환 (pipeline 안 멈춤).
|
||||
Kei API만 사용. fallback 없음. 성공할 때까지 재시도.
|
||||
1회 호출로 모든 꼭지를 한꺼번에 처리.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
topics = analysis.get("topics", [])
|
||||
if not topics:
|
||||
return analysis
|
||||
@@ -150,48 +164,62 @@ async def refine_concepts(
|
||||
)
|
||||
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
RETRY_INTERVAL = 10
|
||||
attempt = 0
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-refine",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}")
|
||||
return analysis
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-refine",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code} (시도 {attempt})")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
full_text = await stream_sse_tokens(response)
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if not full_text:
|
||||
logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.")
|
||||
return analysis
|
||||
if not full_text:
|
||||
logger.warning(f"[1단계-B] 응답 텍스트 없음 (시도 {attempt})")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
result = _parse_json(full_text)
|
||||
if result and "concepts" in result:
|
||||
# topics에 concept 정보 병합
|
||||
concept_map = {c.get("topic_id"): c for c in result["concepts"]}
|
||||
for topic in topics:
|
||||
concept = concept_map.get(topic.get("id"))
|
||||
if concept:
|
||||
topic["relation_type"] = concept.get("relation_type", "")
|
||||
topic["expression_hint"] = concept.get("expression_hint", "")
|
||||
topic["source_data"] = concept.get("source_data", "")
|
||||
result = _parse_json(full_text)
|
||||
if result and "concepts" in result:
|
||||
# topics에 concept 정보 병합
|
||||
# Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 다 체크
|
||||
concept_map = {}
|
||||
for c in result["concepts"]:
|
||||
tid = c.get("topic_id") or c.get("id")
|
||||
if tid is not None:
|
||||
concept_map[tid] = c
|
||||
for topic in topics:
|
||||
concept = concept_map.get(topic.get("id"))
|
||||
if concept:
|
||||
topic["relation_type"] = concept.get("relation_type", "")
|
||||
topic["expression_hint"] = concept.get("expression_hint", "")
|
||||
topic["source_data"] = concept.get("source_data", "")
|
||||
|
||||
logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}개")
|
||||
else:
|
||||
logger.warning(f"[1단계-B] JSON 파싱 실패. 1단계-A 결과 유지. 텍스트: {full_text[:200]}")
|
||||
logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}개")
|
||||
return analysis
|
||||
else:
|
||||
logger.warning(f"[1단계-B] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[1단계-B] Kei API 실패: {e}. 1단계-A 결과 유지.")
|
||||
|
||||
return analysis
|
||||
except Exception as e:
|
||||
logger.warning(f"[1단계-B] Kei API 실패 (시도 {attempt}): {e}")
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
|
||||
async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||||
@@ -234,6 +262,156 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# J-7: Kei 최종 검수
|
||||
# ──────────────────────────────────────
|
||||
|
||||
KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.
|
||||
|
||||
## 검수 관점
|
||||
1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
|
||||
2. 콘텐츠 흐름이 블록 배치와 일치하는가?
|
||||
3. 각 블록이 해당 꼭지의 purpose에 적합한가?
|
||||
4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
|
||||
5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
|
||||
- 텍스트 축약으로 해결 가능 → shrink
|
||||
- 콘텐츠가 본질적으로 큼 → overflow_detected
|
||||
6. **핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?**
|
||||
- 핵심전달 블록이 도입부(문제제기+근거사례)보다 작으면 → rewrite로 비중 조정
|
||||
7. **문제제기가 간결한가? (100자 이내)**
|
||||
- 초과 시 → shrink (target_ratio: 0.5)
|
||||
8. **용어정의가 sidebar에 있는가?**
|
||||
- body에 있으면 → 구조 문제 지적 (issues에 명시)
|
||||
9. **핵심전달 블록이 화면 안에 보이는가?**
|
||||
- 잘리면 → overflow_detected
|
||||
|
||||
## 조정 action
|
||||
- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
|
||||
- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
|
||||
- rewrite: 텍스트 재작성 (detail에 방향)
|
||||
- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)
|
||||
|
||||
## 출력 (JSON만. 설명 없이.)
|
||||
{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", "target_ratio": 1.3, "detail": "..."}]}
|
||||
"""
|
||||
|
||||
|
||||
async def call_kei_final_review(
|
||||
html: str,
|
||||
block_summary: list[str],
|
||||
zone_budget_text: str,
|
||||
overflow_hint_text: str,
|
||||
analysis: dict[str, Any] | None = None,
|
||||
screenshot_b64: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Phase N-4: Kei(Opus)가 스크린샷을 보고 최종 검수한다.
|
||||
|
||||
스크린샷이 있으면: Anthropic API 직접 호출 (Opus + 멀티모달)
|
||||
스크린샷이 없으면: Kei API 경유 (텍스트 기반)
|
||||
어느 경로든 Kei(Opus)가 판단. Sonnet 절대 금지.
|
||||
"""
|
||||
import anthropic
|
||||
|
||||
core_message = analysis.get("core_message", "") if analysis else ""
|
||||
topics_summary = ""
|
||||
if analysis:
|
||||
topics_summary = "\n".join(
|
||||
f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
|
||||
for t in analysis.get("topics", [])
|
||||
)
|
||||
|
||||
review_text = (
|
||||
f"## 핵심 메시지\n{core_message}\n\n"
|
||||
f"## 꼭지 목록\n{topics_summary}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
|
||||
zone_budget_text +
|
||||
overflow_hint_text +
|
||||
f"\n\n위 슬라이드를 검수하고 조정이 필요한지 판단해. JSON만."
|
||||
)
|
||||
|
||||
# 스크린샷이 있으면: Opus 직접 호출 + 이미지 전달
|
||||
if screenshot_b64:
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
max_tokens=4096,
|
||||
system=KEI_REVIEW_PROMPT,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": screenshot_b64,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": review_text,
|
||||
},
|
||||
],
|
||||
}],
|
||||
)
|
||||
|
||||
result_text = response.content[0].text
|
||||
result = _parse_json(result_text)
|
||||
if result and "needs_adjustment" in result:
|
||||
logger.info(
|
||||
f"[Kei 최종 검수] 스크린샷 기반, needs_adjustment={result['needs_adjustment']}"
|
||||
)
|
||||
return result
|
||||
logger.warning("[Kei 최종 검수] 스크린샷 기반 JSON 파싱 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei 최종 검수 (스크린샷) 실패: {e}")
|
||||
return None
|
||||
|
||||
# 스크린샷 없으면: Kei API 경유 (텍스트 기반)
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
prompt = (
|
||||
KEI_REVIEW_PROMPT + "\n\n" + review_text +
|
||||
f"\n\n## 조립 HTML (요약)\n{html[:3000]}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-final-review",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
|
||||
return None
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if full_text:
|
||||
result = _parse_json(full_text)
|
||||
if result and "needs_adjustment" in result:
|
||||
logger.info(
|
||||
f"[Kei 최종 검수] 텍스트 기반, needs_adjustment={result['needs_adjustment']}"
|
||||
)
|
||||
return result
|
||||
logger.warning("[Kei 최종 검수] JSON 파싱 실패")
|
||||
return None
|
||||
|
||||
logger.warning("Kei 최종 검수 텍스트 추출 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei 최종 검수 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# I-9: Kei 넘침 판단 호출
|
||||
# ──────────────────────────────────────
|
||||
@@ -266,7 +444,7 @@ async def call_kei_overflow_judgment(
|
||||
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
|
||||
|
||||
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
|
||||
fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동.
|
||||
실패 시 None → pipeline에서 무한 재시도.
|
||||
"""
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
@@ -370,29 +548,4 @@ def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def manual_classify(content: str) -> dict[str, Any]:
|
||||
"""분류 실패 시 기본 구조 fallback."""
|
||||
return {
|
||||
"title": "슬라이드",
|
||||
"core_message": "",
|
||||
"total_pages": 1,
|
||||
"info_structure": "",
|
||||
"topics": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "핵심 내용",
|
||||
"summary": content[:100],
|
||||
"purpose": "핵심전달",
|
||||
"source_hint": "",
|
||||
"layer": "core",
|
||||
"role": "flow",
|
||||
"emphasis": False,
|
||||
"direction": "flexible",
|
||||
"content_type": "text",
|
||||
"detail_target": False,
|
||||
"page": 1,
|
||||
},
|
||||
],
|
||||
"images": [],
|
||||
"tables": [],
|
||||
}
|
||||
# manual_classify 삭제됨. Kei API는 필수. fallback 없음.
|
||||
|
||||
332
src/pipeline.py
332
src/pipeline.py
@@ -11,19 +11,60 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import anthropic
|
||||
|
||||
from src.kei_client import classify_content, manual_classify, refine_concepts, call_kei_overflow_judgment
|
||||
from src.design_director import create_layout_concept, LAYOUT_PRESETS, select_preset, _downgrade_fallback
|
||||
from src.kei_client import classify_content, refine_concepts, call_kei_overflow_judgment, call_kei_final_review
|
||||
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.space_allocator import calculate_container_specs, finalize_block_specs, find_container_for_topic, calculate_trim_chars
|
||||
from src.slide_measurer import measure_rendered_heights, format_measurement_for_kei, capture_slide_screenshot
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Kei API 재시도 간격(초). 제한 없음 — 성공할 때까지 무한 재시도.
|
||||
KEI_RETRY_INTERVAL = 10
|
||||
|
||||
|
||||
async def _retry_kei(fn, *args, **kwargs):
|
||||
"""Kei API 호출을 성공할 때까지 무한 재시도한다.
|
||||
|
||||
Kei API는 필수 인프라. fallback 없음. 제한 없음.
|
||||
10분이든 20분이든 Kei가 응답할 때까지 기다린다.
|
||||
"""
|
||||
import asyncio
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
result = await fn(*args, **kwargs)
|
||||
if result is not None:
|
||||
if attempt > 1:
|
||||
logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
|
||||
return result
|
||||
logger.warning(
|
||||
f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}). "
|
||||
f"{KEI_RETRY_INTERVAL}초 후 재시도..."
|
||||
)
|
||||
await asyncio.sleep(KEI_RETRY_INTERVAL)
|
||||
|
||||
|
||||
def _save_step(run_dir: Path, filename: str, data: Any) -> None:
|
||||
"""스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
filepath = run_dir / filename
|
||||
if filename.endswith(".html"):
|
||||
filepath.write_text(data, encoding="utf-8")
|
||||
else:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"[중간 산출물] {filename} 저장 → {run_dir.name}/")
|
||||
|
||||
|
||||
async def generate_slide(
|
||||
content: str,
|
||||
@@ -35,6 +76,10 @@ async def generate_slide(
|
||||
Yields:
|
||||
SSE 이벤트: progress / result / error
|
||||
"""
|
||||
# K-1: 중간 산출물 저장용 디렉토리
|
||||
run_id = str(int(time.time() * 1000))
|
||||
run_dir = Path("data/runs") / run_id
|
||||
|
||||
try:
|
||||
# 1단계: Kei 실장 — 꼭지 추출 + 분석
|
||||
yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}
|
||||
@@ -42,18 +87,24 @@ async def generate_slide(
|
||||
if manual_layout:
|
||||
analysis = manual_layout
|
||||
else:
|
||||
analysis = await classify_content(content)
|
||||
if analysis is None:
|
||||
analysis = manual_classify(content)
|
||||
analysis = await _retry_kei(classify_content, content)
|
||||
# _retry_kei는 무한 재시도. None이 올 수 없다.
|
||||
|
||||
topic_count = len(analysis.get("topics", []))
|
||||
page_count = analysis.get("total_pages", 1)
|
||||
logger.info(f"1단계-A 완료: {topic_count}개 꼭지, {page_count}페이지")
|
||||
_save_step(run_dir, "step1_analysis.json", analysis)
|
||||
|
||||
# 1단계-B: 각 꼭지 컨셉 구체화
|
||||
yield {"event": "progress", "data": "1.5/5 Kei 실장이 각 꼭지의 컨셉을 구체화 중..."}
|
||||
analysis = await refine_concepts(content, analysis)
|
||||
logger.info("1단계-B 완료: 컨셉 구체화")
|
||||
_save_step(run_dir, "step1b_concepts.json", {
|
||||
"concepts": [
|
||||
{k: t.get(k) for k in ("id", "title", "purpose", "relation_type", "expression_hint", "source_data")}
|
||||
for t in analysis.get("topics", [])
|
||||
]
|
||||
})
|
||||
|
||||
# I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
|
||||
from difflib import SequenceMatcher
|
||||
@@ -75,10 +126,34 @@ async def generate_slide(
|
||||
analysis["image_sizes"] = image_sizes
|
||||
logger.info(f"이미지 측정: {len(image_sizes)}개")
|
||||
|
||||
# 2단계: 디자인 팀장 — Step A(프리셋) + Step B(블록 매핑)
|
||||
# ★ Phase O-1: 컨테이너 스펙 계산 (Kei 비중 → px 확정)
|
||||
preset_name = select_preset(analysis)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure=page_struct,
|
||||
topics=analysis.get("topics", []),
|
||||
preset=preset,
|
||||
slide_width=settings.slide_width,
|
||||
slide_height=settings.slide_height,
|
||||
)
|
||||
_save_step(run_dir, "step1c_containers.json", {
|
||||
role: {
|
||||
"height_px": spec.height_px,
|
||||
"width_px": spec.width_px,
|
||||
"max_height_cost": spec.max_height_cost,
|
||||
"topic_ids": spec.topic_ids,
|
||||
"weight": spec.weight,
|
||||
"block_constraints": spec.block_constraints,
|
||||
}
|
||||
for role, spec in container_specs.items()
|
||||
})
|
||||
|
||||
# 2단계: 디자인 팀장 — Step A(프리셋) + Step A-2(Kei 블록 확정) + Step B(zone 배치)
|
||||
yield {"event": "progress", "data": "2/5 디자인 팀장이 레이아웃을 설계 중..."}
|
||||
|
||||
layout_concept = await create_layout_concept(content, analysis)
|
||||
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
|
||||
|
||||
total_blocks = sum(
|
||||
len(p.get("blocks", [])) for p in layout_concept.get("pages", [])
|
||||
@@ -87,12 +162,59 @@ async def generate_slide(
|
||||
f"2단계 완료: {len(layout_concept.get('pages', []))}페이지, "
|
||||
f"{total_blocks}개 블록"
|
||||
)
|
||||
_save_step(run_dir, "step2_layout.json", {
|
||||
"preset": layout_concept.get("pages", [{}])[0].get("grid_areas", ""),
|
||||
"blocks": [
|
||||
{
|
||||
"area": b.get("area"), "type": b.get("type"),
|
||||
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
|
||||
"reason": b.get("reason", ""), "size": b.get("size", ""),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
],
|
||||
"overflow": layout_concept.get("overflow", []),
|
||||
})
|
||||
|
||||
# ★ Phase O-3: 블록 스펙 확정 (컨테이너 크기 → 항목수/글자수/폰트)
|
||||
for page in layout_concept.get("pages", []):
|
||||
finalize_block_specs(page.get("blocks", []), container_specs)
|
||||
# 컨테이너 스펙을 layout_concept에 저장 (렌더러에서 사용)
|
||||
layout_concept["_container_specs"] = container_specs
|
||||
|
||||
_save_step(run_dir, "step2c_block_specs.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"type": b.get("type"), "topic_id": b.get("topic_id"),
|
||||
"area": b.get("area"),
|
||||
"_container_height_px": b.get("_container_height_px"),
|
||||
"_max_items": b.get("_max_items"),
|
||||
"_max_chars_per_item": b.get("_max_chars_per_item"),
|
||||
"_max_chars_total": b.get("_max_chars_total"),
|
||||
"_font_size_px": b.get("_font_size_px"),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# 3단계: 텍스트 편집자 — 텍스트 정리
|
||||
yield {"event": "progress", "data": "3/5 텍스트 편집자가 핵심을 정리 중..."}
|
||||
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
logger.info("3단계 완료: 텍스트 정리")
|
||||
_save_step(run_dir, "step3_filled_blocks.json", {
|
||||
"blocks": [
|
||||
{
|
||||
"area": b.get("area"), "type": b.get("type"),
|
||||
"topic_id": b.get("topic_id"), "purpose": b.get("purpose"),
|
||||
"data": b.get("data", {}),
|
||||
"char_count": len(json.dumps(b.get("data", {}), ensure_ascii=False)),
|
||||
}
|
||||
for p in layout_concept.get("pages", [])
|
||||
for b in p.get("blocks", [])
|
||||
]
|
||||
})
|
||||
|
||||
# 4단계: 디자인 실무자 — 디자인 조정 + HTML 조립
|
||||
yield {"event": "progress", "data": "4/5 디자인 실무자가 슬라이드를 조립 중..."}
|
||||
@@ -100,14 +222,117 @@ async def generate_slide(
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info("4단계 완료: HTML 조립")
|
||||
_save_step(run_dir, "step4_css_adjustment.json", {
|
||||
"area_styles": layout_concept.get("pages", [{}])[0].get("area_styles", {})
|
||||
})
|
||||
_save_step(run_dir, "step4_rendered.html", html)
|
||||
|
||||
# 5단계: 디자인 팀장 — 전체 재검토 (최대 MAX_REVIEW_ROUNDS회)
|
||||
MAX_REVIEW_ROUNDS = 2 # 무한 루프 방지 — 최대 재조정 횟수
|
||||
yield {"event": "progress", "data": "5/5 디자인 팀장이 전체 균형을 검토 중..."}
|
||||
# Phase L: 렌더링 측정 + 피드백 루프 (최대 3회)
|
||||
import asyncio
|
||||
MAX_MEASURE_ROUNDS = 3
|
||||
measurement = None
|
||||
|
||||
for review_round in range(MAX_REVIEW_ROUNDS):
|
||||
for measure_round in range(MAX_MEASURE_ROUNDS):
|
||||
measurement = await asyncio.to_thread(measure_rendered_heights, html)
|
||||
_save_step(run_dir, f"step4_measurement_round{measure_round + 1}.json", measurement)
|
||||
|
||||
# overflow 감지 — zone + container 양쪽 체크
|
||||
has_overflow = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if zone_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
break
|
||||
# Phase O: container 레벨 overflow도 체크
|
||||
for cont_name, cont_data in measurement.get("containers", {}).items():
|
||||
if cont_data.get("overflowed"):
|
||||
has_overflow = True
|
||||
logger.warning(
|
||||
f"[측정] container-{cont_name}: "
|
||||
f"scroll={cont_data.get('scrollHeight')}px > "
|
||||
f"allocated={cont_data.get('allocatedHeight')}px "
|
||||
f"(+{cont_data.get('excess_px')}px)"
|
||||
)
|
||||
break
|
||||
|
||||
if not has_overflow:
|
||||
logger.info(f"[측정] 모든 zone/container 정상 (round {measure_round + 1})")
|
||||
break
|
||||
|
||||
logger.warning(f"[측정] overflow 감지 (round {measure_round + 1})")
|
||||
|
||||
# 수학적 축약량 계산 → 편집자 재호출
|
||||
adjusted = False
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
if not zone_data.get("overflowed"):
|
||||
continue
|
||||
excess = zone_data.get("excess_px", 0)
|
||||
zone_info = preset.get("zones", {}).get(zone_name, {})
|
||||
width_px = int(settings.slide_width * zone_info.get("width_pct", 100) / 100 * 0.85)
|
||||
|
||||
# Phase O: overflow 블록의 _max_chars_total 축소
|
||||
for block_m in zone_data.get("blocks", []):
|
||||
if block_m.get("overflowed"):
|
||||
trim_chars = calculate_trim_chars(
|
||||
block_m.get("excess_px", excess),
|
||||
width_px,
|
||||
)
|
||||
for page in layout_concept.get("pages", []):
|
||||
for block in page.get("blocks", []):
|
||||
if block.get("area") == zone_name:
|
||||
current_max = block.get("_max_chars_total", 400)
|
||||
block["_max_chars_total"] = max(20, current_max - trim_chars)
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
adjusted = True
|
||||
logger.info(
|
||||
f"[측정 조정] {zone_name}/{block_m.get('block_type')}: "
|
||||
f"{block_m.get('excess_px')}px 초과 → "
|
||||
f"_max_chars_total {current_max}→{block['_max_chars_total']} ({trim_chars}자 축약)"
|
||||
)
|
||||
break
|
||||
|
||||
if not adjusted:
|
||||
logger.info("[측정] 조정 대상 없음, 현재 결과 확정")
|
||||
break
|
||||
|
||||
# 편집자 재호출 → 재렌더링
|
||||
layout_concept = await fill_content(content, layout_concept, analysis)
|
||||
layout_concept = await _adjust_design(layout_concept, analysis)
|
||||
html = render_slide(layout_concept)
|
||||
logger.info(f"[측정] round {measure_round + 1} 재렌더링 완료")
|
||||
|
||||
# 측정 결과 텍스트 (Kei 검수에 전달)
|
||||
measurement_text = format_measurement_for_kei(measurement) if measurement else ""
|
||||
|
||||
# Phase N-4: 5단계 — Kei 실장 최종 검수 (스크린샷 기반, 최대 1회)
|
||||
# overflow 없으면 skip (시간 절약)
|
||||
has_any_overflow = False
|
||||
if measurement:
|
||||
for zone_data in measurement.get("zones", {}).values():
|
||||
if zone_data.get("overflowed"):
|
||||
has_any_overflow = True
|
||||
break
|
||||
if measurement.get("slide", {}).get("overflowed"):
|
||||
has_any_overflow = True
|
||||
|
||||
MAX_REVIEW_ROUNDS = 1
|
||||
screenshot_b64 = None
|
||||
|
||||
if not has_any_overflow:
|
||||
logger.info("5단계 skip: overflow 없음. 검수 불필요.")
|
||||
else:
|
||||
yield {"event": "progress", "data": "5/5 Kei 실장이 최종 검수 중..."}
|
||||
|
||||
# 스크린샷 캡처 (Selenium)
|
||||
screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
|
||||
if screenshot_b64:
|
||||
_save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
|
||||
logger.info("[5단계] 스크린샷 캡처 완료 → Kei에게 전달")
|
||||
|
||||
for review_round in range(MAX_REVIEW_ROUNDS if has_any_overflow else 0):
|
||||
review_result = await _review_balance(
|
||||
html, layout_concept, content, analysis
|
||||
html, layout_concept, content, analysis, measurement_text,
|
||||
screenshot_b64=screenshot_b64,
|
||||
)
|
||||
|
||||
if not review_result or not review_result.get("needs_adjustment"):
|
||||
@@ -122,6 +347,7 @@ async def generate_slide(
|
||||
f"5단계 ({review_round + 1}/{MAX_REVIEW_ROUNDS}): "
|
||||
f"조정 필요 — {issues}"
|
||||
)
|
||||
_save_step(run_dir, f"step5_review_round{review_round + 1}.json", review_result)
|
||||
|
||||
# overflow_detected가 있으면 Kei에게 판단 요청 (Sonnet은 감지만, 판단은 Kei)
|
||||
overflow_adjs = [
|
||||
@@ -137,14 +363,12 @@ async def generate_slide(
|
||||
)
|
||||
|
||||
if kei_judgment is None:
|
||||
logger.warning("[DOWNGRADE 비상] Kei API 실패 → 기계적 교체")
|
||||
for page in layout_concept.get("pages", []):
|
||||
_downgrade_fallback(
|
||||
page.get("blocks", []), overflow_context
|
||||
)
|
||||
else:
|
||||
_convert_kei_judgment(review_result, kei_judgment)
|
||||
logger.info(
|
||||
# 넘침 판단도 Kei 필수 — 성공할 때까지 무한 재시도
|
||||
kei_judgment = await _retry_kei(
|
||||
call_kei_overflow_judgment, overflow_context, content, analysis
|
||||
)
|
||||
_convert_kei_judgment(review_result, kei_judgment)
|
||||
logger.info(
|
||||
f"[Kei 넘침 판단] decision={kei_judgment.get('decision')}"
|
||||
)
|
||||
|
||||
@@ -164,8 +388,9 @@ async def generate_slide(
|
||||
html = embed_images(html, base_path)
|
||||
logger.info("이미지 base64 삽입 완료")
|
||||
|
||||
_save_step(run_dir, "final.html", html)
|
||||
yield {"event": "result", "data": html}
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지")
|
||||
logger.info(f"슬라이드 생성 완료: {len(layout_concept.get('pages', []))}페이지, run={run_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"파이프라인 오류: {e}")
|
||||
@@ -279,18 +504,18 @@ async def _review_balance(
|
||||
layout_concept: dict[str, Any],
|
||||
content: str,
|
||||
analysis: dict[str, Any] | None = None,
|
||||
measurement_text: str = "",
|
||||
screenshot_b64: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""5단계: 디자인 팀장이 조립 결과를 재검토한다.
|
||||
"""5단계: Kei 실장이 조립 결과를 최종 검수한다. (J-7 + Phase L)
|
||||
|
||||
HTML 코드 기반으로 구조적 점검 + 높이 넘침 감지:
|
||||
- 빈 블록 감지
|
||||
- 블록 간 채움 비율 불균형
|
||||
- 이미지/표 크기 적절성
|
||||
- 높이 초과 감지 → overflow_detected (Kei 판단 필요)
|
||||
Kei가 콘텐츠 관점 + 실제 렌더링 측정 결과 기반으로 검수:
|
||||
- 핵심 메시지 전달 여부
|
||||
- 콘텐츠 흐름 ↔ 블록 배치 일치
|
||||
- 실제 px 기반 높이/비중 검증 (Phase L)
|
||||
- 중요 내용 누락/축소 여부
|
||||
"""
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# 블록별 텍스트 양 요약
|
||||
block_summary = []
|
||||
for page in layout_concept.get("pages", []):
|
||||
@@ -329,51 +554,16 @@ async def _review_balance(
|
||||
+ "\n".join(hint_lines)
|
||||
)
|
||||
|
||||
system = (
|
||||
"당신은 디자인 팀장이다. 조립 결과(HTML)를 검토하여 균형과 높이 제약을 점검한다.\n\n"
|
||||
"## 점검 항목\n"
|
||||
"1. 빈 블록: 데이터가 없거나 극히 적은 블록\n"
|
||||
"2. 채움 불균형: 한 블록은 빽빽하고 다른 블록은 비어있음\n"
|
||||
"3. 이미지/표: 너무 작거나 큰 것은 없는지\n"
|
||||
"4. 전체 정보량: 한 페이지에 너무 많거나 적은지\n"
|
||||
"5. HTML 구조: 블록이 영역 안에 잘 배치되었는지\n"
|
||||
"6. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?\n"
|
||||
" - 텍스트 양/블록 수를 보고 판단\n"
|
||||
" - shrink로 해결 가능하면 shrink 사용\n"
|
||||
" - 불가능 (콘텐츠가 본질적으로 큼) → overflow_detected\n\n"
|
||||
"## 조정 action 설명\n"
|
||||
"- expand: 텍스트를 늘린다. target_ratio로 지정 (예: 1.3 = 30% 증가)\n"
|
||||
"- shrink: 텍스트를 줄인다. target_ratio로 지정 (예: 0.7 = 30% 감소)\n"
|
||||
"- rewrite: 텍스트를 완전히 재작성한다. detail에 방향 명시.\n"
|
||||
"- overflow_detected: 높이 초과로 콘텐츠 판단 필요. 해당 zone과 초과 블록을 detail에 명시.\n\n"
|
||||
"## 출력 형식 (JSON만)\n"
|
||||
'{"needs_adjustment": true/false, '
|
||||
'"issues": ["이슈1", "이슈2"], '
|
||||
'"adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", '
|
||||
'"target_ratio": 1.3, "detail": "..."}]}'
|
||||
)
|
||||
# Phase L: 렌더링 측정 결과를 overflow_hint에 추가 (실제 px 기반)
|
||||
if measurement_text:
|
||||
overflow_hint_text += f"\n\n{measurement_text}"
|
||||
|
||||
user_prompt = (
|
||||
f"## 조립 HTML\n{html}\n\n"
|
||||
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
|
||||
zone_budget_text +
|
||||
overflow_hint_text +
|
||||
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"위 HTML과 데이터를 보고 조정이 필요한지 판단해. JSON으로 답해."
|
||||
# Kei로 최종 검수 (Sonnet 절대 금지, 스크린샷 있으면 이미지 기반)
|
||||
return await call_kei_final_review(
|
||||
html, block_summary, zone_budget_text, overflow_hint_text, analysis,
|
||||
screenshot_b64=screenshot_b64,
|
||||
)
|
||||
|
||||
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
|
||||
return _parse_json(result_text)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"재검토 실패: {e}")
|
||||
return None
|
||||
|
||||
@@ -158,31 +158,89 @@ def _preprocess_svg_data(block_type: str, block_data: dict[str, Any]) -> dict[st
|
||||
return block_data
|
||||
|
||||
|
||||
def _group_blocks_by_area(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""같은 area의 블록들을 하나로 그룹핑한다.
|
||||
def _group_blocks_by_area(
|
||||
blocks: list[dict[str, Any]],
|
||||
container_specs: dict | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Phase O: 같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
|
||||
|
||||
CSS Grid에서 같은 area에 여러 div가 있으면 겹치므로,
|
||||
같은 area의 블록 HTML을 합쳐서 하나의 div로 만든다.
|
||||
container_specs가 있으면 body zone 안에 역할별 고정 높이 컨테이너를 생성.
|
||||
"""
|
||||
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"])
|
||||
grouped[area] = {"area": area, "blocks": []}
|
||||
grouped[area]["blocks"].append(block)
|
||||
|
||||
result = []
|
||||
for area, data in grouped.items():
|
||||
if len(data["htmls"]) == 1:
|
||||
html = data["htmls"][0]
|
||||
block_list = data["blocks"]
|
||||
|
||||
# Phase O: body zone에 컨테이너 스펙 적용
|
||||
if container_specs and area in ("body", "left", "right", "hero", "detail"):
|
||||
container_htmls = []
|
||||
assigned_ids = set()
|
||||
|
||||
role_order = ["배경", "본심"]
|
||||
for role in role_order:
|
||||
spec = container_specs.get(role)
|
||||
if not spec or spec.zone != area:
|
||||
continue
|
||||
|
||||
# 이 역할에 속하는 블록 찾기 (topic_id로 매칭)
|
||||
role_blocks = [
|
||||
b for b in block_list
|
||||
if b.get("_topic_id") in spec.topic_ids
|
||||
and id(b) not in assigned_ids
|
||||
]
|
||||
|
||||
# topic_id 매칭 안 되면 순서로 매칭
|
||||
if not role_blocks:
|
||||
for b in block_list:
|
||||
if id(b) not in assigned_ids:
|
||||
role_blocks.append(b)
|
||||
if len(role_blocks) >= len(spec.topic_ids):
|
||||
break
|
||||
|
||||
for b in role_blocks:
|
||||
assigned_ids.add(id(b))
|
||||
|
||||
if not role_blocks:
|
||||
continue
|
||||
|
||||
inner_html = "\n".join(b["html"] for b in role_blocks)
|
||||
font_size = spec.block_constraints.get("font_size_px", 15.2)
|
||||
padding = spec.block_constraints.get("padding_px", 20)
|
||||
|
||||
container_htmls.append(
|
||||
f'<div class="container-{role}" style="'
|
||||
f'height:{spec.height_px}px; '
|
||||
f'overflow:visible; '
|
||||
f'display:flex; flex-direction:column; gap:8px; '
|
||||
f'font-size:{font_size}px; '
|
||||
f'--spacing-inner:{padding}px; '
|
||||
f'--font-body:{font_size / 16:.3f}rem;">\n'
|
||||
f'{inner_html}\n</div>'
|
||||
)
|
||||
|
||||
# 미배정 블록
|
||||
for b in block_list:
|
||||
if id(b) not in assigned_ids:
|
||||
container_htmls.append(b["html"])
|
||||
|
||||
html = "\n".join(container_htmls)
|
||||
|
||||
elif len(block_list) == 1:
|
||||
html = block_list[0]["html"]
|
||||
else:
|
||||
# 여러 블록을 flex-column으로 세로 쌓기
|
||||
inner = "\n".join(data["htmls"])
|
||||
inner = "\n".join(b["html"] for b in block_list)
|
||||
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
|
||||
@@ -205,6 +263,11 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
block_type = block.get("type", "")
|
||||
block_data = block.get("data", {})
|
||||
|
||||
# 높이 자동 조치: _strip_sub_text 플래그 처리
|
||||
if block_data.get("_strip_sub_text"):
|
||||
block_data.pop("sub_text", None)
|
||||
block_data.pop("_strip_sub_text", None)
|
||||
|
||||
# P2-B: SVG 시각화 블록은 좌표 사전 계산
|
||||
block_data = _preprocess_svg_data(block_type, block_data)
|
||||
|
||||
@@ -226,13 +289,19 @@ def render_multi_page(layout_concept: dict[str, Any]) -> str:
|
||||
f'<div class="body-text">블록 템플릿 미발견: {block_type}</div>'
|
||||
)
|
||||
|
||||
# Phase N-3: max-height CSS 래퍼 제거.
|
||||
# 콘텐츠는 렌더링 전에 _max_chars로 맞춘다. CSS로 사후에 자르지 않는다.
|
||||
# overflow는 slide_measurer가 scrollHeight > clientHeight로 감지한다.
|
||||
|
||||
blocks_raw.append({
|
||||
"area": block.get("area", "main"),
|
||||
"html": rendered_html,
|
||||
"_topic_id": block.get("topic_id"), # Phase O: 컨테이너 매칭용
|
||||
})
|
||||
|
||||
# Fix 1: 같은 area 블록 그룹핑
|
||||
blocks_grouped = _group_blocks_by_area(blocks_raw)
|
||||
# Phase O: 비중 기반 컨테이너 그룹핑
|
||||
page_container_specs = layout_concept.get("_container_specs")
|
||||
blocks_grouped = _group_blocks_by_area(blocks_raw, container_specs=page_container_specs)
|
||||
|
||||
# A-1: area별 CSS 변수 override 주입
|
||||
area_styles = page.get("area_styles", {})
|
||||
|
||||
281
src/slide_measurer.py
Normal file
281
src/slide_measurer.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Phase L: 슬라이드 렌더링 측정 에이전트.
|
||||
|
||||
Selenium headless Chrome으로 HTML을 실제 렌더링하고
|
||||
각 zone/block의 px 높이를 정확히 측정한다.
|
||||
|
||||
LLM 추정이 아닌 브라우저 엔진 측정. 결정론적.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
|
||||
from src.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JavaScript: 각 zone과 블록의 실제 높이를 측정
|
||||
_MEASURE_SCRIPT = """
|
||||
var slide = document.querySelector('.slide');
|
||||
if (!slide) return {error: 'slide not found'};
|
||||
|
||||
var result = {
|
||||
slide: {
|
||||
scrollHeight: slide.scrollHeight,
|
||||
clientHeight: slide.clientHeight,
|
||||
overflowed: slide.scrollHeight > slide.clientHeight,
|
||||
excess_px: Math.max(0, slide.scrollHeight - slide.clientHeight)
|
||||
},
|
||||
zones: {},
|
||||
containers: {}
|
||||
};
|
||||
|
||||
// Zone 측정 (area-* 클래스)
|
||||
var areaDivs = slide.querySelectorAll('[class*="area-"]');
|
||||
for (var i = 0; i < areaDivs.length; i++) {
|
||||
var zone = areaDivs[i];
|
||||
var areaMatch = zone.className.match(/area-(\\w+)/);
|
||||
if (!areaMatch) continue;
|
||||
var areaName = areaMatch[1];
|
||||
|
||||
var blocks = [];
|
||||
var blockDivs = zone.querySelectorAll('[class*="block-"]');
|
||||
for (var j = 0; j < blockDivs.length; j++) {
|
||||
var block = blockDivs[j];
|
||||
var blockMatch = block.className.match(/block-([\\w-]+)/);
|
||||
var blockName = blockMatch ? blockMatch[1] : block.className;
|
||||
blocks.push({
|
||||
block_type: blockName,
|
||||
scrollHeight: Math.round(block.scrollHeight),
|
||||
clientHeight: Math.round(block.clientHeight),
|
||||
offsetHeight: Math.round(block.offsetHeight),
|
||||
overflowed: block.scrollHeight > block.clientHeight + 2,
|
||||
excess_px: Math.max(0, Math.round(block.scrollHeight - block.clientHeight))
|
||||
});
|
||||
}
|
||||
|
||||
result.zones[areaName] = {
|
||||
scrollHeight: Math.round(zone.scrollHeight),
|
||||
clientHeight: Math.round(zone.clientHeight),
|
||||
overflowed: zone.scrollHeight > zone.clientHeight + 2,
|
||||
excess_px: Math.max(0, Math.round(zone.scrollHeight - zone.clientHeight)),
|
||||
block_count: blocks.length,
|
||||
blocks: blocks
|
||||
};
|
||||
}
|
||||
|
||||
// Phase O: 컨테이너 측정 (container-* 클래스)
|
||||
var containerDivs = slide.querySelectorAll('[class*="container-"]');
|
||||
for (var k = 0; k < containerDivs.length; k++) {
|
||||
var container = containerDivs[k];
|
||||
var containerMatch = container.className.match(/container-(.+)/);
|
||||
if (!containerMatch) continue;
|
||||
var containerName = containerMatch[1];
|
||||
|
||||
var cBlocks = [];
|
||||
var cBlockDivs = container.querySelectorAll('[class*="block-"]');
|
||||
for (var m = 0; m < cBlockDivs.length; m++) {
|
||||
var cBlock = cBlockDivs[m];
|
||||
var cBlockMatch = cBlock.className.match(/block-([\\w-]+)/);
|
||||
var cBlockName = cBlockMatch ? cBlockMatch[1] : cBlock.className;
|
||||
cBlocks.push({
|
||||
block_type: cBlockName,
|
||||
scrollHeight: Math.round(cBlock.scrollHeight),
|
||||
clientHeight: Math.round(cBlock.clientHeight),
|
||||
overflowed: cBlock.scrollHeight > cBlock.clientHeight + 2,
|
||||
excess_px: Math.max(0, Math.round(cBlock.scrollHeight - cBlock.clientHeight))
|
||||
});
|
||||
}
|
||||
|
||||
result.containers[containerName] = {
|
||||
scrollHeight: Math.round(container.scrollHeight),
|
||||
clientHeight: Math.round(container.clientHeight),
|
||||
allocatedHeight: parseInt(container.style.height) || 0,
|
||||
overflowed: container.scrollHeight > container.clientHeight + 2,
|
||||
excess_px: Math.max(0, Math.round(container.scrollHeight - container.clientHeight)),
|
||||
block_count: cBlocks.length,
|
||||
blocks: cBlocks
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
"""
|
||||
|
||||
|
||||
def measure_rendered_heights(html: str) -> dict[str, Any]:
|
||||
"""렌더링된 HTML의 각 zone/block 실제 px 높이를 측정한다.
|
||||
|
||||
Selenium headless Chrome 사용. 결정론적.
|
||||
viewport 크기는 config에서 읽음 (하드코딩 아님).
|
||||
|
||||
Args:
|
||||
html: 렌더링할 완성 HTML 문자열
|
||||
|
||||
Returns:
|
||||
{
|
||||
"slide": {"scrollHeight": 750, "clientHeight": 720, "overflowed": true, ...},
|
||||
"zones": {
|
||||
"body": {"scrollHeight": 520, "clientHeight": 490, "overflowed": true, "blocks": [...]},
|
||||
"sidebar": {"scrollHeight": 400, "clientHeight": 490, "overflowed": false, ...},
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--disable-gpu")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument(
|
||||
f"--window-size={settings.slide_width},{settings.slide_height + 200}"
|
||||
)
|
||||
|
||||
driver = None
|
||||
try:
|
||||
driver = webdriver.Chrome(options=options)
|
||||
|
||||
# HTML을 data URI로 로드
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(html)
|
||||
driver.get(f"data:text/html;charset=utf-8,{encoded}")
|
||||
|
||||
# 폰트 로딩 대기 (Pretendard CDN)
|
||||
try:
|
||||
driver.execute_script("return document.fonts.ready")
|
||||
except Exception:
|
||||
pass # 폰트 API 미지원 시 무시
|
||||
|
||||
# 측정 실행
|
||||
result = driver.execute_script(_MEASURE_SCRIPT)
|
||||
|
||||
if result and "error" not in result:
|
||||
_log_measurement(result)
|
||||
return result
|
||||
|
||||
logger.warning(f"[측정] 실패: {result}")
|
||||
return {"slide": {}, "zones": {}}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[측정] Selenium 오류: {e}")
|
||||
return {"slide": {}, "zones": {}}
|
||||
|
||||
finally:
|
||||
if driver:
|
||||
try:
|
||||
driver.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def format_measurement_for_kei(
|
||||
measurement: dict[str, Any],
|
||||
allocation: dict[int, int] | None = None,
|
||||
) -> str:
|
||||
"""측정 결과를 Kei 검수에 전달할 텍스트로 포맷한다.
|
||||
|
||||
Args:
|
||||
measurement: measure_rendered_heights() 결과
|
||||
allocation: allocate_height_budget() 결과 (있으면 할당 대비 비교)
|
||||
|
||||
Returns:
|
||||
Kei에게 전달할 측정 결과 텍스트
|
||||
"""
|
||||
lines = ["## 실제 렌더링 측정 결과 (Selenium)"]
|
||||
|
||||
slide = measurement.get("slide", {})
|
||||
if slide:
|
||||
status = "OK" if not slide.get("overflowed") else f"+{slide.get('excess_px', 0)}px 초과"
|
||||
lines.append(
|
||||
f"- 슬라이드 전체: {slide.get('scrollHeight', '?')}px / "
|
||||
f"{slide.get('clientHeight', '?')}px — {status}"
|
||||
)
|
||||
|
||||
for zone_name, zone_data in measurement.get("zones", {}).items():
|
||||
status = "OK" if not zone_data.get("overflowed") else f"+{zone_data.get('excess_px', 0)}px 초과"
|
||||
lines.append(
|
||||
f"- {zone_name} zone: 실제 {zone_data.get('scrollHeight', '?')}px / "
|
||||
f"가용 {zone_data.get('clientHeight', '?')}px — {status}"
|
||||
)
|
||||
|
||||
for block in zone_data.get("blocks", []):
|
||||
block_status = "OK" if not block.get("overflowed") else f"+{block.get('excess_px', 0)}px 잘림"
|
||||
height = block.get("scrollHeight", "?")
|
||||
|
||||
# zone 내 비중 계산
|
||||
zone_height = zone_data.get("clientHeight", 1)
|
||||
ratio_pct = round(height / zone_height * 100) if isinstance(height, (int, float)) and zone_height > 0 else "?"
|
||||
|
||||
lines.append(
|
||||
f" - {block.get('block_type', '?')}: "
|
||||
f"{height}px ({ratio_pct}%) — {block_status}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def capture_slide_screenshot(html: str) -> str | None:
|
||||
"""Phase N-4: 렌더링된 슬라이드의 스크린샷을 base64 PNG로 캡처한다.
|
||||
|
||||
Selenium 4.x WebElement.screenshot_as_base64 사용.
|
||||
반환: 순수 base64 문자열 (data URI prefix 없음). 실패 시 None.
|
||||
"""
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--disable-gpu")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument("--force-device-scale-factor=1")
|
||||
options.add_argument(
|
||||
f"--window-size={settings.slide_width},{settings.slide_height + 200}"
|
||||
)
|
||||
|
||||
driver = None
|
||||
try:
|
||||
driver = webdriver.Chrome(options=options)
|
||||
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(html)
|
||||
driver.get(f"data:text/html;charset=utf-8,{encoded}")
|
||||
|
||||
# 폰트 로딩 대기
|
||||
try:
|
||||
driver.execute_script("return document.fonts.ready")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
slide = driver.find_element(By.CSS_SELECTOR, ".slide")
|
||||
screenshot_b64 = slide.screenshot_as_base64
|
||||
|
||||
logger.info(f"[스크린샷] 캡처 완료: {len(screenshot_b64)}자 base64")
|
||||
return screenshot_b64
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[스크린샷] Selenium 캡처 실패: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
if driver:
|
||||
try:
|
||||
driver.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_measurement(result: dict[str, Any]) -> None:
|
||||
"""측정 결과를 로그에 출력한다."""
|
||||
slide = result.get("slide", {})
|
||||
overflow_status = "OK" if not slide.get("overflowed") else f"초과 +{slide.get('excess_px')}px"
|
||||
logger.info(f"[측정] 슬라이드: {slide.get('scrollHeight')}px / {slide.get('clientHeight')}px — {overflow_status}")
|
||||
|
||||
for zone_name, zone_data in result.get("zones", {}).items():
|
||||
zone_status = "OK" if not zone_data.get("overflowed") else f"초과 +{zone_data.get('excess_px')}px"
|
||||
logger.info(
|
||||
f"[측정] {zone_name}: {zone_data.get('scrollHeight')}px / "
|
||||
f"{zone_data.get('clientHeight')}px — {zone_status} "
|
||||
f"({zone_data.get('block_count', 0)}개 블록)"
|
||||
)
|
||||
312
src/space_allocator.py
Normal file
312
src/space_allocator.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Phase O: 컨테이너 기반 공간 할당 시스템.
|
||||
|
||||
Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
|
||||
LLM 추정이 아닌 결정론적 계산.
|
||||
|
||||
주요 함수:
|
||||
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec
|
||||
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙
|
||||
- calculate_trim_chars(): 초과 px → 삭제 글자 수
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# height_cost → px 범위 매핑
|
||||
# ──────────────────────────────────────
|
||||
HEIGHT_COST_PX_RANGE = {
|
||||
"compact": (30, 80),
|
||||
"medium": (80, 200),
|
||||
"large": (200, 350),
|
||||
"xlarge": (350, 500),
|
||||
}
|
||||
|
||||
HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
|
||||
|
||||
# 역할별 zone 매핑 (기본)
|
||||
ROLE_ZONE_MAP = {
|
||||
"본심": "body",
|
||||
"배경": "body",
|
||||
"첨부": "sidebar",
|
||||
"결론": "footer",
|
||||
}
|
||||
|
||||
# 폰트 설정 기본값
|
||||
DEFAULT_FONT_SIZE_PX = 15.2
|
||||
DEFAULT_LINE_HEIGHT = 1.7
|
||||
DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# ContainerSpec 데이터 클래스
|
||||
# ──────────────────────────────────────
|
||||
@dataclass
|
||||
class ContainerSpec:
|
||||
"""역할별 컨테이너 스펙."""
|
||||
role: str # "본심", "배경", "첨부", "결론"
|
||||
zone: str # "body", "sidebar", "footer"
|
||||
topic_ids: list[int] # 이 컨테이너에 속하는 topic ID들
|
||||
weight: float # Kei가 판단한 비중 (0.0~1.0)
|
||||
height_px: int # 컨테이너 높이 (px)
|
||||
width_px: int # 컨테이너 너비 (px)
|
||||
max_height_cost: str # 허용 최대 height_cost ("compact"/"medium"/"large"/"xlarge")
|
||||
block_constraints: dict = field(default_factory=dict) # 블록 내부 제약
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# O-1: 컨테이너 스펙 계산
|
||||
# ──────────────────────────────────────
|
||||
def calculate_container_specs(
|
||||
page_structure: dict[str, Any],
|
||||
topics: list[dict[str, Any]],
|
||||
preset: dict[str, Any],
|
||||
slide_width: int = 1280,
|
||||
slide_height: int = 720,
|
||||
gap_px: int = 20,
|
||||
) -> dict[str, ContainerSpec]:
|
||||
"""Kei 비중 → 역할별 ContainerSpec 계산.
|
||||
|
||||
결정론적. AI 호출 없음.
|
||||
|
||||
Args:
|
||||
page_structure: Kei 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
|
||||
topics: 각 topic의 purpose, role, layer
|
||||
preset: 프리셋 zone 정보 (budget_px, width_pct)
|
||||
slide_width: 슬라이드 너비 (px)
|
||||
slide_height: 슬라이드 높이 (px)
|
||||
gap_px: 컨테이너 간 간격 (px)
|
||||
|
||||
Returns:
|
||||
{"본심": ContainerSpec(...), "배경": ContainerSpec(...), ...}
|
||||
"""
|
||||
zones = preset.get("zones", {})
|
||||
specs: dict[str, ContainerSpec] = {}
|
||||
|
||||
# zone별로 해당 역할들의 비중 합산
|
||||
zone_roles: dict[str, list[tuple[str, dict]]] = {} # zone → [(role, info), ...]
|
||||
for role_name, role_info in page_structure.items():
|
||||
if not isinstance(role_info, dict):
|
||||
continue
|
||||
zone = ROLE_ZONE_MAP.get(role_name, "body")
|
||||
if zone not in zone_roles:
|
||||
zone_roles[zone] = []
|
||||
zone_roles[zone].append((role_name, role_info))
|
||||
|
||||
for zone_name, role_list in zone_roles.items():
|
||||
zone_info = zones.get(zone_name, {})
|
||||
zone_budget = zone_info.get("budget_px", 490)
|
||||
zone_width_pct = zone_info.get("width_pct", 100)
|
||||
zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
|
||||
|
||||
# 이 zone 안의 역할별 비중 비율 계산
|
||||
total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
|
||||
if total_weight <= 0:
|
||||
total_weight = 1.0
|
||||
|
||||
# 간격 제외
|
||||
total_gap = gap_px * max(0, len(role_list) - 1)
|
||||
available = zone_budget - total_gap
|
||||
|
||||
for role_name, role_info in role_list:
|
||||
weight = role_info.get("weight", 0.25)
|
||||
topic_ids = role_info.get("topic_ids", [])
|
||||
|
||||
# 비중 비율로 높이 할당
|
||||
ratio = weight / total_weight
|
||||
height_px = max(50, int(available * ratio))
|
||||
|
||||
# 블록 내부 제약 계산 — topic당 높이로 판단
|
||||
topic_count = max(1, len(topic_ids))
|
||||
per_topic_px = height_px // topic_count
|
||||
|
||||
# height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님)
|
||||
max_cost = _max_allowed_height_cost(per_topic_px)
|
||||
font_size, padding, line_h = _determine_typography(height_px // topic_count)
|
||||
constraints = _calculate_block_constraints(
|
||||
height_px, zone_width_px, topic_count, font_size, line_h, padding
|
||||
)
|
||||
constraints["font_size_px"] = font_size
|
||||
constraints["padding_px"] = padding
|
||||
constraints["line_height"] = line_h
|
||||
|
||||
specs[role_name] = ContainerSpec(
|
||||
role=role_name,
|
||||
zone=zone_name,
|
||||
topic_ids=topic_ids,
|
||||
weight=weight,
|
||||
height_px=height_px,
|
||||
width_px=zone_width_px,
|
||||
max_height_cost=max_cost,
|
||||
block_constraints=constraints,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[O-1] 컨테이너 스펙: "
|
||||
+ ", ".join(f"{r}={s.height_px}px({s.max_height_cost})" for r, s in specs.items())
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _max_allowed_height_cost(container_height_px: int) -> str:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost."""
|
||||
if container_height_px >= 350:
|
||||
return "xlarge"
|
||||
elif container_height_px >= 200:
|
||||
return "large"
|
||||
elif container_height_px >= 80:
|
||||
return "medium"
|
||||
else:
|
||||
return "compact"
|
||||
|
||||
|
||||
def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
|
||||
if per_block_height_px >= 300:
|
||||
return (15.2, 20, 1.7)
|
||||
elif per_block_height_px >= 150:
|
||||
return (14.0, 14, 1.6)
|
||||
elif per_block_height_px >= 80:
|
||||
return (13.0, 10, 1.5)
|
||||
else:
|
||||
return (12.0, 8, 1.4)
|
||||
|
||||
|
||||
def _calculate_block_constraints(
|
||||
height_px: int,
|
||||
width_px: int,
|
||||
topic_count: int,
|
||||
font_size_px: float,
|
||||
line_height: float,
|
||||
padding_px: int,
|
||||
) -> dict:
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
|
||||
per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
|
||||
line_height_px = font_size_px * line_height
|
||||
max_lines = max(1, int(per_topic_height / line_height_px))
|
||||
chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
|
||||
max_items = max(1, max_lines // 2)
|
||||
max_chars_total = max_lines * chars_per_line
|
||||
|
||||
return {
|
||||
"max_lines": max_lines,
|
||||
"max_items": max_items,
|
||||
"chars_per_line": chars_per_line,
|
||||
"max_chars_total": max(20, max_chars_total),
|
||||
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# O-1 유틸: topic_id → ContainerSpec 매핑
|
||||
# ──────────────────────────────────────
|
||||
def find_container_for_topic(
|
||||
topic_id: int | None,
|
||||
container_specs: dict[str, ContainerSpec],
|
||||
) -> ContainerSpec | None:
|
||||
"""topic_id로 해당 ContainerSpec을 찾는다."""
|
||||
if topic_id is None:
|
||||
return None
|
||||
for spec in container_specs.values():
|
||||
if topic_id in spec.topic_ids:
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# O-3: 블록 스펙 확정
|
||||
# ──────────────────────────────────────
|
||||
def finalize_block_specs(
|
||||
blocks: list[dict[str, Any]],
|
||||
container_specs: dict[str, ContainerSpec],
|
||||
catalog_map: dict[str, dict] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
|
||||
|
||||
결정론적. AI 호출 없음.
|
||||
|
||||
확정 필드:
|
||||
- _container_height_px, _container_width_px
|
||||
- _max_items, _max_chars_per_item, _max_chars_total
|
||||
- _font_size_px, _padding_px, _line_height
|
||||
"""
|
||||
for block in blocks:
|
||||
tid = block.get("topic_id")
|
||||
spec = find_container_for_topic(tid, container_specs)
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
# 같은 컨테이너 안의 블록 수 (높이 분배)
|
||||
siblings = [b for b in blocks
|
||||
if find_container_for_topic(b.get("topic_id"), container_specs) == spec
|
||||
and b.get("topic_id") is not None]
|
||||
sibling_count = max(1, len(siblings))
|
||||
per_block_height = max(40, spec.height_px // sibling_count)
|
||||
|
||||
# 폰트/패딩 결정
|
||||
font_size, padding, line_h = _determine_typography(per_block_height)
|
||||
|
||||
# 블록별 제약 계산
|
||||
constraints = _calculate_block_constraints(
|
||||
per_block_height, spec.width_px, 1, font_size, line_h, padding
|
||||
)
|
||||
|
||||
# 블록 타입별 세부 조정
|
||||
block_type = block.get("type", "")
|
||||
if block_type in ("dark-bullet-list",):
|
||||
block["_max_items"] = min(constraints["max_items"], 5)
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("card-numbered", "card-icon-desc"):
|
||||
block["_max_items"] = constraints["max_items"]
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
|
||||
block["_max_items"] = constraints["max_items"] # 행 수
|
||||
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
|
||||
elif block_type in ("comparison-2col",):
|
||||
block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
|
||||
elif block_type in ("banner-gradient",):
|
||||
block["_max_chars_total"] = constraints["chars_per_line"]
|
||||
else:
|
||||
pass # 기본값 사용
|
||||
|
||||
# 공통 필드
|
||||
block["_container_height_px"] = per_block_height
|
||||
block["_container_width_px"] = spec.width_px
|
||||
block["_max_chars_total"] = constraints["max_chars_total"]
|
||||
block["_font_size_px"] = font_size
|
||||
block["_padding_px"] = padding
|
||||
block["_line_height"] = line_h
|
||||
|
||||
logger.info(
|
||||
f"[O-3] 블록 스펙 확정: "
|
||||
+ ", ".join(
|
||||
f"t{b.get('topic_id')}={b.get('_container_height_px','?')}px"
|
||||
for b in blocks if b.get("topic_id") is not None
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 기존 유틸 (Phase L 호환)
|
||||
# ──────────────────────────────────────
|
||||
def calculate_trim_chars(
|
||||
excess_px: int,
|
||||
container_width_px: int,
|
||||
font_size_px: float = DEFAULT_FONT_SIZE_PX,
|
||||
line_height: float = DEFAULT_LINE_HEIGHT,
|
||||
avg_char_width_px: float = DEFAULT_AVG_CHAR_WIDTH_PX,
|
||||
) -> int:
|
||||
"""초과 px에서 삭제할 글자 수를 계산한다."""
|
||||
if excess_px <= 0:
|
||||
return 0
|
||||
line_height_px = font_size_px * line_height
|
||||
lines_to_remove = math.ceil(excess_px / line_height_px)
|
||||
chars_per_line = int(container_width_px / avg_char_width_px)
|
||||
return max(lines_to_remove * chars_per_line, 10)
|
||||
Reference in New Issue
Block a user