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 jobCore 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.1 → auto-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).
Related
- 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).