Skip to content

CodeHunt101/plotline-ai

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

104 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PlotlineAI

PlotlineAI is a group movie recommendation app. Each participant shares their tastes -- favourite film, preferred era, current mood, and a favourite film personality -- and the system uses embedding-based vector search combined with a language model to surface movies the whole group will enjoy.

Live Demo CI

Table of Contents


Architecture

graph TD
    Browser["Browser\n(React + MovieContext)"]

    subgraph NextJS["Next.js App"]
        Pages["Pages\n/  /movieForm  /recommendations"]
        API_Rec["POST /api/recommendations"]
        API_Seed["GET /api/embeddings-seed"]

        subgraph Services["lib/services/"]
            SvcRec["movie-recommendations\n(pipeline orchestrator)"]
            SvcEmb["embeddings-server\n(Google Gemini embed)"]
            SvcOAI["openai\n(LLM interface)"]
            SvcSup["supabase\n(worker proxy)"]
            SvcTMDB["tmdb\n(poster lookup)"]
            SvcSeed["seed\n(corpus seeding)"]
        end
    end

    subgraph CloudflareEdge["Cloudflare Edge"]
        AIGateway["AI Gateway\n(logging / caching)"]
        Worker["Supabase CF Worker\n/api/match-movies\n/api/insert-movies\n/api/truncate-movies\n/api/check-empty"]
    end

    subgraph ExternalAPIs["External APIs"]
        Gemini["Google Gemini\ngemini-embedding-001\ngemini-2.5-flash"]
        OpenRouter["OpenRouter\nminimax-m2.5\nllama-3.3-70b\nopenrouter/free"]
        SupabaseDB["Supabase Postgres\nmovies_4 + pgvector\nmatch_movies_4 RPC"]
        TMDB["TMDB API\ movie posters"]
    end

    Browser -->|"form submit / navigate"| Pages
    Pages -->|"fetch"| API_Rec
    API_Rec --> SvcRec
    SvcRec --> SvcEmb
    SvcRec --> SvcSup
    SvcRec --> SvcOAI
    API_Seed --> SvcSeed
    SvcSeed --> SvcEmb

    SvcEmb -->|"embed request"| AIGateway
    SvcOAI -->|"LLM request (primary)"| AIGateway
    AIGateway --> Gemini

    SvcOAI -->|"LLM request (fallback)"| OpenRouter

    SvcSup -->|"POST /api/match-movies\nx-worker-secret"| Worker
    SvcSeed -->|"POST /api/insert-movies\nDELETE /api/truncate-movies"| Worker
    Worker -->|"Supabase RPC"| SupabaseDB

    Pages -->|"searchMoviePoster"| SvcTMDB
    SvcTMDB --> TMDB
Loading

Full diagrams — React component tree, AI fallback circuit breaker, and CI/CD pipeline → docs/diagrams.md


How It Works

The recommendation pipeline has three stages: embed, retrieve, and rank.

flowchart LR
    A["🎬 Participants'\npreferences"] --> B["1. Embed\nGemini → 768-dim vector"]
    B --> C["2. Retrieve\npgvector similarity search\ntop-10 matches"]
    C --> D["3. Rank\nLLM re-ranking\n+ JSON response"]
    D --> E["🍿 Recommended\nmovies + posters"]
Loading
Detailed sequence diagram
sequenceDiagram
    actor User
    participant Browser as Browser<br/>(MovieFormClient)
    participant RecAPI as POST /api/recommendations
    participant Pipeline as movie-recommendations<br/>(lib/services)
    participant Gemini as Google Gemini<br/>(via CF AI Gateway)
    participant CFWorker as Cloudflare Worker
    participant Supabase as Supabase<br/>(pgvector)
    participant LLM as LLM<br/>(Gemini / OpenRouter)
    participant TMDB as TMDB API
    participant API_Country as API Country<br/>(api.country.is)

    User->>Browser: Submit movie preferences<br/>(last participant)
    Browser->>RecAPI: POST participantsData + timeAvailable
    RecAPI->>Pipeline: streamMovieRecommendations()

    Note over Pipeline: 1. Build embedding & normalise<br/>createServerEmbedding(text blob)

    Pipeline->>Gemini: gemini-embedding-001<br/>768-dim vector
    Gemini-->>Pipeline: embedding vector

    Note over Pipeline: 2. Vector similarity search

    Pipeline->>CFWorker: POST /api/match-movies<br/>{ embedding, threshold: 0.25, count: 10 }
    CFWorker->>Supabase: RPC match_movies_4()
    Supabase-->>CFWorker: top-10 similar MovieRecords
    CFWorker-->>Pipeline: matched movies (id, content, similarity)

    Note over Pipeline: 3. LLM streaming with zod schema

    Pipeline->>LLM: system prompt + movie list + user prefs<br/>temperature: 0.65
    LLM-->>Pipeline: Stream chunks via zod schema

    Note over Pipeline: 4. streamObject conversion

    Pipeline-->>RecAPI: Stream text response
    RecAPI-->>Browser: 200 ReadableStream

    Browser->>Browser: consume stream with useObject hook

    loop For each recommended movie
        Browser->>TMDB: searchMoviePoster(title)
        TMDB-->>Browser: poster URL + movie ID
        Browser->>API_Country: fetch user country
        API_Country-->>Browser: AU (cached)
        Browser->>TMDB: getMovieWatchProviders(id, country)
        TMDB-->>Browser: watch providers list
    end

    Browser-->>User: Recommendations carousel<br/>with posters & watch providers
Loading

1. Collect preferences

Each participant fills in:

  • A favourite movie and why they love it
  • New vs classic preference (2015-present or pre-2015)
  • Mood (fun, serious, inspiring, or scary)
  • A favourite film person they would want to be stranded on an island with

The group also sets how much time is available for the session.

2. Embed

All preferences are concatenated into a single text blob within the movie-recommendations service. The server calls Google Gemini (gemini-embedding-001) via the Vercel AI SDK to produce a 768-dimensional vector, which is then L2-normalised on the server.

3. Retrieve -- vector similarity search

The server forwards the normalised vector to the Supabase Cloudflare Worker (POST /api/match-movies). That worker runs the Postgres RPC match_movies_4, using the pgvector <=> (cosine distance) operator against a pre-seeded corpus of movie embeddings and returning the top 10 matches above a 0.25 similarity threshold.

The movie corpus lives in public/constants/movies.txt and is chunked and embedded via the /api/embeddings-seed endpoint on first run.

4. Rank -- language model re-ranking and streaming

The matched movie content is split into individual entries and formatted as a "Movie List Context". This context, together with the original participant preferences, is sent to the LLM. We call Google Gemini 2.5 Flash (primary) via the Vercel AI SDK streamObject function to rank and filter the candidates. If Google is unavailable or its daily quota is exhausted (HTTP 429/403), the request automatically cascades through a series of OpenRouter fallbacks: MiniMax M2.5, Llama 3.3 70B, and finally openrouter/free (dynamic auto-router).

Quota errors trigger individualised circuit breakers: Google drops subsequent requests for 24 hours, whereas transient OpenRouter drops bypass that specific model for just 5 minutes before retrying.

A structured system prompt paired with a Zod schema (movieRecommendationSchema) instructs the model to return a stream of between 1 and 10 movies as a structured object, filtered by time constraints, era preference, mood, and genre fit. The server pipes this stream continuously back to the Next.js client, allowing the UI to display recommendations progressively as they are generated.

5. Fallback and display

If the LLM response cannot be parsed as valid JSON, a heuristic fallback (lib/utils/recommendations.ts) extracts movie titles, years, and synopses directly from the raw vector-match text. Movie posters and location-based streaming watch providers (provided by JustWatch) are fetched from the TMDB API and displayed in a carousel. The user's country is determined via api.country.is to localise the streaming providers shown.

Tech Stack

Layer Technology
Framework Next.js 16 (App Router, Turbopack)
UI React 19, Tailwind CSS, DaisyUI
AI Vercel AI SDK, Google Gemini (primary), OpenRouter (Minimax, Llama, Auto-Router fallbacks)
Gateway Cloudflare AI Gateway (Google language model path)
Database Supabase (Postgres + pgvector)
Edge worker Cloudflare Workers
Testing Jest 29, React Testing Library
Tooling TypeScript (strict), ESLint, Prettier, Husky, lint-staged

Getting Started

Prerequisites

  • Node.js v22.13.1 or higher
  • pnpm
  • A Supabase project with the pgvector extension enabled
  • API keys for Google Gemini and OpenRouter, plus TMDB
  • A Cloudflare account for the AI Gateway

Install

git clone https://github.com/CodeHunt101/plotline-ai.git
cd plotline-ai
pnpm install

Environment variables

Create .env.local for the Next.js app:

# Google Gemini -- primary language model + embeddings
GOOGLE_GENERATIVE_AI_API_KEY=

# OpenRouter -- fallback language model (used when Google is unavailable or quota-limited)
OPENROUTER_API_KEY=
OPENROUTER_LANGUAGE_MODEL=             # optional, defaults to minimax/minimax-m2.5:free

# Cloudflare AI Gateway (required for the primary Google language model)
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_GATEWAY_NAME=
CLOUDFLARE_API_KEY=                    # optional

# Supabase worker URL + shared secret used by server-side worker calls
SUPABASE_WORKER_URL=
SUPABASE_WORKER_SECRET=

# TMDB poster lookup
NEXT_PUBLIC_TMBD_ACCESS_TOKEN=

Create .dev.vars for the Cloudflare Supabase worker (see .dev.vars.example):

SUPABASE_URL=
SUPABASE_API_KEY=
WORKER_SHARED_SECRET=              # must match SUPABASE_WORKER_SECRET

Database setup

Enable the pgvector extension and create the movies table. The embedding provider is Google Gemini (gemini-embedding-001), which produces 768-dimensional vectors.

create extension vector;

create table movies_4 (
  id bigserial primary key,
  content text,
  embedding vector(768)
);

create function match_movies_4(
  query_embedding vector(768),
  match_threshold float,
  match_count int
)
returns table (
  id bigint,
  content text,
  similarity float
)
language sql stable
as $$
  select
    id,
    content,
    1 - (movies_4.embedding <=> query_embedding) as similarity
  from movies_4
  where 1 - (movies_4.embedding <=> query_embedding) > match_threshold
  order by similarity desc
  limit match_count;
$$;

To verify the dimensions of an existing table:

select atttypmod from pg_attribute
where attrelid = 'movies_4'::regclass and attname = 'embedding';

Development

Start the Next.js dev server and the Supabase worker:

pnpm dev
npx wrangler dev --config wrangler.supabase.toml

Then open http://localhost:3000.

To seed the movie corpus into Supabase on first run, call GET /api/embeddings-seed with the x-worker-secret header set to SUPABASE_WORKER_SECRET. This splits public/constants/movies.txt on movie boundaries (one entry per embedding), embeds each entry, and inserts them into the movies_4 table if it is empty.

To force a full reseed (truncates existing data first), call GET /api/embeddings-seed?force=true with the same x-worker-secret header.

Testing

pnpm test              # run the Jest suite
pnpm test:integration  # run the colocated integration tests only
pnpm test:coverage     # coverage report -- 95% threshold enforced
pnpm test:e2e          # run the Playwright browser suite

The first-pass Playwright coverage is deterministic by design: it stubs /api/recommendations and TMDb responses in the browser, so the suite does not depend on live AI, worker, or poster services.

To run Playwright locally, make sure the browser binary is installed once:

pnpm exec playwright install chromium
pnpm test:e2e

pnpm test:e2e reuses an existing local server when one is already running on http://127.0.0.1:3000; otherwise it builds the app and starts a local production server automatically.

Deployment

Deploy the Next.js app to Vercel:

vercel

Deploy the Supabase worker to Cloudflare:

npx wrangler deploy --config wrangler.supabase.toml

Supabase keepalive

This repo includes a GitHub Actions workflow at .github/workflows/supabase-keepalive.yml that runs a lightweight Postgres query once per day.

To enable it:

  1. In GitHub, open Settings -> Secrets and variables -> Actions.
  2. Add a repository secret named SUPABASE_DB_URL.
  3. Paste your Supabase transaction pooler connection string from Connect -> Transaction mode in the Supabase dashboard.

The workflow also supports manual runs from the Actions tab via workflow_dispatch.

CI/CD

This project uses GitHub Actions for continuous integration and automated Cloudflare Worker deploys.

Workflows

Workflow Trigger What it does
ci.yml Push / PR → main ESLint, TypeScript type-check, Jest coverage, and a deterministic Playwright Chromium suite. Uploads coverage and Playwright artefacts when needed.
deploy.yml Push → main (worker files only) Deploys the Supabase Cloudflare Worker via Wrangler.
supabase-keepalive.yml Daily schedule Runs a keepalive query against Supabase so the free-tier project stays active.

Required GitHub secrets

Open Settings → Secrets and variables → Actions in your GitHub repo and add:

Repository secrets (sensitive credentials):

Secret Used by Where to find it
SUPABASE_DB_URL supabase-keepalive.yml Supabase dashboard → Connect → Transaction mode
CLOUDFLARE_API_TOKEN deploy.yml Cloudflare dashboard → My Profile → API Tokens

Repository variables (non-sensitive config):

Variable Used by Where to find it
CLOUDFLARE_ACCOUNT_ID deploy.yml Cloudflare dashboard → right-hand sidebar

Note

The Cloudflare Worker runtime secrets (SUPABASE_URL, SUPABASE_API_KEY, WORKER_SHARED_SECRET) are set directly in the Cloudflare dashboard → Workers → supabase-worker → Settings → Variables and Secrets. They are not managed through GitHub Actions.

Project Structure

plotline-ai/
├── app/
│   ├── (routes)/                 # UI pages
│   │   ├── page.tsx                # Home -- participant setup
│   │   ├── movieForm/page.tsx      # Per-person preference form
│   │   └── recommendations/page.tsx# Results carousel
│   ├── api/
│   │   ├── recommendations/route.ts# Server-side recommendation pipeline (streaming)
│   │   └── embeddings-seed/route.ts# Corpus seeding
│   ├── layout.tsx
│   └── globals.css
├── components/
│   ├── features/                 # Header, Logo, ParticipantsSetup, MovieFormFields
│   └── ui/                       # TextAreaField, TabGroup
├── contexts/                     # MovieContext (shared state)
├── constants/                    # MOVIE_TYPES, MOOD_TYPES, sample data
├── types/                        # TypeScript interfaces (api.ts, movie.ts)
├── lib/
│   ├── config/                   # ai.ts (model selection), supabase.ts
│   ├── services/                 # movies, embeddings, openai, supabase, seed, tmdb
│   └── utils/                    # recommendations.ts, urls.ts
├── workers/
│   └── supabase-worker.ts        # Cloudflare Worker for Supabase operations
├── public/
│   └── constants/movies.txt      # Movie corpus for embedding seeding
├── tests/
│   ├── e2e/                      # Playwright browser specs + route stubs
│   └── support/                  # Shared deterministic test fixtures
├── wrangler.supabase.toml
├── jest.config.js
├── playwright.config.ts
├── tailwind.config.ts
└── package.json

Cloudflare Workers

Supabase Worker

The Supabase worker (workers/supabase-worker.ts, port 7878) proxies database operations so that Supabase credentials stay server-side:

  • POST /api/insert-movies -- batch-insert chunked movie data during seeding. Requires x-worker-secret.
  • GET /api/check-empty -- check whether the movies table needs seeding. Requires x-worker-secret.
  • POST /api/match-movies -- run the pgvector similarity RPC and return the top matches. Requires x-worker-secret.
  • DELETE /api/truncate-movies -- delete all rows from the movies table (used by force-reseed). Requires x-worker-secret.

AI Gateway

Text generation calls to Google Gemini are routed through the Cloudflare AI Gateway for logging, caching, and rate limiting. The gateway is configured in lib/config/ai.ts using the ai-gateway-provider package. The OpenRouter fallback path does not use the gateway.

AI Limitations

PlotlineAI uses artificial intelligence for movie recommendations, and while it strives for accuracy:

  • Recommendations may not always perfectly match group preferences.
  • Movie information and details might occasionally be incomplete or imprecise.
  • The system works best with clear, detailed input from all participants.
  • Results can vary based on the quality and specificity of user inputs.

Contributors

Languages