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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module KPIAssembler
|
|
7
|
+
# In-memory CRM funnel used for local demonstrations and tests.
|
|
8
|
+
# Grain: lead → appointment (tour) → membership → invoice, with a location dimension.
|
|
9
|
+
class SampleDatabase
|
|
10
|
+
LOCATIONS = [
|
|
11
|
+
{ id: 1, name: "Downtown", region: "West" },
|
|
12
|
+
{ id: 2, name: "Riverside", region: "East" },
|
|
13
|
+
{ id: 3, name: "Hillside", region: "West" }
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
SOURCES = %w[walk_in web referral paid_ads].freeze
|
|
17
|
+
LEAD_STATUSES = %w[open touring converted lost].freeze
|
|
18
|
+
|
|
19
|
+
def self.build(seed: 2026)
|
|
20
|
+
new(seed: seed).build
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(seed: 2026)
|
|
24
|
+
@rng = Random.new(seed)
|
|
25
|
+
@now = Time.now.utc
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build
|
|
29
|
+
db = SQLite3::Database.new(":memory:")
|
|
30
|
+
db.execute("PRAGMA foreign_keys = ON;")
|
|
31
|
+
create_schema(db)
|
|
32
|
+
seed(db)
|
|
33
|
+
db
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def create_schema(db)
|
|
39
|
+
db.execute_batch(<<~SQL)
|
|
40
|
+
CREATE TABLE locations (
|
|
41
|
+
id INTEGER PRIMARY KEY,
|
|
42
|
+
name TEXT NOT NULL,
|
|
43
|
+
region TEXT NOT NULL
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE leads (
|
|
47
|
+
id INTEGER PRIMARY KEY,
|
|
48
|
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
|
49
|
+
name TEXT NOT NULL,
|
|
50
|
+
source TEXT NOT NULL,
|
|
51
|
+
status TEXT NOT NULL,
|
|
52
|
+
created_at TEXT NOT NULL,
|
|
53
|
+
converted_at TEXT
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE appointments (
|
|
57
|
+
id INTEGER PRIMARY KEY,
|
|
58
|
+
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
|
59
|
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
|
60
|
+
scheduled_at TEXT NOT NULL,
|
|
61
|
+
status TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE memberships (
|
|
65
|
+
id INTEGER PRIMARY KEY,
|
|
66
|
+
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
|
67
|
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
|
68
|
+
started_at TEXT NOT NULL,
|
|
69
|
+
cancelled_at TEXT,
|
|
70
|
+
monthly_fee REAL NOT NULL
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE invoices (
|
|
74
|
+
id INTEGER PRIMARY KEY,
|
|
75
|
+
membership_id INTEGER NOT NULL REFERENCES memberships(id),
|
|
76
|
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
|
77
|
+
amount REAL NOT NULL,
|
|
78
|
+
status TEXT NOT NULL,
|
|
79
|
+
billed_at TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
SQL
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def seed(db)
|
|
85
|
+
LOCATIONS.each do |loc|
|
|
86
|
+
db.execute(
|
|
87
|
+
"INSERT INTO locations (id, name, region) VALUES (?, ?, ?)",
|
|
88
|
+
[loc[:id], loc[:name], loc[:region]]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
lead_id = 0
|
|
93
|
+
appt_id = 0
|
|
94
|
+
membership_id = 0
|
|
95
|
+
invoice_id = 0
|
|
96
|
+
|
|
97
|
+
180.times do |i|
|
|
98
|
+
lead_id += 1
|
|
99
|
+
location_id = 1 + (i % 3)
|
|
100
|
+
created = days_ago(90 - (i % 90))
|
|
101
|
+
source = SOURCES[i % SOURCES.length]
|
|
102
|
+
roll = @rng.rand
|
|
103
|
+
|
|
104
|
+
# Riverside (2) has a higher no-show / lower convert rate so briefings have a story.
|
|
105
|
+
convert_cut = location_id == 2 ? 0.28 : 0.42
|
|
106
|
+
tour_cut = location_id == 2 ? 0.55 : 0.70
|
|
107
|
+
|
|
108
|
+
status =
|
|
109
|
+
if roll < convert_cut
|
|
110
|
+
"converted"
|
|
111
|
+
elsif roll < tour_cut
|
|
112
|
+
"touring"
|
|
113
|
+
elsif roll < 0.88
|
|
114
|
+
"lost"
|
|
115
|
+
else
|
|
116
|
+
"open"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
converted_at = status == "converted" ? (created + (3 + @rng.rand(14)) * 86_400) : nil
|
|
120
|
+
converted_at = [@now, converted_at].compact.min if converted_at
|
|
121
|
+
|
|
122
|
+
db.execute(
|
|
123
|
+
"INSERT INTO leads (id, location_id, name, source, status, created_at, converted_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
125
|
+
[lead_id, location_id, "Lead #{lead_id}", source, status, iso(created), converted_at && iso(converted_at)]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
next unless %w[touring converted lost].include?(status)
|
|
129
|
+
|
|
130
|
+
appt_id += 1
|
|
131
|
+
scheduled = created + (1 + @rng.rand(10)) * 86_400
|
|
132
|
+
scheduled = [@now, scheduled].min
|
|
133
|
+
appt_status =
|
|
134
|
+
if status == "converted"
|
|
135
|
+
"completed"
|
|
136
|
+
elsif location_id == 2 && @rng.rand < 0.45
|
|
137
|
+
"no_show"
|
|
138
|
+
elsif @rng.rand < 0.18
|
|
139
|
+
"no_show"
|
|
140
|
+
elsif @rng.rand < 0.08
|
|
141
|
+
"cancelled"
|
|
142
|
+
else
|
|
143
|
+
"completed"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
db.execute(
|
|
147
|
+
"INSERT INTO appointments (id, lead_id, location_id, scheduled_at, status)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?)",
|
|
149
|
+
[appt_id, lead_id, location_id, iso(scheduled), appt_status]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
next unless status == "converted"
|
|
153
|
+
|
|
154
|
+
membership_id += 1
|
|
155
|
+
started = converted_at
|
|
156
|
+
fee = [39.0, 49.0, 59.0, 79.0][i % 4]
|
|
157
|
+
cancelled_at = nil
|
|
158
|
+
if @rng.rand < 0.12 && started < days_ago(20)
|
|
159
|
+
cancelled_at = started + (20 + @rng.rand(40)) * 86_400
|
|
160
|
+
cancelled_at = [@now, cancelled_at].min
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
db.execute(
|
|
164
|
+
"INSERT INTO memberships (id, lead_id, location_id, started_at, cancelled_at, monthly_fee)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?)",
|
|
166
|
+
[membership_id, lead_id, location_id, iso(started), cancelled_at && iso(cancelled_at), fee]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
bill = started
|
|
170
|
+
while bill < @now && bill < (cancelled_at || @now)
|
|
171
|
+
invoice_id += 1
|
|
172
|
+
paid = bill < days_ago(2) || @rng.rand < 0.9
|
|
173
|
+
db.execute(
|
|
174
|
+
"INSERT INTO invoices (id, membership_id, location_id, amount, status, billed_at)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?, ?)",
|
|
176
|
+
[invoice_id, membership_id, location_id, fee, paid ? "paid" : "pending", iso(bill)]
|
|
177
|
+
)
|
|
178
|
+
bill += 30 * 86_400
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def days_ago(n)
|
|
184
|
+
@now - (n * 86_400)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def iso(time)
|
|
188
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "connection"
|
|
4
|
+
|
|
5
|
+
module KPIAssembler
|
|
6
|
+
class SchemaInspector
|
|
7
|
+
SKIP_TABLES = %w[
|
|
8
|
+
schema_migrations ar_internal_metadata delayed_jobs versions
|
|
9
|
+
audits audits_archive sessions pghero_space_stats
|
|
10
|
+
oauth_access_grants oauth_access_tokens oauth_applications
|
|
11
|
+
ckeditor_assets friendly_id_slugs data_migrations completed_jobs
|
|
12
|
+
killed_queries old_passwords permissions
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
SKIP_PREFIXES = %w[club_os_ abc_ mb_ pg_].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :db, :options
|
|
18
|
+
|
|
19
|
+
# Configured tables that the database does not actually expose. Populated by
|
|
20
|
+
# #introspect so a typo or a wrong schema surfaces instead of silently
|
|
21
|
+
# narrowing discovery to nothing.
|
|
22
|
+
attr_reader :missing_tables
|
|
23
|
+
|
|
24
|
+
# Tables that exist but were cut by the max_tables budget.
|
|
25
|
+
attr_reader :omitted_tables
|
|
26
|
+
|
|
27
|
+
def initialize(db, options = {})
|
|
28
|
+
@db = Connection.wrap(db)
|
|
29
|
+
@options = options
|
|
30
|
+
@missing_tables = []
|
|
31
|
+
@omitted_tables = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def introspect
|
|
35
|
+
tables = selected_tables
|
|
36
|
+
tables.each_with_object({}) do |table, schema|
|
|
37
|
+
quoted = db.quote_ident(table)
|
|
38
|
+
columns = columns_for(table)
|
|
39
|
+
row_count = row_count_for(table, quoted)
|
|
40
|
+
schema[table] = {
|
|
41
|
+
columns: columns,
|
|
42
|
+
foreign_keys: foreign_keys_for(table),
|
|
43
|
+
row_count: row_count,
|
|
44
|
+
time_columns: columns.select { |col| time_column?(col) }.map { |col| col[:name] },
|
|
45
|
+
categorical_values: profile_categoricals(quoted, columns, row_count),
|
|
46
|
+
sample_rows: sample_rows_for(quoted, row_count)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def selected_tables
|
|
54
|
+
names = all_tables
|
|
55
|
+
include_list = Array(options[:include_tables]).map(&:to_s).reject(&:empty?)
|
|
56
|
+
@missing_tables = include_list - names
|
|
57
|
+
|
|
58
|
+
names = names.select { |name| include_list.include?(name) } unless include_list.empty?
|
|
59
|
+
names = names.reject { |name| skip_table?(name) } if include_list.empty?
|
|
60
|
+
|
|
61
|
+
limit = Integer(options[:max_tables].to_s.empty? ? ENV.fetch("KPI_MAX_TABLES", "30") : options[:max_tables])
|
|
62
|
+
return names if names.size <= limit
|
|
63
|
+
|
|
64
|
+
ranked = names.sort_by { |name| -table_score(name) }
|
|
65
|
+
@omitted_tables = ranked.drop(limit).sort
|
|
66
|
+
ranked.first(limit)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def all_tables
|
|
70
|
+
if db.sqlite?
|
|
71
|
+
db.execute(<<~SQL).flatten
|
|
72
|
+
SELECT name FROM sqlite_master
|
|
73
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
74
|
+
ORDER BY name
|
|
75
|
+
SQL
|
|
76
|
+
else
|
|
77
|
+
schema_name = options[:schema_name] || ENV.fetch("KPI_DB_SCHEMA", "public")
|
|
78
|
+
db.execute(
|
|
79
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = ? ORDER BY tablename",
|
|
80
|
+
[schema_name]
|
|
81
|
+
).flatten
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skip_table?(name)
|
|
86
|
+
return true if SKIP_TABLES.include?(name)
|
|
87
|
+
return true if SKIP_PREFIXES.any? { |prefix| name.start_with?(prefix) }
|
|
88
|
+
return true if name.end_with?("_archive", "_summary", "_caches")
|
|
89
|
+
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def table_score(name)
|
|
94
|
+
score = 0
|
|
95
|
+
score += 10 if name.match?(/people|leads|members|customers|accounts/)
|
|
96
|
+
score += 8 if name.match?(/activit|appoint|event|tours|visits/)
|
|
97
|
+
score += 8 if name.match?(/invoice|payment|order|sale|revenue/)
|
|
98
|
+
score += 6 if name.match?(/compan|location|site|club/)
|
|
99
|
+
score += 4 if name.match?(/membership|subscription/)
|
|
100
|
+
score
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def columns_for(table)
|
|
104
|
+
if db.sqlite?
|
|
105
|
+
db.execute("PRAGMA table_info(#{db.quote_ident(table)})").map do |col|
|
|
106
|
+
{
|
|
107
|
+
cid: col[0],
|
|
108
|
+
name: col[1],
|
|
109
|
+
type: col[2],
|
|
110
|
+
notnull: col[3],
|
|
111
|
+
dflt_value: col[4],
|
|
112
|
+
pk: col[5]
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
rows = db.execute(<<~SQL, [options[:schema_name] || "public", table])
|
|
117
|
+
SELECT ordinal_position, column_name, data_type, is_nullable, column_default
|
|
118
|
+
FROM information_schema.columns
|
|
119
|
+
WHERE table_schema = ? AND table_name = ?
|
|
120
|
+
ORDER BY ordinal_position
|
|
121
|
+
SQL
|
|
122
|
+
rows.map do |row|
|
|
123
|
+
{
|
|
124
|
+
cid: row[0].to_i,
|
|
125
|
+
name: row[1],
|
|
126
|
+
type: row[2],
|
|
127
|
+
notnull: row[3] == "NO" ? 1 : 0,
|
|
128
|
+
dflt_value: row[4],
|
|
129
|
+
pk: row[1] == "id" ? 1 : 0
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def foreign_keys_for(table)
|
|
136
|
+
if db.sqlite?
|
|
137
|
+
db.execute("PRAGMA foreign_key_list(#{db.quote_ident(table)})").map do |fk|
|
|
138
|
+
{ id: fk[0], seq: fk[1], table: fk[2], from: fk[3], to: fk[4] }
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
rows = db.execute(<<~SQL, [options[:schema_name] || "public", table])
|
|
142
|
+
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
|
143
|
+
FROM information_schema.table_constraints tc
|
|
144
|
+
JOIN information_schema.key_column_usage kcu
|
|
145
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
146
|
+
AND tc.table_schema = kcu.table_schema
|
|
147
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
148
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
149
|
+
AND ccu.table_schema = tc.table_schema
|
|
150
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
151
|
+
AND tc.table_schema = ?
|
|
152
|
+
AND tc.table_name = ?
|
|
153
|
+
SQL
|
|
154
|
+
rows.each_with_index.map do |row, index|
|
|
155
|
+
{ id: index, seq: 0, table: row[1], from: row[0], to: row[2] }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def row_count_for(table, quoted)
|
|
161
|
+
if db.postgres?
|
|
162
|
+
estimate = db.execute("SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = ?", [table]).dig(0, 0)
|
|
163
|
+
return estimate.to_i if estimate
|
|
164
|
+
end
|
|
165
|
+
db.execute("SELECT COUNT(*) FROM #{quoted}").dig(0, 0).to_i
|
|
166
|
+
rescue StandardError
|
|
167
|
+
0
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def sample_rows_for(quoted, row_count)
|
|
171
|
+
return [] if row_count > 50_000
|
|
172
|
+
|
|
173
|
+
db.execute("SELECT * FROM #{quoted} LIMIT 3")
|
|
174
|
+
rescue StandardError
|
|
175
|
+
[]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def time_column?(col)
|
|
179
|
+
col[:name].match?(/(_at|_on|date|time)\z/i) ||
|
|
180
|
+
col[:name].match?(/\A(start|created|updated|completed)\z/i) ||
|
|
181
|
+
col[:type].to_s.match?(/DATE|TIME|TIMESTAMP/i)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def profile_categoricals(quoted_table, columns, row_count)
|
|
185
|
+
return {} if row_count > 50_000
|
|
186
|
+
|
|
187
|
+
names = columns.map { |col| col[:name] }.select { |name| name.match?(/status|source|region|type|kind|outcome|state/i) }
|
|
188
|
+
names.each_with_object({}) do |name, acc|
|
|
189
|
+
quoted_col = db.quote_ident(name)
|
|
190
|
+
values = db.execute("SELECT DISTINCT #{quoted_col} FROM #{quoted_table} LIMIT 12").flatten
|
|
191
|
+
acc[name] = values
|
|
192
|
+
rescue StandardError
|
|
193
|
+
acc[name] = []
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|