Ask — LLM commentary

<Ask /> sends a query's result to an LLM and renders its natural-language commentary inline — the explained-insight layer on top of a chart or table. The model's markdown answer is rendered with raw HTML disabled, and answers are cached so repeat page loads don't re-bill. A small sparkle badge marks the block as AI-generated; hovering the card turns it amber and reveals the model that wrote the answer (e.g. Generated by mistral-small-latest). On a live server a fresh answer streams into the card as the model writes it, so the first words appear sub-second instead of after the full generation.

<Ask data={downloads_by_month} ask="Summarize the download trend in two sentences." />
Attribute Purpose
data Required. The query whose result the model sees. A comma list (data={a,b}) grounds one answer in several results — see Cross-dataset commentary.
ask Required. The instruction / prompt.
max_rows Cap on rows sent to the model (default in llm.py).
cache_ttl Seconds to cache the answer.
label Optional heading text next to the AI badge. Default: just a small sparkle icon (hover it for "AI-generated commentary").
replay Typewriter replay of a cached/baked answer: "once" (default), "always", or "off".
refresh refresh=false removes the viewer ↻ regenerate button — and the server stops honoring _refresh for this ask, so viewers can't force billable LLM calls.
inline Chrome-less: no card border/background — the answer reads as part of the page prose (blog style). The small ✦ AI badge stays visible; the ↻ button and model attribution appear on hover.
font_size Answer text size: "xs", "sm" (default), "base", "lg", or an explicit CSS length like "0.8rem". The whole answer scales with it — headings, tables, and code included.
highlight Hover provenance: while the ask is hovered, the charts/tables bound to these queries glow amber. Defaults to the ask's own data query; highlight="a,b" names others, highlight=false disables.
lazy On by default: the answer is generated only once the card scrolls into view — an unseen ask costs nothing, and the viewer watches it type in. lazy=false generates on page load. PDF/screenshot exports always load eagerly.

Live example #

This docs site configures llm: provider: mistral with api_key: ${MISTRAL_API_KEY} — so the card below is real. If the env var isn't set, the site still serves and builds; the card just shows a muted "AI commentary is not available" note instead (a broken or absent llm: block never fails the server — see Graceful degradation).

Hover the commentary card: the chart above glows amber — it's bound to the same query the answer was generated from (see the highlight attribute).

The knobs at a glance #

<Ask data={monthly_downloads} label="Insights" ask="What stands out?" />   <!-- heading text next to the badge -->
<Ask data={monthly_downloads} refresh=false ask="What stands out?" />      <!-- viewers can't force fresh LLM calls -->
<Ask data={monthly_downloads} replay="off" ask="What stands out?" />       <!-- cached answers render instantly, no typewriter -->
<Ask data={monthly_downloads} max_rows=20 cache_ttl=86400 ask="…" />       <!-- smaller data payload, day-long answer cache -->
<Ask data={monthly_downloads} font_size="xs" ask="What stands out?" />      <!-- compact answer text (or "lg", or "0.8rem") -->

Blog-style inline commentary #

Add inline and the answer renders without the card — it reads as a paragraph of the page itself, so generated narrative can sit between your own prose. The small ✦ AI marker stays above it (readers should always know text is generated); hover the paragraph for the model attribution and ↻ button:

<Ask data={monthly_downloads} inline>Narrate the download trend in one short paragraph.</Ask>

Bigger answer text #

font_size scales the whole answer surface — spacing, tables, and code included, not just the paragraphs. "lg" suits commentary that is the centerpiece of a page rather than a footnote to a chart (an explicit CSS length like font_size="1.25rem" works too):

<Ask data={monthly_downloads} font_size="lg" ask="What is the headline takeaway from this data?" />

Cross-dataset commentary #

data accepts a comma list of queries — the model gets one labeled result block per query, so a single ask can reason across datasets ("is churn driving the revenue dip?") instead of commenting on each in isolation:

<Ask data={monthly_downloads,downloads_by_country}
     ask="Do the download trend and the country split tell one story?" />

Everything composes as you'd expect: each name resolves its own connector (the list can span sources), hovering the card highlights all referenced charts/tables, the answer cache busts when a filter any of the queries uses changes (and only then), and static builds bake the answer after executing every referenced query.

Works on semantic-layer data too #

<Ask> accepts a semantic metric reference as an alternative to data={query} — the same grammar a chart uses:

<Ask metric={sales.revenue} by={sales.region} ask="Which region stands out, and why?" />

It binds to the same synthetic query a chart with those attrs would build, so the answer rides the same result cache and filter path. A plain queries/*.py Python query works as a source too.

Explain any chart #

Every chart accepts an explain attribute: it adds a small ✨ button to the chart card (revealed on hover, next to the "filtered by" marker) that generates commentary on demand into a footer under the plot:

<BarChart data={downloads_by_country} x="country" y="downloads"
          title="Downloads by country" explain />

Hover the chart and click the sparkle. It's sugar over <Ask>: at render time the chart registers an ordinary ask with a canned prompt ("explain what is notable in this data", naming the chart's type and title), so viewers still can't send prompts, answers cache and stream exactly the same way, and the ↻ button regenerates. Nothing is generated until the first click — an idle chart costs nothing — and while the footer is closed, a filter change defers the regeneration to the next open instead of billing for commentary nobody is reading.

explain="…" pins your own question instead of the canned one, and cache_ttl= tunes how long the generated answer stays cached — the same attribute (and one-hour default) <Ask> takes:

<LineChart data={monthly_downloads} x="month" y="downloads"
           explain="Is growth accelerating or slowing?" cache_ttl=86400 />

Annotations on the chart #

On line, bar, scatter, combo, and candlestick charts — and on box plots and violins — explain doesn't just write: it can mark the chart itself: a dashed threshold line, a shaded range band, a dot on the peak, a highlighted bar, a marked trading session, an outlined box. Grid charts get an outlined cell: <HeatmapChart> on the cited row/column cell, <CalendarHeatmap> on the cited day (including "the busiest day", recomputed from the live data). Pie and funnel charts get a called-out slice or stage (a dashed outline, with the annotation's label as a leader-line callout on pies), and <MapChart> gets a highlighted region with its label drawn on the map. On the SVG geo maps <BubbleMap> and <DotDensityMap> the mark is a dashed halo ring with a leader-line label on the cited country. Each mark is cited from the commentary by a small numbered chip (hover or focus a chip to bold its mark; the chip's tooltip carries the label). The marks appear when the commentary opens and clear when you close the footer. annotations=false on any chart keeps the commentary but never asks for (or draws) marks.

Restraint is enforced, not just prompted: the model may propose at most a handful of annotations (an empty set is a perfectly good answer for an unremarkable chart), and every candidate is validated server-side against the full query result — a value outside the data's actual range, a category that doesn't exist, or a series the chart doesn't draw is silently dropped. Extremes ("the maximum") are recomputed from the live data rather than trusting the model's coordinates, so marks stay correct as filters change.

Chart types with no mark a validated annotation could safely target keep commentary-only explain — by decision, not omission: radar, gauge, sankey, graph, sunburst, tree, parallel, theme river, and treemap, plus faceted pies (series= small multiples) and the choropleth geo maps (<ChoroplethTime>/<ChoroplethFacets>/<BivariateMap> — facets, animation frames, and two-metric encodings give one static mark nothing stable to point at). Charts bound to a live streaming query are also commentary-only — their data changes under the marks every poll interval.

See it in action

The charts below are live — hover one and click its ✨ sparkle. The commentary types in with numbered chips, and any marks the model proposed (and the server validated) paint onto the plot. Each run may mark different things, or nothing at all — restraint is the point:

<LineChart data={monthly_downloads} x="month" y="downloads" title="Monthly downloads"
           explain="What is the trend, and which months stand out?" />

On part-of-whole charts the mark is a called-out slice or stage — a dashed outline, with the annotation's label as a leader-line callout on pies:

<PieChart data={downloads_by_channel} x="channel" y="downloads" title="Downloads by channel" explain />
<FunnelChart data={funnel_stages} x="stage" y="users" title="Visitor funnel" explain />

And on a <MapChart> the model can point at regions — each proposal is validated against the locations the query actually returned, so it can never highlight a country that isn't in the data:

<MapChart data={downloads_by_country} location="country" value="downloads"
          title="Downloads by country" explain />

The SVG geo maps work the same way with a halo ring: on a <BubbleMap> or <DotDensityMap> the model cites countries by the same join id the data carries, each proposal is validated against the ids in the active year slice (a country with nothing drawn can't earn a halo), and a halo the model scoped to one metric shows only while that metric is toggled active.

max_rows= on the chart tunes how many rows the model sees (annotated explains default to 200 — more than <Ask>'s 50 — so proposals ground in the full picture).

Explain works in static builds too: the answer — commentary, chips, and annotations — is generated once at build time and baked into the export; the first click on the sparkle retrieves that snapshot, exactly like serve mode. Only the ↻ regenerate affordance needs a live server.

Configuration #

Set llm.provider to one of mistral, anthropic (Claude), openai, openrouter (OpenRouter's OpenAI-compatible gateway), or ollama (a local Ollama server). Each provider's SDK is an optional extra — pip install 'dashdown-md[mistral|anthropic|openai|openrouter|ollama]'.

# dashdown.yaml
llm:
  provider: anthropic
  api_key: ${ANTHROPIC_API_KEY}    # ${VAR} reads from the environment
  model: claude-haiku-4-5          # optional (this is the anthropic default)

model is optional for every provider except openrouter and ollama, which route to many upstream / locally-pulled models — name one explicitly (e.g. model: anthropic/claude-3.5-sonnet for openrouter, model: llama3.1 for ollama). The defaults are fast/cheap models; since each uncached request is billed, pin a more capable one (e.g. claude-opus-4-8) via model when quality matters more.

Local models with Ollama. ollama runs models on your own machine, so it needs no api_key and is billed only in local compute — a good fit for private data. Point base_url at a non-default host (it defaults to http://localhost:11434/v1); base_url also lets openai / openrouter target any other OpenAI-compatible gateway.

# dashdown.yaml
llm:
  provider: ollama
  model: llama3.1                  # a model you've pulled (`ollama pull llama3.1`)
  # base_url: http://localhost:11434/v1   # optional — override for a remote host

The block is provider-only (provider / api_key / base_url / model) — per-answer knobs like max_rows and cache_ttl are <Ask> attributes, not config. See Configuration → llm.

Graceful degradation #

A missing or broken llm: block never stops dashdown serve or dashdown build. If the block is absent, the env var behind api_key is unset, or the config is otherwise invalid, the server logs a warning, disables AI commentary, and every <Ask> card renders a muted note explaining why — the rest of the page (charts, tables, filters) is unaffected. A static build bakes the same note into the export without counting it as a build failure. So a teammate without the API key can still work on the dashboard, and CI can build it keyless.

What the model sees #

Beyond your prompt and the (capped) query result, the model gets grounding context: the page's frontmatter title/description, the active filters the query actually substitutes — so after a viewer picks region = East the commentary can say "in the East region…" — and today's date (so "recent" means recent). Filters are sent as plain data in the prompt; they never widen what the endpoint accepts.

Rich answers #

Answers are Markdown, and the card styles the full surface: alongside paragraphs and bullets the model may reply with a small comparison table (it's steered to use one only when comparing a handful of values), inline code, headings, or a blockquote — all rendered compactly inside the card. Everything scales together: font_size resizes the whole answer, tables and code included.

Streaming & replay #

A cache miss is answered over Server-Sent Events: the raw text streams into the card as escaped plain text while the model writes, then the finished answer — rendered server-side with raw HTML disabled, exactly like the non-streaming payload — swaps in and is cached. Cache hits skip streaming and arrive instantly as a single payload.

A cached (or static-baked) answer still gets the streaming feel: the payload carries the raw answer text, and the card replays it as a typewriter before swapping in the rendered HTML — so a cache hit looks just like the live generation did, including in dashdown build exports. Pacing is synthetic (a smooth cadence, capped at a couple of seconds) rather than the model's real timing, which stalls. The replay attribute controls it: "once" (default) replays each answer once per browser session, "always" replays on every load, "off" renders instantly. Viewers with prefers-reduced-motion always skip straight to the finished answer.

Caching & cost #

Each answer is cached by a deterministic id — a hash of ((connector, query) pairs, prompt, max_rows, page title/description) plus the filter params the SQL actually substitutes (unioned across queries for a multi-query ask) — so repeat page loads and shared filter states reuse one answer instead of billing each view. cache_ttl controls expiry; it isn't part of the id (so changing it doesn't bust the cache). A reader's ↻ refresh affordance forces a fresh answer past the cache — each click is a billable LLM call, so on a public dashboard consider refresh=false, which removes the button and makes the endpoint ignore _refresh for that ask (the opt-out holds even against hand-crafted requests).

Safety #

The prompt is registered server-side and addressed by that opaque id, so the GET /_dashdown/api/ask/{id} endpoint can never be fed an arbitrary prompt. The data payload is capped to max_rows rows plus column types, and the model's answer is rendered as markdown with raw HTML disabled.

Data leaves your server

<Ask> sends the (capped) query result to your chosen LLM provider. Treat a hosted provider like any third-party data processor — don't point it at columns you can't share. To keep data on-premises, use the ollama provider (models run locally, nothing leaves the machine) or run any OpenAI-compatible endpoint yourself and target it with the openai / openrouter provider + base_url.

Static builds #

dashdown build bakes one answer JSON per <Ask> def into the export, so the commentary ships in a static site with no server or API key at view time — the answer is computed once at build. Chart explain bakes the same way: the button and footer ship in the export, and the first click retrieves the baked snapshot (commentary + annotations) instead of calling a server. The model attribution is baked in too, but the ↻ refresh affordance is omitted — a baked answer is fixed, with no live endpoint to regenerate against.

Baked answers describe unfiltered data

Filter controls are stripped from a static export, and each answer is baked once with the default (empty) filter state — so baked commentary and annotations describe the unfiltered data, never a filtered view.

Baked answers are world-readable files

A baked answer is an ordinary static JSON file (_dashdown/data/_ask/…), the same exposure class as the export's baked query snapshots. If your live dashboard sits behind the built-in auth:, remember that an export of it does not — don't publish an export whose commentary summarizes data you wouldn't publish directly.

Try it #

Add an llm: block to your dashdown.yaml, drop an <Ask data={your_query} ask="…" /> tag onto a page, and dashdown serve it — the commentary renders inline beneath the data.

Generated · built in 2m 21s