AI Features
show.fm ships three AI capabilities, all executed asynchronously by the ai-processor worker as Cloudflare Workflows:
| Capability | Workflow class | Job type (ai_job_type) | Deep dive |
|---|---|---|---|
| Episode transcription (Whisper + diarization) | TranscriptionWorkflow | transcription | Transcription Pipeline |
| Promo assets from a transcript (titles, summary, social posts, tags) | ContentGenerationWorkflow | content_generation | This page (below) |
| Guest research briefs from booking-form answers | GuestResearchWorkflow | guest_research | Guest Research |
All three spend the single AI meter, ai_credits_per_month. Metering semantics live in Credits & Metering.
Architecture: one job pipeline
Every AI run follows the same shape, regardless of job type:
Key properties, each load-bearing:
- Row first, then message.
createAndEnqueueAiJob()insrc/lib/server/ai-jobs.tsinserts theai_jobsstatus row, then sends the queue message. If the send fails, the row is flipped tofailedso no phantompendingrow survives. - No client writes to
ai_jobs. Migration20260710153000_harden_ai_client_writes.sqlrevoked INSERT/UPDATE/DELETE fromanon/authenticatedand dropped the member INSERT policy: a direct PostgREST insert would create a row no worker ever sees, and the one-active-job unique index would then block the real route withjob_active. Job creation goes through an explicit service-role client; the route derivespodcast_id,episode_id, andbilling_account_idserver-side from the episode row, never from client input. - Instance id = job id. The queue consumer in
workers/ai-processor/src/index.tsis a thin spawn:workflow.create({ id: jobId }). Duplicate queue deliveries dedupe at the platform (create()throws when the instance exists; the consumer confirms viaworkflow.get(id)and acks). After 3 failed spawn attempts (MAX_SPAWN_ATTEMPTS, matchingmax_retries = 2inwrangler.toml) the consumer marks the jobfailedwith a user-visible message instead of stranding a phantompendingrow. - One active job per scope. Partial unique indexes enforce one pending/running job per
(episode_id, job_type)(20260703161335_ai_jobs_and_transcripts.sql) and one active research job per booking (20260704022823_ai_jobs_booking_scope.sql). Concurrent submissions become a clean23505conflict, surfaced as a 409job_active. - Durable steps. Work and retries live inside the workflows; step results persist, so a transcription that fails at chunk 40 of 56 resumes at 41 and Whisper spend is never repaid. Every DB/RPC effect (reserve, refund, notify, upsert) is idempotent by key.
Job status model
ai_jobs.status mirrors the ai_job_status enum: pending | running | complete | failed | skipped (TypeScript lockstep in src/lib/transcripts/types.ts). skipped is specifically the quota-refused outcome, never retried. stage and progress are cosmetic live-progress fields for the Realtime UI; GET /api/ai/jobs/:jobId is the rehydrate endpoint when a Realtime subscription reconnects.
The content-generation workflow
ContentGenerationWorkflow (workers/ai-processor/src/workflows/content-generation.ts) produces PromoAssets (titles, summary, tweet, LinkedIn post, tags) from the episode's current transcript artifact (edited_key ?? raw_key):
validate -> check-headroom (READ) -> generate -> record-spend (SOFT) -> finalize
catch: mark-failed (generation reserves nothing, so no refund)The load-bearing property is the generate / record-spend step split: the generation result is durably persisted by its step, so a transient metering failure retries record-spend without re-running the LLM call. Spend is recorded from actual usage even when the response fails to parse, because the model ran and billed either way; the user-facing failure message says exactly that. Results land in ai_jobs.result.assets (no dedicated table); the transcript surface's PromoAssetsPanel (src/lib/components/transcripts/PromoAssetsPanel.svelte) renders the latest complete generation returned by GET /api/ai/transcriptions/:episodeId.
API surface
All routes live in src/api/routes/ai/index.ts, mounted at /api/ai (src/api/index.ts):
| Method | Path | Role | Feature gate |
|---|---|---|---|
| POST | /ai/transcriptions/:episodeId | member+ | ai_transcription |
| GET | /ai/jobs/:jobId | member+ | none |
| GET | /ai/transcriptions/:episodeId | member+ | none |
| PUT | /ai/transcriptions/:episodeId | member+ | none |
| POST | /ai/transcriptions/:episodeId/publish | admin+ | none |
| DELETE | /ai/transcriptions/:episodeId/publish | admin+ | none |
| POST | /ai/content/:episodeId | member+ | ai_promo_assets |
| POST | /ai/research/:episodeId | member+ | ai_prep_questions |
| GET | /ai/research/:episodeId | member+ | none |
| DELETE | /ai/research/:episodeId/:researchId | admin+ | none |
Non-middleware denials use the GateError envelope { error, code, key, meta } with codes such as audio_not_finalized (428), unsupported_audio_format / audio_too_large (422), quota_exceeded (429), transcript_exists / job_active (409).
Feature gates
| Catalog key | Gates | Notes |
|---|---|---|
ai_transcription | Manual transcription route, the auto-transcribe upload hook, and successor-job spawning | Checked live at each site |
ai_promo_assets | Promo content generation | Added in 20260703161340_ai_promo_assets_catalog_key.sql |
ai_prep_questions | Guest research: research route, confirm-time enqueue, and the public booking form's AI section | The public submission path resolves this as a tri-state (granted / denied / unavailable) via src/lib/entitlements/ai-research-feature.ts; see Guest Research |
Worker bindings
From workers/ai-processor/wrangler.toml: Workers AI (AI, via the podcasterplus-ai AI Gateway), Hyperdrive (shared config), the podcasterplus-media R2 bucket, Browser Run (BROWSER, research page fetching), queue consumer ai-jobs (DLQ ai-jobs-dlq), and queue producers automation-events (fan-out of episode.transcribed / episode.research_ready) and rss-invalidation (stale published transcripts). Shared app modules (src/lib/entitlements/*, src/lib/transcripts/*, src/lib/booking/ai-research.ts, src/lib/research/types.ts) are imported via relative paths, never $lib aliases.