labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)
ci / test (push) Successful in 34s
time/worker-run curl -sS -m 15 https://docs.whop.com/.well-known/skills/index.json | head -c 1500; echo; curl -sS -m

labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)

Opened 2026-10-14 by a Claude Code session under the owner's login, which is why it read as the operator's PR for three days. Lab-only and reversible: Clef-flash draws first, the LLM checks in parallel, the chart is redrawn only on disagreement; API callers keep the LLM default. The lab's tests pass on the branch (run 652, "aicharts: all good"); its deploy step cannot run from prod, which holds no Cloudflare secrets, so nothing is deployed by this merge.

Squash-merged by the CEO session on the owner's explicit instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
This commit was merged in pull request #42.
This commit is contained in:
teahubxyzandClaude Fable 5.1 committed 2026-10-18 00:12:54 +00:00
1 parent b539fff1b2
commit 4f8eda2483
3 files changed
+38 -9

No files matched your search

+28 -2
View File
@@ -169,7 +169,8 @@ export function attach(chart, { data, schema, endpoint, dataset }) {
const Base = globalThis.HTMLElement ?? class {}; const Base = globalThis.HTMLElement ?? class {};
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset) // <ai-chart endpoint="https://..." dataset="orders" brain="fast"></ai-chart>
// brain: fast (decision model first, LLM checks) | 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 // 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). // element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base { export class AiChart extends Base {
@@ -202,7 +203,9 @@ export class AiChart extends Base {
try { try {
await this.load(); await this.load();
const brain = this.getAttribute("brain"); const brain = this.getAttribute("brain");
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) }); const body = { dataset: this.dataset, prompt, prior: this._spec };
if (brain === "fast") return await this.askFast(body);
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, ...(brain ? { brain } : {}) });
await this.render(spec, prompt); await this.render(spec, prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } })); this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} catch (e) { } catch (e) {
@@ -210,6 +213,29 @@ export class AiChart extends Base {
} }
} }
// brain="fast": a decision model draws first (under a second), an LLM answers in the background, and the chart is
// redrawn only if the LLM's chart differs. Whichever comes back alone still draws.
async askFast(body) {
const ask = ++this._asks || (this._asks = 1);
const current = () => ask === this._asks;
const same = (a, b) => JSON.stringify({ ...a, title: 0 }) === JSON.stringify({ ...b, title: 0 });
const llm = fetchSpec(this.endpoint, { ...body, brain: "llm" }).catch((e) => ({ error: e }));
let drawn = null;
try {
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, brain: "clef", floor: 0 });
if (!current()) return;
await this.render(spec, body.prompt);
drawn = spec;
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: "fast" } } }));
} catch { /* the LLM will answer */ }
const out = await llm;
if (!current() || out.error) { if (!drawn && out.error) throw out.error; return; }
const { spec, ...info } = out;
const changed = !drawn || !same(drawn, spec);
if (changed) await this.render(spec, body.prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: drawn ? (changed ? "corrected" : "confirmed") : "llm" } } }));
}
async render(spec, prompt) { async render(spec, prompt) {
await this.load(); await this.load();
const v = validateSpec(spec, this._schema); const v = validateSpec(spec, this._schema);
+6 -4
View File
@@ -52,16 +52,18 @@ export function questions(info) {
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis), 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), measure: choice("Which quantity does the chart measure?", measure),
agg: choice("How should that quantity be combined within each group?", AGG_MENU), 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\").", series: choice("Does the request compare two or more values of a column side by side (\"web vs store\", \"split by channel\", \"each region over time\")? If so, pick that column; it becomes one coloured line or bar per value. Otherwise none.",
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }), { none: "No comparison asked for: a single series.",
...Object.fromEntries(text.map((c) => [c.name, `Compare ${c.name}s (${(info.aggregates[c.name]?.values || []).join(" vs ")}): one series per ${c.name}.`])) }),
order: choice("How should the groups be ordered?", ORDER_MENU), order: choice("How should the groups be ordered?", ORDER_MENU),
top: choice("Does the request limit how many groups to show?", TOP_MENU), top: choice("Does the request limit how many groups to show?", TOP_MENU),
}; };
for (const c of text) { for (const c of text) {
const vals = info.aggregates[c.name]?.values; const vals = info.aggregates[c.name]?.values;
if (vals?.length && vals.length <= 30) 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.`, q[`only.${c.name}`] = choice(`Does the request name exactly one ${c.name} to look at (for example "${vals[0]} sales" or "in the ${vals[0]}")? Pick that value; pick any if it names none, or names several to compare.`,
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) }); { any: `The request names no single ${c.name}.`,
...Object.fromEntries(vals.map((v) => [v, `The request mentions "${v}": keep only rows where ${c.name} is ${v}.`])) });
} }
return q; return q;
} }
+4 -3
View File
@@ -287,8 +287,8 @@ h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;ali
</style></head><body><main> </style></head><body><main>
<h1>AICharts</h1> <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> <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="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> <div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="fast" selected>Fast (Clef, LLM checks)</option><option value="clef">Clef only</option><option value="jev">Jev</option><option value="llm">LLM only</option></select></label></div>
<ai-chart dataset="orders"></ai-chart> <ai-chart dataset="orders" brain="fast"></ai-chart>
<div class="meta" id="meta"></div> <div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details> <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>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
@@ -310,7 +310,8 @@ brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.val
el.addEventListener("spec", (e) => { el.addEventListener("spec", (e) => {
const { spec, info = {} } = e.detail; const { spec, info = {} } = e.detail;
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2); document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""), const stage = { fast: "first draw", confirmed: "LLM agrees", corrected: "corrected by the LLM" }[info.stage];
const bits = [stage, 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); info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
meta.replaceChildren(document.createTextNode(bits.join(" · "))); meta.replaceChildren(document.createTextNode(bits.join(" · ")));
for (const alt of info.alternatives || []) { for (const alt of info.alternatives || []) {