Ask a model what is worth doing with a dataset — and keep only the answers that check out.
Bring your own key. Describe the data, never hand it over. Check every suggestion against the columns that actually exist, and report the ones that fail rather than quietly dropping them.
Framework-agnostic, like the rest of GestaltBI. It renders nothing and stores nothing.
import { profileFrame, listModels, recommend } from '@gestaltbi/inference';
import { StructureDirectory } from '@gestaltbi/stream';
// 1. describe the data — this is the entire payload
const profile = {
...profileFrame(rows, new StructureDirectory(structure)),
modes: ['pivot', 'correlate', 'long'], // what your host can actually render
views: ['table', 'graph'],
};
// 2. let the user pick a model that can do the job
const models = (await listModels({ apiKey })).filter((m) => m.structuredOutputs);
// 3. ask, and keep what grounds
const suggestions = await recommend(profile, { apiKey, model: models[0].id });
Each suggestion names an analysis in your vocabulary — a mode, a view, the columns for each axis,
the measure to fill it — so a host can turn one into a rendered view without interpreting prose.
The rows do not leave
profileFrame returns statistics, not records: what each column is for, how populated it is, how
many distinct values a dimension has, the range of a measure, the period a date column covers. That is
what gets sent — by recommend and by composeStory alike. There is no code path that puts a row
in a request.
The single exception is sampleValues, which is off by default:
Turn it on and a few real values per dimension are included, which helps a model work out what a
column means. It also means a customer list reaches a third party. disclosedValues(profile) returns
exactly what would be sent, so a host can show someone what they are agreeing to before they agree.
Advice that teaches
A chart nobody knows how to read teaches nothing. A recommendation carries three pieces of writing that
are deliberately not the same thing.
field
what it holds
rationale
why this dataset supports it — the cardinality, the period, the range of a measure
question
the one plain question it answers, as an owner would ask it out loud
reading
how to read the result once drawn, and what each shape would mean
"Which product lines carry the margin?" — not "a pivot of margin by product line". And reading
is written for someone who has never read a chart of that kind and has to decide something today: a
long tail, a flat line, one bar carrying most of the total, two series drifting apart.
Nothing is taken on trust
A model asked to name columns will sometimes name plausible ones that are not there — revenue when
the column is smartbi:calc:daily_revenue:sum. Acting on that renders an empty view and explains
nothing, which reads as the product being broken.
So every suggestion is checked against the profile it was generated from: the columns exist, an axis is
a dimension and not a measure, the column is not empty, the mode and view are ones the host offers.
const all = await recommend(profile, { apiKey, model, keepUngrounded: true });
all.filter((r) => !r.grounded).forEach((r) => console.log(r.title, r.problems));
// → "Margin by channel" [ 'rows: no column "channel" in this dataset' ]
The default is to drop them. keepUngrounded shows the misses, which is the more honest thing to put
in front of someone evaluating a model.
From analysis to report
A list of recommended charts is still a to-do list. composeStory turns one into the report an owner
would otherwise have paid a consultant for.
story is a @gestaltbi/storybookStory — data, not
markup — ready for resolveStory(story, rows).
The model writes the sentences; the data supplies the numbers
Every figure in the prose is a {{token}} the model declares and your host computes against the real
frame. The model decides what to compute and never learns what it came to, so it cannot round,
misremember or flatter a number it never saw. It is told to write sentences that stay true whichever
way the value falls, and to put a check on any claim that matters so the data can contradict it.
Stories are grounded like recommendations, but by the chapter: dropping one bad figure would leave
its {{token}} standing in a sentence with no number in it, which reads worse than the chapter being
absent. So a chapter that reaches for a column this dataset does not have goes whole, and problems
says which and why.
problems // → ['Margin — figure "m": no column "margin"']
Choosing a model
OpenRouter fails a request outright when a model cannot honour response_format — it does not
quietly ignore it. So listModels reports structuredOutputs per model, and a picker should prefer
those.
If a model refuses anyway, complete retries once without the schema and sets degraded, because a
user who picked a model should get an answer rather than an error. Parsing stays defensive either way:
JSON in a fenced block, JSON buried in prose, or a bare array all read the same, and something unusable
returns an empty list rather than throwing.
Requests go straight to openrouter.ai/api/v1, so nothing sits between the user and the provider they
are paying. Attribution headers — HTTP-Referer and X-Title, which affect OpenRouter's public
rankings — are sent only when a host asks for them.
Reference
Extracted from src/index.ts when this page was built, so it
cannot drift from what the package actually exports.
async function complete(o: OpenRouterOptions, req: CompleteRequest): Promise<CompleteResult>
One completion.
Asks for a strict JSON schema when one is given. OpenRouter rejects that outright on a model that cannot do it, rather than ignoring it — so a refusal is retried once without the schema, and the caller is told it happened. That keeps a model the user chose usable instead of failing in front of them, while the parsing stays defensive either way.
Write a story from a dataset profile and an analysis of it.
The result is grounded before it is returned: a chapter naming a column this dataset does not have is dropped rather than handed to a renderer that would print an em dash where the number should be. What was dropped, and why, comes back alongside — a silent repair is indistinguishable from the model having got it right.
function ground(recommendations: Recommendation[], profile: DataProfile): Recommendation[]
Check a recommendation against the dataset it claims to be about.
A model asked to name columns will sometimes name plausible ones that are not there — revenue when the column is smartbi:calc:daily_revenue:sum, a region dimension a sales table never had. Acting on that produces an empty view and no explanation, which reads as the product being broken.
So every reference is checked, and a recommendation that fails carries the reasons. Nothing is discarded quietly: the host decides whether to hide these or show them as what the model got wrong.
function groundStory<S = unknown>(raw: any, profile: DataProfile, fallbackTitle?: string): ComposedStory<S>
Keep the parts of a story this dataset can actually answer.
Grounding is chapter-level on purpose. Dropping one bad figure would leave its {{token}} standing in a sentence that no longer has a number in it, which reads worse than the chapter being absent — so a chapter that reaches for something missing goes whole, and says so.
structuredOutputs is the field that matters for this library: OpenRouter fails a request outright when a model cannot honour response_format, so a host offering a model picker should offer models that can — or be ready for the fallback in complete.
function parseRecommendations(text: string): Recommendation[]
Pull the recommendations out of whatever the model actually returned.
Structured output is requested, but a model that ignored it, wrapped the JSON in prose, or fenced it in a code block should still be usable — the alternative is telling the user their model is broken when the answer is sitting right there.
This is the entire payload a model is given: how many rows, what the columns are for, how much of each is populated, how many distinct values a dimension has, what range a measure covers. Enough to reason about what the data can answer; not enough to read anybody's customers, prices or orders back out.
The one exception is sampleValues, which is off by default and is the only way a real value reaches the request. A host that wants better advice can turn it on — deliberately, and on behalf of someone who understands the trade.
async function recommend(profile: DataProfile, options: RecommendOptions): Promise<Recommendation[]>
Ask a model what is worth analysing, and keep only what checks out.
The dataset is described, never disclosed — see profileFrame. What comes back is validated against the same profile, so a recommendation naming a column that does not exist is reported as such rather than handed to a view that will render nothing.
The subset of the storybook format a model is asked to write.
Not the whole thing: figures, four kinds of panel and two kinds of check are enough to carry a report, and every one of them can be validated against a profile. A format wide enough to express anything is also wide enough to express something that will not resolve.
chapters?: number
Chapters to aim for. Default 4.
title?: string
A title to work from, when the host has one.
language?: string
Language to write in, as a name the model will recognise. Default English.
How much to say about the data — and, with sampleValues, whether to say anything the rows themselves contain.
sampleValues?: boolean
Include a handful of real values per dimension.
Off by default, and it is the only switch that changes what leaves the machine. Values help a model reason about what a column means — and a customer list is exactly the sort of thing that should not be handed to a third party without the owner deciding to.
maxSamples?: number
How many values per dimension when sampling. Default 5.
maxDistinct?: number
Stop counting distinct values past this. Default 200.
One thing worth doing with this data, expressed in the host's own vocabulary.
id: string
title: string
rationale: string
Why this dataset supports it. One or two sentences.
question?: string
The plain-language question it answers.
"Which product lines carry the margin?" rather than "pivot of margin by product line" — the owner has the question already; what they lack is the knowledge that this data can answer it.
reading?: string
How to read the result, and what it would tell them.
The analysis is only half of it. A chart nobody knows how to interpret teaches nothing, so this says what shapes to look for and what each would mean for the business.
mode: string
Analysis kind, from the profile's modes.
view: string
Surface, from the profile's views.
rows?: string[]
Axes and measures, as column codes.
columns?: string[]
measures?: string[]
aggregate?: string
confidence: 'high' | 'medium' | 'low'
grounded?: boolean
False when it names something the dataset does not have.
A deliberate subset of the storybook format: figures, four panel kinds and three checks. A format wide enough to express anything is also wide enough to express something that will not resolve.