labs: AICharts demo (NL → validated chart spec) #35
No files matched your search
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -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><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></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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
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:]
|
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")
|
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")
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user