Workout Parse — Behavior
Surface
| Endpoint | Purpose |
|---|---|
POST /organizations/:orgId/workouts/parse | Parse pasted text → 201 { data: ParseDraft }. Synchronous: one extraction call, or one per part for a paste that heads two or more parts. |
GET /organizations/:orgId/workouts/parse/:jobId | Re-fetch a draft (org-scoped) → 200 { data: ParseDraft & { status, workoutId } }. Preview refresh / ?jobId= deep link. workoutId is the workout a committed job wrote (or the one a “Structure this” job structures), else null. |
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'). 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).- Length ≤
PARSE_MAX_INPUT_CHARS(10,000) → else 422parse_too_long. - Budget
AgentRateLimitService.preCheck(orgId, 'structuring')— parsing’s own allowance, apart from Spotter’s (monthly backstop checked first, then daily) → on breach 429ai_budget_exceededwith{ period: 'day' | 'month' }. Aliveread past the allowance runs anyway in cache-only mode: every extraction, rewrite and confirmation is answered from cache, and the first one that would need the model refuses the read with the same 429. All parse spend is recorded asstructuring.
Commit/discard re-check membership + role + tier but not budget (no LLM call on those paths).
Structured fidelity mode (TKN-355)
All new parses use structured fidelity; the existing parse entry-point and
tier gates still apply. New drafts store fidelityMode: true as a format
marker. Previously saved drafts without that marker retain their compatible
review and commit path.
- Explicit multi-session source is rejected before the model call, and the
model’s multi-session result is rejected rather than sliced to session one.
Commit rechecks the stored source and draft, including older drafts. A day
may contain multiple sessions, but this endpoint accepts one session;
it does not replace the upstream Notion/day splitter.
Decorative separators such as
<<>>do not establish session boundaries: warm-up, lift, strength, conditioning and accessory blocks on one class timeline remain one workout. The deterministic guard requires explicit session/day/date headers; extraction also evaluates session semantics. - English programming with Hebrew or Russian guidance is accepted.
Workout-level guidance becomes
coachNotewith paragraph boundaries. Operative section instructions remain in the section description. - Source claims are repaired only against literal source occurrences. Section ownership extends through trailing instructions to the next block; whitespace and decorative separators are not unparsed instructions. Titles must occur in the source, and identical movement coaching cues are not rendered twice.
- Sequential
intoblocks retain order. Explicit+complexes retain their component order and visible grouping text without inventing supersets. - Standalone rounds/sets, including
2-3 sets, become canonical repeat counts. Untimed repeats userounds.forTime: false; absentforTimeretains legacy timed-round behavior. Section-local RPE bands, tempo, percentage ranges, alternation and optionality are recovered from source without duplicating section counts on each movement. A prescription-only continuation is attached to a unique literal movement name, never emitted as a second exercise. - Explicit total-repetition scoring applies to the workout, not its warm-up. Conditional equipment such as an optional vest and sex-labelled box heights stays visible prose; it is not promoted to a mandatory movement load.
- A unique canonical-name match can resolve automatically, unless the name adds a non-barbell implement the coach did not write. A one-word alias, normalized search score alone, or a semantic rewrite cannot silently choose an exercise. Suggested candidates are not preselected in fidelity mode.
- Match quality distinguishes automatic matches, explicit confirmations and movements preserved as notes. Preserving a movement does not count as matching it. Candidate search scores rank results, not confidence; the review popover does not label score-only suggestions as strong matches.
- Every uncertain movement must be picked from its candidates, chosen from a library search, created or explicitly preserved as instructions. Save/publish is blocked until that review is complete. The commit endpoint validates review choices against the stored draft, rejects omitted auto movements and missing preserved source, and requires explicit preservation of unconverted instructions. Multi-line instructions are verified line by line, including repeated source occurrences. Repeating preservation does not duplicate an instruction block.
- Structural issues are separate from exercise matching and preservable prose.
A shared clock with periodic interruptions and remaining-time work is not
representable by the current flat shapes.
structuralIssuesblocks save, publish and builder adjustment even after every other review is acknowledged. Commit rechecks stored source, including drafts without that field, and returnsparse_review_required(409,reason: 'structural'). No interrupted-clock model is inferred. A draft stored by an older parser whose clock line the current grammar recognises (e.g. “Every 3 Minutes × 6 Rounds” stored asintervalswith a null config) also commits as 409structural, because the recheck finds the line unrepresented; re-converting the text produces a valid EMOM. - Declarators the sections already carry are not sent to review or flagged
structural: a rep scheme (and rounds marked for time) is raced by
definition, so a separate
For time.with the same cap or none is represented;For time. Time cap: 18 minutes.scores the workout by time and leaves the cap to the section clock.EMOM for 12 minutesreads as{ intervalSeconds: 60, rounds: 12 }. A first line that is exactly the model’s title, quoted or not (“Iron Triangle”), is the title’s source; the title drops the quotes. A rest-only line (Rest 2 minutes between sets.) joins the section description whether the model files it as a standard or a timing instruction. - A block’s own structure line counts wherever the model files it — as a
timing or standard instruction, or left in its remainder: a full-line round
count (
3 rounds, not for time.) or a rep scheme (21-15-9 reps of:). A rep scheme next toFor time.makes the block that rep scheme, against the same cap. Prose never sets a shape: a round count must fill its line. - Use original as freeform discards the parse draft and opens the existing
freeform editor with the complete original text. It removes parse-route state
while retaining day/session context; saving uses the normal freeform path.
In the coaching board’s workout picker it instead creates a freeform workout
from the pasted text and assigns it to the slot. There, a paste with no
title (structured or freeform) is named
dailyWorkoutTitle(slot date, program name). - Source text, spans and review metadata remain on the parse job. Coach guidance and retained operative instructions travel through normal workout creation/update; the backend does not trust a client acknowledgement alone.
The deterministic regression suite includes explicit five-session headers,
symbol-separated class blocks, source-only programming, repeated complexes,
prescription continuations, conditional equipment, unsupported clock interruption,
ambiguous exercise resolution and commit preservation failures.
fast-check adds 940 bounded generated cases per run:
440 session-boundary mutations, 400 canonical/MCP/builder prescription
round-trip or rejection cases, and 100 set-prefix formatting cases.
Seeds are fixed in the specs; failures report a shrinkable seed/path.
These tests do not call a live LLM or incur provider cost.
They are discovered by the existing test:unit:api CI job, including the
shared-library specs; no separate slow test job is required.
Parse pipeline
ParseOrchestrationService.runParse:
- Normalize input once (
normalizeParseInput): CRLF→LF; markdown backslash escapes a chat app adds on copy (1\.,\-) removed; spreadsheet (TSV) cells Excel quoted on copy unquoted (only a cell beside a tab — a line the coach wrapped in quotes stays as written); Hebrew points/cantillation marks sitting on a non-Hebrew letter dropped. The normalized text is what gets persisted (ai_parse_jobs.input_text) 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 call,MODEL_POLICY.parsePaste(ADR-0022):claude-sonnet-5, forced tool use (tool_choice: { type: 'tool', name: 'record_extraction' }),thinking: { type: 'disabled' }, notemperature(the Sonnet 5 family refuses one),max_tokens: 16000, cached system prompt. The prompt lists each shape’s config keys and the Hebrew/Russian structure vocabulary. A day-grain text naming two or more parts is instead read part by part byMODEL_POLICY.parseDayPart(Day grain). Answers are kept per organization (cacheScope: orgId, the sameAiCacheServicecache the day-grain parts use), so the same paste from the same org reads the same way and a re-convert of unchanged text makes no model call. Output is validated againstextractionResultSchema; one schema-error retry is allowed. A valid answer that leaves a structure-declaring line in remainder outside every extracted section gets one corrective call, keeping whichever answer strands fewer lines; remainder lines inside an extracted section’s span do not count. A reply that ends withstop_reason: 'max_tokens'fails immediately asparse_failed, without the retry: re-emitting under the same cap cannot finish it. Movement spans require exact verbatim line agreement; ordered source alignment repairs unambiguous bad offsets, while ambiguous occurrences remain{start:0,end:0}. Timing/standard instructions and section-local load directives retain their own verbatim text and spans. WithPARSE_EXTRACTION_FIXTURES=1, the API is bypassed for a matching extraction fixture. - Meter the spend immediately —
computeCostUsdMicros+AgentCostTracker.recordTurnrecord the model call even if later structural validation rejects the draft. - Language: mixed-language programming and guidance retain literal source text; non-English guidance alone does not reject the workout.
- Session boundaries: explicit multi-session text is rejected before extraction; a model result with
workoutCount > 1is also rejected. The parser does not silently slice a day to its first workout. - Shape reconciliation (D7) per section — see table below. Superset groups via
detectSupersetGroups(explicitA1/A2markers or a superset word — “superset”,סופרסט,суперсет— only; broken sequences → all-null). - Prescription: the LLM’s partial guess is re-validated against
PrescriptionSchema; invalid →null. Explicit section-local load directives apply only to their named movement mentions; movement-local loads win. Labelled absolute loads useload: { kind: 'absolute', unit: 'lb' | 'kg', bySex: { female?, male? } }with at least one label and no scalarvalue. Unlabelled historical slash values remain unchanged. Separate nonconflicting female/male defaults merge; conflicting defaults are not guessed. - Resolution (D6): mentions deduped case-insensitively (
normalizeMentionKeyfrom@taikan/sharedaftercleanMentionstrips list/superset markers and trailing5x5-style fragments), searched viaExerciseSearchService.search(orgId, { q, mode: 'hybrid', limit: 5 }), max 4 concurrent.cleanMentionstrips a one-letter marker only before a space (A. Squat) or with a digit (A1.); a letter glued to the name is shorthand (p.clean,w.sit upsstay). A leading count equal to the movement’s prescribed reps or calories is dropped from the search mention (7 Cal Bikesearches “Cal Bike”). 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)
Resolution checks the complete organization-visible exact-name and alias lookup before fuzzy search. normalizeExerciseIdentity folds case, spacing, apostrophes, dash variants and supported English plural forms consistently in SQL and JavaScript. A private organization fork takes precedence over its canonical source only for that organization. A mention’s words are counted with hyphens as separators, so Pull-ups and Pull Ups are both two words; the same count decides a several-word alias whether the exact match comes from the lookup or from ranked search results.
| Condition | Status | Selection |
|---|---|---|
| One exact name, or one exact alias with more than one word | auto | Exact exercise |
| Several exact owners, or a one-word alias without an exact name | suggested | Coach must select |
One exact match whose name leads with a non-barbell implement the mention does not name (Dumbbell, Kettlebell, Cable, Machine, Band/Banded, Smith, Landmine, Sandbag; DB/KB count as naming it) | suggested | Coach must select (addsImplement: “Push Jerk” never auto-selects Dumbbell Push Jerk) |
| Fuzzy candidates only | suggested | Coach must select; rank is not confidence |
| No candidates or lookup failure | unresolved | Coach resolves or explicitly preserves instructions |
A semantic rewrite cannot silently select an exercise. Candidate display limits never hide an exact-name collision from the resolution decision. Candidate scores: exact name 1, exact alias 0.95, fuzzy a graded 0.5 × relevance (never above 0.5).
Day-grain resolution (workouts_parse, or a paste that heads two or more parts)
runParse({ dayGrain: true }) passes dayGrain: { sections } to resolveMentions; a paste that heads fewer than two parts resolves exactly as above. Strict confidence still applies. Before ranked search, each mention is tried against the exact name/alias lookup in folds, least changed first (mentionVariants, resolution-vocabulary.ts): as written with parentheticals as a note (“Jerk (of choice)”); the last word’s singular (-ies→y, -s, -es: “Hang power cleans”, “banded good mornings”); spelled-out shorthand (MOVEMENT_ABBREVIATIONS: OHS, T2B/TTB, C2B, HSPU, DU, KBS, RDL, SDHP, MU/BMU/RMU, HPC/HPS, C&J, PWR, and KB/DB/BB as words); then without note words (MOVEMENT_NOTE_MODIFIERS: PVC, empty bar, banded, light, easy, slow, heavy, tempo, paused, alternating, controlled), which become resolution.note and so the movement’s notes. A fold that selects wins; else the first exact match (a suggestion). Folds only build queries: the library decides through the same JS/PostgreSQL identity pair, so a fold that names nothing matches nothing and normalizeExerciseIdentity is unchanged. isNonExerciseMention (“Empty bar”, “easy”, “machine of choice”, “singles”, “rest”, “bar”, a mention of only note words) never resolves: unresolved, no lookup, no confirmation. “Row or Bike” / “A/B” resolves its first option; the other options that select become prescription.alternatives (unless the parse already set some). Last, every mention still suggested goes in ONE batched call to RESOLUTION_CONFIRMATION_MODEL (Haiku, ResolutionConfirmationService) with its line, part and block and its candidates; it confirms one candidate id or none (an id it was not given is ignored; answers cached 30 days by mention+line+context+candidate ids; a failure or no key confirms nothing). A confirmed mention becomes auto with resolution.confirmedBy: 'model', and workouts_parse lists it in an auto_confirmed_movement warning.
Shape reconciliation (D7)
Grammar (detectShape) | LLM shapeGuess | Result |
|---|---|---|
| matched | (shape ignored) | grammar’s shape, shapeSource: 'grammar', confidence high. Grammar reads the display header plus explicit timing instructions. Its clock facts win; a validated LLM configGuess may fill absent keys. |
| no match | present | LLM’s shape, 'llm' / low (flagged in preview), with its configGuess if that config passes the shape’s schema. A config the guessed shape refuses is tried against every other shape; exactly one fit wins (intervals { intervalSeconds, rounds } → emom). Otherwise linear / low, with the clock lines left visible. |
| 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).
decideShape returns exactly one validated { shape, config }: a merged grammar + model config is re-checked with parseSectionShapeConfig (a merge the shape refuses keeps the grammar config alone), so every draft section passes the commit’s own config check. A rounds config always carries an explicit forTime — true only when the block’s header, timing lines or structure lines say “for time” / “RFT” (never “not for time”); a bare “3 rounds” is forTime: false.
In fidelity mode, sequential Then N rounds: headings start separate ordered sections, keeping an initial cardio block outside those repeats. A unique literal strength heading can supply the exercise for load-only lines below it: Back Squat with four build-up lines and 5×5 @ 105–110 kg produces five movement rows and nine prescribed sets. Each row retains its literal load-line span and a validated mentionSpan pointing to the heading. Empty bar stays a load intent without an invented weight. Generic or multi-lift headings require review. Load-only strength rows without matching structured prescriptions block commit; preserving those sets only as prose cannot satisfy fidelity review. Full source headers retain explicit For Time; coaching targets and negative guidance do not create time scoring or caps.
Explicit standards become the section’s operative description (for example, the shuttle-run definition). Score-posting requests and the complete stimulus/strategy block stay in parse-job source/remainder, not workout comments. On a 15-minute clock, for time produces for_time with timeCapMinutes: 15. At 15 minutes, on a 3-minute clock produces timed linear with startOffsetSeconds: 900 and durationSeconds: 180; the offset is absolute workout time, not rest. Builder serialization preserves nonempty linear timing config and sex-labelled loads through no-edit commit.
Structure lexicon and source lines
libs/shared/src/lib/structure-lexicon.ts is the structure vocabulary the grammar, reconciliation, structural review and superset detection share. canonicalizeStructureTokens rewrites Hebrew and Russian structure words to the English tokens the grammar matches — sets (סט/סטים), rounds (סבב/סבבים/סיבוב), reps (חזרה/חזרות), seconds/minutes, per side — and is grammar input only (it changes lengths, so no span derives from it). SUPERSET_WORD_SOURCE / isSupersetWord cover superset words in all three languages; LOAD_UNIT_KG_SOURCE covers kg, קילו, ק״ג, кг and a bare k straight after a number (35k).
- The grammar accepts
×as well asx(“Every 3 Minutes × 6 Rounds”),emom16, andE1:30MOM(a 90 s interval), including behind a letter label or a Hebrew title (B. Strength – 4 סבבים). Zero counts are rejected. - Each tab-separated cell of a spreadsheet row is its own structural source: in
A – Snatch⇥5 sets, “5 sets” is the block’s structure, not a structural issue. - A prescription-only line directly under a movement (
- 3 סטים × 10 חזרות,8–10 reps per side,30 sec, a bulleted- 3 sets) is folded into that movement’s prescription and counts as represented. Unbulleted, a set count is the block’s rounds header. - The workout title line (
# אימון N – …; a markdown heading or bold line reads as the words it shows) and round headers whose meaning is in the section config are represented, not unconverted. - A superset word is never a movement.
- EMOM minute rows (
1-10 cal,2-12 db snatch) — every movement row carrying the next index from 1, at least two — lose the minute index and setalternating. - A
+complex whose parts are listed again below it as their own lines is the block’s heading and is not read as a second set of movements. - Inline loads in any kg form (
60kg,15 ק״ג,35k) stay on the movement.
Day-grain prescription rules
Every parse applies them: ExtractionService.extract appends DAY_GRAIN_EXTRACTION_RULES to the system prompt and its span repair matches plain vs typographic quotes; the parse’s detectShape calls read EMOM interval ranges; namesSection, reconcileProgramming and deriveSectionScoreSlots apply them unconditionally. They do NOT turn on parts: those are workouts_parse and any paste that heads two or more parts (dayGrain, below). Recorded extraction fixtures are keyed by input text only.
Read deterministically from the coach’s lines in reconcile-programming.ts (the prompt asks the model for the same, the source lines win):
21/18 Cal row,12 cal bike,10 Bike calories→reps.kind: 'calories'; a slash pair isbySex { male, female }(male first). Any other slash pair (15/12 Wall balls) sets no reps.- “Score - total T2B” → a
repsslot. - Mentions that name no exercise stay block text: rep-scheme words (
Sets,10 reps, climbing) and the resolver’s non-exercise vocabulary (isNonExerciseMention:Empty bar,easy,machine of choice,singles,rest— an empty-bar row of a lift’s ramp stays that lift’s row). Model-suppliedalternativesexercise ids are dropped (the “or Bike” stays in notes). 200m Run,1.5 km row,1 mile run→distance { value, unit: 'm' | 'km' | 'mi' }, replacing any model rep count.- A movement the model returned over several lines (
Every 2:00 x 8 sets -\n1 Clean lift-off,Back Squat\n…\nPercentages off your Back Squat 3RM) is narrowed to the line naming it during span repair (the first such line starting with a count, else the first); a narrowed copy of a line the model also listed is dropped. The clock, set bands and reference line are then read as the block’s own lines. @24/20”/@24"/@60cm→height(bySexorvalue,in/cm); the@cue is never a load.Level 1 = 10 burpees,Levels 3 & 4 = 15 burpees,Level 2 = 40/30kg→levelson the one movement they apply to: the movement a level names, else the only movement a bare load can take (not calories / distance / height). A level naming no movement in the block (Level 1 = 10 BFB) goes to the one movement whose own line leaves its reps open. All of a block’s level lines apply or none do. Level loads keep unlabelled slash values as written ('35/25').@75% of your Back Squat 3RMand a block linePercentages off your Back Squat 3RM/Heavier than your last complex→percent_referencewithreference { label, repMax? }on every percent load and band in the block. ”% of your max” stayspercent_1rm.Every 3:00-3:30 for 3 sets→ EMOM{ intervalSeconds: 180, intervalSecondsMax: 210, rounds: 3 }(grammar).- Per-set loads (
2 Sets @6RPE,Sets 1-2 - 76-80%) that add up to the block’s set count becomeset_progression. One movement owns them; several movements done each set of a non-alternating EMOM/rounds block are a complex: they become superset groupA(labelsA1,A2, …) and the first carriesset_progression+group_load: true. @building/@heavy→load.kind: 'intent'.
Links and offered alternatives
Every parse (movement-annotations.ts, applied in runParse after the read, before auto_payload). Lines a coach writes under a movement that annotate it rather than prescribe work:
- A link-only line (
https://…and nothing else, a valid media URL) is never a movement, never anunconverted_movement_lineand never an unconverted instruction: it does not blockworkouts_parsesaveor the paste review. It stays in the block’s verbatimsourceText(tappable on the member’s board) and becomesmediaUrlof exactly one movement when the block says which, first match wins:- Footnote: the link sits in, or directly before/after (blank lines between allowed), a paragraph holding a
*-footnote line (*KBs hanging…), and exactly one movement line of the block carries the*marker (5 Earthquake bar overhead squats* (…)) → that movement. - Right under a movement: the line directly above the link (no blank line between) is exactly one movement’s line → that movement.
- The block’s only movement. Otherwise nothing is attached. Two different links pointing at one movement attach neither. A URL is only ever taken verbatim from the text.
- Footnote: the link sits in, or directly before/after (blank lines between allowed), a paragraph holding a
- Offered alternatives: a
Pick one:/Choose one:/pick one of …:lead (also at the end of a footnote:Don't have it / don't like it? Pick one:) with its list lines below it in the same paragraph, and a swap lineif you don't have X → sub with Y(also, sub with,use,swap for, andNo X? Sub with Y). The option lines and the swap line are never movements (a model’s movement on them is dropped inreconcileProgramming) and neverunconverted_movement_line. The options belong to: the*-footnoted movement when the lead is in a footnote paragraph and exactly one movement line carries*; else the one movement whose name holds every word the lead says is missing (a sled→Reverse sled drag; pointer words like “it” name nothing); else the movement above the lead. Each option (count stripped:20m Bottoms-up KB overhead carry (each arm)→Bottoms-up KB overhead carry (each arm)) is matched exactly as the other options of “Row or Bike” are (ResolutionService.resolveAlternatives: day-grain folds, exact and selecting only, never the movement’s own exercise or a repeat, four at most, after any it already has). An option the library does not name exactly is left out of the structure, like an unmatched “or Bike”; its words stay insourceText. It never blocks save. - Link lines, option lines, swap lines and a lead that is only
Pick one:count as represented source; a footnote’s own words (KBs hanging from an empty bar…) are an unconverted instruction like any coach note. - The draft movement carries
mediaUrl(parsedMovementSchema);draftToSectionsPayload(auto payload, theworkouts_parseproposal and its save) and the web review’sdraftToBuilderSectionscarry it into the commit, so a saved day stores it onworkout_movements.media_url. - Capacity’s dips/sled block (
8-12 Strict dips/into 30m Reverse sled drag/if you dont have a sled → sub with 15 tibialis raises/ link): tibialis raises become the sled drag’s alternative; the link attaches to nothing — it follows the swap line, not a movement line, there is no footnote, and the block has two movements. A two-movement list closed by a link after a blank line (hamstring slider curls / hanging leg raises) also attaches nothing.
Day grain (RunParseArgs.dayGrain: workouts_parse, or a paste that heads two or more parts)
The MCP workouts_parse tool always sets it. Any other paste — the dashboard paste, “Structure this workout” — sets it when its own text heads two or more parts (detectDayParts before any model call); a paste with fewer keeps the single-extraction, section-per-heading draft. One whole-text extraction of a long class is not stable: live reads of the same numbered six-part paste folded everything after the warm-up into it, dropped the closing parts, or invented blocks, run to run. Read part by part, the same paste came back with the same parts and blocks every time. A first line wrapped in quotes (“Iron Triangle”) is the workout title; the rest of the opening is the day brief (workout.description). With it (day-parts.ts, day-part-extraction.ts):
- Parts. A line is a part heading when it is a bare name (≤6 words, after an outline number such as
1.or2)) that is not an extracted movement line and is a part word (Warm-up,Lift,Strength,Strength Successory,Successory,Conditioning,Hybrid Conditioning,Skill,WOD,Accessory,Cooldown, …), the first heading after a<<>>-style delimiter, or carries am:ss-m:ssclock window. Clock windows (0:00-11:00 Warm-up,0-11 Warm-up) setclockStartSeconds/clockEndSecondsand leave the title bare;Warm Up (10:00)and1. Warm-up (15 min)are placed from the start of the day or the previous part’s end, else the duration stays in the title.Optional …setsoptional: true(title kept). Keys are slugs without “optional”, unique per day. A day needs two parts that hold blocks; otherwise nothing changes. - Read part by part. When the raw text names two or more parts, each part’s text (heading to last line, without the delimiter) is extracted on its own —
PARSE_PART_CONCURRENCY(4) at a time, byMODEL_POLICY.parseDayPart(claude-sonnet-5-5;tool_choice: auto, no thinking —thinking: between_tools) — its spans moved onto the day’s text and the answers stitched into one extraction (one workout, no model title: a part’s call would offer “Lift”). A part can no longer fold into another, and a single long call that stopped sectioning part-way is gone. Each round’s calls are metered at once (D9). If every part’s call fails the parse fails as before (parse_failed). Text with fewer than two parts keeps the single extraction. - Parts found again. After the first round the parts are detected again around the extracted movements: a line the split took for a heading but the model read as work (
10-15 Face pullsright after<<>>) is no part, and its text joins the part above — no extra call. With fewer than two parts left, the text is read whole like any other. Because the parts were read one by one, the day stands even when only one part holds blocks; each other part is checked and reported on its own. - Cost. The extraction prompt and tool schema (~16k tokens) are cached for 1 hour (
cache_controlon the system block). With no live part read by the model in the last 55 minutes, one part is read first and the rest after it, so they read its cache instead of each writing it. Each part’s answer is kept for 30 days (AiCacheService, operationparse_extraction) keyed by org, model, first read vs re-read, a digest of the prompt + day-grain rules + tool schema, and the part’s text: a re-sent unchanged day (or an unchanged part of an edited one) makes no model call, and resolution still runs against the current library. - Checks (
checkDayPart, per part of the assembled draft):extraction_failed(its call returned nothing usable),missing_blocks(text but no block),block_off_line(a block starting mid-line, outside its part, or right under another block’s movement),no_movements(a clock, sets or reps line but no movement),unconverted_movement_line(a counted line —10 air squats,200m Run,21/18 Cal row,Max T2B— the work after2:00 General -, or a bare name listed under a clock or another movement, that is not a movement; aninto/then intotransition, a link-only line, aPick one:list and a swap line are never one — Links and offered alternatives). - Escalation. Only a part failing its own checks — or whose call failed — is extracted once more by
MODEL_POLICY.parseEscalation(claude-sonnet-5-5again, now reasoning first: adaptive thinking at medium effort). Its answer replaces the part’s and the draft is checked again;extraction_failedstays only when no round answered the part. The part carriesescalatedModel, andparse.completedcarriesescalatedPartCount(+escalationModelwhen > 0) andstageTimings.escalationMs. - Never silently wrong. A part still failing becomes a structural issue
{ reason: 'unstructured_part', partKey, text: <heading line>, failedChecks }; the draft keeps every part it could read, andworkouts_parserefuses to save while it stands, naming the part. - Blocks. Sections stay sections inside their part (
General,Activation, …). A section spanning two parts is split; a block that repeated the part heading as its header loses it and takes its own first name line. Blocks a model returned wrong are put right from the lines: a block starts at its line; one holding no text (the part heading as its own section) is dropped; a movement-less block overlapping another is folded into it; a block that was only the next one’s heading (Every 2:00 x 8 sets -over1 Clean lift-off …), or that has no name of its own and continues the list above (1 Jerk dip/1 Jerk (of choice)) or only comments on it (Highest average for the day.), is folded into the block above. - Notes and brief. A part’s closing paragraph written entirely in Hebrew/Russian/Arabic becomes
part.note; Hebrew lines inside a block become that block’s description; the text above the first part becomesdraft.workout.description. Remaining guidance is still the coach note. - Sessions. Part headings never trigger
parse_multiple_sessions: parts are one workout. ExplicitDay N/Session N/Workout A/ date headers still reject before extraction. - The draft gains
parts: [{ key, span, noteSpan, escalatedModel? }];draftSectionSource(shared) clips a part’s last block before its note, delimiter and the next heading, so blocksourceTextis only the block.draftToSectionsPayloadcarriesparton every block.
MCP workouts_parse
Leaf ParseAgentTools (apps/api/src/ai/agent/tools/leaves/parse.tools.ts), router workouts, staff roles, confirm: 'never', on both MCP and Spotter. Its input/output contract (date, save.resolutions, titles, warnings) is documented in mcp-server. Same pre-flight as POST /parse: workout_builder tier gate, PARSE_MAX_INPUT_CHARS (parse.blocked too_long), budget pre-check (ai_budget_exceeded), parse.requested with the calling channel. Runs runParse({ dayGrain: true }) (above), preserves unconverted lines into their block’s description, runs SectionFidelityService.fill; a draft with structural issues (incl. unstructured_part) is never saved.
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. Legacy commits use it as their correction baseline; fidelity commits use the complete stored draft.
Commit / discard
ParseCommitService (job must be org-scoped and status: 'draft', else 404 / 409 parse_job_closed with { status, workoutId } — on commit and discard alike):
-
Paste flow (
job.workout_idnull):WorkoutsService.create(orgId, clerkId, { …meta, mode: 'structured', sections, isLibrary })— one call, inline sections. Only an explicitly supplied coachdescriptionis persisted; raw input stays on the parse job.isLibrary(defaulttrue) applies only to creation. -
“Convert to structured workout” flow (
job.workout_idset): one atomicWorkoutsService.updateof metadata, structured mode, and sections. The old freeform description is cleared unless an explicit coach description is supplied. No new write path against workout tables (D11); existing membership/tier validation still applies. -
For a stored draft with
fidelityMode: true, the correction baseline includes every draft movement, including suggestions. Confirming or creating the exercise for an unresolved slot does not count as adding a movement; changing an auto-selected identity still counts as a swap. Prescriptions and section configuration are compared against the stored draft. Legacy commits retain theauto_payloadbaseline. The job storesfinal_payload,delta, andworkout_id;parse.committedincludesdeltaBaseline(full_draft_v1orauto_payload) alongside editDistance, zeroEdit, swap/add/remove counts,exercisesCreatedCount, andtimeToCommitMs. Compare telemetry within a baseline version. -
Review actions (
fidelityReview.movements[].action,parseReviewActionValues):picked— one of the movement’s offered candidates, still enforced against the stored draft;searched— a library exercise outside the candidates, which only needsexerciseId(its visibility to the org is enforced byWorkoutsServiceon write);created— an org exercise made during review;preserved— the movement kept as visible text. MCPworkouts_parsesendssearchedfor an off-candidate library exercise. -
Why a review is refused: every 409
parse_review_requiredcarriesreason(parseReviewRequiredReasonValues) and, when one movement is at fault,movementKey:reasonWhen draft_missingThe job has no persisted draft to review against structuralThe stored source has structural issues (including older drafts the current grammar re-reads) review_missingA movement that was not auto-resolved has no review action ( movementKey)no_exerciseA review carries no exerciseId, or the reviewed exercise is not in the committed sections (movementKey)candidate_mismatchA pickedexercise is not among the movement’s candidates (movementKey)instructions_unpreservedUnconverted instructions exist and unconvertedInstructionsPreservedis not setinstructions_missingA required instruction line is not visible in the committed workout -
Instruction visibility: a required instruction line passes when a description copy accounts for it (one per source occurrence), when a committed section renders it (
isInstructionRendered,libs/shared/src/lib/instruction-visibility.ts: the trimmed line appears in the section’ssourceText, else its description), or when it equals the workout title once a leading#and markdown escapes are stripped. -
Publish (TKN-320): when
ParseCommitDto.publishTo({ classTypeId, date }) is present, commit also callsDailyProgrammingService.set(orgId, clerkId, { classTypeId, date, status: 'published', entry: 'paste', workouts: [{ workoutId, sortOrder: 0 }] })in the same request — the single call that publishes the day, emitswod_published, and emitswod_post_completed { entry: 'paste', scope: 'day' }(seedocs/features/daily-programming/behavior.md). AbsentpublishTo, commit never touches daily programming. Publishing runs before the job is marked committed: if it fails, the job staysdraftfor a retry, a workout this call created is soft-deleted again (an in-place structure is re-applied by the retry),parse.committedis not emitted, and the error propagates. -
Discard →
status: 'discarded',parse.discarded. -
parse.*events carry theorganizationgroup in PostHog’s top-levelgroupsfield, so org-group filters work.
Structuring while the coach writes
With workout-text-first-authoring on and the workout_builder feature, the day sheet reads the text into structure as it is written, so what the coach publishes is already a structured workout. Background structuring below is the fallback.
- When it reads — after a 2-second pause or when the coach leaves the text box; only when the text changed since the last read; never with a read in flight (the newest text is read once the coach pauses again). Text under 12 characters is not read. Past the structuring allowance a read is answered only from cache (see pre-flight above); an
ai_budget_exceededrefusal shows a note, and the next edit is asked again. - Request —
POST /workouts/parsewith{ text, source: 'freeform_form', live: true, previousJobId?, workoutId? }.livereads the text as one training day and writes overpreviousJobIdwhile it is still the same writer’s uncommitted draft in the org (ParseJobsRepository.restart), so a sheet holds one job row, not one per pause. Each part is answered from the 30-day answer cache when its text was read before, so re-reading an unchanged part costs no tokens. - What the coach sees — beside each line of the text (
DayTextRows; under the text on a phone), the movement that line names and what was read for it. A line’s place is found by its words, in order, so editing other lines never moves a movement off its own; a movement whose line was rewritten is listed under the text as no longer on a line. Under the text, one status line: reading, waiting for a pause, how many movements are linked, or why the day would publish as text. A movement islinked(library match),guess(the only exact name/alias candidate, or a fuzzy leader at least 0.1 ahead of the runner-up — preselected and saved unless changed) ortext(no reasonable candidate; the line stays as written). Tapping the movement opens the paste review’s resolution popover: pick a candidate, search the library, create an exercise, or keep the line as text. A decision is keyed by the words the coach wrote, so it survives later reads. - What was read — next to a linked movement, its prescription as the read found it (
formatPrescription). Tapping it opens the structured editor’s ownPrescriptionPanelfor that movement; the change is saved with the day in place of the read. A change is keyed by the line and the movement’s words (editKey), so it survives a re-read of an unchanged line and is dropped once the line itself is rewritten; “Use what was read” puts the read back. The text is never rewritten to match. - A saved day — reopened with its text unchanged, the same rows come from the saved sections, not a new read: each section’s
sourceTextis found in the day’s text by its lines and each movement placed by itssourceMarks. Relinking a movement, keeping its line as text, or changing what was read savesPATCH /workouts/:id { title, mode: 'structured', sections }, built from the saved sections (sectionsFromResponse, the structured editor’s own loader) and sent without a read. Editing the text reads it again as above. - Publishing — when the latest read is of the text exactly as it stands and is commitable (no structural issues, one session), publish commits the job (
isLibrary: false, in place when editing an existing day) and hands the structured workout to the board. Every unsure movement is accounted for infidelityReview:pickedfor one of the read’s candidates,searchedfor an exercise from the library search,createdfor one made on the spot,preservedfor a line kept as text. Publishing never waits on a read: a stale read, a failed or refused commit, or a spent budget publishes as text and queues background structuring. - Learning the coach’s words — a movement the coach linked themselves carries
explicit: true; on commit the org learns that word for the exercise (ExerciseOverridesService.learnAlias), so the next read matches it exactly. A library exercise keeps the word on the org’sexercise_org_overridesrow (read by exact matching), an org exercise on its ownaliases. It never forks a library exercise — a fork moves every workout that uses the movement. A preselected guess the coach let stand teaches nothing, and a failed alias write never fails the commit.
Background structuring
A workout written as text in the day sheet is saved and published first; structure comes after, in the background, and only when nothing needs the coach.
- Request —
POST /organizations/:orgId/workouts/:workoutId/structure(staff,workout_builder) queues jobstructure-<workoutId>on theworkout-structurequeue. A waiting job for the same workout is replaced; a running one finishes. Returns the status below with202. - Processor (
WorkoutStructureProcessor) — reads the workout; does nothing if it is gone, already structured or empty. Text pastPARSE_MAX_INPUT_CHARSor an org over its AI budget ends the job without a model call (parse.blocked,channel: 'background'). Otherwise it emitsparse.requested(source: 'freeform_form') and runsDayParseService.runwithworkoutId: one day-grain parse of the whole text, parts taken from the coach’s heading lines. - Commit rule —
DayParseServiceis the MCPworkouts_parsesave path, shared: it commits only when every movement matched and no part or instruction is left unplaced. The commit is the in-place branch above (job.workout_idset), so every day and session pointing at the workout follows. Otherwise the job stays adraftand the workout stays text. - Status —
GET /organizations/:orgId/workouts/:workoutId/structurereturns{ state, movementCount, reviewCount, jobId, partTitles, sourceText, reason }.stateis explicit:none|running|structured|needs_review|failed. A job only speaks for a text workout while itsinput_textis still the workout’s text; an edit since then readsnone.sourceTextis the text the coach wrote, so the sheet can reopen a structured one-off day as text. - Board summaries — the daily-programming board, its “Posted by day” list and the coaching program board carry
structure: { state, movementCount, reviewCount }on each workout, from two batched reads (apps/api/src/workouts/workout-structure.ts). Boards poll while a just-saved day is pending or any cell readsrunning. - Review —
needs_reviewshows as a chip in the day sheet; it opensStructureReviewDialogon the job’s draft with the workout id, and its commit structures in place. - Editing — re-saving changed text sends
PATCH /workouts/:id { mode: 'freeform', description, sections: [] }, then queues a new job.
Web flow
Entry routing is shared. The preselection and non-blocking save behavior below describes legacy drafts only; fidelity-mode drafts use the mandatory review and preservation rules above.
/dashboard/workouts/new is the only creator route. It opens the freeform
form first. With workout-text-first-authoring off, its “Convert to structured
workout” card swaps the same route to the parse input, carrying the current
text through the existing one-shot handoff. On, no screen gives the parser a
button: the editor shows no card, the day sheet, the Plan grid and the session
sheet carry no paste link, and a day written as text is read while it is written
(Structuring while the coach writes),
falling back to Background structuring after save. ?mode=translate and ?jobId= still render when
reached by URL.
- Direct root: freeform is the default.
?mode=translateopens the tier-gated parse surface for contextual schedule links, while?jobId=reloads a shareable draft preview. - Day/session context (flag off): the board and session sheet link to
/dashboard/workouts/new?mode=translate&classTypeId=&date=&scope=day&returnTo=….classTypeName/sessionCountonly label the review’s publish button. - The parse surface wraps in
<FeatureGate feature="workout_builder" fallback="upgrade-card">. - Preview (
parse-preview.tsx,readOnlyprop): left = source text with claimed-span vs remainder highlighting (hover sync with the tree); right = the board the member’s app draws (VerbatimWorkout, viaverbatimSectionsFromBuilder— reused, not forked): the coach’s own text with the resolved movements linked inside it, seeded from the sameuseBuilderStatetreedraftToBuilderSectionsbuilds. A movement with no resolved exercise at all (unresolved, zero search candidates) keeps its words but carries no link — same filteringbuildSectionsPayloadapplies at commit. A low-confidence section shape shows its chip at the head of that section. Every movement is listed below the board, each with the resolution popover: outstanding (non-auto, unactioned) movements first, then reviewed ones (with Undo), then auto-matched ones in a collapsed list — so any movement,autoor already actioned, can be reopened and overridden. The non-readOnlymode (the interactiveSectionEditortree) still exists, unused by any paste-first caller — kept only so existing tests/behavior for a future editable use aren’t disturbed. - Adjustment (
movement-resolution-popover.tsx): pick a candidate, search the library, create an exercise, or drop-to-note (clears the movement and appends its raw line to the section description; dropping several movements of one source line appends the line once). The create panel first offers up to three existing library matches for the typed name (GET /exercises/search; choosing one counts as a search pick) before “Create & use”, which callsPOST /organizations/:orgId/exercises/upsert— find-or-create by case-insensitive name within the org, so one mention never mints two exercises. The mention, raw line and name field aredir="auto". The commit reportspickedonly for an exercise among the movement’s candidates andsearchedotherwise, whichever panel it came from (buildFidelityMovementReviews). - Review state (
use-parse-review-state.ts): a decision on one movement applies to every other unactioned, non-automovement with the same normalized mention, with an undo toast. Decisions persist per job insessionStorage(taikan:parse-review:{jobId}) and are re-applied to the seeded tree after a reload. A re-convert carries decisions onto the new job’s movements by normalized mention (a pick only while the new parse still offers that candidate;automovements keep their own match) and discards the superseded job. Closing the structure-review dialog with decisions made asks for confirmation. - Keep as visible instructions (
preserveUnconvertedInstructions): appends each unconverted instruction line to the section whose source text contains it, skipping lines a section already renders or that equal the workout title. - Pre-commit validation (
prepare-commit-sections.ts): the exact payload the commit sends is checked with the server’s rules (validateSectionsPayload). A section whose only issues are in its timer config commits as untimedlineartext — its source text still carries the coach’s words — with an amber chip naming it; any other issue disables Publish/Save/Adjust and an inline alert names the section. - Commit errors (
commit-feedback.tsx): a failed commit shows a persistent inline alert with localized copy percode(and perreasonforparse_review_required), naming the section fromissues[].pathor the movement frommovementKey. A 4xx never suggests trying again; only a network or 5xx failure adds the genericcommitFailedtoast. After a 4xx the commit buttons stay locked until something the coach can change in review (sections, decisions, title, scoring, time cap, library checkbox) changes.parse_job_closednavigates to the job’s workout. - Closed jobs: a committed job’s URL redirects (
router.replace) to its workout; a discarded job shows an alert with “Start a new conversion”; a non-draft job renders no review controls (no popovers, preservation, freeform action or commit bar). - Publish/save bar — never blocks (TKN-320): a still-unactioned movement no longer blocks the primary button.
finalize-for-publish.ts’sautoDropUnresolvedMovementsruns right before commit: any movement still lacking an exercise gets its raw line folded into the section description (nothing is silently lost); asuggestedmovement (pre-filled with its top candidate bydraftToBuilderSections) keeps that candidate with a confirmation note. The bar shows a soft “N movements to confirm later” count (toConfirmLater, never disables anything) plus an “Also save to Workouts for reuse” checkbox (isLibrary— unchecked by default with a day/session context, checked by default on Workouts › New).- With a day/session context: primary button reads “Publish to {weekday} · {classType} ({n} sessions)”; commit sends
publishToand returns to the board/session sheet. - Without one (Workouts › New, freeform “Convert to structured workout”): primary button reads “Save workout”; commit omits
publishToand redirects to the new workout’s page. - “Adjust in builder”: commits the parse as a workout first (respecting the checkbox, never publishing — even with a day context, the builder’s own save chain owns that), then opens the real structured builder on the committed workout (
session-slotcontext + day scope when a session is known; the plain library edit route otherwise). - Every post-commit navigation (Publish, Save, Adjust) replaces the job URL rather than pushing, so the builder’s back-after-save returns to the origin, not to the committed job. A fresh parse replaces the URL with
?jobId=while keeping the day-context params (returnTo,classTypeId,date, …).
- With a day/session context: primary button reads “Publish to {weekday} · {classType} ({n} sessions)”; commit sends
- Discard behind an
AlertDialog. Input isdir="auto"and auto-grows with its text up to 70dvh, in a proportional font when the text reads right-to-left; layout uses logical properties (RTL-safe).
Error codes
| Code | HTTP | When | Extra fields |
|---|---|---|---|
parse_feature_disabled | 403 | Never raised by the current API; kept in the schema and mapped on web for older builds | |
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 | Legacy extraction detected non-English (D3) | |
parse_multiple_sessions | 422 parse / 409 commit | Fidelity mode detected more than one session | workoutCount on parse |
parse_review_required | 409 | Fidelity review is incomplete or required source was omitted | reason, movementKey? (see Commit / discard) |
parse_job_closed | 409 | Commit or discard on a job that is not draft | status, workoutId |
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). The paste input maps parse codes to localized inline errors (paste-input.tsx); commit failures, including WorkoutsService’s 400 invalid_structure / invalid_prescription / invalid_section_parts, render in the review’s inline alert (commit-feedback.tsx).
Permissions
| Role | Parse / view / commit / discard |
|---|---|
owner, admin, coach | ✅ |
member | 403 at the controller |
| Non-member of the org | 403 (requireMembership) |
Tier matrix
| Tier | Behavior |
|---|---|
Lite (no workout_builder) | Entry points visible but lock-styled; paste route shows the upgrade card; API 403 (tier guard) |
| Pro / Elite | ✅ Full feature |
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 |
| Double commit (race / stale tab) | 409 parse_job_closed; the review navigates to the committed workout | first commit wins |
| Publish fails during commit | Error surfaced inline; retry allowed | stays draft; a workout this commit created is soft-deleted |
| PostHog unreachable | Feature hidden + 403 (fail-closed) | no row created |