Insight adapter
Turns a set of measures into the paragraph a reader would write about them.
The adapter shipped here is deterministic: the same numbers produce the same sentence, every number in it is a real value from the input, and nothing is invented. That matters more than fluency — a summary that rounds, hedges or hallucinates is worse than the table it sits above. The verb comes from the sign of the change and the order from its size, so the sentence opens on what actually moved; a change under half a percent is called flat rather than dressed up as news. The result is a run of segments rather than a string, which is what lets InsightCard render each figure as a real MetricValue — with its delta and its explain button — inside the prose. Swap it for a model by writing another function of the same shape: the card does not care which one it was handed. insightText flattens the same summary to a plain sentence for a title attribute, a log, or a test.
Install
npx shadcn@latest add @vibra/insight-adapterNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { insightText, summarise, type InsightMetric } from "@/lib/insight-adapter"
import { Slider } from "@/components/ui/slider"
const BEFORE = { visitors: 42_860, rate: 0.0361 }
/** The same rows, written up: move the numbers and watch the sentence follow. */
export default function InsightAdapterDemo() {
const [visitors, setVisitors] = React.useState(48_120)
const [rate, setRate] = React.useState(0.034)
const metrics: InsightMetric[] = [
{
key: "visitors",
label: "Visitors",
metric: { kind: "count", value: visitors },
previous: BEFORE.visitors,
},
{
key: "rate",
label: "Signup rate",
metric: { kind: "percent", value: rate, precision: 2 },
previous: BEFORE.rate,
},
]
return (
<div className="flex w-full flex-col gap-5">
<p className="text-prose text-foreground">
{insightText(summarise({ metrics, range: "the last 30 days" }))}
</p>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-2 type-label">
Visitors — {visitors.toLocaleString("en-US")}
<Slider
value={[visitors]}
min={20_000}
max={70_000}
step={500}
onValueChange={(next) => setVisitors(Array.isArray(next) ? next[0] : next)}
/>
</label>
<label className="flex flex-col gap-2 type-label">
Signup rate — {(rate * 100).toFixed(2)}%
<Slider
value={[rate * 10_000]}
min={200}
max={600}
step={5}
onValueChange={(next) => setRate((Array.isArray(next) ? next[0] : next) / 10_000)}
/>
</label>
</div>
<p className="text-sm text-muted-foreground">
The verb comes from the sign of the change and the order from its size, so the sentence
opens on whatever actually moved. Inside half a percent it says the number held.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| summarise | ({ metrics, range, event? }) => Insight | — | The summary: segments to render, and the panels to cite. |
| changeRatio | (metric) => number | undefined | — | How far one measure moved, or undefined with nothing to compare against. |
| insightText | (insight) => string | — | The same summary as one plain sentence. |
Dependencies
Registry
Source
import { formatPercent } from "@/lib/format"
import { formatMetric, type Metric, type Provenance } from "@/lib/metric"
/**
* Turns a set of measures into the paragraph a reader would write about them.
*
* The adapter shipped here is deterministic: the same numbers produce the same
* sentence, every number in it is a real value from the input, and nothing is
* invented. That matters more than fluency — a summary that rounds, hedges or
* hallucinates is worse than the table it sits above. Swap it for a model by
* writing another function of the same shape; the card does not care which one
* it was handed, and the docs show the Vercel AI SDK version.
*/
/** One measure the summary may talk about. */
export type InsightMetric = {
key: string
/** What it measures, e.g. "Visitors". */
label: string
metric: Metric
/** The same measure over the period before, in `metric`'s own kind. */
previous?: number
/** False where down is the win — churn, latency, cost. */
positiveIsGood?: boolean
/** The rows behind it, so the number keeps its explain button inside prose. */
provenance?: Provenance
/** The id of the panel this number came from, for the Sources row. */
sourceId?: string
}
export type InsightRequest = {
metrics: InsightMetric[]
/** The window, in a reader's words: "the last 30 days". */
range: string
/** A named event inside the window worth calling out, e.g. a launch. */
event?: { label: string; on: string }
}
/**
* A summary as a run of segments rather than a string: a metric segment renders
* as the real number, with its own explain trigger, inside the sentence — which
* is what keeps every figure in the prose checkable rather than quotable.
*/
export type InsightSegment =
| { kind: "text"; text: string }
| { kind: "metric"; metric: InsightMetric }
export type InsightCitation = { id: string; label: string }
export type Insight = {
segments: InsightSegment[]
citations: InsightCitation[]
}
/** How far a measure moved, as a ratio, or undefined when there is nothing to compare with. */
export function changeRatio(entry: InsightMetric): number | undefined {
if (entry.previous === undefined || entry.previous === 0) return undefined
return entry.metric.value / entry.previous - 1
}
// Below this a change is noise a reader should not be told a story about.
const FLAT = 0.005
function direction(entry: InsightMetric): "up" | "down" | "flat" {
const change = changeRatio(entry)
if (change === undefined || Math.abs(change) < FLAT) return "flat"
return change > 0 ? "up" : "down"
}
// The size of the move goes in the words and the value stays a real number, so
// a sentence reads as one line of prose rather than as a row of stat cards.
const VERBS = {
up: ["rose", "climbed", "is up"],
down: ["fell", "slipped", "is down"],
flat: ["held at", "stayed at", "sat at"],
} as const
function movement(entry: InsightMetric, index: number): string {
const way = direction(entry)
const verb = VERBS[way][index % VERBS.up.length]
if (way === "flat") return verb
const change = Math.abs(changeRatio(entry) ?? 0)
return `${verb} ${formatPercent(change, { maximumFractionDigits: 1 })} to`
}
/** The strongest mover first, so the sentence opens on what actually changed. */
function byMovement(a: InsightMetric, b: InsightMetric): number {
return Math.abs(changeRatio(b) ?? 0) - Math.abs(changeRatio(a) ?? 0)
}
/**
* The one-paragraph summary of a set of measures over a window.
*
* Deterministic by construction: the verb comes from the sign of the change,
* the order from its size, and every figure is the metric itself rather than a
* copy of it.
*/
export function summarise(request: InsightRequest): Insight {
const { metrics, range, event } = request
if (metrics.length === 0) {
return { segments: [{ kind: "text", text: `Nothing to summarise for ${range}.` }], citations: [] }
}
const ordered = [...metrics].sort(byMovement)
const segments: InsightSegment[] = []
const say = (text: string) => segments.push({ kind: "text", text })
ordered.forEach((entry, index) => {
const verb = movement(entry, index)
if (index === 0) say(`Over ${range}, ${entry.label.toLowerCase()} ${verb} `)
else if (index === ordered.length - 1) say(`, and ${entry.label.toLowerCase()} ${verb} `)
else say(`, ${entry.label.toLowerCase()} ${verb} `)
segments.push({ kind: "metric", metric: entry })
})
say(".")
if (event) say(` ${event.label} on ${event.on} is the step in the middle of the window.`)
const citations: InsightCitation[] = []
for (const entry of ordered) {
if (!entry.sourceId || citations.some((cited) => cited.id === entry.sourceId)) continue
citations.push({ id: entry.sourceId, label: entry.label })
}
return { segments, citations }
}
/** The same summary as a plain sentence — for a title attribute, a log, or a test. */
export function insightText(insight: Insight): string {
return insight.segments
.map((segment) =>
segment.kind === "text" ? segment.text : formatMetric(segment.metric.metric)
)
.join("")
}