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,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KPIAssembler
4
+ # Builds candidate KPI recipes from whatever tables were introspected.
5
+ # Recognized schema patterns are overlays on top of generic volume, rate,
6
+ # and revenue guesses.
7
+ class SchemaKpiProposer
8
+ SUCCESS_STATUSES = /converted|won|sold|closed|paid|complete|active|member/i.freeze
9
+ MONEY = /amount|price|total|revenue|fee|mrr|sales_amount/i.freeze
10
+ LOCATION = %w[location_id company_id site_id club_id].freeze
11
+
12
+ def initialize(schema, options = {})
13
+ @schema = schema
14
+ @options = options
15
+ end
16
+
17
+ def propose
18
+ candidates = []
19
+ candidates.concat(sample_funnel_candidates) if sample_funnel_schema?
20
+ candidates.concat(people_activity_candidates) if people_activity_schema?
21
+ candidates.concat(generic_candidates)
22
+ candidates.uniq { |kpi| kpi[:id] }.first(14)
23
+ end
24
+
25
+ private
26
+
27
+ def sample_funnel_schema?
28
+ %w[leads appointments memberships invoices locations].all? { |table| @schema.key?(table) }
29
+ end
30
+
31
+ def people_activity_schema?
32
+ table_has?("people", %w[created_at]) && table_has?("activities", %w[start])
33
+ end
34
+
35
+ def sample_funnel_candidates
36
+ [
37
+ kpi(
38
+ id: "lead_conversion_rate",
39
+ name: "Lead to Member Conversion Rate",
40
+ category: "Sales Funnel",
41
+ owner: "sales",
42
+ english: "Share of leads created in the period that converted to a membership.",
43
+ sql: period_sql("leads", "created_at", "(CAST(COUNT(CASE WHEN status = 'converted' THEN 1 END) AS FLOAT) / NULLIF(COUNT(*), 0)) * 100"),
44
+ grain: "daily",
45
+ location_column: "location_id",
46
+ fact_table: "leads"
47
+ ),
48
+ kpi(
49
+ id: "tour_show_rate",
50
+ name: "Tour Show Rate",
51
+ category: "Sales Funnel",
52
+ owner: "sales",
53
+ english: "Share of scheduled tours that were completed (shown), excluding cancellations from the denominator.",
54
+ sql: period_sql("appointments", "scheduled_at", "(CAST(COUNT(CASE WHEN status = 'completed' THEN 1 END) AS FLOAT) / NULLIF(COUNT(CASE WHEN status IN ('completed', 'no_show') THEN 1 END), 0)) * 100"),
55
+ grain: "daily",
56
+ location_column: "location_id",
57
+ fact_table: "appointments"
58
+ ),
59
+ kpi(
60
+ id: "no_show_rate",
61
+ name: "Tour No-Show Rate",
62
+ category: "Sales Funnel",
63
+ owner: "site_manager",
64
+ english: "Share of scheduled tours marked no_show among completed + no_show visits.",
65
+ sql: period_sql("appointments", "scheduled_at", "(CAST(COUNT(CASE WHEN status = 'no_show' THEN 1 END) AS FLOAT) / NULLIF(COUNT(CASE WHEN status IN ('completed', 'no_show') THEN 1 END), 0)) * 100"),
66
+ grain: "daily",
67
+ location_column: "location_id",
68
+ fact_table: "appointments"
69
+ ),
70
+ kpi(
71
+ id: "paid_mrr",
72
+ name: "Paid Monthly Recurring Revenue",
73
+ category: "Revenue",
74
+ owner: "exec",
75
+ english: "Sum of paid invoice amounts billed in the period.",
76
+ sql: period_sql("invoices", "billed_at", "COALESCE(SUM(amount), 0)", extra: "status = 'paid'"),
77
+ grain: "monthly",
78
+ location_column: "location_id",
79
+ fact_table: "invoices"
80
+ ),
81
+ kpi(
82
+ id: "avg_revenue_per_member",
83
+ name: "Average Revenue Per Member",
84
+ category: "Revenue",
85
+ owner: "exec",
86
+ english: "Paid invoice amount divided by distinct memberships billed in the period.",
87
+ sql: period_sql("invoices", "billed_at", "SUM(amount) / NULLIF(COUNT(DISTINCT membership_id), 0)", extra: "status = 'paid'"),
88
+ grain: "monthly",
89
+ location_column: "location_id",
90
+ fact_table: "invoices"
91
+ ),
92
+ kpi(
93
+ id: "active_memberships",
94
+ name: "Active Memberships",
95
+ category: "Retention",
96
+ owner: "exec",
97
+ english: "Memberships that started before period end and were not cancelled before period start.",
98
+ sql: "SELECT COUNT(*) AS metric_value FROM memberships WHERE started_at < ? AND (cancelled_at IS NULL OR cancelled_at >= ?)#{tenant_and("memberships")}",
99
+ grain: "daily",
100
+ location_column: location_column_for("memberships"),
101
+ fact_table: "memberships",
102
+ bind_style: "end_then_start"
103
+ ),
104
+ kpi(
105
+ id: "revenue_per_lead_unsafe",
106
+ name: "Revenue per Lead (unconstrained join)",
107
+ category: "Revenue",
108
+ owner: "finance",
109
+ english: "Invoice revenue divided by leads using an unconstrained join — expected to fail certification.",
110
+ sql: "SELECT SUM(invoices.amount) / COUNT(leads.id) AS metric_value FROM leads JOIN invoices WHERE leads.created_at >= ? AND leads.created_at < ?",
111
+ grain: "monthly",
112
+ location_column: "leads.location_id",
113
+ fact_table: "leads"
114
+ )
115
+ ]
116
+ end
117
+
118
+ def people_activity_candidates
119
+ out = []
120
+ people_loc = location_column_for("people")
121
+ activity_loc = location_column_for("activities")
122
+
123
+ out << kpi(
124
+ id: "new_leads",
125
+ name: "New leads",
126
+ category: "Sales Funnel",
127
+ owner: "sales",
128
+ english: "People records created in the period.",
129
+ sql: period_sql("people", "created_at", "COUNT(*)", extra: soft_delete("people")),
130
+ grain: "daily",
131
+ location_column: people_loc,
132
+ fact_table: "people"
133
+ )
134
+
135
+ if column?("people", "sale_at")
136
+ out << kpi(
137
+ id: "lead_close_rate",
138
+ name: "Lead close rate",
139
+ category: "Sales Funnel",
140
+ owner: "sales",
141
+ english: "Share of people created in the period that have a sale_at timestamp.",
142
+ sql: period_sql(
143
+ "people",
144
+ "created_at",
145
+ "(CAST(COUNT(CASE WHEN sale_at IS NOT NULL THEN 1 END) AS FLOAT) / NULLIF(COUNT(*), 0)) * 100",
146
+ extra: soft_delete("people")
147
+ ),
148
+ grain: "daily",
149
+ location_column: people_loc,
150
+ fact_table: "people"
151
+ )
152
+ end
153
+
154
+ extra = [soft_delete("activities"), "type = 'Event'"].compact.reject(&:empty?).join(" AND ")
155
+ extra = nil if extra.empty?
156
+ out << kpi(
157
+ id: "scheduled_appointments",
158
+ name: "Scheduled appointments",
159
+ category: "Sales Funnel",
160
+ owner: "sales",
161
+ english: "Sales appointments (activities.type = Event) starting in the period.",
162
+ sql: period_sql("activities", "start", "COUNT(*)", extra: extra),
163
+ grain: "daily",
164
+ location_column: activity_loc,
165
+ fact_table: "activities"
166
+ )
167
+
168
+ if column?("activities", "complete")
169
+ out << kpi(
170
+ id: "appointment_completion_rate",
171
+ name: "Appointment completion rate",
172
+ category: "Sales Funnel",
173
+ owner: "site_manager",
174
+ english: "Share of period appointments marked complete.",
175
+ sql: period_sql(
176
+ "activities",
177
+ "start",
178
+ "(CAST(COUNT(CASE WHEN complete THEN 1 END) AS FLOAT) / NULLIF(COUNT(*), 0)) * 100",
179
+ extra: extra
180
+ ),
181
+ grain: "daily",
182
+ location_column: activity_loc,
183
+ fact_table: "activities"
184
+ )
185
+ end
186
+
187
+ if column?("activities", "sales_amount")
188
+ out << kpi(
189
+ id: "appointment_sales_amount",
190
+ name: "Appointment sales amount",
191
+ category: "Revenue",
192
+ owner: "exec",
193
+ english: "Sum of sales_amount on appointments starting in the period.",
194
+ sql: period_sql("activities", "start", "COALESCE(SUM(sales_amount), 0)", extra: extra),
195
+ grain: "monthly",
196
+ location_column: activity_loc,
197
+ fact_table: "activities"
198
+ )
199
+ end
200
+
201
+ out
202
+ end
203
+
204
+ def generic_candidates
205
+ @schema.filter_map do |table, meta|
206
+ next if sample_funnel_schema? && %w[leads appointments memberships invoices locations].include?(table)
207
+ next if people_activity_schema? && %w[people activities companies].include?(table)
208
+
209
+ time = pick_time(meta)
210
+ next unless time
211
+
212
+ money = meta[:columns].map { |col| col[:name] }.find { |name| name.match?(MONEY) }
213
+ status_col = meta[:columns].map { |col| col[:name] }.find { |name| name.match?(/\A(status|state|outcome)\z/i) }
214
+ loc = location_column_for(table)
215
+ success = success_value(meta, status_col)
216
+
217
+ if money
218
+ extra = status_col && (meta.dig(:categorical_values, status_col) || []).any? { |value| value.to_s.match?(/paid/i) } ? "#{status_col} = 'paid'" : nil
219
+ kpi(
220
+ id: "#{table}_#{money}_sum",
221
+ name: "#{human(table)} #{human(money)}",
222
+ category: "Revenue",
223
+ owner: "exec",
224
+ english: "Sum of #{money} on #{table} in the period.",
225
+ sql: period_sql(table, time, "COALESCE(SUM(#{money}), 0)", extra: extra),
226
+ grain: "monthly",
227
+ location_column: loc,
228
+ fact_table: table
229
+ )
230
+ elsif success && status_col
231
+ kpi(
232
+ id: "#{table}_#{success}_rate",
233
+ name: "#{human(table)} #{human(success)} rate",
234
+ category: "Conversion",
235
+ owner: "ops",
236
+ english: "Share of #{table} rows whose #{status_col} is #{success}.",
237
+ sql: period_sql(
238
+ table,
239
+ time,
240
+ "(CAST(COUNT(CASE WHEN #{status_col} = '#{success}' THEN 1 END) AS FLOAT) / NULLIF(COUNT(*), 0)) * 100"
241
+ ),
242
+ grain: "daily",
243
+ location_column: loc,
244
+ fact_table: table
245
+ )
246
+ else
247
+ kpi(
248
+ id: "#{table}_volume",
249
+ name: "#{human(table)} volume",
250
+ category: "Operations",
251
+ owner: "ops",
252
+ english: "Row count of #{table} in the period.",
253
+ sql: period_sql(table, time, "COUNT(*)", extra: soft_delete(table)),
254
+ grain: "daily",
255
+ location_column: loc,
256
+ fact_table: table
257
+ )
258
+ end
259
+ end
260
+ end
261
+
262
+ def pick_time(meta)
263
+ preferred = %w[created_at billed_at scheduled_at started_at occurred_at start completed_at]
264
+ (preferred & meta[:time_columns]) .first || meta[:time_columns].first
265
+ end
266
+
267
+ def success_value(meta, status_col)
268
+ return unless status_col
269
+
270
+ Array(meta.dig(:categorical_values, status_col)).map(&:to_s).find { |value| value.match?(SUCCESS_STATUSES) }
271
+ end
272
+
273
+ def location_column_for(table)
274
+ names = Array(@schema.dig(table, :columns)).map { |col| col[:name] }
275
+ LOCATION.find { |column| names.include?(column) }
276
+ end
277
+
278
+ def table_has?(table, columns)
279
+ return false unless @schema.key?(table)
280
+
281
+ names = @schema[table][:columns].map { |col| col[:name] }
282
+ columns.all? { |column| names.include?(column) }
283
+ end
284
+
285
+ def column?(table, name)
286
+ Array(@schema.dig(table, :columns)).any? { |col| col[:name] == name }
287
+ end
288
+
289
+ def soft_delete(table)
290
+ if column?(table, "deleted_at")
291
+ "deleted_at IS NULL"
292
+ elsif column?(table, "deleted")
293
+ "deleted = false"
294
+ end
295
+ end
296
+
297
+ def tenant_clause(table)
298
+ column = @options[:tenant_column]
299
+ id = @options[:tenant_id]
300
+ return unless column && id && column?(table, column)
301
+
302
+ "#{column} = #{Integer(id)}"
303
+ end
304
+
305
+ def tenant_and(table)
306
+ clause = tenant_clause(table)
307
+ clause ? " AND #{clause}" : ""
308
+ end
309
+
310
+ def period_sql(table, time_column, expression, extra: nil)
311
+ clauses = ["#{time_column} >= ?", "#{time_column} < ?", extra, tenant_clause(table)].compact.reject { |part| part.to_s.empty? }
312
+ "SELECT #{expression} AS metric_value FROM #{table} WHERE #{clauses.join(" AND ")}"
313
+ end
314
+
315
+ def human(value)
316
+ value.to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
317
+ end
318
+
319
+ def kpi(attrs)
320
+ attrs.merge(formula_description: attrs.delete(:english) || attrs[:formula_description])
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KPIAssembler
4
+ VERSION = "0.5.0"
5
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require_relative "../kpi_assembler"
5
+ require_relative "connection"
6
+ require_relative "web_service"
7
+
8
+ module KPIAssembler
9
+ class WebApp < Sinatra::Base
10
+ set :root, File.expand_path("../..", __dir__)
11
+ set :public_folder, File.join(root, "public")
12
+ set :static, true
13
+ set :show_exceptions, false
14
+
15
+ configure do
16
+ connection = Connection.open
17
+ include_tables = ENV.fetch("KPI_INCLUDE_TABLES", "").split(",").map(&:strip).reject(&:empty?)
18
+ tenant_column = ENV["KPI_TENANT_COLUMN"]
19
+ tenant_id = ENV["KPI_TENANT_ID"]
20
+
21
+ set :web_service, WebService.new(
22
+ db: connection,
23
+ options: {
24
+ llm_provider: ENV.fetch("KPI_LLM_PROVIDER", ENV["GEMINI_API_KEY"] ? "gemini" : "ollama"),
25
+ use_llm: ENV["KPI_USE_LLM"] != "false",
26
+ use_ollama: ENV["KPI_USE_OLLAMA"] != "false",
27
+ ollama_model: ENV.fetch("KPI_OLLAMA_MODEL", "llama3"),
28
+ gemini_api_key: ENV["GEMINI_API_KEY"],
29
+ gemini_model: ENV.fetch("KPI_GEMINI_MODEL", "gemini-2.0-flash"),
30
+ source_name: connection.label,
31
+ include_tables: include_tables,
32
+ max_tables: ENV["KPI_MAX_TABLES"],
33
+ schema_name: ENV["KPI_DB_SCHEMA"],
34
+ tenant_column: tenant_column,
35
+ tenant_id: tenant_id && !tenant_id.empty? ? Integer(tenant_id) : nil
36
+ }
37
+ )
38
+ end
39
+
40
+ before "/api/*" do
41
+ content_type :json
42
+ headers(
43
+ "Access-Control-Allow-Origin" => ENV.fetch("KPI_ALLOWED_ORIGIN", "*"),
44
+ "Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
45
+ "Access-Control-Allow-Headers" => "Content-Type, Authorization",
46
+ "Cache-Control" => "no-store"
47
+ )
48
+ end
49
+
50
+ options "/api/*" do
51
+ 204
52
+ end
53
+
54
+ get "/" do
55
+ send_file File.join(settings.public_folder, "index.html")
56
+ end
57
+
58
+ get "/api/v1/health" do
59
+ json(
60
+ status: "ok",
61
+ service: "KPIAssembler",
62
+ version: KPIAssembler::VERSION,
63
+ source: settings.web_service.db.label,
64
+ dialect: settings.web_service.db.dialect,
65
+ principle: "The LLM proposes; deterministic code certifies."
66
+ )
67
+ end
68
+
69
+ post "/api/v1/discover" do
70
+ json(settings.web_service.discover)
71
+ end
72
+
73
+ get "/api/v1/discover" do
74
+ json(settings.web_service.discover)
75
+ end
76
+
77
+ post "/api/v1/certify" do
78
+ payload = parse_json_body
79
+ pack = settings.web_service.certify(payload["accepted_ids"])
80
+ json(pack.merge(locations: settings.web_service.locations))
81
+ end
82
+
83
+ get "/api/v1/pack" do
84
+ pack = settings.web_service.pack
85
+ halt 404, json(error: "No pack has been certified yet") unless pack
86
+
87
+ json(pack.merge(locations: settings.web_service.locations))
88
+ end
89
+
90
+ get "/api/v1/integration" do
91
+ json(
92
+ api_version: "v1",
93
+ discovery_endpoint: "/api/v1/discover",
94
+ certification_endpoint: "/api/v1/certify",
95
+ pack_endpoint: "/api/v1/pack",
96
+ embed_script: "/kpi-assembler.js",
97
+ database_configuration: {
98
+ url: "KPI_DATABASE_URL=postgres://user:pass@host:5432/application",
99
+ sqlite: "KPI_DATABASE_PATH=/path/to/application.sqlite3",
100
+ tables: "KPI_INCLUDE_TABLES=orders,customers,accounts",
101
+ tenant: "KPI_TENANT_COLUMN=account_id KPI_TENANT_ID=123"
102
+ }
103
+ )
104
+ end
105
+
106
+ not_found do
107
+ content_type :json if request.path.start_with?("/api/")
108
+ json(error: "Not found", path: request.path)
109
+ end
110
+
111
+ error KPIAssembler::Error do
112
+ status 422
113
+ json(error: env["sinatra.error"].message)
114
+ end
115
+
116
+ error JSON::ParserError do
117
+ status 400
118
+ json(error: "Request body must be valid JSON")
119
+ end
120
+
121
+ error do
122
+ error = env["sinatra.error"]
123
+ warn "[KPIAssembler] #{error.class}: #{error.message}"
124
+ status 500
125
+ json(error: "Unexpected server error")
126
+ end
127
+
128
+ private
129
+
130
+ def parse_json_body
131
+ raw = request.body.read
132
+ return {} if raw.empty?
133
+
134
+ JSON.parse(raw)
135
+ end
136
+
137
+ def json(payload)
138
+ JSON.generate(payload)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+ require_relative "connection"
5
+ require_relative "location_directory"
6
+
7
+ module KPIAssembler
8
+ class WebService
9
+ attr_reader :db, :options
10
+
11
+ def initialize(db:, options: {})
12
+ @db = Connection.wrap(db)
13
+ @options = options
14
+ @mutex = Mutex.new
15
+ @schema = nil
16
+ @candidates = nil
17
+ @pack = nil
18
+ end
19
+
20
+ def discover
21
+ @mutex.synchronize { run_discovery }
22
+ end
23
+
24
+ def certify(accepted_ids)
25
+ @mutex.synchronize do
26
+ ids = Array(accepted_ids).map(&:to_s).uniq
27
+ raise Error, "Select at least one KPI to certify" if ids.empty?
28
+
29
+ # Discovery state lives in process memory, so a certify request can reach a
30
+ # worker that never served the matching discover request. Rebuild rather
31
+ # than rejecting a selection the user legitimately made.
32
+ run_discovery unless @schema && @candidates
33
+
34
+ known_ids = @candidates.map { |candidate| candidate[:id].to_s }
35
+ unknown_ids = ids - known_ids
36
+ raise Error, "Unknown KPI IDs: #{unknown_ids.join(', ')}" unless unknown_ids.empty?
37
+
38
+ selected = @candidates.select { |candidate| ids.include?(candidate[:id].to_s) }
39
+ now = options[:now] || Time.now.utc
40
+ certified, drafts = CertificationEngine.new(
41
+ db,
42
+ @schema,
43
+ now: now,
44
+ tenant_column: options[:tenant_column],
45
+ tenant_id: options[:tenant_id]
46
+ ).certify(selected)
47
+ evaluations = evaluate_views(certified, now)
48
+ briefing = BriefingGenerator.new(db, certified, now: now, tenant_id: options[:tenant_id]).generate
49
+
50
+ @pack = PackBuilder.new(
51
+ certified,
52
+ drafts: drafts,
53
+ briefing: briefing,
54
+ evaluations: evaluations,
55
+ schema: @schema
56
+ ).build
57
+ end
58
+ end
59
+
60
+ def pack
61
+ @mutex.synchronize { @pack }
62
+ end
63
+
64
+ def locations
65
+ LocationDirectory.new(db, tenant_id: options[:tenant_id]).all
66
+ end
67
+
68
+ private
69
+
70
+ def run_discovery
71
+ inspector = SchemaInspector.new(db, inspector_options)
72
+ @schema = inspector.introspect
73
+ generator = CandidateGenerator.new(@schema, generator_options)
74
+ @candidates = generator.generate
75
+ @pack = nil
76
+
77
+ {
78
+ source: options[:source_name] || db.label,
79
+ dialect: db.dialect,
80
+ schema_name: db.postgres? ? inspector_options[:schema_name] : nil,
81
+ tables: schema_summary,
82
+ missing_tables: inspector.missing_tables,
83
+ omitted_tables: inspector.omitted_tables,
84
+ max_tables: inspector_options[:max_tables],
85
+ proposer: generator.meta[:proposer],
86
+ proposer_model: generator.meta[:model],
87
+ proposer_fallback: generator.meta[:fallback_reason],
88
+ ranked_tables: generator.meta[:ranked_tables],
89
+ candidates: @candidates
90
+ }
91
+ end
92
+
93
+ def inspector_options
94
+ {
95
+ include_tables: Array(options[:include_tables]),
96
+ max_tables: options[:max_tables],
97
+ schema_name: options[:schema_name]
98
+ }
99
+ end
100
+
101
+ def generator_options
102
+ options.merge(dialect: db.dialect)
103
+ end
104
+
105
+ def schema_summary
106
+ @schema.map do |name, metadata|
107
+ {
108
+ name: name,
109
+ row_count: metadata[:row_count],
110
+ columns: metadata[:columns].map { |column| column.slice(:name, :type, :pk) },
111
+ foreign_keys: metadata[:foreign_keys],
112
+ time_columns: metadata[:time_columns]
113
+ }
114
+ end
115
+ end
116
+
117
+ def evaluate_views(certified, now)
118
+ evaluator = MetricEvaluator.new(db)
119
+ from = now - (30 * 86_400)
120
+ result = { "all" => values_for(evaluator, certified, from, now, nil) }
121
+ locations.each do |location|
122
+ result[location[:id].to_s] = values_for(evaluator, certified, from, now, location[:id])
123
+ end
124
+ result
125
+ end
126
+
127
+ def values_for(evaluator, certified, from, to, location_id)
128
+ certified.each_with_object({}) do |kpi, result|
129
+ result[kpi[:id]] = evaluator.value(
130
+ kpi,
131
+ from: from,
132
+ to: to,
133
+ location_id: location_id
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end