From 077d411f83169b69d042111e7193c7290039f52f Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 17 May 2026 20:46:41 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs(insta-lab):=20design=5Fimporter=20sp?= =?UTF-8?q?ec=20=E2=80=94=20Claude=20Vision=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=E2=86=92=20Jinja=20HTML=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 만든 카드 디자인 이미지 10장을 Claude Sonnet Vision으로 분석해 페이지별 텍스트 영역·색·레이아웃 모방한 단일 card.html.j2 자동 생성. 핵심 결정: - CLI 진입점 (MVP) — API endpoint는 후속 - env INSTA_DEFAULT_THEME 단일 theme — 슬레이트별 선택은 후속 - 파일명 자동 매핑 (cover→1, cta→10, 나머지 알파벳) + _order.json override - card_renderer 폴백 가드 (theme HTML 없으면 default로) --- ...2026-05-17-insta-design-importer-design.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-insta-design-importer-design.md diff --git a/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md b/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md new file mode 100644 index 0000000..b9c78f8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md @@ -0,0 +1,275 @@ +# insta-lab Design Importer — Claude Vision으로 이미지 디자인 → Jinja HTML 자동 생성 + +작성일: 2026-05-17 +상태: 사용자 승인 대기 → writing-plans 진입 예정 +연관 문서: `2026-05-15-insta-agent-design.md`, `2026-05-16-insta-trends-design.md`, `feedback_external_data_sources.md` + +--- + +## 1. 목적·배경 + +insta-lab의 카드 렌더는 현재 `templates/default/card.html.j2` 한 골격만 사용 (단순 그라데이션 + Noto Sans KR). 사용자가 직접 디자인한 10장 카드 이미지(`templates/minimal/pages/insta_card_*.png`)를 이미 NAS에 배포한 상태인데, 이 이미지들이 카드 렌더에 반영되지 않음. + +이 spec은 사용자가 만든 디자인 이미지를 **카드 렌더 파이프라인에 통합**하는 메커니즘을 정의한다. 핵심은 Claude Vision으로 10장 PNG를 분석해 페이지별 텍스트 영역·색·폰트·레이아웃을 도출하고, 이를 그대로 모방한 단일 Jinja2 HTML 파일을 자동 생성하는 것이다. 생성된 HTML은 동적 카피(headline, body, cta)를 사용자 디자인 위에 layer로 얹어 일관된 시각 + 동적 텍스트를 동시에 확보한다. + +--- + +## 2. 스코프 + +### 포함 + +- 신규 백엔드 모듈 `insta-lab/app/design_importer.py` — 10장 PNG → Claude Sonnet Vision → `card.html.j2` 생성 +- CLI 진입점 `python -m app.design_importer ` (운영자가 한 번씩 실행) +- 환경변수 `INSTA_DEFAULT_THEME` 신규 (default="default") — 모든 슬레이트가 이 theme 사용 +- `card_renderer.render_slate`에 theme 전달 (기존 `template` 인자 활용, 호출자만 변경) +- pytest: Vision 호출 mock + 출력 HTML 파싱 검증 + +### 제외 (후속) + +- API endpoint `POST /api/insta/templates/import` — UI에서 트리거 가능 +- `card_slates.theme` 컬럼 — 슬레이트별 다른 theme 선택 +- 다중 theme 비교/A·B 테스트 UI +- 자동 theme 추천 (트렌드 카테고리별 다른 theme) + +--- + +## 3. 데이터·디렉토리 구조 + +``` +insta-lab/app/templates/ +├── default/ # 기존 — 폴백 / 초기 골격 +│ ├── card.html.j2 +│ └── .gitkeep +└── / # 사용자 디자인 1세트 (반복 가능) + ├── pages/ # 사용자가 git commit으로 업로드 + │ ├── insta_card_start.png # 의미 있는 이름 권장 (Claude가 페이지 의도 파악에 활용) + │ ├── insta_card_keyword.png + │ ├── ... (총 10장) + │ └── README.md (선택, 디자인 의도 메모) + └── card.html.j2 # design_importer가 자동 생성 +``` + +**파일명 컨벤션**: +- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑: + 1. 파일명에 `cover`/`start`/`intro` 단어 포함 → page 1 (커버) + 2. 파일명에 `cta`/`outro`/`finish`/`end` 단어 포함 → page 10 (CTA) + 3. 나머지 8장은 알파벳 정렬 순으로 page 2~9 (본문) +- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, ...}` 명시 가능 (선택) + +--- + +## 4. 핵심 모듈 `design_importer.py` + +### 4-1. Public API + +```python +def import_design_theme(theme_name: str) -> dict: + """templates//pages/*.png 10장 → Claude Sonnet Vision → card.html.j2 생성. + + Returns: + { + "theme_name": str, + "html_path": str, + "page_mapping": {filename: page_no, ...}, + "analysis_summary": str, # Claude가 도출한 디자인 분석 짧은 요약 + "tokens_used": int, + } + + Raises: + ValueError: pages/ 폴더에 PNG 10장 미만이거나 매핑 실패 + anthropic.APIError: Vision 호출 실패 (retry 1회 후) + """ +``` + +### 4-2. 처리 흐름 + +1. `templates//pages/` 폴더 스캔 → PNG 10장 검증 (10장 정확히) +2. 파일명 → 페이지 매핑 결정 (3장 규칙 + 선택적 `_order.json` override) +3. 각 PNG base64 인코딩 +4. Claude Sonnet(`claude-sonnet-4-6`) Vision 호출 1회: + - 시스템 프롬프트: 디자이너 역할 + 출력 형식 명세 + - 사용자 메시지: 10장 이미지 + 페이지 매핑 정보 + 변수 명세 (`page_no`, `headline`, `body`, `cta`) + - 출력 요청: 단일 Jinja2 HTML 파일 (page_no 분기 + 텍스트 영역 절대 위치 CSS + `background-image: url('pages/{{filename}}')`) +5. 응답 HTML 파싱 + Jinja Environment로 sanity render 1회 (분기·문법 검증) +6. `templates//card.html.j2`에 저장 +7. dict 반환 + +### 4-3. Vision 프롬프트 스킴 + +시스템 프롬프트 (요약): +``` +너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다. +입력: 10장의 카드 디자인 이미지 (각 1080×1350) + 페이지 번호 매핑. +출력: 단일 Jinja2 HTML 파일. + +요구사항: +- 컨테이너 width 1080px, height 1350px +- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드 +- 그 위에 텍스트 layer (headline, body, cta) — 각 페이지의 원본 디자인에서 + 텍스트가 있던 위치·크기·색을 그대로 모방. 비어 있는 디자인 영역은 layer 위치 추정 +- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조 +- 폰트는 Noto Sans KR (기존 default 템플릿과 동일) +- 출력은 HTML 본문만 (```html 코드펜스 금지) +``` + +사용자 메시지에 각 이미지 + filename + page_no 매핑 포함. + +### 4-4. 캐시 / 재실행 정책 + +- 이미 `card.html.j2`가 존재하면 덮어쓰기 (사용자 명시적 재import 의도) +- 백업: 기존 HTML이 있으면 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 rename 후 새 파일 작성 +- 분석 결과 캐시 X (재실행할 때마다 최신 결과) + +--- + +## 5. CLI 진입점 + +```bash +# 컨테이너 내부에서 실행 +docker exec insta-lab python -m app.design_importer + +# 결과 stdout (예시) +{ + "theme_name": "minimal", + "html_path": "/app/app/templates/minimal/card.html.j2", + "page_mapping": { + "insta_card_start.png": 1, + "insta_card_keyword.png": 2, + ... + "insta_card_cta.png": 10 + }, + "analysis_summary": "미니멀 카드 — 흰 배경 + 검정 헤드라인 + 회색 본문...", + "tokens_used": 15234 +} +``` + +`__main__` 가드: argparse로 `theme_name` 위치 인자 + `--force` (기존 HTML 백업 없이 덮어쓰기) 옵션. 실패 시 exit 1. + +--- + +## 6. 카드 렌더 통합 + +### 6-1. 환경변수 추가 (`config.py`) + +```python +INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default") +``` + +### 6-2. `main.py:_bg_create_slate` 호출 변경 + +기존: +```python +await card_renderer.render_slate(sid) +``` + +신규: +```python +template_path = f"{INSTA_DEFAULT_THEME}/card.html.j2" +await card_renderer.render_slate(sid, template=template_path) +``` + +`card_renderer.render_slate`는 이미 `template` 인자를 받으며 default 값이 `"default/card.html.j2"`. 변경 없음. + +### 6-3. `card_renderer` 폴백 가드 + +`render_slate` 시작부에 template 파일 존재 확인 추가: +```python +template_full = Path(_resolve_template_dir()) / template +if not template_full.exists(): + logger.warning("Template %s 없음, default로 폴백", template) + template = "default/card.html.j2" +``` + +→ env에 `INSTA_DEFAULT_THEME=minimal` 설정했는데 `minimal/card.html.j2`가 아직 import 안 됐으면 자동 default 폴백. + +### 6-4. 운영 활성화 절차 + +```bash +# 1. 이미지 commit + push (이미 완료 — minimal/pages/ 10장) +# 2. NAS 머지 후 design_importer 실행 +docker exec insta-lab python -m app.design_importer minimal + +# 3. NAS .env에 추가 +echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env + +# 4. 컨테이너 재시작 (env 재로드) +docker compose restart insta-lab +``` + +--- + +## 7. 에러 처리 + +| 상황 | 처리 | +|------|------| +| `pages/` 폴더 없음 또는 PNG 10장 미만 | ValueError + 어떤 파일이 빠졌는지 명시. 모든 이미지가 1080×1350인지도 검증 (Pillow로 size 체크) | +| Vision 호출 실패 (network, rate limit) | retry 1회 (5초 대기), 그래도 실패 시 anthropic.APIError 전파 | +| Vision 응답이 HTML이 아님 / Jinja 문법 깨짐 | Jinja Environment로 sanity render 시도 → 실패 시 raw 응답을 `card.html.j2.error.txt`에 저장 + ValueError 전파 (운영자가 수동 수정 가능) | +| Vision 응답이 max_tokens(16K) 초과 → 잘림 | 응답 끝이 닫힌 `` 없으면 잘렸다고 판단, max_tokens 24K로 retry 1회 | +| 이미지 base64 인코딩 실패 (파일 깨짐) | 어느 파일이 문제인지 로그 + ValueError | +| `_order.json` 형식 깨짐 | log warning + 자동 매핑 규칙으로 폴백 | + +--- + +## 8. 테스트 + +### `insta-lab/tests/test_design_importer.py` (~6 케이스) + +1. `test_auto_page_mapping_with_cover_and_cta`: 의미 이름 파일 10개 → cover→1, cta→10, 나머지 알파벳 순 +2. `test_explicit_order_json_overrides`: `_order.json` 있으면 그것 우선 +3. `test_validates_exactly_ten_pngs`: 9장 또는 11장이면 ValueError +4. `test_validates_image_dimensions`: 1080×1350 아닌 이미지 있으면 ValueError + 어떤 파일인지 +5. `test_import_generates_html_via_mocked_claude`: Anthropic Vision mock, 응답 HTML이 Jinja 렌더 가능한 형식인지 검증 +6. `test_import_falls_back_on_jinja_parse_failure`: mock이 깨진 HTML 반환 시 ValueError + `.error.txt` 저장 + +### `insta-lab/tests/test_card_renderer.py` (기존, 보강 1개) + +7. `test_render_falls_back_to_default_when_theme_html_missing`: `template="ghost/card.html.j2"` 지정 시 파일 없어도 default로 폴백 + 정상 PNG 생성 + +--- + +## 9. 운영 영향 + +| 항목 | 영향 | +|------|------| +| Anthropic 토큰 비용 | +1회당 ~15K 토큰 (이미지 10장 × ~1K + 프롬프트 + HTML 출력). Claude Sonnet 단가 기준 ~$0.05/import. 자주 실행 X | +| 빌드 시간 | 영향 없음 (코드 변경만, 의존성 추가 없음) | +| 카드 렌더 시간 | 영향 없음 (Playwright는 background-image까지 wait_until="networkidle"로 처리) | +| 디스크 | 사용자 디자인 PNG 12MB (이미 push됨) + 자동 생성 HTML ~10KB | +| 운영 중 카드 품질 | env `INSTA_DEFAULT_THEME=minimal` 설정 후 다음 슬레이트부터 사용자 디자인 적용. 기존 슬레이트는 default 그대로 | + +--- + +## 10. 마이그레이션 절차 + +배포 후 사용자가 운영 NAS에서 수동 실행: + +1. PR 머지 → webhook으로 `design_importer.py` 코드 배포 + minimal/ 디렉토리는 이미 배포됨 +2. SSH NAS: + ```bash + docker exec insta-lab python -m app.design_importer minimal + ``` +3. 결과 JSON에서 `html_path`와 `page_mapping` 확인. 매핑이 의도와 다르면 `pages/_order.json`로 override 후 재실행 +4. `.env`에 `INSTA_DEFAULT_THEME=minimal` 추가 +5. `docker compose restart insta-lab` (env 재로드) +6. 새 슬레이트 1개 만들어서 시각 검증 (Insta 페이지 Trends 탭 또는 수동 트리거) + +생성된 `card.html.j2`가 마음에 안 들면: +- `pages/_order.json`으로 페이지 순서 조정 후 importer 재실행 +- 또는 자동 생성 HTML을 사용자가 직접 수정 (importer 재실행 안 함) +- 백업본 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 롤백 가능 + +--- + +## 11. 완료 정의 + +- [ ] `insta-lab/app/design_importer.py` 작성, CLI `python -m app.design_importer` 작동 +- [ ] `_resolve_page_mapping` + 의미 이름 기반 자동 매핑 + `_order.json` override +- [ ] Vision 호출 mock 기반 pytest 6 케이스 PASS +- [ ] `card_renderer.render_slate`에 theme 폴백 가드 추가, 테스트 1 케이스 PASS +- [ ] `insta-lab/app/config.py`에 `INSTA_DEFAULT_THEME` 추가 +- [ ] `insta-lab/app/main.py:_bg_create_slate`가 `INSTA_DEFAULT_THEME` 사용 +- [ ] `docker-compose.yml` insta-lab 환경변수에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가 +- [ ] CLAUDE.md 9.x insta-lab 섹션에 design_importer + INSTA_DEFAULT_THEME 항목 추가 +- [ ] 운영 NAS에서 `docker exec insta-lab python -m app.design_importer minimal` 실행 → `card.html.j2` 생성 확인 +- [ ] `.env` 설정 + 새 슬레이트 1개 생성 → 시각적으로 minimal 디자인 반영 확인 -- 2.49.1 From ecf1f643b21a5b2d61e8544746620913c3a5f856 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 17 May 2026 20:47:26 +0900 Subject: [PATCH 02/11] =?UTF-8?q?docs(insta-lab):=20design=5Fimporter=20sp?= =?UTF-8?q?ec=20=E2=80=94=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=B6=A9=EB=8F=8C=20=EC=B2=98=EB=A6=AC=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=20(=EC=85=80=ED=94=84=20=EB=A6=AC=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-05-17-insta-design-importer-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md b/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md index b9c78f8..7f1e987 100644 --- a/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md +++ b/docs/superpowers/specs/2026-05-17-insta-design-importer-design.md @@ -51,10 +51,12 @@ insta-lab/app/templates/ **파일명 컨벤션**: - 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑: - 1. 파일명에 `cover`/`start`/`intro` 단어 포함 → page 1 (커버) - 2. 파일명에 `cta`/`outro`/`finish`/`end` 단어 포함 → page 10 (CTA) - 3. 나머지 8장은 알파벳 정렬 순으로 page 2~9 (본문) -- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, ...}` 명시 가능 (선택) + 1. 파일명에 `cover` > `start` > `intro` 키워드 포함 (우선순위 순서) → page 1 (커버). 여러 파일이 매치되면 가장 앞 키워드를 가진 파일만 선택, 나머지는 본문 풀로 + 2. 파일명에 `cta` > `outro` > `finish` > `end` 키워드 포함 (우선순위 순서) → page 10. 동일하게 첫 매치만 page 10, 나머지는 본문 풀로 + 3. 남은 8장은 알파벳 정렬 순으로 page 2~9 (본문) +- **현재 운영 케이스**: `insta_card_start.png`(start=1순위) → page 1, `insta_card_cta.png`(cta=1순위) → page 10, `insta_card_finish.png`는 finish=3순위인데 cta가 이미 page 10이므로 본문 풀로 떨어져 알파벳 순에 따라 page 2~9 어딘가 배치됨 +- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, "insta_card_finish.png": 10, ...}` 명시 가능 (충돌·의도 명시 시 강력 권장) +- 매핑이 의도와 어긋나면 importer 실행 결과 dict의 `page_mapping` 필드로 확인 후 `_order.json` 추가하고 재실행 --- -- 2.49.1 From 8ceb0af7362fced9113973f4ec69f94e7f2f7c4a Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 17 May 2026 20:52:28 +0900 Subject: [PATCH 03/11] docs(insta-lab): design_importer implementation plan (8 TDD tasks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지 매핑 → 이미지 검증 → Vision 호출 → Jinja sanity → 백업 저장 → CLI → card_renderer 폴백 → env/compose/CLAUDE.md 통합. Vision은 모든 테스트에서 mock, 실제 호출은 운영 NAS에서 수동 (~$0.05/import). --- ...17-insta-design-importer-implementation.md | 1005 +++++++++++++++++ 1 file changed, 1005 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md diff --git a/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md b/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md new file mode 100644 index 0000000..223fc0d --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-insta-design-importer-implementation.md @@ -0,0 +1,1005 @@ +# 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//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 `. 카드 렌더는 `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//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 +""" + +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//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 = """ +{% if page_no == 1 %}
{{ headline }}
{% endif %} +{% if page_no >= 2 and page_no <= 9 %}
{{ headline }}

{{ body }}

{% endif %} +{% if page_no == 10 %}
{{ headline }}

{{ cta }}

{% endif %} +""" + + 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 = "
{% 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": "
{{ headline }}
", "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) + 파일명 → 페이지 번호 매핑. +출력: 단일 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 에