# 로스카 × 와디즈 메타 광고봇 — 최종 구축지시서 v1.0

> 목적: 일예산 10,000원 규모의 와디즈 펀딩 메타 광고를 **룰 엔진 95% + LLM 5%** 구조로 자동 운영한다.
> 설계 목표: ① 실전투입 가능 ② 최소 부품 ③ LLM 토큰비용 월 $2 이하 ④ 광고성과 극대화 장치 내장.
> 사용법: STEP 0부터 순서대로 실행. 각 STEP 끝의 [완료 기준]을 통과해야 다음 STEP 진행.

---

## 0. 설계 3원칙 (전 구성원 암기)

1. **LLM은 집행권이 없다.** 돈이 움직이는 모든 결정은 결정론적 룰 엔진 → 승인 → 실행 어댑터를 거친다. LLM은 소재 카피 초안과 애매 판정 보조만 담당한다.
2. **폴링 루프에 토큰 제로.** 수집·판단·알림·집행은 100% 코드다. LLM 호출은 주 단위 배치(소재 변주)와 예외 상황 분류뿐이다.
3. **지출 방어는 3중.** Meta 계정 지출한도(1층) + 봇 hard_limits(2층) + 실행기 환경변수 상한(3층). 봇이 죽어도 Meta가 막는다.

---

## 1. 아키텍처 (부품 7개, 서버 0~1대)

```
┌─ 기존 PC 또는 $5 VPS 1대 (cron) ─────────────────────────────┐
│                                                              │
│  [수집기 collector.py]  ← 6h 주기 (스퍼트 기간 3h)            │
│    ├─ Meta Insights (CLI --json 또는 Graph API)              │
│    └─ 와디즈 시트 CSV (게시 URL fetch, OAuth 불필요)          │
│           ↓ SQLite (adbot.sqlite 단일 파일 = DB 전체)         │
│  [페이즈 phase.py] 날짜 기반 상태머신                         │
│  [룰엔진 rules.py] 결정론 룰 20개 → decision JSON 생성        │
│           ↓                                                  │
│  [알림 notify.py] → Slack Webhook (리포트·경보·승인요청)      │
│  [실행기 actions.py] ← 사람이 승인 명령 실행 시에만 작동       │
│    └─ Meta Ads CLI (쓰기: 항상 PAUSED 생성, 상태/예산 변경)    │
│  [감사 audit] 모든 변경 전후값 SQLite 기록 + 롤백 명령         │
└──────────────────────────────────────────────────────────────┘
   외부 의존: Slack Webhook(무료) · healthchecks.io(무료, 데드맨스위치)
   LLM: Anthropic API — 소재 변주 배치에만 호출
```

버린 것: n8n, Supabase, Redis, 큐, 대시보드 프레임워크, 웹서버 전부.
이 예산 규모에서 대시보드는 Slack 일일 리포트와 SQLite 조회로 충분하다.

---

## 2. 토큰 비용 설계 (월 $2 이하)

| 티어 | 용도 | 모델 | 빈도 | 월 추정 |
|---|---|---|---|---|
| T0 | 수집·룰판단·알림·집행·일일리포트 | **LLM 미사용** (f-string 템플릿) | 상시 | $0 |
| T1 | 정책 애매 문구 판정 (정규식 통과 못한 것만) | Haiku | 월 ~20콜 | ~$0.05 |
| T2 | 승자 소재 변주 초안 5개 배치 | Sonnet | 월 ~4콜 | ~$0.5 |
| T3 | 주간 리포트 서술 요약 (선택) | Haiku | 월 4콜 | ~$0.05 |

강제 규칙: `llm.monthly_call_budget: 60` 초과 시 T1~T3 전면 차단·경보. 일일 리포트에 LLM 절대 금지(템플릿으로 충분).
결과: LLM 비용 ≈ 광고비(월 30만원)의 1% 미만.

---

## 3. 구축 순서

### STEP 0 — 수동 기반 정비 (Day 0, 봇 없음) ★건너뛰기 금지

먼저 아래 확정값 표를 채운다. **빈칸이 있으면 STEP 1 진행 금지.**

| 항목 | 값 | 비고 |
|---|---|---|
| 타겟 연령 | [ 25–47 / 40–60 중 택1 ] | 소재 모델·자막 크기·톤이 갈림 |
| 알림(오픈예정) 시작일 | [ YYYY-MM-DD ] | |
| 펀딩 오픈일 | [ YYYY-MM-DD ] | |
| 펀딩 마감일 | [ YYYY-MM-DD ] | 모든 캠페인 end_time으로 강제 주입 |
| LPV 목표 단가 | [ 기본 1,000원 ] | |
| 알림신청 목표 단가 | [ 기본 2,000원 ] | |
| 마감 스퍼트 예산 배수 | [ 기본 1.3~1.5 ] | 사전 승인 프리셋 |

수동 작업 체크리스트:
- [ ] Meta 광고 계정 설정 → **계정 지출 한도 300,000원** 설정 (1층 방어)
- [ ] 와디즈 대시보드 **유입 경로 추적 URL 생성기**로 영상별 링크 3개 발급
      (utm_content=vid_a / vid_b / vid_c — 임의 UTM 조립 금지, 반드시 생성기 사용)
- [ ] Google Sheets 생성 → 부록 A 스키마대로 탭 구성 → 파일 > 공유 > **웹에 게시 > CSV** URL 확보
      (주의: 게시 URL은 비공개 링크이나 URL 유출 시 열람 가능. 민감치 않은 집계값만 기록. 꺼림칙하면 STEP 9에서 서비스계정 전환)
- [ ] Slack 워크스페이스 → Incoming Webhook 생성 → URL 확보
- [ ] healthchecks.io 무료 가입 → 체크 1개 생성(기대 주기 6h, 유예 7h) → ping URL 확보

[완료 기준] 표의 빈칸 0개 + 체크 5개 완료.

---

### STEP 1 — 환경 설치 (Day 1)

```bash
# 1. Python 3.12+ 확인
python3 --version

# 2. Meta Ads CLI 설치 — 베타 기간 중 배포 채널이 pip/npm 양쪽으로 보고됨.
#    반드시 developers.facebook.com 공식 문서의 현재 안내를 따를 것. 예:
pip install meta-ads-cli        # 또는: npm install -g @meta/ads-cli

# 3. 인증 (브라우저 OAuth — 개발자앱/앱리뷰 불필요)
meta auth login
meta accounts list              # 계정 보이면 인증 성공. 명령 시그니처는 meta --help로 확인

# 4. 프로젝트 골격
mkdir -p /opt/adbot/{src,db,approvals,logs}
cd /opt/adbot && python3 -m venv venv && source venv/bin/activate
pip install requests pyyaml
```

레포 구조:
```
/opt/adbot/
  config.yaml          # 유일한 설정 파일 (STEP 2)
  run.sh               # 진입점 (cycle|daily|weekly|approve|reject|rollback|kill)
  src/
    collector.py  phase.py  rules.py  notify.py  actions.py  store.py
  db/adbot.sqlite
  approvals/           # 승인 대기 decision JSON
  logs/
```

[완료 기준] `meta accounts list`가 광고 계정을 출력한다.

---

### STEP 2 — 설정 파일 (config.yaml 전문)

```yaml
project: { name: roska_headskin_wadiz, timezone: Asia/Seoul }

target: { gender: female, age_min: 40, age_max: 60 }   # ← STEP 0 확정값

dates:
  notify_open:  "2026-08-01"
  funding_open: "2026-08-20"
  funding_close: "2026-09-10"    # end_time 강제 주입 원본

goals: { lpv_cost_krw: 1000, notify_cost_krw: 2000, lpv_reach_rate_min: 0.60 }

budget:
  daily_krw: 10000
  emergency_daily_stop_krw: 15000
  monthly_hard_cap_krw: 300000
  max_increase_pct: 15
  max_consecutive_increases: 2
  cooldown_hours: 48
  sprint_cooldown_hours: 24
  sprint_multiplier: 1.4          # STEP 0 사전 승인 프리셋

meta: { ad_account_id: "act_XXXX", page_id: "XXXX", ig_id: "XXXX" }

polling: { interval_hours: 6, sprint_interval_hours: 3 }

sources:
  wadiz_sheet_csv_url: "https://docs.google.com/spreadsheets/d/e/.../pub?output=csv"
  freshness_max_hours: 24

alerts:
  slack_webhook: "https://hooks.slack.com/services/XXX"
  healthcheck_ping: "https://hc-ping.com/XXXX"

llm:
  provider: anthropic
  monthly_call_budget: 60
  allowed: [creative_variants, policy_ambiguous, weekly_summary]
  forbidden_in_loop: true         # collector/rules에서 import 자체 금지
```

[완료 기준] `python3 -c "import yaml;yaml.safe_load(open('config.yaml'))"` 무오류.

---

### STEP 3 — 수집기 (collector.py 핵심 골격)

```python
# 매 사이클: ① Meta 지표 → snapshot ② 와디즈 CSV → external_conversions ③ 하트비트 ping
import subprocess, json, requests, csv, io, time
from store import save_snapshot, save_wadiz, get_config

def pull_meta(cfg):
    # 읽기도 CLI --json 우선. CLI 미지원 필드만 Graph API 폴백.
    out = subprocess.run(
        ["meta","ads","insights","--level","ad",
         "--fields","spend,impressions,outbound_clicks_ctr,cpm,frequency,actions",
         "--date-preset","today","--json"],
        capture_output=True, text=True, timeout=120)
    if out.returncode != 0:
        raise RuntimeError(f"insights 실패: {out.stderr[:300]}")   # 룰엔진이 SEV1 처리
    return json.loads(out.stdout)

def pull_wadiz(cfg):
    r = requests.get(cfg["sources"]["wadiz_sheet_csv_url"], timeout=30)
    r.raise_for_status()
    rows = list(csv.DictReader(io.StringIO(r.text)))
    return rows          # updated_at 컬럼 포함 → 신선도 가드 재료

def heartbeat(cfg):
    requests.get(cfg["alerts"]["healthcheck_ping"], timeout=10)

def run_cycle():
    cfg = get_config()
    save_snapshot(pull_meta(cfg))          # 실패 시 예외 → notify가 SEV1 발송
    save_wadiz(pull_wadiz(cfg))
    heartbeat(cfg)                          # 성공 시에만 ping = 데드맨스위치
```

구현 규칙:
- API 오류 시 지수 백오프 3회(2s→8s→30s), 최종 실패는 SEV1 경보. 레이트리밋은 커넥터를 써도 그대로 적용되므로 백오프 필수.
- LPV(landing_page_view)는 actions 배열에서 추출. 어트리뷰션은 7d_click 고정.

[완료 기준] `./run.sh cycle` 1회 실행 → SQLite에 스냅샷 행 생성 + healthchecks 대시보드에 ping 도착.

---

### STEP 4 — 페이즈 + 룰 엔진 (성과의 심장)

phase.py — 날짜만으로 결정되는 상태머신:

```python
def current_phase(cfg, today):
    d = cfg["dates"]
    if today <  d["notify_open"]:                  return "SETUP"
    if today <  d["funding_open"]:                 return "PRE_LAUNCH"   # 알림신청+오디언스 축적
    if today <= d["funding_open"] + 3d:            return "LAUNCH_BURST" # 리타겟팅 투입
    if today <  d["funding_close"] - 3d:           return "MID"
    if today <= d["funding_close"]:                return "FINAL_SPRINT" # 프리셋 배수·쿨다운 24h
    return "CLOSED"                                 # 전 캠페인 자동 OFF
```

rules.py — 룰 20개 전체 (우선순위 순서대로 평가, 첫 매치에서 결정):

```
[SEV1 — 무승인 자동 실행]
R01 phase==CLOSED                            → 전 캠페인 OFF (감사기록)
R02 당일지출 > emergency_daily_stop_krw       → 전 캠페인 OFF + 경보
R03 월누적 ≥ monthly_hard_cap                 → 전 캠페인 OFF + 경보
R04 랜딩 URL 4xx/5xx (3연속 확인)             → 해당 광고 OFF + 경보
R05 추적URL-영상 매핑 불일치 감지              → 해당 광고 OFF + 경보

[가드 — 판단 보류]
R06 수집 실패/하트비트 누락                    → SEV1 경보만 (자동 OFF 금지)
R07 wadiz updated_at > 24h                    → 증액·성과중단 결정 전면 보류 + 시트갱신 요청
R08 learning_phase ACTIVE                     → 구조편집 잠금 (예산 ±20% 이내만 통과)

[성과 — 승인 필요 제안 생성]
R09 72h+ AND 노출≥1,500 AND 지출≥20,000 AND outCTR<0.8% AND LPV단가>목표×1.5 → 중단 제안
R10 지출≥30,000 AND outCTR<0.6% AND LPV단가>목표×2                        → 강한 중단 제안
R11 3일 와디즈전환≥5 AND 전환단가≤목표×0.8 AND 추적정상 AND 쿨다운경과      → +15% 증액 제안
R12 frequency>3.0 AND CTR<peak×0.75                                       → 소재교체 제안 (예산 불변)
R13 outCTR 정상 AND LPV→서포터 CVR<1%                                     → "스토리페이지 진단" 태그 (소재 OFF 금지)
R14 LPV도달률<60%                                                          → 링크·모바일로딩 점검 경보

[페이즈 — 시점 제안]
R15 FINAL_SPRINT 진입                          → sprint_multiplier 증액 제안 (프리셋)
R16 PRE_LAUNCH: 알림단가>목표×1.5 (지출 15,000+) → 소재교체 제안
R17 funding_open 전일                           → LAUNCH_BURST 리타겟팅 세트 PAUSED 자동생성 + 승인요청
R18 증액 연속 2회 도달                          → 3회째 요청 자동 기각
R19 하루 자동액션 수 > 5                        → kill-switch (전 자동기능 정지 + 경보)
R20 정규식 정책패턴 검출(신규 카피)              → 게시 차단 + (애매시에만) T1 LLM 판정
```

decision JSON 규격(문서 승계): `{decision_id, object_id, recommended_action, reason_codes[], evidence{}, requires_human_approval, rollback_available}` — approvals/ 폴더에 저장.

[완료 기준] 더미 스냅샷으로 R09·R11 발화 유닛테스트 통과 + R02를 낮은 상한으로 강제 발화 → 자동 OFF 확인.

---

### STEP 5 — 알림 (notify.py, 토큰 제로)

일일 리포트는 순수 템플릿. 예시 출력:

```
📊 로스카 광고 일일 리포트 (07-12) — 페이즈: MID (마감 D-29)
지출 9,842원 | LPV 11건(단가 895원) | outCTR 1.41% | 와디즈 전환 1건
A영상 4,312원/LPV6/전환1 → 유지 ✅
B영상 3,008원/LPV4/전환0 → 관찰 👀
C영상 2,522원/LPV1     → 데이터부족 ⏳
대기 승인: 없음 | 이상: 없음 | 시트 갱신: 4시간 전 ✅
```

승인 요청 카드:
```
🟠 [승인 대기] DEC-20260712-001
B영상 중단 제안 — 근거: outCTR 0.61% (<0.8), LPV단가 1,958원 (목표×1.6), 지출 23,500원
실행:  ./run.sh approve DEC-20260712-001
기각:  ./run.sh reject  DEC-20260712-001 "사유"
```

승인은 웹훅 버튼(서버 필요) 대신 **로컬 명령 1줄**로 처리 — 서버 0대 유지의 핵심 트릭.

[완료 기준] 더미 데이터로 Slack에 리포트·승인카드 각 1회 수신.

---

### STEP 6 — 실행기 (actions.py)

```
approve 흐름:
  approvals/DEC-xxx.json 로드
  → hard_limits 재검증 (승인 시점 기준으로 다시)
  → 현재값 스냅샷 저장 (rollback_value)
  → CLI 실행 예: meta ads ad update <AD_ID> --status PAUSED --no-input
                meta ads adset update <ADSET_ID> --daily-budget <KRW> --no-input
  → 실행 결과 재조회로 검증 → 감사로그 → Slack 확정 알림
rollback:
  ./run.sh rollback DEC-xxx  → 저장된 이전 값으로 역실행
환경변수 3층 방어:
  ADBOT_MAX_DAILY_BUDGET_KRW=15000  # 이 값 초과 예산 명령은 실행기 자체가 거부
```

캠페인 생성은 항상 스펙 JSON 경유(§7) + CLI 생성 → **커넥터가 PAUSED를 강제하므로 이중 안전** → QA 체크리스트 통과 후 사람이 Ads Manager에서 게시.

[완료 기준] 테스트 광고 1개로 approve→PAUSED 전환→rollback→ACTIVE 복귀 풀사이클 성공.

---

### STEP 7 — 스케줄러 (crontab 4줄)

```cron
0 */6 * * *  cd /opt/adbot && ./run.sh cycle  >> logs/cycle.log 2>&1
30 8 * * *   cd /opt/adbot && ./run.sh daily  >> logs/daily.log 2>&1
0 9 * * 1    cd /opt/adbot && ./run.sh weekly >> logs/weekly.log 2>&1
# FINAL_SPRINT 자동 전환: cycle 내부에서 phase 확인 후 3h 간격 서브스케줄 활성
```

[완료 기준] 24시간 방치 → 사이클 4회 + 아침 리포트 1회 자동 수신.

---

### STEP 8 — 리허설 3종 (라이브 전 필수)

| # | 시나리오 | 방법 | 통과 기준 |
|---|---|---|---|
| 1 | 봇 사망 감지 | cron 주석처리 후 8시간 방치 | healthchecks가 Slack/메일 경보 |
| 2 | 랜딩 장애 자동중단 | 추적링크를 404 더미로 임시 교체 | R04 발화 → 광고 OFF → 경보, 3분 내 |
| 3 | 승인 풀사이클 | R09 임계 임시 하향으로 강제 발화 | 제안→approve→실행→rollback 무결 |

[완료 기준] 3종 전부 통과. 하나라도 실패 시 라이브 금지.

---

### STEP 9 — 라이브 및 권한 승급 게이트

- Day 1~14: LEVEL 2(승인형)로만 운영. 자동 실행은 R01~R05 긴급 계열만.
- **LEVEL 3 승급 조건(둘 다 충족)**: ① 14일간 봇 오작동 0건 ② 승인 지연이 실제 병목이 된 사례 존재.
- 승급 시 개방 범위: R10(강한 중단) 자동 OFF + R11 증액 자동 실행, 단 R19 kill-switch 상시.
- 완전 자율(LEVEL 4)은 이 예산 규모에서 영구 비개방.

---

## 4. 초기 캠페인 스펙 (JSON 전문 — 성과 극대화 배선)

```json
{
  "campaign": { "objective": "OUTCOME_TRAFFIC", "status": "PAUSED",
                "stop_time": "<funding_close>T23:59+09:00" },
  "adset": {
    "optimization_goal": "LANDING_PAGE_VIEWS",
    "daily_budget_krw": 10000,
    "targeting": { "geo": "KR", "gender": "female",
                   "age": "<STEP0 확정값>", "advantage_audience": true,
                   "detailed_interests": [] },
    "placements": "advantage_plus",
    "attribution": "7d_click",
    "end_time": "<funding_close>"
  },
  "ads": [
    { "video": "A", "url": "<와디즈생성기 vid_a>", "message": "오후 두피 산뜻함" },
    { "video": "B", "url": "<와디즈생성기 vid_b>", "message": "두피 수분 밸런스" },
    { "video": "C", "url": "<와디즈생성기 vid_c>", "message": "두피·모발 동시 관리" }
  ],
  "creative_enhancements": { "standard(밝기·대비·비율)": "ON",
                             "auto_text_overlay": "OFF", "auto_music": "OFF" },
  "learning_math": "LPV 1,000원 × 주50건 = 주50,000원 ≤ 주70,000원 예산 → 러닝 탈출 가능"
}
```

성과 장치 요약: 1캠페인-1세트-3소재(예산 분산 방지) · Advantage+ 오디언스/배치(광폭 배포) · end_time 강제(마감 후 지출 0) · PRE_LAUNCH 시청자 오디언스 축적 → LAUNCH_BURST 리타겟팅 · FINAL_SPRINT 프리셋 · 자동 텍스트 오버레이 OFF(두피 소재 CTR 방어) · 소재 2주당 2~3개 교체 파이프라인.

---

## 5. KPI 가드레일 (룰 엔진과 동일 수치)

| 지표 | 양호 | 경계 | 조치선 |
|---|---|---|---|
| 아웃바운드 CTR | 1.3%+ | 0.8~1.3 | <0.8% |
| LPV 단가 | ~1,000원 | ~1,800 | >1,800원 |
| LPV 도달률 | 70%+ | 60~70 | <60% |
| CPM | ~12,000원 | ~20,000 | >20,000원 |
| 알림신청 단가 | ~2,000원 | ~4,000 | >4,000원 |
| LPV→서포터 CVR | 2%+ | 1~2 | <1% → 스토리페이지 진단(소재 OFF 금지) |

---

## 6. LLM 사용 규격 (T2 소재 변주 프롬프트 골격)

```
system: 너는 두피케어 크라우드펀딩 광고 카피라이터다. 출력은 JSON only.
금지: 탈모치료/두피염치료/즉시개선/100%효과, 2인칭 상태지칭("~하세요?","당신의 두피"),
     비포애프터 표현, 의학적 효능 단정.
input: 승자소재 훅 구조 {첫장면, 자막, 전환시점}, 타겟 <STEP0 연령>, 페이즈 <현재>
output: {"variants":[{"hook_scene","sub_3s","script_20s","body","headline","cta"}...5개]}
```
호출 규칙: 승자 확정 시 또는 R12/R16 발화 시에만, 월 4회 상한, max_tokens 2000, 결과는 반드시 정규식 정책검사(R20) 재통과 후 사람 검수.

---

## 7. 운영 루틴

- 매일 3분: 아침 리포트 확인 → 대기 승인 처리 → 시트 갱신(와디즈 전환·알림 수 입력).
- 매주 30분: 주간 리포트 → 소재 소진도 확인 → 변주 배치 검수 → 자가운영 vs 와디즈 타겟광고 효율 비교(LPV단가>1,800원 지속 시 예산 일부 와디즈 타겟광고 이관 검토).

---

## 8. 트러블슈팅 10선

1. CLI 인증 만료 → `meta auth login` 재실행. 사이클 실패는 R06이 잡는다.
2. 레이트리밋 오류 → 백오프 자동. 반복 시 폴링 12h로 완화.
3. 시트 신선도 경보 → 와디즈 대시보드 보고 즉시 입력. R07이 오판단을 막고 있는 중.
4. "러닝 리셋 경고" → R08 잠금 확인. 구조 편집은 러닝 종료 후로 미룬다.
5. 광고 반려(개인속성) → 2인칭 지칭 검사(부록 B) 후 수정 재게시. 이의제기는 사람 직접.
6. CPM 20,000원 돌파 → 소재 훅 문제 우선 의심. 타겟 좁히기 금지(악화됨).
7. LPV 도달률 <60% → 와디즈 링크 리다이렉트·모바일 로딩·UTM 파라미터 3종 점검.
8. frequency 3+ & CTR 하락 → R12 소재교체. 예산 만지지 말 것.
9. 시트 공개 URL 우려 → 서비스계정 OAuth로 전환(gspread), 게시 중단.
10. CLI 미지원 기능 봉착 → Python Business SDK 폴백 경로 사용(베타 공백 정상).

---

## 부록 A — 와디즈 시트 스키마 (탭: conversions)

| date | utm_content | 결제건수 | 결제금액 | 알림신청수 | updated_at |
|---|---|---|---|---|---|
| 2026-07-12 | vid_a | 1 | 69000 | 3 | 2026-07-12 21:05 |

updated_at은 매 입력 시 반드시 갱신 — R07 신선도 가드의 기준값.

## 부록 B — 정책 정규식 시드 (R20)

```
금칙:  (탈모\s*치료|두피염\s*치료|즉시\s*개선|100%\s*효과|의학적)
2인칭: ((하|이)세요\?|계신가요\?|당신의\s*(두피|모발|머리)|고민이시죠)
B/A :  (비포|애프터|before|after|사용\s*전후.*(변화|비교))
```
정규식 미검출이지만 애매한 문구만 T1(Haiku) 판정으로 넘긴다.

---

*v1.0 — 작성일 2026-07-12. STEP 0 확정값 변경 시 config.yaml만 수정하면 전체가 따라온다.*
