git-fit 0.21.4 → 0.22.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: ee59f447feece2b79c93671bd58f34374178d6b7e1f17ad6444f6b1d07a906c3
4
- data.tar.gz: 35493309be5989b82b7b27dcee0821befc8f42fe86d68c0f1304444e63461ab3
3
+ metadata.gz: 1f9fda747ee0bdc1b1d6588bce636576cb8e371ea0ae36b2bcfedd377a516891
4
+ data.tar.gz: e19094889528196ead4715706a46fe035516d219ba2c69264cf32e4fc0820da6
5
5
  SHA512:
6
- metadata.gz: da503a2a3edfad88f2de9dd5018be3955f254316630aa507a2a58b7c55632001c1b942422c1f306850f1792d7858298a4c0c3cfbf19bf5f8559e696480fee60c
7
- data.tar.gz: ffa50cac4cb4ef2534408cc971c6f75bb61c4b2e921146cdf3d58e647c7e549c3e57e5e87c082f504bc9addd6b8d28878e6e0649632dfadb7b4aa4b32cd71599
6
+ metadata.gz: a3fec0c7cfe0f51d7087698900585e480b9ce52d73aa6a8238cfa2493d5500e9d20e93087dda785da91844b3099ea1c4d36b4e642dbd867187d94843ae63b466
7
+ data.tar.gz: b98ec2e23dc34c3c5694d4627c6ae12a153a2b185d76f0f9567903c2e39a4ff6bb7995ff928e4b17ef4978cdd0a03485e38ae41ef17d9f95bf63f16870d2324b
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
1
4
  Sequel.migration do
2
5
  up do
3
6
  create_table?(:activities) do
@@ -7,7 +10,7 @@ Sequel.migration do
7
10
  Float :distance
8
11
  Integer :moving_time
9
12
  Integer :elapsed_time
10
- String :sport_category, null: false, default: "run"
13
+ String :sport_category, null: false, default: 'run'
11
14
  String :sport_type
12
15
  String :start_date
13
16
  String :start_date_local
@@ -23,6 +26,14 @@ Sequel.migration do
23
26
  Integer :calories
24
27
  Float :average_speed
25
28
  Float :elevation_gain
29
+ Float :elevation_loss
30
+ Float :elevation_min
31
+ Float :elevation_max
32
+ Float :elevation_gain_terrain
33
+ Float :elevation_loss_terrain
34
+ Float :elevation_min_terrain
35
+ Float :elevation_max_terrain
36
+ Integer :steps
26
37
  String :source
27
38
  String :duplicate_info, text: true
28
39
  String :divisions, text: true
@@ -62,3 +73,4 @@ Sequel.migration do
62
73
  drop_table?(:activities)
63
74
  end
64
75
  end
76
+ # rubocop:enable Metrics/BlockLength
data/lib/git-fit.rb CHANGED
@@ -61,6 +61,7 @@ require_relative 'git_fit/import'
61
61
  require_relative 'git_fit/sync/base'
62
62
  require_relative 'git_fit/std/resolver'
63
63
  require_relative 'git_fit/util'
64
+ require_relative 'git_fit/llm'
64
65
  require_relative 'git_fit/sync/lorem'
65
66
  require_relative 'git_fit/sync/garmin_base'
66
67
  require_relative 'git_fit/sync/garmin_base_di'
@@ -78,5 +78,36 @@ module GitFit
78
78
  rescue StandardError => e
79
79
  say_status :error, "Stats export failed: #{e.message}", :red
80
80
  end
81
+
82
+ desc 'ai', 'Export AI insights to insights.json (skips with warn when ai: disabled — never blocks CI)'
83
+ option :output, type: :string, aliases: '-o', desc: 'Output path'
84
+ option :period, type: :string, default: 'monthly', enum: %w[monthly yearly]
85
+ option :lang, type: :string, desc: 'Output language override (default: ai.language)'
86
+
87
+ def ai
88
+ config = git_fit_config
89
+ unless config.ai_config['enabled']
90
+ say_status :warn, 'AI: ai.enabled is false, skipping (Fix: set ai.enabled: true or GIT_FIT_AI_ENABLED=true)',
91
+ :yellow
92
+ return
93
+ end
94
+ client = GitFit::LLM::Client.from_config(config)
95
+ return if client.nil? # unreachable: disabled handled above
96
+
97
+ conn = GitFit::DB::Connection.new(config.db_path)
98
+ conn.migrate!
99
+ path = options[:output] || config.export_config.dig('ai', 'path') || 'site/insights.json'
100
+ doc = Export::AI.new(db: conn.db, config: config, client: client, output: path,
101
+ period: options[:period], language: options[:lang]).call
102
+ if doc
103
+ sections = %w[summary_markdown insights coaching].count { |k| doc[k] }
104
+ say_status :done, "Exported AI insights to #{path} (#{sections}/3 sections)", :green
105
+ else
106
+ say_status :warn, 'AI: nothing to do (empty db or insights unchanged)', :yellow
107
+ end
108
+ rescue GitFit::LLM::Error => e
109
+ # AI failures are non-fatal by contract — CI deploys must not break.
110
+ say_status :warn, e.message, :yellow
111
+ end
81
112
  end
82
113
  end
@@ -18,10 +18,21 @@ module GitFit
18
18
  'svg' => { 'dir' => 'site/svg' },
19
19
  'csv' => { 'path' => 'site/workouts.csv' },
20
20
  'gpx' => { 'path' => 'site/gpx_out' },
21
+ 'ai' => { 'path' => 'site/insights.json' },
21
22
  },
22
23
  'privacy' => {
23
24
  'start_end_range' => 200,
24
25
  },
26
+ 'ai' => {
27
+ 'enabled' => false,
28
+ 'endpoint' => nil, # OpenAI-compatible base URL, e.g. https://open.bigmodel.cn/api/paas/v4
29
+ 'model' => nil,
30
+ 'api_key_env' => 'AI_API_KEY',
31
+ 'language' => 'zh',
32
+ 'timeout' => 60,
33
+ 'style' => '',
34
+ 'prompts' => {}, # scenario => template override; multi-line, intentionally not env-overridable
35
+ },
25
36
  'system' => {
26
37
  'units' => 'metric',
27
38
  'log_level' => 'info',
@@ -29,7 +40,7 @@ module GitFit
29
40
  },
30
41
  }.freeze
31
42
 
32
- KNOWN_TOP_KEYS = %w[database sync export privacy system].freeze
43
+ KNOWN_TOP_KEYS = %w[database sync export privacy ai system].freeze
33
44
 
34
45
  # Default config file path: GIT_FIT_CONFIG_PATH env override, else root config.yml.
35
46
  def self.default_path
@@ -67,6 +78,10 @@ module GitFit
67
78
  @data['export'] || {}
68
79
  end
69
80
 
81
+ def ai_config
82
+ @data['ai'] || {}
83
+ end
84
+
70
85
  def db_path
71
86
  @data.dig('database', 'path') || DEFAULTS['database']['path']
72
87
  end
@@ -94,6 +109,9 @@ module GitFit
94
109
  @data['system']['timezone'] = ENV['GIT_FIT_SYSTEM_TIMEZONE']
95
110
  end
96
111
 
112
+ # ai: scalars only; ai.prompts templates are multi-line and config.yml-only.
113
+ load_ai_env
114
+
97
115
  GitFit.registered_configs.each do |reg|
98
116
  reg[:keys].each do |key|
99
117
  segments = key.is_a?(Array) ? key.map(&:to_s) : [key.to_s]
@@ -104,6 +122,27 @@ module GitFit
104
122
  end
105
123
  end
106
124
 
125
+ def load_ai_env
126
+ if ENV['GIT_FIT_AI_ENABLED'] && !ENV['GIT_FIT_AI_ENABLED'].empty?
127
+ @data['ai']['enabled'] = typed_value(ENV['GIT_FIT_AI_ENABLED'])
128
+ end
129
+ if ENV['GIT_FIT_AI_ENDPOINT'] && !ENV['GIT_FIT_AI_ENDPOINT'].empty?
130
+ @data['ai']['endpoint'] = ENV['GIT_FIT_AI_ENDPOINT']
131
+ end
132
+ @data['ai']['model'] = ENV['GIT_FIT_AI_MODEL'] if ENV['GIT_FIT_AI_MODEL'] && !ENV['GIT_FIT_AI_MODEL'].empty?
133
+ if ENV['GIT_FIT_AI_API_KEY_ENV'] && !ENV['GIT_FIT_AI_API_KEY_ENV'].empty?
134
+ @data['ai']['api_key_env'] = ENV['GIT_FIT_AI_API_KEY_ENV']
135
+ end
136
+ if ENV['GIT_FIT_AI_LANGUAGE'] && !ENV['GIT_FIT_AI_LANGUAGE'].empty?
137
+ @data['ai']['language'] = ENV['GIT_FIT_AI_LANGUAGE']
138
+ end
139
+ if ENV['GIT_FIT_AI_TIMEOUT'] && !ENV['GIT_FIT_AI_TIMEOUT'].empty?
140
+ @data['ai']['timeout'] = typed_value(ENV['GIT_FIT_AI_TIMEOUT'])
141
+ end
142
+ return unless ENV['GIT_FIT_AI_STYLE'] && !ENV['GIT_FIT_AI_STYLE'].empty?
143
+ @data['ai']['style'] = ENV['GIT_FIT_AI_STYLE']
144
+ end
145
+
107
146
  def validate!
108
147
  @data.each_key do |k|
109
148
  next if k.empty? || KNOWN_TOP_KEYS.include?(k)
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module GitFit
8
+ module Export
9
+ # Builds insights.json — the AI narrative layer on top of stats (#40, git-fit#93).
10
+ # Contract (wiki/AI): generated_at / period / source_digest / model / language /
11
+ # summary_markdown / insights[] / coaching[]
12
+ #
13
+ # Cost guardrails:
14
+ # - input is summary-level (~2-5k tokens, ≤200 activity lines)
15
+ # - source_digest unchanged vs existing file -> zero LLM calls
16
+ # - per-scenario retry 1 -> warn + omit that section (never raises to caller)
17
+ class AI
18
+ SCENARIOS = %i[monthly_report insights coaching].freeze
19
+ ACTIVITY_LIMIT = 200
20
+ INSIGHT_TYPES = %w[trend anomaly pb consistency season].freeze
21
+ INSIGHT_SEVERITIES = %w[info positive warning].freeze
22
+ COACHING_PRIORITIES = %w[low medium high].freeze
23
+
24
+ # Raised when a scenario response violates the insights.json contract.
25
+ class ValidationError < StandardError; end
26
+
27
+ def initialize(db:, config:, client:, output:, period: 'monthly', language: nil)
28
+ @db = db
29
+ @config = config
30
+ @client = client
31
+ @output = output
32
+ unless %w[monthly yearly].include?(period.to_s)
33
+ raise ArgumentError, "AI: unknown period '#{period}'\n Fix: use --period monthly|yearly"
34
+ end
35
+ @period = period.to_s
36
+ @language = language || config.ai_config['language'] || 'zh'
37
+ end
38
+
39
+ # Returns the written doc hash, or nil when skipped (empty db / digest match).
40
+ def call
41
+ stats = Calculator.new(db: @db).call
42
+ rows = activity_rows
43
+ return warn_skip('no activities in database') if rows.empty?
44
+
45
+ digest = source_digest(stats, rows)
46
+ return warn_skip('insights unchanged (source_digest match)') if unchanged?(digest)
47
+
48
+ sections = {}
49
+ SCENARIOS.each do |scenario|
50
+ sections[scenario] = generate_scenario(scenario, stats, rows)
51
+ end
52
+
53
+ doc = base_doc(digest)
54
+ doc['summary_markdown'] = sections[:monthly_report]['summary_markdown'] if sections[:monthly_report]
55
+ doc['insights'] = sections[:insights]['insights'] if sections[:insights]
56
+ doc['coaching'] = sections[:coaching]['coaching'] if sections[:coaching]
57
+
58
+ File.write(@output, "#{::JSON.pretty_generate(doc)}\n")
59
+ doc
60
+ end
61
+
62
+ private
63
+
64
+ def warn_skip(reason)
65
+ warn "AI: skipping export — #{reason}"
66
+ nil
67
+ end
68
+
69
+ # --- input digest ---
70
+
71
+ def activity_rows
72
+ @activity_rows ||= @db[:activities]
73
+ .order(Sequel.desc(:start_date_local))
74
+ .limit(ACTIVITY_LIMIT)
75
+ .select(:start_date_local, :sport_category, :distance,
76
+ :moving_time, :average_heartrate, :elevation_gain)
77
+ .all
78
+ end
79
+
80
+ # Compact one-line-per-activity summary — keeps the prompt in the
81
+ # ~2-5k token budget (distance km / time min / HR bpm / ascent m).
82
+ def activity_lines
83
+ activity_rows.map do |r|
84
+ parts = [r[:start_date_local].to_s[0, 10], r[:sport_category].to_s]
85
+ parts << format('%.1fkm', r[:distance] / 1000.0) if r[:distance]
86
+ parts << "#{(r[:moving_time] / 60).round}min" if r[:moving_time]
87
+ parts << "HR#{r[:average_heartrate].round}" if r[:average_heartrate]
88
+ parts << "asc#{r[:elevation_gain].round}m" if r[:elevation_gain]
89
+ parts.join(' ')
90
+ end
91
+ end
92
+
93
+ def period_label
94
+ latest = activity_rows.find { |r| r[:start_date_local] }
95
+ base = latest ? latest[:start_date_local].to_s : Time.now.utc.iso8601
96
+ @period == 'yearly' ? base[0, 4] : base[0, 7]
97
+ end
98
+
99
+ # Digest covers everything that changes the output: data, prompt mode,
100
+ # language and model. Prompt template edits are intentionally NOT
101
+ # tracked — delete insights.json to force regeneration.
102
+ def source_digest(stats, lines)
103
+ payload = ::JSON.generate(
104
+ 'model' => @client.model,
105
+ 'language' => @language,
106
+ 'period_mode' => @period,
107
+ 'stats' => stats,
108
+ 'activities' => lines,
109
+ )
110
+ "sha256:#{Digest::SHA256.hexdigest(payload)}"
111
+ end
112
+
113
+ def unchanged?(digest)
114
+ return false unless File.exist?(@output)
115
+
116
+ existing = ::JSON.parse(File.read(@output))
117
+ existing.is_a?(Hash) && existing['source_digest'] == digest
118
+ rescue JSON::ParserError, TypeError
119
+ false
120
+ end
121
+
122
+ # --- generation ---
123
+
124
+ def generate_scenario(scenario, stats, rows)
125
+ template = LLM::Prompts.resolve(scenario, @config.ai_config['prompts'])
126
+ rendered = LLM::Template.render(template, slots(stats, rows))
127
+ messages = system_messages + [{ 'role' => 'user', 'content' => rendered }]
128
+
129
+ last_error = nil
130
+ 2.times do
131
+ parsed = parse_model_json(@client.chat(messages: messages))
132
+ validate_section!(scenario, parsed)
133
+ return parsed
134
+ rescue GitFit::LLM::Error, ::JSON::ParserError, ValidationError => e
135
+ last_error = e
136
+ end
137
+ warn "AI: scenario #{scenario} failed after retry, omitting section " \
138
+ "(#{last_error.class.name.split('::').last}: #{last_error.message.to_s[0, 120]})"
139
+ nil
140
+ end
141
+
142
+ # Parse model output: strip fences, then fall back to a repair pass —
143
+ # models like glm-4-flash emit literal newlines inside string values
144
+ # (invalid JSON; the spec requires \n escapes), e.g. long markdown in
145
+ # summary_markdown. The repair walks the text tracking string state and
146
+ # escapes control characters inside strings; newlines outside strings
147
+ # (pretty formatting) are legal and kept as-is.
148
+ def parse_model_json(raw)
149
+ body = strip_fences(raw)
150
+ begin
151
+ ::JSON.parse(body)
152
+ rescue ::JSON::ParserError
153
+ ::JSON.parse(repair_json(body))
154
+ end
155
+ end
156
+
157
+ def repair_json(text)
158
+ out = +''
159
+ in_string = false
160
+ escape = false
161
+ text.each_char do |ch|
162
+ if in_string && escape
163
+ escape = false
164
+ out << ch
165
+ elsif in_string && ch == '\\'
166
+ escape = true
167
+ out << ch
168
+ elsif in_string && ch == '"'
169
+ in_string = false
170
+ out << ch
171
+ elsif in_string && ch == "\n"
172
+ out << '\\n'
173
+ elsif in_string && ch == "\t"
174
+ out << '\\t'
175
+ elsif in_string && ch == "\r"
176
+ out << '\\r'
177
+ else
178
+ in_string = true if !in_string && ch == '"'
179
+ out << ch
180
+ end
181
+ end
182
+ out
183
+ end
184
+
185
+ def slots(stats, _rows)
186
+ {
187
+ 'period' => period_label,
188
+ 'stats' => ::JSON.generate(stats),
189
+ 'activities_summary' => activity_lines.join("\n"),
190
+ 'language' => @language,
191
+ }
192
+ end
193
+
194
+ # ai.style is a site-owner directive — belongs in the system message,
195
+ # never in the user template (user templates are user-overridable).
196
+ def system_messages
197
+ content = "你是运动数据产品 workouts 的分析助手,输出语言:#{@language}。"
198
+ style = @config.ai_config['style'].to_s.strip
199
+ content += "\n站主附加指令:#{style}" unless style.empty?
200
+ [{ 'role' => 'system', 'content' => content }]
201
+ end
202
+
203
+ # Models like glm-4-flash wrap JSON in ```json fences despite the
204
+ # contract — strip them before parsing.
205
+ def strip_fences(text)
206
+ text.to_s.strip
207
+ .sub(/\A```(?:json)?\s*/m, '')
208
+ .sub(/```\s*\z/m, '')
209
+ .strip
210
+ end
211
+
212
+ # --- contract validation ---
213
+
214
+ def validate_section!(scenario, parsed)
215
+ case scenario
216
+ when :monthly_report then validate_summary(parsed)
217
+ when :insights then validate_insights(parsed)
218
+ when :coaching then validate_coaching(parsed)
219
+ end
220
+ end
221
+
222
+ def validate_summary(parsed)
223
+ summary = parsed.is_a?(Hash) ? parsed['summary_markdown'] : nil
224
+ return if summary.is_a?(String) && !summary.strip.empty?
225
+
226
+ raise ValidationError, 'missing or empty summary_markdown'
227
+ end
228
+
229
+ def validate_insights(parsed)
230
+ items = parsed.is_a?(Hash) ? parsed['insights'] : nil
231
+ raise ValidationError, 'missing insights array' unless items.is_a?(Array)
232
+
233
+ items.each_with_index { |item, i| validate_insight_item(item, i) }
234
+ end
235
+
236
+ def validate_insight_item(item, index)
237
+ raise ValidationError, "insights[#{index}] is not an object" unless item.is_a?(Hash)
238
+
239
+ unless INSIGHT_TYPES.include?(item['type'])
240
+ raise ValidationError, "insights[#{index}].type '#{item['type']}' not in #{INSIGHT_TYPES.join('|')}"
241
+ end
242
+ unless INSIGHT_SEVERITIES.include?(item['severity'])
243
+ raise ValidationError,
244
+ "insights[#{index}].severity '#{item['severity']}' not in #{INSIGHT_SEVERITIES.join('|')}"
245
+ end
246
+ %w[id title body].each do |field|
247
+ unless item[field].is_a?(String) && !item[field].empty?
248
+ raise ValidationError, "insights[#{index}].#{field} missing or empty"
249
+ end
250
+ end
251
+ item['data_refs'] = item['data_refs'].is_a?(Array) ? item['data_refs'] : []
252
+ end
253
+
254
+ def validate_coaching(parsed)
255
+ items = parsed.is_a?(Hash) ? parsed['coaching'] : nil
256
+ raise ValidationError, 'missing coaching array' unless items.is_a?(Array)
257
+
258
+ items.each_with_index do |item, i|
259
+ raise ValidationError, "coaching[#{i}] is not an object" unless item.is_a?(Hash)
260
+
261
+ unless COACHING_PRIORITIES.include?(item['priority'])
262
+ raise ValidationError,
263
+ "coaching[#{i}].priority '#{item['priority']}' not in #{COACHING_PRIORITIES.join('|')}"
264
+ end
265
+ %w[title body].each do |field|
266
+ unless item[field].is_a?(String) && !item[field].empty?
267
+ raise ValidationError, "coaching[#{i}].#{field} missing or empty"
268
+ end
269
+ end
270
+ end
271
+ end
272
+
273
+ # --- output ---
274
+
275
+ def base_doc(digest)
276
+ {
277
+ 'generated_at' => Time.now.utc.iso8601,
278
+ 'period' => period_label,
279
+ 'source_digest' => digest,
280
+ 'model' => @client.model,
281
+ 'language' => @language,
282
+ }
283
+ end
284
+ end
285
+ end
286
+ end
@@ -11,3 +11,4 @@ require_relative 'export/csv'
11
11
  require_relative 'export/gpx'
12
12
  require_relative 'export/calculator'
13
13
  require_relative 'export/stats'
14
+ require_relative 'export/ai'
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module GitFit
7
+ module LLM
8
+ # OpenAI-compatible chat-completions client (POST {endpoint}/chat/completions).
9
+ #
10
+ # Provider-agnostic: endpoint / model / api key all come from the config
11
+ # `ai:` section — the gem never hardcodes a vendor. Non-streaming (P1);
12
+ # SSE can be layered later without changing call sites.
13
+ class Client
14
+ attr_reader :endpoint, :model, :timeout
15
+
16
+ # Builds a client from GitFit::Config.
17
+ # Returns nil when ai.enabled is false (caller decides to warn+skip);
18
+ # raises ConfigError with a Fix: hint when enabled but misconfigured.
19
+ def self.from_config(config)
20
+ ai = config.ai_config
21
+ return nil unless ai['enabled']
22
+
23
+ missing = %w[endpoint model].select { |k| ai[k].to_s.strip.empty? }
24
+ unless missing.empty?
25
+ raise ConfigError,
26
+ "LLM: ai.#{missing.join(', ai.')} not configured\n" \
27
+ ' Fix: set ai.endpoint / ai.model in config.yml ' \
28
+ '(e.g. https://open.bigmodel.cn/api/paas/v4 + glm-4-flash)'
29
+ end
30
+
31
+ key_env = ai['api_key_env'].to_s.strip
32
+ key_env = 'AI_API_KEY' if key_env.empty?
33
+ api_key = ENV[key_env].to_s
34
+ if api_key.empty?
35
+ raise ConfigError,
36
+ "LLM: env #{key_env} is empty\n" \
37
+ " Fix: export #{key_env}=<your-api-key> (local: add to .env.local and source it)"
38
+ end
39
+
40
+ new(endpoint: ai['endpoint'], model: ai['model'], api_key: api_key, timeout: ai['timeout'])
41
+ end
42
+
43
+ def initialize(endpoint:, model:, api_key:, timeout: 60)
44
+ @endpoint = endpoint.to_s.sub(%r{/+\z}, '')
45
+ @model = model
46
+ @api_key = api_key
47
+ @timeout = timeout.is_a?(Numeric) ? timeout : 60
48
+ end
49
+
50
+ # messages: [{ 'role' => 'system'|'user', 'content' => '...' }, ...]
51
+ # Returns the assistant content string.
52
+ def chat(messages:, temperature: nil)
53
+ payload = { model: @model, messages: messages }
54
+ payload[:temperature] = temperature if temperature
55
+ status, body = http_post(chat_completions_url, JSON.generate(payload))
56
+ parse_content(status, body)
57
+ end
58
+
59
+ private
60
+
61
+ def chat_completions_url
62
+ @endpoint.end_with?('/chat/completions') ? @endpoint : "#{@endpoint}/chat/completions"
63
+ end
64
+
65
+ # Single HTTP seam — specs stub this method (zero network), mirroring
66
+ # the http_request stub convention used by sync adapters.
67
+ # Returns [status, body_string].
68
+ def http_post(url, body_json)
69
+ resp = conn.post(url) do |req|
70
+ req.headers['Content-Type'] = 'application/json'
71
+ req.headers['Authorization'] = "Bearer #{@api_key}"
72
+ req.body = body_json
73
+ end
74
+ [resp.status, resp.body]
75
+ rescue Faraday::TimeoutError
76
+ raise TimeoutError,
77
+ "LLM: request timed out after #{@timeout}s\n" \
78
+ " Fix: increase ai.timeout (current #{@timeout}) or check endpoint reachability: curl #{url}"
79
+ rescue Faraday::ConnectionFailed => e
80
+ raise ConnectionError,
81
+ "LLM: connection failed — #{e.message}\n Fix: verify ai.endpoint is reachable: curl #{url}"
82
+ end
83
+
84
+ def conn
85
+ @conn ||= Faraday.new do |f|
86
+ f.options.timeout = @timeout
87
+ f.options.open_timeout = 10
88
+ end
89
+ end
90
+
91
+ def parse_content(status, body)
92
+ case status
93
+ when 200..299
94
+ data = parse_json_body(body)
95
+ content = data.dig('choices', 0, 'message', 'content')
96
+ if content.nil?
97
+ raise BadResponseError,
98
+ "LLM: unexpected response shape (missing choices[0].message.content)\n" \
99
+ " Fix: verify ai.endpoint is an OpenAI-compatible API; top-level keys: #{data.keys.inspect}"
100
+ end
101
+ if content.to_s.strip.empty?
102
+ raise BadResponseError,
103
+ "LLM: empty completion content\n" \
104
+ ' Fix: retry; if persistent, check ai.model output style or switch model'
105
+ end
106
+ content
107
+ when 400
108
+ raise RequestError,
109
+ "LLM: bad request (400) — #{api_message(body)}\n" \
110
+ ' Fix: check ai.model name and prompt size (activities_summary may need a lower limit)'
111
+ when 401, 403
112
+ raise AuthError,
113
+ "LLM: auth failed (#{status}) — #{api_message(body)}\n" \
114
+ ' Fix: check the API key env var (ai.api_key_env) and rotate it if expired'
115
+ when 429
116
+ raise RateLimitError,
117
+ "LLM: rate limited (429) — #{api_message(body)}\n" \
118
+ ' Fix: retry later or switch ai.model to a lower/cheaper tier'
119
+ else
120
+ raise ServerError,
121
+ "LLM: upstream error (#{status}) — #{api_message(body)}\n" \
122
+ ' Fix: retry later; if persistent, switch provider via ai.endpoint'
123
+ end
124
+ end
125
+
126
+ def parse_json_body(body)
127
+ JSON.parse(body)
128
+ rescue JSON::ParserError, TypeError
129
+ raise BadResponseError,
130
+ "LLM: response is not valid JSON (got #{body.to_s[0, 80].inspect}...)\n" \
131
+ ' Fix: verify ai.endpoint points to an OpenAI-compatible API (…/v1), not a HTML page'
132
+ end
133
+
134
+ def api_message(body)
135
+ JSON.parse(body.to_s).dig('error', 'message')
136
+ rescue JSON::ParserError, TypeError
137
+ body.to_s[0, 200]
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module LLM
5
+ # Base for all LLM errors. Messages follow the actionable-error convention:
6
+ # "<scope>: <failure reason>" + "\n Fix: <copy-paste command>"
7
+ class Error < StandardError; end
8
+
9
+ # ai.enabled but endpoint/model/api key missing or invalid.
10
+ class ConfigError < Error; end
11
+
12
+ # HTTP 400 — prompt too large / malformed request / unknown model name.
13
+ class RequestError < Error; end
14
+
15
+ # HTTP 401/403 — API key rejected or revoked.
16
+ class AuthError < Error; end
17
+
18
+ # HTTP 429 — rate limited / quota exhausted.
19
+ class RateLimitError < Error; end
20
+
21
+ # HTTP 5xx — provider-side failure.
22
+ class ServerError < Error; end
23
+
24
+ # HTTP 2xx but body not JSON or unexpected shape (not OpenAI-compatible).
25
+ class BadResponseError < Error; end
26
+
27
+ class TimeoutError < Error; end
28
+
29
+ class ConnectionError < Error; end
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module LLM
5
+ # Built-in prompt templates for AI insight scenarios.
6
+ #
7
+ # Contract: the JSON schema block (SCHEMAS) is baked at the end of every
8
+ # resolved template and is intentionally NOT user-customizable —
9
+ # ai.prompts.<scenario> replaces only the body, so insights.json
10
+ # structured output stays stable (see wiki/AI).
11
+ module Prompts
12
+ SCENARIOS = %i[monthly_report insights coaching].freeze
13
+
14
+ BODIES = {
15
+ monthly_report: <<~TXT,
16
+ 你是资深骑行/跑步教练。请基于以下运动数据撰写 {{period}} 的训练总结。
17
+
18
+ ## 输入数据
19
+
20
+ ### 汇总统计
21
+ {{stats}}
22
+
23
+ ### 活动摘要
24
+ {{activities_summary}}
25
+
26
+ ## 写作要求
27
+ - 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;当期数据以 monthly/yearly 中 {{period}} 对应行为准,活动摘要可用作补充
28
+ - 用 {{language}} 撰写,Markdown 格式,正文 800 字以内
29
+ - 结构:先总后分 —— 总体负荷印象 → 亮点 → 隐忧
30
+ - 所有数字必须来自输入数据,禁止编造或外推
31
+ - 语气客观克制,避免空洞鼓励和套话
32
+ TXT
33
+ insights: <<~TXT,
34
+ 你是运动数据分析专家。请基于以下运动数据找出值得注意的洞察与异常。分析周期:{{period}}。
35
+
36
+ ## 输入数据
37
+
38
+ ### 汇总统计
39
+ {{stats}}
40
+
41
+ ### 活动摘要
42
+ {{activities_summary}}
43
+
44
+ ## 分析要求
45
+ - 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;对比当期与历史时以 monthly/yearly 分组行为准
46
+ - 关注维度:趋势变化、个人纪录(PB)、疲劳信号、季节性规律、运动项目占比变化
47
+ - 输出 3-6 条,按重要性排序
48
+ - 数据不足以支撑的结论不要输出;无异常时给少量 info 条目即可,禁止强行制造发现
49
+ - 每条结论必须引用具体数字,并给出其来源路径(如 monthly.2026-08.ride.distance)
50
+ - 用 {{language}} 撰写
51
+ TXT
52
+ coaching: <<~TXT,
53
+ 你是谨慎的耐力运动教练。请基于以下运动数据给出下一周期的训练建议。当前周期:{{period}}。
54
+
55
+ ## 输入数据
56
+
57
+ ### 汇总统计
58
+ {{stats}}
59
+
60
+ ### 活动摘要
61
+ {{activities_summary}}
62
+
63
+ ## 建议要求
64
+ - 汇总统计中的 summary 是全历史累计,不是 {{period}} 当期数据;当期负荷以 monthly/yearly 中 {{period}} 对应行为准
65
+ - 输出 2-4 条可执行建议,每条说明数据依据
66
+ - 除非数据明确支持(如连续多周稳定负荷且无疲劳信号),不建议提高训练量
67
+ - 关注一致性、恢复与渐进,而非单次表现
68
+ - 用 {{language}} 撰写
69
+ TXT
70
+ }.freeze
71
+
72
+ # Structured output contract — appended to every resolved template,
73
+ # never user-editable (guarantees insights.json parseability).
74
+ SCHEMAS = {
75
+ monthly_report: <<~TXT,
76
+ ## 输出契约(必须严格遵守)
77
+ 仅输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记:
78
+ {"summary_markdown": "<训练总结正文,Markdown 字符串>"}
79
+ TXT
80
+ insights: <<~TXT,
81
+ ## 输出契约(必须严格遵守)
82
+ 仅输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记:
83
+ {"insights": [{"id": "<kebab-case-id>", "type": "<trend|anomaly|pb|consistency|season>", "severity": "<info|positive|warning>", "title": "<一句话结论>", "body": "<Markdown 正文,引用具体数字>", "data_refs": ["<stats 数据路径>"]}]}
84
+ TXT
85
+ coaching: <<~TXT,
86
+ ## 输出契约(必须严格遵守)
87
+ 仅输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记:
88
+ {"coaching": [{"title": "<建议标题>", "body": "<Markdown 正文,含数据依据>", "priority": "<low|medium|high>"}]}
89
+ TXT
90
+ }.freeze
91
+
92
+ def self.builtin(scenario)
93
+ BODIES.fetch(scenario.to_sym)
94
+ end
95
+
96
+ def self.schema(scenario)
97
+ SCHEMAS.fetch(scenario.to_sym)
98
+ end
99
+
100
+ # Resolves a scenario template: user override (config ai.prompts) if
101
+ # present, else the built-in body; then appends the baked schema block.
102
+ def self.resolve(scenario, overrides = {})
103
+ key = scenario.to_sym
104
+ raise_unknown_scenario(scenario) unless BODIES.key?(key)
105
+ body = overrides.is_a?(Hash) ? (overrides[scenario.to_s] || overrides[key.to_s] || BODIES[key]) : nil
106
+ "#{body.to_s.rstrip}\n\n#{SCHEMAS[key].rstrip}"
107
+ end
108
+
109
+ def self.raise_unknown_scenario(scenario)
110
+ raise ConfigError,
111
+ "LLM: unknown prompt scenario '#{scenario}'\n" \
112
+ " Fix: use one of #{SCENARIOS.map(&:to_s).join(', ')}"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module LLM
5
+ # {{slot}} renderer for prompt templates.
6
+ # Unknown or leftover slots fail fast — never ship a half-rendered prompt.
7
+ module Template
8
+ SLOT_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/
9
+
10
+ # Raised when a template references a slot that is not provided.
11
+ class UnknownSlotError < Error
12
+ def initialize(name, allowed)
13
+ super(
14
+ "LLM: template references unknown slot {{#{name}}}\n" \
15
+ " Fix: use one of the allowed slots: #{allowed.join(', ')}"
16
+ )
17
+ end
18
+ end
19
+
20
+ # Renders {{slot}} placeholders. Values are coerced with to_s.
21
+ # Raises UnknownSlotError for slots without a provided value and for
22
+ # slots that appear in the output after substitution (data-injected
23
+ # placeholders are treated as a bug, not passed through).
24
+ def self.render(text, slots)
25
+ rendered = text.gsub(SLOT_PATTERN) do
26
+ name = Regexp.last_match(1)
27
+ raise UnknownSlotError.new(name, slots.keys.sort) unless slots.key?(name)
28
+
29
+ slots[name].to_s
30
+ end
31
+ leftover = rendered.match(SLOT_PATTERN)
32
+ return rendered unless leftover
33
+
34
+ raise UnknownSlotError.new(leftover[1], slots.keys.sort)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LLM integration — OpenAI-compatible chat completions.
4
+ # Provider-agnostic by design: endpoint / model / api key are all config-driven
5
+ # (config.yml `ai:` section, see Config::DEFAULTS['ai'] and wiki/AI).
6
+
7
+ require_relative 'llm/errors'
8
+ require_relative 'llm/template'
9
+ require_relative 'llm/prompts'
10
+ require_relative 'llm/client'
11
+
12
+ module GitFit
13
+ module LLM
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.21.4'
4
+ VERSION = '0.22.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.21.4
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -257,9 +257,6 @@ extensions: []
257
257
  extra_rdoc_files: []
258
258
  files:
259
259
  - db/migrations/001_full_schema.rb
260
- - db/migrations/002_iso8601_time_format.rb
261
- - db/migrations/003_add_elevation_fields.rb
262
- - db/migrations/004_add_elevation_terrain_fields.rb
263
260
  - exe/git-fit
264
261
  - lib/git-fit.rb
265
262
  - lib/git_fit/auth/garmin.rb
@@ -306,6 +303,7 @@ files:
306
303
  - lib/git_fit/elevation/train/fitter.rb
307
304
  - lib/git_fit/elevation/train/runner.rb
308
305
  - lib/git_fit/export.rb
306
+ - lib/git_fit/export/ai.rb
309
307
  - lib/git_fit/export/calculator.rb
310
308
  - lib/git_fit/export/csv.rb
311
309
  - lib/git_fit/export/defaults.rb
@@ -329,6 +327,11 @@ files:
329
327
  - lib/git_fit/install/actions.rb
330
328
  - lib/git_fit/install/actions/db-store/restore.yml.erb
331
329
  - lib/git_fit/install/actions/db-store/save.yml.erb
330
+ - lib/git_fit/llm.rb
331
+ - lib/git_fit/llm/client.rb
332
+ - lib/git_fit/llm/errors.rb
333
+ - lib/git_fit/llm/prompts.rb
334
+ - lib/git_fit/llm/template.rb
332
335
  - lib/git_fit/parser.rb
333
336
  - lib/git_fit/parser/base.rb
334
337
  - lib/git_fit/parser/fit.rb
@@ -1,54 +0,0 @@
1
- Sequel.migration do
2
- up do
3
- rows = self[:activities].all
4
-
5
- rows.each do |row|
6
- updates = {}
7
-
8
- # Detect format: ISO 8601 contains "T", naive format does not
9
- sd = row[:start_date]
10
- if sd && sd.is_a?(String) && !sd.include?("T")
11
- t = Time.parse(sd) rescue nil
12
- if t
13
- case row[:source]
14
- when "apple_health"
15
- # Apple Health stored +0800 local time as naive "2026-06-25 19:49:17"
16
- # Parse as local (+0800) then convert to UTC ISO 8601
17
- local_t = Time.parse(sd + " +0800") rescue nil
18
- updates[:start_date] = local_t.utc.iso8601 if local_t
19
- else
20
- # All other sources stored UTC naive "2026-06-25 11:49:17"
21
- # Parse as UTC then output ISO 8601
22
- utc_t = Time.parse(sd + " UTC") rescue nil
23
- updates[:start_date] = utc_t.iso8601 if utc_t
24
- end
25
- end
26
- end
27
-
28
- sdl = row[:start_date_local]
29
- if sdl && sdl.is_a?(String) && !sdl.include?("T")
30
- t = Time.parse(sdl) rescue nil
31
- if t
32
- case row[:source]
33
- when "strava", "igpsport"
34
- # These stores proper local time, preserve as ISO 8601 with offset
35
- local_t = Time.parse(sdl + " +0800") rescue nil
36
- updates[:start_date_local] = local_t.iso8601 if local_t
37
- else
38
- # Same as start_date (UTC)
39
- utc_t = Time.parse(sdl + " UTC") rescue nil
40
- updates[:start_date_local] = utc_t.iso8601 if utc_t
41
- end
42
- end
43
- end
44
-
45
- self[:activities].where(run_id: row[:run_id]).update(updates) unless updates.empty?
46
- end
47
- end
48
-
49
- down do
50
- # One-way migration: cannot automatically revert ISO 8601 → naive
51
- # because timezone info would be silently lost.
52
- # To rollback: restore from DB backup or re-import.
53
- end
54
- end
@@ -1,19 +0,0 @@
1
- Sequel.migration do
2
- up do
3
- alter_table(:activities) do
4
- add_column :elevation_loss, Float
5
- add_column :elevation_min, Float
6
- add_column :elevation_max, Float
7
- add_column :steps, Integer
8
- end
9
- end
10
-
11
- down do
12
- alter_table(:activities) do
13
- drop_column :steps
14
- drop_column :elevation_max
15
- drop_column :elevation_min
16
- drop_column :elevation_loss
17
- end
18
- end
19
- end
@@ -1,19 +0,0 @@
1
- Sequel.migration do
2
- up do
3
- alter_table(:activities) do
4
- add_column :elevation_gain_terrain, Float
5
- add_column :elevation_loss_terrain, Float
6
- add_column :elevation_min_terrain, Float
7
- add_column :elevation_max_terrain, Float
8
- end
9
- end
10
-
11
- down do
12
- alter_table(:activities) do
13
- drop_column :elevation_max_terrain
14
- drop_column :elevation_min_terrain
15
- drop_column :elevation_loss_terrain
16
- drop_column :elevation_gain_terrain
17
- end
18
- end
19
- end