labs/aicharts: Jev / Clef decision brains (choice answers -> spec), LLM as the door (#40)
decide.js asks ~10 choice questions in one call (mark, x, measure, agg, series, order, top, one filter per text column) and assembles the spec; the validator still checks it. Weakest needed answer < FLOOR, a failed check or a model error hands the question to the LLM path, and the response says why. Page: brain picker, latency/confidence line, 'or as <mark>' from the mark probabilities. bench.py scores brains on 12 fixed questions. Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #40.
This commit is contained in:
1 parent
3086cb52ff
commit
2d0f997030
6 files changed
+349
-15
No files matched your search
@@ -144,7 +144,7 @@ async function fetchSpec(endpoint, body) {
|
||||
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;
|
||||
return out; // { spec, brain?, model?, ms?, confidence?, alternatives?, handoff? }
|
||||
}
|
||||
|
||||
// Drive an existing Chart.js chart. `data` stays in the browser; only `schema` (plus a sample and aggregates the server
|
||||
@@ -152,8 +152,8 @@ async function fetchSpec(endpoint, body) {
|
||||
export function attach(chart, { data, schema, endpoint, dataset }) {
|
||||
let last;
|
||||
return {
|
||||
async ask(prompt, prior = last) {
|
||||
const spec = await fetchSpec(endpoint, { dataset, prompt, prior });
|
||||
async ask(prompt, prior = last, brain) {
|
||||
const { spec } = await fetchSpec(endpoint, { dataset, prompt, prior, ...(brain ? { brain } : {}) });
|
||||
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);
|
||||
@@ -169,7 +169,7 @@ export function attach(chart, { data, schema, endpoint, dataset }) {
|
||||
|
||||
const Base = globalThis.HTMLElement ?? class {};
|
||||
|
||||
// <ai-chart endpoint="https://..." dataset="orders"></ai-chart>
|
||||
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset)
|
||||
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
|
||||
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
|
||||
export class AiChart extends Base {
|
||||
@@ -201,9 +201,10 @@ export class AiChart extends Base {
|
||||
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 } }));
|
||||
const brain = this.getAttribute("brain");
|
||||
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) });
|
||||
await this.render(spec, prompt);
|
||||
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
|
||||
} catch (e) {
|
||||
this._msg.textContent = String(e.message || e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Compare AICharts brains on one fixed question set against the live demo.
|
||||
|
||||
python3 bench.py https://aicharts.yolopreneur.lol [brain ...] (default brains: llm jev clef)
|
||||
|
||||
Each question lists what a right chart must have; a spec scores 1 when it has all of it. Reports, per brain: right
|
||||
answers, median and p90 latency (as the browser sees it), and how often a decision brain handed off to the LLM.
|
||||
Uses the public API like a visitor, so it counts against the demo's daily AI limit (12 calls per brain).
|
||||
"""
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
Q = [
|
||||
("total revenue by region", {"x": "region", "agg": ("sum", "amount")}),
|
||||
("monthly revenue, web vs store", {"mark": {"line"}, "x": "month", "series": "channel", "agg": ("sum", "amount")}),
|
||||
("share of units by product", {"mark": {"pie", "doughnut"}, "x": "product", "agg": ("sum", "units")}),
|
||||
("top 3 products in the West by revenue", {"x": "product", "only": {"region": "West"}, "agg": ("sum", "amount"), "limit": 3}),
|
||||
("average order amount by channel", {"x": "channel", "agg": ("avg", "amount")}),
|
||||
("how did matcha units trend month by month?", {"mark": {"line"}, "x": "month", "only": {"product": "Matcha"}, "agg": ("sum", "units")}),
|
||||
("how many orders per region", {"x": "region", "agg": ("count", None)}),
|
||||
("biggest single order amount for each product", {"x": "product", "agg": ("max", "amount")}),
|
||||
("store revenue by region", {"x": "region", "only": {"channel": "store"}, "agg": ("sum", "amount")}),
|
||||
("teapot units by month in the North", {"x": "month", "only": {"product": "Teapot", "region": "North"}, "agg": ("sum", "units")}),
|
||||
("which region sells the most hojicha?", {"x": "region", "only": {"product": "Hojicha"}, "sorted_desc": True}),
|
||||
("revenue by product, split by channel", {"x": {"product", "channel"}, "series": {"product", "channel"}, "agg": ("sum", "amount")}),
|
||||
]
|
||||
|
||||
|
||||
def facts(spec):
|
||||
f = {"mark": spec.get("mark"), "x": spec.get("x"), "series": spec.get("series"), "only": {}, "limit": None, "sorted_desc": False}
|
||||
for s in spec.get("transform") or []:
|
||||
if s.get("op") == "filter":
|
||||
if "eq" in s:
|
||||
f["only"][s["field"]] = s["eq"]
|
||||
elif isinstance(s.get("in"), list) and len(s["in"]) == 1:
|
||||
f["only"][s["field"]] = s["in"][0]
|
||||
elif s.get("op") == "group":
|
||||
f["agg"] = (s.get("agg"), None if s.get("agg") == "count" else s.get("field"))
|
||||
elif s.get("op") == "limit":
|
||||
f["limit"] = s.get("n")
|
||||
elif s.get("op") == "sort" and s.get("order") == "desc" and s.get("field") not in ("month", "date", f["x"]):
|
||||
f["sorted_desc"] = True
|
||||
return f
|
||||
|
||||
|
||||
def misses(spec, want):
|
||||
f, out = facts(spec), []
|
||||
for k, v in want.items():
|
||||
got = f.get(k)
|
||||
if k == "only":
|
||||
if {a: str(b).lower() for a, b in got.items()} != {a: b.lower() for a, b in v.items()}:
|
||||
out.append(f"only {got} != {v}")
|
||||
elif isinstance(v, set):
|
||||
if got not in v:
|
||||
out.append(f"{k} {got} not in {sorted(v)}")
|
||||
elif k == "agg":
|
||||
if got is None or got[0] != v[0] or (v[1] and got[1] != v[1]):
|
||||
out.append(f"agg {got} != {v}")
|
||||
elif got != v:
|
||||
out.append(f"{k} {got} != {v}")
|
||||
if "x" in want and "series" in want and f["x"] == f["series"]:
|
||||
out.append("x == series")
|
||||
return out
|
||||
|
||||
|
||||
def ask(url, prompt, brain):
|
||||
body = json.dumps({"dataset": "orders", "prompt": prompt, "brain": brain}).encode()
|
||||
req = urllib.request.Request(url.rstrip("/") + "/api/spec", data=body, headers={"content-type": "application/json"})
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
out = json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
out = json.loads(e.read() or b"{}")
|
||||
return out, int((time.time() - t0) * 1000)
|
||||
|
||||
|
||||
def main():
|
||||
url, brains = sys.argv[1], sys.argv[2:] or ["llm", "jev", "clef"]
|
||||
report = {}
|
||||
for brain in brains:
|
||||
rows = []
|
||||
for prompt, want in Q:
|
||||
out, ms = ask(url, prompt, brain)
|
||||
m = misses(out["spec"], want) if out.get("spec") else [out.get("error", "no spec")]
|
||||
rows.append({"q": prompt, "ok": not m, "ms": ms, "via": out.get("brain"), "handoff": (out.get("handoff") or {}).get("reason"),
|
||||
"confidence": out.get("confidence"), "misses": m})
|
||||
print(f"[{brain}] {'ok ' if not m else 'MISS'} {ms:>6} ms via {out.get('brain')}: {prompt}" + (f" -- {'; '.join(m)}" if m else "")
|
||||
+ (f" (handoff: {rows[-1]['handoff']})" if rows[-1]["handoff"] else ""), flush=True)
|
||||
lat = sorted(r["ms"] for r in rows)
|
||||
report[brain] = {"right": sum(r["ok"] for r in rows), "of": len(rows), "median_ms": int(statistics.median(lat)),
|
||||
"p90_ms": lat[int(0.9 * (len(lat) - 1))], "handoffs": sum(1 for r in rows if r["handoff"]), "rows": rows}
|
||||
print(json.dumps({b: {k: v for k, v in r.items() if k != "rows"} for b, r in report.items()}, indent=1))
|
||||
json.dump(report, open("bench.json", "w"), indent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
// A decision-model brain for AICharts: Jev (TypeSafe) or Clef (Cloudflare) answers a handful of choice questions in
|
||||
// one pass, and this module assembles the chart spec from the answers. The model never writes JSON, so a spec can't
|
||||
// be malformed; the validator still checks it, and weak confidence hands the question to the LLM path.
|
||||
//
|
||||
// questions(info) -> System One questions for one dataset (info = describe(rows, columns))
|
||||
// assemble(answers, info) -> { spec, confidence, weakest, alternatives }
|
||||
// decide(env, model, info, prompt, prior) -> the above plus { model, ms } (throws if the model call fails)
|
||||
|
||||
const MARK_MENU = {
|
||||
bar: "Comparing amounts across categories or a handful of periods.",
|
||||
line: "A value over time (months, dates) or another ordered sequence; the trend matters.",
|
||||
pie: "Shares of one whole across a few categories (6 or fewer).",
|
||||
doughnut: "Shares of one whole, shown as a ring; same use as a pie.",
|
||||
scatter: "Two numeric measures plotted against each other across many points.",
|
||||
};
|
||||
const AGG_MENU = {
|
||||
sum: "The total (revenue, units sold): add the values up.",
|
||||
avg: "The typical or average value per order.",
|
||||
count: "How many orders or rows, regardless of their values.",
|
||||
min: "The smallest value.",
|
||||
max: "The largest value.",
|
||||
};
|
||||
const ORDER_MENU = {
|
||||
natural: "No particular order asked for (time stays in time order).",
|
||||
value_desc: "Biggest first: top, best, most, highest, ranking.",
|
||||
value_asc: "Smallest first: bottom, worst, least, lowest.",
|
||||
};
|
||||
const TOP_MENU = { all: "Show every group.", 3: "Only the top or bottom 3.", 5: "Only the top or bottom 5.", 10: "Only the top or bottom 10." };
|
||||
|
||||
function shuffled(menu) {
|
||||
const e = Object.entries(menu);
|
||||
for (let i = e.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [e[i], e[j]] = [e[j], e[i]]; }
|
||||
return Object.fromEntries(e);
|
||||
}
|
||||
const choice = (instructions, menu) => ({ type: "choice", instructions, criteria: shuffled(menu) });
|
||||
|
||||
const describeCol = (c, agg) => {
|
||||
const a = agg[c.name] || {};
|
||||
if (a.values) return `${c.type} column; values: ${a.values.join(", ")}`;
|
||||
if ("min" in a) return `${c.type} column from ${a.min} to ${a.max}`;
|
||||
return `${c.type} column`;
|
||||
};
|
||||
|
||||
export function questions(info) {
|
||||
const cols = info.columns.filter((c) => c.name !== "id");
|
||||
const text = cols.filter((c) => c.type === "string");
|
||||
const nums = cols.filter((c) => c.type === "number");
|
||||
const axis = Object.fromEntries(cols.map((c) => [c.name, describeCol(c, info.aggregates)]));
|
||||
const measure = { ...Object.fromEntries(nums.map((c) => [c.name, describeCol(c, info.aggregates)])), rows: "The number of orders (no column)." };
|
||||
const q = {
|
||||
mark: choice("Which chart type answers the request best?", MARK_MENU),
|
||||
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
|
||||
measure: choice("Which quantity does the chart measure?", measure),
|
||||
agg: choice("How should that quantity be combined within each group?", AGG_MENU),
|
||||
series: choice("Should the chart split into one coloured series per value of another column? Pick none unless the request compares groups against each other (\"web vs store\", \"by region over time\").",
|
||||
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }),
|
||||
order: choice("How should the groups be ordered?", ORDER_MENU),
|
||||
top: choice("Does the request limit how many groups to show?", TOP_MENU),
|
||||
};
|
||||
for (const c of text) {
|
||||
const vals = info.aggregates[c.name]?.values;
|
||||
if (vals?.length && vals.length <= 30)
|
||||
q[`only.${c.name}`] = choice(`Does the request restrict the data to one ${c.name}? Pick any unless it names one.`,
|
||||
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) });
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
export function assemble(answers, info) {
|
||||
const pick = (k) => answers[k]?.choice;
|
||||
const conf = Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Number(v?.confidence ?? 0)]));
|
||||
const types = Object.fromEntries(info.columns.map((c) => [c.name, c.type]));
|
||||
const mark = pick("mark"), x = pick("x"), measure = pick("measure");
|
||||
let agg = pick("agg");
|
||||
let series = pick("series");
|
||||
if (series === "none" || series === x || ["pie", "doughnut", "scatter"].includes(mark)) series = undefined;
|
||||
const gating = ["mark", "x", "measure", "order", "top"];
|
||||
if (measure === "rows") agg = "count"; else gating.push("agg");
|
||||
if (series) gating.push("series");
|
||||
|
||||
const transform = [];
|
||||
for (const k of Object.keys(answers).filter((k) => k.startsWith("only."))) {
|
||||
const v = pick(k);
|
||||
if (v && v !== "any") { transform.push({ op: "filter", field: k.slice(5), eq: v }); gating.push(k); }
|
||||
}
|
||||
let spec;
|
||||
if (mark === "scatter") {
|
||||
// a scatter plots rows, not groups: x and the measure are both raw number columns
|
||||
spec = { mark, x, y: measure === "rows" ? "amount" : measure, transform };
|
||||
} else {
|
||||
transform.push({ op: "group", by: series ? [x, series] : [x], agg, ...(agg === "count" ? {} : { field: measure }), as: "value" });
|
||||
const order = pick("order");
|
||||
if (order === "value_desc" || order === "value_asc") transform.push({ op: "sort", field: "value", order: order === "value_desc" ? "desc" : "asc" });
|
||||
else if (["month", "date", "number"].includes(types[x])) transform.push({ op: "sort", field: x, order: "asc" });
|
||||
const top = pick("top");
|
||||
if (top && top !== "all") transform.push({ op: "limit", n: Number(top) });
|
||||
spec = { mark, x, y: "value", ...(series ? { series } : {}), transform };
|
||||
}
|
||||
const what = measure === "rows" ? "Orders" : `${{ sum: "Total", avg: "Average", count: "Count of", min: "Lowest", max: "Highest" }[agg]} ${measure}`;
|
||||
const only = transform.filter((s) => s.op === "filter").map((s) => s.eq);
|
||||
spec.title = mark === "scatter" ? `${spec.y} vs ${x}` : `${what} by ${x}${series ? ` and ${series}` : ""}${only.length ? ` (${only.join(", ")})` : ""}`;
|
||||
|
||||
const weakest = Math.min(...gating.map((k) => conf[k] ?? 0));
|
||||
// other chart types the model rated close behind: cheap to offer, already the same data
|
||||
const probs = answers.mark?.probabilities || {};
|
||||
const alternatives = Object.entries(probs).filter(([m, p]) => m !== mark && p >= 0.15).sort((a, b) => b[1] - a[1]).slice(0, 2)
|
||||
.map(([m, p]) => ({ mark: m, p: Math.round(p * 100) / 100 }));
|
||||
return { spec, confidence: conf, weakest: Math.round(weakest * 100) / 100, alternatives };
|
||||
}
|
||||
|
||||
export async function decide(env, model, info, prompt, prior) {
|
||||
const state = { user_request: prompt, table: { columns: info.columns.map((c) => c.name), rows: info.rows },
|
||||
...(prior ? { current_chart: prior } : {}) };
|
||||
const input = { state, questions: questions(info) };
|
||||
if (model.startsWith("@cf/cloudflare/clef")) input.model = model.endsWith("flash") ? "clef-flash" : "clef";
|
||||
const t0 = Date.now();
|
||||
const res = await env.AI.run(model, input);
|
||||
if (!res?.answers) throw new Error("the decision model returned no answers");
|
||||
return { ...assemble(res.answers, info), model: res.model || model, ms: Date.now() - t0 };
|
||||
}
|
||||
@@ -26,6 +26,7 @@ def parts() -> list[tuple[str, str, bytes]]:
|
||||
return [("main.js", "application/javascript+module", MAIN),
|
||||
("worker.js", "application/javascript+module", (HERE / "worker.js").read_bytes()),
|
||||
("aicharts.js", "application/javascript+module", lib),
|
||||
("decide.js", "application/javascript+module", (HERE / "decide.js").read_bytes()),
|
||||
("aicharts.txt", "text/plain", lib)]
|
||||
|
||||
|
||||
@@ -35,7 +36,8 @@ def deploy() -> dict:
|
||||
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/meta/llama-3.3-70b-instruct-fp8-fast"), "AI_DAILY": cf.env("AICHARTS_AI_DAILY", "300"),
|
||||
"SAVE_DAILY": cf.env("AICHARTS_SAVE_DAILY", "1000")}
|
||||
"SAVE_DAILY": cf.env("AICHARTS_SAVE_DAILY", "1000"),
|
||||
"BRAIN": cf.env("AICHARTS_BRAIN", "llm"), "FLOOR": cf.env("AICHARTS_FLOOR", "0.5")}
|
||||
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"}]
|
||||
|
||||
@@ -85,7 +85,10 @@ class D1 {
|
||||
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; } } };
|
||||
AI: { async run(model, input) {
|
||||
if (input.questions) { decisions.push({ model, input }); const d = decided.shift(); if (d instanceof Error) throw d; return d; }
|
||||
calls.push(input); const a = answers.shift(); if (a instanceof Error) throw a; return a; } } };
|
||||
const decisions = [], decided = [];
|
||||
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);
|
||||
@@ -152,6 +155,71 @@ 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);
|
||||
|
||||
// ---------- decision brains (Jev / Clef): choice answers -> spec, LLM only as the door ----------
|
||||
const D = await import(join(here, "decide.js"));
|
||||
const ans = (choice, confidence = 0.9, probabilities) => ({ type: "choice", choice, confidence, probabilities: probabilities || { [choice]: confidence } });
|
||||
const westTop3 = { mark: ans("bar", 0.8, { bar: 0.8, pie: 0.17, line: 0.03 }), x: ans("product"), measure: ans("amount"), agg: ans("sum"), series: ans("none"),
|
||||
order: ans("value_desc"), top: ans("3"), "only.region": ans("West", 0.95), "only.product": ans("any"), "only.channel": ans("any") };
|
||||
const infoNow = W.describe(data, sch.schema.columns);
|
||||
const qs = D.questions(infoNow);
|
||||
assert.deepEqual(Object.keys(qs).sort(), ["agg", "mark", "measure", "only.channel", "only.product", "only.region", "order", "series", "top", "x"]);
|
||||
assert.ok(!("id" in qs.x.criteria), "id is never an axis");
|
||||
assert.ok("rows" in qs.measure.criteria && "amount" in qs.measure.criteria && !("region" in qs.measure.criteria));
|
||||
assert.deepEqual(Object.keys(qs["only.region"].criteria).sort(), ["East", "North", "South", "West", "any"]);
|
||||
const asm = D.assemble(westTop3, infoNow);
|
||||
assert.deepEqual(asm.spec.transform, [{ op: "filter", field: "region", eq: "West" },
|
||||
{ op: "group", by: ["product"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }, { op: "limit", n: 3 }]);
|
||||
assert.equal(asm.spec.title, "Total amount by product (West)");
|
||||
assert.ok(lib.validateSpec(asm.spec, sch.schema).ok);
|
||||
assert.equal(lib.applySpec(data, asm.spec).length, 3);
|
||||
assert.deepEqual(asm.alternatives, [{ mark: "pie", p: 0.17 }]);
|
||||
assert.equal(asm.weakest, 0.8);
|
||||
// time on x sorts by time; a series only when asked; count needs no field
|
||||
const monthly = D.assemble({ ...westTop3, mark: ans("line"), x: ans("month"), measure: ans("rows"), agg: ans("avg", 0.1), series: ans("channel"),
|
||||
order: ans("natural"), top: ans("all"), "only.region": ans("any") }, infoNow);
|
||||
assert.deepEqual(monthly.spec.transform, [{ op: "group", by: ["month", "channel"], agg: "count", as: "value" }, { op: "sort", field: "month", order: "asc" }]);
|
||||
assert.equal(monthly.spec.series, "channel");
|
||||
assert.equal(monthly.weakest, 0.9, "agg doesn't gate when counting rows");
|
||||
assert.ok(lib.validateSpec(monthly.spec, sch.schema).ok);
|
||||
assert.equal(D.assemble({ ...westTop3, mark: ans("pie"), series: ans("channel") }, infoNow).spec.series, undefined, "no series on a pie");
|
||||
|
||||
// through the worker: a confident Jev answer is the spec, no LLM call
|
||||
const resetQuota = () => env.DB.db.exec("DELETE FROM quota");
|
||||
resetQuota(); calls.length = 0;
|
||||
decided.push({ model: "jev-1", answers: westTop3, usage: {} });
|
||||
r = await post("/api/spec", { dataset: "orders", prompt: "top 3 products in the West by revenue", brain: "jev" });
|
||||
let body = await r.json();
|
||||
assert.equal(r.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.brain, "jev");
|
||||
assert.equal(decisions.at(-1).model, "typesafe/jev");
|
||||
assert.equal(decisions.at(-1).input.state.user_request, "top 3 products in the West by revenue");
|
||||
const toJev = JSON.stringify(decisions.at(-1).input), mid = data[200];
|
||||
assert.ok(!toJev.includes(mid.date) && !toJev.includes(String(mid.amount)) && !("sample" in decisions.at(-1).input.state),
|
||||
"rows never reach the decision model");
|
||||
assert.equal(calls.length, 0);
|
||||
assert.deepEqual(body.spec, asm.spec);
|
||||
// Clef names its variant in the input
|
||||
decided.push({ answers: westTop3 });
|
||||
await post("/api/spec", { dataset: "orders", prompt: "x", brain: "clef" });
|
||||
assert.equal(decisions.at(-1).model, "@cf/cloudflare/clef-flash");
|
||||
assert.equal(decisions.at(-1).input.model, "clef-flash");
|
||||
// unsure -> the door: the LLM answers, and the response says why
|
||||
decided.push({ answers: { ...westTop3, x: ans("product", 0.31) } });
|
||||
answers.push({ response: good });
|
||||
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
|
||||
assert.equal(body.brain, "llm");
|
||||
assert.match(body.handoff.reason, /low confidence \(0.31\)/);
|
||||
assert.deepEqual(body.spec, good);
|
||||
// decision model down -> the door too
|
||||
decided.push(new Error("no such model"));
|
||||
answers.push({ response: good });
|
||||
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
|
||||
assert.match(body.handoff.reason, /decision model error: no such model/);
|
||||
// the server default brain, and an unknown brain falls back to it
|
||||
env.BRAIN = "jev"; decided.push({ answers: westTop3 });
|
||||
assert.equal((await (await post("/api/spec", { prompt: "q", brain: "gpt" })).json()).brain, "jev");
|
||||
delete env.BRAIN; resetQuota();
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// 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 } from "./aicharts.js";
|
||||
import { decide } from "./decide.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)",
|
||||
@@ -172,9 +173,33 @@ async function model(env, messages) {
|
||||
}
|
||||
|
||||
// Ask, check, and give the model one chance to fix what the validator found.
|
||||
async function specFor(env, dataset, prompt, prior) {
|
||||
// brain: "llm" writes the spec; "jev" / "clef" answer choice questions and decide.js assembles it. A decision that
|
||||
// fails validation, or whose weakest needed answer is under FLOOR, goes to the LLM: the cage with a door.
|
||||
export const BRAINS = ["llm", "jev", "clef"];
|
||||
async function specVia(env, dataset, prompt, prior, brain) {
|
||||
const info = describe(await rows(env), DATASETS[dataset].columns);
|
||||
const schema = { columns: info.columns };
|
||||
const t0 = Date.now();
|
||||
let handoff;
|
||||
if (brain !== "llm") {
|
||||
const model = brain === "jev" ? env.JEV_MODEL || "typesafe/jev" : env.CLEF_MODEL || "@cf/cloudflare/clef-flash";
|
||||
try {
|
||||
const d = await decide(env, model, info, prompt, prior && validateSpec(prior, schema).ok ? prior : undefined);
|
||||
const v = validateSpec(d.spec, schema), floor = Number(env.FLOOR || 0.5);
|
||||
if (v.ok && d.weakest >= floor)
|
||||
return { spec: d.spec, brain, model: d.model, ms: d.ms, confidence: d.weakest, alternatives: d.alternatives };
|
||||
handoff = { reason: v.ok ? `low confidence (${d.weakest})` : "decision failed checks: " + v.errors[0], decided: d.spec, ms: d.ms };
|
||||
} catch (e) {
|
||||
handoff = { reason: "decision model error: " + String(e.message || e).slice(0, 160) };
|
||||
}
|
||||
}
|
||||
const out = await specFor(env, dataset, prompt, prior, info);
|
||||
return { ...out, brain: "llm", model: env.AI_MODEL, ms: Date.now() - t0, ...(handoff ? { handoff } : {}) };
|
||||
}
|
||||
|
||||
async function specFor(env, dataset, prompt, prior, known) {
|
||||
const info = known || 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) });
|
||||
@@ -213,8 +238,9 @@ async function api(req, env, url) {
|
||||
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);
|
||||
if (!(await spend(env, "ai", await who(req), 60, Number(env.AI_DAILY || 300)))) return json({ error: "over today's limit, try tomorrow" }, 429);
|
||||
const brain = BRAINS.includes(body.brain) ? body.brain : BRAINS.includes(env.BRAIN) ? env.BRAIN : "llm";
|
||||
const out = await specVia(env, ds, prompt, body.prior, brain);
|
||||
return json(out, out.spec ? 200 : 422);
|
||||
}
|
||||
const v = validateSpec(body.spec, { columns: DATASETS[ds].columns });
|
||||
@@ -251,12 +277,15 @@ button{padding:10px 14px;border:0;border-radius:8px;background:var(--accent);col
|
||||
.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)}
|
||||
h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between}
|
||||
.brain{font-size:14px;color:var(--mute)}.brain select{font:inherit;padding:4px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--fg)}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;font-size:14px;color:var(--mute)}.meta button{padding:4px 10px;font-size:14px;background:var(--card);color:var(--fg);border:1px solid var(--line)}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>
|
||||
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="">default</option><option value="jev">Jev</option><option value="clef">Clef</option><option value="llm">LLM</option></select></label></div>
|
||||
<ai-chart dataset="orders"></ai-chart>
|
||||
<div class="meta" id="meta"></div>
|
||||
<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>
|
||||
@@ -272,7 +301,20 @@ for (const q of ["Revenue by region", "Monthly revenue, web vs store", "Share of
|
||||
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); });
|
||||
const brainSel = document.getElementById("brain"), meta = document.getElementById("meta");
|
||||
brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.value) : el.removeAttribute("brain");
|
||||
el.addEventListener("spec", (e) => {
|
||||
const { spec, info = {} } = e.detail;
|
||||
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
|
||||
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
|
||||
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
|
||||
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
|
||||
for (const alt of info.alternatives || []) {
|
||||
const b = document.createElement("button"); b.textContent = "or as " + alt.mark + " (" + Math.round(alt.p * 100) + "%)";
|
||||
b.onclick = () => el.render({ ...spec, mark: alt.mark }, e.detail.prompt).catch((err) => { meta.append(" " + err.message); });
|
||||
meta.append(b);
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user