Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
+45
-41
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user