Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0014: Freeform-to-structured workout transformer

ADR-0014: Freeform-to-structured workout transformer

Status: Accepted Date: 2026-07-16 Context owner: Saar Issue: FIT-245 

Context

Coaches already have workout content — Excel sheets, personal notes, Instagram captions — as freeform text. The structured builder (sections, shapes, prescriptions, movement resolution) is the right end state for a workout, but re-typing existing content into it is slow enough that coaches skip it and paste plain text into the freeform field instead, losing shape detection, exercise linking, and analytics.

We need a way to turn pasted text into the same set_sections payload the builder produces, without asking the coach to describe the workout to a chatbot and without silently guessing wrong — a wrong guess that looks confident is worse than no guess.

Decision

A dedicated, non-conversational parse pipeline, new module apps/api/src/ai/parse/ (NOT the Spotter chat orchestrator — no conversation state, no SSE, no confirm-card UX). It reuses Spotter’s building blocks: cookbook prompt content, ExerciseSearchService, the ai_usage_daily cost/rate-limit meter, and AnthropicClient.

paste text → LLM extraction (Sonnet, ONE forced-tool call): movement mentions + section structure + char spans. No exercise IDs. No authority over section shape. → deterministic grammar: detects canonical shapes (amrap/emom/for_time/tabata/ rep_scheme/rounds/intervals) from header/body text; reconciles with the LLM's guess → embedding resolution: each mention → ExerciseSearchService.search (hybrid RRF over pgvector/tsvector/trigram) → top-N candidates + confidence → ParseDraft (IR), persisted to ai_parse_jobs, returned to the client → web preview: original text ↔ builder canvas side by side; adjust movements (swap / create org-local exercise / drop-to-note) → commit: existing WorkoutsService write path; correction delta recorded on the job

Core doctrine: the LLM does the least it can. Under-structure, never mis-structure. Everything the pipeline deduces is an editable suggestion, not a fact. Text the pipeline can’t place becomes a note, never a fabricated section. Supersets are only inferred from explicit signal (A1/A2 markers, the literal word “superset”).

The IR is the contract

ParseDraft (libs/shared/src/lib/parse-schemas/) is what every stage downstream of extraction reads and writes, and what’s persisted and returned to the client. All spans are [start, end) char offsets into the normalized input (CRLF→LF, normalized once server-side before the LLM call — the normalized text, not the raw paste, is what’s persisted and diffed against).

Confidence is a fused rank score, not raw cosine

Production hybrid search returns RRF-fused, normalized scores in [0, 1] — a different scale than cosine similarity. Movement resolution reuses that scale directly: score ≥ 0.6 AND (top − second) ≥ 0.1auto-accept; a non-empty result short of that → suggested; empty → unresolved. The gap term exists because a high top score next to an equally-high second score means the search found a category match, not a confident single answer. Thresholds are named constants in parse-schemas/constants.ts, seeded from the existing exercisesResolveBatch heuristic, and are expected to move once a golden set exists to tune against (see Deferred).

Section-shape confidence is a separate axis: the deterministic grammar and the LLM’s guess either agree (shapeSource: 'grammar', confidence: 'high' — grammar wins on disagreement, since it’s auditable and the LLM isn’t), or only the LLM guessed ('llm'/'low', surfaced in the preview), or neither matched ('none'/'high', section falls back to linear with the body text preserved rather than dropped).

Drafts are server-persisted

ai_parse_jobs holds the input text, the IR draft, the auto-resolved payload, the final committed payload, the correction delta, cost, and stage timings. This is where “freeform text retained as source of truth” lives — the original paste survives as the workout description, and a stored draft means a job can be re-fetched (preview refresh) or later re-parsed without asking the coach to paste again.

Strict English-only in v1

Extraction detects input language; anything other than English gets status: 'rejected_non_english', HTTP 422, and a localized client error — no partial Hebrew parse. This is a hard product tension: the first validation customer’s own content is Hebrew, so Hebrew parsing is the top deferred item, not a nice-to-have. The synthetic golden corpus (Task K) includes Hebrew samples specifically to measure the gap it leaves, even though nothing in v1 acts on them beyond confirming rejection.

Multi-workout paste: detect, warn, parse first

A single paste (e.g., a week’s programming) may contain several day-delimited workouts. Extraction reports workoutCount; when it’s more than one, the pipeline parses only the first workout’s span, sets multiWorkoutDetected, and the UI shows a warning banner. Full multi-workout import is deferred (FIT-38) — this is intentionally the smallest useful slice.

Synchronous endpoint, same spend meter

POST /organizations/:orgId/workouts/parse is a synchronous POST — one Sonnet call lands in 5–15s, which fits inside a normal request. No async job + polling in v1 (see Alternatives). Input is capped at 10,000 characters (422 over the cap). Spend goes through the same meter Spotter uses: AgentRateLimitService.preCheck(orgId) before the call, AgentCostTracker .recordTurn(...) after; a budget breach is HTTP 429, ai_budget_exceeded, not a silent degrade.

Gating: tier AND flag, fail-closed

Access requires both the workout_builder tier feature (existing platform-tier guard) and the PostHog flag workout-parse-transformer, evaluated per-org via EventTrackingService.isFeatureEnabled. The flag defaults OFF and is fail-closed: only an explicit === true unlocks the feature. This inverts the repo’s usual “fail toward current behavior” framing on purpose — for a brand-new surface, “current behavior” is feature-absent, so failing closed and failing open are the same thing. An unreachable PostHog or a non-prod environment leaves the feature hidden, never exposed.

Enabling fix: org-local exercises get embeddings

Movement resolution is only as good as the exercise index. POST /exercises (org-local create) never enqueued embedding enrichment, so a coach-created exercise was invisible to semantic search until some unrelated backfill touched it — meaning the adjustment panel’s “create new exercise” action would create an exercise that the next parse still couldn’t find. This ADR’s scope includes closing that gap (enqueue on create, and on name/category update) as a prerequisite, not a follow-up.

Consequences

Positive

  • Coaches keep their existing authoring habit (paste from wherever) and still get a fully structured, analytics-capable workout — the builder’s editing surface is reused as-is for the preview, so there’s no second editing UI to maintain.
  • The under-structure doctrine means a bad parse degrades to “some things need attention,” never to a wrong workout silently committed.
  • Correction delta (payload-diff.ts) gives a real, computable product metric (% zero-edit, mean edit distance) instead of a vibes-based “does this feel good” call.
  • Reusing Spotter’s cost meter, search service, and Anthropic client means no new infrastructure to operate — just a new call pattern against existing services.

Negative

  • Two systems now guess at section shape (grammar + LLM) with a reconciliation rule to maintain; a new shape pattern requires updating the grammar, not just the prompt.
  • Sync POST means the request holds a connection for up to ~15s; if paste volume grows, this may need to move to async + polling (deferred by choice, see below).
  • English-only in v1 excludes the exact customer whose content motivated the ADR most directly. This is a known, accepted gap, not an oversight.
  • One more ai_* table and one more PostHog cost/volume surface to keep an eye on alongside Spotter’s existing ones.

Alternatives considered

  • Reuse the Spotter chat orchestrator. Rejected — it carries conversation state, SSE streaming, and confirm-card UX built for a different interaction shape (multi-turn agent actions), none of which this single-shot transform needs.
  • Pure-LLM one-shot straight into set_sections. Rejected — hands the model shape and exercise-ID authority it can’t be trusted with; no candidate surface for the coach to correct, and a wrong guess is indistinguishable from a right one until it’s too late.
  • Client-side parsing. Rejected per ADR-0005 — business logic (grammar, resolution, cost metering) belongs in the API so future clients (WhatsApp bot, native) get it for free.
  • Async job + polling. Rejected for v1 — a single Sonnet call fits comfortably inside a synchronous request budget; polling infrastructure is overhead this doesn’t need yet. Worth revisiting if extraction gets more stages (e.g. multi-workout batch import).
  • ADR-0005 — API-first; why parsing logic lives server-side.
  • ADR-0008 — tier gate this feature composes with.
  • ADR-0009 — background job pattern (exercise-enrichment queue this feature depends on for org-local resolvability).
  • ADR-0010 — shared cost meter, Anthropic client, and tool/schema conventions this feature reuses.
  • Linear: FIT-245 (this feature), FIT-38 (deferred full multi-workout import), FIT-227 (AI suggestion-layer precedent — editable suggestions over autonomous writes).