Files
web-page-backend/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md
gahusb 8ceb0af736 docs(insta-lab): design_importer implementation plan (8 TDD tasks)
페이지 매핑 → 이미지 검증 → Vision 호출 → Jinja sanity → 백업 저장 →
CLI → card_renderer 폴백 → env/compose/CLAUDE.md 통합. Vision은
모든 테스트에서 mock, 실제 호출은 운영 NAS에서 수동 (~$0.05/import).
2026-05-17 20:52:28 +09:00

34 KiB
Raw Blame History

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 동기화 확인

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 브랜치 생성
git checkout -b feat/insta-design-importer

Expected: Switched to a new branch 'feat/insta-design-importer'

  • Step 3: insta-lab 기존 테스트 baseline
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

"""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: 테스트 실패 확인
cd insta-lab && pytest tests/test_design_importer.py -v

Expected: ImportError (design_importer 모듈 없음).

  • Step 3: 구현insta-lab/app/design_importer.py 생성
"""사용자 디자인 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)을 호출하도록 정리. 최종 형태:
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: 테스트 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v

Expected: 3 PASS.

  • Step 6: Commit
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 끝에 추가:

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: 실패 확인
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에 함수 추가
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: 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v

Expected: 5 PASS (Task 1의 3개 + 신규 2개).

  • Step 5: Commit
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 끝에 추가:

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: 실패 확인
cd insta-lab && pytest tests/test_design_importer.py -v

Expected: 새 3개 test가 ImportError/AttributeError로 실패.

  • Step 3: 구현 — design_importer.py에 함수 추가

파일 상단 import 추가:

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

함수 추가 (파일 끝에):

_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.

입력: 10장의 카드 디자인 이미지 (각 1080×1350) + 파일명 → 페이지 번호 매핑.
출력: 단일 Jinja2 HTML 파일 본문 (코드펜스, 설명 텍스트 금지).

요구사항:
- 컨테이너 width 1080px, height 1350px
- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드
- 그 위에 텍스트 layer (headline, body, cta) — 원본 디자인에서 텍스트가 있던 위치·크기·색을 모방
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본
- 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: 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v

Expected: 8 PASS (Task 1·2의 5개 + 신규 3개).

  • Step 5: Commit
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__ 추가

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만)
cd insta-lab && python -m app.design_importer --help

Expected: argparse usage 메시지 출력 (theme_name 인자 + --templates-dir 옵션 표시).

  • Step 3: Commit
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 끝에:

@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: 실패 확인
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.pyrender_slate 시작부 수정

기존 (대략 line 50 근처):

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)

수정:

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: 통과 확인
cd insta-lab && pytest tests/test_card_renderer.py -v

Expected: 모두 PASS (기존 1 + 신규 1).

  • Step 5: Commit
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 라인 다음에:

INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
  • Step 2: insta-lab/app/main.py 변경

상단 import에 추가:

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 호출:

        await card_renderer.render_slate(sid)

        await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")

_bg_render도 동일 변경 (재렌더 endpoint도 같은 theme 적용):

        await card_renderer.render_slate(slate_id)

        await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
  • Step 3: docker-compose.yml insta-lab env에 추가
      - 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 검증
cd insta-lab && pytest -v 2>&1 | tail -5

Expected: 모두 PASS. 새 변경이 기존 test 깨뜨리면 fix 후 진행.

  • Step 5: docker-compose syntax 검증
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
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 목록 표 직전 또는 직후에 추가:

**디자인 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
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
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 실행
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 만들어 명시:

# 로컬 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 활성화
# 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: 시각 검증
# 새 카드 슬레이트 생성 (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 매치