Test and deploy / test-and-deploy (push) Successful in 1m6s
Brewing: - roasted_beans + brews tables; /beans (bean management with LLM URL prefill) and /brews (silhouette brewer picker across immersion/ percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting notes); bean remaining weight derived from logged brew doses - Green inventory lot form also prefills from a product URL Navigation/UX: - Side nav is now generated from one definition in nav.js, grouped Roasting / Brewing / account, consistent on every page LLM: - 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat) grounded in the plan, computed ledger, learned pace, and a new roaster-behavior profile aggregated from uploaded .alogs (/api/roaster-profile: TP lag, phase RoR, median milestone temps) - The profile also feeds roast reviews and the planner curve's fallback milestone temps API platform: - User-generated bearer tokens (rpt_…) with account-page management; token requests skip CSRF; hand-authored OpenAPI 3 spec at /api/openapi.json rendered by self-hosted Swagger UI at /api-docs - Full-database backup export/import (admin) + per-user data export Co-Authored-By: Claude Fable 5 <[email protected]>
83 lines
3.4 KiB
JavaScript
83 lines
3.4 KiB
JavaScript
// Learned roaster behavior, aggregated from every finished roast the user has uploaded
|
|
// (actual_roasts.parsed). Purely deterministic — medians and averages, no model involved —
|
|
// so the same profile can ground the plan curve, the plan chat, and roast evaluations
|
|
// without drift. Complements shared/learn.js, which learns pace from worksheet planActual
|
|
// entries; this learns from real telemetry.
|
|
|
|
const median = (values) => {
|
|
const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
|
if (!sorted.length) return null;
|
|
const mid = Math.floor(sorted.length / 2);
|
|
const value = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
return Math.round(value * 10) / 10;
|
|
};
|
|
|
|
function avgRor(curve, fromS, toS) {
|
|
const pts = (curve ?? []).filter((p) => p.t >= fromS && p.t <= toS);
|
|
if (pts.length < 2) return null;
|
|
const first = pts[0];
|
|
const last = pts[pts.length - 1];
|
|
if (last.t <= first.t) return null;
|
|
return ((last.bt - first.bt) / (last.t - first.t)) * 60;
|
|
}
|
|
|
|
/**
|
|
* @param {object[]} parsedRoasts array of parseAlog() outputs (actual_roasts.parsed)
|
|
* @returns compact profile of how this user's machine actually behaves, or {n: 0}.
|
|
*/
|
|
export function computeRoasterProfile(parsedRoasts) {
|
|
const roasts = (parsedRoasts ?? []).filter((p) => p && Array.isArray(p.curve));
|
|
if (!roasts.length) return { n: 0 };
|
|
|
|
const milestone = (p, key) => p.milestones?.find((m) => m.key === key) ?? null;
|
|
const collect = (fn) => roasts.map(fn);
|
|
|
|
const chargeTemps = collect((p) => p.curve.find((pt) => pt.t >= 0)?.bt);
|
|
const tpTimes = collect((p) => p.turningPoint?.timeS);
|
|
const tpTemps = collect((p) => p.turningPoint?.tempC);
|
|
const yellowTimes = collect((p) => milestone(p, "yellow")?.timeS);
|
|
const yellowTemps = collect((p) => milestone(p, "yellow")?.tempC);
|
|
const fcTimes = collect((p) => milestone(p, "fc")?.timeS);
|
|
const fcTemps = collect((p) => milestone(p, "fc")?.tempC);
|
|
const dropTimes = collect((p) => milestone(p, "drop")?.timeS);
|
|
const dropTemps = collect((p) => milestone(p, "drop")?.tempC);
|
|
const dtrs = collect((p) => p.derived?.dtrPct);
|
|
const losses = collect((p) => p.roast?.weightLossPct);
|
|
|
|
const dryingRor = [];
|
|
const maillardRor = [];
|
|
const developmentRor = [];
|
|
for (const p of roasts) {
|
|
const yellow = milestone(p, "yellow");
|
|
const fc = milestone(p, "fc");
|
|
const drop = milestone(p, "drop");
|
|
if (yellow) dryingRor.push(avgRor(p.curve, 60, yellow.timeS));
|
|
if (yellow && fc) maillardRor.push(avgRor(p.curve, yellow.timeS, fc.timeS));
|
|
if (fc && drop) developmentRor.push(avgRor(p.curve, fc.timeS, drop.timeS));
|
|
}
|
|
|
|
return {
|
|
n: roasts.length,
|
|
medians: {
|
|
chargeTempC: median(chargeTemps),
|
|
// Turning-point time is the practical "thermal lag" of the machine: how long charged
|
|
// energy takes to reverse the probe dip. Deep/late TPs mean slow heat response.
|
|
turningPointS: median(tpTimes),
|
|
turningPointTempC: median(tpTemps),
|
|
yellowS: median(yellowTimes),
|
|
yellowTempC: median(yellowTemps),
|
|
firstCrackS: median(fcTimes),
|
|
firstCrackTempC: median(fcTemps),
|
|
dropS: median(dropTimes),
|
|
dropTempC: median(dropTemps),
|
|
dtrPct: median(dtrs),
|
|
weightLossPct: median(losses),
|
|
},
|
|
rorCPerMin: {
|
|
drying: median(dryingRor),
|
|
maillard: median(maillardRor),
|
|
development: median(developmentRor),
|
|
},
|
|
};
|
|
}
|