Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksMovements library update (canonical exercises)

Movements library update (canonical exercises)

How to refresh the canonical movement library in production — cleaned taxonomy/names, per-movement category/kind, and owned soft-3D thumbnails.

There are two operations, and only the second touches the prod DB:

GENERATE (make the data)SEED / THUMBNAILS (load into prod)
Whatcanonicalize names · LLM polish category/kindupsert rows + Voyage embed · generate thumbnails
Whereoffline (your machine) — Python pipeline, not the APIprod admin endpoints
Cost~$1 Anthropicfal credits for thumbnails

The name/category pass must run offline (it needs movements.input.json, which isn’t in the repo or the API). Thumbnails run in the API (a Bull queue), so the expensive step is triggered from prod admin — no laptop.

Prerequisites (one-time)

  • Prod env (@taikan/api): FAL_KEY, R2_THUMBNAILS_BUCKET (the dedicated public images bucket, e.g. movements-thumbnails), R2_PUBLIC_BASE (that bucket’s public domain — pub-xxxx.r2.dev or custom), plus the already-present CANONICAL_R2_BUCKET, VOYAGE_API_KEY, ANTHROPIC_API_KEY, R2_* creds.
  • Dedicated public bucket for thumbnails — images only, so the canonical JSON bucket stays private. Create movements-thumbnails, enable public access (Cloudflare → R2 → bucket → Settings → Public access); its r2.dev domain is R2_PUBLIC_BASE.
  • fal.ai balance topped up.
  • Local: movements.input.json present in tools/movements-pipeline/; CANONICAL_R2_BUCKET set locally to the same bucket prod reads; pip install -r tools/movements-pipeline/requirements.txt.

Step 1 — Generate the data (offline)

make movements-canonicalize # taxonomy/naming → movements.canonical.json make movements-polish # LLM names + category/kind → movements.polished.json (~$1)

Review tools/movements-pipeline/canonicalization_report.md and polish_report.md (renames, category fill, the dedup/flag review queue).

Step 2 — Publish to R2

make movements-publish MOVEMENTS_SRC=tools/movements-pipeline/movements.polished.json

Prints the versioned r2Key (e.g. v2026-06-01T…Z.json) — keys live at the root of the dedicated movements bucket, so no movements/ prefix. The publish script self-loads apps/api/.env; ensure CANONICAL_R2_BUCKET there points at the prod bucket.

Step 3 — Cleanup orphans (after seed, before thumbnails)

Renamed/demoted slugs leave orphan rows on upsert (the seed adds/updates by slug but never deletes). There is no exposed purge endpoint by design — cleanup is a manual, engineer-run script against prod:

# read-only dry-run: lists canonical rows whose slug isn't in the published lib DATABASE_URL="<Postgres service → DATABASE_PUBLIC_URL>" \ pnpm exec tsx scripts/cleanup-canonical-orphans.ts # soft-delete them (deleted_at = now()) — reversible, preserves FK + history DATABASE_URL="<…>" pnpm exec tsx scripts/cleanup-canonical-orphans.ts --apply

It soft-deletes (never hard-deletes), only touches organization_id IS NULL rows, and aborts if orphans exceed 20% of the canonical set (bad-source guard). Run it after the seed and before thumbnails so the in-API queue doesn’t burn fal credits rendering soon-to-be-hidden rows. Orphans still referenced by real data (workouts/results/PRs) stay resolvable — they’re just hidden from search/pickers. Repoint those references to the canonical replacement first if a clean history matters.

Step 4 — Seed the prod DB (admin)

POST /admin/exercises/canonical/seed { "r2Key": "<version>.json", "rebuild": true, "rpm": 60 }

rebuild: true overwrites enriched fields; rpm: 60 uses the paid Voyage tier (free tier is 3 rpm → hours for ~600 rows). Monitor:

GET /admin/exercises/canonical/jobs GET /admin/exercises/canonical/jobs/{queue}/{id}

Step 5 — Generate thumbnails (admin)

POST /admin/exercises/canonical/thumbnails # missing only POST /admin/exercises/canonical/thumbnails { "force": true } # regenerate all

Enqueues one exercise-thumbnail job per canonical row. Each job: fal/FLUX (ControlNet-Canny when the row has a clean reference, flux/dev otherwise) → upload PNG to the public R2 bucket at movements/thumbnails/<slug>.png → set thumbnailUrl to R2_PUBLIC_BASE/.... Safe no-op if FAL_KEY/R2_PUBLIC_BASE are unset.

Step 6 — Verify

Spot-check exercises: names cleaned (Deadlift, not Conventional Deadlift), category/kind populated, thumbnailUrl is on R2_PUBLIC_BASE and renders.

Step 6.5 — Find and merge duplicates (admin)

Run this before video discovery/ranking and cues — a duplicate row about to be merged away shouldn’t spend YouTube quota or model budget on the video discovery / ranking / cues passes below.

POST /admin/exercises/canonical/duplicates {} POST /admin/exercises/canonical/duplicates { "limit": 50 }

Always a dry run — it only reports. A merge candidate needs embedding cosine similarity ≥ DUPLICATE_EMBEDDING_THRESHOLD (0.92) and a matching movement_pattern. A shared alias or name alone is never merge evidence: most shared aliases are a wrong alias on a different movement (hspu on Push-Up, run on Treadmill Run), and merging them deletes a real movement. Those terms are returned separately as aliasCollisions and fixed in Step 6.6. Pairs where only one name carries a variant qualifier (kipping, ring, weighted, wide/close grip, …) are excluded. GET /admin/exercises/canonical/jobs/{queue}/{id} returns the report: each pair’s two rows (aliases, referencing counts across workout_movements, workout_set_results, personal_records, goals, exercise_org_overrides), the similarity, and a suggestedWinnerId.

Only trust a report produced by the currently deployed API — a duplicates job enqueued while an older build is still serving runs the old rules. The admin app hides legacy alias_match pairs for that reason.

Review the report in the admin app’s “Find duplicates” card: “Review merge” (or “Review all”) dry-runs every pair, lets you pick which movement is kept, and shows the guard verdict, repointed references and the aliases the kept movement absorbs before anything is written. The equivalent API calls:

POST /admin/exercises/canonical/merge { "pairs": [{ "winnerId": "<id>", "loserId": "<id>" }], "dryRun": true } POST /admin/exercises/canonical/merge { "pairs": [{ "winnerId": "<id>", "loserId": "<id>" }] }

dryRun: true returns the merge plan (per-table counts) and writes nothing. Without it, each pair is merged in one transaction: every referencing table is repointed onto the winner, the loser’s name/aliases are folded into the winner’s aliases (there is no merged_into_id column — this is the entire merge trail), the loser is soft-deleted, and the winner is enqueued for re-embedding. Merging an already-merged pair (loser already soft-deleted) is a no-op reported as already_merged — safe to re-run. See behavior.md → Duplicate detection and merge for the full merge rules (personal-record and override conflict handling, video adoption).

Step 6.6 — Resolve alias collisions (admin)

Every normalized name/alias term must identify one movement, or the exact lookup returns ambiguous and matches nothing. Collisions are detected with the same normalization as the lookup (normalizeExerciseIdentity: case, spaces, apostrophes, dashes and movement plurals — “lunges” collides with “lunge”).

In the admin app’s “Alias collisions” card, “Resolve” a term: keep it on the movement a coach means (locked when the term is one movement’s canonical name) or remove it from all when it is too generic, preview the dry run, then apply. The API equivalent, one call per movement losing the term:

GET /admin/exercises/canonical/alias-collisions POST /admin/exercises/canonical/alias-change { "sourceId": "<id>", "alias": "<term>", "dryRun": true }

alias matches every spelling of the term on the source row. Each write records the before/after alias arrays in audit_logs (the rollback record) and re-embeds the row. True duplicates — the same movement twice — are merged in Step 6.5 instead.

Step 7 — Discover candidates, then rank (admin)

Prerequisite: YOUTUBE_API_KEY (a YouTube Data API v3 key — see .env.example). Without it, discovery is a clean no-op that logs once and reports disabled: "YOUTUBE_API_KEY not set".

Ranking only re-scores whatever is already sitting in video_candidates — on most movements that list is seed-time junk or empty, so run discovery first.

Discovery always saves what it finds — a candidate changes nothing a member sees, only what ranking has to choose from. There’s no dry-run mode that withholds discovery’s own write; dryRun on discovery only suppresses a chained ranking job’s write (via video/refresh’s thenRank). Dry-run before applying the ranking pass instead — that’s the step that actually changes what members see.

POST /admin/exercises/canonical/video/discover {} POST /admin/exercises/canonical/video/discover { "limit": 20 } POST /admin/exercises/canonical/video/discover { "ids": ["<id from a previous run's `remaining`>"] } POST /admin/exercises/canonical/video/discover { "force": true, "slugs": ["back-squat"] }

Daily procedure:

  1. Discover daily with no special arguments (POST video/discover {}), until remaining comes back empty. By default a movement that already has a source: 'youtube-search' candidate from a prior run is skipped — only uncovered movements spend quota — so running it every day is safe and cheap; pass force: true to re-search specific movements (e.g. slugs) that need a fresh search anyway. If a run stops with quotaExhausted: true, nothing further is needed — the next day’s run picks up the still-uncovered movements on its own (they were never marked covered), so there’s no remaining-id bookkeeping required for the normal daily case; feed remaining back as ids only if you want to finish a specific run’s coverage sooner than the next scheduled one.
  2. Rank, covered only, dry run first — POST video/rank { "dryRun": true }. Ranking defaults to includeUncovered: false: it skips any row discovery hasn’t reached yet (no discovered candidate to score) rather than clearing its videoUrl, and returns those ids under skippedUncovered. Ranking rows discovery never reached is almost always what makes a ranking run look like it “cleared everything” — resist the urge to pass includeUncovered: true unless you specifically want to re-judge legacy-only candidate lists.
  3. Read the outcome summary before doing anything else. GET /admin/exercises/canonical/jobs/{queue}/{id} returns the job’s returnvalue: a summary block (counts per outcome — kept, changed, cleared, filled, still_none — plus uncovered, costUsdMicros, dryRun, model) and skippedUncovered (ids not ranked this run). Each report row also carries previousUrl, outcome, covered, candidateCount, and dryRun, so “what is different on prod now” is readable straight off the report without diffing anything by hand.
  4. Apply — once the summary looks right, re-run the same request with dryRun off (or dryRun: false). A dry run changes nothing on prod — it only scores and reports. Only a run with dryRun off writes videoUrl.
  5. Cues — Step 8 below, same dry-run-first pattern.

For each movement not already covered, in order (movements referenced by workout_movements first, then the rest, stable by slug), searches YouTube ("<name> demo" at the default queriesPerMovement: 1; 2 adds "how to <name>"; 3 adds the first alias; plus, if VIDEO_TRUSTED_CHANNELS (apps/api/src/exercises/video-trusted-channels.ts) is non-empty, one extra "<name> <trusted channel>" query beyond the cap), pulls duration/view/like counts, and appends new candidates (score: 0, modelScore unset, source: 'youtube-search', discoveredAt set) to video_candidates — deduped against both existing candidates and video_rejected_ids, and filtered to 15s–4min. Never removes anything.

Quota: search.list costs 100 units against the default 10,000/day project quota (~100 calls/day). At the default queriesPerMovement: 1, maxSearches (default 90) leaves headroom under that ceiling — a plain daily run covers roughly 90 new movements. The job stops cleanly (never throws) either when maxSearches is hit or when the provider itself reports its quota is spent (HTTP 429, or 403 naming quotaExceeded), returning covered (ids searched this run), remaining (ids not yet covered, in order), searchesUsed, and quotaExhausted (true only for the provider-quota case, so you can tell “we set our own ceiling” from “YouTube cut us off”).

POST /admin/exercises/canonical/video/refresh {}

Chains discover → rank for the same filter in one enqueue (the discovery job enqueues the ranking job on completion). Discovered candidates are always saved regardless of dryRun; pass dryRun: true here to run the chained ranking pass in report-only mode over whatever got discovered. Use the two endpoints separately to inspect discovery’s own report before ranking runs.

POST /admin/exercises/canonical/video/rank { "dryRun": true } POST /admin/exercises/canonical/video/rank { "dryRun": true, "limit": 20 } POST /admin/exercises/canonical/video/rank { "dryRun": true, "slugs": ["back-squat"] } POST /admin/exercises/canonical/video/rank { "dryRun": true, "includeUncovered": true }

Scores the union of whatever the seed pipeline wrote and whatever discovery has since appended against the movement name/aliases (with VIDEO_TRUSTED_CHANNELS as a soft hint), picks the best above threshold as videoUrl (clears to null below threshold), and persists the score on the candidate’s modelScore field. Ties on model score break toward a trusted channel, then the shorter video, then the more-viewed one, then stable candidate order. The pick rule reads modelScore only — a legacy score value some rows were seeded with offline (an unrelated, unbounded popularity scale) never competes with a real model score; a candidate the model hasn’t scored counts as 0. Rows referenced by a workout_movements row are scored first. Uncovered rows are skipped by default (see the daily procedure above); never touches video_status='manual' rows.

GET /admin/exercises/canonical/jobs/{queue}/{id} returns the job’s returnvalue — a summary block, skippedUncovered (ids not ranked this run), and report: one row per ranked movement, carrying the chosen candidate + score, every rejected candidate + score, previousUrl (the videoUrl before this run), outcome (kept | changed | cleared | filled | still_none), covered, candidateCount, and dryRun. The admin page’s expanded job row surfaces the summary line and this table directly — see apps/admin/src/pages/canonical/show.tsx.

Both the ranking and discovery model/search calls go through small provider interfaces — MovementContentModel (apps/api/src/exercises/movement-content-model.ts, currently Anthropic) and VideoSearchProvider (apps/api/src/exercises/video-search-provider.ts, currently YouTube Data API v3 over plain fetch) — so either can be swapped without touching the ranking/discovery services.

Step 8 — Cues (admin)

POST /admin/exercises/canonical/cues { "dryRun": true } POST /admin/exercises/canonical/cues { "rebuild": true }

Fills cues/common_faults/scaling_options (3/2/2, English). Without rebuild, only rows short of the required count are touched — re-running is a safe no-op once the library is complete. rebuild: true regenerates every candidate row, including already-complete ones.

Step 9 — Review a video (admin) or report one (coach/member)

POST /admin/exercises/canonical/{id}/video/verify # mark reviewed-good POST /admin/exercises/canonical/{id}/video/demote # reject; falls through to the next scored candidate, or null POST /organizations/{orgId}/exercises/{id}/video/report # any org member — auto-demotes at 3 negative reports

demote and the auto-demote path in report share the same fall-through rule: reject the current candidate, pick the next-highest-scored candidate still above threshold, or clear videoUrl to null. A verified or manual video does not auto-demote from reports — an admin has to demote it explicitly.

Custom movements (coaches)

  • Creating a custom exercise auto-enqueues a thumbnail in the same style.
  • Coaches can upload their own image: PUT /organizations/:orgId/exercises/:id/thumbnail (multipart file, png/jpeg/webp ≤5 MB) — org-owned movements only; canonical thumbnails are platform-managed.

Pipeline reference

The offline pipeline lives in tools/movements-pipeline/ (see its RUNBOOK.md): canonicalize.py → canonicalize_llm.py → publish-movements.ts. The legacy offline image_prod.py is superseded in production by Step 5 (the in-API queue).