← back to Contact Mailer Ideas

auto-batch-generator.sh

168 lines

#!/bin/bash
# auto-batch-generator.sh — picks the oldest-themed batch, generates 20 ideas via claude CLI,
# scores them, generates SWOT + marketing, renders pitch videos, syncs to prod.
# Run by launchd com.steve.auto-batch-generator Tue + Fri at 06:00 PT.
# Manual: ./auto-batch-generator.sh [theme-slug]
set -euo pipefail

ROOT="$HOME/Projects/contact-mailer-ideas"
THEMES="$ROOT/auto-batch-themes.json"
JSONL="$HOME/.claude/skills/idea-loop/data/ideas.jsonl"
DATE=$(date +%Y-%m-%d)
LOG="$ROOT/logs/auto-batch-$DATE.log"
mkdir -p "$ROOT/logs" "$ROOT/batches"

# Per MEMORY: NEVER use Anthropic API key — strip both before claude CLI
unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN

log() { echo "$(date -Iseconds) $*" >> "$LOG"; }

# Pick theme: arg-override OR oldest last_generated
THEME="${1:-}"
if [ -z "$THEME" ]; then
  THEME=$(jq -r '.themes | sort_by(.last_generated // "0000") | .[0].slug' "$THEMES")
fi
log "selected theme: $THEME"

THEME_OBJ=$(jq --arg s "$THEME" '.themes[] | select(.slug == $s)' "$THEMES")
TITLE=$(echo "$THEME_OBJ" | jq -r '.title')
FOCUS=$(echo "$THEME_OBJ" | jq -r '.prompt_focus')
BATCH_SLUG="${THEME}-${DATE//-/}"
OUT="$ROOT/batches/${DATE}-${THEME}.json"

if [ -f "$OUT" ]; then
  log "batch $OUT already exists today, skipping"
  exit 0
fi

# Existing idea names to avoid duplicates (across ALL prior batches)
EXISTING=$(jq -r '.ideas[].name' "$ROOT"/batches/*.json 2>/dev/null | sort -u | tr '\n' ',' | sed 's/,$//')

PROMPT="You are generating 20 NEW product ideas in this theme:

THEME: $TITLE
FOCUS: $FOCUS

HARD RULES:
- 20 ideas, distinct from each other and from existing names: ${EXISTING}
- Each idea must be a real PRODUCT (not a feature), shippable by a 2-person team in ≤8 weeks.
- Each name is ShortPascalCase, distinctive, no overlap with existing names.
- Mix angles within the theme: AI-native, anti-features, vertical, hardware-adjacent, marketplace, etc.

OUTPUT FORMAT: pure JSON, no markdown, no commentary. Exact shape:
{
  \"ideas\": [
    {
      \"name\": \"ShortPascalCase\",
      \"angle\": \"one-line product angle\",
      \"target\": \"who buys this\",
      \"diff\": \"what makes it different in one sentence\",
      \"headline\": \"a 4-8 word hero headline\",
      \"action_items\": [
        {\"step\":\"VALIDATE\",\"what\":\"one concrete test to de-risk before building\"},
        {\"step\":\"MVP\",\"what\":\"minimum shippable scope + pricing hint\"},
        {\"step\":\"DISTRIBUTION\",\"what\":\"where to find first 10 customers\"}
      ]
    }
  ]
}

Return ONLY the JSON object."

log "calling claude CLI"
RAW="$(claude -p "$PROMPT" 2>>"$LOG" || true)"

# Parse JSON (same extractor as generate-daily.sh)
JSON=$(printf '%s' "$RAW" | python3 -c "
import sys, re, json
s = sys.stdin.read()
m = re.search(r'\`\`\`(?:json)?\s*(\{.*?\})\s*\`\`\`', s, re.DOTALL)
if m: s = m.group(1)
depth = 0; start = -1
for i, ch in enumerate(s):
    if ch == '{':
        if depth == 0: start = i
        depth += 1
    elif ch == '}':
        depth -= 1
        if depth == 0 and start >= 0:
            try:
                obj = json.loads(s[start:i+1])
                if 'ideas' in obj: print(json.dumps(obj)); sys.exit(0)
            except: pass
sys.exit(1)
" 2>>"$LOG" || true)

if [ -z "$JSON" ]; then
  log "FAILED to parse claude output. Raw → ${LOG}.raw"
  printf '%s' "$RAW" > "${LOG}.raw"
  exit 1
fi

COUNT=$(printf '%s' "$JSON" | jq '.ideas | length')
log "got $COUNT ideas"
if [ "$COUNT" -lt 10 ]; then exit 1; fi

# Compose batch document
printf '%s' "$JSON" | jq --arg batch "$BATCH_SLUG" --arg date "$DATE" --arg title "$TITLE" --arg ts "$(date -Iseconds)" '{
  batch: $batch,
  batch_date: $date,
  vertical: $title,
  authority: "ideate-only",
  generated_at: $ts,
  generator: "claude-cli auto-batch (2x-weekly)",
  ideas: [.ideas | to_entries[] | {
    idx: (.key + 1), name: .value.name, angle: .value.angle,
    target: .value.target, diff: .value.diff,
    headline: .value.headline, action_items: .value.action_items
  }]
}' > "$OUT"

# Append seeds + first-pass scoring (heuristic from existing data scoring patterns)
# Score = base 28 + bonus from headline punch — defer real scoring to next claude-cli pass
python3 <<PYEOF >> "$JSONL"
import json
ts = "$(date -Iseconds)"
batch = "$BATCH_SLUG"
d = json.load(open("$OUT"))
for idea in d["ideas"]:
    seed = {
        "batch": batch, "batch_date": d["batch_date"], "tick": 0,
        "idx": idea["idx"], "name": idea["name"],
        "headline": idea["headline"], "angle": idea["angle"],
        "authority": "ideate-only", "generated_at": ts
    }
    print(json.dumps(seed, ensure_ascii=False))
    # Conservative first-pass score (28-31 range) — refinement happens later
    score = {
        "batch": batch, "tick": idea["idx"], "pass": 1,
        "idx": idea["idx"], "name": idea["name"],
        "scores": {"novelty": 7, "fit": 7, "feas": 7, "impact": 7},
        "new_headline": idea["headline"],
        "refinement": "Auto-batch first-pass scoring; refine via pass-2 (gap-prioritized).",
        "verdict": "sharpen", "score_total": 28, "ts": ts
    }
    print(json.dumps(score, ensure_ascii=False))
PYEOF

log "wrote $OUT + jsonl seeds"

# Update theme last_generated timestamp
TMP=$(mktemp)
jq --arg s "$THEME" --arg d "$DATE" '.themes |= map(if .slug == $s then .last_generated = $d else . end)' "$THEMES" > "$TMP"
mv "$TMP" "$THEMES"
log "updated theme tracker"

# Sync to prod (syncs batches dir + ideas.jsonl)
"$ROOT/sync-to-prod.sh" >> "$LOG" 2>&1 || log "sync-to-prod failed"

# Render pitch videos for the new batch
log "rendering pitch videos"
"$ROOT/render-all-pitches.sh" >> "$LOG" 2>&1 || log "render-all-pitches failed"

# Trigger publish watcher
launchctl start com.steve.publish-shipped-apps 2>&1 >> "$LOG"

log "DONE — batch $BATCH_SLUG with $COUNT ideas"
echo "auto-batch generated: $BATCH_SLUG ($COUNT ideas)"