diff --git a/.github/time/labs/aicharts/aicharts.js b/.github/time/labs/aicharts/aicharts.js index c1bc0cc..05973e3 100644 --- a/.github/time/labs/aicharts/aicharts.js +++ b/.github/time/labs/aicharts/aicharts.js @@ -169,7 +169,8 @@ export function attach(chart, { data, schema, endpoint, dataset }) { const Base = globalThis.HTMLElement ?? class {}; -// brain: llm | jev | clef (server default if unset) +// +// 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); diff --git a/.github/time/labs/aicharts/decide.js b/.github/time/labs/aicharts/decide.js index 86538cc..2b28e97 100644 --- a/.github/time/labs/aicharts/decide.js +++ b/.github/time/labs/aicharts/decide.js @@ -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; } diff --git a/.github/time/labs/aicharts/worker.js b/.github/time/labs/aicharts/worker.js index 158d0a4..9ab4cef 100644 --- a/.github/time/labs/aicharts/worker.js +++ b/.github/time/labs/aicharts/worker.js @@ -287,8 +287,8 @@ h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;ali

AICharts

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.

-
- +
+
Spec
Ask something first.

Saved charts

@@ -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 || []) {