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) | |
|---|---|---|
| What | canonicalize names · LLM polish category/kind | upsert rows + Voyage embed · generate thumbnails |
| Where | offline (your machine) — Python pipeline, not the API | prod admin endpoints |
| Cost | ~$1 Anthropic | fal 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.devor custom), plus the already-presentCANONICAL_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); itsr2.devdomain isR2_PUBLIC_BASE. - fal.ai balance topped up.
- Local:
movements.input.jsonpresent intools/movements-pipeline/;CANONICAL_R2_BUCKETset 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.jsonPrints 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 --applyIt 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 allEnqueues 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:
- Discover daily with no special arguments (
POST video/discover {}), untilremainingcomes back empty. By default a movement that already has asource: 'youtube-search'candidate from a prior run is skipped — only uncovered movements spend quota — so running it every day is safe and cheap; passforce: trueto re-search specific movements (e.g.slugs) that need a fresh search anyway. If a run stops withquotaExhausted: 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 noremaining-id bookkeeping required for the normal daily case; feedremainingback asidsonly if you want to finish a specific run’s coverage sooner than the next scheduled one. - Rank, covered only, dry run first —
POST video/rank { "dryRun": true }. Ranking defaults toincludeUncovered: false: it skips any row discovery hasn’t reached yet (no discovered candidate to score) rather than clearing itsvideoUrl, and returns those ids underskippedUncovered. Ranking rows discovery never reached is almost always what makes a ranking run look like it “cleared everything” — resist the urge to passincludeUncovered: trueunless you specifically want to re-judge legacy-only candidate lists. - Read the outcome summary before doing anything else.
GET /admin/exercises/canonical/jobs/{queue}/{id}returns the job’sreturnvalue: asummaryblock (counts per outcome —kept,changed,cleared,filled,still_none— plusuncovered,costUsdMicros,dryRun,model) andskippedUncovered(ids not ranked this run). Eachreportrow also carriespreviousUrl,outcome,covered,candidateCount, anddryRun, so “what is different on prod now” is readable straight off the report without diffing anything by hand. - Apply — once the summary looks right, re-run the same request with
dryRunoff (ordryRun: false). A dry run changes nothing on prod — it only scores and reports. Only a run withdryRunoff writesvideoUrl. - 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 reportsdemote 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(multipartfile, 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).