git-fit 0.21.5 → 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 +4 -4
- data/lib/git-fit.rb +1 -0
- data/lib/git_fit/cli/export.rb +31 -0
- data/lib/git_fit/config.rb +40 -1
- data/lib/git_fit/export/ai.rb +286 -0
- data/lib/git_fit/export.rb +1 -0
- data/lib/git_fit/llm/client.rb +141 -0
- data/lib/git_fit/llm/errors.rb +31 -0
- data/lib/git_fit/llm/prompts.rb +116 -0
- data/lib/git_fit/llm/template.rb +38 -0
- data/lib/git_fit/llm.rb +15 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f9fda747ee0bdc1b1d6588bce636576cb8e371ea0ae36b2bcfedd377a516891
|
|
4
|
+
data.tar.gz: e19094889528196ead4715706a46fe035516d219ba2c69264cf32e4fc0820da6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3fec0c7cfe0f51d7087698900585e480b9ce52d73aa6a8238cfa2493d5500e9d20e93087dda785da91844b3099ea1c4d36b4e642dbd867187d94843ae63b466
|
|
7
|
+
data.tar.gz: b98ec2e23dc34c3c5694d4627c6ae12a153a2b185d76f0f9567903c2e39a4ff6bb7995ff928e4b17ef4978cdd0a03485e38ae41ef17d9f95bf63f16870d2324b
|
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'
|
data/lib/git_fit/cli/export.rb
CHANGED
|
@@ -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
|
data/lib/git_fit/config.rb
CHANGED
|
@@ -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
|
data/lib/git_fit/export.rb
CHANGED
|
@@ -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
|
data/lib/git_fit/llm.rb
ADDED
|
@@ -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
|
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.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -303,6 +303,7 @@ files:
|
|
|
303
303
|
- lib/git_fit/elevation/train/fitter.rb
|
|
304
304
|
- lib/git_fit/elevation/train/runner.rb
|
|
305
305
|
- lib/git_fit/export.rb
|
|
306
|
+
- lib/git_fit/export/ai.rb
|
|
306
307
|
- lib/git_fit/export/calculator.rb
|
|
307
308
|
- lib/git_fit/export/csv.rb
|
|
308
309
|
- lib/git_fit/export/defaults.rb
|
|
@@ -326,6 +327,11 @@ files:
|
|
|
326
327
|
- lib/git_fit/install/actions.rb
|
|
327
328
|
- lib/git_fit/install/actions/db-store/restore.yml.erb
|
|
328
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
|
|
329
335
|
- lib/git_fit/parser.rb
|
|
330
336
|
- lib/git_fit/parser/base.rb
|
|
331
337
|
- lib/git_fit/parser/fit.rb
|