labs/aicharts: fast mode (Clef draws first, LLM checks) as the demo default; clearer series/filter questions

Bench: Clef-flash 9/12 at ~0.5 s with uninformative confidence, Llama 12/12 at ~2.6 s.
brain=fast draws Clef's chart at once and redraws only if the LLM's differs.
Series and filter questions reworded around the three misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva
This commit is contained in:
Claude committed 2026-10-13 15:52:16 +00:00
1 parent e52e116815
commit 8d1f3baf36
3 files changed
+38 -9

No files matched your search

+28 -2
View File
@@ -169,7 +169,8 @@ export function attach(chart, { data, schema, endpoint, dataset }) {
const Base = globalThis.HTMLElement ?? class {}; const Base = globalThis.HTMLElement ?? class {};
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset) // <ai-chart endpoint="https://..." dataset="orders" brain="fast"></ai-chart>
// brain: fast (decision model first, LLM checks) | llm | jev | clef (server default if unset)
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set // Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1). // element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base { export class AiChart extends Base {
@@ -202,7 +203,9 @@ export class AiChart extends Base {
try { try {
await this.load(); await this.load();
const brain = this.getAttribute("brain"); const brain = this.getAttribute("brain");
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) }); const body = { dataset: this.dataset, prompt, prior: this._spec };
if (brain === "fast") return await this.askFast(body);
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, ...(brain ? { brain } : {}) });
await this.render(spec, prompt); await this.render(spec, prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } })); this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} catch (e) { } catch (e) {
@@ -210,6 +213,29 @@ export class AiChart extends Base {
} }
} }
// brain="fast": a decision model draws first (under a second), an LLM answers in the background, and the chart is
// redrawn only if the LLM's chart differs. Whichever comes back alone still draws.
async askFast(body) {
const ask = ++this._asks || (this._asks = 1);
const current = () => ask === this._asks;
const same = (a, b) => JSON.stringify({ ...a, title: 0 }) === JSON.stringify({ ...b, title: 0 });
const llm = fetchSpec(this.endpoint, { ...body, brain: "llm" }).catch((e) => ({ error: e }));
let drawn = null;
try {
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, brain: "clef", floor: 0 });
if (!current()) return;
await this.render(spec, body.prompt);
drawn = spec;
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: "fast" } } }));
} catch { /* the LLM will answer */ }
const out = await llm;
if (!current() || out.error) { if (!drawn && out.error) throw out.error; return; }
const { spec, ...info } = out;
const changed = !drawn || !same(drawn, spec);
if (changed) await this.render(spec, body.prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: drawn ? (changed ? "corrected" : "confirmed") : "llm" } } }));
}
async render(spec, prompt) { async render(spec, prompt) {
await this.load(); await this.load();
const v = validateSpec(spec, this._schema); const v = validateSpec(spec, this._schema);
+6 -4
View File
@@ -52,16 +52,18 @@ export function questions(info) {
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis), x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
measure: choice("Which quantity does the chart measure?", measure), measure: choice("Which quantity does the chart measure?", measure),
agg: choice("How should that quantity be combined within each group?", AGG_MENU), agg: choice("How should that quantity be combined within each group?", AGG_MENU),
series: choice("Should the chart split into one coloured series per value of another column? Pick none unless the request compares groups against each other (\"web vs store\", \"by region over time\").", series: choice("Does the request compare two or more values of a column side by side (\"web vs store\", \"split by channel\", \"each region over time\")? If so, pick that column; it becomes one coloured line or bar per value. Otherwise none.",
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }), { none: "No comparison asked for: a single series.",
...Object.fromEntries(text.map((c) => [c.name, `Compare ${c.name}s (${(info.aggregates[c.name]?.values || []).join(" vs ")}): one series per ${c.name}.`])) }),
order: choice("How should the groups be ordered?", ORDER_MENU), order: choice("How should the groups be ordered?", ORDER_MENU),
top: choice("Does the request limit how many groups to show?", TOP_MENU), top: choice("Does the request limit how many groups to show?", TOP_MENU),
}; };
for (const c of text) { for (const c of text) {
const vals = info.aggregates[c.name]?.values; const vals = info.aggregates[c.name]?.values;
if (vals?.length && vals.length <= 30) if (vals?.length && vals.length <= 30)
q[`only.${c.name}`] = choice(`Does the request restrict the data to one ${c.name}? Pick any unless it names one.`, q[`only.${c.name}`] = choice(`Does the request name exactly one ${c.name} to look at (for example "${vals[0]} sales" or "in the ${vals[0]}")? Pick that value; pick any if it names none, or names several to compare.`,
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) }); { any: `The request names no single ${c.name}.`,
...Object.fromEntries(vals.map((v) => [v, `The request mentions "${v}": keep only rows where ${c.name} is ${v}.`])) });
} }
return q; return q;
} }
+4 -3
View File
@@ -287,8 +287,8 @@ h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;ali
</style></head><body><main> </style></head><body><main>
<h1>AICharts</h1> <h1>AICharts</h1>
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p> <p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="">default</option><option value="jev">Jev</option><option value="clef">Clef</option><option value="llm">LLM</option></select></label></div> <div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="fast" selected>Fast (Clef, LLM checks)</option><option value="clef">Clef only</option><option value="jev">Jev</option><option value="llm">LLM only</option></select></label></div>
<ai-chart dataset="orders"></ai-chart> <ai-chart dataset="orders" brain="fast"></ai-chart>
<div class="meta" id="meta"></div> <div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details> <details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul> <h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
@@ -310,7 +310,8 @@ brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.val
el.addEventListener("spec", (e) => { el.addEventListener("spec", (e) => {
const { spec, info = {} } = e.detail; const { spec, info = {} } = e.detail;
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2); document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""), const stage = { fast: "first draw", confirmed: "LLM agrees", corrected: "corrected by the LLM" }[info.stage];
const bits = [stage, info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean); info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
meta.replaceChildren(document.createTextNode(bits.join(" · "))); meta.replaceChildren(document.createTextNode(bits.join(" · ")));
for (const alt of info.alternatives || []) { for (const alt of info.alternatives || []) {