Workout Parse — Behavior
Surface
| Endpoint | Purpose |
|---|---|
POST /organizations/:orgId/workouts/parse | Parse pasted text → 201 { data: ParseDraft }. Synchronous; one Sonnet call, ~5–15s. |
GET /organizations/:orgId/workouts/parse/:jobId | Re-fetch a draft (org-scoped) → 200 { data: ParseDraft & { status } }. Preview refresh / ?jobId= deep link. |
POST /organizations/:orgId/workouts/parse/:jobId/commit | Commit the adjusted draft → 201 { data: { workoutId, delta } }. |
POST /organizations/:orgId/workouts/parse/:jobId/discard | Discard → 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):
- Auth +
requireMembership; role must beowner/admin/coach→ else 403 (plain Forbidden). @RequiresFeature('workout_builder')tier guard → else 403 (plain tier error; noparse.blockedevent — the guard runs before the handler).- Flag
workout-parse-transformerviaEventTrackingService.isFeatureEnabled(PARSE_FLAG_KEY, orgId, { organization: orgId })— proceeds only on=== true(D8 fail-closed; honors theFEATURE_FLAGSenv override in dev/e2e) → else 403parse_feature_disabled. - Length ≤
PARSE_MAX_INPUT_CHARS(10,000) → else 422parse_too_long. - Budget
AgentRateLimitService.preCheck(orgId)(monthly backstop checked first, then daily) → on breach 429ai_budget_exceededwith{ 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:
- Normalize input CRLF→LF once; the normalized text is what gets persisted and what every span (
[start, end)char offsets) points into. - Insert the job row first (
status: 'draft') — a failed LLM call still leaves an audit row. - 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 againstextractionResultSchema; on failure one retry with the Zod error summary appended, thenParseExtractionError. Spans are clamped to text bounds and a movement span whose text doesn’t contain itsmentiondegrades to{start:0,end:0}— never a crash. WithPARSE_EXTRACTION_FIXTURES=1the API is bypassed and a recorded fixture (fixtures/extraction/<sha256-prefix>.json) is played back. - Meter the spend immediately —
computeCostUsdMicros+AgentCostTracker.recordTurnrun before the language gate, so a rejected non-English parse is still billed (the call was made). - Language gate (D3):
language !== 'en'→ job updated torejected_non_english(with language, tokens, cost, timings,error_code: 'parse_english_only'),parse.blocked { reason: 'non_english', language }emitted, throw 422parse_english_only. - Multi-workout slicing (D4):
workoutCount > 1→ sections/remainder filtered tofirstWorkoutSpan,multiWorkoutDetected: true. - Shape reconciliation (D7) per section — see table below. Superset groups via
detectSupersetGroups(explicitA1/A2markers or the literal word “superset” only; broken sequences → all-null). - Prescription: the LLM’s partial guess is re-validated against the full
PrescriptionSchema; invalid →null(under-structure). - Resolution (D6): mentions deduped case-insensitively (
normalizeMentionKeyaftercleanMentionstrips list/superset markers and trailing5x5-style fragments), searched viaExerciseSearchService.search(orgId, { q, mode: 'hybrid', limit: 5 }), max 4 concurrent. A search that throws → that mention isunresolved, never a failed parse. - Assemble
ParseDraft(section keyss-0…, draft-wide movement keysm-0…), computeauto_payloadviadraftToSectionsPayload, update the job row (draft, payload, model, tokens, cost,stage_timings { extractionMs, resolutionMs, totalMs }), emitparse.completed, return the draft. - Any other failure → job
status: 'failed',error_code: 'parse_failed', emitparse.failed, throw sanitized 500parse_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 (Thrusters ≡ thruster, Pull-ups ≡ pull up); both sides of every comparison fold identically. Resolution probes 10 search results but displays at most 5, exact matches pinned first.
| Condition | Status | selectedExerciseId |
|---|---|---|
| Exactly ONE candidate whose folded name or alias equals the folded mention | auto | that 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) | suggested | null — a genuine coin flip the coach calls |
No exact match: top.score ≥ 0.6 AND (top − second) ≥ 0.1 | auto | top candidate |
| No exact match, short of that | suggested | null |
| Empty results / search error | unresolved | null |
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 shapeGuess | Result |
|---|---|---|
| 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 match | present | LLM’s shape, 'llm' / low (flagged in preview); configGuess validated against SECTION_SHAPES[shape].config, invalid → null |
| no match | absent | linear, '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_idnull):WorkoutsService.create(orgId, clerkId, { …meta, mode: 'structured', sections })— one call, inline sections.descriptiondefaults tojob.input_text(the original paste is the source of truth) unless the coach overrode it. - “Structure this” flow (
job.workout_idset):WorkoutsService.update(meta +mode: 'structured') thensetSections. No new write logic against workout tables (D11);WorkoutsServicere-runs its own membership/tier validation. delta = diffSectionsPayload(auto_payload, committed sections)→ job updated tocommittedwithfinal_payload,delta,workout_id;parse.committedemitted (editDistance, zeroEdit, swap/add/remove counts,exercisesCreatedCountfrom 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 lacksworkout_builder. - Entry (b):
structure-this-buttonon the freeform form — writes{ text, workoutId? }to sessionStorage (paste-handoff.ts) and navigates; the paste view reads-and-clears it once, setssource: 'freeform_form'. - The page wraps in
<FeatureGate feature="workout_builder" fallback="upgrade-card">; the flag is re-checked insidePasteWorkoutView(deep links are safe —parse-unavailablestate when off). - On parse success the URL becomes
?jobId=<id>(shareable / refresh-safe via the GET endpoint). A job loaded with a non-draftstatus 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 viadraftToBuilderSections, 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-automovement — 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-
automovement is unactioned (unresolved-counter+ tooltip;useParseCommit.commitalso no-ops defensively). Discard behind anAlertDialog. Success → invalidate workout queries, toast, redirect to the workout page. - Input is
dir="auto"; layout uses logical properties (RTL-safe).
Error codes
| Code | HTTP | When | Extra fields |
|---|---|---|---|
parse_feature_disabled | 403 | Flag not === true (all four routes) | |
parse_too_long | 422 | Input > 10,000 chars | |
ai_budget_exceeded | 429 | Daily or monthly org AI budget breached (parse only) | period: 'day' | 'month' |
parse_english_only | 422 | Extraction detected non-English (D3) | |
parse_failed | 500 | Any 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
| Role | Parse / view / commit / discard |
|---|---|
owner, admin, coach | ✅ |
member | 403 at the controller |
| Non-member of the org | 403 (requireMembership) |
Tier × flag matrix
| Flag off / unevaluable | Flag 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 / Elite | Entry 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
| Failure | Surface | Job row |
|---|---|---|
| Anthropic down / invalid tool output twice | 500 parse_failed | failed + error_code |
| Voyage / exercise search down | Parse succeeds; affected mentions unresolved | draft |
| Cost-tracking write fails | Logged warning; parse continues | unchanged |
| Non-English paste | 422 parse_english_only, localized message | rejected_non_english (cost still metered) |
| Double commit (race / stale tab) | 409; generic failure toast | first commit wins |
| PostHog unreachable | Feature hidden + 403 (fail-closed) | no row created |