Files
web-page-backend/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md
gahusb 01bb837525 docs(insta-lab): design_importer — placeholder 텍스트 마스킹 요구 추가
사용자 디자인 PNG에 placeholder 텍스트가 이미 박혀있는 경우 대응.
Vision system prompt에 두 layer 요구:
(a) 마스킹 박스: placeholder 영역 좌표 + 주변 배경색으로 덮음
(b) 동적 텍스트 layer: 동일 좌표에 새 카피, 원본 폰트 스타일 모방
+ overflow:hidden으로 긴 카피가 박스 밖 새지 않게.

spec 4-3 + plan Task 3 step 3 동시 패치.
2026-05-17 20:54:00 +09:00

1020 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 매치 |