사용자 디자인 PNG에 placeholder 텍스트가 이미 박혀있는 경우 대응. Vision system prompt에 두 layer 요구: (a) 마스킹 박스: placeholder 영역 좌표 + 주변 배경색으로 덮음 (b) 동적 텍스트 layer: 동일 좌표에 새 카피, 원본 폰트 스타일 모방 + overflow:hidden으로 긴 카피가 박스 밖 새지 않게. spec 4-3 + plan Task 3 step 3 동시 패치.
1020 lines
35 KiB
Markdown
1020 lines
35 KiB
Markdown
# insta-lab Design Importer Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 사용자가 `insta-lab/app/templates/<theme>/pages/`에 둔 카드 디자인 PNG 10장을 Claude Sonnet Vision으로 분석해 단일 `card.html.j2`를 자동 생성하고, 새 theme를 env로 활성화해 카드 렌더에 반영한다.
|
||
|
||
**Architecture:** 신규 `design_importer.py` 모듈이 (a) 파일명 자동 매핑 (b) 이미지 검증 (c) Vision API 호출 (d) Jinja sanity render (e) HTML 저장 + 백업까지 처리. CLI 진입점 `python -m app.design_importer <theme>`. 카드 렌더는 `INSTA_DEFAULT_THEME` env로 어떤 theme의 HTML을 쓸지 결정하며, theme HTML이 없으면 default로 폴백.
|
||
|
||
**Tech Stack:** Python 3.12, anthropic 0.52 (Claude Sonnet Vision), Jinja2, Pillow (이미지 dimension 검증), pytest.
|
||
|
||
**Spec reference:** `docs/superpowers/specs/2026-05-17-insta-design-importer-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Files to create
|
||
|
||
| Path | Responsibility |
|
||
|------|----------------|
|
||
| `insta-lab/app/design_importer.py` | 페이지 매핑 + 이미지 검증 + Vision 호출 + Jinja 검증 + 저장. CLI `__main__` 포함 |
|
||
| `insta-lab/tests/test_design_importer.py` | 6 케이스 (매핑 / 검증 / Vision mock / Jinja 폴백) |
|
||
|
||
### Files to modify
|
||
|
||
| Path | Change |
|
||
|------|--------|
|
||
| `insta-lab/app/config.py` | `INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")` 추가 |
|
||
| `insta-lab/app/main.py` | `_bg_create_slate`에서 `render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")` |
|
||
| `insta-lab/app/card_renderer.py` | `render_slate` 시작부에 template 파일 존재 가드 (없으면 default 폴백) |
|
||
| `insta-lab/tests/test_card_renderer.py` | 폴백 가드 테스트 1개 추가 |
|
||
| `docker-compose.yml` | insta-lab env에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가 |
|
||
| `CLAUDE.md` | section 9 insta-lab에 design_importer + INSTA_DEFAULT_THEME + CLI 항목 추가 |
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
- 작업 디렉토리: `C:\Users\jaeoh\Desktop\workspace\web-backend`. 모든 commit은 feat 브랜치 `feat/insta-design-importer`에서.
|
||
- TDD 엄수: failing test → 실패 확인 → 구현 → 통과 확인 → commit.
|
||
- Windows-safe SQLite cleanup 불필요 (DB 미사용 모듈).
|
||
- Anthropic Vision은 모든 테스트에서 mock — 실제 API 호출 금지.
|
||
- Pillow 이미지 dimension 검증 시 실제 PNG fixture는 가벼운 1080×1350 단색 PNG로 임시 생성.
|
||
|
||
---
|
||
|
||
## Task 0: Branch + scaffold
|
||
|
||
**Files:**
|
||
- No code changes.
|
||
|
||
- [ ] **Step 1: 현재 main 동기화 확인**
|
||
|
||
```bash
|
||
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||
git checkout main
|
||
git pull --ff-only origin main
|
||
git log --oneline -3
|
||
```
|
||
Expected: 최근 commit이 `077d411` 또는 그 이후 (spec commit `ecf1f64` 포함).
|
||
|
||
- [ ] **Step 2: feat 브랜치 생성**
|
||
|
||
```bash
|
||
git checkout -b feat/insta-design-importer
|
||
```
|
||
Expected: `Switched to a new branch 'feat/insta-design-importer'`
|
||
|
||
- [ ] **Step 3: insta-lab 기존 테스트 baseline**
|
||
|
||
```bash
|
||
cd insta-lab && pytest -q 2>&1 | tail -3
|
||
```
|
||
Expected: 모두 PASS. 통과 못 하면 baseline부터 fix 후 plan 진행.
|
||
|
||
---
|
||
|
||
## Task 1: `_resolve_page_mapping` (파일명 자동 매핑 + `_order.json` override)
|
||
|
||
**Files:**
|
||
- Create: `insta-lab/app/design_importer.py` (이 task에서 페이지 매핑 부분만)
|
||
- Create: `insta-lab/tests/test_design_importer.py` (이 task에서 매핑 테스트 3건만)
|
||
|
||
- [ ] **Step 1: failing test 작성** `insta-lab/tests/test_design_importer.py`
|
||
|
||
```python
|
||
"""design_importer 회귀 테스트."""
|
||
import json
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app import design_importer
|
||
|
||
|
||
@pytest.fixture
|
||
def tmp_theme(tmp_path):
|
||
"""templates/<theme>/pages/ 구조를 가진 임시 디렉토리."""
|
||
pages = tmp_path / "minimal" / "pages"
|
||
pages.mkdir(parents=True)
|
||
return tmp_path / "minimal"
|
||
|
||
|
||
def _touch(pages_dir: Path, names: list[str]):
|
||
for n in names:
|
||
(pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함
|
||
|
||
|
||
def test_auto_page_mapping_with_cover_and_cta(tmp_theme):
|
||
"""cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9."""
|
||
_touch(tmp_theme / "pages", [
|
||
"insta_card_start.png", # start → page 1 (cover priority)
|
||
"insta_card_keyword.png",
|
||
"insta_card_highlight.png",
|
||
"insta_card_observation.png",
|
||
"insta_card_memo.png",
|
||
"insta_card_oneline.png",
|
||
"insta_card_checklist.png",
|
||
"insta_card_study.png",
|
||
"insta_card_cta.png", # cta → page 10
|
||
"insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로
|
||
])
|
||
mapping = design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||
assert mapping["insta_card_start.png"] == 1
|
||
assert mapping["insta_card_cta.png"] == 10
|
||
# 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study
|
||
body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9}
|
||
assert body_pages[2] == "insta_card_checklist.png"
|
||
assert body_pages[3] == "insta_card_finish.png"
|
||
assert body_pages[9] == "insta_card_study.png"
|
||
assert set(mapping.values()) == set(range(1, 11))
|
||
|
||
|
||
def test_explicit_order_json_overrides_auto_mapping(tmp_theme):
|
||
"""_order.json이 있으면 자동 매핑보다 우선."""
|
||
pages = tmp_theme / "pages"
|
||
_touch(pages, [
|
||
"insta_card_start.png",
|
||
"insta_card_cta.png",
|
||
"insta_card_finish.png",
|
||
] + [f"insta_card_body{i}.png" for i in range(1, 8)])
|
||
(pages / "_order.json").write_text(json.dumps({
|
||
"insta_card_start.png": 1,
|
||
"insta_card_finish.png": 10, # cta 대신 finish를 page 10으로
|
||
"insta_card_cta.png": 5, # cta를 본문 한가운데로 강제
|
||
"insta_card_body1.png": 2,
|
||
"insta_card_body2.png": 3,
|
||
"insta_card_body3.png": 4,
|
||
"insta_card_body4.png": 6,
|
||
"insta_card_body5.png": 7,
|
||
"insta_card_body6.png": 8,
|
||
"insta_card_body7.png": 9,
|
||
}), encoding="utf-8")
|
||
mapping = design_importer._resolve_page_mapping(pages)
|
||
assert mapping["insta_card_finish.png"] == 10
|
||
assert mapping["insta_card_cta.png"] == 5
|
||
assert mapping["insta_card_start.png"] == 1
|
||
|
||
|
||
def test_validates_exactly_ten_pngs(tmp_theme):
|
||
"""PNG가 정확히 10장이 아니면 ValueError."""
|
||
_touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장
|
||
with pytest.raises(ValueError, match="10"):
|
||
design_importer._resolve_page_mapping(tmp_theme / "pages")
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실패 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py -v
|
||
```
|
||
Expected: ImportError (design_importer 모듈 없음).
|
||
|
||
- [ ] **Step 3: 구현** — `insta-lab/app/design_importer.py` 생성
|
||
|
||
```python
|
||
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||
|
||
CLI: python -m app.design_importer <theme_name>
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Dict, List
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||
# 페이지 10 (CTA) 키워드 우선순위
|
||
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||
|
||
|
||
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||
"""templates/<theme>/pages/ 안의 PNG 10장을 page 1~10에 매핑.
|
||
|
||
우선순위:
|
||
1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 후 반환)
|
||
2. 자동 매핑:
|
||
- _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1
|
||
- _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10
|
||
- 남은 8장은 알파벳 정렬 → page 2~9
|
||
"""
|
||
pages_dir = Path(pages_dir)
|
||
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||
if len(pngs) != 10:
|
||
raise ValueError(
|
||
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||
)
|
||
|
||
# _order.json override
|
||
order_path = pages_dir / "_order.json"
|
||
if order_path.exists():
|
||
try:
|
||
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||
else:
|
||
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||
return {k: int(v) for k, v in mapping.items()}
|
||
logger.warning(
|
||
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백: keys=%s values=%s",
|
||
sorted(mapping.keys()), sorted(mapping.values()),
|
||
)
|
||
|
||
# 자동 매핑
|
||
remaining = list(pngs)
|
||
cover_pick = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||
cta_pick = _pick_by_keywords(remaining, _CTA_KEYWORDS) if cover_pick != _CTA_KEYWORDS_FIRST_MATCH else None
|
||
# 위 한 줄은 명확하지 않으니 다음처럼 풀어쓰자
|
||
return _build_mapping(pngs)
|
||
|
||
|
||
_CTA_KEYWORDS_FIRST_MATCH = object() # sentinel, 사용 안 함
|
||
|
||
|
||
def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None:
|
||
"""names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None)."""
|
||
lower_names = [(n, n.lower()) for n in names]
|
||
for kw in keywords:
|
||
for orig, low in lower_names:
|
||
if kw in low:
|
||
return orig
|
||
return None
|
||
|
||
|
||
def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||
"""자동 매핑 알고리즘 본체."""
|
||
mapping: Dict[str, int] = {}
|
||
remaining = list(pngs)
|
||
|
||
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||
if cover:
|
||
mapping[cover] = 1
|
||
remaining.remove(cover)
|
||
|
||
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||
if cta:
|
||
mapping[cta] = 10
|
||
remaining.remove(cta)
|
||
|
||
# 남은 파일을 알파벳 정렬 후 비어있는 페이지 (2~9 우선, 1·10이 비었으면 거기도)
|
||
remaining_sorted = sorted(remaining)
|
||
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||
for name, page in zip(remaining_sorted, free_pages):
|
||
mapping[name] = page
|
||
|
||
return mapping
|
||
```
|
||
|
||
> 위 `_pick_by_keywords` 호출이 한 줄에 잘못 들어간 sentinel은 무시하고 `_build_mapping`만 사용. (다음 step에서 정리.)
|
||
|
||
- [ ] **Step 4: 잘못된 sentinel 라인 제거** — 위 구현에서 `cover_pick = ...` 라인부터 `_CTA_KEYWORDS_FIRST_MATCH = object()`까지 삭제하고 `_resolve_page_mapping`이 바로 `_build_mapping(pngs)`을 호출하도록 정리. 최종 형태:
|
||
|
||
```python
|
||
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||
pages_dir = Path(pages_dir)
|
||
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||
if len(pngs) != 10:
|
||
raise ValueError(
|
||
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||
)
|
||
|
||
order_path = pages_dir / "_order.json"
|
||
if order_path.exists():
|
||
try:
|
||
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||
else:
|
||
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||
return {k: int(v) for k, v in mapping.items()}
|
||
logger.warning(
|
||
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백"
|
||
)
|
||
|
||
return _build_mapping(pngs)
|
||
|
||
|
||
def _pick_by_keywords(names, keywords):
|
||
lower_names = [(n, n.lower()) for n in names]
|
||
for kw in keywords:
|
||
for orig, low in lower_names:
|
||
if kw in low:
|
||
return orig
|
||
return None
|
||
|
||
|
||
def _build_mapping(pngs):
|
||
mapping = {}
|
||
remaining = list(pngs)
|
||
|
||
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||
if cover:
|
||
mapping[cover] = 1
|
||
remaining.remove(cover)
|
||
|
||
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||
if cta:
|
||
mapping[cta] = 10
|
||
remaining.remove(cta)
|
||
|
||
remaining_sorted = sorted(remaining)
|
||
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||
for name, page in zip(remaining_sorted, free_pages):
|
||
mapping[name] = page
|
||
|
||
return mapping
|
||
```
|
||
|
||
- [ ] **Step 5: 테스트 통과 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py -v
|
||
```
|
||
Expected: 3 PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py
|
||
git commit -m "feat(insta-lab): design_importer page mapping (자동 + _order.json override)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `_validate_images` (이미지 dimension 검증)
|
||
|
||
**Files:**
|
||
- Modify: `insta-lab/app/design_importer.py`
|
||
- Modify: `insta-lab/tests/test_design_importer.py`
|
||
|
||
- [ ] **Step 1: failing test 추가**
|
||
|
||
`tests/test_design_importer.py` 끝에 추가:
|
||
|
||
```python
|
||
def _make_png(path: Path, size: tuple[int, int]) -> None:
|
||
"""size 픽셀의 단색 PNG를 생성."""
|
||
from PIL import Image
|
||
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
|
||
|
||
|
||
def test_validate_images_accepts_1080x1350(tmp_theme):
|
||
pages = tmp_theme / "pages"
|
||
for i in range(10):
|
||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||
# 예외 없이 통과해야 함
|
||
design_importer._validate_images(pages)
|
||
|
||
|
||
def test_validate_images_rejects_wrong_dimensions(tmp_theme):
|
||
pages = tmp_theme / "pages"
|
||
for i in range(10):
|
||
size = (800, 800) if i == 5 else (1080, 1350)
|
||
_make_png(pages / f"insta_card_{i:02d}.png", size)
|
||
with pytest.raises(ValueError, match="1080x1350"):
|
||
design_importer._validate_images(pages)
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py::test_validate_images_accepts_1080x1350 -v
|
||
```
|
||
Expected: AttributeError on `_validate_images`.
|
||
|
||
- [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가**
|
||
|
||
```python
|
||
from PIL import Image # 파일 상단 import 섹션에 추가
|
||
|
||
_EXPECTED_SIZE = (1080, 1350)
|
||
|
||
|
||
def _validate_images(pages_dir: Path) -> None:
|
||
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError."""
|
||
pages_dir = Path(pages_dir)
|
||
bad = []
|
||
for png_path in sorted(pages_dir.glob("*.png")):
|
||
with Image.open(png_path) as img:
|
||
if img.size != _EXPECTED_SIZE:
|
||
bad.append((png_path.name, img.size))
|
||
if bad:
|
||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||
raise ValueError(
|
||
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py -v
|
||
```
|
||
Expected: 5 PASS (Task 1의 3개 + 신규 2개).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py
|
||
git commit -m "feat(insta-lab): design_importer image dimension 검증 (1080x1350)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `import_design_theme` (Vision 호출 + Jinja sanity + 저장)
|
||
|
||
**Files:**
|
||
- Modify: `insta-lab/app/design_importer.py`
|
||
- Modify: `insta-lab/tests/test_design_importer.py`
|
||
|
||
- [ ] **Step 1: failing test 추가**
|
||
|
||
`tests/test_design_importer.py` 끝에 추가:
|
||
|
||
```python
|
||
def test_import_design_theme_writes_html_via_mocked_vision(tmp_theme, monkeypatch):
|
||
"""Vision mock이 정상 HTML 반환 시 card.html.j2 파일이 저장되고 결과 dict 반환."""
|
||
pages = tmp_theme / "pages"
|
||
names = [
|
||
"insta_card_start.png",
|
||
"insta_card_cta.png",
|
||
] + [f"insta_card_body{i}.png" for i in range(8)]
|
||
for n in names:
|
||
_make_png(pages / n, (1080, 1350))
|
||
|
||
fake_html = """<!DOCTYPE html><html><body>
|
||
{% if page_no == 1 %}<div class="cover">{{ headline }}</div>{% endif %}
|
||
{% if page_no >= 2 and page_no <= 9 %}<div class="body">{{ headline }}<p>{{ body }}</p></div>{% endif %}
|
||
{% if page_no == 10 %}<div class="cta">{{ headline }}<p>{{ cta }}</p></div>{% endif %}
|
||
</body></html>"""
|
||
|
||
def fake_vision_call(images_with_pages, theme_name):
|
||
return {"html": fake_html, "tokens": 12345, "summary": "test summary"}
|
||
|
||
monkeypatch.setattr(design_importer, "_call_vision", fake_vision_call)
|
||
|
||
result = design_importer.import_design_theme(str(tmp_theme))
|
||
assert result["theme_name"] == "minimal"
|
||
assert "card.html.j2" in result["html_path"]
|
||
assert (tmp_theme / "card.html.j2").exists()
|
||
assert (tmp_theme / "card.html.j2").read_text(encoding="utf-8") == fake_html
|
||
assert "insta_card_start.png" in result["page_mapping"]
|
||
assert result["tokens_used"] == 12345
|
||
|
||
|
||
def test_import_design_theme_raises_on_jinja_parse_failure(tmp_theme, monkeypatch):
|
||
"""Vision이 깨진 Jinja 반환 시 ValueError + .error.txt 보존."""
|
||
pages = tmp_theme / "pages"
|
||
for i in range(10):
|
||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||
|
||
broken_html = "<div>{% if page_no == 1 unclosed"
|
||
|
||
monkeypatch.setattr(design_importer, "_call_vision",
|
||
lambda imgs, name: {"html": broken_html, "tokens": 100, "summary": ""})
|
||
|
||
with pytest.raises(ValueError, match="Jinja"):
|
||
design_importer.import_design_theme(str(tmp_theme))
|
||
assert (tmp_theme / "card.html.j2.error.txt").exists()
|
||
|
||
|
||
def test_import_design_theme_backs_up_existing_html(tmp_theme, monkeypatch):
|
||
"""기존 card.html.j2가 있으면 .bak.YYYYMMDD-HHMMSS로 백업 후 새로 작성."""
|
||
pages = tmp_theme / "pages"
|
||
for i in range(10):
|
||
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
|
||
(tmp_theme / "card.html.j2").write_text("OLD HTML", encoding="utf-8")
|
||
|
||
monkeypatch.setattr(design_importer, "_call_vision",
|
||
lambda imgs, name: {"html": "<div>{{ headline }}</div>", "tokens": 50, "summary": ""})
|
||
|
||
design_importer.import_design_theme(str(tmp_theme))
|
||
# .bak.* 파일이 생성되었어야 함
|
||
backups = list(tmp_theme.glob("card.html.j2.bak.*"))
|
||
assert len(backups) == 1
|
||
assert backups[0].read_text(encoding="utf-8") == "OLD HTML"
|
||
# 새 파일은 새 내용
|
||
assert "headline" in (tmp_theme / "card.html.j2").read_text(encoding="utf-8")
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py -v
|
||
```
|
||
Expected: 새 3개 test가 ImportError/AttributeError로 실패.
|
||
|
||
- [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가**
|
||
|
||
파일 상단 import 추가:
|
||
```python
|
||
import base64
|
||
import datetime
|
||
from typing import Any, Dict, List, Tuple
|
||
|
||
from anthropic import Anthropic
|
||
from jinja2 import Environment, BaseLoader, TemplateSyntaxError
|
||
|
||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||
```
|
||
|
||
함수 추가 (파일 끝에):
|
||
```python
|
||
_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||
|
||
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑.
|
||
출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지).
|
||
|
||
핵심 제약 — placeholder 텍스트 마스킹:
|
||
PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때
|
||
원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라:
|
||
(a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표.
|
||
background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유.
|
||
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||
font-size·font-weight·color는 원본 placeholder의 스타일을 모방.
|
||
{{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용.
|
||
|
||
페이지 종류별 영역 가이드:
|
||
- page 1 (cover): 메인 headline 1개 영역
|
||
- page 2~9 (body): headline 영역 + body 영역
|
||
- page 10 (cta): headline + body + cta 영역
|
||
|
||
요구사항:
|
||
- 컨테이너 width 1080px, height 1350px
|
||
- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드
|
||
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||
- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본
|
||
- 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록)
|
||
- HTML <head>에 <style>로 모든 CSS 인라인. <link> 외부 stylesheet 금지
|
||
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 문서
|
||
"""
|
||
|
||
|
||
def _call_vision(images_with_pages: List[Tuple[str, int, bytes]],
|
||
theme_name: str) -> Dict[str, Any]:
|
||
"""Claude Sonnet Vision 호출. images_with_pages: [(filename, page_no, png_bytes), ...].
|
||
|
||
Returns: {"html": str, "tokens": int, "summary": str}
|
||
"""
|
||
if not ANTHROPIC_API_KEY:
|
||
raise RuntimeError("ANTHROPIC_API_KEY 미설정 — design_importer 사용 불가")
|
||
|
||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||
content: List[Dict[str, Any]] = []
|
||
for filename, page_no, png_bytes in sorted(images_with_pages, key=lambda x: x[1]):
|
||
content.append({
|
||
"type": "image",
|
||
"source": {
|
||
"type": "base64",
|
||
"media_type": "image/png",
|
||
"data": base64.b64encode(png_bytes).decode("ascii"),
|
||
},
|
||
})
|
||
content.append({
|
||
"type": "text",
|
||
"text": f"위 이미지 = '{filename}' = page {page_no}",
|
||
})
|
||
content.append({
|
||
"type": "text",
|
||
"text": (
|
||
f"theme 이름: '{theme_name}'. 위 10장 디자인을 모방한 단일 Jinja2 HTML을 출력해라."
|
||
),
|
||
})
|
||
|
||
msg = client.messages.create(
|
||
model=ANTHROPIC_MODEL_SONNET,
|
||
max_tokens=16000,
|
||
system=_VISION_SYSTEM_PROMPT,
|
||
messages=[{"role": "user", "content": content}],
|
||
)
|
||
raw = msg.content[0].text.strip()
|
||
# 코드펜스 자르기
|
||
if raw.startswith("```"):
|
||
raw = re.sub(r"^```(?:html)?\s*|\s*```$", "", raw).strip()
|
||
summary = raw[:200].replace("\n", " ") # 첫 200자만 분석 요약으로
|
||
return {"html": raw, "tokens": msg.usage.input_tokens + msg.usage.output_tokens,
|
||
"summary": summary}
|
||
|
||
|
||
def _validate_html_template(html: str) -> None:
|
||
"""Jinja2 Environment로 sanity render. 문법 오류면 TemplateSyntaxError 전파."""
|
||
env = Environment(loader=BaseLoader())
|
||
env.from_string(html) # 파싱만으로도 syntax error 검출
|
||
|
||
|
||
def import_design_theme(theme_dir: str) -> Dict[str, Any]:
|
||
"""templates/<theme>/pages/*.png 10장 → Vision → card.html.j2 생성.
|
||
|
||
Args:
|
||
theme_dir: theme 디렉토리 절대 경로 (예: /app/app/templates/minimal)
|
||
Returns:
|
||
{theme_name, html_path, page_mapping, analysis_summary, tokens_used}
|
||
"""
|
||
theme_path = Path(theme_dir)
|
||
theme_name = theme_path.name
|
||
pages_dir = theme_path / "pages"
|
||
|
||
# 1. 매핑 + 검증
|
||
mapping = _resolve_page_mapping(pages_dir)
|
||
_validate_images(pages_dir)
|
||
|
||
# 2. Vision 호출
|
||
images_with_pages = []
|
||
for filename, page_no in mapping.items():
|
||
png_bytes = (pages_dir / filename).read_bytes()
|
||
images_with_pages.append((filename, page_no, png_bytes))
|
||
|
||
vision_result = _call_vision(images_with_pages, theme_name)
|
||
html = vision_result["html"]
|
||
|
||
# 3. Jinja sanity
|
||
html_path = theme_path / "card.html.j2"
|
||
try:
|
||
_validate_html_template(html)
|
||
except TemplateSyntaxError as e:
|
||
error_path = theme_path / "card.html.j2.error.txt"
|
||
error_path.write_text(html, encoding="utf-8")
|
||
raise ValueError(
|
||
f"Vision 응답이 Jinja 문법 오류: {e}. 원본 HTML은 {error_path}에 저장됨"
|
||
)
|
||
|
||
# 4. 백업 + 저장
|
||
if html_path.exists():
|
||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
backup_path = theme_path / f"card.html.j2.bak.{ts}"
|
||
html_path.rename(backup_path)
|
||
logger.info("기존 HTML 백업: %s", backup_path)
|
||
|
||
html_path.write_text(html, encoding="utf-8")
|
||
|
||
return {
|
||
"theme_name": theme_name,
|
||
"html_path": str(html_path),
|
||
"page_mapping": mapping,
|
||
"analysis_summary": vision_result["summary"],
|
||
"tokens_used": vision_result["tokens"],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_design_importer.py -v
|
||
```
|
||
Expected: 8 PASS (Task 1·2의 5개 + 신규 3개).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py
|
||
git commit -m "feat(insta-lab): import_design_theme — Vision 호출 + Jinja sanity + 백업 저장"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: CLI entrypoint `__main__`
|
||
|
||
**Files:**
|
||
- Modify: `insta-lab/app/design_importer.py`
|
||
|
||
- [ ] **Step 1: 파일 끝에 `__main__` 추가**
|
||
|
||
```python
|
||
def main_cli():
|
||
"""CLI: python -m app.design_importer <theme_name> [--templates-dir PATH]"""
|
||
import argparse
|
||
parser = argparse.ArgumentParser(
|
||
prog="design_importer",
|
||
description="사용자 카드 디자인 PNG 10장을 Claude Vision으로 분석해 card.html.j2 생성",
|
||
)
|
||
parser.add_argument("theme_name", help="templates/<theme_name>/ 디렉토리명")
|
||
parser.add_argument(
|
||
"--templates-dir",
|
||
default="/app/app/templates",
|
||
help="templates 루트 디렉토리 (기본 컨테이너 내부 경로)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
theme_dir = Path(args.templates_dir) / args.theme_name
|
||
if not theme_dir.is_dir():
|
||
print(f"ERROR: theme 디렉토리 없음: {theme_dir}")
|
||
raise SystemExit(1)
|
||
|
||
try:
|
||
result = import_design_theme(str(theme_dir))
|
||
except Exception as e:
|
||
print(f"ERROR: {e}")
|
||
raise SystemExit(1)
|
||
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_cli()
|
||
```
|
||
|
||
- [ ] **Step 2: CLI 동작 sanity (no Vision 호출, --help만)**
|
||
|
||
```bash
|
||
cd insta-lab && python -m app.design_importer --help
|
||
```
|
||
Expected: argparse usage 메시지 출력 (theme_name 인자 + --templates-dir 옵션 표시).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/design_importer.py
|
||
git commit -m "feat(insta-lab): design_importer CLI entrypoint (python -m app.design_importer)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: card_renderer 폴백 가드
|
||
|
||
**Files:**
|
||
- Modify: `insta-lab/app/card_renderer.py`
|
||
- Modify: `insta-lab/tests/test_card_renderer.py`
|
||
|
||
- [ ] **Step 1: failing test 추가** `insta-lab/tests/test_card_renderer.py` 끝에:
|
||
|
||
```python
|
||
@pytest.mark.asyncio
|
||
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
|
||
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
|
||
sid = _seed_slate()
|
||
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
|
||
assert len(paths) == 10
|
||
for p in paths:
|
||
assert os.path.exists(p)
|
||
assert os.path.getsize(p) > 1000
|
||
```
|
||
|
||
- [ ] **Step 2: 실패 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_card_renderer.py::test_render_falls_back_to_default_when_theme_html_missing -v
|
||
```
|
||
Expected: jinja2 TemplateNotFound 또는 유사 에러.
|
||
|
||
- [ ] **Step 3: 구현** — `card_renderer.py`의 `render_slate` 시작부 수정
|
||
|
||
기존 (대략 line 50 근처):
|
||
```python
|
||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||
slate = db.get_card_slate(slate_id)
|
||
if not slate:
|
||
raise ValueError(f"slate {slate_id} not found")
|
||
env = _env()
|
||
tmpl = env.get_template(template)
|
||
```
|
||
|
||
수정:
|
||
```python
|
||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||
slate = db.get_card_slate(slate_id)
|
||
if not slate:
|
||
raise ValueError(f"slate {slate_id} not found")
|
||
env = _env()
|
||
|
||
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
|
||
template_full = Path(_resolve_template_dir()) / template
|
||
if not template_full.exists():
|
||
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
|
||
template = "default/card.html.j2"
|
||
|
||
tmpl = env.get_template(template)
|
||
```
|
||
|
||
- [ ] **Step 4: 통과 확인**
|
||
|
||
```bash
|
||
cd insta-lab && pytest tests/test_card_renderer.py -v
|
||
```
|
||
Expected: 모두 PASS (기존 1 + 신규 1).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/card_renderer.py insta-lab/tests/test_card_renderer.py
|
||
git commit -m "feat(insta-lab): card_renderer theme 폴백 가드 (HTML 없으면 default)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: env + main.py + docker-compose 통합
|
||
|
||
**Files:**
|
||
- Modify: `insta-lab/app/config.py`
|
||
- Modify: `insta-lab/app/main.py`
|
||
- Modify: `docker-compose.yml`
|
||
|
||
- [ ] **Step 1: `insta-lab/app/config.py`에 추가**
|
||
|
||
기존 `CARD_TEMPLATE_DIR` 라인 다음에:
|
||
```python
|
||
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||
```
|
||
|
||
- [ ] **Step 2: `insta-lab/app/main.py` 변경**
|
||
|
||
상단 import에 추가:
|
||
```python
|
||
from .config import (
|
||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
||
INSTA_DEFAULT_THEME,
|
||
)
|
||
```
|
||
|
||
`_bg_create_slate` 함수 안의 render_slate 호출:
|
||
```python
|
||
await card_renderer.render_slate(sid)
|
||
```
|
||
→
|
||
```python
|
||
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||
```
|
||
|
||
`_bg_render`도 동일 변경 (재렌더 endpoint도 같은 theme 적용):
|
||
```python
|
||
await card_renderer.render_slate(slate_id)
|
||
```
|
||
→
|
||
```python
|
||
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||
```
|
||
|
||
- [ ] **Step 3: `docker-compose.yml` insta-lab env에 추가**
|
||
|
||
```yaml
|
||
- INSTA_DATA_PATH=/app/data
|
||
- CARD_TEMPLATE_DIR=/app/app/templates
|
||
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||
```
|
||
(`CARD_TEMPLATE_DIR` 다음 줄에 추가)
|
||
|
||
- [ ] **Step 4: 전체 insta-lab pytest 검증**
|
||
|
||
```bash
|
||
cd insta-lab && pytest -v 2>&1 | tail -5
|
||
```
|
||
Expected: 모두 PASS. 새 변경이 기존 test 깨뜨리면 fix 후 진행.
|
||
|
||
- [ ] **Step 5: docker-compose syntax 검증**
|
||
|
||
```bash
|
||
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||
python -c "import yaml; yaml.safe_load(open('docker-compose.yml', encoding='utf-8')); print('YAML OK')"
|
||
```
|
||
Expected: `YAML OK`
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add insta-lab/app/config.py insta-lab/app/main.py docker-compose.yml
|
||
git commit -m "feat(insta-lab): INSTA_DEFAULT_THEME env 통합 (main + docker-compose)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: CLAUDE.md insta-lab 섹션 갱신
|
||
|
||
**Files:**
|
||
- Modify: `CLAUDE.md`
|
||
|
||
- [ ] **Step 1: CLAUDE.md의 insta-lab 섹션 (section 9)에서 환경변수 목록 부분 찾기**
|
||
|
||
다음 패턴 검색:
|
||
```
|
||
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||
```
|
||
|
||
다음 라인 추가:
|
||
```
|
||
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||
```
|
||
|
||
- [ ] **Step 2: insta-lab 섹션에 "디자인 import" 항목 추가**
|
||
|
||
insta-lab API 목록 표 직전 또는 직후에 추가:
|
||
```markdown
|
||
**디자인 import (사용자 디자인 PNG → Jinja HTML)**
|
||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
|
||
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
|
||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "docs(claude-md): insta-lab section에 design_importer + INSTA_DEFAULT_THEME 항목"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: PR + 운영 활성화 안내
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: 브랜치 push**
|
||
|
||
```bash
|
||
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||
git log --oneline main..HEAD
|
||
git push -u origin feat/insta-design-importer
|
||
```
|
||
Expected: 7개 commit visible, PR 링크 출력.
|
||
|
||
- [ ] **Step 2: PR 머지 안내**
|
||
|
||
Gitea에서 PR 생성 + 머지. webhook이 자동으로 `design_importer.py`를 NAS 컨테이너에 배포.
|
||
|
||
- [ ] **Step 3: 머지 후 NAS에서 실제 디자인 import 실행**
|
||
|
||
```bash
|
||
ssh gahusb.synology.me
|
||
|
||
# minimal theme 디자인 변환 (15K 토큰 ≈ $0.05)
|
||
docker exec insta-lab python -m app.design_importer minimal
|
||
|
||
# 결과 dict JSON 확인 — page_mapping이 의도와 맞는지 검토
|
||
```
|
||
|
||
매핑이 의도와 다르면 (예: insta_card_finish.png가 본문 page 2에 가버림) `pages/_order.json` 만들어 명시:
|
||
```bash
|
||
# 로컬 web-backend에서
|
||
cat > insta-lab/app/templates/minimal/pages/_order.json <<'EOF'
|
||
{
|
||
"insta_card_start.png": 1,
|
||
"insta_card_keyword.png": 2,
|
||
"insta_card_highlight.png": 3,
|
||
"insta_card_observation.png": 4,
|
||
"insta_card_memo.png": 5,
|
||
"insta_card_oneline.png": 6,
|
||
"insta_card_checklist.png": 7,
|
||
"insta_card_study.png": 8,
|
||
"insta_card_finish.png": 9,
|
||
"insta_card_cta.png": 10
|
||
}
|
||
EOF
|
||
git add insta-lab/app/templates/minimal/pages/_order.json
|
||
git commit -m "feat(insta-lab): minimal theme page order override"
|
||
git push origin main
|
||
```
|
||
|
||
webhook 배포 후 NAS에서 importer 재실행.
|
||
|
||
- [ ] **Step 4: theme 활성화**
|
||
|
||
```bash
|
||
# NAS .env에 추가
|
||
ssh gahusb.synology.me
|
||
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env
|
||
cd /volume1/docker/webpage
|
||
docker compose restart insta-lab
|
||
```
|
||
|
||
- [ ] **Step 5: 시각 검증**
|
||
|
||
```bash
|
||
# 새 카드 슬레이트 생성 (Insta UI 또는 수동 트리거)
|
||
curl -X POST http://localhost:18700/api/insta/slates \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"keyword":"테스트", "category":"economy"}'
|
||
|
||
# task 폴링 후 새 슬레이트의 첫 페이지 PNG 받아보기
|
||
curl http://localhost:18700/api/insta/slates/<new_id>/assets/1 -o test_minimal_page1.png
|
||
```
|
||
|
||
PNG가 minimal 디자인 배경 + 동적 카피로 렌더되었으면 성공.
|
||
|
||
생성된 `card.html.j2`가 마음에 안 들면:
|
||
- `_order.json` 매핑 수정 후 importer 재실행
|
||
- 또는 `card.html.j2`를 직접 수동 편집 (.bak으로 롤백 가능)
|
||
|
||
---
|
||
|
||
## Verification matrix (PR 생성 전 모두 통과)
|
||
|
||
| Check | Command | Expected |
|
||
|-------|---------|----------|
|
||
| design_importer tests | `cd insta-lab && pytest tests/test_design_importer.py -v` | 8 PASS |
|
||
| card_renderer regression | `cd insta-lab && pytest tests/test_card_renderer.py -v` | 2 PASS (기존 1 + 신규 1) |
|
||
| insta-lab 전체 | `cd insta-lab && pytest -v` | 모두 PASS, 0 failures |
|
||
| CLI help | `cd insta-lab && python -m app.design_importer --help` | argparse usage 출력 |
|
||
| docker-compose | `python -c "import yaml; yaml.safe_load(open('docker-compose.yml', encoding='utf-8'))"` | YAML OK |
|
||
| 잔여 placeholder grep | `grep -n "TODO\|TBD" insta-lab/app/design_importer.py` | 0 매치 |
|