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