<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Akshat Srivastava</title>
    <description>The latest articles on DEV Community by Akshat Srivastava (@akshat_srivastava_1930291).</description>
    <link>https://dev.to/akshat_srivastava_1930291</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4120609%2F483046b8-eedc-490b-a4e4-00c3ba7988c6.jpg</url>
      <title>DEV Community: Akshat Srivastava</title>
      <link>https://dev.to/akshat_srivastava_1930291</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akshat_srivastava_1930291"/>
    <language>en</language>
    <item>
      <title>KPI Assembler: stop hand-picking KPIs, let AI propose them</title>
      <dc:creator>Akshat Srivastava</dc:creator>
      <pubDate>Fri, 11 Sep 2026 10:17:11 +0000</pubDate>
      <link>https://dev.to/akshat_srivastava_1930291/kpiassembler-stop-hand-picking-kpis-let-ai-propose-them-kbo</link>
      <guid>https://dev.to/akshat_srivastava_1930291/kpiassembler-stop-hand-picking-kpis-let-ai-propose-them-kbo</guid>
      <description>&lt;p&gt;Text-to-SQL looks great in a demo. Then someone ships a conversion rate that &lt;code&gt;JOIN&lt;/code&gt;s without &lt;code&gt;ON&lt;/code&gt;, a revenue figure that double-counts through a fan-out, or a "tenant-safe" query that forgot &lt;code&gt;account_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The chart library was never the hard part. &lt;strong&gt;Metric definition&lt;/strong&gt; is.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;KPI Assembler&lt;/strong&gt; for that gap: inspect the live schema, let a model &lt;em&gt;propose&lt;/em&gt; KPI recipes, and let &lt;strong&gt;deterministic Ruby&lt;/strong&gt; decide what is certified. The model never gets a vote on publication.&lt;/p&gt;

&lt;p&gt;Gem: &lt;a href="https://rubygems.org/gems/kpi_assembler" rel="noopener noreferrer"&gt;kpi_assembler&lt;/a&gt;&lt;br&gt;
Source: &lt;a href="https://github.com/Akshatsrivastava700/kpi_assembler" rel="noopener noreferrer"&gt;github.com/Akshatsrivastava700/kpi_assembler&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The LLM proposes. Code certifies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Certification is boring on purpose. Each accepted candidate must:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;be a single read-only &lt;code&gt;SELECT&lt;/code&gt; (or &lt;code&gt;WITH&lt;/code&gt; … &lt;code&gt;SELECT&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;reference tables that actually exist&lt;/li&gt;
&lt;li&gt;include tenant scope when that column is on the fact table&lt;/li&gt;
&lt;li&gt;never &lt;code&gt;JOIN&lt;/code&gt; without &lt;code&gt;ON&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;guard divisions (&lt;code&gt;NULLIF&lt;/code&gt; / &lt;code&gt;CASE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;survive &lt;code&gt;EXPLAIN&lt;/code&gt; and a sample-period replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fail any check and the KPI is &lt;strong&gt;draft&lt;/strong&gt;, with reasons. Draft is not "you rejected it in the UI." Rejected candidates never reach this step. Draft means you &lt;em&gt;accepted&lt;/em&gt; it and the engine still refused to publish the number.&lt;/p&gt;

&lt;p&gt;That distinction is the whole product.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the pipeline actually does
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discover&lt;/strong&gt; — introspect tables, columns, FKs, time columns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propose&lt;/strong&gt; — Gemini, local Ollama, or schema heuristics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept&lt;/strong&gt; — you pick the recipes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certify&lt;/strong&gt; — the checks above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish&lt;/strong&gt; — a JSON pack of certified KPIs (plus drafts and why they failed)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Heuristics still run as a backfill. If Gemini is down or Ollama has the wrong model pulled, you get schema-driven candidates instead of an empty screen. The UI says whether the proposer was &lt;code&gt;llm&lt;/code&gt; or &lt;code&gt;heuristics&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install in a Rails 7 app
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"kpi_assembler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 0.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle &lt;span class="nb"&gt;install
&lt;/span&gt;bin/rails generate kpi_assembler:install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes.rb — inside the same auth scope as the rest of the app&lt;/span&gt;
&lt;span class="n"&gt;mount&lt;/span&gt; &lt;span class="no"&gt;KPIAssembler&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/kpi-assembler"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Point the initializer at a &lt;strong&gt;read-only&lt;/strong&gt; pool. Certification executes candidate SQL. Do not hang this off the write primary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;KPIAssembler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connection_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connected_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;role: :reading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connection_pool&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tenant_column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"account_id"&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tenant_id_resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:current_account&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorize_with&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:authenticate_user!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:current_account&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;llm_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:gemini&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gemini_api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GEMINI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KPI_LLM_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini
&lt;span class="nv"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-key
&lt;span class="nv"&gt;KPI_GEMINI_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini-2.0-flash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Gemini URL to set. Restart, sign in, open &lt;code&gt;/kpi-assembler&lt;/code&gt;, click &lt;strong&gt;Discover metrics&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Prefer local models? &lt;code&gt;KPI_LLM_PROVIDER=ollama&lt;/code&gt; and a running &lt;code&gt;ollama serve&lt;/code&gt;. Prefer no model? &lt;code&gt;KPI_USE_LLM=false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Walkthrough and troubleshooting: &lt;a href="https://github.com/Akshatsrivastava700/kpi_assembler/blob/main/docs/setup.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A certification failure worth keeping
&lt;/h2&gt;

&lt;p&gt;The sample catalog includes a metric that is &lt;em&gt;supposed&lt;/em&gt; to fail: revenue per lead via an unconstrained join. It is accepted on purpose so you can watch certification refuse it.&lt;/p&gt;

&lt;p&gt;You should see reasons like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;JOIN without ON — unconstrained join / cartesian risk&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Unsafe division without NULLIF or CASE&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the demo I care about — not a green dashboard.&lt;/p&gt;

&lt;p&gt;Other drafts you will hit with a real LLM: missing &lt;code&gt;account_id = 123&lt;/code&gt;, invented table names, or &lt;code&gt;Sample-period replay returned NULL&lt;/code&gt; when &lt;code&gt;NULLIF&lt;/code&gt; did its job on an empty window. Those are honest failures. Empty last-30-days is not the same as bad SQL, but the engine currently treats a NULL replay as unpublished. Read the &lt;code&gt;reasons&lt;/code&gt; array before you rewrite the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is not Looker, Metabase, or a warehouse. It does not persist packs to your database yet: the engine keeps the latest pack &lt;strong&gt;in memory per tenant&lt;/strong&gt;. Restart the process and it is gone. &lt;code&gt;GET /kpi-assembler/api/v1/pack&lt;/code&gt; is the JSON to save yourself if you need it durable.&lt;/p&gt;

&lt;p&gt;It is not "AI analytics." It is a gated compiler for metric SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"kpi_assembler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 0.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Issues and PRs: &lt;a href="https://github.com/Akshatsrivastava700/kpi_assembler" rel="noopener noreferrer"&gt;Akshatsrivastava700/kpi_assembler&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you already generate KPIs with a chatbot, run one of those queries through a join-without-&lt;code&gt;ON&lt;/code&gt; check before you put it on a slide. That is the same instinct this gem encodes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ruby</category>
      <category>rails</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
