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.