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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/lib/coach_zed/catalog.rb +195 -0
- data/lib/coach_zed/clients/ruby_openai.rb +42 -0
- data/lib/coach_zed/feed_reader.rb +94 -0
- data/lib/coach_zed/feed_writer.rb +102 -0
- data/lib/coach_zed/prompt_builder.rb +68 -0
- data/lib/coach_zed/schedule_parser.rb +60 -0
- data/lib/coach_zed/version.rb +5 -0
- data/lib/coach_zed.rb +271 -0
- data/sig/coach_zed.rbs +238 -0
- metadata +56 -0
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
|
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: []
|