creepyblues avatar

batch-comps

Batch generation of Hollywood comparable titles using GPT-4o. This skill should be used for generati

by creepyblues|Open Source

Batch Comps

This skill orchestrates batch generation of Hollywood comparable titles, enabling large-scale comp generation without manual UI interaction.

When to Use This Skill

  • Generating comps for multiple titles at once (20+)
  • Filling gaps (titles without comps)
  • Genre-specific batch runs (all horror titles, all romance titles)
  • New title processing (recently added titles)
  • Cost-controlled batch runs with budget limits
  • Porting the feature to another app

What It Does

For each title, the comps generator:

  1. Collects title data (synopsis, genre, content analysis)
  2. Deconstructs story across 8 dimensions using GPT-4o
  3. Generates 5-8 Hollywood comparable titles with scoring
  4. Enriches with OMDB data (IMDB IDs, posters)
  5. Saves to comps and comps_analysis columns

8 Scoring Dimensions:

  • Save the Cat genre
  • Tone/atmosphere
  • Character archetypes
  • Plot structure
  • Setting/world
  • Themes
  • Target audience
  • Format/medium

Commands

/batch-comps --missing                    # Titles without comps
/batch-comps --genre=horror               # Specific genre
/batch-comps --limit=50                   # Limit batch size
/batch-comps --min-views=100000           # High-value titles only
/batch-comps --cost-estimate              # Estimate cost before running
/batch-comps --dry-run                    # Preview without executing
/batch-comps --data-complete-only         # Only titles with rich data
/batch-comps --recent=7                   # Titles added in last 7 days

Edge Function Reference

Location: supabase/functions/comps-generator/index.ts

Endpoint: POST /functions/v1/comps-generator

Request Body:

interface CompsRequest {
  title_id: string;
  // Optional: force regeneration even if comps exist
  force?: boolean;
}

Response:

interface CompsResponse {
  success: boolean;
  title_id: string;
  comps: string[];  // Array of comp title names
  comps_analysis: Array<{
    title: string;
    year?: number;
    imdb_id?: string;
    poster_url?: string;
    match_score: number;  // 0-100
    dimension_scores: {
      stc_genre: number;
      tone: number;
      characters: number;
      plot: number;
      setting: number;
      themes: number;
      audience: number;
      format: number;
    };
    explanation: string;
  }>;
  story_deconstruction: {
    stc_genre: string;
    tone: string[];
    character_archetypes: string[];
    plot_structure: string;
    setting: string;
    themes: string[];
    target_audience: string;
    format_notes: string;
  };
  data_completeness: number;  // 0-100
  mode: 'rich' | 'limited';
  processing_time_ms: number;
  cost_estimate: number;
}

Cost Estimation

Model: GPT-4o (2 API calls per title) Cost: ~$0.01 per title

Batch SizeEst. TimeEst. Cost
10 titles~2.5 min$0.10
50 titles~12 min$0.50
100 titles~25 min$1.00
500 titles~2 hours$5.00

Pre-Run Cost Check

-- Count titles that need comps
SELECT COUNT(*) as titles_needing_comps
FROM titles
WHERE (comps IS NULL OR array_length(comps, 1) = 0)
  AND views > 100000;  -- High-value only

-- Estimated cost = count * $0.01

Batch Workflow

Step 1: Identify Titles to Process

-- Titles without comps (high-value first)
SELECT title_id, title_name_en, views, genre
FROM titles
WHERE comps IS NULL OR array_length(comps, 1) = 0
ORDER BY views DESC NULLS LAST
LIMIT 50;

-- Genre-specific (horror titles)
SELECT title_id, title_name_en
FROM titles
WHERE 'Horror' = ANY(genre)
  AND (comps IS NULL OR array_length(comps, 1) = 0);

-- Recently added titles
SELECT title_id, title_name_en
FROM titles
WHERE created_at > NOW() - INTERVAL '7 days'
  AND (comps IS NULL OR array_length(comps, 1) = 0);

-- Data-complete titles only (better results)
SELECT title_id, title_name_en
FROM titles
WHERE synopsis IS NOT NULL
  AND LENGTH(synopsis) > 100
  AND genre IS NOT NULL
  AND array_length(genre, 1) > 0
  AND (comps IS NULL OR array_length(comps, 1) = 0);

Step 2: Batch Generation

const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;

async function generateCompsBatch(titleIds, options = {}) {
  const results = { success: [], failed: [], skipped: [] };
  let totalCost = 0;

  // Cost estimation
  if (options.costEstimateOnly) {
    const estimatedCost = titleIds.length * 0.01;
    console.log(`Estimated cost: $${estimatedCost.toFixed(2)} for ${titleIds.length} titles`);
    return { estimatedCost, titleCount: titleIds.length };
  }

  for (const titleId of titleIds) {
    try {
      const response = await fetch(`${SUPABASE_URL}/functions/v1/comps-generator`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ title_id: titleId })
      });

      const data = await response.json();

      if (data.success) {
        results.success.push({
          title_id: titleId,
          comps_count: data.comps?.length || 0,
          cost: data.cost_estimate || 0.01,
          mode: data.mode
        });
        totalCost += data.cost_estimate || 0.01;
      } else {
        results.failed.push({
          title_id: titleId,
          error: data.error
        });
      }

      // Rate limiting (respect API limits)
      await delay(2000);

    } catch (error) {
      results.failed.push({
        title_id: titleId,
        error: error.message
      });
    }

    // Budget check
    if (options.maxCost && totalCost >= options.maxCost) {
      console.log(`Budget limit reached: $${totalCost.toFixed(2)}`);
      break;
    }
  }

  return { results, totalCost };
}

Step 3: Verification

-- Verify comps were generated
SELECT
  title_name_en,
  array_length(comps, 1) as comp_count,
  updated_at
FROM titles
WHERE title_id = ANY($1::uuid[])
ORDER BY updated_at DESC;

-- Check data quality
SELECT
  title_name_en,
  comps_analysis->0->>'match_score' as top_match_score,
  comps_analysis->0->>'title' as top_comp
FROM titles
WHERE title_id = ANY($1::uuid[])
  AND comps_analysis IS NOT NULL;

Console Output

Starting batch comps generation...

Configuration:
  Filter: missing comps, high-value (views > 100k)
  Limit: 50
  Budget: $1.00 max

Cost Estimate:
  Titles to process: 50
  Estimated cost: $0.50
  Proceed? (Y/n)

[1/50] Processing "재벌집 막내아들"
       Data completeness: 85% (rich mode)
       ✅ Generated 7 comps
       Top match: "Succession" (92%)
       Cost: $0.012

[2/50] Processing "Solo Leveling"
       Data completeness: 92% (rich mode)
       ✅ Generated 8 comps
       Top match: "Sword Art Online" (88%)
       Cost: $0.011

[3/50] Processing "Unknown Title"
       Data completeness: 23% (limited mode)
       ⚠️ Generated 5 comps (limited data)
       Cost: $0.008

...

Summary:
  ✅ Success: 47 titles (avg 6.8 comps each)
  ⚠️ Limited mode: 8 titles
  ❌ Failed: 3 titles
  💰 Total cost: $0.48

Failed titles:
  - "Title A": Insufficient data (no synopsis)
  - "Title B": API timeout
  - "Title C": Rate limit exceeded

Error Handling

Common Errors

ErrorCauseResolution
Insufficient dataMissing synopsis/genreCollect data first via /batch-intelligence
API timeoutGPT-4o slow responseRetry with longer timeout
Rate limitToo many requestsIncrease delay between requests
OMDB enrichment failedOMDB API issueNon-fatal, comps still saved

Data Quality Pre-Check

Before running batch, verify data quality:

-- Check data completeness for target titles
SELECT
  title_name_en,
  CASE
    WHEN synopsis IS NOT NULL AND LENGTH(synopsis) > 100
         AND genre IS NOT NULL AND array_length(genre, 1) > 0
         AND tone IS NOT NULL
    THEN 'rich'
    ELSE 'limited'
  END as expected_mode,
  CASE WHEN synopsis IS NULL THEN 'missing synopsis' END as issue1,
  CASE WHEN genre IS NULL THEN 'missing genre' END as issue2
FROM titles
WHERE comps IS NULL;

Porting Guide

To port this feature to another app (e.g., Creator):

1. Service File Template

Create apps/[app]/src/services/compsGeneratorService.ts:

import { supabase } from '@/integrations/supabase/client';

const FUNCTION_URL = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/comps-generator`;

export interface SuggestedComp {
  title: string;
  year?: number;
  imdb_id?: string;
  poster_url?: string;
  match_score: number;
  dimension_scores: {
    stc_genre: number;
    tone: number;
    characters: number;
    plot: number;
    setting: number;
    themes: number;
    audience: number;
    format: number;
  };
  explanation: string;
}

export interface CompsResult {
  success: boolean;
  comps: string[];
  comps_analysis: SuggestedComp[];
  data_completeness: number;
  mode: 'rich' | 'limited';
  processing_time_ms: number;
  cost_estimate: number;
  error?: string;
}

export async function generateComps(titleId: string): Promise<CompsResult> {
  const { data: { session } } = await supabase.auth.getSession();

  const response = await fetch(FUNCTION_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${session?.access_token}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ title_id: titleId })
  });

  return response.json();
}

export function estimateCost(titleCount: number): number {
  return titleCount * 0.01;  // ~$0.01 per title
}

2. UI Component Template

Create apps/[app]/src/components/GenerateCompsButton.tsx:

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { generateComps, estimateCost } from '@/services/compsGeneratorService';
import { Sparkles, Loader2 } from 'lucide-react';
import { toast } from 'sonner';

interface Props {
  titleId: string;
  hasExistingComps?: boolean;
  onSuccess?: (comps: string[]) => void;
}

export function GenerateCompsButton({ titleId, hasExistingComps, onSuccess }: Props) {
  const [loading, setLoading] = useState(false);

  const handleGenerate = async () => {
    setLoading(true);
    try {
      const result = await generateComps(titleId);

      if (result.success) {
        toast.success(`Generated ${result.comps.length} comps`, {
          description: `Mode: ${result.mode}, Cost: $${result.cost_estimate.toFixed(3)}`
        });
        onSuccess?.(result.comps);
      } else {
        toast.error(result.error || 'Generation failed');
      }
    } catch (error) {
      toast.error('Failed to generate comps');
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button
      variant={hasExistingComps ? 'outline' : 'default'}
      size="sm"
      onClick={handleGenerate}
      disabled={loading}
      className={hasExistingComps ? 'border-blue-500' : ''}
    >
      {loading ? (
        <Loader2 className="w-4 h-4 mr-2 animate-spin" />
      ) : (
        <Sparkles className="w-4 h-4 mr-2" />
      )}
      {loading ? 'Generating...' : hasExistingComps ? 'Regenerate Comps' : 'Generate Comps'}
    </Button>
  );
}

3. Results Display Component

import { SuggestedComp } from '@/services/compsGeneratorService';

interface Props {
  comps: SuggestedComp[];
}

export function CompsDisplay({ comps }: Props) {
  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      {comps.map((comp, index) => (
        <div key={index} className="bg-white rounded-lg border p-4">
          {comp.poster_url && (
            <img
              src={comp.poster_url}
              alt={comp.title}
              className="w-full h-40 object-cover rounded mb-2"
            />
          )}
          <h4 className="font-semibold text-sm">{comp.title}</h4>
          {comp.year && <p className="text-xs text-gray-500">{comp.year}</p>}
          <div className="mt-2">
            <span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
              {comp.match_score}% match
            </span>
          </div>
        </div>
      ))}
    </div>
  );
}

4. Integration Points

  • Add button to title edit page
  • Add comps display section
  • Wire up data refresh after generation

Related Skills

  • /batch-intelligence - Collect data before generating comps for better results
  • /batch-format-fit - Run format analysis after comps generation
  • /title-pipeline - Orchestrate full workflow
  • /regenerate-embeddings - Update embeddings after comps change