Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout ParseWorkout Parse — Behavior

Workout Parse — Behavior

Surface

EndpointPurpose
POST /organizations/:orgId/workouts/parseParse pasted text → 201 { data: ParseDraft }. Synchronous; one Sonnet call, ~5–15s.
GET /organizations/:orgId/workouts/parse/:jobIdRe-fetch a draft (org-scoped) → 200 { data: ParseDraft & { status } }. Preview refresh / ?jobId= deep link.
POST /organizations/:orgId/workouts/parse/:jobId/commitCommit the adjusted draft → 201 { data: { workoutId, delta } }.
POST /organizations/:orgId/workouts/parse/:jobId/discardDiscard → 200 { data: { ok: true } }. Job can no longer be committed.

All routes: global AuthGuard + org membership + staff role + @RequiresFeature('workout_builder') + the PostHog flag (fail-closed). Controller: apps/api/src/ai/parse/parse.controller.ts.

Gate order (POST /parse)

Each gate maps to a distinct error code (libs/shared/src/lib/parse-schemas/parse-api.schema.ts):

  1. Auth + requireMembership; role must be owner/admin/coach → else 403 (plain Forbidden).
  2. @RequiresFeature('workout_builder') tier guard → else 403 (plain tier error; no parse.blocked event — the guard runs before the handler).
  3. Flag workout-parse-transformer via EventTrackingService.isFeatureEnabled(PARSE_FLAG_KEY, orgId, { organization: orgId }) — proceeds only on === true (D8 fail-closed; honors the FEATURE_FLAGS env override in dev/e2e) → else 403 parse_feature_disabled.
  4. Length ≤ PARSE_MAX_INPUT_CHARS (10,000) → else 422 parse_too_long.
  5. Budget AgentRateLimitService.preCheck(orgId) (monthly backstop checked first, then daily) → on breach 429 ai_budget_exceeded with { period: 'day' | 'month' }.

Commit/discard re-check membership + role + tier + flag but not budget (no LLM call on those paths).

Pipeline (POST /parse happy path)

ParseOrchestrationService.runParse:

  1. Normalize input CRLF→LF once; the normalized text is what gets persisted and what every span ([start, end) char offsets) points into.
  2. Insert the job row first (status: 'draft') — a failed LLM call still leaves an audit row.
  3. Extraction (ExtractionService.extract): one non-streaming Sonnet (PARSE_MODEL = 'claude-sonnet-4-5') call with forced tool use (tool_choice: { type: 'tool', name: 'record_extraction' }, max_tokens: 4096, system prompt cached { type: 'ephemeral', ttl: '1h' }). Output validated against extractionResultSchema; on failure one retry with the Zod error summary appended, then ParseExtractionError. Spans are clamped to text bounds and a movement span whose text doesn’t contain its mention degrades to {start:0,end:0} — never a crash. With PARSE_EXTRACTION_FIXTURES=1 the API is bypassed and a recorded fixture (fixtures/extraction/<sha256-prefix>.json) is played back.
  4. Meter the spend immediatelycomputeCostUsdMicros + AgentCostTracker.recordTurn run before the language gate, so a rejected non-English parse is still billed (the call was made).
  5. Language gate (D3): language !== 'en' → job updated to rejected_non_english (with language, tokens, cost, timings, error_code: 'parse_english_only'), parse.blocked { reason: 'non_english', language } emitted, throw 422 parse_english_only.
  6. Multi-workout slicing (D4): workoutCount > 1 → sections/remainder filtered to firstWorkoutSpan, multiWorkoutDetected: true.
  7. Shape reconciliation (D7) per section — see table below. Superset groups via detectSupersetGroups (explicit A1/A2 markers or the literal word “superset” only; broken sequences → all-null).
  8. Prescription: the LLM’s partial guess is re-validated against the full PrescriptionSchema; invalid → null (under-structure).
  9. Resolution (D6): mentions deduped case-insensitively (normalizeMentionKey after cleanMention strips list/superset markers and trailing 5x5-style fragments), searched via ExerciseSearchService.search(orgId, { q, mode: 'hybrid', limit: 5 }), max 4 concurrent. A search that throws → that mention is unresolved, never a failed parse.
  10. Assemble ParseDraft (section keys s-0…, draft-wide movement keys m-0…), compute auto_payload via draftToSectionsPayload, update the job row (draft, payload, model, tokens, cost, stage_timings { extractionMs, resolutionMs, totalMs }), emit parse.completed, return the draft.
  11. Any other failure → job status: 'failed', error_code: 'parse_failed', emit parse.failed, throw sanitized 500 parse_failed.

Resolution confidence (D6)

The primary rule is exact folded name/alias equality — the library’s aliases encode coach vocabulary (“Back Squat” on barbell-back-squat, “pull up” on pull-up), and an exact match against them beats any score heuristic. foldExerciseName lowercases, folds punctuation to spaces, and singular-folds English tokens (Thrustersthruster, Pull-upspull up); both sides of every comparison fold identically. Resolution probes 10 search results but displays at most 5, exact matches pinned first.

ConditionStatusselectedExerciseId
Exactly ONE candidate whose folded name or alias equals the folded mentionautothat candidate (even if RRF ranked it below the display cap)
TWO+ candidates fold-equal (alias collision, e.g. “Run” on both Treadmill Run and Outdoor Run)suggestednull — a genuine coin flip the coach calls
No exact match: top.score ≥ 0.6 AND (top − second) ≥ 0.1autotop candidate
No exact match, short of thatsuggestednull
Empty results / search errorunresolvednull

The score fallback’s caveat: scores are RRF-fused then max-normalized — the top candidate is always 1.0 regardless of quality, and the gap measures signal-list disjointness rather than confidence (two candidates present in every signal list can never be more than ~0.02 apart). It’s kept as a conservative fallback, not the primary gate. The search’s full-text signal additionally OR-s an 'english'-config tsquery against the 'simple'-config document tsv, so English plurals reach the multilingual index.

Shape reconciliation (D7)

Grammar (detectShape)LLM shapeGuessResult
matched(shape ignored)grammar’s shape, shapeSource: 'grammar', confidence high. Config = grammar’s, with the LLM’s configGuess (validated against the grammar shape’s schema) filling keys grammar didn’t set — grammar only reads the header line, so a “For Time:” section with “Cap: 30 Minutes” elsewhere gets timeCapMinutes from the LLM. Grammar keys win every collision.
no matchpresentLLM’s shape, 'llm' / low (flagged in preview); configGuess validated against SECTION_SHAPES[shape].config, invalid → null
no matchabsentlinear, 'none' / high, body preserved

Shapes: amrap, emom, for_time, rounds, tabata, rep_scheme, intervals, else linear. 5x5 on a line that carries a movement mention is a prescription, not a shape (hasMention guard).

Auto payload (under-structure doctrine)

draftToSectionsPayload includes only auto-resolved movements. Every other movement’s raw line is appended to the section description under "Needs review (not auto-matched to an exercise):". This payload is persisted as auto_payload and is the baseline the commit-time correction delta measures against.

Commit / discard

ParseCommitService (job must be org-scoped and status: 'draft', else 404 / 409 Conflict — a plain Nest conflict body with no code):

  • Paste flow (job.workout_id null): WorkoutsService.create(orgId, clerkId, { …meta, mode: 'structured', sections }) — one call, inline sections. description defaults to job.input_text (the original paste is the source of truth) unless the coach overrode it.
  • “Structure this” flow (job.workout_id set): WorkoutsService.update (meta + mode: 'structured') then setSections. No new write logic against workout tables (D11); WorkoutsService re-runs its own membership/tier validation.
  • delta = diffSectionsPayload(auto_payload, committed sections) → job updated to committed with final_payload, delta, workout_id; parse.committed emitted (editDistance, zeroEdit, swap/add/remove counts, exercisesCreatedCount from the DTO, timeToCommitMs).
  • Discard → status: 'discarded', parse.discarded.

Web flow

  • Entry (a): chooser card paste-workout-card (workout-type-selection.tsx) → /[lang]/dashboard/workouts/new/paste. Card renders only when the flag hook returns true; dimmed + lock icon when the org lacks workout_builder.
  • Entry (b): structure-this-button on the freeform form — writes { text, workoutId? } to sessionStorage (paste-handoff.ts) and navigates; the paste view reads-and-clears it once, sets source: 'freeform_form'.
  • The page wraps in <FeatureGate feature="workout_builder" fallback="upgrade-card">; the flag is re-checked inside PasteWorkoutView (deep links are safe — parse-unavailable state when off).
  • On parse success the URL becomes ?jobId=<id> (shareable / refresh-safe via the GET endpoint). A job loaded with a non-draft status renders the preview without the commit bar.
  • Preview (parse-preview.tsx): left = source text with claimed-span vs remainder highlighting (hover sync with the tree); right = the real builder (useBuilderState + SectionEditor) seeded via draftToBuilderSections, decorated with confidence chips through a sidecar map (parse-meta.ts) — builder state itself is untouched. Remainder shows in a collapsed “unparsed notes” block.
  • Adjustment (movement-resolution-popover.tsx): per non-auto movement — pick a candidate (qualitative labels, not raw scores), search the library, create an org-local exercise (name-prefilled, POST /exercises — which now enqueues embedding enrichment, D10), or drop-to-note (clears the movement, appends the raw line to the section description).
  • Commit bar: commit disabled while any non-auto movement is unactioned (unresolved-counter + tooltip; useParseCommit.commit also no-ops defensively). Discard behind an AlertDialog. Success → invalidate workout queries, toast, redirect to the workout page.
  • Input is dir="auto"; layout uses logical properties (RTL-safe).

Error codes

CodeHTTPWhenExtra fields
parse_feature_disabled403Flag not === true (all four routes)
parse_too_long422Input > 10,000 chars
ai_budget_exceeded429Daily or monthly org AI budget breached (parse only)period: 'day' | 'month'
parse_english_only422Extraction detected non-English (D3)
parse_failed500Any pipeline failure after the gates

Body shape: { code, message, ...extra } (ParseHttpError). Not code-carrying: plain 403 (role/tier), 404 (job not found), 409 (commit/discard on a non-draft job — surfaced client-side as the generic failure toast). The web maps each code to a localized inline error (paste-input.tsx).

Permissions

RoleParse / view / commit / discard
owner, admin, coach
member403 at the controller
Non-member of the org403 (requireMembership)

Tier × flag matrix

Flag off / unevaluableFlag true
Lite (no workout_builder)Hidden; API 403 (tier guard). Chooser card hidden (flag)Entry points visible but lock-styled; paste route shows the upgrade card; API 403 (tier guard)
Pro / EliteEntry points hidden; deep link → “not available” state; API 403 parse_feature_disabled✅ Full feature

Fail-closed (D8): an unreachable PostHog or a non-prod env without the FEATURE_FLAGS override behaves like flag-off. “Current behavior” for this brand-new surface is feature-absent, so fail-closed and fail-safe coincide.

Failure modes

FailureSurfaceJob row
Anthropic down / invalid tool output twice500 parse_failedfailed + error_code
Voyage / exercise search downParse succeeds; affected mentions unresolveddraft
Cost-tracking write failsLogged warning; parse continuesunchanged
Non-English paste422 parse_english_only, localized messagerejected_non_english (cost still metered)
Double commit (race / stale tab)409; generic failure toastfirst commit wins
PostHog unreachableFeature hidden + 403 (fail-closed)no row created