Skip to content

Commit beb54fb

Browse files
committed
Drop structured-outputs enforcement for the exercise-details call
Sixth attempt at satisfying Anthropic's structured-outputs grammar compiler for a rich per-exercise schema, after five straight failures (optional-count limit, union-type limit, "too complex" twice, grammar compilation timeout) -- every one of them in the exercise-details call specifically. The workout-shape call has never failed once. Rather than reshaping the schema a sixth time, the exercise-details call now skips output_config/json_schema entirely: SystemPrompt#exercise_details_text describes the exact JSON shape in prose (built programmatically from EXERCISE_SCHEMA's properties, so the description can't silently drift from the Exercise model) and instructs the model to respond with only a JSON object, no markdown fences. parse_json_response gains a strip_markdown_fences step before JSON.parse -- a no-op for the workout-shape call's still-enforced, fence-free response, a real safety net for the exercise-details call's unenforced one. This trades away the hard guarantee that the response can't violate the shape, but downstream validation already covers what that guarantee was protecting against: lookup_movement! catches an unrecognized movement name, the exercise-count-mismatch check catches a wrong array length, ActiveRecord type-casts a stray numeric string automatically, and Workout#valid? catches anything else. Also drops `notes` from EXERCISE_SCHEMA per a separate simplification call, unrelated to this specific fix. EXERCISE_DETAILS_SCHEMA is removed (no longer used for output_config); EXERCISE_SCHEMA stays, now purely as the source the prompt's field descriptions are derived from. Test updates: exercise_payload helpers drop notes; the schema test's "call 2 property counts" test is removed since Anthropic's structured-outputs limits no longer apply to that call; added a test confirming a markdown-fenced exercise-details response still parses correctly.
1 parent 982be5f commit beb54fb

6 files changed

Lines changed: 77 additions & 45 deletions

File tree

app/services/workout_extraction/llm_parser.rb

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@ class UnrepresentableWorkoutError < StandardError; end
66
MODEL = 'claude-haiku-4-5'.freeze
77
MAX_TOKENS = 2048
88

9-
# Anthropic's structured-outputs grammar compiler caps how many *optional* (non-required)
10-
# properties a schema can have (limit 24), how many *nullable/union-typed* properties it can have
11-
# (limit 16), separately rejects schemas that are too large/deeply nested ("Schema is too
12-
# complex"), and can time out compiling a schema outright ("Grammar compilation timed out") --
13-
# observed specifically after union types (anyOf) were used inside array item schemas, which is
14-
# consistent with union-typed array elements requiring the compiler to account for a much larger
15-
# combined state space than a flat optional field would. So: extraction is split into two calls
16-
# (one for the workout's shape -- segments, exercise text snippets, no per-exercise detail --
17-
# and one that structures every snippet into a full exercise in a single flat array), and every
18-
# field in both schemas is a single, non-union type: required where the field is unconditionally
19-
# present, plainly optional (omittable) everywhere else. No anyOf/nullable types anywhere.
9+
# Extraction is two calls: one for the workout's shape (segments, exercise text snippets, no
10+
# per-exercise detail) and one that structures every snippet into full exercise details. The
11+
# workout-shape call enforces WORKOUT_SHAPE_SCHEMA via output_config/json_schema; the
12+
# exercise-details call does not enforce EXERCISE_SCHEMA that way (see the comment there) --
13+
# every prior attempt to satisfy Anthropic's structured-outputs grammar compiler for a rich
14+
# per-exercise schema (optional-property limits, nullable/union-type limits, "too complex",
15+
# "grammar compilation timed out") failed specifically on that call, never on the shape call.
2016
SEGMENT_OUTLINE_SCHEMA = {
2117
type: 'object',
2218
properties: ModelSchema.properties_for(Segment, except: %w[id workout_id created_at updated_at position]),
@@ -55,24 +51,21 @@ class UnrepresentableWorkoutError < StandardError; end
5551
additionalProperties: false
5652
}.freeze
5753

54+
# Every failure above happened in the exercise-details call specifically (this one has never
55+
# failed); the exercise-details call therefore doesn't enforce this schema via output_config at
56+
# all -- it's used only to derive the prompt's field descriptions programmatically, so the
57+
# prompt can't silently drift from the Exercise model. See SystemPrompt#exercise_details_text.
5858
EXERCISE_SCHEMA = {
5959
type: 'object',
6060
properties: ModelSchema.properties_for(
6161
Exercise,
62-
except: %w[id workout_id movement_id segment_id created_at updated_at position],
62+
except: %w[id workout_id movement_id segment_id created_at updated_at position notes],
6363
overrides: { movement_name: { type: 'string' } }
6464
),
6565
required: %w[movement_name],
6666
additionalProperties: false
6767
}.freeze
6868

69-
EXERCISE_DETAILS_SCHEMA = {
70-
type: 'object',
71-
properties: { exercises: { type: 'array', items: EXERCISE_SCHEMA } },
72-
required: %w[exercises],
73-
additionalProperties: false
74-
}.freeze
75-
7669
def self.call(text) = new(text).parse
7770

7871
def initialize(text)
@@ -118,8 +111,7 @@ def fetch_exercise_details(snippets)
118111
model: MODEL,
119112
max_tokens: MAX_TOKENS,
120113
system: SystemPrompt.exercise_details_text,
121-
messages: [{ role: 'user', content: numbered_snippets }],
122-
output_config: { format: { type: 'json_schema', schema: EXERCISE_DETAILS_SCHEMA } }
114+
messages: [{ role: 'user', content: numbered_snippets }]
123115
)
124116
parse_json_response(response)[:exercises]
125117
end
@@ -128,11 +120,18 @@ def parse_json_response(response)
128120
text_block = response.content.find { |block| block.type == :text }
129121
raise ExtractionError, 'no text content in Anthropic response' unless text_block
130122

131-
JSON.parse(text_block.text, symbolize_names: true)
123+
JSON.parse(strip_markdown_fences(text_block.text), symbolize_names: true)
132124
rescue JSON::ParserError => e
133125
raise ExtractionError, "malformed JSON from Anthropic: #{e.message}"
134126
end
135127

128+
# Only the (still schema-enforced) workout-shape call is guaranteed fence-free; the
129+
# exercise-details call has no such guarantee, so strip a ```json fence if the model added one
130+
# despite being told not to. A no-op when there's no fence to strip.
131+
def strip_markdown_fences(text)
132+
text.strip.sub(/\A```(?:json)?\s*\n?/, '').sub(/\n?```\s*\z/, '')
133+
end
134+
136135
def build_workout(shape, snippets, exercise_details)
137136
workout_attrs = shape.slice(:name, :score_type, :rounds, :time, :interval, :time_cap,
138137
:ladder_step, :team_size, :notes).compact

app/services/workout_extraction/system_prompt.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,18 @@ def self.workout_shape_text
5757
def self.exercise_details_text
5858
<<~PROMPT
5959
You convert a numbered list of CrossFit exercise prescription snippets into structured JSON,
60-
one entry per snippet, in the same order, matching the provided schema's "exercises" array.
60+
one entry per snippet, in the same order.
61+
62+
Respond with ONLY a JSON object -- no other text, no markdown code fences -- matching exactly
63+
this shape:
64+
{
65+
"exercises": [
66+
{
67+
"movement_name": "<string, required -- copied verbatim from the recognized list below>",
68+
#{exercise_field_lines}
69+
}
70+
]
71+
}
6172
6273
Rules:
6374
- "movement_name" must be copied verbatim from this exact list of recognized movements (case
@@ -70,5 +81,14 @@ def self.exercise_details_text
7081
#{PRESCRIPTION_CHEAT_SHEET}
7182
PROMPT
7283
end
84+
85+
# Builds the exercise-details prompt's field list directly from EXERCISE_SCHEMA, so the prompt
86+
# can't silently drift from the Exercise model the way a hand-written description could.
87+
def self.exercise_field_lines
88+
WorkoutExtraction::LlmParser::EXERCISE_SCHEMA[:properties].except(:movement_name).map do |name, property|
89+
type_hint = property[:enum] ? property[:enum].map(&:inspect).join('/') : property[:type]
90+
" \"#{name}\": <#{type_hint}, omit if not specified>"
91+
end.join(",\n")
92+
end
7393
end
7494
end

test/services/workout_extraction/llm_parser_error_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def exercise_payload(overrides)
9494
movement_name: nil, reps: nil, duration_seconds: nil, load: nil, female_load: nil, male_load: nil,
9595
implement_count: nil, distance: nil, female_distance: nil, male_distance: nil, distance_unit: nil,
9696
distance_units_per_rep: nil, calories: nil, female_calories: nil, male_calories: nil,
97-
ladder_step_every: nil, ladder_exempt: nil, notes: nil
97+
ladder_step_every: nil, ladder_exempt: nil
9898
}.merge(overrides)
9999
end
100100
end

test/services/workout_extraction/llm_parser_schema_test.rb

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class LlmParserSchemaTest < ActiveSupport::TestCase
66
assert_equal(
77
%i[reps duration_seconds load female_load male_load implement_count distance female_distance
88
male_distance distance_unit distance_units_per_rep calories female_calories male_calories
9-
ladder_step_every ladder_exempt notes movement_name].sort,
9+
ladder_step_every ladder_exempt movement_name].sort,
1010
LlmParser::EXERCISE_SCHEMA[:properties].keys.sort
1111
)
1212
assert_equal(
@@ -19,7 +19,6 @@ class LlmParserSchemaTest < ActiveSupport::TestCase
1919
LlmParser::WORKOUT_SHAPE_SCHEMA[:properties].keys.sort
2020
)
2121
assert_equal(%i[text segment_index].sort, LlmParser::EXERCISE_SNIPPET_SCHEMA[:properties].keys.sort)
22-
assert_equal(%i[exercises], LlmParser::EXERCISE_DETAILS_SCHEMA[:properties].keys)
2322
end
2423

2524
test 'distance_unit is auto-constrained to the enum values and stays plainly optional' do
@@ -50,31 +49,23 @@ class LlmParserSchemaTest < ActiveSupport::TestCase
5049
test 'no schema uses anyOf/nullable types anywhere, since those were the cause of a grammar compilation timeout' do
5150
schemas = [
5251
LlmParser::WORKOUT_SHAPE_SCHEMA, LlmParser::SEGMENT_OUTLINE_SCHEMA, LlmParser::EXERCISE_SNIPPET_SCHEMA,
53-
LlmParser::EXERCISE_SCHEMA, LlmParser::EXERCISE_DETAILS_SCHEMA
52+
LlmParser::EXERCISE_SCHEMA
5453
]
5554

5655
schemas.each do |schema|
5756
assert_empty(schema[:properties].select { |_, prop| prop.key?(:anyOf) }, "#{schema} has an anyOf-typed property")
5857
end
5958
end
6059

60+
# Only the workout-shape call is still enforced via output_config/json_schema -- the
61+
# exercise-details call stopped using structured outputs entirely after every prior attempt to
62+
# satisfy the grammar compiler for a rich per-exercise schema failed, so EXERCISE_SCHEMA's
63+
# property counts against Anthropic's structured-outputs limits are no longer a relevant check.
6164
test 'call 1 (workout shape) property counts stay within Anthropic structured-outputs limits' do
6265
call_1_schemas = [LlmParser::WORKOUT_SHAPE_SCHEMA, LlmParser::SEGMENT_OUTLINE_SCHEMA, LlmParser::EXERCISE_SNIPPET_SCHEMA]
6366

64-
assert_within_structured_output_limits(call_1_schemas)
65-
end
66-
67-
test 'call 2 (exercise details) property counts stay within Anthropic structured-outputs limits' do
68-
call_2_schemas = [LlmParser::EXERCISE_DETAILS_SCHEMA, LlmParser::EXERCISE_SCHEMA]
69-
70-
assert_within_structured_output_limits(call_2_schemas)
71-
end
72-
73-
private
74-
75-
def assert_within_structured_output_limits(schemas)
76-
total_optional = schemas.sum { |schema| schema[:properties].keys.map(&:to_s).length - schema[:required].length }
77-
total_nullable = schemas.sum { |schema| schema[:properties].count { |_, prop| prop.key?(:anyOf) } }
67+
total_optional = call_1_schemas.sum { |schema| schema[:properties].keys.map(&:to_s).length - schema[:required].length }
68+
total_nullable = call_1_schemas.sum { |schema| schema[:properties].count { |_, prop| prop.key?(:anyOf) } }
7869

7970
assert_operator total_optional, :<=, 24, "optional count #{total_optional} exceeds Anthropic's limit of 24"
8071
assert_operator total_nullable, :<=, 16, "nullable count #{total_nullable} exceeds Anthropic's limit of 16"

test/services/workout_extraction/llm_parser_test.rb

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ class LlmParserTest < ActiveSupport::TestCase
5858
assert top_level_exercise.segment.blank?
5959
end
6060

61+
test 'parses exercise details even when wrapped in a markdown code fence' do
62+
stub_request(:post, 'https://api.anthropic.com/v1/messages').to_return(
63+
anthropic_http_response(
64+
extractable: true, name: 'Fran', score_type: 'time', rounds: nil, time: nil, interval: nil,
65+
time_cap: nil, ladder_step: nil, team_size: nil, notes: nil, gap_reason: nil, segments: [],
66+
exercise_snippets: [{ text: 'Thrusters (95/65)', segment_index: nil }]
67+
),
68+
anthropic_http_response_with_raw_text(
69+
"```json\n#{{ exercises: [exercise_payload(movement_name: @movement.name, reps: 1)] }.to_json}\n```"
70+
)
71+
)
72+
73+
workout = WorkoutExtraction::LlmParser.call('21-15-9 Thrusters (95/65)')
74+
75+
assert_equal 1, workout.exercises.size
76+
assert_equal @movement, workout.exercises.first.movement
77+
end
78+
6179
private
6280

6381
# Stubs the two sequential calls LlmParser makes: the workout-shape call, then (only if the shape
@@ -71,6 +89,10 @@ def stub_two_call_response(shape:, exercises: nil)
7189
end
7290

7391
def anthropic_http_response(payload)
92+
anthropic_http_response_with_raw_text(payload.to_json)
93+
end
94+
95+
def anthropic_http_response_with_raw_text(text)
7496
{
7597
status: 200,
7698
headers: { 'Content-Type' => 'application/json' },
@@ -79,21 +101,21 @@ def anthropic_http_response(payload)
79101
type: 'message',
80102
role: 'assistant',
81103
model: 'claude-haiku-4-5',
82-
content: [{ type: 'text', text: payload.to_json }],
104+
content: [{ type: 'text', text: text }],
83105
stop_reason: 'end_turn',
84106
usage: { input_tokens: 100, output_tokens: 50 }
85107
}.to_json
86108
}
87109
end
88110

89111
# A full EXERCISE_SCHEMA-shaped payload with every optional field nil except what's overridden --
90-
# keeps individual tests from having to spell out all 18 fields every time.
112+
# keeps individual tests from having to spell out all 17 fields every time.
91113
def exercise_payload(overrides)
92114
{
93115
movement_name: nil, reps: nil, duration_seconds: nil, load: nil, female_load: nil, male_load: nil,
94116
implement_count: nil, distance: nil, female_distance: nil, male_distance: nil, distance_unit: nil,
95117
distance_units_per_rep: nil, calories: nil, female_calories: nil, male_calories: nil,
96-
ladder_step_every: nil, ladder_exempt: nil, notes: nil
118+
ladder_step_every: nil, ladder_exempt: nil
97119
}.merge(overrides)
98120
end
99121
end

test/tasks/workout_extraction_rake_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class WorkoutExtractionRakeTest < ActiveSupport::TestCase
1919
{ movement_name: movement.name, reps: 1, load: nil, female_load: 65, male_load: 95,
2020
duration_seconds: nil, implement_count: nil, distance: nil, female_distance: nil,
2121
male_distance: nil, distance_unit: nil, distance_units_per_rep: nil, calories: nil,
22-
female_calories: nil, male_calories: nil, ladder_step_every: nil, ladder_exempt: nil, notes: nil }
22+
female_calories: nil, male_calories: nil, ladder_step_every: nil, ladder_exempt: nil }
2323
]
2424
)
2525

0 commit comments

Comments
 (0)