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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +83 -0
  4. data/app/controllers/kpi_assembler/api/v1/workspace_controller.rb +67 -0
  5. data/app/controllers/kpi_assembler/application_controller.rb +19 -0
  6. data/app/controllers/kpi_assembler/workspace_controller.rb +17 -0
  7. data/bin/kpi_assembler +77 -0
  8. data/bin/kpi_assembler_web +19 -0
  9. data/config/routes.rb +17 -0
  10. data/docs/integration.md +89 -0
  11. data/docs/rails-engine.md +92 -0
  12. data/lib/generators/kpi_assembler/install_generator.rb +25 -0
  13. data/lib/generators/kpi_assembler/templates/kpi_assembler.rb +47 -0
  14. data/lib/kpi_assembler/asset_server.rb +42 -0
  15. data/lib/kpi_assembler/briefing_generator.rb +108 -0
  16. data/lib/kpi_assembler/candidate_generator.rb +421 -0
  17. data/lib/kpi_assembler/certification_engine.rb +125 -0
  18. data/lib/kpi_assembler/configuration.rb +115 -0
  19. data/lib/kpi_assembler/connection.rb +140 -0
  20. data/lib/kpi_assembler/engine.rb +18 -0
  21. data/lib/kpi_assembler/env.rb +40 -0
  22. data/lib/kpi_assembler/html_report.rb +177 -0
  23. data/lib/kpi_assembler/location_directory.rb +32 -0
  24. data/lib/kpi_assembler/metric_evaluator.rb +62 -0
  25. data/lib/kpi_assembler/pack_builder.rb +53 -0
  26. data/lib/kpi_assembler/sample_database.rb +191 -0
  27. data/lib/kpi_assembler/schema_inspector.rb +197 -0
  28. data/lib/kpi_assembler/schema_kpi_proposer.rb +323 -0
  29. data/lib/kpi_assembler/version.rb +5 -0
  30. data/lib/kpi_assembler/web_app.rb +141 -0
  31. data/lib/kpi_assembler/web_service.rb +138 -0
  32. data/lib/kpi_assembler.rb +162 -0
  33. data/public/app.css +458 -0
  34. data/public/app.js +372 -0
  35. data/public/index.html +240 -0
  36. data/public/kpi-assembler.js +75 -0
  37. 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