git-fit 0.24.3 → 0.25.0
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 +4 -4
- data/lib/git-fit.rb +2 -0
- data/lib/git_fit/ability/computer.rb +254 -0
- data/lib/git_fit/ability/snapshot.rb +114 -0
- data/lib/git_fit/ability.rb +12 -0
- data/lib/git_fit/cli/ability.rb +61 -0
- data/lib/git_fit/cli.rb +3 -0
- data/lib/git_fit/config.rb +5 -2
- data/lib/git_fit/export/ai.rb +75 -2
- data/lib/git_fit/llm/prompts.rb +12 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b5ac295fe113517b660831a4cdaf826ac0905781ccdc591174117e0ec91973b
|
|
4
|
+
data.tar.gz: ecafcdedbdb2469570e946bd2b885d2605d92bf43a65fed7668f0074709a9a09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a588657ec9decf802ec4b2dc68bce7eb9e3c149282fd67e10a8e80a07c69c22cb418aef44513ba62e4de59c153475ba37dbf66da596b330d88d64601ea89f5a1
|
|
7
|
+
data.tar.gz: 2f1cdbe55b0f3ddd59406bb3b01c8d43ea09d491ec9a47a3fb316356e35cf6834633a1aa0315e495f5f26e623b214052b6934bd025a3c620d6f869c50d707a1e
|
data/lib/git-fit.rb
CHANGED
|
@@ -93,6 +93,8 @@ require_relative 'git_fit/cli/install_cli'
|
|
|
93
93
|
require_relative 'git_fit/cli/purge_cli'
|
|
94
94
|
require_relative 'git_fit/cli/gh_cli'
|
|
95
95
|
require_relative 'git_fit/mcp'
|
|
96
|
+
require_relative 'git_fit/ability'
|
|
97
|
+
require_relative 'git_fit/cli/ability'
|
|
96
98
|
require_relative 'git_fit/cli/export'
|
|
97
99
|
require_relative 'git_fit/cli/mcp'
|
|
98
100
|
require_relative 'git_fit/cli/import_cli'
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative 'snapshot'
|
|
5
|
+
|
|
6
|
+
module GitFit
|
|
7
|
+
module Ability
|
|
8
|
+
# Derives two-layer athlete ability snapshots (fitness + per-sport) from the L3 DB.
|
|
9
|
+
# Design: wiki/Ability (workouts#49); contract: git-fit#95.
|
|
10
|
+
#
|
|
11
|
+
# Rules (all acceptance-tested):
|
|
12
|
+
# - causal: --as-of excludes activities after the cutoff date
|
|
13
|
+
# - dedup: same unique-totals semantics as Export::Calculator — grouped run_ids
|
|
14
|
+
# are excluded and each dedup group counts once via per-field median
|
|
15
|
+
# - windowing: start_date_local wall-clock date (see Snapshot.date_of)
|
|
16
|
+
# - no invented metrics: volume/frequency/longest/trend + pace percentiles + Riegel
|
|
17
|
+
class Computer
|
|
18
|
+
RIEGEL_EXP = 1.06
|
|
19
|
+
MIN_PACE_SAMPLES = 5
|
|
20
|
+
DIRECT_MATCH_TOLERANCE = 0.02
|
|
21
|
+
MIN_VALID_DISTANCE_M = 500
|
|
22
|
+
MIN_VALID_TIME_S = 60
|
|
23
|
+
TARGET_SPORTS = %w[run ride].freeze
|
|
24
|
+
RUN_TARGETS = { '5k' => 5000.0, '10k' => 10_000.0, 'half' => 21_097.5, 'full' => 42_195.0 }.freeze
|
|
25
|
+
RIDE_TARGETS = { '20k' => 20_000.0, '40k' => 40_000.0, '100k' => 100_000.0 }.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(db:, as_of: nil)
|
|
28
|
+
@db = db
|
|
29
|
+
@as_of = normalize_as_of(as_of)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
rows = load_rows
|
|
34
|
+
{
|
|
35
|
+
'schema_version' => 1,
|
|
36
|
+
'generated_at' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
37
|
+
'fitness' => { 'snapshots' => fitness_snapshots(rows) },
|
|
38
|
+
'sports' => sports_section(rows),
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def normalize_as_of(as_of)
|
|
45
|
+
return nil if as_of.nil? || as_of.to_s.empty?
|
|
46
|
+
|
|
47
|
+
valid = as_of.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
48
|
+
unless valid
|
|
49
|
+
raise ArgumentError, "Ability: invalid --as-of '#{as_of}' (expected YYYY-MM-DD)\n" \
|
|
50
|
+
' Fix: git fit ability compute --as-of 2025-03-15'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
as_of
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load_rows
|
|
57
|
+
rows = @db[:activities]
|
|
58
|
+
.order(:start_date_local)
|
|
59
|
+
.select(:run_id, :start_date_local, :sport_category, :distance, :moving_time)
|
|
60
|
+
.all
|
|
61
|
+
# Causal filter: undated rows cannot be proven to precede the cutoff — excluded.
|
|
62
|
+
rows = rows.select { |r| Snapshot.date_of(r) && Snapshot.date_of(r) <= @as_of } if @as_of
|
|
63
|
+
dedup_aware(rows)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mirrors Export::Calculator#dedup_aware_totals: grouped activities drop out of
|
|
67
|
+
# the unique set; each group contributes one representative with per-field medians.
|
|
68
|
+
def dedup_aware(rows)
|
|
69
|
+
return rows unless @db.table_exists?(:dedup_groups) && @db.table_exists?(:dedup_group_members)
|
|
70
|
+
|
|
71
|
+
grouped_ids = @db[:dedup_group_members].select_map(:run_id)
|
|
72
|
+
unique = rows.reject { |r| grouped_ids.include?(r[:run_id]) }
|
|
73
|
+
by_run_id = rows.each_with_object({}) { |r, h| h[r[:run_id]] = r }
|
|
74
|
+
@db[:dedup_groups].select_map(:group_id).each do |gid|
|
|
75
|
+
members = @db[:dedup_group_members].where(group_id: gid).select_map(:run_id)
|
|
76
|
+
.filter_map { |rid| by_run_id[rid] }
|
|
77
|
+
next if members.empty?
|
|
78
|
+
|
|
79
|
+
unique << representative(members)
|
|
80
|
+
end
|
|
81
|
+
unique.sort_by { |r| Snapshot.date_of(r).to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def representative(members)
|
|
85
|
+
first = members.first
|
|
86
|
+
first.merge(
|
|
87
|
+
distance: median(members.map { |r| r[:distance] }),
|
|
88
|
+
moving_time: median(members.map { |r| r[:moving_time] }),
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def median(values)
|
|
93
|
+
sorted = values.compact.sort
|
|
94
|
+
return nil if sorted.empty?
|
|
95
|
+
|
|
96
|
+
sorted[sorted.size / 2]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- fitness layer ---
|
|
100
|
+
|
|
101
|
+
def fitness_snapshots(rows)
|
|
102
|
+
base = {}
|
|
103
|
+
Snapshot::PERIOD_TYPES.each do |type|
|
|
104
|
+
buckets = bucketize(rows, type)
|
|
105
|
+
buckets.each { |key, period_rows| base[[type, key]] = fitness_fields(type, key, period_rows) }
|
|
106
|
+
end
|
|
107
|
+
out = []
|
|
108
|
+
Snapshot::PERIOD_TYPES.each do |type|
|
|
109
|
+
bucketize(rows, type).keys.sort.each do |key|
|
|
110
|
+
fields = base[[type, key]]
|
|
111
|
+
prev_key = Snapshot.previous_key(type, key)
|
|
112
|
+
prev = prev_key && base[[type, prev_key]]
|
|
113
|
+
out << fields.merge('trend_vs_prev' => prev ? trend_fields(fields, prev) : nil)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
out
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bucketize(rows, type)
|
|
120
|
+
rows.each_with_object({}) do |row, buckets|
|
|
121
|
+
date = Snapshot.date_of(row)
|
|
122
|
+
# Windowless rows (NULL/unparseable start_date_local) join only 'all'.
|
|
123
|
+
next if type != 'all' && date.nil?
|
|
124
|
+
|
|
125
|
+
key = type == 'all' ? 'all' : Snapshot.period_key(type, date)
|
|
126
|
+
(buckets[key] ||= []) << row
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fitness_fields(type, key, period_rows)
|
|
131
|
+
distances = period_rows.map { |r| r[:distance] }.compact
|
|
132
|
+
dates = period_rows.filter_map { |r| Snapshot.date_of(r) }
|
|
133
|
+
start_d, end_d = Snapshot.period_bounds(type, key, period_rows)
|
|
134
|
+
total_weeks = start_d ? Snapshot.week_count(start_d, end_d) : 0
|
|
135
|
+
active_weeks = dates.map { |d| Snapshot.week_key_of_date(Date.parse(d)) }.uniq.size
|
|
136
|
+
{
|
|
137
|
+
'period_key' => key,
|
|
138
|
+
'period_type' => type,
|
|
139
|
+
'volume_km' => (distances.sum / 1000.0).round(1),
|
|
140
|
+
'volume_s' => period_rows.map { |r| r[:moving_time] }.compact.sum,
|
|
141
|
+
'frequency' => total_weeks.zero? ? nil : (active_weeks.to_f / total_weeks).round(2),
|
|
142
|
+
'longest_km' => distances.empty? ? nil : (distances.max / 1000.0).round(1),
|
|
143
|
+
'confidence' => Snapshot.confidence_for(period_rows.size),
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def trend_fields(cur, prev)
|
|
148
|
+
{
|
|
149
|
+
'volume_pct' => pct_delta(cur['volume_km'], prev['volume_km']),
|
|
150
|
+
'frequency_pct' => pct_delta(cur['frequency'], prev['frequency']),
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def pct_delta(cur, prev)
|
|
155
|
+
return nil if prev.nil? || cur.nil? || prev.zero?
|
|
156
|
+
|
|
157
|
+
(((cur - prev) / prev.abs) * 100).round(1)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- sports layer ---
|
|
161
|
+
|
|
162
|
+
def sports_section(rows)
|
|
163
|
+
total_km = rows.sum { |r| r[:distance] || 0 } / 1000.0
|
|
164
|
+
TARGET_SPORTS.each_with_object({}) do |sport, section|
|
|
165
|
+
sport_rows = rows.select { |r| r[:sport_category] == sport }
|
|
166
|
+
section[sport] = { 'snapshots' => sport_snapshots(sport, sport_rows, total_km) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def sport_snapshots(sport, sport_rows, total_km)
|
|
171
|
+
Snapshot::PERIOD_TYPES.flat_map do |type|
|
|
172
|
+
buckets = bucketize(sport_rows, type)
|
|
173
|
+
buckets.keys.sort.map do |key|
|
|
174
|
+
period_rows = buckets[key]
|
|
175
|
+
sport_km = period_rows.sum { |r| r[:distance] || 0 } / 1000.0
|
|
176
|
+
{
|
|
177
|
+
'period_key' => key,
|
|
178
|
+
'period_type' => type,
|
|
179
|
+
'volume_km' => sport_km.round(1),
|
|
180
|
+
'share' => total_km.positive? ? (sport_km / total_km).round(2) : nil,
|
|
181
|
+
'pace_profile' => pace_profile(sport, period_rows),
|
|
182
|
+
'pr_equivalents' => pr_equivalents(sport, period_rows),
|
|
183
|
+
'confidence' => Snapshot.confidence_for(period_rows.size),
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# run: pace percentiles p80(easy)/p50/p40(fast) in s_per_km;
|
|
190
|
+
# ride: speed percentiles p20(easy)/p50/p80(fast) in km_h.
|
|
191
|
+
def pace_profile(sport, period_rows)
|
|
192
|
+
samples = period_rows.filter_map do |r|
|
|
193
|
+
d = r[:distance]
|
|
194
|
+
t = r[:moving_time]
|
|
195
|
+
next if d.nil? || t.nil? || d < MIN_VALID_DISTANCE_M || t < MIN_VALID_TIME_S
|
|
196
|
+
|
|
197
|
+
sport == 'ride' ? (d / 1000.0) / (t / 3600.0) : t / (d / 1000.0)
|
|
198
|
+
end.sort
|
|
199
|
+
return nil if samples.size < MIN_PACE_SAMPLES
|
|
200
|
+
|
|
201
|
+
if sport == 'ride'
|
|
202
|
+
{ 'p20' => Snapshot.percentile(samples, 20).round(1),
|
|
203
|
+
'p50' => Snapshot.percentile(samples, 50).round(1),
|
|
204
|
+
'p80' => Snapshot.percentile(samples, 80).round(1),
|
|
205
|
+
'unit' => 'km_h', 'sample_n' => samples.size }
|
|
206
|
+
else
|
|
207
|
+
{ 'p80' => Snapshot.percentile(samples, 80).round,
|
|
208
|
+
'p50' => Snapshot.percentile(samples, 50).round,
|
|
209
|
+
'p40' => Snapshot.percentile(samples, 40).round,
|
|
210
|
+
'unit' => 's_per_km', 'sample_n' => samples.size }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Riegel equivalents: direct result when an activity matches the target within
|
|
215
|
+
# ±2% (extrapolated: false); otherwise best prediction T2 = T1 * (D2/D1)^1.06
|
|
216
|
+
# — every prediction is extrapolated: true with its source label (contract
|
|
217
|
+
# sample: half -> full). Factors above 3.5x are low-trust; the prompt layer
|
|
218
|
+
# (git-fit#96) states that in prose, the flag itself stays honest.
|
|
219
|
+
def pr_equivalents(sport, period_rows)
|
|
220
|
+
targets = sport == 'ride' ? RIDE_TARGETS : RUN_TARGETS
|
|
221
|
+
valid = period_rows.select { |r| valid_effort?(r) }
|
|
222
|
+
targets.each_with_object({}) do |(label, target_d), out|
|
|
223
|
+
direct = valid.select { |r| (r[:distance] - target_d).abs <= target_d * DIRECT_MATCH_TOLERANCE }
|
|
224
|
+
.min_by { |r| r[:moving_time] }
|
|
225
|
+
if direct
|
|
226
|
+
out[label] = { 's' => direct[:moving_time], 'date' => Snapshot.date_of(direct),
|
|
227
|
+
'run_id' => direct[:run_id], 'extrapolated' => false }
|
|
228
|
+
next
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
prediction = valid.filter_map do |r|
|
|
232
|
+
factor = target_d / r[:distance]
|
|
233
|
+
[(r[:moving_time] * factor**RIEGEL_EXP).round, r]
|
|
234
|
+
end.min_by(&:first)
|
|
235
|
+
next unless prediction
|
|
236
|
+
|
|
237
|
+
seconds, source = prediction
|
|
238
|
+
out[label] = { 's' => seconds, 'extrapolated' => true,
|
|
239
|
+
'from' => nearest_label(targets, source[:distance]) }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def valid_effort?(row)
|
|
244
|
+
d = row[:distance]
|
|
245
|
+
t = row[:moving_time]
|
|
246
|
+
!d.nil? && !t.nil? && d >= MIN_VALID_DISTANCE_M && t >= MIN_VALID_TIME_S
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def nearest_label(targets, distance)
|
|
250
|
+
targets.min_by { |_, d| (d - distance).abs }.first
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Ability
|
|
7
|
+
# Period key math + statistical gates for ability snapshots (wiki/Ability).
|
|
8
|
+
# Wall-clock windowing: start_date_local with offset slices to local date,
|
|
9
|
+
# naive values are already UTC-stored — both cases take the first 10 chars.
|
|
10
|
+
module Snapshot
|
|
11
|
+
PERIOD_TYPES = %w[week month quarter year all].freeze
|
|
12
|
+
|
|
13
|
+
DATE_PREFIX_RE = /\A\d{4}-\d{2}-\d{2}/
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Wall-clock date prefix, or nil when the value is missing/unparseable
|
|
17
|
+
# (e.g. apple_health rows without start_date_local — windowless rows
|
|
18
|
+
# participate only in the timeless 'all' bucket).
|
|
19
|
+
def date_of(row)
|
|
20
|
+
raw = row[:start_date_local].to_s
|
|
21
|
+
raw.match?(DATE_PREFIX_RE) ? raw[0, 10] : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# week -> "2025-W12" (ISO cwyear/cweek, may cross calendar year)
|
|
25
|
+
# month -> "2025-03"; quarter -> "2025-Q1"; year -> "2025"; all -> "all"
|
|
26
|
+
def period_key(type, date)
|
|
27
|
+
case type
|
|
28
|
+
when 'week'
|
|
29
|
+
d = Date.parse(date)
|
|
30
|
+
format('%<year>04d-W%<week>02d', year: d.cwyear, week: d.cweek)
|
|
31
|
+
when 'month' then date[0, 7]
|
|
32
|
+
when 'quarter'
|
|
33
|
+
y = date[0, 4].to_i
|
|
34
|
+
m = date[5, 2].to_i
|
|
35
|
+
format('%<year>04d-Q%<quarter>d', year: y, quarter: (m - 1) / 3 + 1)
|
|
36
|
+
when 'year' then date[0, 4]
|
|
37
|
+
when 'all' then 'all'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def period_bounds(type, key, all_rows = nil)
|
|
42
|
+
case type
|
|
43
|
+
when 'week'
|
|
44
|
+
y, w = key.split('-W').map(&:to_i)
|
|
45
|
+
start_d = Date.commercial(y, w, 1)
|
|
46
|
+
[start_d, start_d + 6]
|
|
47
|
+
when 'month'
|
|
48
|
+
start_d = Date.parse("#{key}-01")
|
|
49
|
+
[start_d, start_d.next_month - 1]
|
|
50
|
+
when 'quarter'
|
|
51
|
+
y, q = key.split('-Q').map(&:to_i)
|
|
52
|
+
start_d = Date.new(y, (q - 1) * 3 + 1, 1)
|
|
53
|
+
[start_d, Date.new(y, q * 3, 1).next_month - 1]
|
|
54
|
+
when 'year'
|
|
55
|
+
start_d = Date.parse("#{key}-01-01")
|
|
56
|
+
[start_d, Date.new(key.to_i, 12, 31)]
|
|
57
|
+
when 'all'
|
|
58
|
+
dates = all_rows.filter_map { |r| date_of(r) }.sort
|
|
59
|
+
dates.empty? ? [nil, nil] : [Date.parse(dates.first), Date.parse(dates.last)]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Previous same-type key; nil for 'all' (no earlier peer).
|
|
64
|
+
def previous_key(type, key)
|
|
65
|
+
case type
|
|
66
|
+
when 'week'
|
|
67
|
+
y, w = key.split('-W').map(&:to_i)
|
|
68
|
+
week_key_of_date(Date.commercial(y, w, 1) - 7)
|
|
69
|
+
when 'month'
|
|
70
|
+
d = Date.parse("#{key}-01") << 1
|
|
71
|
+
format('%<year>04d-%<month>02d', year: d.year, month: d.month)
|
|
72
|
+
when 'quarter'
|
|
73
|
+
y, q = key.split('-Q').map(&:to_i)
|
|
74
|
+
if q == 1
|
|
75
|
+
format('%<year>04d-Q%<quarter>d', year: y - 1, quarter: 4)
|
|
76
|
+
else
|
|
77
|
+
format('%<year>04d-Q%<quarter>d', year: y, quarter: q - 1)
|
|
78
|
+
end
|
|
79
|
+
when 'year' then format('%<year>04d', year: key.to_i - 1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def week_key_of_date(date)
|
|
84
|
+
format('%<year>04d-W%<week>02d', year: date.cwyear, week: date.cweek)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Distinct ISO weeks overlapping [start, end].
|
|
88
|
+
def week_count(start_date, end_date)
|
|
89
|
+
keys = {}
|
|
90
|
+
(start_date..end_date).each { |d| keys[week_key_of_date(d)] = true }
|
|
91
|
+
keys.size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Sample-count confidence: <3 low (prompt-excluded), 3-9 medium, >=10 high.
|
|
95
|
+
def confidence_for(count)
|
|
96
|
+
if count < 3
|
|
97
|
+
'low'
|
|
98
|
+
elsif count < 10
|
|
99
|
+
'medium'
|
|
100
|
+
else
|
|
101
|
+
'high'
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Nearest-rank percentile of ascending-sorted values.
|
|
106
|
+
def percentile(sorted_values, percent)
|
|
107
|
+
return nil if sorted_values.empty?
|
|
108
|
+
|
|
109
|
+
sorted_values[((percent / 100.0) * sorted_values.size).ceil - 1]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Athlete ability snapshots — two-layer (fitness + per-sport) derived profile.
|
|
4
|
+
# Design: wiki/Ability (workouts#49); contract: git-fit#95.
|
|
5
|
+
|
|
6
|
+
require_relative 'ability/snapshot'
|
|
7
|
+
require_relative 'ability/computer'
|
|
8
|
+
|
|
9
|
+
module GitFit
|
|
10
|
+
module Ability
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
class AbilityCLI < Thor
|
|
9
|
+
desc 'compute', 'Compute athlete ability snapshots (fitness + per-sport) to ability.json'
|
|
10
|
+
option :output, type: :string, aliases: '-o', desc: 'Output path'
|
|
11
|
+
option :as_of, type: :string, desc: 'Causal cutoff YYYY-MM-DD — activities after are excluded (digest use)'
|
|
12
|
+
|
|
13
|
+
no_commands do
|
|
14
|
+
def git_fit_config
|
|
15
|
+
@git_fit_config ||= GitFit::Config.new(options[:config])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def compute
|
|
20
|
+
config = git_fit_config
|
|
21
|
+
conn = GitFit::DB::Connection.new(config.db_path)
|
|
22
|
+
conn.migrate!
|
|
23
|
+
path = options[:output] || config.export_config.dig('ability', 'path') || 'site/ability.json'
|
|
24
|
+
doc = Ability::Computer.new(db: conn.db, as_of: options[:as_of]).call
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
File.write(path, "#{JSON.pretty_generate(doc)}\n")
|
|
27
|
+
count = doc['fitness']['snapshots'].size
|
|
28
|
+
say_status :done, "Ability snapshots -> #{path} (fitness: #{count} periods)", :green
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
say_status :error, "Ability compute failed: #{e.message}", :red
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc 'show', 'Print a single ability snapshot by period key'
|
|
34
|
+
option :period, type: :string, required: true, desc: 'Period key, e.g. 2025-03 / 2025-Q1 / 2025-W12 / 2025 / all'
|
|
35
|
+
option :output, type: :string, aliases: '-o', desc: 'ability.json path to read'
|
|
36
|
+
|
|
37
|
+
def show
|
|
38
|
+
path = options[:output] || git_fit_config.export_config.dig('ability', 'path') || 'site/ability.json'
|
|
39
|
+
doc = JSON.parse(File.read(path))
|
|
40
|
+
snapshot = find_snapshot(doc, options[:period])
|
|
41
|
+
if snapshot
|
|
42
|
+
puts JSON.pretty_generate(snapshot)
|
|
43
|
+
else
|
|
44
|
+
say_status :warn, "Ability: no snapshot for period '#{options[:period]}' in #{path}", :yellow
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
say_status :error, "Ability show failed: #{e.message}", :red
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def find_snapshot(doc, period_key)
|
|
53
|
+
candidates = (doc.dig('fitness', 'snapshots') || []) +
|
|
54
|
+
(doc['sports'] || {}).values.flat_map { |s| s['snapshots'] || [] }
|
|
55
|
+
matches = candidates.select { |s| s['period_key'] == period_key }
|
|
56
|
+
return matches.first if matches.one?
|
|
57
|
+
|
|
58
|
+
matches # both fitness and sport layers may carry the key — print all
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -23,6 +23,9 @@ module GitFit
|
|
|
23
23
|
desc 'export SUBCOMMAND', 'Export activities to various formats'
|
|
24
24
|
subcommand 'export', ExportCLI
|
|
25
25
|
|
|
26
|
+
desc 'ability SUBCOMMAND', 'Athlete ability snapshots (fitness + per-sport, wiki/Ability)'
|
|
27
|
+
subcommand 'ability', AbilityCLI
|
|
28
|
+
|
|
26
29
|
desc 'mcp serve', 'Start the MCP stdio server (read-only workouts data for AI clients)'
|
|
27
30
|
subcommand 'mcp', McpCLI
|
|
28
31
|
|
data/lib/git_fit/config.rb
CHANGED
|
@@ -31,6 +31,7 @@ module GitFit
|
|
|
31
31
|
'language' => 'zh',
|
|
32
32
|
'timeout' => 60,
|
|
33
33
|
'style' => '',
|
|
34
|
+
'ability_disabled' => false, # skip ability baseline injection (wiki/Ability)
|
|
34
35
|
'prompts' => {}, # scenario => template override; multi-line, intentionally not env-overridable
|
|
35
36
|
},
|
|
36
37
|
'system' => {
|
|
@@ -139,8 +140,10 @@ module GitFit
|
|
|
139
140
|
if ENV['GIT_FIT_AI_TIMEOUT'] && !ENV['GIT_FIT_AI_TIMEOUT'].empty?
|
|
140
141
|
@data['ai']['timeout'] = typed_value(ENV['GIT_FIT_AI_TIMEOUT'])
|
|
141
142
|
end
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
@data['ai']['style'] = ENV['GIT_FIT_AI_STYLE'] if ENV['GIT_FIT_AI_STYLE'] && !ENV['GIT_FIT_AI_STYLE'].empty?
|
|
144
|
+
return unless ENV['GIT_FIT_ABILITY_DISABLED'] && !ENV['GIT_FIT_ABILITY_DISABLED'].empty?
|
|
145
|
+
|
|
146
|
+
@data['ai']['ability_disabled'] = typed_value(ENV['GIT_FIT_ABILITY_DISABLED'])
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
def validate!
|
data/lib/git_fit/export/ai.rb
CHANGED
|
@@ -42,6 +42,7 @@ module GitFit
|
|
|
42
42
|
rows = activity_rows
|
|
43
43
|
return warn_skip('no activities in database') if rows.empty?
|
|
44
44
|
|
|
45
|
+
@ability_context = build_ability_context
|
|
45
46
|
digest = source_digest(stats, rows)
|
|
46
47
|
return warn_skip('insights unchanged (source_digest match)') if unchanged?(digest)
|
|
47
48
|
|
|
@@ -121,8 +122,10 @@ module GitFit
|
|
|
121
122
|
end
|
|
122
123
|
|
|
123
124
|
# Digest covers everything that changes the output: data, prompt mode,
|
|
124
|
-
# language and
|
|
125
|
-
#
|
|
125
|
+
# language, model and the ability baseline (derived from the full table —
|
|
126
|
+
# changes beyond the recent-activity window must invalidate the digest).
|
|
127
|
+
# Prompt template edits are intentionally NOT tracked — delete
|
|
128
|
+
# insights.json to force regeneration.
|
|
126
129
|
def source_digest(stats, lines)
|
|
127
130
|
payload = ::JSON.generate(
|
|
128
131
|
'model' => @client.model,
|
|
@@ -131,6 +134,7 @@ module GitFit
|
|
|
131
134
|
'period' => period_label,
|
|
132
135
|
'stats' => stats,
|
|
133
136
|
'activities' => lines,
|
|
137
|
+
'ability' => @ability_context,
|
|
134
138
|
)
|
|
135
139
|
"sha256:#{Digest::SHA256.hexdigest(payload)}"
|
|
136
140
|
end
|
|
@@ -226,6 +230,7 @@ module GitFit
|
|
|
226
230
|
'today' => Time.now.strftime('%Y-%m-%d'),
|
|
227
231
|
'stats' => ::JSON.generate(stats),
|
|
228
232
|
'activities_summary' => activity_lines.join("\n"),
|
|
233
|
+
'ability_context' => @ability_context,
|
|
229
234
|
'language' => @language,
|
|
230
235
|
}
|
|
231
236
|
end
|
|
@@ -239,6 +244,74 @@ module GitFit
|
|
|
239
244
|
[{ 'role' => 'system', 'content' => content }]
|
|
240
245
|
end
|
|
241
246
|
|
|
247
|
+
# --- ability baseline (wiki/Ability, git-fit#96) ---
|
|
248
|
+
|
|
249
|
+
# One compact block (<=150 tokens) injected into every scenario template.
|
|
250
|
+
# Failures degrade to empty string — never block the export.
|
|
251
|
+
def build_ability_context
|
|
252
|
+
return '' if @config.ai_config['ability_disabled']
|
|
253
|
+
|
|
254
|
+
doc = Ability::Computer.new(db: @db).call
|
|
255
|
+
month = doc.dig('fitness', 'snapshots')
|
|
256
|
+
&.select { |s| s['period_type'] == 'month' }
|
|
257
|
+
&.max_by { |s| s['period_key'] }
|
|
258
|
+
return '' unless month
|
|
259
|
+
|
|
260
|
+
lines = ['运动员能力基线(系统派生估算,仅作参考):']
|
|
261
|
+
lines << fitness_line(month)
|
|
262
|
+
doc['sports'].each do |sport, layer|
|
|
263
|
+
sport_line = sport_line(sport, layer)
|
|
264
|
+
lines << sport_line unless sport_line.empty?
|
|
265
|
+
end
|
|
266
|
+
lines << '样本不足,评价置信度低' if month['confidence'] == 'low'
|
|
267
|
+
lines.join("\n")
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
warn "AI: ability context unavailable (#{e.class.name.split('::').last}: #{e.message.to_s[0, 120]})"
|
|
270
|
+
''
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def fitness_line(month)
|
|
274
|
+
parts = ["基础体能(#{month['period_key']}):月量 #{month['volume_km']}km"]
|
|
275
|
+
parts << "活跃周 #{(month['frequency'] * 100).round}%" if month['frequency']
|
|
276
|
+
parts << "最长单次 #{month['longest_km']}km" if month['longest_km']
|
|
277
|
+
trend = month.dig('trend_vs_prev', 'volume_pct')
|
|
278
|
+
parts << "距上月量 #{trend.positive? ? '+' : ''}#{trend}%" if trend
|
|
279
|
+
parts.join(' · ')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def sport_line(sport, layer)
|
|
283
|
+
month = layer['snapshots'].to_a.select { |s| s['period_type'] == 'month' }
|
|
284
|
+
.max_by { |s| s['period_key'] }
|
|
285
|
+
return '' unless month
|
|
286
|
+
|
|
287
|
+
parts = []
|
|
288
|
+
parts << pace_part(month['pace_profile']) if month['pace_profile']
|
|
289
|
+
prs = direct_prs(month['pr_equivalents'])
|
|
290
|
+
parts << "等效 #{prs.map { |label, entry| "#{label} #{format_clock(entry['s'])}" }.join(' / ')}" unless prs.empty?
|
|
291
|
+
return '' if parts.empty?
|
|
292
|
+
|
|
293
|
+
"#{sport}(#{month['period_key']}):#{parts.join(' · ')}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def pace_part(profile)
|
|
297
|
+
if profile['unit'] == 'km_h'
|
|
298
|
+
"easy #{profile['p20']}km/h(P20)"
|
|
299
|
+
else
|
|
300
|
+
"easy 配速 #{format_clock(profile['p80'])}/km(P80)"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def direct_prs(equivalents)
|
|
305
|
+
(equivalents || {}).select { |_, entry| entry['extrapolated'] == false }.first(2)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def format_clock(seconds)
|
|
309
|
+
s = seconds.to_i
|
|
310
|
+
return format('%<m>d:%<ss>02d', m: s / 60, ss: s % 60) if s < 3600
|
|
311
|
+
|
|
312
|
+
format('%<h>d:%<m>02d:%<ss>02d', h: s / 3600, m: (s % 3600) / 60, ss: s % 60)
|
|
313
|
+
end
|
|
314
|
+
|
|
242
315
|
# Models like glm-4-flash wrap JSON in ```json fences despite the
|
|
243
316
|
# contract — strip them before parsing.
|
|
244
317
|
def strip_fences(text)
|
data/lib/git_fit/llm/prompts.rb
CHANGED
|
@@ -23,9 +23,13 @@ module GitFit
|
|
|
23
23
|
### 活动摘要
|
|
24
24
|
{{activities_summary}}
|
|
25
25
|
|
|
26
|
+
### 能力基线(系统派生估算,可能为空)
|
|
27
|
+
{{ability_context}}
|
|
28
|
+
|
|
26
29
|
## 写作要求
|
|
27
30
|
- 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;当期数据以 monthly/yearly 中 {{period}} 对应行为准,活动摘要可用作补充
|
|
28
31
|
- 今天是 {{today}}。若活动摘要中出现 {{period}} 之后的当月数据,那是不完整的当月数据:仅在结尾以『最新动态』一句带过并明确标注,不纳入总结主体,禁止据此得出训练量骤降/中断等跨周期结论
|
|
32
|
+
- 能力基线非空时作为评价参照:结论应相对该基线(如本期量/配速相对基线的变化);基线为空或标注『样本不足』时禁止编造能力结论
|
|
29
33
|
- 用 {{language}} 撰写,Markdown 格式,正文 800 字以内
|
|
30
34
|
- 结构:先总后分 —— 总体负荷印象 → 亮点 → 隐忧
|
|
31
35
|
- 所有数字必须来自输入数据,禁止编造或外推
|
|
@@ -42,10 +46,14 @@ module GitFit
|
|
|
42
46
|
### 活动摘要
|
|
43
47
|
{{activities_summary}}
|
|
44
48
|
|
|
49
|
+
### 能力基线(系统派生估算,可能为空)
|
|
50
|
+
{{ability_context}}
|
|
51
|
+
|
|
45
52
|
## 分析要求
|
|
46
53
|
- 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;对比当期与历史时以 monthly/yearly 分组行为准
|
|
47
54
|
- 今天是 {{today}}。当月不完整的数据(如月内仅数天)只可作为『最新动态』级别观察并明确标注,禁止据此输出骤降/中断等趋势性 anomaly
|
|
48
55
|
- 关注维度:趋势变化、个人纪录(PB)、疲劳信号、季节性规律、运动项目占比变化
|
|
56
|
+
- 能力基线非空时作为评价参照(如异常判断相对基线水平);基线为空或标注『样本不足』时禁止编造能力结论
|
|
49
57
|
- 输出 3-6 条,按重要性排序
|
|
50
58
|
- 数据不足以支撑的结论不要输出;无异常时给少量 info 条目即可,禁止强行制造发现
|
|
51
59
|
- 每条结论必须引用具体数字,并给出其来源路径(如 monthly.2026-08.ride.distance)
|
|
@@ -62,10 +70,14 @@ module GitFit
|
|
|
62
70
|
### 活动摘要
|
|
63
71
|
{{activities_summary}}
|
|
64
72
|
|
|
73
|
+
### 能力基线(系统派生估算,可能为空)
|
|
74
|
+
{{ability_context}}
|
|
75
|
+
|
|
65
76
|
## 建议要求
|
|
66
77
|
- 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;当期负荷以 monthly/yearly 中 {{period}} 对应行为准
|
|
67
78
|
- 今天是 {{today}}。不完整的当月数据不足以支撑训练量调整类建议;如有,仅作观察性提示并标注
|
|
68
79
|
- 输出 2-4 条可执行建议,每条说明数据依据
|
|
80
|
+
- 能力基线非空时建议强度应相对该基线(渐进而非跳级);基线为空或标注『样本不足』时禁止编造能力结论
|
|
69
81
|
- 除非数据明确支持(如连续多周稳定负荷且无疲劳信号),不建议提高训练量
|
|
70
82
|
- 关注一致性、恢复与渐进,而非单次表现
|
|
71
83
|
- 用 {{language}} 撰写
|
data/lib/git_fit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-fit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.25.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -273,6 +273,9 @@ files:
|
|
|
273
273
|
- db/migrations/001_full_schema.rb
|
|
274
274
|
- exe/git-fit
|
|
275
275
|
- lib/git-fit.rb
|
|
276
|
+
- lib/git_fit/ability.rb
|
|
277
|
+
- lib/git_fit/ability/computer.rb
|
|
278
|
+
- lib/git_fit/ability/snapshot.rb
|
|
276
279
|
- lib/git_fit/auth/garmin.rb
|
|
277
280
|
- lib/git_fit/auth/garmin/di_exchange.rb
|
|
278
281
|
- lib/git_fit/auth/garmin/login_session.rb
|
|
@@ -282,6 +285,7 @@ files:
|
|
|
282
285
|
- lib/git_fit/auth/paths.rb
|
|
283
286
|
- lib/git_fit/auth/strava.rb
|
|
284
287
|
- lib/git_fit/cli.rb
|
|
288
|
+
- lib/git_fit/cli/ability.rb
|
|
285
289
|
- lib/git_fit/cli/checkpointable.rb
|
|
286
290
|
- lib/git_fit/cli/elevation.rb
|
|
287
291
|
- lib/git_fit/cli/export.rb
|