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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module KPIAssembler
6
+ class Configuration
7
+ attr_accessor :connection_provider,
8
+ :tenant_id_resolver,
9
+ :authorize_with,
10
+ :include_tables,
11
+ :max_tables,
12
+ :schema_name,
13
+ :tenant_column,
14
+ :llm_provider,
15
+ :use_llm,
16
+ :use_ollama,
17
+ :ollama_model,
18
+ :ollama_url,
19
+ :gemini_api_key,
20
+ :gemini_model,
21
+ :gemini_url,
22
+ :parent_controller
23
+
24
+ def initialize
25
+ @connection_provider = lambda do |_controller|
26
+ ActiveRecord::Base.connection_pool
27
+ end
28
+ @tenant_id_resolver = ->(_controller) {}
29
+ @authorize_with = lambda do |controller|
30
+ controller.send(:authenticate_user!) if controller.respond_to?(:authenticate_user!, true)
31
+ true
32
+ end
33
+ @include_tables = []
34
+ @max_tables = 30
35
+ @schema_name = "public"
36
+ @tenant_column = nil
37
+ @llm_provider = ENV.fetch("KPI_LLM_PROVIDER", ENV["GEMINI_API_KEY"] ? "gemini" : "ollama").to_sym
38
+ @use_ollama = ENV["KPI_USE_OLLAMA"] != "false"
39
+ @use_llm = ENV["KPI_USE_LLM"] != "false"
40
+ @ollama_model = ENV.fetch("KPI_OLLAMA_MODEL", "llama3")
41
+ @ollama_url = ENV.fetch("KPI_OLLAMA_URL", "http://localhost:11434/api/generate")
42
+ @gemini_api_key = ENV["GEMINI_API_KEY"]
43
+ @gemini_model = ENV.fetch("KPI_GEMINI_MODEL", "gemini-2.0-flash")
44
+ @gemini_url = ENV["KPI_GEMINI_URL"]
45
+ @parent_controller = "ApplicationController"
46
+ end
47
+ end
48
+
49
+ class << self
50
+ def configuration
51
+ @configuration ||= Configuration.new
52
+ end
53
+
54
+ def configure
55
+ yield(configuration)
56
+ reset_services!
57
+ end
58
+
59
+ def reset_configuration!
60
+ @configuration = Configuration.new
61
+ reset_services!
62
+ end
63
+
64
+ def service_for(controller)
65
+ tenant_id = invoke(configuration.tenant_id_resolver, controller)
66
+ source = invoke(configuration.connection_provider, controller)
67
+ connection = Connection.wrap(source, label: "Rails application database")
68
+ key = [connection.cache_key, tenant_id]
69
+
70
+ services_mutex.synchronize do
71
+ services[key] ||= WebService.new(
72
+ db: connection,
73
+ options: {
74
+ source_name: connection.label,
75
+ include_tables: Array(configuration.include_tables),
76
+ max_tables: configuration.max_tables,
77
+ schema_name: configuration.schema_name,
78
+ tenant_column: configuration.tenant_column,
79
+ tenant_id: tenant_id,
80
+ llm_provider: configuration.llm_provider,
81
+ use_llm: configuration.use_llm,
82
+ use_ollama: configuration.use_ollama,
83
+ ollama_model: configuration.ollama_model,
84
+ ollama_url: configuration.ollama_url,
85
+ gemini_api_key: configuration.gemini_api_key,
86
+ gemini_model: configuration.gemini_model,
87
+ gemini_url: configuration.gemini_url
88
+ }
89
+ )
90
+ end
91
+ end
92
+
93
+ def authorized?(controller)
94
+ configuration.authorize_with.nil? || invoke(configuration.authorize_with, controller) != false
95
+ end
96
+
97
+ def reset_services!
98
+ @services = {}
99
+ end
100
+
101
+ private
102
+
103
+ def services
104
+ @services ||= {}
105
+ end
106
+
107
+ def services_mutex
108
+ @services_mutex ||= Mutex.new
109
+ end
110
+
111
+ def invoke(callable, controller)
112
+ callable.arity.zero? ? callable.call : callable.call(controller)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module KPIAssembler
6
+ # Uniform query surface for SQLite and PostgreSQL. KPI SQL keeps `?`
7
+ # placeholders; Postgres binds are rewritten to $1, $2, ...
8
+ class Connection
9
+ attr_reader :raw, :adapter, :label
10
+
11
+ def self.wrap(object, label: nil)
12
+ return object if object.is_a?(Connection)
13
+
14
+ if object.respond_to?(:with_connection)
15
+ adapter_name = object.with_connection { |connection| connection.adapter_name }
16
+ adapter = adapter_name.to_s.match?(/postgre/i) ? :postgres : :sqlite
17
+ new(raw: object, adapter: adapter, label: label || "ActiveRecord #{adapter_name}", pool: true)
18
+ elsif defined?(SQLite3::Database) && object.is_a?(SQLite3::Database)
19
+ new(raw: object, adapter: :sqlite, label: label || "sqlite")
20
+ elsif defined?(PG::Connection) && object.is_a?(PG::Connection)
21
+ new(raw: object, adapter: :postgres, label: label || "postgres")
22
+ else
23
+ raise Error, "Unsupported database object: #{object.class}"
24
+ end
25
+ end
26
+
27
+ def self.open(url: nil, sqlite_path: nil)
28
+ url ||= ENV["KPI_DATABASE_URL"]
29
+ sqlite_path ||= ENV["KPI_DATABASE_PATH"]
30
+
31
+ if url && !url.to_s.empty?
32
+ from_url(url)
33
+ elsif sqlite_path && !sqlite_path.to_s.empty?
34
+ require "sqlite3"
35
+ wrap(SQLite3::Database.new(sqlite_path), label: sqlite_path)
36
+ else
37
+ require_relative "sample_database"
38
+ wrap(SampleDatabase.build, label: "Built-in sample database")
39
+ end
40
+ end
41
+
42
+ def self.from_url(url)
43
+ uri = URI.parse(url)
44
+ case uri.scheme
45
+ when "postgres", "postgresql"
46
+ require "pg"
47
+ dbname = uri.path.to_s.sub(%r{\A/}, "")
48
+ raw = PG.connect(
49
+ host: uri.host,
50
+ port: uri.port || 5432,
51
+ dbname: dbname,
52
+ user: uri.user,
53
+ password: uri.password
54
+ )
55
+ wrap(raw, label: "#{uri.host}/#{dbname}")
56
+ when "sqlite", "sqlite3"
57
+ require "sqlite3"
58
+ path = uri.host == ":memory:" ? ":memory:" : uri.path
59
+ wrap(SQLite3::Database.new(path), label: path)
60
+ else
61
+ raise Error, "Unsupported database URL scheme: #{uri.scheme.inspect}"
62
+ end
63
+ end
64
+
65
+ def initialize(raw:, adapter:, label:, pool: false)
66
+ @raw = raw
67
+ @adapter = adapter.to_sym
68
+ @label = label
69
+ @pool = pool
70
+ end
71
+
72
+ def postgres?
73
+ adapter == :postgres
74
+ end
75
+
76
+ def sqlite?
77
+ adapter == :sqlite
78
+ end
79
+
80
+ def dialect
81
+ postgres? ? "PostgreSQL" : "SQLite"
82
+ end
83
+
84
+ # Stable identity for caching. Host applications hand back a fresh connection
85
+ # object on every request, so object identity cannot be used here.
86
+ def cache_key
87
+ [adapter, label, pool_name]
88
+ end
89
+
90
+ def execute(sql, binds = [])
91
+ binds = Array(binds)
92
+ return execute_with_pool(sql, binds) if @pool
93
+
94
+ execute_raw(raw, sql, binds)
95
+ end
96
+
97
+ def explain(sql)
98
+ if sqlite?
99
+ execute("EXPLAIN QUERY PLAN #{sql}")
100
+ else
101
+ execute("EXPLAIN #{pg_sql(sql)}")
102
+ end
103
+ end
104
+
105
+ def quote_ident(name)
106
+ %("#{name.to_s.gsub('"', '""')}")
107
+ end
108
+
109
+ private
110
+
111
+ def pool_name
112
+ return nil unless @pool && raw.respond_to?(:db_config)
113
+
114
+ raw.db_config.name
115
+ end
116
+
117
+ def execute_with_pool(sql, binds)
118
+ raw.with_connection do |connection|
119
+ execute_raw(connection.raw_connection, sql, binds)
120
+ end
121
+ end
122
+
123
+ def execute_raw(connection, sql, binds)
124
+ if sqlite?
125
+ binds.empty? ? connection.execute(sql) : connection.execute(sql, binds)
126
+ else
127
+ result = binds.empty? ? connection.exec(sql) : connection.exec_params(pg_sql(sql), binds)
128
+ result.values
129
+ end
130
+ end
131
+
132
+ def pg_sql(sql)
133
+ index = 0
134
+ sql.gsub("?") do
135
+ index += 1
136
+ "$#{index}"
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module KPIAssembler
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace KPIAssembler
8
+
9
+ # Without this the names derived from `KPIAssembler` are `k_p_i_assembler`.
10
+ engine_name "kpi_assembler"
11
+ railtie_name "kpi_assembler"
12
+ end
13
+ end
14
+
15
+ # Rails camelizes the `kpi_assembler` path segment of engine controllers as
16
+ # `KpiAssembler`. Defining an acronym inflection instead would change how the
17
+ # host application resolves its own `Kpi` constants.
18
+ KpiAssembler = KPIAssembler unless defined?(KpiAssembler)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KPIAssembler
4
+ # Loads KEY=VALUE pairs from a .env file without overriding values already
5
+ # present in the process environment.
6
+ module Env
7
+ module_function
8
+
9
+ def load!(path)
10
+ return unless path && File.file?(path)
11
+
12
+ File.foreach(path) do |line|
13
+ line = line.strip
14
+ next if line.empty? || line.start_with?("#")
15
+
16
+ key, value = line.split("=", 2)
17
+ next if key.nil? || value.nil?
18
+
19
+ key = key.strip
20
+ next if key.empty? || ENV.key?(key)
21
+
22
+ ENV[key] = unquote(value.strip)
23
+ end
24
+ end
25
+
26
+ def load_defaults!
27
+ load!(File.expand_path("../../.env", __dir__))
28
+ load!(File.expand_path(".env"))
29
+ end
30
+
31
+ def unquote(value)
32
+ if (value.start_with?('"') && value.end_with?('"')) ||
33
+ (value.start_with?("'") && value.end_with?("'"))
34
+ value[1..-2]
35
+ else
36
+ value
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+
6
+ module KPIAssembler
7
+ class HtmlReport
8
+ def initialize(pack, locations: [])
9
+ @pack = pack
10
+ @locations = locations
11
+ end
12
+
13
+ def render
14
+ <<~HTML
15
+ <!DOCTYPE html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <title>#{h(@pack[:pack_name])}</title>
20
+ <style>
21
+ :root { --bg:#0f1419; --card:#1a2332; --muted:#8b9bb4; --ok:#3dd68c; --draft:#f5c542; --bad:#ff6b6b; --line:#2a3548; --text:#e8eef7; }
22
+ * { box-sizing: border-box; }
23
+ body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background:var(--bg); color:var(--text); }
24
+ header { padding:28px 32px 12px; border-bottom:1px solid var(--line); }
25
+ header p { color:var(--muted); max-width:720px; }
26
+ main { padding:24px 32px 64px; }
27
+ h1 { margin:0 0 8px; font-size:28px; }
28
+ h2 { margin:28px 0 12px; font-size:18px; letter-spacing:.02em; }
29
+ .badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; }
30
+ .ok { background:#143d2c; color:var(--ok); }
31
+ .draft { background:#3d3414; color:var(--draft); }
32
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
33
+ .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px; }
34
+ .card.dim { opacity:.55; }
35
+ .value { font-size:28px; font-weight:700; margin:8px 0 0; }
36
+ .muted { color:var(--muted); font-size:13px; }
37
+ table { width:100%; border-collapse:collapse; background:var(--card); border-radius:12px; overflow:hidden; }
38
+ th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); font-size:14px; vertical-align:top; }
39
+ th { color:var(--muted); font-weight:600; }
40
+ pre { white-space:pre-wrap; font-size:12px; color:#c5d4ea; margin:0; }
41
+ .tabs { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 16px; }
42
+ .tabs button { background:#243044; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 12px; cursor:pointer; }
43
+ .tabs button.active { background:#2f6fed; border-color:#2f6fed; }
44
+ .panel { display:none; }
45
+ .panel.active { display:block; }
46
+ .brief { font-size:16px; line-height:1.5; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <header>
51
+ <h1>#{h(@pack[:pack_name])}</h1>
52
+ <p>#{h(@pack[:principle])} Draft metrics never reach the board or the briefing.</p>
53
+ <p class="muted">Generated #{h(@pack[:generated_at])} · #{h(@pack[:certified_by])}</p>
54
+ </header>
55
+ <main>
56
+ <h2>Weekly briefing</h2>
57
+ <div class="card brief">#{h(briefing_headline)}</div>
58
+ #{drill_html}
59
+
60
+ <h2>Role views</h2>
61
+ <div class="tabs">
62
+ <button class="active" data-panel="exec">Exec (all sites)</button>
63
+ #{location_tab_buttons}
64
+ </div>
65
+ <div id="exec" class="panel active">#{kpi_grid(evaluations_for(nil))}</div>
66
+ #{location_panels}
67
+
68
+ <h2>Metric catalog</h2>
69
+ <table>
70
+ <thead>
71
+ <tr><th>Status</th><th>Name</th><th>Definition</th><th>Grain</th><th>Owner</th><th>Why draft?</th></tr>
72
+ </thead>
73
+ <tbody>#{catalog_rows}</tbody>
74
+ </table>
75
+ </main>
76
+ <script>
77
+ document.querySelectorAll('.tabs button').forEach(function(btn) {
78
+ btn.addEventListener('click', function() {
79
+ document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); });
80
+ document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
81
+ btn.classList.add('active');
82
+ document.getElementById(btn.dataset.panel).classList.add('active');
83
+ });
84
+ });
85
+ </script>
86
+ </body>
87
+ </html>
88
+ HTML
89
+ end
90
+
91
+ private
92
+
93
+ def briefing_headline
94
+ @pack.dig(:briefing, :headline) || "No briefing."
95
+ end
96
+
97
+ def drill_html
98
+ drill = @pack.dig(:briefing, :drill_dimension) || {}
99
+ site = drill[:explaining_site]
100
+ return "" unless site
101
+
102
+ rows = Array(drill[:locations]).map { |l| "#{h(l[:name])}: #{h(l[:value])}" }.join(" · ")
103
+ %(<p class="muted">Drill on location — weakest site for #{h(drill[:kpi_id])}: <strong>#{h(site[:name])}</strong> (#{h(site[:value])}). #{rows}</p>)
104
+ end
105
+
106
+ def evaluations_for(location_id)
107
+ key = location_id.nil? ? "all" : location_id.to_s
108
+ (@pack[:evaluations] || {}).fetch(key, {})
109
+ end
110
+
111
+ def kpi_grid(values)
112
+ certified = Array(@pack[:kpis]).map do |kpi|
113
+ val = values[kpi[:id]]
114
+ <<~CARD
115
+ <div class="card">
116
+ <span class="badge ok">certified</span>
117
+ <div class="muted">#{h(kpi[:name])}</div>
118
+ <div class="value">#{h(format_value(val))}</div>
119
+ </div>
120
+ CARD
121
+ end
122
+ drafts = Array(@pack[:drafts]).map do |kpi|
123
+ <<~CARD
124
+ <div class="card dim">
125
+ <span class="badge draft">draft</span>
126
+ <div class="muted">#{h(kpi[:name])}</div>
127
+ <div class="value">—</div>
128
+ <div class="muted">Failed certification; excluded from the board.</div>
129
+ </div>
130
+ CARD
131
+ end
132
+ %(<div class="grid">#{certified.join}#{drafts.join}</div>)
133
+ end
134
+
135
+ def location_tab_buttons
136
+ @locations.map do |loc|
137
+ %(<button data-panel="loc-#{loc[:id]}">Site manager · #{h(loc[:name])}</button>)
138
+ end.join
139
+ end
140
+
141
+ def location_panels
142
+ @locations.map do |loc|
143
+ %(<div id="loc-#{loc[:id]}" class="panel">#{kpi_grid(evaluations_for(loc[:id]))}</div>)
144
+ end.join
145
+ end
146
+
147
+ def catalog_rows
148
+ Array(@pack[:catalog]).map do |entry|
149
+ badge = entry[:status] == "certified" ? %(<span class="badge ok">certified</span>) : %(<span class="badge draft">draft</span>)
150
+ reasons = Array(entry[:reasons]).map { |r| h(r) }.join("<br>")
151
+ <<~TR
152
+ <tr>
153
+ <td>#{badge}</td>
154
+ <td>#{h(entry[:name])}<div class="muted"><code>#{h(entry[:id])}</code></div></td>
155
+ <td>#{h(entry[:english])}<pre>#{h(entry[:sql])}</pre></td>
156
+ <td>#{h(entry[:grain])}</td>
157
+ <td>#{h(entry[:owner])}</td>
158
+ <td>#{reasons.empty? ? "—" : reasons}</td>
159
+ </tr>
160
+ TR
161
+ end.join
162
+ end
163
+
164
+ def format_value(val)
165
+ return "n/a" if val.nil?
166
+
167
+ n = Float(val)
168
+ n.abs >= 100 ? format("%.0f", n) : format("%.1f", n)
169
+ rescue ArgumentError, TypeError
170
+ val.to_s
171
+ end
172
+
173
+ def h(value)
174
+ CGI.escapeHTML(value.to_s)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+
5
+ module KPIAssembler
6
+ class LocationDirectory
7
+ def initialize(db, tenant_id: nil)
8
+ @db = Connection.wrap(db)
9
+ @tenant_id = tenant_id
10
+ end
11
+
12
+ def all
13
+ if @tenant_id
14
+ scoped = fetch("SELECT id, name FROM companies WHERE id = ? LIMIT 1", [Integer(@tenant_id)])
15
+ return scoped unless scoped.empty?
16
+ end
17
+
18
+ named = fetch("SELECT id, name FROM locations ORDER BY id LIMIT 12")
19
+ return named unless named.empty?
20
+
21
+ fetch("SELECT id, name FROM companies ORDER BY id LIMIT 12")
22
+ end
23
+
24
+ private
25
+
26
+ def fetch(sql, binds = [])
27
+ @db.execute(sql, binds).map { |id, name| { id: id.to_i, name: name.to_s } }
28
+ rescue StandardError
29
+ []
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+
5
+ module KPIAssembler
6
+ class MetricEvaluator
7
+ attr_reader :db
8
+
9
+ def initialize(db)
10
+ @db = Connection.wrap(db)
11
+ end
12
+
13
+ def value(kpi, from:, to:, location_id: nil)
14
+ sql = scoped_sql(kpi, location_id: location_id)
15
+ binds = period_binds(kpi, from: from, to: to)
16
+ rows =
17
+ if placeholder_count(sql).positive?
18
+ db.execute(sql, binds)
19
+ else
20
+ db.execute(sql)
21
+ end
22
+ raw = rows.dig(0, 0)
23
+ return nil if raw.nil?
24
+
25
+ Float(raw)
26
+ rescue ArgumentError, TypeError
27
+ nil
28
+ end
29
+
30
+ def scoped_sql(kpi, location_id: nil)
31
+ sql = kpi[:sql].to_s.strip.sub(/;\s*\z/, "")
32
+ return sql unless location_id && kpi[:location_column].to_s.strip != ""
33
+
34
+ col = kpi[:location_column]
35
+ clause = "#{col} = #{Integer(location_id)}"
36
+ if sql.match?(/\bWHERE\b/i)
37
+ sql.sub(/\bWHERE\b/i, "WHERE #{clause} AND ")
38
+ else
39
+ "#{sql} WHERE #{clause}"
40
+ end
41
+ end
42
+
43
+ def period_binds(kpi, from:, to:)
44
+ from_s = iso(from)
45
+ to_s = iso(to)
46
+ if kpi[:bind_style].to_s == "end_then_start"
47
+ [to_s, from_s]
48
+ else
49
+ [from_s, to_s]
50
+ end
51
+ end
52
+
53
+ def placeholder_count(sql)
54
+ sql.scan("?").length
55
+ end
56
+
57
+ def iso(time)
58
+ time = Time.parse(time.to_s) unless time.is_a?(Time)
59
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module KPIAssembler
6
+ class PackBuilder
7
+ def initialize(certified_kpis, drafts: [], briefing: {}, evaluations: {}, schema: {}, version: KPIAssembler::VERSION)
8
+ @certified_kpis = certified_kpis
9
+ @drafts = drafts
10
+ @briefing = briefing
11
+ @evaluations = evaluations
12
+ @schema = schema
13
+ @version = version
14
+ end
15
+
16
+ def build
17
+ {
18
+ pack_name: "Certified KPI Pack",
19
+ version: "1.0.0",
20
+ generated_at: Time.now.utc.iso8601,
21
+ certified_by: "KPIAssembler v#{@version}",
22
+ principle: "The LLM proposes; deterministic code certifies.",
23
+ catalog: catalog_entries,
24
+ kpis: @certified_kpis,
25
+ drafts: @drafts,
26
+ evaluations: @evaluations,
27
+ briefing: @briefing,
28
+ lineage: {
29
+ tables: @schema.keys,
30
+ grain: "lead → appointment → membership → invoice",
31
+ dimension: "location"
32
+ }
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def catalog_entries
39
+ (@certified_kpis + @drafts).map do |kpi|
40
+ {
41
+ id: kpi[:id],
42
+ name: kpi[:name],
43
+ english: kpi[:formula_description],
44
+ sql: kpi[:sql],
45
+ grain: kpi[:grain],
46
+ owner: kpi[:owner],
47
+ status: kpi[:status],
48
+ reasons: kpi[:reasons]
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end