Workout Parse — Data Model
One table, in libs/db/src/lib/schema/ai-parse.ts. One row per transform attempt — this is where “freeform text retained as source of truth” lives (D5), and the audit trail for cost, timings, and the commit-time correction delta.
ai_parse_jobs
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
organization_id | uuid FK → organizations.id (cascade), not null | Scope. |
user_id | uuid FK → users.id (cascade), not null | Requesting coach (internal users.id, same referencing style as ai_conversations). |
source | varchar(20) not null | 'paste' | 'freeform_form' (D12 entry point). |
status | varchar(30) not null, default 'draft' | Lifecycle below. |
input_text | text not null | Normalized (LF) source text — every span in draft is an offset into this; becomes the committed workout’s description by default. |
language | varchar(10) nullable | BCP-47-ish primary tag from extraction ('en', 'he', …). |
multi_workout_detected | boolean not null, default false | D4. |
workout_count | integer nullable | Extraction’s count of distinct workouts in the paste. |
draft | jsonb nullable | The persisted ParseDraft IR (parse-draft.schema.ts). Null on jobs that failed before assembly. |
auto_payload | jsonb nullable | draftToSectionsPayload(draft) at parse time — auto-resolved movements only. The baseline for the correction delta. |
final_payload | jsonb nullable | The committed set_sections-shaped payload (post-adjustment). Set on commit. |
delta | jsonb nullable | PayloadDelta from diffSectionsPayload(auto_payload, final_payload) — the headline product metric (zeroEdit, editDistance, swap/add/remove counts). Set on commit. |
workout_id | uuid FK → workouts.id (set null), nullable | Input workoutId for the “Structure this” flow; set on commit for the paste flow. |
model | varchar(64) nullable | e.g. claude-sonnet-4-5. |
input_tokens / output_tokens | integer nullable | Extraction usage. |
cost_usd_micros | bigint nullable | Same math as Spotter (computeCostUsdMicros, apps/api/src/ai/cost.util.ts); also upserted into ai_usage_daily via AgentCostTracker.recordTurn. |
duration_ms | integer nullable | Wall clock of the whole parse. |
stage_timings | jsonb nullable | { extractionMs, resolutionMs, totalMs }. |
error_code | varchar(64) nullable | parse_english_only / parse_failed on terminal-failure rows. |
created_at / updated_at | timestamp tz not null | House pattern. |
Indexes:
ai_parse_jobs_org_created_idx—(organization_id, created_at DESC)(org-scoped listing / lookup).ai_parse_jobs_workout_idx— partial on(workout_id) WHERE workout_id IS NOT NULL.
Relations (aiParseJobsRelations): job → organization, user, workout.
Status lifecycle
The row is inserted first (status: 'draft') before the LLM call, so even a crashed extraction leaves an audit row. Terminal states are never re-entered — commit/discard require status = 'draft' and 409 otherwise.
POST /workouts/parse
│
insert row (status: draft)
│
LLM extraction
│
┌───────────────────┼──────────────────────┐
│ │ │
language ≠ en pipeline error success
│ │ │
▼ ▼ ▼
rejected_non_english failed draft (IR + auto_payload persisted)
(422, cost still (500, error_code) │
metered) ┌────────┴────────┐
│ │
POST /commit POST /discard
│ │
▼ ▼
committed discarded
(final_payload + delta
+ workout_id recorded)Why the payload triple (draft / auto_payload / final_payload)
draftis the client-facing IR — spans, candidates, confidence. It’s what the preview renders and what a future re-parse feature would compare against.auto_payloadfreezes “what the machine got right on its own” at parse time.final_payloadis what the coach actually shipped.deltabetween the two is the correction distance — computed server-side at commit through the exact samediffSectionsPayloadthe eval harness uses, so telemetry and eval numbers are on one scale.
Multi-org isolation & PII
- All reads go through
ParseJobsRepository.findById(orgId, jobId)— org-scoped; cross-org job ids 404. input_textanddraftcontain coach-authored content and are never emitted to observability (scrub()blocklist inparse-observability.service.tsdrops text/mentions/titles/payloads — counts and enums only).- Cascade on org/user delete;
workout_idisset nullso deleting a workout keeps the audit row.
Retention posture
No automatic deletion; rows grow with usage (one per parse attempt, including rejected/failed ones). Payload jsonb is bounded by the 10,000-char input cap and the section/movement caps. Revisit alongside the Spotter retention backlog item if volume warrants.