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,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,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
|