Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s

This commit is contained in:
snowspeeder committed 2026-09-06 18:43:33 -04:00
1 parent 1fd53091ac
commit 8c21750356
28 files changed
+3848 -384

No files matched your search

+45 -41
View File
@@ -1,3 +1,5 @@
import { registerWorkspace, workspaceFor, savePlanRevision } from "./workspace.js";
import { coffeeKey, cleanSuggestions } from "../shared/workspace.js";
import { blankPlan } from "../shared/fields.js";
import { sanitizeObservations } from "../shared/roast-comparison.js";
import express from "express";
@@ -49,6 +51,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/cupping.html",
"/roasts.html",
"/plans.html",
"/workspace.html",
"/beans.html",
"/brews.html",
"/gear.html",
@@ -146,7 +149,7 @@ export function createApp({
req.path === "/inventory" ||
req.path === "/cupping" ||
req.path === "/roasts" ||
req.path === "/plans" ||
req.path === "/plans" || req.path === "/home" || req.path === "/coffees" ||
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
@@ -432,6 +435,7 @@ export function createApp({
});
for (const [route, file] of [
["/plans", "plans.html"],
["/home", "workspace.html"], ["/coffees", "workspace.html"],
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
@@ -752,6 +756,7 @@ export function createApp({
);
// ─── Plans ─────────────────────────────────────────────────────────────
registerWorkspace(app, {db, requireAuth, csrf, requireUuidParam});
app.get("/api/plans", requireAuth, async (req, res, next) => {
try {
if (req.query.view === "summary") {
@@ -798,6 +803,9 @@ export function createApp({
const plan = structuredClone(source.plan);
const defaults = blankPlan();
plan.planActual = defaults.planActual;
plan.assistant = {messages:[]};
plan.roastDay = {};
plan.experiment = {parentPlanId:req.params.id, question:"", change:"", expected:""};
plan.afterRoast = defaults.afterRoast;
plan.inventory = {...defaults.inventory, ...plan.inventory, consumed:null};
const title = source.name || plan.fields?.["0.1"] || "Untitled plan";
@@ -808,7 +816,7 @@ export function createApp({
});
app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
if (!req.body.plan || typeof req.body.plan !== "object" || Array.isArray(req.body.plan))
return res.status(400).json({ ok: false, code: "bad_plan" });
const name = String(req.body.name ?? "").trim().slice(0, 120);
const p = (
@@ -831,29 +839,13 @@ export function createApp({
try {
const hasPlan = req.body.plan !== undefined;
const hasName = req.body.name !== undefined;
if (hasPlan && (!req.body.plan || typeof req.body.plan !== "object"))
if (hasPlan && (!req.body.plan || typeof req.body.plan !== "object" || Array.isArray(req.body.plan)))
return res.status(400).json({ ok: false, code: "bad_plan" });
if (!hasPlan && !hasName)
return res.status(400).json({ ok: false, code: "bad_plan" });
const existing = hasPlan && hasName
? null
: (
await db.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
const plan = hasPlan ? req.body.plan : existing?.plan;
const name = hasName
? String(req.body.name ?? "").trim().slice(0, 120)
: (existing?.name ?? "");
const r = await db.query(
"UPDATE roast_plans SET plan=$1,name=$2,updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING id,plan,name,updated_at",
[plan, name, req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, plan: r.rows[0] });
const saved=await savePlanRevision(db,req.user.id,req.params.id,req.body);
if(!saved)return res.status(404).json({ok:false,code:"not_found"});
res.json({ok:true,plan:saved});
} catch (e) {
next(e);
}
@@ -1503,7 +1495,7 @@ export function createApp({
});
app.post("/api/plan-chat", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
if (!req.body.plan || typeof req.body.plan !== "object" || Array.isArray(req.body.plan))
return res.status(400).json({ ok: false, code: "bad_plan" });
let messages;
try {
@@ -1511,7 +1503,11 @@ export function createApp({
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_messages", error: e.message });
}
const result = await runPlanChat({ plan: req.body.plan, messages, preferredModel: await llmModelSetting() });
const workspace = await workspaceFor(db,req.user.id);
const coffee = workspace.coffees.find(c=>c.key===coffeeKey(req.body.plan));
const evidence = coffee ? {name:coffee.name,roasts:coffee.roasts.slice(0,12),cuppings:coffee.cuppings.slice(0,12),experiments:coffee.plans.map(p=>({id:p.id,experiment:p.experiment})).slice(0,12)} : null;
const result = await runPlanChat({ plan: req.body.plan, messages, evidence, preferredModel: await llmModelSetting() });
result.suggestions = cleanSuggestions(result.suggestions).map(s=>({...s,evidenceRoastIds:s.evidenceRoastIds.filter(id=>evidence?.roasts.some(r=>r.id===id))}));
res.json({ ok: true, ...result });
} catch (err) {
const code = err.code ?? "chat_failed";
@@ -1541,7 +1537,7 @@ export function createApp({
createdAt: row.created_at,
};
return full
? { ...base, parsed, evaluation, observations: row.observations ?? {}, after: row.after ?? {}, plan: row.plan ?? null }
? { ...base, parsed, evaluation, observations: row.observations ?? {}, after: row.after ?? {}, hasPlanSnapshot: Boolean(row.plan_snapshot), plan: row.plan_snapshot ?? row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
@@ -1549,7 +1545,7 @@ export function createApp({
// stay strings like the worksheet always stored them.
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof", "tasteBalance", "sweetness", "favorite", "experimentResult",
];
function sanitizeAfter(raw) {
if (!raw || typeof raw !== "object") return null;
@@ -1567,7 +1563,7 @@ export function createApp({
const run = (async () => {
const row = (
await db.query(
`SELECT a.parsed, a.roaster_id, p.plan FROM actual_roasts a
`SELECT a.parsed, a.roaster_id, COALESCE(a.plan_snapshot,p.plan) AS plan FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[roastId, userId],
@@ -1600,19 +1596,20 @@ export function createApp({
if (typeof content !== "string" || !content.trim())
return res.status(400).json({ ok: false, code: "bad_request" });
const filename = String(req.body.filename || "upload.alog").slice(0, 200);
let roastPlanId = null;
let roastPlanId = null, planSnapshot = null;
if (req.body.roastPlanId) {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const ownsPlan = (
await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
"SELECT plan FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.body.roastPlanId, req.user.id],
)
).rowCount;
).rows[0];
if (!ownsPlan)
return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
planSnapshot = {...ownsPlan.plan, assistant:undefined, roastDay:undefined};
}
let parsed;
try {
@@ -1646,9 +1643,9 @@ export function createApp({
}
const row = (
await db.query(
`INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed)
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[req.user.id, roastPlanId, roasterId, filename, content, parsed],
`INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed,plan_snapshot)
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[req.user.id, roastPlanId, roasterId, filename, content, parsed, planSnapshot],
)
).rows[0];
startEvaluation(row.id, req.user.id);
@@ -1720,6 +1717,7 @@ export function createApp({
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
if(row.parsed?.roast?.manual){if(req.query.variant==='updated')return res.status(400).json({ok:false,code:"manual_record",error:"Manual records export as JSON, not Artisan telemetry."});res.set("Content-Disposition",`attachment; filename="roast-day-${req.params.id}.json"`);return res.type("application/json").send(row.original_content);}
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
name = name.replace(/\.alog$/i, "");
const updated = req.query.variant === "updated";
@@ -1809,22 +1807,24 @@ export function createApp({
sets.push(`roaster_id=$${params.length}`);
}
if (req.body.roastPlanId !== undefined) {
let roastPlanId = null;
let roastPlanId = null, planSnapshot = null;
if (req.body.roastPlanId !== null && req.body.roastPlanId !== "") {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
"SELECT plan FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.body.roastPlanId, req.user.id],
)
).rowCount;
).rows[0];
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
planSnapshot = {...owns.plan, assistant:undefined, roastDay:undefined};
}
params.push(roastPlanId);
sets.push(`roast_plan_id=$${params.length}`);
params.push(planSnapshot);sets.push(`plan_snapshot=$${params.length}`);
}
if (req.body.observations !== undefined) {
const observations = sanitizeObservations(req.body.observations);
@@ -2889,11 +2889,11 @@ export function createApp({
const BACKUP_TABLES = [
["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at", "avatar"]],
["app_settings", ["key", "value"]],
["roast_plans", ["id", "user_id", "plan", "created_at", "updated_at"]],
["roast_plans", ["id", "user_id", "plan", "name", "created_at", "updated_at"]],
[
"green_bean_lots",
[
"id", "user_id", "origin", "variety", "process", "producer", "purchase_date",
"id", "user_id", "name", "origin", "variety", "process", "producer", "purchase_date",
"initial_weight_g", "remaining_weight_g", "cost_total", "moisture_pct",
"density_g_l", "notes", "archived", "created_at", "updated_at",
],
@@ -2901,11 +2901,12 @@ export function createApp({
["bean_consumption", ["id", "lot_id", "user_id", "roast_plan_id", "weight_g", "created_at"]],
["cupping_sessions", ["id", "user_id", "roast_plan_id", "data", "total_score", "created_at", "updated_at"]],
["roasters", ["id", "user_id", "name", "model", "notes", "is_default", "overrides", "created_at", "updated_at"]],
["plan_revisions", ["id", "user_id", "plan_id", "plan", "name", "created_at"]],
[
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"observations", "after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
"manual_key", "plan_snapshot", "observations", "after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
@@ -2986,7 +2987,8 @@ export function createApp({
for (const row of rows) {
const params = columns.map((column) => {
const value = row[column];
if (value === undefined) return table === "actual_roasts" && column === "observations" ? "{}" : null;
if (value === undefined && column === "name") return "";
if (value === undefined) return table === "actual_roasts" && column === "observations" ? "{}" : null;
// jsonb columns arrive as objects; everything else is scalar.
return value !== null && typeof value === "object"
? JSON.stringify(value)
@@ -3070,11 +3072,13 @@ export function createApp({
} catch (e) {
modelsError = "llm_unavailable";
}
res.json({ ok: true, current, models, modelsError });
const effective = [current, env.LLM_MODEL, env.PREFILL_MODEL].find(key=>models.some(m=>m.key===key)) || models[0]?.key || null;
res.json({ ok: true, current, models, modelsError, effective });
} catch (e) {
next(e);
}
});
app.post("/api/admin/llm/test", requireAuth, csrf, admin, async(req,res)=>{try{const result=await runPlanChat({plan:blankPlan(),messages:[{role:"user",content:"Reply with a short connection confirmation. Do not suggest plan changes."}],preferredModel:await llmModelSetting()});res.json({ok:true,model:result.model,reply:result.reply});}catch(e){res.status(503).json({ok:false,code:"connection_failed",error:e.message});}});
app.put(
"/api/admin/llm",
requireAuth,