labs: AICharts demo (NL → validated chart spec) #35

Merged
teahubxyz merged 1 commits from labs/aicharts into main 2026-10-13 13:35:18 +00:00
6 changed files with 790 additions and 0 deletions

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
// AICharts: natural-language charts for the Chart.js you already have, without sending your rows to a model.
//
// The model sees a dataset's schema, a few sample rows and aggregates, never the data itself. It answers with a small
// JSON chart spec. The spec is validated, then a fixed set of operations (filter, group, sort, bin, limit) computes the
// chart's data in the browser. No model-written code runs anywhere.
//
// attach(chart, { data, schema, endpoint }) -> { ask(prompt) } drive an existing Chart.js chart
// <ai-chart endpoint="..." dataset="..."> a framework-free element: prompt box, chart, save
// validateSpec(spec, schema) -> { ok, errors, fields }
// applySpec(rows, spec) -> rows the fixed operations
// chartConfig(rows, spec) -> a Chart.js config
//
// A spec:
// { "title": "...", "mark": "bar" | "line" | "pie" | "doughnut" | "scatter",
// "transform": [ { "op": "filter", "field": "region", "in": ["West", "East"] },
// { "op": "group", "by": ["month"], "agg": "sum", "field": "amount", "as": "value" },
// { "op": "sort", "field": "month", "order": "asc" }, { "op": "limit", "n": 12 } ],
// "x": "month", "y": "value", "series": "region" (optional) }
export const MARKS = ["bar", "line", "pie", "doughnut", "scatter"];
export const AGGS = ["sum", "avg", "count", "min", "max"];
export const OPS = ["filter", "group", "sort", "bin", "limit"];
const MAX_STEPS = 8;
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,40}$/;
// schema: { columns: [{ name, type: "string" | "number" | "month" | "date" }] }
export function validateSpec(spec, schema) {
const errors = [];
const err = (m) => { errors.push(m); return { ok: false, errors }; };
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return err("the spec must be a JSON object");
if (!MARKS.includes(spec.mark)) err(`mark must be one of ${MARKS.join(", ")}`);
const types = new Map((schema?.columns || []).map((c) => [c.name, c.type]));
const steps = spec.transform ?? [];
if (!Array.isArray(steps)) return err("transform must be a list of steps");
if (steps.length > MAX_STEPS) err(`at most ${MAX_STEPS} transform steps`);
const has = (f) => types.has(f);
steps.forEach((s, i) => {
const at = `step ${i + 1}`;
if (!s || typeof s !== "object" || !OPS.includes(s.op)) { err(`${at}: op must be one of ${OPS.join(", ")}`); return; }
if (s.op === "filter") {
if (!has(s.field)) { err(`${at}: unknown field ${JSON.stringify(s.field)}`); return; }
const keys = ["eq", "in", "gte", "lte"].filter((k) => k in s);
if (!keys.length) err(`${at}: a filter needs eq, in, gte or lte`);
if ("in" in s && (!Array.isArray(s.in) || s.in.length > 50)) err(`${at}: in must be a list of at most 50 values`);
} else if (s.op === "group") {
const by = s.by ?? [];
if (!Array.isArray(by) || by.length > 2 || !by.every(has)) { err(`${at}: by must list up to 2 known fields`); return; }
if (!AGGS.includes(s.agg)) { err(`${at}: agg must be one of ${AGGS.join(", ")}`); return; }
if (s.agg !== "count" && types.get(s.field) !== "number") { err(`${at}: ${s.agg} needs a number field`); return; }
const as = s.as ?? "value";
if (!NAME.test(as)) { err(`${at}: bad output name ${JSON.stringify(as)}`); return; }
const next = new Map(by.map((f) => [f, types.get(f)]));
next.set(as, "number");
types.clear();
next.forEach((t, f) => types.set(f, t));
} else if (s.op === "sort") {
if (!has(s.field)) err(`${at}: unknown field ${JSON.stringify(s.field)}`);
if (s.order && !["asc", "desc"].includes(s.order)) err(`${at}: order must be asc or desc`);
} else if (s.op === "bin") {
if (types.get(s.field) !== "number") { err(`${at}: bin needs a number field`); return; }
if (!(Number(s.step) > 0)) err(`${at}: step must be a positive number`);
const as = s.as ?? `${s.field}_bin`;
if (!NAME.test(as)) { err(`${at}: bad output name`); return; }
types.set(as, "number");
} else if (s.op === "limit") {
if (!Number.isInteger(s.n) || s.n < 1 || s.n > 500) err(`${at}: n must be a whole number from 1 to 500`);
}
});
for (const k of ["x", "y"]) if (!has(spec[k])) err(`${k} must be a field the transform leaves (got ${JSON.stringify(spec[k])})`);
if (has(spec.y) && types.get(spec.y) !== "number") err("y must be a number field");
if (spec.series != null && !has(spec.series)) err(`series must be a field the transform leaves`);
if (spec.mark === "scatter" && types.get(spec.x) !== "number") err("a scatter needs a number on x");
if (spec.title != null && typeof spec.title !== "string") err("title must be text");
return { ok: errors.length === 0, errors, fields: Object.fromEntries(types) };
}
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
export function applySpec(rows, spec) {
let out = rows.map((r) => ({ ...r }));
for (const s of spec.transform ?? []) {
if (s.op === "filter") {
out = out.filter((r) => {
const v = r[s.field];
if ("eq" in s && v != s.eq) return false; // loose on purpose: "2026" matches 2026
if ("in" in s && !s.in.some((x) => x == v)) return false;
if ("gte" in s && !(v >= s.gte)) return false;
if ("lte" in s && !(v <= s.lte)) return false;
return true;
});
} else if (s.op === "group") {
const by = s.by ?? [], as = s.as ?? "value", groups = new Map();
for (const r of out) {
const key = JSON.stringify(by.map((f) => r[f]));
if (!groups.has(key)) groups.set(key, { row: Object.fromEntries(by.map((f) => [f, r[f]])), vals: [] });
groups.get(key).vals.push(s.agg === "count" ? 1 : Number(r[s.field]));
}
out = [...groups.values()].map(({ row, vals }) => {
const sum = vals.reduce((a, b) => a + b, 0);
const v = { sum, count: vals.length, avg: sum / (vals.length || 1), min: Math.min(...vals), max: Math.max(...vals) }[s.agg];
return { ...row, [as]: Math.round(v * 100) / 100 };
});
} else if (s.op === "sort") {
const dir = s.order === "desc" ? -1 : 1;
out = [...out].sort((a, b) => dir * cmp(a[s.field], b[s.field]));
} else if (s.op === "bin") {
const as = s.as ?? `${s.field}_bin`, step = Number(s.step);
out = out.map((r) => ({ ...r, [as]: Math.floor(Number(r[s.field]) / step) * step }));
} else if (s.op === "limit") {
out = out.slice(0, s.n);
}
}
return out;
}
const PALETTE = ["#2f6fdf", "#e0752d", "#2f9e6e", "#c2417a", "#7a5bd6", "#9a8a2a", "#3aa0b8", "#b8573a"];
export function chartConfig(rows, spec) {
const title = spec.title ? { display: true, text: spec.title } : { display: false };
if (spec.mark === "scatter") {
return { type: "scatter", data: { datasets: [{ label: spec.y, data: rows.map((r) => ({ x: Number(r[spec.x]), y: Number(r[spec.y]) })),
backgroundColor: PALETTE[0] }] }, options: { plugins: { title }, animation: false } };
}
const labels = [...new Set(rows.map((r) => r[spec.x]))];
let datasets;
if (spec.series && !["pie", "doughnut"].includes(spec.mark)) {
const names = [...new Set(rows.map((r) => r[spec.series]))];
datasets = names.map((name, i) => ({
label: String(name),
data: labels.map((l) => rows.find((r) => r[spec.x] === l && r[spec.series] === name)?.[spec.y] ?? null),
backgroundColor: PALETTE[i % PALETTE.length], borderColor: PALETTE[i % PALETTE.length],
}));
} else {
const round = ["pie", "doughnut"].includes(spec.mark);
datasets = [{ label: spec.y, data: labels.map((l) => rows.find((r) => r[spec.x] === l)?.[spec.y] ?? null),
backgroundColor: round ? labels.map((_, i) => PALETTE[i % PALETTE.length]) : PALETTE[0], borderColor: PALETTE[0] }];
}
return { type: spec.mark, data: { labels: labels.map(String), datasets },
options: { plugins: { title, legend: { display: datasets.length > 1 || ["pie", "doughnut"].includes(spec.mark) } }, animation: false } };
}
async function fetchSpec(endpoint, body) {
const r = await fetch(endpoint.replace(/\/$/, "") + "/api/spec", { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(body) });
const out = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(out.error || `the chart service answered ${r.status}`);
return out.spec;
}
// Drive an existing Chart.js chart. `data` stays in the browser; only `schema` (plus a sample and aggregates the server
// computes for its own datasets) shapes the prompt.
export function attach(chart, { data, schema, endpoint, dataset }) {
let last;
return {
async ask(prompt, prior = last) {
const spec = await fetchSpec(endpoint, { dataset, prompt, prior });
const v = validateSpec(spec, schema);
if (!v.ok) throw new Error("the spec failed validation: " + v.errors.join("; "));
const cfg = chartConfig(applySpec(data, spec), spec);
chart.config.type = cfg.type;
chart.config.data = cfg.data;
chart.config.options = { ...chart.config.options, ...cfg.options };
chart.update();
last = spec;
return spec;
},
};
}
const Base = globalThis.HTMLElement ?? class {};
// <ai-chart endpoint="https://..." dataset="orders"></ai-chart>
// 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 {
connectedCallback() {
if (this._ready) return;
this._ready = true;
this.innerHTML = `<form class="aic-ask"><input name="q" placeholder="Ask for a chart, e.g. sales by region" aria-label="Chart question"
autocomplete="off"><button>Chart it</button></form><div class="aic-msg" role="status"></div>
<div class="aic-canvas"><canvas></canvas></div><button class="aic-save" hidden>Save chart</button>`;
this._msg = this.querySelector(".aic-msg");
this.querySelector("form").addEventListener("submit", (e) => { e.preventDefault(); this.ask(e.target.q.value); });
this.querySelector(".aic-save").addEventListener("click", () => this.save());
}
get endpoint() { return this.getAttribute("endpoint") || location.origin; }
get dataset() { return this.getAttribute("dataset") || "orders"; }
async load() {
if (this._data) return;
const base = this.endpoint.replace(/\/$/, "");
const [schema, data] = await Promise.all([fetch(`${base}/api/schema?dataset=${this.dataset}`).then((r) => r.json()),
fetch(`${base}/api/data?dataset=${this.dataset}`).then((r) => r.json())]);
this._schema = schema.schema;
this._data = data.rows;
}
async ask(prompt) {
if (!prompt?.trim()) return;
this._msg.textContent = "Thinking…";
try {
await this.load();
const spec = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec });
this.render(spec, prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt } }));
} catch (e) {
this._msg.textContent = String(e.message || e);
}
}
async render(spec, prompt) {
await this.load();
const v = validateSpec(spec, this._schema);
if (!v.ok) throw new Error("That chart didn't pass the checks: " + v.errors.join("; "));
const cfg = chartConfig(applySpec(this._data, spec), spec);
this._chart?.destroy();
this._chart = new globalThis.Chart(this.querySelector("canvas"), cfg);
this._spec = spec;
this._prompt = prompt;
this._msg.textContent = spec.title || "";
this.querySelector(".aic-save").hidden = false;
}
async save() {
const detail = { spec: this._spec, prompt: this._prompt, dataset: this.dataset };
this.dispatchEvent(new CustomEvent("save", { detail }));
if (this.onSave) {
try { await this.onSave(detail); this._msg.textContent = "Saved."; } catch (e) { this._msg.textContent = String(e.message || e); }
}
}
}
if (globalThis.customElements && !customElements.get("ai-chart")) customElements.define("ai-chart", AiChart);
+48
View File
@@ -0,0 +1,48 @@
"""Deploy the AICharts demo: one Worker (`time-aicharts`), one D1 database, Workers AI, on workers.dev only.
python3 .github/time/labs/aicharts/deploy.py # prints {url, worker, db, rev}
Reuses cloudflare.py (token, account, database, workers.dev). No DNS and no custom domain. Idempotent: the same
database is reused and the script is replaced in place.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[1]))
import cloudflare as cf # noqa: E402
NAME = "time-aicharts"
MAIN = b'import src from "./aicharts.txt";\nimport { make } from "./worker.js";\nexport default make(src);\n'
def parts() -> list[tuple[str, str, bytes]]:
lib = (HERE / "aicharts.js").read_bytes()
return [("main.js", "application/javascript+module", MAIN),
("worker.js", "application/javascript+module", (HERE / "worker.js").read_bytes()),
("aicharts.js", "application/javascript+module", lib),
("aicharts.txt", "text/plain", lib)]
def deploy() -> dict:
if not cf.enabled():
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
code = parts()
rev = hashlib.sha256(b"".join(d for _, _, d in code)).hexdigest()[:12]
vars_ = {"REV": rev, "AI_MODEL": cf.env("AICHARTS_AI_MODEL", cf.AI_MODEL), "AI_DAILY": cf.env("AICHARTS_AI_DAILY", "300"),
"SAVE_DAILY": cf.env("AICHARTS_SAVE_DAILY", "1000")}
db = cf.database("aicharts") # named time-aicharts, like the script
meta = {"main_module": "main.js", "compatibility_date": cf.COMPAT,
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()), *code])
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
return {"url": cf.workers_dev(NAME), "worker": NAME, "db": db, "rev": rev}
if __name__ == "__main__":
print(json.dumps(deploy(), indent=1))
@@ -0,0 +1,167 @@
// Offline test for the AICharts library and demo Worker: a real SQLite behind a D1-shaped shim, and a fake Workers AI.
// Run: node .github/time/labs/aicharts/test_aicharts.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const lib = await import(join(here, "aicharts.js"));
const W = await import(join(here, "worker.js"));
const src = readFileSync(join(here, "aicharts.js"), "utf8");
// ---------- library ----------
const schema = { columns: [{ name: "month", type: "month" }, { name: "region", type: "string" }, { name: "amount", type: "number" }] };
const rows = [
{ month: "2026-01", region: "West", amount: 10 }, { month: "2026-01", region: "East", amount: 5 },
{ month: "2026-02", region: "West", amount: 7 }, { month: "2026-02", region: "East", amount: 9 },
];
const bySum = { mark: "bar", x: "region", y: "value",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] };
assert.ok(lib.validateSpec(bySum, schema).ok);
assert.deepEqual(lib.applySpec(rows, bySum), [{ region: "West", value: 17 }, { region: "East", value: 14 }]);
const bad = lib.validateSpec({ mark: "radar", x: "month", y: "amount",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount" }, { op: "eval", code: "alert(1)" }] }, schema);
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes("mark")));
assert.ok(bad.errors.some((e) => e.includes("step 2")));
assert.ok(bad.errors.some((e) => e.startsWith("x must be a field the transform leaves")), "month is gone after the group");
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "region" }, schema).ok, false, "y must be a number");
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "amount", transform: Array(9).fill({ op: "limit", n: 1 }) }, schema).ok, false);
assert.equal(lib.validateSpec("[1]", schema).ok, false);
const lines = { mark: "line", x: "month", y: "value", series: "region",
transform: [{ op: "group", by: ["month", "region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "month" }] };
const cfg = lib.chartConfig(lib.applySpec(rows, lines), lines);
assert.deepEqual(cfg.data.labels, ["2026-01", "2026-02"]);
assert.deepEqual(cfg.data.datasets.map((d) => [d.label, d.data]), [["West", [10, 7]], ["East", [5, 9]]]);
const pie = lib.chartConfig(lib.applySpec(rows, { ...bySum, mark: "pie" }), { ...bySum, mark: "pie" });
assert.equal(pie.data.datasets[0].backgroundColor.length, 2);
const binned = lib.applySpec(rows, { transform: [{ op: "bin", field: "amount", step: 5 }, { op: "filter", field: "amount_bin", gte: 5, lte: 5 }] });
assert.deepEqual(binned.map((r) => r.amount), [5, 7, 9]);
// attach() drives an existing chart and keeps the rows in the browser
const sent = [];
globalThis.fetch = async (url, init) => { sent.push({ url, body: JSON.parse(init.body) }); return Response.json({ spec: bySum }); };
const chart = { config: { type: "line", data: {}, options: { responsive: true } }, updates: 0, update() { this.updates++; } };
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example/", dataset: "orders" }).ask("sales by region");
assert.equal(sent[0].url, "https://x.example/api/spec");
assert.deepEqual(Object.keys(sent[0].body).sort(), ["dataset", "prompt"], "no rows go to the server");
assert.equal(chart.config.type, "bar");
assert.equal(chart.config.options.responsive, true);
assert.equal(chart.updates, 1);
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" }).ask("now as a pie");
const a = lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" });
await a.ask("one"); await a.ask("two");
assert.deepEqual(sent.at(-1).body.prior, bySum, "follow-ups carry the last chart");
// ---------- worker ----------
assert.deepEqual(W.firstObject('Sure!\n```json\n{"a": {"b": "}"}}\n```'), { a: { b: "}" } });
assert.deepEqual(W.firstObject('Sure! {"mark": "bar", "x": "a"} trailing {'), { mark: "bar", x: "a" });
assert.equal(W.firstObject("no json"), null);
class D1 {
constructor() { this.db = new DatabaseSync(":memory:"); this.writes = []; }
prepare(sql) {
const db = this;
const make = (args) => ({
bind: (...a) => make(a),
async all() { db.log(sql); return { results: db.db.prepare(sql).all(...args) }; },
async first() { db.log(sql); return db.db.prepare(sql).get(...args) ?? null; },
async run() { db.log(sql); const s = db.db.prepare(sql); return /RETURNING|^\s*SELECT/i.test(sql) ? { results: s.all(...args) } : (s.run(...args), { results: [] }); },
});
return make([]);
}
log(sql) { if (/^\s*(INSERT|UPDATE|DELETE|CREATE)/i.test(sql)) this.writes.push(sql.split("(")[0].trim()); }
async batch(stmts) { return Promise.all(stmts.map((s) => s.run())); }
}
const answers = [];
const calls = [];
const env = { DB: new D1(), REV: "r1", AI_MODEL: "@cf/test/m", AI_DAILY: "5", SAVE_DAILY: "100",
AI: { async run(model, input) { calls.push(input); const a = answers.shift(); if (a instanceof Error) throw a; return a; } } };
const worker = W.make(src);
const call = (path, init = {}, ip = "1.1.1.1") =>
worker.fetch(new Request("https://aicharts.t.workers.dev" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
const post = (path, body, ip) => call(path, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }, ip);
let r = await call("/");
let html = await r.text();
assert.ok(html.includes("<ai-chart") && html.includes("https://aicharts.t.workers.dev/aicharts.js"));
r = await call("/aicharts.js");
assert.equal(await r.text(), src);
assert.equal(r.headers.get("access-control-allow-origin"), "*");
// the schema: what the model sees. Seeded once, deterministic.
r = await call("/api/schema?dataset=orders");
const sch = await r.json();
assert.equal(sch.sample.length, 5);
assert.deepEqual(sch.aggregates.region.values, ["East", "North", "South", "West"]);
assert.equal(sch.schema.columns.find((c) => c.name === "amount").type, "number");
const data = (await (await call("/api/data?dataset=orders")).json()).rows;
assert.equal(data.length, 24 * 4 * 5);
assert.deepEqual(data, W.seedOrders());
assert.equal((await call("/api/schema?dataset=users")).status, 404);
const seeded = env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length;
await call("/api/data");
assert.equal(env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length, seeded, "seeding happens once");
// a spec: structured output first; the system prompt carries schema + sample, never all rows
const good = { title: "Revenue by region", mark: "bar", x: "region", y: "value",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }] };
answers.push({ response: good });
r = await post("/api/spec", { dataset: "orders", prompt: "revenue by region" });
assert.equal(r.status, 200);
assert.deepEqual((await r.json()).spec, good);
assert.ok(calls[0].response_format, "asks for structured output");
const sys = calls[0].messages[0].content;
assert.ok(sys.includes("region (string)") && sys.includes('"North"'));
assert.ok(sys.length < 4000 && !sys.includes(`"id":${data.length}`), "the prompt has a sample, not the table");
// a model without response_format, prose around the JSON, a bad first answer, then the fix
answers.push(new Error("response_format unsupported"), { response: 'Here: {"mark": "bar", "x": "month", "y": "amount", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount"}]}' },
{ choices: [{ message: { content: JSON.stringify({ ...good, mark: "line", x: "region" }) } }] });
calls.length = 0;
r = await post("/api/spec", { dataset: "orders", prompt: "trend", prior: good });
assert.equal(r.status, 200);
assert.equal((await r.json()).spec.mark, "line");
assert.ok(calls[1].messages.some((m) => m.content.includes("Current chart spec")), "the prior chart is context");
assert.ok(calls.at(-1).messages.at(-1).content.includes("fails these checks"), "one repair round with the errors");
// two bad answers: a 422 with the reasons, nothing drawn
answers.push({ response: "{}" }, { response: '{"mark": "pie"}' });
r = await post("/api/spec", { dataset: "orders", prompt: "nonsense" });
assert.equal(r.status, 422);
assert.match((await r.json()).error, /valid chart/);
// limits: AI_DAILY=5 overall; we've used 3
answers.push({ response: good }, { response: good });
assert.equal((await post("/api/spec", { prompt: "a" }, "2.2.2.2")).status, 200);
assert.equal((await post("/api/spec", { prompt: "b" }, "2.2.2.2")).status, 200);
assert.equal((await post("/api/spec", { prompt: "c" }, "3.3.3.3")).status, 429);
assert.equal((await post("/api/spec", { prompt: "" })).status, 400);
// save, list, load; only valid specs are stored
r = await post("/api/charts", { dataset: "orders", prompt: "revenue by region", spec: good });
assert.equal(r.status, 201);
const { id, link } = await r.json();
assert.equal(link, `https://aicharts.t.workers.dev/?c=${id}`);
assert.equal((await post("/api/charts", { dataset: "orders", spec: { mark: "bar", x: "nope", y: "amount" } })).status, 400);
const list = (await (await call("/api/charts")).json()).charts;
assert.deepEqual(list.map((c) => [c.id, c.title]), [[id, "Revenue by region"]]);
const back = await (await call("/api/charts/" + id)).json();
assert.deepEqual(back.spec, good);
assert.equal((await call("/api/charts/zzzzzz")).status, 404);
// read-only: the only tables written after seeding are quota and saved_charts
const after = env.DB.writes.slice(env.DB.writes.lastIndexOf("INSERT OR IGNORE INTO orders") + 1);
assert.ok(after.every((w) => /^(CREATE TABLE IF NOT EXISTS|INSERT INTO quota|INSERT INTO saved_charts)/.test(w)), after.join(", "));
// the saved spec renders with the library against the real rows
const out = lib.applySpec(data, back.spec);
assert.deepEqual(out.map((x) => x.region).sort(), ["East", "North", "South", "West"]);
assert.equal(Math.round(out.reduce((a, x) => a + x.value, 0)), Math.round(data.reduce((a, x) => a + x.amount, 0)));
console.log("aicharts: all good");
+298
View File
@@ -0,0 +1,298 @@
// AICharts demo Worker: the page, the library, a sample dataset in D1, the spec endpoint (Workers AI) and saved charts.
//
// GET / the demo page (Chart.js + <ai-chart>)
// GET /aicharts.js the library, as served to any page that embeds it
// GET /api/schema?dataset=orders columns, a few sample rows and aggregates: all the model ever sees
// GET /api/data?dataset=orders the rows, for the browser to chart
// POST /api/spec { dataset, prompt, prior? } -> { spec } a validated chart spec, never code
// POST /api/charts { dataset, prompt, spec } -> { id, link } save a chart
// GET /api/charts, /api/charts/:id the saved charts
//
// Read-only except saved_charts and quota. Bindings: DB (D1), AI (Workers AI). Vars: AI_MODEL, AI_DAILY, SAVE_DAILY, REV.
// deploy.py uploads main.js, which hands this module the library source: export default make(src).
import { validateSpec, MARKS, AGGS, OPS } from "./aicharts.js";
const SCHEMA = [
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, date TEXT, month TEXT, region TEXT, product TEXT, channel TEXT, units INTEGER, amount REAL)",
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS saved_charts (id TEXT PRIMARY KEY, created TEXT NOT NULL, dataset TEXT NOT NULL, prompt TEXT, title TEXT, spec TEXT NOT NULL)",
];
const COLUMNS = [
{ name: "id", type: "number" }, { name: "date", type: "date" }, { name: "month", type: "month" },
{ name: "region", type: "string" }, { name: "product", type: "string" }, { name: "channel", type: "string" },
{ name: "units", type: "number" }, { name: "amount", type: "number" },
];
const DATASETS = { orders: { title: "Orders (sample shop, 24 months)", columns: COLUMNS } };
const MAX_PROMPT = 400;
// The sample shop: deterministic, so every deploy seeds the same rows and tests can count on them.
export function seedOrders() {
let s = 42;
const rnd = () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const regions = ["North", "South", "East", "West"], channels = ["web", "store"];
const products = [["Matcha", 24], ["Sencha", 14], ["Hojicha", 12], ["Teapot", 48], ["Whisk", 18]];
const rows = [];
for (let m = 0; m < 24; m++) {
const y = 2024 + Math.floor((m + 9) / 12), mo = ((m + 9) % 12) + 1, month = `${y}-${String(mo).padStart(2, "0")}`;
const season = 1 + 0.25 * Math.sin((mo / 12) * 2 * Math.PI) + m * 0.015;
for (const region of regions) for (const [product, price] of products) {
const units = Math.max(1, Math.round((8 + rnd() * 30) * season * (region === "West" ? 1.3 : 1)));
const channel = rnd() < (product === "Teapot" ? 0.4 : 0.65) ? "web" : "store";
const day = String(1 + Math.floor(rnd() * 28)).padStart(2, "0");
rows.push({ id: rows.length + 1, date: `${month}-${day}`, month, region, product, channel, units,
amount: Math.round(units * price * (0.9 + rnd() * 0.2) * 100) / 100 });
}
}
return rows;
}
let ready = null;
async function setup(env) {
ready ??= (async () => {
await env.DB.batch(SCHEMA.map((q) => env.DB.prepare(q)));
const n = await env.DB.prepare("SELECT COUNT(*) AS n FROM orders").first();
if (!n?.n) {
const ins = env.DB.prepare("INSERT OR IGNORE INTO orders (id, date, month, region, product, channel, units, amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
const rows = seedOrders();
for (let i = 0; i < rows.length; i += 100)
await env.DB.batch(rows.slice(i, i + 100).map((r) => ins.bind(r.id, r.date, r.month, r.region, r.product, r.channel, r.units, r.amount)));
}
})().catch((e) => { ready = null; throw e; });
return ready;
}
const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "content-type" };
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...CORS } });
async function who(req) {
const ip = req.headers.get("cf-connecting-ip") || "?";
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`aicharts:${ip}`));
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
async function spend(env, kind, person, perPerson, total) {
const day = new Date().toISOString().slice(0, 10);
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
return mine.results[0].n <= perPerson && all.results[0].n <= total;
}
async function rows(env) {
return (await env.DB.prepare("SELECT id, date, month, region, product, channel, units, amount FROM orders ORDER BY id").all()).results;
}
// What the model sees: columns, five rows, distinct values of text columns, ranges of number columns.
export function describe(data, columns) {
const aggregates = {};
for (const c of columns) {
const vals = data.map((r) => r[c.name]);
if (c.type === "number") aggregates[c.name] = { min: Math.min(...vals), max: Math.max(...vals) };
else {
const d = [...new Set(vals)].sort();
aggregates[c.name] = d.length <= 20 ? { values: d } : { first: d[0], last: d.at(-1), distinct: d.length };
}
}
return { columns, sample: data.slice(0, 5), aggregates, rows: data.length };
}
const SPEC_SCHEMA = {
type: "object", required: ["mark", "x", "y"],
properties: {
title: { type: "string" }, mark: { type: "string", enum: MARKS }, x: { type: "string" }, y: { type: "string" },
series: { type: "string" },
transform: { type: "array", maxItems: 8, items: { type: "object", required: ["op"], properties: {
op: { type: "string", enum: OPS }, field: { type: "string" }, by: { type: "array", items: { type: "string" } },
agg: { type: "string", enum: AGGS }, as: { type: "string" }, order: { type: "string", enum: ["asc", "desc"] },
eq: {}, in: { type: "array" }, gte: {}, lte: {}, step: { type: "number" }, n: { type: "integer" } } } },
},
};
export function systemPrompt(dataset, info) {
return `You turn a question about a table into a chart spec. Answer with ONE JSON object and nothing else.
Table "${dataset}" (${info.rows} rows). Columns: ${info.columns.map((c) => `${c.name} (${c.type})`).join(", ")}.
Values: ${JSON.stringify(info.aggregates)}
Sample rows: ${JSON.stringify(info.sample)}
Spec: {"title": text, "mark": ${MARKS.map((m) => `"${m}"`).join(" | ")}, "transform": [steps, applied in order, at most 8],
"x": field, "y": number field, "series": optional field (one line/bar colour per value)}
Steps:
{"op": "filter", "field": f, "eq": v} or "in": [v, ...] or "gte": v and/or "lte": v
{"op": "group", "by": [up to 2 fields], "agg": ${AGGS.map((a) => `"${a}"`).join(" | ")}, "field": number field (not for count), "as": "value"}
after a group, only the "by" fields and "as" remain
{"op": "sort", "field": f, "order": "asc" | "desc"}
{"op": "bin", "field": number field, "step": n, "as": name}
{"op": "limit", "n": 1..500}
Use "month" for time on x and sort it asc. Use "series" with group by [x, series] to compare. Pie only for shares of a whole.
Example: {"title": "Revenue by region", "mark": "bar", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount", "as": "value"}, {"op": "sort", "field": "value", "order": "desc"}], "x": "region", "y": "value"}`;
}
// The first balanced {...} in a model's answer (models wrap JSON in prose or fences when they feel like it).
export function firstObject(text) {
if (text && typeof text === "object") return text;
const s = String(text ?? "");
for (let i = s.indexOf("{"); i >= 0; i = s.indexOf("{", i + 1)) {
let depth = 0, str = false, esc = false;
for (let j = i; j < s.length; j++) {
const ch = s[j];
if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
if (ch === '"') str = true;
else if (ch === "{") depth++;
else if (ch === "}" && --depth === 0) {
try { return JSON.parse(s.slice(i, j + 1)); } catch { break; }
}
}
}
return null;
}
async function model(env, messages) {
const input = { messages, max_tokens: 700, temperature: 0.1 };
let out;
try {
out = await env.AI.run(env.AI_MODEL, { ...input, response_format: { type: "json_schema", json_schema: SPEC_SCHEMA } });
} catch {
out = await env.AI.run(env.AI_MODEL, input); // not every model takes response_format
}
return firstObject(out?.response ?? out?.choices?.[0]?.message?.content ?? "");
}
// Ask, check, and give the model one chance to fix what the validator found.
async function specFor(env, dataset, prompt, prior) {
const info = describe(await rows(env), DATASETS[dataset].columns);
const schema = { columns: info.columns };
const messages = [{ role: "system", content: systemPrompt(dataset, info) }];
if (prior && validateSpec(prior, schema).ok)
messages.push({ role: "user", content: "Current chart spec (change it if the question builds on it): " + JSON.stringify(prior) });
messages.push({ role: "user", content: prompt });
let spec = await model(env, messages), v = validateSpec(spec, schema);
if (!v.ok) {
messages.push({ role: "assistant", content: JSON.stringify(spec) },
{ role: "user", content: `That spec fails these checks: ${v.errors.join("; ")}. Answer with the corrected JSON object only.` });
spec = await model(env, messages);
v = validateSpec(spec, schema);
}
return v.ok ? { spec } : { error: "couldn't turn that into a valid chart: " + v.errors.slice(0, 3).join("; ") };
}
const newId = () => [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 10);
async function api(req, env, url) {
const path = url.pathname;
if (path === "/api/health") return json({ ok: true, rev: env.REV || "", ai: !!env.AI });
await setup(env);
const dataset = url.searchParams.get("dataset") || "orders";
if (req.method === "GET" && (path === "/api/schema" || path === "/api/data")) {
if (!DATASETS[dataset]) return json({ error: "no such dataset" }, 404);
const data = await rows(env);
if (path === "/api/data") return json({ dataset, rows: data });
const { columns, sample, aggregates } = describe(data, DATASETS[dataset].columns);
return json({ dataset, title: DATASETS[dataset].title, schema: { columns }, sample, aggregates });
}
if (req.method === "POST" && (path === "/api/spec" || path === "/api/charts")) {
let body;
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400); }
const ds = body?.dataset || "orders";
if (!DATASETS[ds]) return json({ error: "no such dataset" }, 404);
const prompt = String(body.prompt || "").trim().slice(0, MAX_PROMPT);
if (path === "/api/spec") {
if (!env.AI) return json({ error: "no AI here" }, 501);
if (!prompt) return json({ error: "ask a question" }, 400);
if (!(await spend(env, "ai", await who(req), 30, Number(env.AI_DAILY || 300)))) return json({ error: "over today's limit, try tomorrow" }, 429);
const out = await specFor(env, ds, prompt, body.prior);
return json(out, out.spec ? 200 : 422);
}
const v = validateSpec(body.spec, { columns: DATASETS[ds].columns });
if (!v.ok) return json({ error: "that spec doesn't validate: " + v.errors.join("; ") }, 400);
if (!(await spend(env, "save", await who(req), 50, Number(env.SAVE_DAILY || 1000)))) return json({ error: "over today's limit" }, 429);
const id = newId(), title = String(body.spec.title || prompt || "Chart").slice(0, 120);
await env.DB.prepare("INSERT INTO saved_charts (id, created, dataset, prompt, title, spec) VALUES (?, ?, ?, ?, ?, ?)")
.bind(id, new Date().toISOString(), ds, prompt, title, JSON.stringify(body.spec)).run();
return json({ id, link: `${url.origin}/?c=${id}` }, 201);
}
if (req.method === "GET" && path === "/api/charts") {
const list = (await env.DB.prepare("SELECT id, created, dataset, prompt, title FROM saved_charts ORDER BY created DESC LIMIT 20").all()).results;
return json({ charts: list });
}
const one = path.match(/^\/api\/charts\/([a-z0-9]{4,16})$/);
if (one && req.method === "GET") {
const r = await env.DB.prepare("SELECT id, created, dataset, prompt, title, spec FROM saved_charts WHERE id = ?").bind(one[1]).first();
return r ? json({ ...r, spec: JSON.parse(r.spec) }) : json({ error: "not found" }, 404);
}
return json({ error: "no such endpoint" }, 404);
}
export const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>AICharts</title>
<meta name="description" content="Ask for a chart in plain words. The model writes a small checked spec, never code, and never sees your rows.">
<style>
:root{--bg:#faf9f6;--fg:#1d1d1b;--mute:#6b6a65;--line:#e3e1da;--card:#fff;--accent:#2f6fdf}
@media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--mute:#9b9a94;--line:#2c2b28;--card:#1f1f1c;--accent:#7aa5ff}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
main{max-width:860px;margin:0 auto;padding:32px 16px 64px}h1{margin:0 0 4px;font-size:28px}p.lede{margin:0 0 20px;color:var(--mute)}
.aic-ask{display:flex;gap:8px}.aic-ask input{flex:1;min-width:0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
button{padding:10px 14px;border:0;border-radius:8px;background:var(--accent);color:#fff;font:inherit;cursor:pointer}
.aic-msg{min-height:24px;margin:10px 0;color:var(--mute)}.aic-canvas{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
.aic-save{margin-top:10px}.chips{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0}.chips button{background:var(--card);color:var(--fg);border:1px solid var(--line);padding:6px 10px;font-size:14px}
details{margin-top:16px}pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:13px}
h2{font-size:18px;margin:28px 0 8px}ul{padding-left:18px}a{color:var(--accent)}
</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="chips" id="chips"></div>
<ai-chart dataset="orders"></ai-chart>
<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>Use it on your page</h2>
<pre>&lt;script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"&gt;&lt;/script&gt;
&lt;script type="module" src="__ORIGIN__/aicharts.js"&gt;&lt;/script&gt;
&lt;ai-chart endpoint="__ORIGIN__" dataset="orders"&gt;&lt;/ai-chart&gt;</pre>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script type="module">
import "/aicharts.js";
const el = document.querySelector("ai-chart"), list = document.getElementById("saved");
for (const q of ["Revenue by region", "Monthly revenue, web vs store", "Share of units by product", "Top 3 products in the West by revenue"]) {
const b = document.createElement("button"); b.textContent = q; b.onclick = () => { el.querySelector("input").value = q; el.ask(q); };
document.getElementById("chips").append(b);
}
el.addEventListener("spec", (e) => { document.getElementById("spec").textContent = JSON.stringify(e.detail.spec, null, 2); });
el.onSave = async (d) => {
const r = await fetch("/api/charts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(d) });
const out = await r.json(); if (!r.ok) throw new Error(out.error); history.replaceState(null, "", "?c=" + out.id); await refresh();
};
async function show(id) {
const r = await fetch("/api/charts/" + id); if (!r.ok) return;
const c = await r.json(); await el.render(c.spec, c.prompt); document.getElementById("spec").textContent = JSON.stringify(c.spec, null, 2);
el.querySelector("input").value = c.prompt || "";
}
async function refresh() {
const { charts } = await (await fetch("/api/charts")).json();
list.replaceChildren(...(charts.length ? charts.map((c) => { const li = document.createElement("li"), a = document.createElement("a");
a.href = "?c=" + c.id; a.textContent = c.title; a.onclick = (e) => { e.preventDefault(); history.replaceState(null, "", "?c=" + c.id); show(c.id); };
li.append(a); return li; }) : [Object.assign(document.createElement("li"), { textContent: "None yet." })]));
}
const id = new URLSearchParams(location.search).get("c");
customElements.whenDefined("ai-chart").then(() => { refresh(); if (id) show(id); });
</script></body></html>`;
export function make(librarySource) {
return {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
try {
if (url.pathname.startsWith("/api/")) return await api(req, env, url);
} catch (e) {
return json({ error: "something broke: " + String(e.message || e).slice(0, 160) }, 500);
}
if (url.pathname === "/aicharts.js")
return new Response(librarySource, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=300", ...CORS } });
if (url.pathname === "/" || url.pathname === "/index.html")
return new Response(PAGE.replaceAll("__ORIGIN__", url.origin), { headers: { "content-type": "text/html; charset=utf-8" } });
return new Response("not found", { status: 404 });
},
};
}
+9
View File
@@ -1249,3 +1249,12 @@ if _sh.which("node"):
res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_auth.mjs")], capture_output=True, text=True, timeout=120)
assert res.returncode == 0 and ("AUTH OK" in res.stdout or "AUTH SKIPPED" in res.stdout), res.stdout[-2000:] + res.stderr[-2000:]
print("ALL OK 36" if "AUTH OK" in res.stdout else "skipped the passkey test: no node:sqlite")
# the AICharts lab: spec validator, fixed operations and the demo Worker against SQLite (needs node:sqlite)
if _sh.which("node"):
res = _sp.run(["node", str(Path(__file__).resolve().parent / "labs" / "aicharts" / "test_aicharts.mjs")], capture_output=True, text=True, timeout=60)
if "node:sqlite" in res.stderr and "ERR_UNKNOWN_BUILTIN_MODULE" in res.stderr:
print("skipped the aicharts test: no node:sqlite")
else:
assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:]
print("ALL OK 41")
+34
View File
@@ -0,0 +1,34 @@
name: labs-aicharts
# Deploy the AICharts demo Worker (workers.dev, one D1, Workers AI), then smoke-test it. Manual only.
on: workflow_dispatch
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node .github/time/labs/aicharts/test_aicharts.mjs
- id: deploy
run: |
python3 .github/time/labs/aicharts/deploy.py | tee out.json
echo "url=$(python3 -c 'import json; print(json.load(open("out.json"))["url"])')" >> "$GITHUB_OUTPUT"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: smoke
run: |
u="${{ steps.deploy.outputs.url }}"
for i in 1 2 3 4 5 6; do curl -fsS "${u}api/health" && break; sleep 10; done
curl -fsS "${u}api/schema?dataset=orders" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert len(d["sample"])==5, d; print("schema ok")'
curl -fsS -X POST "${u}api/spec" -H 'content-type: application/json' \
-d '{"dataset":"orders","prompt":"total revenue by region"}' | tee spec.json
echo
echo "### AICharts: ${u}" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"; cat spec.json >> "$GITHUB_STEP_SUMMARY"; echo '```' >> "$GITHUB_STEP_SUMMARY"