Class: CoachZed

Inherits:
Object
  • Object
show all
Defined in:
lib/coach_zed.rb,
lib/coach_zed/catalog.rb,
lib/coach_zed/version.rb,
lib/coach_zed/feed_reader.rb,
lib/coach_zed/feed_writer.rb,
lib/coach_zed/prompt_builder.rb,
lib/coach_zed/schedule_parser.rb,
lib/coach_zed/schedule_schema.rb,
lib/coach_zed/clients/ruby_openai.rb,
sig/coach_zed.rbs

Defined Under Namespace

Modules: Catalog, Clients, ScheduleSchema Classes: Config, FeedReader, FeedWriter, PromptBuilder, Result, ScheduleParser

Constant Summary collapse

VERSION =

Returns:

  • (String)
"0.8.0"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client:, workout_catalog_dir: nil, model: nil, output_dir: nil, feed_output_basename: nil, feed_title: nil, existing_feed_path: nil, existing_schedule_path: nil, merge_policy: nil) ⇒ CoachZed

Returns a new instance of CoachZed.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/coach_zed.rb', line 101

def initialize(
  client:,
  workout_catalog_dir: nil,
  model: nil,
  output_dir: nil,
  feed_output_basename: nil,
  feed_title: nil,
  existing_feed_path: nil,
  existing_schedule_path: nil,
  merge_policy: nil
)
  config = self.class.config

  @workout_catalog_dir = Pathname(workout_catalog_dir || config.workout_catalog_dir || raise(ArgumentError, "workout_catalog_dir is required"))
  model_name = model || config.model || "gpt-4.1"
  @ai_client = wrap_client(client, model: model_name)
  @output_dir = Pathname(output_dir || config.output_dir || "results")
  @schedule_output_dir = @output_dir.join("schedules")
  @feed_output_dir = @output_dir.join("feeds")
  @feed_output_basename = feed_output_basename.nil? ? config.feed_output_basename : feed_output_basename
  @feed_title = feed_title.nil? ? config.feed_title : feed_title
  resolved_existing_feed_path = existing_feed_path.nil? ? config.existing_feed_path : existing_feed_path
  @existing_feed_path = resolved_existing_feed_path && Pathname(resolved_existing_feed_path)
  resolved_existing_schedule_path = existing_schedule_path.nil? ? config.existing_schedule_path : existing_schedule_path
  resolved_existing_schedule_path =
    if resolved_existing_schedule_path.nil? && @existing_feed_path
      Pathname(@existing_feed_path.to_s.sub(/\.ics\z/, ".json"))
    else
      resolved_existing_schedule_path
    end
  @existing_schedule_path = resolved_existing_schedule_path && Pathname(resolved_existing_schedule_path)
  @merge_policy = merge_policy.nil? ? config.merge_policy : merge_policy
end

Instance Attribute Details

#ai_clientObject (readonly)

Returns the value of attribute ai_client.

Returns:

  • (Object)


172
173
174
# File 'lib/coach_zed.rb', line 172

def ai_client
  @ai_client
end

#existing_feed_pathPathname? (readonly)

Returns the value of attribute existing_feed_path.

Returns:

  • (Pathname, nil)


172
173
174
# File 'lib/coach_zed.rb', line 172

def existing_feed_path
  @existing_feed_path
end

#existing_schedule_pathPathname? (readonly)

Returns the value of attribute existing_schedule_path.

Returns:

  • (Pathname, nil)


172
173
174
# File 'lib/coach_zed.rb', line 172

def existing_schedule_path
  @existing_schedule_path
end

#feed_output_basenameString? (readonly)

Returns the value of attribute feed_output_basename.

Returns:

  • (String, nil)


172
173
174
# File 'lib/coach_zed.rb', line 172

def feed_output_basename
  @feed_output_basename
end

#feed_output_dirPathname (readonly)

Returns the value of attribute feed_output_dir.

Returns:

  • (Pathname)


172
173
174
# File 'lib/coach_zed.rb', line 172

def feed_output_dir
  @feed_output_dir
end

#feed_titleString? (readonly)

Returns the value of attribute feed_title.

Returns:

  • (String, nil)


172
173
174
# File 'lib/coach_zed.rb', line 172

def feed_title
  @feed_title
end

#merge_policyString? (readonly)

Returns the value of attribute merge_policy.

Returns:

  • (String, nil)


172
173
174
# File 'lib/coach_zed.rb', line 172

def merge_policy
  @merge_policy
end

#output_dirPathname (readonly)

Returns the value of attribute output_dir.

Returns:

  • (Pathname)


172
173
174
# File 'lib/coach_zed.rb', line 172

def output_dir
  @output_dir
end

#schedule_output_dirPathname (readonly)

Returns the value of attribute schedule_output_dir.

Returns:

  • (Pathname)


172
173
174
# File 'lib/coach_zed.rb', line 172

def schedule_output_dir
  @schedule_output_dir
end

#workout_catalog_dirPathname (readonly)

Returns the value of attribute workout_catalog_dir.

Returns:

  • (Pathname)


172
173
174
# File 'lib/coach_zed.rb', line 172

def workout_catalog_dir
  @workout_catalog_dir
end

Class Method Details

.configConfig

Returns:



53
54
55
56
57
# File 'lib/coach_zed.rb', line 53

def config
  @config ||= default_config
  load_config_file
  @config
end

.config_file_pathsArray[String]

Returns:

  • (Array[String])


96
97
98
# File 'lib/coach_zed.rb', line 96

def config_file_paths
  [".coach_zed.yml", File.expand_path("~/.config/coach_zed.yml")]
end

.configure {|config| ... } ⇒ Object

Yields:

Yield Parameters:

Yield Returns:

  • (Object)

Returns:

  • (Object)


59
60
61
62
# File 'lib/coach_zed.rb', line 59

def configure
  load_config_file
  yield config
end

.default_configConfig

Returns:



89
90
91
92
93
94
# File 'lib/coach_zed.rb', line 89

def default_config
  Config.new(
    model: "gpt-4.1",
    output_dir: "results"
  )
end

.load_config_fileString?

Returns:

  • (String, nil)


64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/coach_zed.rb', line 64

def load_config_file
  return if @config_file_loaded

  @config ||= default_config

  config_file_paths.each do |path|
    next unless File.exist?(path)

    parsed = YAML.load_file(path)
    next unless parsed.is_a?(Hash)

    @config.apply(parsed.transform_keys(&:to_sym))
    @config_file_loaded = true
    return path
  end

  @config_file_loaded = true
  nil
end

.reset_config!void

This method returns an undefined value.



84
85
86
87
# File 'lib/coach_zed.rb', line 84

def reset_config!
  @config = nil
  @config_file_loaded = false
end

Instance Method Details

#catalog_digest(catalog) ⇒ String

Parameters:

Returns:

  • (String)


283
284
285
# File 'lib/coach_zed.rb', line 283

def catalog_digest(catalog)
  Digest::SHA256.hexdigest(catalog.map(&:fingerprint).join("\n"))
end

#feed_basenameString

Returns:

  • (String)


362
363
364
# File 'lib/coach_zed.rb', line 362

def feed_basename
  feed_output_basename || "schedule"
end

#generate_schedule(start_date:, consultation_prompt: nil, consultation_prompt_path: nil, generation_mode: nil, merge_policy: nil) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/coach_zed.rb', line 135

def generate_schedule(start_date:, consultation_prompt: nil, consultation_prompt_path: nil, generation_mode: nil, merge_policy: nil)
  prompt_text = resolve_prompt_text(consultation_prompt, consultation_prompt_path)
  catalog = Catalog::Loader.new(@workout_catalog_dir).load
  generation_mode = normalize_generation_mode(generation_mode)
  merge_policy = normalize_merge_policy(merge_policy || @merge_policy || generation_mode)
  existing_schedule = load_existing_schedule if merge_policy == :append
  existing_feed = load_existing_feed if existing_schedule.nil? && generation_mode != :refresh
  start_date = generation_start_date(start_date, existing_schedule:, generation_mode:, merge_policy:)
  generation_days = generation_days_for(start_date, generation_mode:, existing_schedule:, merge_policy:)
  existing_context = existing_schedule ? schedule_context(existing_schedule, limit_days: 28) : existing_feed&.to_context(limit_days: 28)
  schedule_key = schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_context, merge_policy)
  prompt = PromptBuilder.new(
    consultation_prompt: prompt_text,
    catalog: catalog,
    start_date: start_date,
    schedule_key: schedule_key,
    generation_days: generation_days,
    existing_feed_context: existing_context
  ).build
  raw_schedule = @ai_client.generate(prompt:)
  schedule = ScheduleParser.parse(raw_schedule)
  schedule = normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:, merge_policy:)
  schedule = merge_schedule(existing_schedule, schedule, merge_policy)

  schedule_path = write_schedule(schedule, schedule_key)
  feed_paths = write_feeds(schedule)

  Result.new(
    schedule_path: schedule_path,
    ics_path: feed_paths.fetch(:ics),
    webcal_path: feed_paths.fetch(:webcal),
    schedule: schedule
  )
end

#generation_days_for(start_date, generation_mode:, existing_schedule:, merge_policy:) ⇒ Integer

Parameters:

  • start_date (Date)
  • generation_mode: (Symbol, nil)
  • existing_schedule: (Hash[String, untyped], nil)
  • merge_policy: (Symbol, nil)

Returns:

  • (Integer)


261
262
263
264
265
266
267
268
# File 'lib/coach_zed.rb', line 261

def generation_days_for(start_date, generation_mode:, existing_schedule:, merge_policy:)
  return 7 if merge_policy == :append && existing_schedule
  return 28 if merge_policy == :append
  return 28 if generation_mode.nil?

  upcoming_sunday = start_date + ((7 - start_date.wday) % 7)
  (upcoming_sunday - start_date).to_i + 29
end

#generation_start_date(start_date, existing_schedule:, generation_mode:, merge_policy:) ⇒ Date

Parameters:

  • start_date (Object)
  • existing_schedule: (Hash[String, untyped], nil)
  • generation_mode: (Symbol, nil)
  • merge_policy: (Symbol, nil)

Returns:

  • (Date)


248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/coach_zed.rb', line 248

def generation_start_date(start_date, existing_schedule:, generation_mode:, merge_policy:)
  return normalize_date(start_date) if generation_mode == :refresh

  if merge_policy == :append
    last_date = existing_schedule&.fetch("days")&.map { |day| Date.parse(day.fetch("date")) }&.max
    return normalize_date(start_date) if last_date.nil?

    last_date + 1
  else
    normalize_date(start_date)
  end
end

#load_existing_feedCoachZed::FeedReader?

Returns:



210
211
212
213
214
215
# File 'lib/coach_zed.rb', line 210

def load_existing_feed
  return nil if existing_feed_path.nil?
  return nil unless existing_feed_path.exist?

  FeedReader.load_existing(existing_feed_path)
end

#load_existing_scheduleHash[String, untyped]?

Returns:

  • (Hash[String, untyped], nil)


217
218
219
220
221
222
223
224
# File 'lib/coach_zed.rb', line 217

def load_existing_schedule
  return nil if existing_schedule_path.nil?
  return nil unless existing_schedule_path.exist?

  schedule = JSON.parse(existing_schedule_path.read)
  ScheduleParser.validate!(schedule)
  schedule
end

#merge_schedule(existing_schedule, schedule, merge_policy) ⇒ Hash[String, untyped]

Parameters:

  • (Hash[String, untyped], nil)
  • (Hash[String, untyped])
  • (Symbol, nil)

Returns:

  • (Hash[String, untyped])


318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/coach_zed.rb', line 318

def merge_schedule(existing_schedule, schedule, merge_policy)
  return schedule if merge_policy == :replace || existing_schedule.nil?

  existing_by_date = existing_schedule.fetch("days").to_h { |day| [day.fetch("date"), day] }
  merged_by_date = existing_by_date.merge(schedule.fetch("days").to_h { |day| [day.fetch("date"), day] })

  merged_days = merged_by_date.values.sort_by { |day| Date.parse(day.fetch("date")) }
  merged_days = merged_days.each_with_index.map do |day, index|
    day.merge("day_number" => index + 1)
  end

  schedule.merge(
    "merged_from_schedule_id" => existing_schedule["schedule_id"],
    "start_date" => merged_days.first&.fetch("date"),
    "program_length_days" => merged_days.length,
    "days" => merged_days
  )
end

#normalize_date(value) ⇒ Date

Parameters:

  • value (Object)

Returns:

  • (Date)


199
200
201
202
203
204
205
206
207
208
# File 'lib/coach_zed.rb', line 199

def normalize_date(value)
  case value
  when Date
    value
  when Time, DateTime
    value.to_date
  else
    Date.parse(value.to_s)
  end
end

#normalize_generation_mode(value) ⇒ Symbol?

Parameters:

  • value (Object)

Returns:

  • (Symbol, nil)


226
227
228
229
230
231
232
233
234
235
# File 'lib/coach_zed.rb', line 226

def normalize_generation_mode(value)
  return nil if value.nil?

  case value.to_sym
  when :refresh, :append
    value.to_sym
  else
    raise ArgumentError, "unsupported generation mode: #{value}"
  end
end

#normalize_merge_policy(value) ⇒ Symbol?

Parameters:

  • value (Object)

Returns:

  • (Symbol, nil)


237
238
239
240
241
242
243
244
245
246
# File 'lib/coach_zed.rb', line 237

def normalize_merge_policy(value)
  return :replace if value.nil?

  case value.to_sym
  when :replace, :append
    value.to_sym
  else
    raise ArgumentError, "unsupported merge policy: #{value}"
  end
end

#normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:, merge_policy:) ⇒ Hash[String, untyped]

Parameters:

  • schedule (Hash[String, untyped])
  • start_date: (Date)
  • prompt_text: (String)
  • schedule_key: (String)
  • catalog: (Array[CoachZed::Catalog::Entry])
  • generation_days: (Integer)
  • merge_policy: (Symbol, nil)

Returns:

  • (Hash[String, untyped])


287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/coach_zed.rb', line 287

def normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:, merge_policy:)
  catalog_texts = catalog.to_h { |entry| [entry.relative_path, entry.path.read] }
  days = schedule.fetch("days")
  normalized_days = days.each_with_index.map do |day, index|
    day_number = day.fetch("day_number", index + 1).to_i
    date = start_date + (day_number - 1)
    workout = day["workout"]
    workout = workout&.merge("catalog_text" => catalog_texts.fetch(workout["catalog_path"], ""))
    {
      "day_number" => day_number,
      "date" => date.iso8601,
      "day_type" => day.fetch("day_type"),
      "workout" => workout&.transform_keys(&:to_s),
      "notes" => day["notes"].to_s
    }
  end

  schedule.merge(
    "schema_version" => 1,
    "schedule_id" => schedule_key,
    "start_date" => start_date.iso8601,
    "consultation_prompt" => prompt_text,
    "catalog_directory" => workout_catalog_dir.to_s,
    "catalog_count" => catalog.count,
    "program_length_days" => schedule.fetch("program_length_days", generation_days).to_i,
    "merge_policy" => merge_policy.to_s,
    "generated_at" => Time.now.utc.iso8601,
    "days" => normalized_days
  )
end

#resolve_prompt_text(consultation_prompt, consultation_prompt_path) ⇒ String

Parameters:

  • consultation_prompt (String, nil)
  • consultation_prompt_path (String, nil)

Returns:

  • (String)


185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/coach_zed.rb', line 185

def resolve_prompt_text(consultation_prompt, consultation_prompt_path)
  if consultation_prompt && consultation_prompt_path
    raise ArgumentError, "provide either consultation_prompt or consultation_prompt_path, not both"
  end

  if consultation_prompt.nil? && consultation_prompt_path.nil?
    raise ArgumentError, "provide consultation_prompt or consultation_prompt_path"
  end

  return consultation_prompt if consultation_prompt

  Pathname(consultation_prompt_path.to_s).read
end

#schedule_context(schedule, limit_days: 28) ⇒ String

Parameters:

  • schedule (Hash[String, untyped])
  • limit_days: (Integer) (defaults to: 28)

Returns:

  • (String)


366
367
368
369
370
371
372
373
374
375
376
# File 'lib/coach_zed.rb', line 366

def schedule_context(schedule, limit_days: 28)
  days = schedule.fetch("days")
  recent_days = days.last(limit_days)

  recent_days.map do |day|
    pieces = [day.fetch("date")]
    pieces << ((day["day_type"] == "workout") ? day.fetch("workout").fetch("title") : "Rest")
    pieces << day["notes"] if day["notes"] && !day["notes"].to_s.empty?
    pieces.join(" | ")
  end.join("\n")
end

#schedule_filename(schedule_key) ⇒ String

Parameters:

  • schedule_key (String)

Returns:

  • (String)


358
359
360
# File 'lib/coach_zed.rb', line 358

def schedule_filename(schedule_key)
  "schedule-#{schedule_key}.json"
end

#schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_context, merge_policy) ⇒ String

Parameters:

  • prompt_text (String)
  • start_date (Date)
  • catalog (Array[CoachZed::Catalog::Entry])
  • generation_days (Integer)
  • existing_context (String, nil)
  • merge_policy (Symbol, nil)

Returns:

  • (String)


270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/coach_zed.rb', line 270

def schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_context, merge_policy)
  Digest::SHA256.hexdigest(
    [
      prompt_text.strip,
      start_date.iso8601,
      generation_days,
      catalog_digest(catalog),
      merge_policy.to_s,
      existing_context.to_s
    ].join("\n")
  )[0...12] || ""
end

#wrap_client(client, model:) ⇒ Object

Parameters:

  • client (Object)
  • model: (String)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


174
175
176
177
178
179
180
181
182
183
# File 'lib/coach_zed.rb', line 174

def wrap_client(client, model:)
  return client if client.is_a?(Clients::RubyOpenAI)

  client_name = client.class.name
  if client_name == "OpenAI::Client"
    return Clients::RubyOpenAI.new(client:, model:)
  end

  raise ArgumentError, "unsupported client: #{client_name || client.class}"
end

#write_feeds(schedule) ⇒ Hash[Symbol, Pathname]

Parameters:

  • schedule (Hash[String, untyped])

Returns:

  • (Hash[Symbol, Pathname])


344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/coach_zed.rb', line 344

def write_feeds(schedule)
  feed_output_dir.mkpath
  base_path = feed_output_dir.join(feed_basename)
  feed = FeedWriter.new(
    schedule:,
    calendar_name: feed_title
  ).build
  ics_path = base_path.sub_ext(".ics")
  webcal_path = base_path.sub_ext(".webcal")
  ics_path.write(feed)
  webcal_path.write(feed)
  {ics: ics_path, webcal: webcal_path}
end

#write_schedule(schedule, schedule_key) ⇒ Pathname

Parameters:

  • schedule (Hash[String, untyped])
  • schedule_key (String)

Returns:

  • (Pathname)


337
338
339
340
341
342
# File 'lib/coach_zed.rb', line 337

def write_schedule(schedule, schedule_key)
  schedule_output_dir.mkpath
  path = schedule_output_dir.join(schedule_filename(schedule_key))
  path.write(JSON.pretty_generate(schedule) + "\n")
  path
end