Compare commits

..
1 Commits
Author SHA1 Message Date
hermes c7ca9c3801 feat(advice): LLM brewing advice on every observation (work in progress)
Stores a model's advice for each recorded reading, in its own field, so the
brewer's own comment is never overwritten.

Fire and forget: the advice is generated in a background thread, so
recording a reading never blocks on the model.  A failure is recorded as a
visible note saying the advice could not be generated, because a silent
failure is indistinguishable from "still thinking".

The model is `glm-5.3:cloud` on the Ollama instance at 192.168.178.45:11434.
Several models on that instance are retired and fail at call time while
still appearing in the model list, so the default is one that was probed.

The system prompt tells the model never to call a reading impossible: a
gravity below 1.000 is always a mis-entry, but the rest of the run may be a
genuine long fermentation and still worth understanding.

150 tests.
2026-11-09 15:52:26 +10:00
7 changed files with 32 additions and 276 deletions
+9 -50
View File
@@ -218,32 +218,14 @@ class Run:
"""Gravity points of every *usable* reading, in chronological order.""" """Gravity points of every *usable* reading, in chronological order."""
return [r.sg_points for r in self.readings if r.sg_points is not None] return [r.sg_points for r in self.readings if r.sg_points is not None]
def dated_sg_values(self) -> list[int]:
"""Gravity points of the readings that have a date, chronologically.
An undated reading cannot be placed in the run's timeline, so it
must not be used as the original gravity: it sorts to the front
and would put the OG after the FG.
"""
dated = [r for r in self.readings
if r.sg_points is not None and r.date is not None]
return [r.sg_points for r in dated]
def original_gravity(self) -> int | None: def original_gravity(self) -> int | None:
"""OG = the first *dated* usable reading of the run.""" """OG = the first usable reading of the run."""
values = self.dated_sg_values() values = self.sg_values()
return values[0] if values else None return values[0] if values else None
def final_gravity(self) -> int | None: def final_gravity(self) -> int | None:
"""FG = the last *dated* usable reading of the run (if it is finished). """FG = the last usable reading of the run (if the run is finished)."""
values = self.sg_values()
For a finished run the final reading is the one flagged as final,
which is the reading the brewer took when they racked it.
"""
final = self.final_reading()
if final is not None and final.sg_points is not None:
return final.sg_points
values = self.dated_sg_values()
return values[-1] if values else None return values[-1] if values else None
def max_sg(self) -> int | None: def max_sg(self) -> int | None:
@@ -534,11 +516,6 @@ def attenuation_percent(run: Run) -> float | None:
fg = run.final_gravity() fg = run.final_gravity()
if og is None or fg is None or og <= 1000 or fg < 1000: if og is None or fg is None or og <= 1000 or fg < 1000:
return None return None
if fg > og:
# The gravity went UP. That is not attenuation - it is either a
# mis-ordered pair of readings or a contaminated sample, and a
# negative attenuation is not a number a brewer can act on.
return None
attenuation = (og - fg) / (og - 1000) * 100 attenuation = (og - fg) / (og - 1000) * 100
if attenuation > 100: if attenuation > 100:
# Cannot ferment away more than the sugar that was there. # Cannot ferment away more than the sugar that was there.
@@ -568,17 +545,6 @@ def implausible_runs(runs: Iterable[Run]) -> list[dict]:
"is probably wrong" "is probably wrong"
), ),
}) })
elif og is not None and fg is not None and fg > og:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"the final gravity {fg / 1000:.3f} is higher than the "
f"original {og / 1000:.3f} — the gravity went up, which "
"is usually a mis-ordered pair of readings"
),
})
elif og is not None and fg is not None and og > 1000 and fg > 1000 \ elif og is not None and fg is not None and og > 1000 and fg > 1000 \
and (og - fg) / (og - 1000) * 100 > 100: and (og - fg) / (og - 1000) * 100 > 100:
problems.append({ problems.append({
@@ -596,19 +562,12 @@ def days_to_target(run: Run, target: int) -> int | None:
Used to answer "how long does this usually take?", which is the question Used to answer "how long does this usually take?", which is the question
a brewer actually has when planning the next batch. a brewer actually has when planning the next batch.
""" """
first = run.first_reading()
if first is None or first.date is None:
# Without a start date there is no elapsed time to measure.
return None
# Skip the readings that cannot be placed on the timeline (no date)
# rather than giving up on the whole run because of one of them.
for reading in run.readings: for reading in run.readings:
if reading.sg_points is None or reading.sg_points > target: if reading.sg_points is not None and reading.sg_points <= target:
continue first = run.first_reading()
if reading.date is None: if first is None or first.date is None or reading.date is None:
continue return None
return days_between(first.date, reading.date) return days_between(first.date, reading.date)
return None return None
+10 -59
View File
@@ -179,7 +179,8 @@
var canvas = document.getElementById("abv-chart"); var canvas = document.getElementById("abv-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetchRuns() fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) { .then(function (runs) {
// Only finished brews have a measured ABV. An unfinished run // Only finished brews have a measured ABV. An unfinished run
// has a projection, not a measurement, so it is not plotted // has a projection, not a measurement, so it is not plotted
@@ -239,7 +240,8 @@
var canvas = document.getElementById("months-chart"); var canvas = document.getElementById("months-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetchRuns() fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) { .then(function (runs) {
var counts = {}; var counts = {};
runs.forEach(function (r) { runs.forEach(function (r) {
@@ -300,7 +302,8 @@
var canvas = document.getElementById("attenuation-chart"); var canvas = document.getElementById("attenuation-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetchRuns() fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) { .then(function (runs) {
// A run with an impossible reading has no attenuation, and // A run with an impossible reading has no attenuation, and
// is flagged as suspect. Both are excluded, so one bad // is flagged as suspect. Both are excluded, so one bad
@@ -381,18 +384,14 @@
var canvas = document.getElementById("curve-chart"); var canvas = document.getElementById("curve-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetchRuns() fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) { .then(function (runs) {
// A run with an impossible reading has a broken curve - it // A run with an impossible reading has a broken curve - it
// plunges to a gravity below water - which would drag the // plunges to a gravity below water - which would drag the
// median line down and stretch the envelope to cover it. // median line down and stretch the envelope to cover it.
// Only FINISHED brews belong on the curve. An active run's
// curve is a partial fermentation, and the curve is meant to
// describe how a completed fermentation behaves - including
// a partial one pulls the median line off the real shape.
var withCurve = runs.filter(function (r) { var withCurve = runs.filter(function (r) {
return !r.suspect && !r.active && return !r.suspect && r.curve && r.curve.length > 1;
r.curve && r.curve.length > 1;
}); });
if (!withCurve.length) { return; } if (!withCurve.length) { return; }
@@ -463,7 +462,6 @@
// progress point. // progress point.
var envelope = []; var envelope = [];
var median = []; var median = [];
var envelopeMax = [];
for (var i = 0; i < samples; i++) { for (var i = 0; i < samples; i++) {
var pct = (i / (samples - 1)) * 100; var pct = (i / (samples - 1)) * 100;
var values = []; var values = [];
@@ -480,7 +478,6 @@
y: values[0], y: values[0],
yMax: values[values.length - 1] yMax: values[values.length - 1]
}); });
envelopeMax.push({ x: pct, y: values[values.length - 1] });
var mid = Math.floor(values.length / 2); var mid = Math.floor(values.length / 2);
median.push({ median.push({
x: pct, x: pct,
@@ -495,12 +492,6 @@
data: { data: {
datasets: [ datasets: [
{ {
// The band is drawn as TWO datasets: the min
// curve filled down to the max curve. With
// a single dataset and `fill: "+1"` the band
// was filled to the NEXT dataset - which was
// the median line, not the max - so the
// tooltip and the shading disagreed.
label: "Range (min–max)", label: "Range (min–max)",
data: envelope, data: envelope,
borderColor: withAlpha(ACCENT, 0.35), borderColor: withAlpha(ACCENT, 0.35),
@@ -510,18 +501,6 @@
fill: "+1", fill: "+1",
tension: 0.3 tension: 0.3
}, },
{
// The upper edge of the band. It is the
// dataset the one above fills to.
label: "Range max",
data: envelopeMax,
borderColor: withAlpha(ACCENT, 0.35),
backgroundColor: "transparent",
borderWidth: 1,
pointRadius: 0,
fill: false,
tension: 0.3
},
{ {
label: "Median brew", label: "Median brew",
data: median, data: median,
@@ -543,12 +522,7 @@
boxWidth: 10, boxWidth: 10,
boxHeight: 10, boxHeight: 10,
usePointStyle: true, usePointStyle: true,
font: { size: 11 }, font: { size: 11 }
// The max curve is the helper the band
// fills to, not a series of its own.
filter: function (item, data) {
return item.text !== "Range max";
}
} }
}, },
tooltip: { tooltip: {
@@ -559,9 +533,6 @@
return "range " + point.y + return "range " + point.y +
" to " + (point.yMax || point.y); " to " + (point.yMax || point.y);
} }
if (ctx.datasetIndex === 1) {
return null;
}
return "median " + point.y; return "median " + point.y;
} }
} }
@@ -598,26 +569,6 @@
.catch(function (err) { console.warn(err); }); .catch(function (err) { console.warn(err); });
} }
/* ------------------------------------------------- shared run payload */
// The analysis page has two charts that both need /api/runs, and the
// payload contains every reading of every run. Fetch it once and
// share it rather than making two identical round-trips.
var runsPromise = null;
function fetchRuns() {
if (!runsPromise) {
runsPromise = fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.catch(function (err) {
console.warn(err);
runsPromise = null;
return [];
});
}
return runsPromise;
}
function init() { function init() {
drawSparklines(); drawSparklines();
drawRunChart(); drawRunChart();
+1 -3
View File
@@ -164,9 +164,7 @@
<tbody> <tbody>
{% for row in suspect %} {% for row in suspect %}
<tr> <tr>
<td class="mono"> <td class="mono">{{ row.beer_run_id }}</td>
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td> <td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td> <td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td> <td class="comment">{{ row.problem }}</td>
+5
View File
@@ -70,6 +70,7 @@ def deep():
"attenuation": attenuation_percent(run), "attenuation": attenuation_percent(run),
"days": summary["days_in_fermenter"], "days": summary["days_in_fermenter"],
"days_to_1010": days_to_target(run, 1010), "days_to_1010": days_to_target(run, 1010),
"days_to_1015": days_to_target(run, 1015),
"readings": summary["reading_count"], "readings": summary["reading_count"],
"curve": summary["curve"], "curve": summary["curve"],
# A suspect run is shown in the table so it can be corrected, # A suspect run is shown in the table so it can be corrected,
@@ -77,6 +78,9 @@ def deep():
"suspect": run.beer_run_id in suspect_ids, "suspect": run.beer_run_id in suspect_ids,
}) })
# The rows with a usable curve, for the overlay chart.
curves = [r for r in rows if r["curve"]]
# Only the trustworthy runs feed the statistics. # Only the trustworthy runs feed the statistics.
good = [r for r in rows if not r["suspect"]] good = [r for r in rows if not r["suspect"]]
@@ -105,6 +109,7 @@ def deep():
return render_template( return render_template(
"analysis.html", "analysis.html",
rows=rows, rows=rows,
curves=curves,
suspect=suspect, suspect=suspect,
stats={ stats={
"count": len(rows), "count": len(rows),
+5 -22
View File
@@ -132,10 +132,6 @@ class ReadingForm(FlaskForm):
beer_run_type = StringField("Beer / cider name", beer_run_type = StringField("Beer / cider name",
validators=[Optional(), Length(max=120)]) validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()]) Date = DateField("Reading date", validators=[DataRequired()])
# The floor is deliberately lower than is_plausible_sg's. An
# out-of-range reading is SAVED with a warning rather than rejected:
# the brewer may have mis-typed it, and seeing it recorded is how they
# spot and correct the mistake.
sg = IntegerField("Gravity reading (points)", sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1500)]) validators=[DataRequired(), NumberRange(min=900, max=1500)])
final_reading = BooleanField("This is the final reading") final_reading = BooleanField("This is the final reading")
@@ -169,24 +165,11 @@ def index():
if form.validate_on_submit(): if form.validate_on_submit():
sg_points = int(form.sg.data) sg_points = int(form.sg.data)
if not is_plausible_sg(sg_points): if not is_plausible_sg(sg_points):
if sg_points < 1000: flash(
# Beer cannot be less dense than water, so this is always f"{sg_points} is outside the usual hydrometer range "
# a mis-entry - and a mis-typed "1.012" as "012" is the "(990-1200). It was saved anyway — double-check the reading.",
# likely cause. It is still saved so the mistake is "warning",
# visible and can be corrected. )
flash(
f"{sg_points} is below 1.000 — beer cannot be less dense "
"than water, so this is almost certainly a typo (a 1.012 "
"reading entered as 012?). It was saved anyway; correct "
"it on the run's page.",
"warning",
)
else:
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
run_id = str(form.beer_run_id.data) run_id = str(form.beer_run_id.data)
reading_id = repo().upsert_reading( reading_id = repo().upsert_reading(
beer_run_id=run_id, beer_run_id=run_id,
+1 -117
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import pytest import pytest
from brewing import (Run, attenuation_percent, days_to_target, from brewing import (Run, attenuation_percent, days_to_target,
fermentation_curve, implausible_runs) fermentation_curve)
from conftest import seed_readings from conftest import seed_readings
@@ -392,119 +392,3 @@ def test_the_charts_skip_suspect_runs():
assert charts_js.count("!r.suspect") >= 2, ( assert charts_js.count("!r.suspect") >= 2, (
"the charts must filter out the runs flagged as suspect" "the charts must filter out the runs flagged as suspect"
) )
# ------------------------------------------------- rising gravity #
def test_attenuation_is_none_when_the_gravity_went_up():
"""A negative attenuation is not a number a brewer can act on.
The guard rejected a final gravity below 1.000 and one implying more
than 100% attenuation, but a run whose gravity ROSE produced a
negative figure that was averaged into the headline number.
"""
run = Run.from_docs("30", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) is None
def test_a_rising_gravity_run_is_reported_as_implausible():
"""It must be listed for review, not just quietly dropped."""
run = Run.from_docs("31", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-20", final="True"),
])
problems = implausible_runs([run])
assert len(problems) == 1
assert problems[0]["beer_run_id"] == "31"
assert "went up" in problems[0]["problem"]
# ------------------------------------------------- undated readings #
def test_the_original_gravity_comes_from_the_first_dated_reading():
"""An undated reading cannot be placed on the timeline.
It used to sort to the front, so it became the OG - which put the OG
after the FG and produced a negative attenuation.
"""
run = Run.from_docs("32", [
_doc(sg="1050", date_="2026-01-01"),
{"_id": "x", "beer_run_id": "32", "beer_type": "Ale", "sg": "1005",
"date": None, "final_reading": "False", "comment": ""},
_doc(sg="1010", date_="2026-01-25", final="True"),
])
assert run.original_gravity() == 1050
assert run.final_gravity() == 1010
assert attenuation_percent(run) is not None
assert attenuation_percent(run) > 0
def test_days_to_target_skips_an_undated_reading():
"""One undated reading must not lose the whole figure.
The function gave up and returned None when the first reading at or
below the target had no date, even when a later reading did.
"""
run = Run.from_docs("33", [
_doc(sg="1050", date_="2026-01-01"),
{"_id": "y", "beer_run_id": "33", "beer_type": "Ale", "sg": "1005",
"date": None, "final_reading": "False", "comment": ""},
_doc(sg="1010", date_="2026-01-25", final="True"),
])
assert days_to_target(run, 1010) == 24
# ------------------------------------------------------- the charts #
def test_the_curve_chart_excludes_active_and_suspect_runs():
"""Only finished, plausible brews belong on the curve.
An active run's curve is a partial fermentation, and the curve is
meant to describe how a completed fermentation behaves.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "!r.active" in charts_js, (
"the curve chart must leave the active runs out"
)
def test_the_curve_band_is_filled_to_the_max_not_the_median():
"""The shaded band has to span min to max.
With one dataset and `fill: "+1"` the band was filled to the NEXT
dataset - which was the median line, so the upper half of the range
was drawn as a hairline but never shaded, and the tooltip disagreed
with the shading.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "envelopeMax" in charts_js, (
"the band needs a separate max dataset to fill down to"
)
assert 'label: "Range max"' in charts_js
def test_the_charts_fetch_the_run_payload_once():
"""/api/runs carries every reading of every run.
Four charts each fetched it independently, so the analysis page made
two identical round-trips of the whole collection.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert charts_js.count('fetch("/api/runs"') == 1, (
"the run payload should be fetched once and shared"
)
assert "function fetchRuns()" in charts_js
+1 -25
View File
@@ -186,16 +186,11 @@ def test_logout_clears_the_session(logged_in):
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo): def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
"""A reading outside the range is SAVED with a warning.
Rejecting it would lose the brewer's actual observation, and seeing it
recorded is how they spot and correct a typo.
"""
response = logged_in.post("/brews", data={ response = logged_in.post("/brews", data={
"beer_run_id": "12", "beer_run_id": "12",
"beer_run_type": "Odd", "beer_run_type": "Odd",
"Date": "2026-05-01", "Date": "2026-05-01",
"sg": "1250", "sg": "950",
}, follow_redirects=True) }, follow_redirects=True)
assert response.status_code == 200 assert response.status_code == 200
assert b"outside the usual hydrometer range" in response.data assert b"outside the usual hydrometer range" in response.data
@@ -203,25 +198,6 @@ def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
assert repo.readings.find_one({"beer_run_id": "12"}) is not None assert repo.readings.find_one({"beer_run_id": "12"}) is not None
def test_a_reading_below_water_says_it_is_always_a_typo(logged_in, repo):
"""A gravity below 1.000 cannot be real, so the warning says so.
This is the run 12 case: a 1.012 reading entered as "012". The
reading is still saved, but the message names the likely cause.
"""
response = logged_in.post("/brews", data={
"beer_run_id": "13",
"beer_run_type": "Typo",
"Date": "2026-05-01",
"sg": "900",
}, follow_redirects=True)
assert response.status_code == 200
assert b"below 1.000" in response.data
assert b"typo" in response.data
# Still saved, so the mistake is visible and can be corrected.
assert repo.readings.find_one({"beer_run_id": "13"}) is not None
# ------------------------------------------------------- security: passwords # # ------------------------------------------------------- security: passwords #
def test_legacy_plaintext_password_is_accepted_and_migrated(repo): def test_legacy_plaintext_password_is_accepted_and_migrated(repo):