5단계 파이프라인 전면 재작성 + Figma 추출 계획 업데이트
DA-12: 1단계 Kei 실장 — 꼭지 2~5개 추출 + 레이어/강조/배치/이미지/표/자세히보기 판단 DA-13: 2단계 디자인 팀장 — catalog 연동 + 블록 매핑 + 공간 배분 + 글자 수 가이드 DA-13b: 3단계 텍스트 편집자 — 글자 수 가이드 참고, 의미 우선 편집 + 자세히보기(요약+상세) DA-14: 4단계 실무자(AI+코드) + 5단계 팀장 재검토 (균형 점검 → 2차 조정) 문서: - CLAUDE.md: 5단계 프로세스 + 이미지/표/자세히보기 처리 원칙 - PLAN.md: DA-12~14 태스크 전면 재작성 - PROGRESS.md: 동기화 - FIGMA-COMPONENT-EXTRACTION-PLAN.md: 모드 독립 블록, 변환 규칙, image-block/details-block, MCP, 토큰 매핑 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+61
-44
@@ -1,8 +1,8 @@
|
||||
"""DA-13b: 텍스트 편집자 — 슬롯 텍스트 정리 (Kei 역할).
|
||||
"""DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리).
|
||||
|
||||
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
|
||||
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
|
||||
핵심 내용을 유지하면서 슬롯 분량에 맞게 편집.
|
||||
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,20 +18,47 @@ from src.design_director import BLOCK_SLOTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다.
|
||||
원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.
|
||||
|
||||
## 핵심 원칙
|
||||
- **내용의 의미와 정확성이 글자 수보다 우선한다**
|
||||
- 팀장이 제시한 글자 수 가이드는 참고. 의미를 살리려면 가이드를 초과해도 된다.
|
||||
- 디자인 실무자가 텍스트에 맞게 디자인을 조정할 것이므로, 텍스트를 억지로 자르지 않는다.
|
||||
|
||||
## 편집 규칙
|
||||
- 전체 컨텍스트와 핵심 용어를 보존한다
|
||||
- 세련된 표현으로 편집한다 (원본 그대로가 아님)
|
||||
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
|
||||
- 출처가 있는 내용은 출처를 반드시 보존한다
|
||||
- 출처가 없는 수치나 통계를 만들지 않는다
|
||||
|
||||
## 표 편집 규칙
|
||||
- 표는 표로 유지한다 (다른 형태로 전환하지 않음)
|
||||
- 팀장이 요약 요청하면 핵심 행/열만 선택하고 "...외 N건" 표기
|
||||
|
||||
## 자세히보기 편집 규칙
|
||||
- detail_target인 꼭지는 두 버전을 작성:
|
||||
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
|
||||
- detail: 펼치면 보일 전체 내용
|
||||
|
||||
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
|
||||
|
||||
|
||||
async def fill_content(
|
||||
content: str,
|
||||
layout_concept: dict[str, Any],
|
||||
analysis: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""각 페이지의 각 블록 슬롯에 텍스트를 채운다.
|
||||
"""3단계: 각 페이지의 각 블록 슬롯에 텍스트를 채운다.
|
||||
|
||||
Args:
|
||||
content: 원본 텍스트 콘텐츠
|
||||
layout_concept: 디자인 팀장의 레이아웃 컨셉
|
||||
{"title": "...", "pages": [{"blocks": [...]}]}
|
||||
analysis: 1단계 실장의 꼭지 분석 결과 (참고용)
|
||||
|
||||
Returns:
|
||||
슬롯이 채워진 layout_concept (pages[n].blocks[m].data에 텍스트 추가)
|
||||
슬롯이 채워진 layout_concept
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
@@ -40,51 +67,39 @@ async def fill_content(
|
||||
if not blocks:
|
||||
continue
|
||||
|
||||
# 슬롯 요구사항 생성
|
||||
# 블록별 슬롯 + 글자 수 가이드 생성
|
||||
slot_requirements = []
|
||||
for i, block in enumerate(blocks):
|
||||
block_type = block["type"]
|
||||
block_type = block.get("type", "")
|
||||
slots = BLOCK_SLOTS.get(block_type, {})
|
||||
slot_requirements.append(
|
||||
f"블록 {i+1} ({block_type}, 영역: {block['area']}):\n"
|
||||
char_guide = block.get("char_guide", {})
|
||||
|
||||
req_text = (
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}):\n"
|
||||
f" 용도: {block.get('reason', '미지정')}\n"
|
||||
f" 크기: {block.get('size', 'medium')}\n"
|
||||
f" 필수 슬롯: {slots.get('required', [])}\n"
|
||||
f" 선택 슬롯: {slots.get('optional', [])}\n"
|
||||
f" 용도: {block.get('reason', '미지정')}"
|
||||
f" 선택 슬롯: {slots.get('optional', [])}"
|
||||
)
|
||||
|
||||
system_prompt = (
|
||||
"당신은 도메인 전문가이자 콘텐츠 편집자이다.\n"
|
||||
"원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.\n\n"
|
||||
"## 규칙\n"
|
||||
"- 핵심 내용과 맥락을 보존한다. 과도한 요약 금지.\n"
|
||||
"- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.\n"
|
||||
"- 출처가 있는 내용은 출처를 보존한다.\n"
|
||||
"- 출처가 없는 수치나 통계를 만들지 않는다.\n"
|
||||
"- 각 슬롯의 분량을 지킨다:\n"
|
||||
" - 제목(title): 최대 30자\n"
|
||||
" - 본문(content/description): 최대 200자\n"
|
||||
" - 설명(subtitle/source): 최대 80자\n"
|
||||
" - 카드 설명: 카드당 최대 150자\n"
|
||||
"- JSON 형식으로만 응답한다. 설명 없이 JSON만.\n\n"
|
||||
"## 슬롯 구조 참고\n"
|
||||
"- comparison: {left_title, left_content, right_title, right_content}\n"
|
||||
"- card-grid: {cards: [{title, description, category?, source?}]}\n"
|
||||
"- relationship: {center_label, center_sub?, items: [{label, color?}], description?}\n"
|
||||
"- process: {steps: [{title, description?, number?}]}\n"
|
||||
"- quote-block: {quote_text, source?}\n"
|
||||
"- conclusion-bar: {conclusion_text, label?}\n"
|
||||
"- comparison-table: {headers: [...], rows: [[...], ...]}\n"
|
||||
)
|
||||
if char_guide:
|
||||
guide_lines = [f" {k}: ~{v}자" for k, v in char_guide.items()]
|
||||
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
|
||||
|
||||
page_label = f"(페이지 {page_idx + 1}/{len(layout_concept['pages'])})" if len(layout_concept['pages']) > 1 else ""
|
||||
slot_requirements.append(req_text)
|
||||
|
||||
page_label = ""
|
||||
if len(layout_concept.get("pages", [])) > 1:
|
||||
page_label = f" (페이지 {page_idx + 1}/{len(layout_concept['pages'])})"
|
||||
|
||||
user_prompt = (
|
||||
f"## 원본 콘텐츠\n{content}\n\n"
|
||||
f"## 블록 배치 {page_label}\n"
|
||||
f"## 블록 배치{page_label}\n"
|
||||
+ "\n".join(slot_requirements)
|
||||
+ "\n\n## 요청\n"
|
||||
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
|
||||
"원본의 핵심 내용을 충실하게 반영하되, 각 슬롯 분량에 맞게 편집해.\n"
|
||||
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
|
||||
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
|
||||
"형식:\n"
|
||||
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
|
||||
)
|
||||
@@ -93,7 +108,7 @@ async def fill_content(
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
@@ -103,7 +118,7 @@ async def fill_content(
|
||||
if filled and "blocks" in filled:
|
||||
for filled_block in filled["blocks"]:
|
||||
for orig_block in blocks:
|
||||
if orig_block["area"] == filled_block.get("area"):
|
||||
if orig_block.get("area") == filled_block.get("area"):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
|
||||
@@ -112,7 +127,7 @@ async def fill_content(
|
||||
f"{len(filled['blocks'])}개 블록"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값 사용.")
|
||||
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
|
||||
_apply_defaults(blocks)
|
||||
|
||||
except Exception as e:
|
||||
@@ -138,18 +153,20 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
|
||||
},
|
||||
"process": {"steps": []},
|
||||
"comparison-table": {"headers": [], "rows": []},
|
||||
"image-block": {"src": "", "alt": "이미지"},
|
||||
"details-block": {"summary_text": "(상세 내용)", "detail_content": ""},
|
||||
}
|
||||
for block in blocks:
|
||||
if "data" not in block:
|
||||
block["data"] = defaults.get(block["type"], {})
|
||||
block["data"] = defaults.get(block.get("type", ""), {})
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다."""
|
||||
patterns = [
|
||||
r'```json\s*(.*?)```',
|
||||
r'```\s*(.*?)```',
|
||||
r'(\{.*\})',
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
|
||||
Reference in New Issue
Block a user