labs/aicharts: tolerate model spelling, show its answer on failure #37

Merged
teahubxyz merged 1 commits from labs/aicharts-raw into main 2026-10-13 13:47:49 +00:00
3 changed files with 39 additions and 6 deletions

No files matched your search

+7 -1
View File
@@ -61,6 +61,10 @@ assert.deepEqual(sent.at(-1).body.prior, bySum, "follow-ups carry the last chart
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);
assert.deepEqual(W.normalizeSpec({ mark: "Bar", x: "region", y: "value",
transform: [{ type: "groupBy", groupBy: "region", aggregate: "SUM", field: "amount", as: "value" }, { op: "sort", field: "value", order: "DESC" }] }),
{ mark: "bar", x: "region", y: "value", transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] });
assert.equal(W.normalizeSpec({ mark: "bar", transform: [{ op: "group", agg: "mean" }] }).transform[0].agg, "avg");
class D1 {
constructor() { this.db = new DatabaseSync(":memory:"); this.writes = []; }
@@ -134,7 +138,9 @@ assert.ok(calls.at(-1).messages.at(-1).content.includes("fails these checks"), "
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/);
const failed = await r.json();
assert.match(failed.error, /valid chart/);
assert.equal(failed.model_said, '{"mark": "pie"}', "the model's answer is shown on failure");
// limits: AI_DAILY=5 overall; we've used 3
answers.push({ response: good }, { response: good });
+31 -5
View File
@@ -147,6 +147,30 @@ export function firstObject(text) {
return null;
}
// Small models drift on spelling, not meaning: "SUM", "mean", "aggregate", "groupBy": "region". Fix the spelling, then
// the validator decides. Nothing here invents a field or an operation.
const AGG_ALIAS = { average: "avg", mean: "avg", total: "sum", minimum: "min", maximum: "max", cnt: "count" };
export function normalizeSpec(spec) {
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
const low = (v) => (typeof v === "string" ? v.trim().toLowerCase() : v);
const out = { ...spec, mark: low(spec.mark) };
if (spec.chart && !spec.mark) out.mark = low(spec.chart.type ?? spec.chart);
if (Array.isArray(spec.transform)) out.transform = spec.transform.map((s0) => {
if (!s0 || typeof s0 !== "object") return s0;
const s = { ...s0, op: low(s0.op ?? s0.type) };
if (s.op === "groupby" || s.op === "aggregate") s.op = "group";
if (s.aggregate != null && s.agg == null) s.agg = s.aggregate;
if (s.groupby != null && s.by == null) s.by = s.groupby;
if (s.groupBy != null && s.by == null) s.by = s.groupBy;
if (typeof s.by === "string") s.by = [s.by];
if (s.agg != null) { s.agg = low(s.agg); s.agg = AGG_ALIAS[s.agg] ?? s.agg; }
if (s.order != null) s.order = low(s.order);
delete s.type; delete s.aggregate; delete s.groupby; delete s.groupBy;
return s;
});
return out;
}
async function model(env, messages) {
const input = { messages, max_tokens: 700, temperature: 0.1 };
let out;
@@ -155,7 +179,8 @@ async function model(env, messages) {
} 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 ?? "");
const raw = out?.response ?? out?.choices?.[0]?.message?.content ?? "";
return { spec: normalizeSpec(firstObject(raw)), raw: typeof raw === "string" ? raw : JSON.stringify(raw) };
}
// Ask, check, and give the model one chance to fix what the validator found.
@@ -166,14 +191,15 @@ async function specFor(env, dataset, prompt, prior) {
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);
let { spec, raw } = await model(env, messages), v = validateSpec(spec, schema);
if (!v.ok) {
messages.push({ role: "assistant", content: JSON.stringify(spec) },
messages.push({ role: "assistant", content: raw.slice(0, 2000) },
{ role: "user", content: `That spec fails these checks: ${v.errors.join("; ")}. Answer with the corrected JSON object only.` });
spec = await model(env, messages);
({ spec, raw } = 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("; ") };
// On failure the model's own answer comes back too: it is shown, never run.
return v.ok ? { spec } : { error: "couldn't turn that into a valid chart: " + v.errors.slice(0, 3).join("; "), model_said: raw.slice(0, 800) };
}
const newId = () => [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 10);
+1
View File
@@ -26,6 +26,7 @@ jobs:
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
- name: smoke
run: |
set -o pipefail
u="${{ steps.deploy.outputs.url }}"
for i in $(seq 1 18); 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")'