kpi_assembler 0.5.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +83 -0
- data/app/controllers/kpi_assembler/api/v1/workspace_controller.rb +67 -0
- data/app/controllers/kpi_assembler/application_controller.rb +19 -0
- data/app/controllers/kpi_assembler/workspace_controller.rb +17 -0
- data/bin/kpi_assembler +77 -0
- data/bin/kpi_assembler_web +19 -0
- data/config/routes.rb +17 -0
- data/docs/integration.md +89 -0
- data/docs/rails-engine.md +92 -0
- data/lib/generators/kpi_assembler/install_generator.rb +25 -0
- data/lib/generators/kpi_assembler/templates/kpi_assembler.rb +47 -0
- data/lib/kpi_assembler/asset_server.rb +42 -0
- data/lib/kpi_assembler/briefing_generator.rb +108 -0
- data/lib/kpi_assembler/candidate_generator.rb +421 -0
- data/lib/kpi_assembler/certification_engine.rb +125 -0
- data/lib/kpi_assembler/configuration.rb +115 -0
- data/lib/kpi_assembler/connection.rb +140 -0
- data/lib/kpi_assembler/engine.rb +18 -0
- data/lib/kpi_assembler/env.rb +40 -0
- data/lib/kpi_assembler/html_report.rb +177 -0
- data/lib/kpi_assembler/location_directory.rb +32 -0
- data/lib/kpi_assembler/metric_evaluator.rb +62 -0
- data/lib/kpi_assembler/pack_builder.rb +53 -0
- data/lib/kpi_assembler/sample_database.rb +191 -0
- data/lib/kpi_assembler/schema_inspector.rb +197 -0
- data/lib/kpi_assembler/schema_kpi_proposer.rb +323 -0
- data/lib/kpi_assembler/version.rb +5 -0
- data/lib/kpi_assembler/web_app.rb +141 -0
- data/lib/kpi_assembler/web_service.rb +138 -0
- data/lib/kpi_assembler.rb +162 -0
- data/public/app.css +458 -0
- data/public/app.js +372 -0
- data/public/index.html +240 -0
- data/public/kpi-assembler.js +75 -0
- metadata +101 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_evaluator"
|
|
4
|
+
require_relative "location_directory"
|
|
5
|
+
|
|
6
|
+
module KPIAssembler
|
|
7
|
+
class BriefingGenerator
|
|
8
|
+
def initialize(db, certified_kpis, now: Time.now.utc, tenant_id: nil)
|
|
9
|
+
@evaluator = MetricEvaluator.new(db)
|
|
10
|
+
@kpis = certified_kpis
|
|
11
|
+
@now = now
|
|
12
|
+
@tenant_id = tenant_id
|
|
13
|
+
@current_from = now - (30 * 86_400)
|
|
14
|
+
@previous_from = now - (60 * 86_400)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate
|
|
18
|
+
movements = @kpis.filter_map { |kpi| movement_for(kpi) }
|
|
19
|
+
ranked = movements.sort_by { |m| -m[:abs_delta_pct] }.first(3)
|
|
20
|
+
drill = worst_location_for(ranked.first)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
period: {
|
|
24
|
+
current: { from: iso(@current_from), to: iso(@now) },
|
|
25
|
+
previous: { from: iso(@previous_from), to: iso(@current_from) }
|
|
26
|
+
},
|
|
27
|
+
headline: headline(ranked),
|
|
28
|
+
movements: ranked,
|
|
29
|
+
drill_dimension: drill
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def movement_for(kpi)
|
|
36
|
+
current = @evaluator.value(kpi, from: @current_from, to: @now)
|
|
37
|
+
previous = @evaluator.value(kpi, from: @previous_from, to: @current_from)
|
|
38
|
+
return if current.nil? || previous.nil?
|
|
39
|
+
|
|
40
|
+
delta = current - previous
|
|
41
|
+
base = previous.abs < 0.0001 ? nil : ((delta / previous) * 100.0)
|
|
42
|
+
{
|
|
43
|
+
id: kpi[:id],
|
|
44
|
+
name: kpi[:name],
|
|
45
|
+
current: round_num(current),
|
|
46
|
+
previous: round_num(previous),
|
|
47
|
+
delta: round_num(delta),
|
|
48
|
+
delta_pct: base && round_num(base),
|
|
49
|
+
abs_delta_pct: (base || delta).abs,
|
|
50
|
+
sentence: sentence(kpi[:name], current, previous, delta, base)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def worst_location_for(movement)
|
|
55
|
+
return { dimension: "location", note: "No certified movement to drill." } unless movement
|
|
56
|
+
|
|
57
|
+
kpi = @kpis.find { |k| k[:id] == movement[:id] }
|
|
58
|
+
return { dimension: "location", note: "KPI has no location column." } unless kpi && kpi[:location_column]
|
|
59
|
+
|
|
60
|
+
rows = db_locations.filter_map do |loc|
|
|
61
|
+
value = @evaluator.value(kpi, from: @current_from, to: @now, location_id: loc[:id])
|
|
62
|
+
next unless value
|
|
63
|
+
|
|
64
|
+
{ id: loc[:id], name: loc[:name], value: round_num(value) }
|
|
65
|
+
end
|
|
66
|
+
worst = rows.min_by { |r| r[:value] }
|
|
67
|
+
{
|
|
68
|
+
dimension: "location",
|
|
69
|
+
kpi_id: movement[:id],
|
|
70
|
+
locations: rows,
|
|
71
|
+
explaining_site: worst
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def db_locations
|
|
76
|
+
LocationDirectory.new(@evaluator.db, tenant_id: @tenant_id).all
|
|
77
|
+
rescue StandardError
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def headline(ranked)
|
|
82
|
+
return "No certified KPIs moved enough to brief." if ranked.empty?
|
|
83
|
+
|
|
84
|
+
ranked.map { |m| m[:sentence] }.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sentence(name, current, previous, delta, delta_pct)
|
|
88
|
+
direction = delta.negative? ? "fell" : "rose"
|
|
89
|
+
if delta_pct
|
|
90
|
+
"#{name} #{direction} #{format('%.1f', delta_pct.abs)} pts/percent vs the prior 30 days (#{format_num(previous)} → #{format_num(current)})."
|
|
91
|
+
else
|
|
92
|
+
"#{name} is #{format_num(current)} this period vs #{format_num(previous)} prior."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_num(n)
|
|
97
|
+
n.abs >= 100 ? format("%.0f", n) : format("%.1f", n)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def round_num(n)
|
|
101
|
+
n.round(2)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def iso(time)
|
|
105
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "schema_kpi_proposer"
|
|
7
|
+
|
|
8
|
+
module KPIAssembler
|
|
9
|
+
# The inspector already decided which tables exist. This class only proposes
|
|
10
|
+
# KPIs: the LLM (Gemini or Ollama) ranks those tables and writes recipes;
|
|
11
|
+
# heuristics fill gaps. Certification never happens here.
|
|
12
|
+
class CandidateGenerator
|
|
13
|
+
MAX_CANDIDATES = 16
|
|
14
|
+
MAX_PROMPT_TABLES = 24
|
|
15
|
+
|
|
16
|
+
attr_reader :schema, :options, :meta
|
|
17
|
+
|
|
18
|
+
def initialize(schema, options = {})
|
|
19
|
+
@schema = schema
|
|
20
|
+
@options = options
|
|
21
|
+
@meta = default_meta
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def generate
|
|
25
|
+
heuristic = heuristic_candidates(schema.keys)
|
|
26
|
+
return finalize(heuristic, proposer: "heuristics") unless use_llm?
|
|
27
|
+
|
|
28
|
+
llm = prompt_llm
|
|
29
|
+
unless llm
|
|
30
|
+
return finalize(
|
|
31
|
+
heuristic,
|
|
32
|
+
proposer: "heuristics",
|
|
33
|
+
fallback_reason: @meta[:fallback_reason]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ranked = llm[:ranked_tables]
|
|
38
|
+
merged = merge_candidates(llm[:kpis], heuristic)
|
|
39
|
+
finalize(
|
|
40
|
+
merged,
|
|
41
|
+
proposer: "llm",
|
|
42
|
+
ranked_tables: ranked,
|
|
43
|
+
model: active_model_name
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def default_meta
|
|
50
|
+
{
|
|
51
|
+
proposer: "heuristics",
|
|
52
|
+
ranked_tables: [],
|
|
53
|
+
model: nil,
|
|
54
|
+
fallback_reason: nil
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def use_llm?
|
|
59
|
+
return false if options[:use_llm] == false
|
|
60
|
+
return false if llm_provider == :ollama && options[:use_ollama] == false
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def llm_provider
|
|
66
|
+
provider = options[:llm_provider] || ENV["KPI_LLM_PROVIDER"]
|
|
67
|
+
if provider && !provider.to_s.empty?
|
|
68
|
+
provider.to_sym
|
|
69
|
+
elsif options[:gemini_api_key] || ENV["GEMINI_API_KEY"]
|
|
70
|
+
:gemini
|
|
71
|
+
else
|
|
72
|
+
:ollama
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def active_model_name
|
|
77
|
+
llm_provider == :gemini ? gemini_model : ollama_model
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ollama_model
|
|
81
|
+
options[:ollama_model] || ENV.fetch("KPI_OLLAMA_MODEL", "llama3")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def gemini_model
|
|
85
|
+
options[:gemini_model] || ENV.fetch("KPI_GEMINI_MODEL", "gemini-2.0-flash")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def heuristic_candidates(table_names)
|
|
89
|
+
names = Array(table_names).map(&:to_s)
|
|
90
|
+
focused = schema.select { |name, _| names.include?(name.to_s) }
|
|
91
|
+
focused = schema if focused.empty?
|
|
92
|
+
SchemaKpiProposer.new(focused, options).propose
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prompt_llm
|
|
96
|
+
case llm_provider
|
|
97
|
+
when :gemini
|
|
98
|
+
prompt_gemini
|
|
99
|
+
else
|
|
100
|
+
prompt_ollama
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def prompt_gemini
|
|
105
|
+
api_key = options[:gemini_api_key] || ENV["GEMINI_API_KEY"]
|
|
106
|
+
if api_key.nil? || api_key.to_s.empty?
|
|
107
|
+
reason = "GEMINI_API_KEY is not set. Set GEMINI_API_KEY or configure KPI_LLM_PROVIDER=ollama."
|
|
108
|
+
@meta = default_meta.merge(fallback_reason: reason)
|
|
109
|
+
warn "Gemini proposal failed: #{reason}"
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
model = gemini_model
|
|
114
|
+
endpoint = options[:gemini_url] || "https://generativelanguage.googleapis.com/v1beta/models/#{model}:generateContent"
|
|
115
|
+
known = schema.keys.map(&:to_s)
|
|
116
|
+
prompt_text = build_prompt(known)
|
|
117
|
+
|
|
118
|
+
uri = URI.parse(endpoint)
|
|
119
|
+
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "x-goog-api-key" => api_key)
|
|
120
|
+
req.body = {
|
|
121
|
+
contents: [
|
|
122
|
+
{
|
|
123
|
+
parts: [{ text: prompt_text }]
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
generationConfig: {
|
|
127
|
+
responseMimeType: "application/json"
|
|
128
|
+
}
|
|
129
|
+
}.to_json
|
|
130
|
+
|
|
131
|
+
res = Net::HTTP.start(
|
|
132
|
+
uri.hostname,
|
|
133
|
+
uri.port,
|
|
134
|
+
use_ssl: uri.scheme == "https",
|
|
135
|
+
open_timeout: Integer(options[:gemini_open_timeout] || 5),
|
|
136
|
+
read_timeout: Integer(options[:gemini_timeout] || 60)
|
|
137
|
+
) { |http| http.request(req) }
|
|
138
|
+
|
|
139
|
+
code = res.code.to_i
|
|
140
|
+
unless code.between?(200, 299)
|
|
141
|
+
@meta = default_meta.merge(fallback_reason: describe_gemini_error(code, res.body))
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
raw_json = extract_gemini_text(res.body)
|
|
146
|
+
parsed = extract_json(raw_json)
|
|
147
|
+
ranked = sanitize_ranked_tables(parsed, known)
|
|
148
|
+
kpis = sanitize_llm_kpis(parsed, known)
|
|
149
|
+
if kpis.empty?
|
|
150
|
+
@meta = default_meta.merge(fallback_reason: "Gemini returned no usable KPIs")
|
|
151
|
+
return nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{ ranked_tables: ranked, kpis: kpis }
|
|
155
|
+
rescue Net::OpenTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
156
|
+
reason = "Cannot reach Gemini API (#{e.class}: #{e.message}). Check internet connection."
|
|
157
|
+
@meta = default_meta.merge(fallback_reason: reason)
|
|
158
|
+
warn "Gemini proposal failed: #{reason}"
|
|
159
|
+
nil
|
|
160
|
+
rescue Net::ReadTimeout
|
|
161
|
+
seconds = Integer(options[:gemini_timeout] || 60)
|
|
162
|
+
reason = "Gemini (#{model}) did not answer within #{seconds}s."
|
|
163
|
+
@meta = default_meta.merge(fallback_reason: reason)
|
|
164
|
+
warn "Gemini proposal failed: #{reason}"
|
|
165
|
+
nil
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
@meta = default_meta.merge(fallback_reason: e.message)
|
|
168
|
+
warn "Gemini proposal failed (#{e.message}). Using schema-driven heuristics."
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def extract_gemini_text(body)
|
|
173
|
+
parsed = JSON.parse(body)
|
|
174
|
+
text = parsed.dig("candidates", 0, "content", "parts", 0, "text")
|
|
175
|
+
text || body
|
|
176
|
+
rescue StandardError
|
|
177
|
+
body
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def describe_gemini_error(code, body)
|
|
181
|
+
detail = begin
|
|
182
|
+
parsed = JSON.parse(body.to_s)
|
|
183
|
+
parsed.dig("error", "message") || parsed["error"].to_s
|
|
184
|
+
rescue StandardError
|
|
185
|
+
""
|
|
186
|
+
end
|
|
187
|
+
"Gemini API returned HTTP #{code}#{detail.empty? ? "" : ": #{detail}"}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def prompt_ollama
|
|
191
|
+
endpoint = options[:ollama_url] || "http://localhost:11434/api/generate"
|
|
192
|
+
known = schema.keys.map(&:to_s)
|
|
193
|
+
prompt_text = build_prompt(known)
|
|
194
|
+
|
|
195
|
+
uri = URI.parse(endpoint)
|
|
196
|
+
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
|
|
197
|
+
req.body = {
|
|
198
|
+
model: ollama_model,
|
|
199
|
+
prompt: prompt_text,
|
|
200
|
+
stream: false,
|
|
201
|
+
format: "json"
|
|
202
|
+
}.to_json
|
|
203
|
+
|
|
204
|
+
res = Net::HTTP.start(
|
|
205
|
+
uri.hostname,
|
|
206
|
+
uri.port,
|
|
207
|
+
open_timeout: Integer(options[:ollama_open_timeout] || 2),
|
|
208
|
+
read_timeout: Integer(options[:ollama_timeout] || 900)
|
|
209
|
+
) { |http| http.request(req) }
|
|
210
|
+
|
|
211
|
+
code = res.code.to_i
|
|
212
|
+
unless code.between?(200, 299)
|
|
213
|
+
@meta = default_meta.merge(fallback_reason: describe_http_error(code, res.body, endpoint))
|
|
214
|
+
return nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
parsed = extract_json(res.body)
|
|
218
|
+
ranked = sanitize_ranked_tables(parsed, known)
|
|
219
|
+
kpis = sanitize_llm_kpis(parsed, known)
|
|
220
|
+
if kpis.empty?
|
|
221
|
+
@meta = default_meta.merge(fallback_reason: "Ollama returned no usable KPIs")
|
|
222
|
+
return nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
{ ranked_tables: ranked, kpis: kpis }
|
|
226
|
+
rescue Net::OpenTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
227
|
+
reason = "Cannot reach Ollama at #{endpoint} (#{e.class}). Start it with `ollama serve`."
|
|
228
|
+
@meta = default_meta.merge(fallback_reason: reason)
|
|
229
|
+
warn "Ollama proposal failed: #{reason}"
|
|
230
|
+
nil
|
|
231
|
+
rescue Net::ReadTimeout
|
|
232
|
+
seconds = Integer(options[:ollama_timeout] || 90)
|
|
233
|
+
reason = "#{ollama_model} did not answer within #{seconds}s. Use a smaller model or raise ollama_timeout."
|
|
234
|
+
@meta = default_meta.merge(fallback_reason: reason)
|
|
235
|
+
warn "Ollama proposal failed: #{reason}"
|
|
236
|
+
nil
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
@meta = default_meta.merge(fallback_reason: e.message)
|
|
239
|
+
warn "Ollama proposal failed (#{e.message}). Using schema-driven heuristics."
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_prompt(known)
|
|
244
|
+
dialect = options[:dialect] || "SQL"
|
|
245
|
+
|
|
246
|
+
<<~PROMPT
|
|
247
|
+
You are a business analytics architect. The tables below were already
|
|
248
|
+
discovered by a schema inspector — do not invent tables or columns.
|
|
249
|
+
|
|
250
|
+
1. Rank the most useful fact tables for KPIs (6 or fewer).
|
|
251
|
+
2. Propose 6 to 10 KPI recipes for those tables.
|
|
252
|
+
|
|
253
|
+
Reply ONLY with JSON:
|
|
254
|
+
{
|
|
255
|
+
"ranked_tables": ["table_name"],
|
|
256
|
+
"kpis": [
|
|
257
|
+
{
|
|
258
|
+
"id": "snake_case_id",
|
|
259
|
+
"name": "Human name",
|
|
260
|
+
"category": "Sales Funnel|Revenue|Retention|Operations|Conversion",
|
|
261
|
+
"owner": "sales|exec|ops|site_manager|finance",
|
|
262
|
+
"formula_description": "One sentence in English",
|
|
263
|
+
"sql": "SELECT ... AS metric_value FROM ... WHERE ...",
|
|
264
|
+
"grain": "daily|weekly|monthly",
|
|
265
|
+
"location_column": "location_id or company_id or null",
|
|
266
|
+
"fact_table": "one of the provided table names"
|
|
267
|
+
}
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
SQL rules:
|
|
272
|
+
- Dialect: #{dialect}
|
|
273
|
+
- Return a single column aliased metric_value.
|
|
274
|
+
- Use two positional placeholders ? ? for period start and end.
|
|
275
|
+
- Guard every division with NULLIF.
|
|
276
|
+
- Only JOIN on real foreign keys. Never JOIN without ON.
|
|
277
|
+
- fact_table and every FROM/JOIN target MUST be a provided table name.
|
|
278
|
+
- Infer the domain from names. Do not assume a gym unless the schema looks like one.
|
|
279
|
+
#{tenant_prompt_line}
|
|
280
|
+
|
|
281
|
+
Discovered schema:
|
|
282
|
+
#{JSON.pretty_generate(schema_for_prompt)}
|
|
283
|
+
PROMPT
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Ollama reports a missing model as a 404 with a JSON error body. Say which
|
|
287
|
+
# models the daemon actually has, so the fix does not need a support round trip.
|
|
288
|
+
def describe_http_error(code, body, endpoint)
|
|
289
|
+
detail = begin
|
|
290
|
+
JSON.parse(body.to_s)["error"].to_s
|
|
291
|
+
rescue StandardError
|
|
292
|
+
""
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
return "Ollama at #{endpoint} returned HTTP #{code}#{detail.empty? ? "" : ": #{detail}"}" unless code == 404
|
|
296
|
+
|
|
297
|
+
installed = installed_models(endpoint)
|
|
298
|
+
hint =
|
|
299
|
+
if installed.empty?
|
|
300
|
+
"No models are installed. Run `ollama pull #{ollama_model}`."
|
|
301
|
+
else
|
|
302
|
+
"Installed models: #{installed.join(", ")}. Run `ollama pull #{ollama_model}` or set ollama_model to one of these."
|
|
303
|
+
end
|
|
304
|
+
"Model #{ollama_model.inspect} is not available. #{hint}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def installed_models(endpoint)
|
|
308
|
+
uri = URI.parse(endpoint)
|
|
309
|
+
tags = URI::HTTP.build(host: uri.host, port: uri.port, path: "/api/tags")
|
|
310
|
+
body = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 2, read_timeout: 5) do |http|
|
|
311
|
+
http.request(Net::HTTP::Get.new(tags)).body
|
|
312
|
+
end
|
|
313
|
+
Array(JSON.parse(body)["models"]).map { |model| model["name"].to_s }.reject(&:empty?)
|
|
314
|
+
rescue StandardError
|
|
315
|
+
[]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def extract_json(body)
|
|
319
|
+
envelope = JSON.parse(body.to_s)
|
|
320
|
+
payload = envelope.is_a?(Hash) ? envelope["response"] || envelope : envelope
|
|
321
|
+
parsed =
|
|
322
|
+
if payload.is_a?(String)
|
|
323
|
+
JSON.parse(payload)
|
|
324
|
+
else
|
|
325
|
+
payload
|
|
326
|
+
end
|
|
327
|
+
parsed = parsed["kpis"] || parsed["candidates"] || parsed if parsed.is_a?(Hash) && !parsed.key?("kpis") && !parsed.key?("ranked_tables")
|
|
328
|
+
parsed
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def sanitize_ranked_tables(parsed, known)
|
|
332
|
+
names =
|
|
333
|
+
case parsed
|
|
334
|
+
when Hash then Array(parsed["ranked_tables"] || parsed[:ranked_tables])
|
|
335
|
+
else []
|
|
336
|
+
end
|
|
337
|
+
names.map(&:to_s).uniq.select { |name| known.include?(name) }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def sanitize_llm_kpis(parsed, known)
|
|
341
|
+
rows =
|
|
342
|
+
case parsed
|
|
343
|
+
when Hash then Array(parsed["kpis"] || parsed[:kpis] || parsed["candidates"])
|
|
344
|
+
when Array then parsed
|
|
345
|
+
else []
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
rows.filter_map do |row|
|
|
349
|
+
next unless row.respond_to?(:to_h)
|
|
350
|
+
|
|
351
|
+
kpi = row.to_h.transform_keys(&:to_sym)
|
|
352
|
+
fact = kpi[:fact_table].to_s
|
|
353
|
+
fact = infer_fact_table(kpi[:sql], known) if fact.empty? || !known.include?(fact)
|
|
354
|
+
next unless known.include?(fact)
|
|
355
|
+
next if kpi[:sql].to_s.strip.empty? || kpi[:id].to_s.strip.empty?
|
|
356
|
+
|
|
357
|
+
kpi.merge(fact_table: fact, source: "llm")
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def infer_fact_table(sql, known)
|
|
362
|
+
match = sql.to_s.match(/\bfrom\s+["']?([a-zA-Z0-9_]+)["']?/i)
|
|
363
|
+
name = match && match[1]
|
|
364
|
+
known.include?(name) ? name : nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def merge_candidates(llm_kpis, heuristic)
|
|
368
|
+
seen = {}
|
|
369
|
+
combined = []
|
|
370
|
+
(Array(llm_kpis) + Array(heuristic)).each do |kpi|
|
|
371
|
+
id = kpi[:id].to_s
|
|
372
|
+
next if id.empty? || seen[id]
|
|
373
|
+
|
|
374
|
+
seen[id] = true
|
|
375
|
+
combined << kpi
|
|
376
|
+
end
|
|
377
|
+
combined.first(MAX_CANDIDATES)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def finalize(candidates, proposer:, ranked_tables: [], model: nil, fallback_reason: nil)
|
|
381
|
+
normalized = normalize(candidates)
|
|
382
|
+
@meta = {
|
|
383
|
+
proposer: proposer,
|
|
384
|
+
ranked_tables: Array(ranked_tables),
|
|
385
|
+
model: model,
|
|
386
|
+
fallback_reason: fallback_reason
|
|
387
|
+
}
|
|
388
|
+
normalized
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def tenant_prompt_line
|
|
392
|
+
column = options[:tenant_column]
|
|
393
|
+
return "" unless column
|
|
394
|
+
|
|
395
|
+
"If a table has #{column}, every query on that table MUST include #{column} = #{options[:tenant_id] || "?"}."
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def schema_for_prompt
|
|
399
|
+
schema.first(MAX_PROMPT_TABLES).to_h.transform_values do |meta|
|
|
400
|
+
{
|
|
401
|
+
row_count: meta[:row_count],
|
|
402
|
+
columns: Array(meta[:columns]).map { |col| { name: col[:name], type: col[:type] } },
|
|
403
|
+
time_columns: meta[:time_columns],
|
|
404
|
+
foreign_keys: Array(meta[:foreign_keys]).map { |fk| fk.slice(:table, :from, :to) },
|
|
405
|
+
categorical_values: meta[:categorical_values]
|
|
406
|
+
}
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def normalize(candidates)
|
|
411
|
+
candidates.map do |kpi|
|
|
412
|
+
h = kpi.transform_keys(&:to_sym)
|
|
413
|
+
h[:sql] = h[:sql].to_s.strip.sub(/;\s*\z/, "")
|
|
414
|
+
h[:grain] = h[:grain].to_s
|
|
415
|
+
h[:source] ||= "heuristics"
|
|
416
|
+
h[:location_column] = nil if h[:location_column].to_s.match?(/\A(null|nil|none)?\z/i)
|
|
417
|
+
h
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "connection"
|
|
5
|
+
require_relative "metric_evaluator"
|
|
6
|
+
|
|
7
|
+
module KPIAssembler
|
|
8
|
+
class CertificationEngine
|
|
9
|
+
attr_reader :db, :schema, :evaluator
|
|
10
|
+
|
|
11
|
+
def initialize(db, schema, now: Time.now.utc, tenant_column: nil, tenant_id: nil)
|
|
12
|
+
@db = Connection.wrap(db)
|
|
13
|
+
@schema = schema
|
|
14
|
+
@now = now
|
|
15
|
+
@tenant_column = tenant_column
|
|
16
|
+
@tenant_id = tenant_id
|
|
17
|
+
@evaluator = MetricEvaluator.new(@db)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def certify(candidates)
|
|
21
|
+
certified = []
|
|
22
|
+
drafts = []
|
|
23
|
+
|
|
24
|
+
candidates.each do |kpi|
|
|
25
|
+
reasons = collect_reasons(kpi)
|
|
26
|
+
record = kpi.merge(certified_at: @now.iso8601)
|
|
27
|
+
|
|
28
|
+
if reasons.empty?
|
|
29
|
+
certified << record.merge(status: "certified")
|
|
30
|
+
else
|
|
31
|
+
drafts << record.merge(status: "draft", reasons: reasons)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
[certified, drafts]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def collect_reasons(kpi)
|
|
41
|
+
reasons = []
|
|
42
|
+
sql = kpi[:sql].to_s
|
|
43
|
+
|
|
44
|
+
reasons << "Only one read-only SELECT statement is allowed" unless read_only_select?(sql)
|
|
45
|
+
reasons << "Missing aggregation grain" if kpi[:grain].to_s.strip.empty?
|
|
46
|
+
reasons.concat(schema_reasons(sql))
|
|
47
|
+
reasons.concat(tenant_scope_reasons(kpi, sql))
|
|
48
|
+
reasons << "JOIN without ON — unconstrained join / cartesian risk" if join_without_on?(sql)
|
|
49
|
+
reasons << "Unsafe division without NULLIF or CASE" if unsafe_division?(sql)
|
|
50
|
+
reasons.concat(execution_reasons(kpi))
|
|
51
|
+
reasons
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def schema_reasons(sql)
|
|
55
|
+
unknown = referenced_tables(sql) - schema.keys.map(&:to_s)
|
|
56
|
+
return [] if unknown.empty?
|
|
57
|
+
|
|
58
|
+
["References unknown tables: #{unknown.join(", ")}"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def referenced_tables(sql)
|
|
62
|
+
sql.scan(/\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)/i).flatten.map(&:downcase).uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_only_select?(sql)
|
|
66
|
+
statement = sql.strip
|
|
67
|
+
return false unless statement.match?(/\A(?:SELECT|WITH)\b/i)
|
|
68
|
+
return false if statement.sub(/;\s*\z/, "").include?(";")
|
|
69
|
+
|
|
70
|
+
!statement.match?(/\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|COPY)\b/i)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tenant_scope_reasons(kpi, sql)
|
|
74
|
+
return [] unless @tenant_column && @tenant_id
|
|
75
|
+
|
|
76
|
+
fact_table = kpi[:fact_table].to_s
|
|
77
|
+
columns = Array(schema.dig(fact_table, :columns)).map { |column| column[:name] }
|
|
78
|
+
return [] unless columns.include?(@tenant_column.to_s)
|
|
79
|
+
|
|
80
|
+
pattern = /\b#{Regexp.escape(@tenant_column.to_s)}\s*=\s*#{Regexp.escape(@tenant_id.to_s)}\b/i
|
|
81
|
+
return [] if sql.match?(pattern)
|
|
82
|
+
|
|
83
|
+
["Missing required tenant scope: #{@tenant_column} = #{@tenant_id}"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def join_without_on?(sql)
|
|
87
|
+
sql.split(/\bJOIN\b/i).drop(1).any? { |fragment| !fragment.match?(/\bON\b/i) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unsafe_division?(sql)
|
|
91
|
+
return false unless sql.include?("/")
|
|
92
|
+
|
|
93
|
+
!sql.match?(/\bNULLIF\b/i) && !sql.match?(/\bCASE\b/i)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def execution_reasons(kpi)
|
|
97
|
+
reasons = []
|
|
98
|
+
from = @now - (30 * 86_400)
|
|
99
|
+
to = @now
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
plan_sql = evaluator.scoped_sql(kpi).gsub("?", "'2000-01-01T00:00:00Z'")
|
|
103
|
+
db.explain(plan_sql)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
reasons << "SQL syntax or planner error: #{e.message}"
|
|
106
|
+
return reasons
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
value = evaluator.value(kpi, from: from, to: to)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
reasons << "Execution failed on sample period: #{e.message}"
|
|
113
|
+
return reasons
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if value.nil?
|
|
117
|
+
reasons << "Sample-period replay returned NULL"
|
|
118
|
+
elsif !value.finite?
|
|
119
|
+
reasons << "Sample-period replay returned non-finite value"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
reasons
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|