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