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.