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