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. Retained for audit/re-parse, never copied into the structured workout’s description. |
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. Legacy correction baseline. |
final_payload | jsonb nullable | The committed set_sections-shaped payload (post-adjustment). Set on commit. |
delta | jsonb nullable | PayloadDelta comparing the full draft to final_payload for fidelity commits, or auto_payload for legacy commits. Set on commit; the event’s deltaBaseline identifies the comparison. |
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 | The model that served the extraction, as the API reports it (e.g. claude-sonnet-5-20260801). |
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_failed / parse_multiple_sessions on terminal-failure rows. parse_english_only appears only on historical rejected_non_english rows and is no longer produced. |
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
│
├──────────────────────┐
│ │
pipeline error success
│ │
▼ ▼
failed draft (IR + auto_payload persisted)
(500, error_code) │
┌────────┴────────┐
│ │
POST /commit POST /discard
│ │
▼ ▼
committed discarded
(final_payload + delta
+ workout_id recorded)rejected_non_english is a terminal status found only on historical rows from the removed English-only gate (D3). No current path writes it; a non-English paste follows the same path as any other.
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. Fidelity commits compare it to all movements indraft, using validated review choices to identify unresolved slots. Accepting a suggestion is not a new movement. Auto-selected identity corrections, prescription edits, and section configuration changes still count. Legacy commits compare againstauto_payload;parse.committed.deltaBaselinedistinguishes these metric versions. The baseline containing unresolved placeholders exists only in memory for metrics and is never written as a workout.
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.