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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 73810fbb8d6411ed8f52fd84ac84bed1c50372b2fa9cac5bb5bbce43de714130
|
|
4
|
+
data.tar.gz: efd60220f7d69ab53a23162048a65226a5ac5f4ab852e6685cde31c0298d4bab
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 10baca12a1597969b0bf610da6cbf7b505655b74b55b07d1532a661f7649e13ebc704f8e2a119855871877afa0d7c7f9fad349bf7460189d8ee98ae59f72e3e1
|
|
7
|
+
data.tar.gz: bdab7aef95e09eea0ae8d62a622859d0bd096e2c96072e642fd4e164a6835d1535a671b848a346d3e3d32cfa7285c389f304171bc8278e8b4803e50f50357738
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KPIAssembler contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# KPIAssembler
|
|
2
|
+
|
|
3
|
+
KPIAssembler discovers application schemas, proposes useful business metrics,
|
|
4
|
+
and deterministically certifies generated SQL before publishing a KPI pack.
|
|
5
|
+
|
|
6
|
+
The LLM proposes; deterministic code certifies. Candidate queries are checked
|
|
7
|
+
against the real schema, tenant boundary, SQL safety rules, query planner, and
|
|
8
|
+
sample execution before they can be marked certified.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Mountable Rails Engine with an interactive workspace and JSON API
|
|
13
|
+
- Standalone Rack application and command-line interface
|
|
14
|
+
- SQLite and PostgreSQL schema introspection
|
|
15
|
+
- Gemini and Ollama KPI proposal providers
|
|
16
|
+
- Schema-driven heuristic fallback when no model is available
|
|
17
|
+
- Deterministic SQL, join, division, tenant, and execution checks
|
|
18
|
+
- JSON and HTML KPI packs
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Add the gem to your application:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "kpi_assembler"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
bin/rails generate kpi_assembler:install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Mount the engine:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
mount KPIAssembler::Engine => "/kpi-assembler"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
See [Rails Engine integration](docs/rails-engine.md) for configuration and
|
|
42
|
+
[standalone integration](docs/integration.md) for Rack, CLI, and API usage.
|
|
43
|
+
|
|
44
|
+
## LLM configuration
|
|
45
|
+
|
|
46
|
+
Gemini:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
KPI_LLM_PROVIDER=gemini
|
|
50
|
+
GEMINI_API_KEY=your-key
|
|
51
|
+
KPI_GEMINI_MODEL=gemini-2.0-flash
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Ollama:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
KPI_LLM_PROVIDER=ollama
|
|
58
|
+
KPI_OLLAMA_MODEL=llama3.2:3b
|
|
59
|
+
KPI_OLLAMA_URL=http://localhost:11434/api/generate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Set `KPI_USE_LLM=false` to use schema-driven heuristics only.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bundle install
|
|
68
|
+
bundle exec rspec
|
|
69
|
+
bundle exec ruby bin/kpi_assembler --sample-db
|
|
70
|
+
bundle exec ruby bin/kpi_assembler_web
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The standalone workspace starts at `http://127.0.0.1:9292`.
|
|
74
|
+
|
|
75
|
+
## Security
|
|
76
|
+
|
|
77
|
+
Use a read-only database user or replica. Generated candidate SQL is executed
|
|
78
|
+
during deterministic certification. Configure tenant scoping and authorization
|
|
79
|
+
before exposing the engine or API.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KPIAssembler
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class WorkspaceController < KPIAssembler::ApplicationController
|
|
7
|
+
skip_forgery_protection
|
|
8
|
+
|
|
9
|
+
rescue_from KPIAssembler::Error, with: :unprocessable_entity
|
|
10
|
+
rescue_from JSON::ParserError, with: :invalid_json
|
|
11
|
+
|
|
12
|
+
def health
|
|
13
|
+
render json: {
|
|
14
|
+
status: "ok",
|
|
15
|
+
service: "KPIAssembler",
|
|
16
|
+
version: KPIAssembler::VERSION,
|
|
17
|
+
source: kpi_service.db.label,
|
|
18
|
+
dialect: kpi_service.db.dialect,
|
|
19
|
+
principle: "The LLM proposes; deterministic code certifies."
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def discover
|
|
24
|
+
render json: kpi_service.discover
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def certify
|
|
28
|
+
pack = kpi_service.certify(request_payload["accepted_ids"])
|
|
29
|
+
render json: pack.merge(locations: kpi_service.locations)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pack
|
|
33
|
+
value = kpi_service.pack
|
|
34
|
+
return render json: { error: "No pack has been certified yet" }, status: :not_found unless value
|
|
35
|
+
|
|
36
|
+
render json: value.merge(locations: kpi_service.locations)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def integration
|
|
40
|
+
render json: {
|
|
41
|
+
api_version: "v1",
|
|
42
|
+
mount_path: request.script_name,
|
|
43
|
+
discovery_endpoint: api_v1_discover_path,
|
|
44
|
+
certification_endpoint: api_v1_certify_path,
|
|
45
|
+
pack_endpoint: api_v1_pack_path
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def request_payload
|
|
52
|
+
return {} if request.raw_post.empty?
|
|
53
|
+
|
|
54
|
+
JSON.parse(request.raw_post)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unprocessable_entity(error)
|
|
58
|
+
render json: { error: error.message }, status: :unprocessable_entity
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invalid_json
|
|
62
|
+
render json: { error: "Request body must be valid JSON" }, status: :bad_request
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KPIAssembler
|
|
4
|
+
class ApplicationController < KPIAssembler.configuration.parent_controller.constantize
|
|
5
|
+
before_action :authorize_kpi_assembler!
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def authorize_kpi_assembler!
|
|
10
|
+
return if KPIAssembler.authorized?(self)
|
|
11
|
+
|
|
12
|
+
head :forbidden
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def kpi_service
|
|
16
|
+
KPIAssembler.service_for(self)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KPIAssembler
|
|
4
|
+
class WorkspaceController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
base = request.script_name.to_s.sub(%r{/\z}, "")
|
|
7
|
+
html = File.read(KPIAssembler::Engine.root.join("public/index.html"))
|
|
8
|
+
html.sub!('<meta name="kpi-api-base" content="/api/v1">', %(<meta name="kpi-api-base" content="#{base}/api/v1">))
|
|
9
|
+
html.sub!('href="/app.css"', %(href="#{base}/assets/app.css"))
|
|
10
|
+
html.sub!('src="/app.js"', %(src="#{base}/assets/app.js"))
|
|
11
|
+
html.gsub!("http://localhost:9292/kpi-assembler.js", "#{request.base_url}#{base}/assets/kpi-assembler.js")
|
|
12
|
+
html.gsub!('service-url="http://localhost:9292"', %(service-url="#{request.base_url}#{base}"))
|
|
13
|
+
|
|
14
|
+
render html: html.html_safe, layout: false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/bin/kpi_assembler
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../lib/kpi_assembler"
|
|
6
|
+
|
|
7
|
+
options = {
|
|
8
|
+
sample_db: true,
|
|
9
|
+
use_ollama: false,
|
|
10
|
+
demo: true,
|
|
11
|
+
output: File.expand_path("../output", __dir__)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
parser = OptionParser.new do |opts|
|
|
15
|
+
opts.banner = "Usage: ruby bin/kpi_assembler [options]"
|
|
16
|
+
opts.on("--sample-db", "Use the built-in CRM funnel SQLite sample (default)") { options[:sample_db] = true }
|
|
17
|
+
opts.on("--db PATH", "Open a SQLite database") do |path|
|
|
18
|
+
options[:sample_db] = false
|
|
19
|
+
options[:db_path] = path
|
|
20
|
+
end
|
|
21
|
+
opts.on("--url URL", "Connect with a database URL (postgres:// or sqlite://)") do |url|
|
|
22
|
+
options[:sample_db] = false
|
|
23
|
+
options[:database_url] = url
|
|
24
|
+
end
|
|
25
|
+
opts.on("--tables NAMES", "Comma-separated tables to inspect") do |names|
|
|
26
|
+
options[:include_tables] = names.split(",").map(&:strip)
|
|
27
|
+
end
|
|
28
|
+
opts.on("--tenant-id ID", Integer, "Scope generated SQL to this company/location id") do |id|
|
|
29
|
+
options[:tenant_id] = id
|
|
30
|
+
options[:tenant_column] ||= "company_id"
|
|
31
|
+
end
|
|
32
|
+
opts.on("--ollama", "Propose KPIs via local Ollama (falls back to schema heuristics)") do
|
|
33
|
+
options[:use_llm] = true
|
|
34
|
+
options[:llm_provider] = :ollama
|
|
35
|
+
end
|
|
36
|
+
opts.on("--ollama-model NAME", "Ollama model name (default: llama3)") { |n| options[:ollama_model] = n }
|
|
37
|
+
opts.on("--gemini", "Propose KPIs via Google Gemini API") do
|
|
38
|
+
options[:use_llm] = true
|
|
39
|
+
options[:llm_provider] = :gemini
|
|
40
|
+
end
|
|
41
|
+
opts.on("--gemini-key KEY", "Google Gemini API key (or set GEMINI_API_KEY)") { |k| options[:gemini_api_key] = k }
|
|
42
|
+
opts.on("--gemini-model NAME", "Gemini model name (default: gemini-2.0-flash)") { |n| options[:gemini_model] = n }
|
|
43
|
+
opts.on("--accept IDS", "Comma-separated KPI ids to send through certification") do |ids|
|
|
44
|
+
options[:accept_ids] = ids.split(",").map(&:strip)
|
|
45
|
+
options[:demo] = false
|
|
46
|
+
end
|
|
47
|
+
opts.on("--accept-all", "Skip the human gate; certify every proposal") { options[:accept_all] = true }
|
|
48
|
+
opts.on("--output DIR", "Directory for kpi_pack.json and kpi_pack.html") { |d| options[:output] = d }
|
|
49
|
+
end
|
|
50
|
+
parser.parse!
|
|
51
|
+
|
|
52
|
+
puts "======================================================="
|
|
53
|
+
puts " KPIAssembler — Autonomous Certified Analytics Pack "
|
|
54
|
+
puts "======================================================="
|
|
55
|
+
|
|
56
|
+
db =
|
|
57
|
+
if options[:database_url]
|
|
58
|
+
puts "Connecting to #{options[:database_url].sub(/:[^:@]+@/, ":***@")}..."
|
|
59
|
+
KPIAssembler::Connection.from_url(options[:database_url])
|
|
60
|
+
elsif options[:db_path]
|
|
61
|
+
puts "Opening SQLite #{options[:db_path]}..."
|
|
62
|
+
KPIAssembler::Connection.open(sqlite_path: options[:db_path])
|
|
63
|
+
else
|
|
64
|
+
puts "Loading sample CRM database..."
|
|
65
|
+
KPIAssembler::Connection.wrap(KPIAssembler::SampleDatabase.build)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
pipeline = KPIAssembler::Pipeline.new(db_connection: db, options: options)
|
|
69
|
+
pack = pipeline.run
|
|
70
|
+
json_path, html_path = pipeline.write_outputs!(pack, options[:output])
|
|
71
|
+
|
|
72
|
+
puts
|
|
73
|
+
puts "Wrote #{json_path}"
|
|
74
|
+
puts "Wrote #{html_path}"
|
|
75
|
+
puts
|
|
76
|
+
puts "Certified: #{pack[:kpis].map { |k| k[:id] }.join(", ")}"
|
|
77
|
+
puts "Draft: #{pack[:drafts].map { |k| k[:id] }.join(", ")}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
ENV["RACK_ENV"] ||= "development"
|
|
5
|
+
|
|
6
|
+
require "rackup"
|
|
7
|
+
require "puma"
|
|
8
|
+
|
|
9
|
+
host = ENV.fetch("HOST", "127.0.0.1")
|
|
10
|
+
port = Integer(ENV.fetch("PORT", "9292"))
|
|
11
|
+
config = File.expand_path("../config.ru", __dir__)
|
|
12
|
+
|
|
13
|
+
puts "KPIAssembler UI: http://#{host}:#{port}"
|
|
14
|
+
Rackup::Server.start(
|
|
15
|
+
app: Rack::Builder.parse_file(config),
|
|
16
|
+
Host: host,
|
|
17
|
+
Port: port,
|
|
18
|
+
server: "puma"
|
|
19
|
+
)
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
KPIAssembler::Engine.routes.draw do
|
|
4
|
+
root "workspace#show"
|
|
5
|
+
|
|
6
|
+
mount KPIAssembler::AssetServer.new => "/assets"
|
|
7
|
+
|
|
8
|
+
namespace :api do
|
|
9
|
+
namespace :v1 do
|
|
10
|
+
get "health", to: "workspace#health"
|
|
11
|
+
match "discover", to: "workspace#discover", via: %i[get post]
|
|
12
|
+
post "certify", to: "workspace#certify"
|
|
13
|
+
get "pack", to: "workspace#pack"
|
|
14
|
+
get "integration", to: "workspace#integration"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/docs/integration.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Standalone integration
|
|
2
|
+
|
|
3
|
+
KPIAssembler can run as a Rack service and expose its workspace and JSON API to
|
|
4
|
+
applications written in any language.
|
|
5
|
+
|
|
6
|
+
## Start the service
|
|
7
|
+
|
|
8
|
+
Copy `.env.example` to `.env`, configure a read-only database connection, then:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle install
|
|
12
|
+
bundle exec ruby bin/kpi_assembler_web
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The service starts at `http://127.0.0.1:9292`.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
HOST=0.0.0.0
|
|
19
|
+
PORT=9292
|
|
20
|
+
KPI_DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/application
|
|
21
|
+
KPI_ALLOWED_ORIGIN=https://application.example
|
|
22
|
+
KPI_INCLUDE_TABLES=orders,customers,accounts
|
|
23
|
+
KPI_TENANT_COLUMN=account_id
|
|
24
|
+
KPI_TENANT_ID=123
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use a read-only database user, replica, or database copy.
|
|
28
|
+
|
|
29
|
+
## LLM provider
|
|
30
|
+
|
|
31
|
+
Gemini:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
KPI_LLM_PROVIDER=gemini
|
|
35
|
+
GEMINI_API_KEY=your-key
|
|
36
|
+
KPI_GEMINI_MODEL=gemini-2.0-flash
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Local Ollama:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
KPI_LLM_PROVIDER=ollama
|
|
43
|
+
KPI_OLLAMA_MODEL=llama3.2:3b
|
|
44
|
+
KPI_OLLAMA_URL=http://localhost:11434/api/generate
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The model only proposes definitions. Deterministic code certifies every
|
|
48
|
+
accepted candidate. Set `KPI_USE_LLM=false` to use heuristics only.
|
|
49
|
+
|
|
50
|
+
## Embed the workspace
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<script src="http://localhost:9292/kpi-assembler.js"></script>
|
|
54
|
+
<kpi-assembler service-url="http://localhost:9292" height="820px"></kpi-assembler>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## JSON API
|
|
58
|
+
|
|
59
|
+
Discover the schema and proposals:
|
|
60
|
+
|
|
61
|
+
```http
|
|
62
|
+
POST /api/v1/discover
|
|
63
|
+
Content-Type: application/json
|
|
64
|
+
|
|
65
|
+
{}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Certify selected proposals:
|
|
69
|
+
|
|
70
|
+
```http
|
|
71
|
+
POST /api/v1/certify
|
|
72
|
+
Content-Type: application/json
|
|
73
|
+
|
|
74
|
+
{"accepted_ids":["orders_total_sum","customers_volume"]}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Retrieve the latest pack:
|
|
78
|
+
|
|
79
|
+
```http
|
|
80
|
+
GET /api/v1/pack
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Production requirements
|
|
84
|
+
|
|
85
|
+
- Require authentication and authorization.
|
|
86
|
+
- Set a specific `KPI_ALLOWED_ORIGIN`.
|
|
87
|
+
- Use least-privilege, read-only database access.
|
|
88
|
+
- Enforce tenant isolation at both application and database boundaries.
|
|
89
|
+
- Persist versioned packs outside the process.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Rails Engine integration
|
|
2
|
+
|
|
3
|
+
KPIAssembler is a mountable Rails Engine. It uses the host application's
|
|
4
|
+
session, authorization, and Active Record connection pool.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
gem "kpi_assembler"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bundle install
|
|
14
|
+
bin/rails generate kpi_assembler:install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The generator creates `config/initializers/kpi_assembler.rb`.
|
|
18
|
+
|
|
19
|
+
## Configure
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
KPIAssembler.configure do |config|
|
|
23
|
+
config.connection_provider = lambda do |_controller|
|
|
24
|
+
ApplicationRecord.connected_to(role: :reading) do
|
|
25
|
+
ApplicationRecord.connection_pool
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
config.parent_controller = "ApplicationController"
|
|
30
|
+
config.tenant_column = "account_id"
|
|
31
|
+
config.tenant_id_resolver = lambda do |controller|
|
|
32
|
+
controller.send(:current_account).id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.include_tables = []
|
|
36
|
+
config.max_tables = 30
|
|
37
|
+
config.schema_name = "public"
|
|
38
|
+
|
|
39
|
+
config.authorize_with = lambda do |controller|
|
|
40
|
+
controller.send(:authenticate_user!)
|
|
41
|
+
controller.send(:current_account).present?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
config.llm_provider = ENV.fetch("KPI_LLM_PROVIDER", "gemini").to_sym
|
|
45
|
+
config.use_llm = ENV["KPI_USE_LLM"] != "false"
|
|
46
|
+
|
|
47
|
+
config.gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
48
|
+
config.gemini_model = ENV.fetch("KPI_GEMINI_MODEL", "gemini-2.0-flash")
|
|
49
|
+
|
|
50
|
+
config.ollama_model = ENV.fetch("KPI_OLLAMA_MODEL", "llama3.2:3b")
|
|
51
|
+
config.ollama_url = ENV.fetch(
|
|
52
|
+
"KPI_OLLAMA_URL",
|
|
53
|
+
"http://localhost:11434/api/generate"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Store secrets in the host application's environment rather than the initializer:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
KPI_LLM_PROVIDER=gemini
|
|
62
|
+
GEMINI_API_KEY=your-key
|
|
63
|
+
KPI_GEMINI_MODEL=gemini-2.0-flash
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Restart the Rails server after changing environment variables.
|
|
67
|
+
|
|
68
|
+
## Mount
|
|
69
|
+
|
|
70
|
+
Add the engine to `config/routes.rb`:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
mount KPIAssembler::Engine => "/kpi-assembler"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The mounted endpoints are:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
GET/POST /kpi-assembler/api/v1/discover
|
|
80
|
+
POST /kpi-assembler/api/v1/certify
|
|
81
|
+
GET /kpi-assembler/api/v1/pack
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Production guidance
|
|
85
|
+
|
|
86
|
+
Point `connection_provider` at a read-only pool. Replace the example
|
|
87
|
+
authorization callback with the host application's permission policy. Tenant
|
|
88
|
+
scoping must correspond to real columns in the inspected tables.
|
|
89
|
+
|
|
90
|
+
The engine currently keeps the latest pack in process memory per tenant.
|
|
91
|
+
Production applications should persist versioned packs and define an
|
|
92
|
+
invalidation policy.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module KPIAssembler
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
# `KpiAssembler` is an alias of `KPIAssembler`, which Rails would
|
|
9
|
+
# otherwise expose as `k_p_i_assembler:install`.
|
|
10
|
+
namespace "kpi_assembler:install"
|
|
11
|
+
|
|
12
|
+
desc "Creates config/initializers/kpi_assembler.rb in the host application."
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
template "kpi_assembler.rb", "config/initializers/kpi_assembler.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def print_mount_instruction
|
|
20
|
+
say "\nMount KPIAssembler in config/routes.rb:", :green
|
|
21
|
+
say ' mount KPIAssembler::Engine => "/kpi-assembler"'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
KPIAssembler.configure do |config|
|
|
4
|
+
# Use the host application's ActiveRecord pool. In a multi-database Rails app,
|
|
5
|
+
# return the read/report pool here instead of the primary write pool.
|
|
6
|
+
config.connection_provider = lambda do |_controller|
|
|
7
|
+
ApplicationRecord.connected_to(role: :reading) do
|
|
8
|
+
ApplicationRecord.connection_pool
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Keep every generated query inside the current tenant.
|
|
13
|
+
config.tenant_column = "company_id"
|
|
14
|
+
config.tenant_id_resolver = lambda do |controller|
|
|
15
|
+
controller.send(:current_company).id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Optional allowlist. Leave empty so the inspector reads the live schema and
|
|
19
|
+
# the LLM ranks tables that actually exist. Names must match the database
|
|
20
|
+
# (people, not Person). Pin tables here only to constrain cost or tenancy.
|
|
21
|
+
config.include_tables = []
|
|
22
|
+
config.max_tables = 30
|
|
23
|
+
config.schema_name = "public"
|
|
24
|
+
|
|
25
|
+
# The engine inherits the host controller, including authentication and
|
|
26
|
+
# authorization callbacks. This extra gate must return true.
|
|
27
|
+
config.parent_controller = "ApplicationController"
|
|
28
|
+
config.authorize_with = lambda do |controller|
|
|
29
|
+
controller.send(:authenticate_user!)
|
|
30
|
+
controller.send(:current_company).present?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The LLM ranks discovered tables and proposes KPIs. Ruby still certifies.
|
|
34
|
+
# Choose your provider (:gemini or :ollama). Set KPI_USE_LLM=false to use heuristics only.
|
|
35
|
+
# Gemini: requires GEMINI_API_KEY environment variable.
|
|
36
|
+
# Ollama: requires a running local daemon (e.g. `ollama serve`).
|
|
37
|
+
config.llm_provider = ENV.fetch("KPI_LLM_PROVIDER", :gemini).to_sym
|
|
38
|
+
config.use_llm = ENV["KPI_USE_LLM"] != "false"
|
|
39
|
+
|
|
40
|
+
# Gemini configuration
|
|
41
|
+
config.gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
42
|
+
config.gemini_model = ENV.fetch("KPI_GEMINI_MODEL", "gemini-2.0-flash")
|
|
43
|
+
|
|
44
|
+
# Ollama configuration
|
|
45
|
+
config.ollama_model = ENV.fetch("KPI_OLLAMA_MODEL", "llama3")
|
|
46
|
+
config.ollama_url = ENV.fetch("KPI_OLLAMA_URL", "http://localhost:11434/api/generate")
|
|
47
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KPIAssembler
|
|
4
|
+
# Serves the workspace's static UI files as a plain Rack endpoint.
|
|
5
|
+
#
|
|
6
|
+
# These files carry no tenant data, so they do not need the host application's
|
|
7
|
+
# controller stack — and routing them through it is actively harmful: Rails
|
|
8
|
+
# raises InvalidCrossOriginRequest (422) for a `.js` response rendered inside
|
|
9
|
+
# the forgery-protection chain, which breaks the <script> tag on the page.
|
|
10
|
+
class AssetServer
|
|
11
|
+
ROOT = ::File.expand_path("../../public", __dir__)
|
|
12
|
+
|
|
13
|
+
CONTENT_TYPES = {
|
|
14
|
+
"app.css" => "text/css",
|
|
15
|
+
"app.js" => "text/javascript",
|
|
16
|
+
"kpi-assembler.js" => "text/javascript"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
name = env["PATH_INFO"].to_s.delete_prefix("/")
|
|
21
|
+
content_type = CONTENT_TYPES[name]
|
|
22
|
+
return not_found unless content_type
|
|
23
|
+
|
|
24
|
+
body = ::File.binread(::File.join(ROOT, name))
|
|
25
|
+
[
|
|
26
|
+
200,
|
|
27
|
+
{
|
|
28
|
+
"content-type" => "#{content_type}; charset=utf-8",
|
|
29
|
+
"content-length" => body.bytesize.to_s,
|
|
30
|
+
"cache-control" => "public, max-age=300"
|
|
31
|
+
},
|
|
32
|
+
[body]
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def not_found
|
|
39
|
+
[404, { "content-type" => "text/plain", "content-length" => "9" }, ["Not found"]]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|