coach_zed 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2d8daf5761eb18163bfa3580c333a16f45d78e9c42cba6c57fdcc10f181d029
4
+ data.tar.gz: f1587187415f4436aee180a525b26e09157c947ac00efe0ca833cd69dca0338c
5
+ SHA512:
6
+ metadata.gz: c3dd7fd8e883d7d5cf970e5ef71b0aed8e1c4f5e1dec12b072b0f1f6bef511d27cbab4128556782438506e943e72a7d8f8f8035d32b7dcd65a1a27dc32f36d06
7
+ data.tar.gz: d11ce506d3f700217bf1ba9fe02d425aeadbf4b385e0eb04b8ac8e74d0e461b462c26cfcf57a056b4f8123f0c9e27aae9aafcc9d6e38dc906a9a7c9a4e2f1272
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ ## [](https://github.com/rossta/coach_zed/compare/v0.4.0...v) (2026-06-15)
2
+
3
+ ### Bug Fixes
4
+
5
+ * bump version ([588bd4a](https://github.com/rossta/coach_zed/commit/588bd4a8a67ab005523648132fdfdf41835e05e5))
6
+ ## [](https://github.com/rossta/coach_zed/compare/v0.3.0...v) (2026-06-15)
7
+
8
+ ### Features
9
+
10
+ * add steep typechecking ([7dd711b](https://github.com/rossta/coach_zed/commit/7dd711b3bdd15d5b04a6917bb5864b513fcc35d7))
11
+ ## [](https://github.com/rossta/coach_zed/compare/v0.2.0...v) (2026-06-15)
12
+
13
+ ### Features
14
+
15
+ * nudge release pipeline again ([91166c9](https://github.com/rossta/coach_zed/commit/91166c9d0392d18f4723d4c96d7cd0bedd28b215))
16
+ ## [](https://github.com/rossta/coach_zed/compare/v0.1.0...v) (2026-06-15)
17
+
18
+ ### Features
19
+
20
+ * add config defaults and data result ([c5a4d81](https://github.com/rossta/coach_zed/commit/c5a4d81aa269bde5a85d7f8bb7fa4afaa1a3f6dd))
21
+ * nudge release pipeline ([fac7bb8](https://github.com/rossta/coach_zed/commit/fac7bb87969dfae2c5112061cf0b8dffba6159c7))
22
+ * rename fitness_butler to coach_zed ([2d8be2d](https://github.com/rossta/coach_zed/commit/2d8be2d3855fb93c09f104689e94a014ce05d85f))
23
+ ## [Unreleased]
24
+
25
+ ## [0.1.0] - 2026-06-13
26
+
27
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ross Kaffenberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # CoachZed
2
+
3
+ CoachZed reads a workout catalog, asks an AI client for a day-by-day training plan, and writes schedule JSON plus calendar feeds.
4
+
5
+ ## Configuration
6
+
7
+ You can set shared defaults with:
8
+
9
+ ```ruby
10
+ CoachZed.configure do |config|
11
+ config.workout_catalog_dir = "workouts"
12
+ config.output_dir = "results"
13
+ end
14
+ ```
15
+
16
+ `CoachZed.new` also loads defaults from either:
17
+
18
+ - `.coach_zed.yml` in the current directory
19
+ - `~/.config/coach_zed.yml`
20
+
21
+ If a config file is present, you can initialize with just `client:`.
22
+
23
+ Generated files are written beneath `output_dir`:
24
+
25
+ - `output_dir/schedules`
26
+ - `output_dir/feeds`
27
+
28
+ ## Development
29
+
30
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
31
+
32
+ To install this gem onto your local machine, run `bundle exec rake install`. Releases are automated through GitHub Actions: conventional commits determine the next version, the workflow updates `lib/coach_zed/version.rb` and `CHANGELOG.md`, tags the release, builds the gem, and publishes the package to [rubygems.org](https://rubygems.org).
33
+
34
+ ## Contributing
35
+
36
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/coach_zed. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/coach_zed/blob/main/CODE_OF_CONDUCT.md).
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
41
+
42
+ ## Code of Conduct
43
+
44
+ Everyone interacting in the CoachZed project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/coach_zed/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+
6
+ class CoachZed
7
+ module Catalog
8
+ class Entry
9
+ attr_reader :path, :relative_path, :title, :domain, :session_duration, :frequency, :program, :format, :equipment, :summary, :work_items, :notes, :source_urls
10
+
11
+ def initialize(
12
+ path:,
13
+ relative_path:,
14
+ title:,
15
+ summary:,
16
+ work_items:,
17
+ notes:,
18
+ source_urls:,
19
+ domain: nil,
20
+ session_duration: nil,
21
+ frequency: nil,
22
+ program: nil,
23
+ format: nil,
24
+ equipment: nil
25
+ )
26
+ @path = path
27
+ @relative_path = relative_path
28
+ @title = title
29
+ @domain = domain
30
+ @session_duration = session_duration
31
+ @frequency = frequency
32
+ @program = program
33
+ @format = format
34
+ @equipment = equipment
35
+ @summary = summary
36
+ @work_items = work_items
37
+ @notes = notes
38
+ @source_urls = source_urls
39
+ end
40
+
41
+ def fingerprint
42
+ Digest::SHA256.hexdigest([
43
+ relative_path,
44
+ title,
45
+ domain,
46
+ session_duration,
47
+ frequency,
48
+ program,
49
+ format,
50
+ equipment,
51
+ summary,
52
+ work_items.join("\n"),
53
+ notes.join("\n"),
54
+ source_urls.join("\n")
55
+ ].join("\u0000"))
56
+ end
57
+
58
+ def to_h
59
+ {
60
+ "path" => path.to_s,
61
+ "relative_path" => relative_path,
62
+ "title" => title,
63
+ "domain" => domain,
64
+ "session_duration" => session_duration,
65
+ "frequency" => frequency,
66
+ "program" => program,
67
+ "format" => format,
68
+ "equipment" => equipment,
69
+ "summary" => summary,
70
+ "work_items" => work_items,
71
+ "notes" => notes,
72
+ "source_urls" => source_urls
73
+ }
74
+ end
75
+ end
76
+
77
+ class Loader
78
+ IGNORED_BASENAMES = %w[INDEX.md TEMPLATE.md].freeze
79
+
80
+ def initialize(root)
81
+ @root = Pathname(root)
82
+ end
83
+
84
+ def load
85
+ Dir.glob(@root.join("**/*.md")).sort.filter_map do |file|
86
+ path = Pathname(file)
87
+ next if ignored?(path)
88
+
89
+ parse_entry(path)
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :root
96
+
97
+ def ignored?(path)
98
+ IGNORED_BASENAMES.include?(path.basename.to_s)
99
+ end
100
+
101
+ def parse_entry(path)
102
+ relative_path = path.relative_path_from(root).to_s
103
+ lines = path.read.lines(chomp: true)
104
+ title = lines.find { |line| line.start_with?("# ") }&.sub("# ", "")
105
+ return if title.nil?
106
+
107
+ # @type var metadata: Hash[String, String]
108
+ metadata = {}
109
+ index = 1
110
+ index += 1 while index < lines.length && lines[index].strip.empty?
111
+ while index < lines.length
112
+ line = lines[index]
113
+ break if line.nil? || line.empty?
114
+ break unless line.start_with?("- ")
115
+
116
+ if (match = line.match(/^- ([^:]+):\s*(.*)$/))
117
+ key = normalize_key(match[1].to_s)
118
+ metadata[key] = strip_ticks(match[2].to_s)
119
+ end
120
+
121
+ index += 1
122
+ end
123
+
124
+ index += 1 while index < lines.length && lines[index].strip.empty?
125
+
126
+ sections = parse_sections(lines[index..] || [])
127
+
128
+ Entry.new(
129
+ path: path,
130
+ relative_path: relative_path,
131
+ title: title,
132
+ domain: metadata["domain"],
133
+ session_duration: metadata["session_duration"],
134
+ frequency: metadata["frequency"],
135
+ program: metadata["program"],
136
+ format: metadata["format"],
137
+ equipment: metadata["equipment"],
138
+ summary: sections.fetch("summary", []).join(" ").strip,
139
+ work_items: normalize_bullets(sections.fetch("work", [])),
140
+ notes: normalize_bullets(sections.fetch("notes", [])),
141
+ source_urls: normalize_urls(sections.fetch("source", []))
142
+ )
143
+ end
144
+
145
+ def parse_sections(lines)
146
+ # @type var sections: Hash[String, Array[String]]
147
+ sections = {}
148
+ current = nil
149
+
150
+ lines.each do |line|
151
+ if (match = line.match(/^##\s+(.+)$/))
152
+ current = normalize_section_name(match[1].to_s)
153
+ next
154
+ end
155
+
156
+ next unless current
157
+ section_name = current.to_s
158
+
159
+ sections[section_name] ||= []
160
+ sections[section_name] << line
161
+ end
162
+
163
+ sections
164
+ end
165
+
166
+ def normalize_section_name(name)
167
+ name.downcase.strip
168
+ end
169
+
170
+ def normalize_key(key)
171
+ key.downcase.tr(" -", "_")
172
+ end
173
+
174
+ def strip_ticks(value)
175
+ value.to_s.delete_prefix("`").delete_suffix("`")
176
+ end
177
+
178
+ def normalize_bullets(lines)
179
+ lines.filter_map do |line|
180
+ next if line.strip.empty?
181
+
182
+ line.sub(/^- /, "").strip
183
+ end
184
+ end
185
+
186
+ def normalize_urls(lines)
187
+ lines.filter_map do |line|
188
+ next unless line.match?(%r{\A-\s+https?://})
189
+
190
+ line.sub(/^- /, "")
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CoachZed
4
+ module Clients
5
+ class RubyOpenAI
6
+ def initialize(client:, model: "gpt-4.1")
7
+ @client = client
8
+ @model = model
9
+ end
10
+
11
+ def generate(prompt:)
12
+ response = client.chat(parameters: {
13
+ model: model,
14
+ messages: [{role: "user", content: prompt}]
15
+ })
16
+ extract_content(response)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :client, :model
22
+
23
+ def extract_content(response)
24
+ return response if response.is_a?(String)
25
+
26
+ if response.respond_to?(:dig)
27
+ content = response.dig("choices", 0, "message", "content") ||
28
+ response.dig(:choices, 0, :message, :content)
29
+ return content if content.is_a?(String)
30
+ end
31
+
32
+ if response.respond_to?(:[])
33
+ content = response["choices"]&.first&.dig("message", "content") ||
34
+ response[:choices]&.first&.dig(:message, :content)
35
+ return content if content.is_a?(String)
36
+ end
37
+
38
+ raise ArgumentError, "unable to extract content from ruby-openai response"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ class CoachZed
6
+ class FeedReader
7
+ class Event
8
+ attr_reader :date, :summary, :description
9
+
10
+ def initialize(date: nil, summary: nil, description: nil)
11
+ @date = date
12
+ @summary = summary
13
+ @description = description
14
+ end
15
+ end
16
+
17
+ def self.load(path)
18
+ new(File.read(path.to_s)).events
19
+ end
20
+
21
+ def self.load_existing(path)
22
+ new(File.read(path.to_s))
23
+ end
24
+
25
+ def initialize(feed_content)
26
+ @feed_content = feed_content
27
+ end
28
+
29
+ attr_reader :feed_content
30
+
31
+ def events
32
+ @events ||= parse_events
33
+ end
34
+
35
+ def last_date
36
+ events.map(&:date).compact.max
37
+ end
38
+
39
+ def recent_events(limit_days: 28)
40
+ last = last_date
41
+ cutoff = last ? last - (limit_days - 1) : nil
42
+ return events if cutoff.nil?
43
+
44
+ events.select { |event| event.date && event.date >= cutoff }
45
+ end
46
+
47
+ def to_context(limit_days: 28)
48
+ recent_events(limit_days:).map do |event|
49
+ # @type var pieces: Array[String]
50
+ pieces = []
51
+ pieces << event.date.iso8601 if event.date
52
+ pieces << event.summary if event.summary && !event.summary.empty?
53
+ pieces << event.description if event.description && !event.description.empty?
54
+ pieces.join(" | ")
55
+ end.join("\n")
56
+ end
57
+
58
+ private
59
+
60
+ def parse_events
61
+ blocks = feed_content.split(/BEGIN:VEVENT\r?\n/).drop(1)
62
+ blocks.filter_map do |block|
63
+ event_text = block.split("END:VEVENT").first
64
+ next if event_text.nil?
65
+
66
+ Event.new(
67
+ date: parse_date(event_text),
68
+ summary: parse_line(event_text, /^SUMMARY:(.*)$/),
69
+ description: unescape(parse_line(event_text, /^DESCRIPTION:(.*)$/))
70
+ )
71
+ end
72
+ end
73
+
74
+ def parse_date(event_text)
75
+ value = parse_line(event_text, /^DTSTART;VALUE=DATE:(\d{8})$/)
76
+ return if value.nil?
77
+
78
+ Date.strptime(value, "%Y%m%d")
79
+ end
80
+
81
+ def parse_line(event_text, pattern)
82
+ match = event_text.match(pattern)
83
+ match && match[1]
84
+ end
85
+
86
+ def unescape(value)
87
+ value.to_s
88
+ .gsub("\\n", "\n")
89
+ .gsub("\\,", ",")
90
+ .gsub("\\;", ";")
91
+ .gsub("\\\\", "\\")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ class CoachZed
7
+ class FeedWriter
8
+ def initialize(schedule:, start_date:, existing_feed_content: nil)
9
+ @schedule = schedule
10
+ @start_date = start_date
11
+ @existing_feed_content = existing_feed_content
12
+ end
13
+
14
+ def build
15
+ if existing_feed_content
16
+ append_to_existing_feed(existing_feed_content)
17
+ else
18
+ fresh_feed
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :schedule, :start_date, :existing_feed_content
25
+
26
+ def fresh_feed
27
+ lines = header_lines
28
+ lines.concat(event_lines)
29
+ lines << "END:VCALENDAR"
30
+ lines.join("\r\n") + "\r\n"
31
+ end
32
+
33
+ def append_to_existing_feed(existing_feed)
34
+ event_block = event_lines.join("\r\n") + "\r\n"
35
+ existing_feed.sub(/END:VCALENDAR\s*\z/, "#{event_block}END:VCALENDAR\r\n")
36
+ end
37
+
38
+ def header_lines
39
+ [
40
+ "BEGIN:VCALENDAR",
41
+ "VERSION:2.0",
42
+ "PRODID:-//CoachZed//EN",
43
+ "CALSCALE:GREGORIAN",
44
+ "METHOD:PUBLISH",
45
+ "X-WR-CALNAME:#{escape(schedule_name)}",
46
+ "X-WR-TIMEZONE:America/New_York"
47
+ ]
48
+ end
49
+
50
+ def event_lines
51
+ schedule.fetch("days").flat_map do |day|
52
+ date = start_date + (day.fetch("day_number").to_i - 1)
53
+ [
54
+ "BEGIN:VEVENT",
55
+ "UID:#{schedule.fetch("schedule_id")}-#{format("%02d", day.fetch("day_number").to_i)}@coach_zed",
56
+ "DTSTAMP:#{generated_timestamp}",
57
+ "DTSTART;VALUE=DATE:#{date.strftime("%Y%m%d")}",
58
+ "DTEND;VALUE=DATE:#{(date + 1).strftime("%Y%m%d")}",
59
+ "SUMMARY:#{escape(event_summary(day))}",
60
+ "DESCRIPTION:#{escape(event_description(day))}",
61
+ "END:VEVENT"
62
+ ]
63
+ end
64
+ end
65
+
66
+ def schedule_name
67
+ schedule["program_name"] || "Training Schedule"
68
+ end
69
+
70
+ def generated_timestamp
71
+ Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
72
+ end
73
+
74
+ def event_summary(day)
75
+ return "Rest" if day["day_type"] == "rest"
76
+
77
+ day.fetch("workout").fetch("title")
78
+ end
79
+
80
+ def event_description(day)
81
+ # @type var pieces: Array[String]
82
+ pieces = []
83
+ pieces << day["notes"].to_s if day["notes"] && !day["notes"].to_s.empty?
84
+ if day["day_type"] == "workout"
85
+ workout = day.fetch("workout")
86
+ pieces << "Catalog path: #{workout.fetch("catalog_path")}"
87
+ pieces << "Domain: #{workout.fetch("domain")}"
88
+ pieces << "Session duration: #{workout.fetch("session_duration")}"
89
+ end
90
+ pieces.join("\n")
91
+ end
92
+
93
+ def escape(value)
94
+ value.to_s
95
+ .gsub("\\", "\\\\")
96
+ .gsub(";", "\\;")
97
+ .gsub(",", "\\,")
98
+ .gsub("\r\n", "\\n")
99
+ .gsub("\n", "\\n")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class CoachZed
6
+ class PromptBuilder
7
+ def initialize(consultation_prompt:, catalog:, start_date:, schedule_key:, generation_days:, existing_feed_context: nil)
8
+ @consultation_prompt = consultation_prompt
9
+ @catalog = catalog
10
+ @start_date = start_date
11
+ @schedule_key = schedule_key
12
+ @generation_days = generation_days
13
+ @existing_feed_context = existing_feed_context
14
+ end
15
+
16
+ def build
17
+ <<~PROMPT
18
+ You are a training coach. Build a daily training schedule for the athlete using only the provided catalog.
19
+
20
+ Return JSON only. Do not wrap it in markdown fences or commentary.
21
+
22
+ Required schema:
23
+ {
24
+ "program_name": string,
25
+ "program_length_days": integer,
26
+ "days": [
27
+ {
28
+ "day_number": integer,
29
+ "day_type": "workout" or "rest",
30
+ "workout": {
31
+ "title": string,
32
+ "catalog_path": string,
33
+ "domain": string,
34
+ "session_duration": string
35
+ } or null,
36
+ "notes": string
37
+ }
38
+ ]
39
+ }
40
+
41
+ Rules:
42
+ - Produce exactly #{generation_days} entries for the requested time period.
43
+ - Include rest days when appropriate.
44
+ - Use only workouts that exist in the catalog.
45
+ - Match the athlete's goals and the requested time period.
46
+ - If existing feed context is provided, continue from the end of the prior feed and avoid changing earlier weeks.
47
+ - Keep the JSON valid and complete.
48
+
49
+ Schedule metadata:
50
+ - Start date: #{@start_date.iso8601}
51
+ - Schedule key: #{@schedule_key}
52
+
53
+ Athlete consultation prompt:
54
+ #{@consultation_prompt}
55
+
56
+ Existing feed context (most recent weeks, if available):
57
+ #{existing_feed_context || "none"}
58
+
59
+ Catalog:
60
+ #{JSON.pretty_generate(catalog.map(&:to_h))}
61
+ PROMPT
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :consultation_prompt, :catalog, :start_date, :schedule_key, :generation_days, :existing_feed_context
67
+ end
68
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class CoachZed
6
+ class ScheduleParser
7
+ class Error < StandardError; end
8
+
9
+ def self.parse(raw_schedule)
10
+ json = extract_json(raw_schedule)
11
+ schedule = JSON.parse(json)
12
+ validate!(schedule)
13
+ schedule
14
+ rescue JSON::ParserError => e
15
+ raise Error, "invalid schedule JSON: #{e.message}"
16
+ end
17
+
18
+ def self.extract_json(raw_schedule)
19
+ text = raw_schedule.to_s.strip
20
+ return text unless text.start_with?("```")
21
+
22
+ fenced = text.match(/```(?:json)?\s*(.*?)\s*```/m)
23
+ fenced ? fenced[1].to_s.strip : text
24
+ end
25
+
26
+ def self.validate!(schedule)
27
+ raise Error, "schedule must be a JSON object" unless schedule.is_a?(Hash)
28
+
29
+ days = schedule.fetch("days")
30
+ raise Error, "schedule must contain days" unless days.is_a?(Array) && !days.empty?
31
+
32
+ expected_length = schedule["program_length_days"] || days.length
33
+ raise Error, "program_length_days must match days length" unless expected_length.to_i == days.length
34
+
35
+ days.each_with_index do |day, index|
36
+ validate_day!(day, index + 1)
37
+ end
38
+ rescue KeyError => e
39
+ raise Error, "missing required schedule field: #{e.key}"
40
+ end
41
+
42
+ def self.validate_day!(day, expected_day_number)
43
+ raise Error, "each day must be an object" unless day.is_a?(Hash)
44
+ raise Error, "day_number must be sequential" unless day.fetch("day_number").to_i == expected_day_number
45
+ raise Error, "day_type must be workout or rest" unless %w[workout rest].include?(day.fetch("day_type"))
46
+ raise Error, "notes must be present" unless day.key?("notes")
47
+
48
+ if day["day_type"] == "workout"
49
+ workout = day.fetch("workout")
50
+ raise Error, "workout must be present for workout days" unless workout.is_a?(Hash)
51
+
52
+ %w[title catalog_path domain session_duration].each do |field|
53
+ raise Error, "workout must include #{field}" unless workout[field].is_a?(String) && !workout[field].empty?
54
+ end
55
+ end
56
+ rescue KeyError => e
57
+ raise Error, "missing required day field: #{e.key}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CoachZed
4
+ VERSION = "0.4.1"
5
+ end
data/lib/coach_zed.rb ADDED
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "digest"
5
+ require "json"
6
+ require "pathname"
7
+ require "yaml"
8
+
9
+ require_relative "coach_zed/version"
10
+ require_relative "coach_zed/catalog"
11
+ require_relative "coach_zed/clients/ruby_openai"
12
+ require_relative "coach_zed/feed_reader"
13
+ require_relative "coach_zed/feed_writer"
14
+ require_relative "coach_zed/prompt_builder"
15
+ require_relative "coach_zed/schedule_parser"
16
+
17
+ class CoachZed
18
+ Result = Data.define(:schedule_path, :ics_path, :webcal_path, :schedule)
19
+
20
+ class Config
21
+ attr_accessor :workout_catalog_dir, :model, :output_dir, :feed_output_basename, :existing_feed_path
22
+
23
+ def initialize(
24
+ workout_catalog_dir: nil,
25
+ model: nil,
26
+ output_dir: nil,
27
+ feed_output_basename: nil,
28
+ existing_feed_path: nil
29
+ )
30
+ @workout_catalog_dir = workout_catalog_dir
31
+ @model = model
32
+ @output_dir = output_dir
33
+ @feed_output_basename = feed_output_basename
34
+ @existing_feed_path = existing_feed_path
35
+ end
36
+
37
+ def apply(hash)
38
+ hash.each do |key, value|
39
+ public_send("#{key}=", value) if respond_to?("#{key}=")
40
+ end
41
+ end
42
+ end
43
+
44
+ class << self
45
+ def config
46
+ @config ||= default_config
47
+ load_config_file
48
+ @config
49
+ end
50
+
51
+ def configure
52
+ load_config_file
53
+ yield config
54
+ end
55
+
56
+ def load_config_file
57
+ return if @config_file_loaded
58
+
59
+ @config ||= default_config
60
+
61
+ config_file_paths.each do |path|
62
+ next unless File.exist?(path)
63
+
64
+ parsed = YAML.load_file(path)
65
+ next unless parsed.is_a?(Hash)
66
+
67
+ @config.apply(parsed.transform_keys(&:to_sym))
68
+ @config_file_loaded = true
69
+ return path
70
+ end
71
+
72
+ @config_file_loaded = true
73
+ nil
74
+ end
75
+
76
+ def reset_config!
77
+ @config = nil
78
+ @config_file_loaded = false
79
+ end
80
+
81
+ def default_config
82
+ Config.new(
83
+ model: "gpt-4.1",
84
+ output_dir: "results"
85
+ )
86
+ end
87
+
88
+ def config_file_paths
89
+ [".coach_zed.yml", File.expand_path("~/.config/coach_zed.yml")]
90
+ end
91
+ end
92
+
93
+ def initialize(
94
+ client:,
95
+ workout_catalog_dir: nil,
96
+ model: nil,
97
+ output_dir: nil,
98
+ feed_output_basename: nil,
99
+ existing_feed_path: nil
100
+ )
101
+ config = self.class.config
102
+
103
+ @workout_catalog_dir = Pathname(workout_catalog_dir || config.workout_catalog_dir || raise(ArgumentError, "workout_catalog_dir is required"))
104
+ model_name = model || config.model || "gpt-4.1"
105
+ @ai_client = wrap_client(client, model: model_name)
106
+ @output_dir = Pathname(output_dir || config.output_dir || "results")
107
+ @schedule_output_dir = @output_dir.join("schedules")
108
+ @feed_output_dir = @output_dir.join("feeds")
109
+ @feed_output_basename = feed_output_basename.nil? ? config.feed_output_basename : feed_output_basename
110
+ resolved_existing_feed_path = existing_feed_path.nil? ? config.existing_feed_path : existing_feed_path
111
+ @existing_feed_path = resolved_existing_feed_path && Pathname(resolved_existing_feed_path)
112
+ end
113
+
114
+ def generate_schedule(start_date:, consultation_prompt: nil, consultation_prompt_path: nil)
115
+ prompt_text = resolve_prompt_text(consultation_prompt, consultation_prompt_path)
116
+ catalog = Catalog::Loader.new(@workout_catalog_dir).load
117
+ existing_feed = load_existing_feed
118
+ start_date = generation_start_date(start_date, existing_feed:)
119
+ generation_days = existing_feed ? 7 : 28
120
+ existing_feed_context = existing_feed&.to_context(limit_days: 28)
121
+ schedule_key = schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_feed_context)
122
+ prompt = PromptBuilder.new(
123
+ consultation_prompt: prompt_text,
124
+ catalog: catalog,
125
+ start_date: start_date,
126
+ schedule_key: schedule_key,
127
+ generation_days: generation_days,
128
+ existing_feed_context: existing_feed_context
129
+ ).build
130
+ raw_schedule = @ai_client.generate(prompt:)
131
+ schedule = ScheduleParser.parse(raw_schedule)
132
+ schedule = normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:)
133
+
134
+ schedule_path = write_schedule(schedule, schedule_key)
135
+ feed_paths = write_feeds(schedule, start_date:, schedule_key:, existing_feed:)
136
+
137
+ Result.new(
138
+ schedule_path: schedule_path,
139
+ ics_path: feed_paths.fetch(:ics),
140
+ webcal_path: feed_paths.fetch(:webcal),
141
+ schedule: schedule
142
+ )
143
+ end
144
+
145
+ private
146
+
147
+ attr_reader :workout_catalog_dir, :ai_client, :output_dir, :schedule_output_dir, :feed_output_dir, :feed_output_basename, :existing_feed_path
148
+
149
+ def wrap_client(client, model:)
150
+ return client if client.is_a?(Clients::RubyOpenAI)
151
+
152
+ client_name = client.class.name
153
+ if client_name == "OpenAI::Client"
154
+ return Clients::RubyOpenAI.new(client:, model:)
155
+ end
156
+
157
+ raise ArgumentError, "unsupported client: #{client_name || client.class}"
158
+ end
159
+
160
+ def resolve_prompt_text(consultation_prompt, consultation_prompt_path)
161
+ if consultation_prompt && consultation_prompt_path
162
+ raise ArgumentError, "provide either consultation_prompt or consultation_prompt_path, not both"
163
+ end
164
+
165
+ if consultation_prompt.nil? && consultation_prompt_path.nil?
166
+ raise ArgumentError, "provide consultation_prompt or consultation_prompt_path"
167
+ end
168
+
169
+ return consultation_prompt if consultation_prompt
170
+
171
+ Pathname(consultation_prompt_path.to_s).read
172
+ end
173
+
174
+ def normalize_date(value)
175
+ case value
176
+ when Date
177
+ value
178
+ when Time, DateTime
179
+ value.to_date
180
+ else
181
+ Date.parse(value.to_s)
182
+ end
183
+ end
184
+
185
+ def load_existing_feed
186
+ return nil if existing_feed_path.nil?
187
+ return nil unless existing_feed_path.exist?
188
+
189
+ FeedReader.load_existing(existing_feed_path)
190
+ end
191
+
192
+ def generation_start_date(start_date, existing_feed:)
193
+ last_date = existing_feed&.last_date
194
+ return normalize_date(start_date) if last_date.nil?
195
+
196
+ last_date + 1
197
+ end
198
+
199
+ def schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_feed_context)
200
+ Digest::SHA256.hexdigest(
201
+ [
202
+ prompt_text.strip,
203
+ start_date.iso8601,
204
+ generation_days,
205
+ catalog_digest(catalog),
206
+ existing_feed_context.to_s
207
+ ].join("\n")
208
+ )[0...12] || ""
209
+ end
210
+
211
+ def catalog_digest(catalog)
212
+ Digest::SHA256.hexdigest(catalog.map(&:fingerprint).join("\n"))
213
+ end
214
+
215
+ def normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:)
216
+ days = schedule.fetch("days")
217
+ normalized_days = days.each_with_index.map do |day, index|
218
+ day_number = day.fetch("day_number", index + 1).to_i
219
+ date = start_date + (day_number - 1)
220
+ workout = day["workout"]
221
+ {
222
+ "day_number" => day_number,
223
+ "date" => date.iso8601,
224
+ "day_type" => day.fetch("day_type"),
225
+ "workout" => workout&.transform_keys(&:to_s),
226
+ "notes" => day["notes"].to_s
227
+ }
228
+ end
229
+
230
+ schedule.merge(
231
+ "schema_version" => 1,
232
+ "schedule_id" => schedule_key,
233
+ "start_date" => start_date.iso8601,
234
+ "consultation_prompt" => prompt_text,
235
+ "catalog_directory" => workout_catalog_dir.to_s,
236
+ "catalog_count" => catalog.count,
237
+ "program_length_days" => schedule.fetch("program_length_days", generation_days).to_i,
238
+ "days" => normalized_days
239
+ )
240
+ end
241
+
242
+ def write_schedule(schedule, schedule_key)
243
+ schedule_output_dir.mkpath
244
+ path = schedule_output_dir.join(schedule_filename(schedule_key))
245
+ path.write(JSON.pretty_generate(schedule) + "\n")
246
+ path
247
+ end
248
+
249
+ def write_feeds(schedule, start_date:, schedule_key:, existing_feed:)
250
+ feed_output_dir.mkpath
251
+ base_path = feed_output_dir.join(feed_basename(schedule_key))
252
+ feed = FeedWriter.new(
253
+ schedule:,
254
+ start_date:,
255
+ existing_feed_content: existing_feed&.feed_content
256
+ ).build
257
+ ics_path = base_path.sub_ext(".ics")
258
+ webcal_path = base_path.sub_ext(".webcal")
259
+ ics_path.write(feed)
260
+ webcal_path.write(feed)
261
+ {ics: ics_path, webcal: webcal_path}
262
+ end
263
+
264
+ def schedule_filename(schedule_key)
265
+ "schedule-#{schedule_key}.json"
266
+ end
267
+
268
+ def feed_basename(schedule_key)
269
+ feed_output_basename || "schedule-#{schedule_key}"
270
+ end
271
+ end
data/sig/coach_zed.rbs ADDED
@@ -0,0 +1,238 @@
1
+ class CoachZed
2
+ VERSION: String
3
+
4
+ class Result
5
+ attr_reader schedule_path: Pathname
6
+ attr_reader ics_path: Pathname
7
+ attr_reader webcal_path: Pathname
8
+ attr_reader schedule: Hash[String, untyped]
9
+
10
+ def initialize: (
11
+ schedule_path: Pathname,
12
+ ics_path: Pathname,
13
+ webcal_path: Pathname,
14
+ schedule: Hash[String, untyped]
15
+ ) -> void
16
+ end
17
+
18
+ class Config
19
+ attr_accessor workout_catalog_dir: String?
20
+ attr_accessor model: String?
21
+ attr_accessor output_dir: String?
22
+ attr_accessor feed_output_basename: String?
23
+ attr_accessor existing_feed_path: String?
24
+
25
+ def initialize: (
26
+ ?workout_catalog_dir: String?,
27
+ ?model: String?,
28
+ ?output_dir: String?,
29
+ ?feed_output_basename: String?,
30
+ ?existing_feed_path: String?
31
+ ) -> void
32
+
33
+ def apply: (Hash[untyped, untyped]) -> void
34
+ end
35
+
36
+ def self.config: -> Config
37
+ def self.configure: { (Config) -> untyped } -> untyped
38
+ def self.load_config_file: -> String?
39
+ def self.reset_config!: -> void
40
+ def self.default_config: -> Config
41
+ def self.config_file_paths: -> Array[String]
42
+
43
+ attr_reader workout_catalog_dir: Pathname
44
+ attr_reader ai_client: untyped
45
+ attr_reader output_dir: Pathname
46
+ attr_reader schedule_output_dir: Pathname
47
+ attr_reader feed_output_dir: Pathname
48
+ attr_reader feed_output_basename: String?
49
+ attr_reader existing_feed_path: Pathname?
50
+
51
+ def initialize: (
52
+ client: untyped,
53
+ ?workout_catalog_dir: String?,
54
+ ?model: String?,
55
+ ?output_dir: String?,
56
+ ?feed_output_basename: String?,
57
+ ?existing_feed_path: String?
58
+ ) -> void
59
+
60
+ def generate_schedule: (
61
+ start_date: untyped,
62
+ ?consultation_prompt: String?,
63
+ ?consultation_prompt_path: String?
64
+ ) -> Result
65
+
66
+ private
67
+
68
+ def wrap_client: (untyped client, model: String) -> untyped
69
+ def resolve_prompt_text: (String? consultation_prompt, String? consultation_prompt_path) -> String
70
+ def normalize_date: (untyped value) -> Date
71
+ def load_existing_feed: -> CoachZed::FeedReader?
72
+ def generation_start_date: (untyped start_date, existing_feed: CoachZed::FeedReader?) -> Date
73
+ def schedule_key_for: (String prompt_text, Date start_date, Array[CoachZed::Catalog::Entry] catalog, Integer generation_days, String? existing_feed_context) -> String
74
+ def catalog_digest: (Array[CoachZed::Catalog::Entry] catalog) -> String
75
+ def normalize_schedule: (Hash[String, untyped] schedule, start_date: Date, prompt_text: String, schedule_key: String, catalog: Array[CoachZed::Catalog::Entry], generation_days: Integer) -> Hash[String, untyped]
76
+ def write_schedule: (Hash[String, untyped] schedule, String schedule_key) -> Pathname
77
+ def write_feeds: (Hash[String, untyped] schedule, start_date: Date, schedule_key: String, existing_feed: CoachZed::FeedReader?) -> Hash[Symbol, Pathname]
78
+ def schedule_filename: (String schedule_key) -> String
79
+ def feed_basename: (String schedule_key) -> String
80
+
81
+ module Catalog
82
+ class Entry
83
+ attr_reader path: Pathname
84
+ attr_reader relative_path: String
85
+ attr_reader title: String
86
+ attr_reader domain: String?
87
+ attr_reader session_duration: String?
88
+ attr_reader frequency: String?
89
+ attr_reader program: String?
90
+ attr_reader format: String?
91
+ attr_reader equipment: String?
92
+ attr_reader summary: String
93
+ attr_reader work_items: Array[String]
94
+ attr_reader notes: Array[String]
95
+ attr_reader source_urls: Array[String]
96
+
97
+ def initialize: (
98
+ path: Pathname,
99
+ relative_path: String,
100
+ title: String,
101
+ ?domain: String?,
102
+ ?session_duration: String?,
103
+ ?frequency: String?,
104
+ ?program: String?,
105
+ ?format: String?,
106
+ ?equipment: String?,
107
+ summary: String,
108
+ work_items: Array[String],
109
+ notes: Array[String],
110
+ source_urls: Array[String]
111
+ ) -> void
112
+
113
+ def fingerprint: -> String
114
+ def to_h: -> Hash[String, untyped]
115
+ end
116
+
117
+ class Loader
118
+ IGNORED_BASENAMES: Array[String]
119
+
120
+ attr_reader root: Pathname
121
+
122
+ def initialize: (Pathname | String) -> void
123
+ def load: -> Array[Entry]
124
+
125
+ private
126
+
127
+ def ignored?: (Pathname path) -> bool
128
+ def parse_entry: (Pathname path) -> Entry?
129
+ def parse_sections: (Array[String] lines) -> Hash[untyped, Array[String]]
130
+ def normalize_section_name: (String name) -> String
131
+ def normalize_key: (String key) -> String
132
+ def strip_ticks: (String? value) -> String
133
+ def normalize_bullets: (Array[String] lines) -> Array[String]
134
+ def normalize_urls: (Array[String] lines) -> Array[String]
135
+ end
136
+ end
137
+
138
+ module Clients
139
+ class RubyOpenAI
140
+ attr_reader client: untyped
141
+ attr_reader model: String
142
+
143
+ def initialize: (client: untyped, ?model: String) -> void
144
+ def generate: (prompt: String) -> String
145
+
146
+ private
147
+
148
+ def extract_content: (untyped response) -> String
149
+ end
150
+ end
151
+
152
+ class FeedReader
153
+ class Event
154
+ attr_reader date: Date?
155
+ attr_reader summary: String?
156
+ attr_reader description: String?
157
+
158
+ def initialize: (
159
+ ?date: Date?,
160
+ ?summary: String?,
161
+ ?description: String?
162
+ ) -> void
163
+ end
164
+
165
+ def self.load: (Pathname | String) -> Array[Event]
166
+ def self.load_existing: (Pathname | String) -> FeedReader
167
+
168
+ def initialize: (String) -> void
169
+ attr_reader feed_content: String
170
+ def events: -> Array[Event]
171
+ def last_date: -> Date?
172
+ def recent_events: (?limit_days: Integer) -> Array[Event]
173
+ def to_context: (?limit_days: Integer) -> String
174
+
175
+ private
176
+
177
+ def parse_events: -> Array[Event]
178
+ def parse_date: (String event_text) -> Date?
179
+ def parse_line: (String event_text, Regexp pattern) -> String?
180
+ def unescape: (String? value) -> String
181
+ end
182
+
183
+ class FeedWriter
184
+ attr_reader schedule: Hash[String, untyped]
185
+ attr_reader start_date: Date
186
+ attr_reader existing_feed_content: String?
187
+
188
+ def initialize: (
189
+ schedule: Hash[String, untyped],
190
+ start_date: Date,
191
+ ?existing_feed_content: String?
192
+ ) -> void
193
+
194
+ def build: -> String
195
+
196
+ private
197
+
198
+ def fresh_feed: -> String
199
+ def append_to_existing_feed: (String existing_feed) -> String
200
+ def header_lines: -> Array[String]
201
+ def event_lines: -> Array[String]
202
+ def schedule_name: -> String
203
+ def generated_timestamp: -> String
204
+ def event_summary: (Hash[String, untyped] day) -> String
205
+ def event_description: (Hash[String, untyped] day) -> String
206
+ def escape: (untyped value) -> String
207
+ end
208
+
209
+ class PromptBuilder
210
+ attr_reader consultation_prompt: String
211
+ attr_reader catalog: Array[Catalog::Entry]
212
+ attr_reader start_date: Date
213
+ attr_reader schedule_key: String
214
+ attr_reader generation_days: Integer
215
+ attr_reader existing_feed_context: String?
216
+
217
+ def initialize: (
218
+ consultation_prompt: String,
219
+ catalog: Array[Catalog::Entry],
220
+ start_date: Date,
221
+ schedule_key: String,
222
+ generation_days: Integer,
223
+ ?existing_feed_context: String?
224
+ ) -> void
225
+
226
+ def build: -> String
227
+ end
228
+
229
+ class ScheduleParser
230
+ class Error < StandardError
231
+ end
232
+
233
+ def self.parse: (String) -> Hash[String, untyped]
234
+ def self.extract_json: (String raw_schedule) -> String
235
+ def self.validate!: (Hash[String, untyped] schedule) -> void
236
+ def self.validate_day!: (Hash[String, untyped] day, Integer expected_day_number) -> void
237
+ end
238
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: coach_zed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.1
5
+ platform: ruby
6
+ authors:
7
+ - Ross Kaffenberger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: CoachZed reads a workout catalog, asks an AI client for a daily training
13
+ plan, and writes schedule JSON plus calendar feeds.
14
+ email:
15
+ - rosskaff@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/coach_zed.rb
24
+ - lib/coach_zed/catalog.rb
25
+ - lib/coach_zed/clients/ruby_openai.rb
26
+ - lib/coach_zed/feed_reader.rb
27
+ - lib/coach_zed/feed_writer.rb
28
+ - lib/coach_zed/prompt_builder.rb
29
+ - lib/coach_zed/schedule_parser.rb
30
+ - lib/coach_zed/version.rb
31
+ - sig/coach_zed.rbs
32
+ homepage: https://example.com/coach_zed
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ homepage_uri: https://example.com/coach_zed
37
+ source_code_uri: https://example.com/coach_zed/source
38
+ changelog_uri: https://example.com/coach_zed/changelog
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.2.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 4.0.4
54
+ specification_version: 4
55
+ summary: Generate fitness schedules from a workout catalog.
56
+ test_files: []