Skip to main content
The creative-analysis pipeline ships with three operator-grade management commands plus the beat schedule that keeps everything fresh. This page is the production runbook.

Management commands

seed_creative_test_data — local seeding

Creates synthetic AdCreativeFields rows pointing at stable Picsum image URLs plus matching Ads_AdTracker rows shaped to actually trigger interesting signals (some archetypes decline so exhaustion lights up, others have flat ROAS).
The seeded perf is shaped so:
  • Spend is Pareto-skewed (a handful of ads carry most of the budget).
  • 60% of ads convert; the rest are duds.
  • before_after and urgency archetypes have a 4-week declining ROAS curve so hook exhaustion fires.
  • Other archetypes hold roughly steady or improve.

run_creative_pipeline — end-to-end orchestrator

Runs all five pipeline stages for one tenant or all tenants. Prints a per-tenant cost preview up front and prompts for confirmation if the bill exceeds $5.
Stages 1+2 enqueue Celery tasks (you need a worker on automations_standard and automations_low). Stages 3+4+5 run synchronously, so the brief is materialized when the command exits.

creative_pipeline_status — read-only status

Per-tenant report:

backfill_creative_embeddings — embedding-only backfill

Used during the v1 → v2 cutover (or any time you need to retroactively embed old CreativeAnalysis rows without running vision again).
v2 is kind-aware: a creative is only “already embedded” when both analysis_text AND image rows exist. Pre-v2 single-kind rows are picked up so the missing kind lands on the next run.

analyze_creatives — vision-only backfill

Standalone wrapper for the vision-only stage of the pipeline. run_creative_pipeline calls this internally; you can invoke directly when you only want vision.

Celery beat schedule

The hourly cadence + idempotency means a worker outage of up to ~60 minutes recovers automatically on the next tick. Don’t run multiple beat workers — django-celery-beat’s DatabaseScheduler is the single source of truth.

Task queues

Per task_routes in layerfive/celery.py:
In production, run separate worker processes for automations_standard (latency-sensitive ingest path) and automations_low (batch sweeps + briefs).

Deployment checklist

When deploying the v2 pipeline to a new environment:
1

Postgres has pgvector

CREATE EXTENSION IF NOT EXISTS vector; is included as a defensive RunSQL in migration 0012_creative_v2_schema. If you’re on a managed Postgres that doesn’t allow extensions by default, enable pgvector at the DB level first (Supabase / RDS / Cloud SQL all support it).Verify with: python manage.py checkagent_surface.W002 warns if the extension isn’t loaded.
2

ffmpeg is on the path

Required for video keyframe extraction. The Dockerfile should apt-get install ffmpeg. Without ffmpeg, video assets degrade gracefully to thumbnail-only analysis (you lose frame-by-frame observations and the spoken_hook signal).Verify: which ffmpeg && which ffprobe.
3

OPENAI_API_KEY is configured

Required for vision, Whisper, embeddings, brief generation, and concept naming.Verify: python manage.py checkagent_surface.W001 warns if the key is missing.
4

Migrations applied

python manage.py migrate marketing_resources (also picks up the knowledge_base migration adding cost / transcript columns).The migrations are idempotent — re-running on an already-migrated DB is a no-op.
5

Beat scheduler enabled

Run celery -A layerfive beat --loglevel=info exactly once per cluster. The schedule in layerfive/celery.py registers itself; user-created automations also write PeriodicTask rows that the same scheduler picks up.
6

Workers running on both queues

celery -A layerfive worker -Q automations_standard,automations_low --concurrency=4Adjust concurrency based on OpenAI rate limits.

Backfilling production

For a fresh tenant or a v1 → v2 migration:
For all tenants at once:

Cost monitoring

Cost is captured on every artifact:
  • CreativeAnalysis.cost_cents — vision + Whisper combined, per creative.
  • CreativeEmbedding.cost_cents — embedding call, per kind per creative.
  • CreativeBrief.cost_cents — daily brief, per tenant per day.
Per-tenant 30-day rollup is available via:
…or as an HTTP endpoint:
…or surfaced in the analytics page’s “OpenAI spend (30d)” KPI tile. For multi-tenant rollup, use creative_pipeline_status --json and aggregate the cost_30d_usd field across rows.

Troubleshooting

Check, in order:
  1. Is a Celery worker running on automations_standard? (The signal enqueues there.)
  2. Is the interaction_insight app loaded? apps.py:ready() raises on missing imports — if startup logs show a stack trace there, fix it before relying on the signal.
  3. Does the AdCreativeFields row actually have s3_image_url or s3_video_url? The signal short-circuits without media.
  4. Does the row have a client_id? The signal also short-circuits without it.
  5. If saves come from a code path passing update_fields, the v2 signal short-circuits unless one of the media URL fields is in the list. Audit the call site.
Belt-and-suspenders: kb-sweep-unanalyzed-creatives runs hourly and picks up missed creatives. If even that’s not running, check the beat scheduler.
The model occasionally returns markdown-fenced JSON or invalid JSON despite response_format='json_object'. v2 catches that and returns {'_unparseable': '<first 5000 chars>'}. The analysis is not persisted in this case — re-run with --analyze-force to retry.Recurring unparseable responses on the same asset usually mean the model couldn’t fetch the image (private S3 URL, dead link). Test the URL with curl -I from the worker host.
All three gates must hold simultaneously:
  • composite ≥ 0.6 — high enough composite score.
  • spend_share ≥ 0.20 — enough budget on this archetype.
  • roas_slope < 0 — actually declining.
A common confusion: a flat-ROAS but high-spend archetype is dominant, not exhausted. The trigger is intentionally narrow — it fires when an archetype is genuinely failing. Use the analytics page’s per-archetype rows to see which gate is missing.
The generator persists a row with status='failed' if the LLM call errored. The headline includes the error class. Common causes:
  • OPENAI_API_KEY not set (system check W001 should have warned at boot).
  • Rate limit (429). Retry; the existing token bucket usually recovers within seconds.
  • Network timeout. Re-running with the Regenerate button typically succeeds on retry.
To force a clean retry: POST /daily-brief/regenerate/ with body { "client_id": "..." }.
The naming pass is best-effort. If the GPT-4o-mini call fails for a cluster, the row is persisted with name="Cluster N" and summary="". The page still renders it. The next weekly refresh tries again.Force an immediate retry: POST /concept-clusters/refresh/.
The page module gate uses validateAccessPermission (not the strict validateBlockAccessPermission). Super admins always pass. For other users, permissions['creative-reporting']?.view must be true — same gate as the existing Creative Library.See Permissions for the full agent-surface gate.
The “Ad Creative Analytics” entry is a top-level item gated by is_superuser || creative-reporting.view. If you can see Creative Library but not Analytics, either the bundle is stale (hard-refresh) or the user logged in before the entry shipped (re-login).

Tunable settings

All read from Django settings if defined:

Where the code lives