Files
Shane MaynardandClaude Fable 5 38c7d01e03
Test and deploy / test-and-deploy (push) Successful in 1m6s
Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
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]>
2026-08-08 22:37:42 -04:00

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),
},
};
}