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:
3 files changed
+38
-9
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || []) {
|
||||
|
||||
Reference in new issue
Block a user