GestaltBI la forma all’origine del significato

inference

@gestaltbi/inference

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.

npm install @gestaltbi/inference on npm →

The shape of it

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:

profileFrame(rows, directory, { sampleValues: true, maxSamples: 5 });

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.

fieldwhat it holds
rationalewhy this dataset supports it — the cardinality, the period, the range of a measure
questionthe one plain question it answers, as an owner would ask it out loud
readinghow 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.

const { story, problems } = await composeStory(profile, suggestions, {
  apiKey, model, chapters: 4, language: 'Italian',
});

story is a @gestaltbi/storybook Story — 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.

Functions

complete

fn
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.

composeStory

fn
async function composeStory<S = unknown>( profile: DataProfile, recommendations: Recommendation[], options: ComposeStoryOptions, ): Promise<ComposedStory<S>>

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.

disclosedValues

fn
function disclosedValues(profile: DataProfile): string[]

Everything in a profile that could be a real value.

Exported so a host can show the owner exactly what is about to be sent, and so the test suite can assert that nothing else slipped in.

ground

fn
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.

groundStory

fn
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.

listModels

fn
async function listModels( o: Pick<OpenRouterOptions, 'apiKey' | 'baseUrl' | 'fetchImpl' | 'signal' | 'attribution'>, ): Promise<ModelInfo[]>

Models the key can reach.

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.

parseRecommendations

fn
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.

parseStory

fn
function parseStory(text: string): any

Pull a story object out of whatever the model actually returned.

profileFrame

fn
function profileFrame( rows: any[], directory?: ColumnDirectory, options: ProfileOptions = {}, ): DataProfile

Describe a frame without disclosing it.

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.

recommend

fn
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.

Classes

OpenRouterError

class
class OpenRouterError extends Error

Raised with OpenRouter's own message when it rejects a request.

Interfaces

ColumnProfile

interface
interface ColumnProfile

What is known about one column — shape, not content.

Counts, ranges and cardinalities. No rows. samples is the single field that can carry real values, and it is off unless the caller asks for it.

column: string
label?: string
type?: string
tags: string[]
role: Role
present: number

Rows carrying something for this column.

distinct?: number

Distinct values, capped — dimensions only. >= when the cap was hit.

distinctCapped?: boolean
min?: number

Measures only.

max?: number
mean?: number
earliest?: string

Time columns only, ISO dates.

latest?: string
samples?: string[]

Real values. Empty unless sampleValues was set.

CompleteRequest

interface
interface CompleteRequest

One prompt: the system framing, the user turn, and the schema the answer should honour.

system: string
user: string
schema?: { name: string; schema: Record<string, unknown> }

Asked for as a strict JSON schema when the model supports it.

CompleteResult

interface
interface CompleteResult

What came back, and whether the schema had to be given up to get it.

text: string
degraded: boolean

True when the answer came back without the schema, because it was refused.

ComposedStory

interface
interface ComposedStory<S = unknown>

A written report, and whatever had to be dropped for the rest of it to resolve.

story: S

A Story from @gestaltbi/storybook, ready to resolve.

problems: string[]

What had to be dropped for the rest to resolve, in the host's words.

Empty is the normal case. Anything here is worth showing: it is the model having written about a column this dataset does not have.

ComposeStoryOptions

interface
interface ComposeStoryOptions extends OpenRouterOptions

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.

DataProfile

interface
interface DataProfile

Everything the model is told about a dataset.

This is the whole payload. If something is not here, it is not sent.

rows: number
columns: ColumnProfile[]
modes?: string[]

Analysis kinds the host can actually render, so advice is actionable.

views?: string[]
context?: string

Free text the host wants the model to know — a domain, a language.

ModelInfo

interface
interface ModelInfo

A model as OpenRouter describes it.

id: string
name?: string
contextLength?: number
supportedParameters?: string[]

Present when OpenRouter reports it; used to tell whether it can do the job.

promptPrice?: number
completionPrice?: number
structuredOutputs: boolean

True when the model can be asked for a JSON schema and honour it.

OpenRouterOptions

interface
interface OpenRouterOptions

Everything a call to OpenRouter needs, including the user's own key.

apiKey: string

The user's own key. Never stored, never logged, sent only to OpenRouter.

model: string

Model slug, e.g. from {@link listModels}.

baseUrl?: string
fetchImpl?: typeof fetch

Injectable for tests and for hosts with their own fetch.

attribution?: { referer?: string; title?: string }

Optional attribution shown on OpenRouter's rankings.

signal?: AbortSignal
temperature?: number
maxTokens?: number

ProfileOptions

interface
interface ProfileOptions

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.

label?: (column: string) => string | undefined

Resolve a column code to its human label.

Recommendation

interface
interface Recommendation

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.

problems?: string[]

What failed to ground, in the host's words.

RecommendOptions

interface
interface RecommendOptions extends OpenRouterOptions

How many suggestions to ask for, and whether to keep the ones that fail to ground.

limit?: number

Cap on returned recommendations. Default 6.

keepUngrounded?: boolean

Keep ungrounded recommendations, marked, instead of dropping them. Default false.

Types

Role

type
type Role = 'dimension' | 'measure' | 'time' | 'other'

What a column is for, as far as the structure says.

Constants

RECOMMENDATION_SCHEMA

const
const RECOMMENDATION_SCHEMA

The shape the model is asked to return, when it can honour a schema.

STORY_SCHEMA

const
const STORY_SCHEMA

The story shape a model is asked to return.

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.