Move After-the-Roast onto actual roasts; add Artisan-importable updated .alog download
Test and deploy / test-and-deploy (push) Successful in 56s

One system: post-roast observations (weights, colour, DTR, cup notes,
'one change next batch', disproof) now live on the uploaded roast
(actual_roasts.after, migration 011), edited on the roast detail view
with weights/DTR prefilled from the .alog itself. The planner's screen
section is removed (the print worksheet keeps its hand-fill copy), and
the lot 'last refine' suggestion reads the note from both the new home
and legacy plans, newest wins.

Every roast now downloads two ways: the untouched original .alog, and
an updated supplemental copy with the app's data written back as valid
Artisan fields (weight, beans, roastingnotes incl. the LLM review,
cuppingnotes incl. linked cupping score/flavors) — serialized as a
Python literal so Artisan's ast.literal_eval re-imports it.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 08:35:11 -04:00
co-authored by Claude Fable 5
parent 9b6ea9880e
commit 4a3d192c2c
12 changed files with 395 additions and 161 deletions
+32 -1
View File
@@ -77,7 +77,7 @@ export function pyLiteralToJson(text) {
return out;
}
function parseAlogRaw(content) {
export function parseAlogRaw(content) {
try {
return JSON.parse(content);
} catch {
@@ -85,6 +85,37 @@ function parseAlogRaw(content) {
}
}
/** Serializes a JS value as a Python dict/list literal — the format Artisan itself writes
* and re-reads with ast.literal_eval. JSON is NOT safe for re-import (true/false/null are
* not Python literals), so the supplemental "updated" .alog must go out in this form. */
export function jsToPyLiteral(value) {
if (value === null || value === undefined) return "None";
if (value === true) return "True";
if (value === false) return "False";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "None";
if (typeof value === "string") {
// Python string literal with double quotes; escape backslashes, quotes, newlines.
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
}
if (Array.isArray(value)) return `[${value.map(jsToPyLiteral).join(", ")}]`;
if (typeof value === "object")
return `{${Object.entries(value)
.map(([k, v]) => `${jsToPyLiteral(String(k))}: ${jsToPyLiteral(v)}`)
.join(", ")}}`;
return "None";
}
/** The supplemental .alog: the original file re-serialized with `updates` merged in (only
* keys whose value is not undefined are touched — everything else is preserved verbatim
* from the parse). Returns a Python-literal string Artisan can import. */
export function buildUpdatedAlog(originalContent, updates) {
const data = parseAlogRaw(originalContent);
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) data[key] = value;
}
return jsToPyLiteral(data);
}
/**
* @param {string} content raw file text
* @param {string} filename for the title fallback
+130 -31
View File
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { parseAlog } from "./alog.js";
import { buildUpdatedAlog, parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js";
import { listAvailableModels as defaultListModels } from "./llm.js";
@@ -974,14 +974,25 @@ export function createApp({
)
).rows[0];
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
// Two sources, newest wins: legacy plans that stored the note in the worksheet's
// afterRoast, and actual roasts (where after-the-roast now lives) linked to a
// plan against this lot.
const row = (
await db.query(
`SELECT plan->'afterRoast'->>'oneChange' AS one_change,
plan->'fields'->>'0.1' AS plan_title, updated_at
FROM roast_plans
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
ORDER BY updated_at DESC LIMIT 1`,
`SELECT one_change, plan_title, updated_at FROM (
SELECT plan->'afterRoast'->>'oneChange' AS one_change,
plan->'fields'->>'0.1' AS plan_title, updated_at
FROM roast_plans
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
UNION ALL
SELECT a.after->>'oneChange' AS one_change,
p.plan->'fields'->>'0.1' AS plan_title, a.updated_at
FROM actual_roasts a
JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
AND coalesce(a.after->>'oneChange','')<>''
) src ORDER BY updated_at DESC LIMIT 1`,
[req.user.id, req.params.id],
)
).rows[0];
@@ -1471,8 +1482,25 @@ export function createApp({
evaluationSummary: evaluation?.summary ?? null,
createdAt: row.created_at,
};
return full ? { ...base, parsed, evaluation, plan: row.plan ?? null } : base;
return full
? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
// plan worksheet). All free-text/short strings — the numbers among them (weights, DTR)
// stay strings like the worksheet always stored them.
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
];
function sanitizeAfter(raw) {
if (!raw || typeof raw !== "object") return null;
const out = {};
for (const field of AFTER_FIELDS)
if (raw[field] !== undefined && raw[field] !== null)
out[field] = String(raw[field]).slice(0, 2_000);
return out;
}
// Fire-and-forget: the upload response never waits on the model (a deep review takes tens of
// seconds, and uploads arrive in batches); the row starts 'pending' and the client polls.
// Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the
@@ -1625,25 +1653,76 @@ export function createApp({
try {
const row = (
await db.query(
"SELECT filename,original_content FROM actual_roasts WHERE id=$1 AND user_id=$2",
`SELECT a.filename, a.original_content, a.after, a.evaluation, a.parsed,
p.plan->'fields'->>'0.1' AS plan_title, a.roast_plan_id
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`,
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
if (!/\.alog$/i.test(name)) name += ".alog";
name = name.replace(/\.alog$/i, "");
const updated = req.query.variant === "updated";
let content = row.original_content;
if (updated) {
// Supplemental copy: everything this app tracks about the roast written back
// as valid Artisan fields (Python-literal serialization — Artisan re-imports
// it with ast.literal_eval). The original stays untouched.
const after = row.after ?? {};
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const greenIn = num(after.greenIn) ?? row.parsed?.roast?.weightInG ?? null;
const out = num(after.out) ?? row.parsed?.roast?.weightOutG ?? null;
const cupping = row.roast_plan_id
? (
await db.query(
"SELECT data,total_score FROM cupping_sessions WHERE roast_plan_id=$1 AND user_id=$2 ORDER BY updated_at DESC LIMIT 1",
[row.roast_plan_id, req.user.id],
)
).rows[0]
: null;
const cuppingParts = [
after.cupNotes,
cupping ? `Cupping score ${Number(cupping.total_score).toFixed(2)}` : null,
cupping?.data?.flavor_tags?.length
? `Flavors: ${cupping.data.flavor_tags.join(", ")}`
: null,
after.brewRatio || after.method
? `Brewed ${[after.method, after.brewRatio].filter(Boolean).join(" ")}`
: null,
after.restedDays ? `Rested ${after.restedDays} days` : null,
].filter(Boolean);
const roastingParts = [
after.oneChange ? `One change next batch: ${after.oneChange}` : null,
after.disproof ? `Would disprove it: ${after.disproof}` : null,
after.colour ? `Colour ${after.colour}` : null,
after.actualDtrPct ? `Actual DTR ${after.actualDtrPct}%` : null,
row.evaluation?.summary ? `LLM review: ${row.evaluation.summary}` : null,
].filter(Boolean);
content = buildUpdatedAlog(row.original_content, {
weight:
greenIn !== null && out !== null ? [greenIn, out, "g"] : undefined,
beans: row.plan_title || undefined,
cuppingnotes: cuppingParts.length ? cuppingParts.join("\n") : undefined,
roastingnotes: roastingParts.length ? roastingParts.join("\n") : undefined,
});
}
res.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${name}"`,
"Content-Disposition": `attachment; filename="${name}${updated ? "-updated" : ""}.alog"`,
});
res.send(row.original_content);
res.send(content);
} catch (e) {
next(e);
}
},
);
// Reassign a roast to another of the user's machines (the only mutable field on an upload —
// the file and its parse are immutable history).
// Mutable parts of an upload: which machine it belongs to, and the after-the-roast
// observations. The file and its parse are immutable history.
app.put(
"/api/roasts/:id",
requireAuth,
@@ -1651,27 +1730,47 @@ export function createApp({
requireUuidParam("id"),
async (req, res, next) => {
try {
let roasterId = null;
if (req.body.roasterId !== undefined && req.body.roasterId !== null && req.body.roasterId !== "") {
if (!UUID_RE.test(req.body.roasterId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
req.body.roasterId,
req.user.id,
])
).rowCount;
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
roasterId = req.body.roasterId;
const sets = [];
const params = [];
if (req.body.roasterId !== undefined) {
let roasterId = null;
if (req.body.roasterId !== null && req.body.roasterId !== "") {
if (!UUID_RE.test(req.body.roasterId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query(
"SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2",
[req.body.roasterId, req.user.id],
)
).rowCount;
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
roasterId = req.body.roasterId;
}
params.push(roasterId);
sets.push(`roaster_id=$${params.length}`);
}
if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after);
if (!after)
return res.status(400).json({ ok: false, code: "bad_after" });
params.push(after);
sets.push(`after=$${params.length}`);
}
if (!sets.length)
return res.status(400).json({ ok: false, code: "bad_request" });
params.push(req.params.id, req.user.id);
const r = await db.query(
"UPDATE actual_roasts SET roaster_id=$1, updated_at=now() WHERE id=$2 AND user_id=$3",
[roasterId, req.params.id, req.user.id],
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, after`,
params,
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, roasterId });
res.json({
ok: true,
roasterId: r.rows[0].roaster_id,
after: r.rows[0].after,
});
} catch (e) {
next(e);
}
@@ -2703,7 +2802,7 @@ export function createApp({
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
"after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
+2 -2
View File
@@ -108,10 +108,10 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
"/api/roasts/{id}": {
get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Reassign the roast to another of your machines", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true } })), responses: { 200: ok("Reassigned"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Update the roast's machine and/or after-the-roast observations", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the original .alog", parameters: [idParam], responses: { 200: { description: "Original file as attachment" }, 404: err("Not found") } } },
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the .alog — original, or ?variant=updated for a supplemental copy with the app's data (weights, notes, cupping) written back as Artisan fields, importable into Artisan", parameters: [idParam, { name: "variant", in: "query", schema: { type: "string", enum: ["updated"] } }], responses: { 200: { description: "File as attachment" }, 404: err("Not found") } } },
"/api/roasts/{id}/evaluate": { post: { tags: ["actual roasts"], summary: "Queue a re-review", parameters: [idParam], responses: { 200: ok("Queued"), 404: err("Not found") } } },
// ── Green inventory ──