diff --git a/.github/time/labs/aicharts/aicharts.js b/.github/time/labs/aicharts/aicharts.js new file mode 100644 index 0000000..10f7873 --- /dev/null +++ b/.github/time/labs/aicharts/aicharts.js @@ -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 +// 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 {}; + +// +// 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 = `
+
`; + 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); diff --git a/.github/time/labs/aicharts/deploy.py b/.github/time/labs/aicharts/deploy.py new file mode 100644 index 0000000..93d6c4c --- /dev/null +++ b/.github/time/labs/aicharts/deploy.py @@ -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)) diff --git a/.github/time/labs/aicharts/test_aicharts.mjs b/.github/time/labs/aicharts/test_aicharts.mjs new file mode 100644 index 0000000..b3d85d3 --- /dev/null +++ b/.github/time/labs/aicharts/test_aicharts.mjs @@ -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(" 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"); diff --git a/.github/time/labs/aicharts/worker.js b/.github/time/labs/aicharts/worker.js new file mode 100644 index 0000000..cc7eee1 --- /dev/null +++ b/.github/time/labs/aicharts/worker.js @@ -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 + ) +// 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 = ` + +AICharts + +
+

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

  • None yet.
+

Use it on your page

+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
+<script type="module" src="__ORIGIN__/aicharts.js"></script>
+<ai-chart endpoint="__ORIGIN__" dataset="orders"></ai-chart>
+
+ +`; + +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 }); + }, + }; +} diff --git a/.github/time/test_time.py b/.github/time/test_time.py index b0b1cf2..e1f8b05 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -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") diff --git a/.github/workflows/labs-aicharts.yml b/.github/workflows/labs-aicharts.yml new file mode 100644 index 0000000..5c95229 --- /dev/null +++ b/.github/workflows/labs-aicharts.yml @@ -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"