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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c834f3b0e5e85120e6e29fe14cf32ebdfe6893f9a6e247baf574b69ffe5ecf79
4
- data.tar.gz: 7cd64d82a9fa70fe3e614c7566b2262dbcde8d7c70ee0e65f9e6435ee0d1c234
3
+ metadata.gz: 0b5ac295fe113517b660831a4cdaf826ac0905781ccdc591174117e0ec91973b
4
+ data.tar.gz: ecafcdedbdb2469570e946bd2b885d2605d92bf43a65fed7668f0074709a9a09
5
5
  SHA512:
6
- metadata.gz: 714b7fdaf63da4e029372996514590b53b1866d561ca45a22b41ed2bd3bc7d0eda524f96156b44b2794a460946c5e6ae20eb78ba01b9e143d60aaf6fbd8fb14e
7
- data.tar.gz: 48a886f80f97ccd1c7d93c00490f061480f67d32e3254d9d15b4800ca18bdb0454cd998b7cb1891aabe796185ad2d950b6cfb22e3ba43338158de21559db1ae9
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
 
@@ -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
- return unless ENV['GIT_FIT_AI_STYLE'] && !ENV['GIT_FIT_AI_STYLE'].empty?
143
- @data['ai']['style'] = ENV['GIT_FIT_AI_STYLE']
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!
@@ -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 model. Prompt template edits are intentionally NOT
125
- # tracked delete insights.json to force regeneration.
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)
@@ -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}} 撰写
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.24.3'
4
+ VERSION = '0.25.0'
5
5
  end
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.24.3
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