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
data/public/app.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const state = {
|
|
5
|
+
candidates: [],
|
|
6
|
+
selected: new Set(),
|
|
7
|
+
rankedTables: new Set(),
|
|
8
|
+
activeLocation: "all"
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const $ = (selector) => document.querySelector(selector);
|
|
12
|
+
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
|
13
|
+
const apiBase = document.querySelector('meta[name="kpi-api-base"]')?.content.replace(/\/$/, "") || "/api/v1";
|
|
14
|
+
|
|
15
|
+
if (new URLSearchParams(window.location.search).get("embed") === "1") {
|
|
16
|
+
document.body.classList.add("embed");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escapeHtml(value) {
|
|
20
|
+
return String(value == null ? "" : value)
|
|
21
|
+
.replaceAll("&", "&")
|
|
22
|
+
.replaceAll("<", "<")
|
|
23
|
+
.replaceAll(">", ">")
|
|
24
|
+
.replaceAll('"', """)
|
|
25
|
+
.replaceAll("'", "'");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function api(path, options) {
|
|
29
|
+
const response = await fetch(path, {
|
|
30
|
+
credentials: "same-origin",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
Accept: "application/json",
|
|
34
|
+
// Host apps commonly redirect non-XHR requests to a login page.
|
|
35
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
36
|
+
},
|
|
37
|
+
...options
|
|
38
|
+
});
|
|
39
|
+
let payload;
|
|
40
|
+
try {
|
|
41
|
+
payload = await response.json();
|
|
42
|
+
} catch (_error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"The service returned " + response.status + " " + response.statusText +
|
|
45
|
+
" instead of JSON (" + path + ")."
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) throw new Error(payload.error || "Request failed with " + response.status + ".");
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setLoading(button, loading) {
|
|
53
|
+
button.disabled = loading;
|
|
54
|
+
button.classList.toggle("loading", loading);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let toastTimer;
|
|
58
|
+
function toast(message, type) {
|
|
59
|
+
const node = $("#toast");
|
|
60
|
+
node.textContent = message;
|
|
61
|
+
node.className = "toast show" + (type === "error" ? " error" : "");
|
|
62
|
+
clearTimeout(toastTimer);
|
|
63
|
+
toastTimer = setTimeout(() => { node.className = "toast"; }, 3200);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function showView(name) {
|
|
67
|
+
$$(".view").forEach((view) => view.classList.remove("active"));
|
|
68
|
+
$("#" + name + "-view").classList.add("active");
|
|
69
|
+
$$(".nav-item").forEach((item) => {
|
|
70
|
+
item.classList.toggle("active", item.dataset.view === name);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const titles = {
|
|
74
|
+
workspace: "Metric discovery",
|
|
75
|
+
dashboard: "Trusted analytics",
|
|
76
|
+
integration: "Integration"
|
|
77
|
+
};
|
|
78
|
+
$("#page-title").textContent = titles[name];
|
|
79
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function checkHealth() {
|
|
83
|
+
const status = $("#api-status");
|
|
84
|
+
try {
|
|
85
|
+
await api(apiBase + "/health");
|
|
86
|
+
status.className = "api-status online";
|
|
87
|
+
status.innerHTML = "<i></i> Service connected";
|
|
88
|
+
} catch (_error) {
|
|
89
|
+
status.className = "api-status error";
|
|
90
|
+
status.innerHTML = "<i></i> Service unavailable";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function discover() {
|
|
95
|
+
const buttons = [$("#discover-button"), $("#rediscover-button")];
|
|
96
|
+
buttons.forEach((button) => setLoading(button, true));
|
|
97
|
+
try {
|
|
98
|
+
const result = await api(apiBase + "/discover", { method: "POST", body: "{}" });
|
|
99
|
+
const tables = result.tables || [];
|
|
100
|
+
state.candidates = result.candidates || [];
|
|
101
|
+
state.rankedTables = new Set(result.ranked_tables || []);
|
|
102
|
+
state.selected = new Set(state.candidates.map((candidate) => String(candidate.id)));
|
|
103
|
+
renderSchema(tables);
|
|
104
|
+
renderCandidates();
|
|
105
|
+
renderDiscoveryNotice(result);
|
|
106
|
+
$("#source-label").textContent = sourceLabel(result, tables);
|
|
107
|
+
$("#proposal-subtitle").textContent = proposalSubtitle(result);
|
|
108
|
+
$("#discovery-results").classList.remove("hidden");
|
|
109
|
+
$("#discovery-results").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
110
|
+
toast(
|
|
111
|
+
tables.length === 0
|
|
112
|
+
? "Discovery found no tables. Check the configured table names."
|
|
113
|
+
: proposalToast(result),
|
|
114
|
+
tables.length === 0 || result.proposer_fallback ? "error" : undefined
|
|
115
|
+
);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
toast(error.message, "error");
|
|
118
|
+
} finally {
|
|
119
|
+
buttons.forEach((button) => setLoading(button, false));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderDiscoveryNotice(result) {
|
|
124
|
+
const notice = $("#discovery-notice");
|
|
125
|
+
const missing = result.missing_tables || [];
|
|
126
|
+
const messages = [];
|
|
127
|
+
|
|
128
|
+
if (missing.length) {
|
|
129
|
+
messages.push(
|
|
130
|
+
"Configured but not found in the database: <strong>" +
|
|
131
|
+
missing.map(escapeHtml).join(", ") +
|
|
132
|
+
"</strong>. Check spelling" +
|
|
133
|
+
(result.schema_name ? ", or whether they live outside schema \"" + escapeHtml(result.schema_name) + '"' : "") +
|
|
134
|
+
"."
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const omitted = result.omitted_tables || [];
|
|
138
|
+
if (omitted.length) {
|
|
139
|
+
messages.push(
|
|
140
|
+
"Dropped by the <code>max_tables</code> limit of " + escapeHtml(result.max_tables) +
|
|
141
|
+
": <strong>" + omitted.map(escapeHtml).join(", ") + "</strong>. Raise the limit to include them."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!(result.tables || []).length) {
|
|
145
|
+
messages.push("No tables were read, so there is nothing to propose. Update <code>config.include_tables</code> and restart the application.");
|
|
146
|
+
}
|
|
147
|
+
if (result.proposer_fallback) {
|
|
148
|
+
messages.push(
|
|
149
|
+
"LLM proposal was not used (" + escapeHtml(result.proposer_fallback) +
|
|
150
|
+
"). Showing schema-driven heuristics instead."
|
|
151
|
+
);
|
|
152
|
+
} else if (result.proposer === "llm") {
|
|
153
|
+
const ranked = result.ranked_tables || [];
|
|
154
|
+
messages.push(
|
|
155
|
+
"LLM ranked " + ranked.length + " table" + (ranked.length === 1 ? "" : "s") +
|
|
156
|
+
(ranked.length ? " (<strong>" + ranked.map(escapeHtml).join(", ") + "</strong>)" : "") +
|
|
157
|
+
" and proposed KPIs. Heuristics filled any gaps. SQL is still untrusted until certification."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
notice.innerHTML = messages.join("<br>");
|
|
162
|
+
notice.classList.toggle("hidden", messages.length === 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sourceLabel(result, tables) {
|
|
166
|
+
const bits = [result.source, tables.length + " tables"];
|
|
167
|
+
if (result.schema_name) bits.push("schema " + result.schema_name);
|
|
168
|
+
if (result.proposer === "llm") {
|
|
169
|
+
bits.push("proposer " + (result.proposer_model || "llm"));
|
|
170
|
+
} else {
|
|
171
|
+
bits.push("proposer heuristics");
|
|
172
|
+
}
|
|
173
|
+
return bits.join(" · ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function proposalSubtitle(result) {
|
|
177
|
+
if (result.proposer === "llm") {
|
|
178
|
+
return "The model ranked discovered tables and wrote recipes. They are not trusted until certification.";
|
|
179
|
+
}
|
|
180
|
+
if (result.proposer_fallback) {
|
|
181
|
+
return "Heuristics proposed these metrics because the LLM was unavailable.";
|
|
182
|
+
}
|
|
183
|
+
return "Schema heuristics proposed these metrics. Configure Gemini or Ollama to let the model rank tables.";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function proposalToast(result) {
|
|
187
|
+
if (result.proposer === "llm") return "LLM proposed metrics from the discovered schema.";
|
|
188
|
+
if (result.proposer_fallback) return "LLM unavailable — showing heuristic KPIs.";
|
|
189
|
+
return "Discovery complete. Review the proposed definitions.";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderSchema(tables) {
|
|
193
|
+
$("#schema-summary").innerHTML = tables.map((table) => {
|
|
194
|
+
const columns = (table.columns || []).slice(0, 4).map((column) => escapeHtml(column.name)).join(" · ");
|
|
195
|
+
const more = (table.columns || []).length > 4 ? " +" + ((table.columns || []).length - 4) : "";
|
|
196
|
+
const ranked = state.rankedTables.has(table.name);
|
|
197
|
+
return `
|
|
198
|
+
<article class="schema-card${ranked ? " ranked" : ""}">
|
|
199
|
+
<span class="schema-icon">▤</span>
|
|
200
|
+
<strong>${escapeHtml(table.name)}</strong>
|
|
201
|
+
<small>${ranked ? "LLM-ranked · " : ""}${escapeHtml(table.row_count)} rows · ${(table.foreign_keys || []).length} relations</small>
|
|
202
|
+
<div class="column-list">${columns}${more}</div>
|
|
203
|
+
</article>`;
|
|
204
|
+
}).join("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderCandidates() {
|
|
208
|
+
$("#candidate-list").innerHTML = state.candidates.map((candidate) => {
|
|
209
|
+
const id = String(candidate.id);
|
|
210
|
+
const selected = state.selected.has(id);
|
|
211
|
+
return `
|
|
212
|
+
<article class="candidate ${selected ? "selected" : ""}" data-id="${escapeHtml(id)}">
|
|
213
|
+
<input type="checkbox" aria-label="Select ${escapeHtml(candidate.name)}" ${selected ? "checked" : ""}>
|
|
214
|
+
<div class="candidate-name">
|
|
215
|
+
<strong>${escapeHtml(candidate.name)}</strong>
|
|
216
|
+
<small>${escapeHtml(candidate.category)} · owner: ${escapeHtml(candidate.owner || "unassigned")} · ${escapeHtml(candidate.source || "heuristics")}</small>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="candidate-definition">${escapeHtml(candidate.formula_description)}</div>
|
|
219
|
+
<span class="grain">${escapeHtml(candidate.grain)}</span>
|
|
220
|
+
<button class="sql-toggle" type="button" aria-label="View SQL"></></button>
|
|
221
|
+
<pre class="candidate-sql">${escapeHtml(candidate.sql)}</pre>
|
|
222
|
+
</article>`;
|
|
223
|
+
}).join("");
|
|
224
|
+
updateSelectedCount();
|
|
225
|
+
|
|
226
|
+
$$(".candidate").forEach((card) => {
|
|
227
|
+
card.addEventListener("click", (event) => {
|
|
228
|
+
if (event.target.closest(".sql-toggle")) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
card.classList.toggle("open");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const checkbox = card.querySelector("input");
|
|
235
|
+
if (event.target !== checkbox) checkbox.checked = !checkbox.checked;
|
|
236
|
+
setSelected(card.dataset.id, checkbox.checked, card);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function setSelected(id, selected, card) {
|
|
242
|
+
if (selected) state.selected.add(id);
|
|
243
|
+
else state.selected.delete(id);
|
|
244
|
+
card.classList.toggle("selected", selected);
|
|
245
|
+
updateSelectedCount();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function updateSelectedCount() {
|
|
249
|
+
$("#selected-count").textContent = state.selected.size;
|
|
250
|
+
$("#certify-button").disabled = state.selected.size === 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function certify() {
|
|
254
|
+
const button = $("#certify-button");
|
|
255
|
+
setLoading(button, true);
|
|
256
|
+
try {
|
|
257
|
+
state.pack = await api(apiBase + "/certify", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
body: JSON.stringify({ accepted_ids: Array.from(state.selected) })
|
|
260
|
+
});
|
|
261
|
+
state.activeLocation = "all";
|
|
262
|
+
renderPack();
|
|
263
|
+
showView("dashboard");
|
|
264
|
+
toast(
|
|
265
|
+
state.pack.kpis.length + " certified · " + state.pack.drafts.length + " held as draft"
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
toast(error.message, "error");
|
|
269
|
+
} finally {
|
|
270
|
+
setLoading(button, false);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderPack() {
|
|
275
|
+
const pack = state.pack;
|
|
276
|
+
if (!pack) return;
|
|
277
|
+
|
|
278
|
+
$("#empty-dashboard").classList.add("hidden");
|
|
279
|
+
$("#pack-content").classList.remove("hidden");
|
|
280
|
+
$("#briefing-headline").textContent = pack.briefing.headline;
|
|
281
|
+
const drill = pack.briefing.drill_dimension || {};
|
|
282
|
+
$("#briefing-drill").textContent = drill.explaining_site
|
|
283
|
+
? "Location drill: " + drill.explaining_site.name + " is the lowest site for " + drill.kpi_id + "."
|
|
284
|
+
: "No location-level exception was detected.";
|
|
285
|
+
|
|
286
|
+
renderRoleTabs();
|
|
287
|
+
renderMetricGrid();
|
|
288
|
+
$("#certification-report").innerHTML = (pack.catalog || []).map((entry) => {
|
|
289
|
+
const passed = entry.status === "certified";
|
|
290
|
+
const explanation = passed
|
|
291
|
+
? "Passed schema, join safety, division safety, grain, query-plan, and sample replay checks."
|
|
292
|
+
: (entry.reasons || []).join(" · ");
|
|
293
|
+
return `
|
|
294
|
+
<article class="report-row">
|
|
295
|
+
<span class="report-status ${passed ? "" : "draft"}">${escapeHtml(entry.status)}</span>
|
|
296
|
+
<strong>${escapeHtml(entry.name)}</strong>
|
|
297
|
+
<p>${escapeHtml(explanation)}</p>
|
|
298
|
+
</article>`;
|
|
299
|
+
}).join("");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderRoleTabs() {
|
|
303
|
+
const tabs = [{ id: "all", name: "Executive · All sites" }].concat(
|
|
304
|
+
(state.pack.locations || []).map((location) => ({
|
|
305
|
+
id: String(location.id),
|
|
306
|
+
name: "Site manager · " + location.name
|
|
307
|
+
}))
|
|
308
|
+
);
|
|
309
|
+
$("#role-tabs").innerHTML = tabs.map((tab) => `
|
|
310
|
+
<button class="role-tab ${state.activeLocation === tab.id ? "active" : ""}" data-location="${escapeHtml(tab.id)}">
|
|
311
|
+
${escapeHtml(tab.name)}
|
|
312
|
+
</button>`).join("");
|
|
313
|
+
$$(".role-tab").forEach((tab) => {
|
|
314
|
+
tab.addEventListener("click", () => {
|
|
315
|
+
state.activeLocation = tab.dataset.location;
|
|
316
|
+
renderRoleTabs();
|
|
317
|
+
renderMetricGrid();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderMetricGrid() {
|
|
323
|
+
const values = (state.pack.evaluations || {})[state.activeLocation] || {};
|
|
324
|
+
$("#metric-grid").innerHTML = (state.pack.kpis || []).map((kpi) => `
|
|
325
|
+
<article class="metric-card">
|
|
326
|
+
<div class="metric-label"><span>${escapeHtml(kpi.name)}</span><i class="status-dot"></i></div>
|
|
327
|
+
<div class="metric-value">${escapeHtml(formatMetric(values[kpi.id], kpi))}</div>
|
|
328
|
+
<small>Certified · ${escapeHtml(kpi.grain)} grain · ${escapeHtml(kpi.owner)}</small>
|
|
329
|
+
</article>`).join("");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function formatMetric(value, kpi) {
|
|
333
|
+
if (value == null) return "No data";
|
|
334
|
+
const number = Number(value);
|
|
335
|
+
const name = String(kpi.name).toLowerCase();
|
|
336
|
+
if (name.includes("rate") || name.includes("conversion")) return number.toFixed(1) + "%";
|
|
337
|
+
if (name.includes("revenue") || name.includes("mrr")) {
|
|
338
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(number);
|
|
339
|
+
}
|
|
340
|
+
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(number);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function downloadPack() {
|
|
344
|
+
if (!state.pack) return;
|
|
345
|
+
const blob = new Blob([JSON.stringify(state.pack, null, 2)], { type: "application/json" });
|
|
346
|
+
const link = document.createElement("a");
|
|
347
|
+
link.href = URL.createObjectURL(blob);
|
|
348
|
+
link.download = "kpi-pack.json";
|
|
349
|
+
link.click();
|
|
350
|
+
URL.revokeObjectURL(link.href);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
$$(".nav-item").forEach((item) => item.addEventListener("click", () => showView(item.dataset.view)));
|
|
354
|
+
$("#integration-shortcut").addEventListener("click", () => showView("integration"));
|
|
355
|
+
$("#discover-button").addEventListener("click", discover);
|
|
356
|
+
$("#rediscover-button").addEventListener("click", discover);
|
|
357
|
+
$("#certify-button").addEventListener("click", certify);
|
|
358
|
+
$("#back-to-catalog").addEventListener("click", () => showView("workspace"));
|
|
359
|
+
$("#empty-discover").addEventListener("click", () => { showView("workspace"); discover(); });
|
|
360
|
+
$("#download-pack").addEventListener("click", downloadPack);
|
|
361
|
+
|
|
362
|
+
$$(".copy-button").forEach((button) => {
|
|
363
|
+
button.addEventListener("click", async () => {
|
|
364
|
+
const target = $("#" + button.dataset.copy);
|
|
365
|
+
await navigator.clipboard.writeText(target.textContent);
|
|
366
|
+
button.textContent = "Copied";
|
|
367
|
+
setTimeout(() => { button.textContent = "Copy"; }, 1400);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
checkHealth();
|
|
372
|
+
}());
|
data/public/index.html
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="description" content="Discover and certify trusted KPIs from an application database.">
|
|
7
|
+
<meta name="kpi-api-base" content="/api/v1">
|
|
8
|
+
<title>KPIAssembler — Certified analytics</title>
|
|
9
|
+
<link rel="stylesheet" href="/app.css">
|
|
10
|
+
<script src="/app.js" defer></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div class="app-shell">
|
|
14
|
+
<aside class="sidebar">
|
|
15
|
+
<a class="brand" href="#" aria-label="KPIAssembler home">
|
|
16
|
+
<span class="brand-mark" aria-hidden="true">
|
|
17
|
+
<span></span><span></span><span></span>
|
|
18
|
+
</span>
|
|
19
|
+
<span>KPI<span>Assembler</span></span>
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
<nav aria-label="Workflow">
|
|
23
|
+
<p class="nav-label">Workflow</p>
|
|
24
|
+
<button class="nav-item active" data-view="workspace">
|
|
25
|
+
<span class="nav-number">1</span>
|
|
26
|
+
<span><strong>Discover</strong><small>Inspect your data model</small></span>
|
|
27
|
+
</button>
|
|
28
|
+
<button class="nav-item" data-view="workspace">
|
|
29
|
+
<span class="nav-number">2</span>
|
|
30
|
+
<span><strong>Propose</strong><small>Review candidate KPIs</small></span>
|
|
31
|
+
</button>
|
|
32
|
+
<button class="nav-item" data-view="workspace">
|
|
33
|
+
<span class="nav-number">3</span>
|
|
34
|
+
<span><strong>Certify</strong><small>Run deterministic checks</small></span>
|
|
35
|
+
</button>
|
|
36
|
+
<button class="nav-item" data-view="dashboard">
|
|
37
|
+
<span class="nav-number">4</span>
|
|
38
|
+
<span><strong>Publish</strong><small>Use the trusted pack</small></span>
|
|
39
|
+
</button>
|
|
40
|
+
<p class="nav-label secondary">Product</p>
|
|
41
|
+
<button class="nav-item simple" data-view="integration">
|
|
42
|
+
<span class="nav-icon"></></span>
|
|
43
|
+
<span><strong>Integrate</strong><small>API and embed options</small></span>
|
|
44
|
+
</button>
|
|
45
|
+
</nav>
|
|
46
|
+
|
|
47
|
+
<div class="trust-note">
|
|
48
|
+
<span class="shield">✓</span>
|
|
49
|
+
<div><strong>Trust boundary</strong><small>The model proposes.<br>Code certifies.</small></div>
|
|
50
|
+
</div>
|
|
51
|
+
</aside>
|
|
52
|
+
|
|
53
|
+
<div class="page">
|
|
54
|
+
<header class="topbar">
|
|
55
|
+
<div>
|
|
56
|
+
<p class="eyebrow">KPI discovery workspace</p>
|
|
57
|
+
<h1 id="page-title">Metric discovery</h1>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="top-actions">
|
|
60
|
+
<span id="api-status" class="api-status"><i></i> Checking service</span>
|
|
61
|
+
<button class="button ghost" id="integration-shortcut">Integration guide</button>
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<main>
|
|
66
|
+
<section id="workspace-view" class="view active">
|
|
67
|
+
<div class="hero">
|
|
68
|
+
<div>
|
|
69
|
+
<span class="kicker">AI-first metric governance</span>
|
|
70
|
+
<h2>Turn application data into<br><em>metrics you can trust.</em></h2>
|
|
71
|
+
<p>Point KPIAssembler at an application database. It reads the schema, proposes KPI definitions, and certifies the SQL before a number is trusted.</p>
|
|
72
|
+
<div class="hero-actions">
|
|
73
|
+
<button class="button primary" id="discover-button">
|
|
74
|
+
<span class="button-icon">⌁</span>
|
|
75
|
+
Discover metrics
|
|
76
|
+
</button>
|
|
77
|
+
<span class="privacy"><span>●</span> Runs locally · Your data stays yours</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="pipeline-visual" aria-label="Discovery pipeline diagram">
|
|
81
|
+
<div class="pipeline-node database">
|
|
82
|
+
<span class="node-icon">▤</span>
|
|
83
|
+
<strong>Any app DB</strong>
|
|
84
|
+
<small>SQLite or Postgres</small>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="pipeline-line"><span></span></div>
|
|
87
|
+
<div class="pipeline-node model">
|
|
88
|
+
<span class="node-icon">✦</span>
|
|
89
|
+
<strong>Metric proposer</strong>
|
|
90
|
+
<small>Local LLM / heuristics</small>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="pipeline-line"><span></span></div>
|
|
93
|
+
<div class="pipeline-node certified">
|
|
94
|
+
<span class="node-icon">✓</span>
|
|
95
|
+
<strong>Certified pack</strong>
|
|
96
|
+
<small>Deterministic checks</small>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div id="discovery-results" class="results hidden">
|
|
102
|
+
<div class="section-heading">
|
|
103
|
+
<div>
|
|
104
|
+
<span class="step-tag">01 · DISCOVERED</span>
|
|
105
|
+
<h2>Your application data model</h2>
|
|
106
|
+
<p id="source-label">Connected source</p>
|
|
107
|
+
</div>
|
|
108
|
+
<button class="button ghost compact" id="rediscover-button">Scan again</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div id="discovery-notice" class="discovery-notice hidden" role="status"></div>
|
|
112
|
+
|
|
113
|
+
<div id="schema-summary" class="schema-grid" aria-live="polite"></div>
|
|
114
|
+
|
|
115
|
+
<div class="section-heading candidates-heading">
|
|
116
|
+
<div>
|
|
117
|
+
<span class="step-tag violet">02 · PROPOSED</span>
|
|
118
|
+
<h2>Select metrics to certify</h2>
|
|
119
|
+
<p id="proposal-subtitle">The proposal can be edited or rejected. It is not trusted yet.</p>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="selection-count"><strong id="selected-count">0</strong> selected</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div id="candidate-list" class="candidate-list"></div>
|
|
125
|
+
|
|
126
|
+
<div class="certify-bar">
|
|
127
|
+
<div>
|
|
128
|
+
<span class="shield large">✓</span>
|
|
129
|
+
<div><strong>Ready for deterministic certification</strong><small>Schema, join, null-safety, grain, and execution checks</small></div>
|
|
130
|
+
</div>
|
|
131
|
+
<button class="button primary" id="certify-button">Certify selected metrics <span>→</span></button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
|
|
136
|
+
<section id="dashboard-view" class="view">
|
|
137
|
+
<div class="dashboard-heading">
|
|
138
|
+
<div>
|
|
139
|
+
<span class="step-tag green">04 · PUBLISHED</span>
|
|
140
|
+
<h2>Certified KPI pack</h2>
|
|
141
|
+
<p>Only metrics that passed deterministic checks are shown as trusted values.</p>
|
|
142
|
+
</div>
|
|
143
|
+
<button class="button ghost compact" id="back-to-catalog">← Edit catalog</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div id="empty-dashboard" class="empty-state">
|
|
147
|
+
<span>◇</span>
|
|
148
|
+
<h3>No certified pack yet</h3>
|
|
149
|
+
<p>Discover your schema and certify at least one proposed metric.</p>
|
|
150
|
+
<button class="button primary" id="empty-discover">Start discovery</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div id="pack-content" class="hidden">
|
|
154
|
+
<article class="briefing-card">
|
|
155
|
+
<div class="briefing-icon">✦</div>
|
|
156
|
+
<div>
|
|
157
|
+
<span>OPERATING BRIEFING · LAST 30 DAYS</span>
|
|
158
|
+
<p id="briefing-headline"></p>
|
|
159
|
+
<small id="briefing-drill"></small>
|
|
160
|
+
</div>
|
|
161
|
+
</article>
|
|
162
|
+
|
|
163
|
+
<div class="role-tabs" id="role-tabs"></div>
|
|
164
|
+
<div id="metric-grid" class="metric-grid"></div>
|
|
165
|
+
|
|
166
|
+
<div class="catalog-heading">
|
|
167
|
+
<div>
|
|
168
|
+
<h3>Certification report</h3>
|
|
169
|
+
<p>Every accepted proposal has an explicit outcome and audit trail.</p>
|
|
170
|
+
</div>
|
|
171
|
+
<button class="button ghost compact" id="download-pack">Download JSON</button>
|
|
172
|
+
</div>
|
|
173
|
+
<div id="certification-report" class="certification-report"></div>
|
|
174
|
+
</div>
|
|
175
|
+
</section>
|
|
176
|
+
|
|
177
|
+
<section id="integration-view" class="view">
|
|
178
|
+
<div class="integration-hero">
|
|
179
|
+
<span class="step-tag violet">FRAMEWORK AGNOSTIC</span>
|
|
180
|
+
<h2>Bring trusted metrics into<br>any application.</h2>
|
|
181
|
+
<p>KPIAssembler runs as a sidecar service. Your product can consume its versioned REST API or embed the complete workflow with one web component.</p>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div class="integration-grid">
|
|
185
|
+
<article class="integration-card featured">
|
|
186
|
+
<span class="integration-type">01 · WEB COMPONENT</span>
|
|
187
|
+
<h3>Embed the complete UI</h3>
|
|
188
|
+
<p>Works in Rails, React, Angular, Vue, plain HTML, or any application that renders a webpage.</p>
|
|
189
|
+
<div class="code-block">
|
|
190
|
+
<button class="copy-button" data-copy="embed-code">Copy</button>
|
|
191
|
+
<pre id="embed-code"><code><script src="http://localhost:9292/kpi-assembler.js"></script>
|
|
192
|
+
<kpi-assembler
|
|
193
|
+
service-url="http://localhost:9292"
|
|
194
|
+
height="820px">
|
|
195
|
+
</kpi-assembler></code></pre>
|
|
196
|
+
</div>
|
|
197
|
+
</article>
|
|
198
|
+
|
|
199
|
+
<article class="integration-card">
|
|
200
|
+
<span class="integration-type">02 · REST API</span>
|
|
201
|
+
<h3>Build your own experience</h3>
|
|
202
|
+
<p>Use the certification engine from a mobile app, backend, BI surface, or existing admin panel.</p>
|
|
203
|
+
<div class="code-block">
|
|
204
|
+
<button class="copy-button" data-copy="api-code">Copy</button>
|
|
205
|
+
<pre id="api-code"><code>POST /api/v1/discover
|
|
206
|
+
|
|
207
|
+
POST /api/v1/certify
|
|
208
|
+
{
|
|
209
|
+
"accepted_ids": [
|
|
210
|
+
"lead_conversion_rate",
|
|
211
|
+
"paid_mrr"
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
GET /api/v1/pack</code></pre>
|
|
216
|
+
</div>
|
|
217
|
+
</article>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div class="integration-steps">
|
|
221
|
+
<h3>Application setup</h3>
|
|
222
|
+
<ol>
|
|
223
|
+
<li><span>1</span><div><strong>Run the service</strong><code>bundle exec ruby bin/kpi_assembler_web</code></div></li>
|
|
224
|
+
<li><span>2</span><div><strong>Connect an application database</strong><code>KPI_DATABASE_URL=postgres://user:pass@host:5432/application</code></div></li>
|
|
225
|
+
<li><span>3</span><div><strong>Limit tables / tenant</strong><code>KPI_INCLUDE_TABLES=orders,customers,accounts KPI_TENANT_ID=123</code></div></li>
|
|
226
|
+
<li><span>4</span><div><strong>Restrict browser access in production</strong><code>KPI_ALLOWED_ORIGIN=https://your-app.example</code></div></li>
|
|
227
|
+
</ol>
|
|
228
|
+
<aside>
|
|
229
|
+
<span>Important</span>
|
|
230
|
+
<p>Use a read-only database user or replica in production. KPIAssembler executes candidate queries during certification.</p>
|
|
231
|
+
</aside>
|
|
232
|
+
</div>
|
|
233
|
+
</section>
|
|
234
|
+
</main>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
239
|
+
</body>
|
|
240
|
+
</html>
|