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 {};
// <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
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base {
@@ -202,7 +203,9 @@ export class AiChart extends Base {
try {
await this.load();
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);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} 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) {
await this.load();
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),
measure: choice("Which quantity does the chart measure?", measure),
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\").",
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }),
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: "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),
top: choice("Does the request limit how many groups to show?", TOP_MENU),
};
for (const c of text) {
const vals = info.aggregates[c.name]?.values;
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.`,
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) });
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: `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;
}
+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>
<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>
<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>
<ai-chart dataset="orders"></ai-chart>
<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" brain="fast"></ai-chart>
<div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
<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) => {
const { spec, info = {} } = e.detail;
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);
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
for (const alt of info.alternatives || []) {