Compare commits

...
3 Commits
Author SHA1 Message Date
hermes ab3050194d 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 16:03:29 +10:00
hermes 41cf2cbb8e fix(analysis): rising gravity, undated readings, and the range band (#13)
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(analysis): rising gravity, undated readings, and the range band (#13)
2026-11-09 15:57:59 +10:00
hermes 76a4d0805c fix(analysis): the review findings - rising gravity, undated readings, the band
A second, deeper review of PR #12 found five things the first review did
not.  All are fixed here.

The band was not the band
-------------------------
The fermentation curve's shaded range was drawn as ONE dataset with
`fill: "+1"`, which fills to the NEXT dataset - and the next dataset was
the median line, not the maximum.  So the band shaded min-to-median, the
upper half of the range was drawn as a hairline but never shaded, and the
tooltip said "range min to max" while the picture showed something else.
The band is now two datasets - the min curve filled down to a max curve -
with the max hidden from the legend.

Rising gravity
--------------
`attenuation_percent` rejected a final gravity below 1.000 and one
implying more than 100% attenuation, but a run whose gravity went UP
produced a NEGATIVE attenuation - which was then averaged into the
headline figure and plotted as a negative bar.  Verified against the live
code: OG 1010 -> FG 1020 gave -100.0%.  Now rejected, and reported by
`implausible_runs` with the reason "the gravity went up, which is usually
a mis-ordered pair of readings".

Undated readings
----------------
An undated reading sorted to the front of the run, so it became the
original gravity - which put the OG after the FG and produced a negative
attenuation.  The OG now comes from the earliest *dated* reading, and
`days_to_target` skips the readings it cannot place on the timeline rather
than giving up on the whole run.  Verified: readings 1050 (dated), 1005
(undated), 1010 (dated) gave None instead of 24 days, and an OG of 1005
instead of 1050.

Active runs on the curve
------------------------
The curve chart filtered on `curve.length > 1` only, so it plotted the
active runs as well - a partial fermentation on a chart meant to describe
how a completed one behaves.  It now excludes them, along with the
suspect runs.

Four fetches, one payload
-------------------------
`/api/runs` carries every reading of every run, and four charts each
fetched it independently - so the analysis page made two identical
round-trips of the whole collection.  There is now one shared fetch.

Also
----
- The "Readings that need a second look" table links to the run, so a
  suspect reading can be corrected in one click.
- `days_to_1015` and the `curves` variable were computed and never used.
  Removed.
- A reading below 1.000 now says what it is - "beer cannot be less dense
  than water, so this is almost certainly a typo (a 1.012 reading entered
  as 012?)".  The reading is still SAVED: rejecting it would lose the
  brewer's actual observation, and seeing it recorded is how they spot the
  mistake.

142 tests (up from 134).
2026-11-09 15:55:56 +10:00
14 changed files with 1075 additions and 36 deletions
+211
View File
@@ -0,0 +1,211 @@
"""Brewing advice from an LLM, generated for each recorded observation.
The advice is a *comment on the reading*, not a replacement for the
brewer's own comment. Every reading gets its own advice text, stored in
its own field, so nothing the brewer wrote is ever overwritten.
Design notes
------------
- **The LLM is never in the request path.** Recording a reading must not
block on a model that may be slow or down, so the advice is generated in
a background thread and written when it is ready. The page shows the
reading immediately and the advice appears on a later visit.
- **Failures are recorded, not raised.** If the model is unreachable the
advice field gets a short note saying so. A silent failure would look
identical to "no advice yet", which is confusing when you are waiting
for a tip that is never going to arrive.
- **The model is configurable**, with a default that is known to work on
this homelab. Several models on the Ollama instance have been retired
and fail at call time while still appearing in the model list, so the
default is verified rather than assumed.
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
log = logging.getLogger(__name__)
#: The Ollama instance on the homelab. Reachable from the pod.
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.178.45:11434")
#: A model that is known to work. `deepseek-v4-flash:cloud`,
#: `deepseek-v3.2:cloud` and `qwen3.5:cloud` are all retired and fail at
#: call time, so they are not safe defaults.
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "glm-5.3:cloud")
#: How long to wait for the model before giving up. The advice is
#: generated in the background, so this only bounds the background thread.
DEFAULT_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "45"))
#: The advice is advice, not instruction. Keep it short - it has to be
#: readable on the brewing page next to the brewer's own comment.
MAX_ADVICE_CHARS = 1200
SYSTEM_PROMPT = """\
You are an experienced home brewer giving practical advice to a single \
brewer about their own beer. You know brewing science: specific gravity, \
attenuation, yeast health, temperature, timing, and what a stalled or \
struck-out fermentation looks like.
You will be given one brew's readings so far: its name, the original \
gravity, each reading's date and specific gravity, and any comment the \
brewer wrote.
Give the brewer useful, specific, practical advice about THIS reading and \
this brew. Consider things like:
- Is the fermentation progressing at a normal rate for the style and \
temperature?
- Has it stalled, and if so what should they do about it?
- Is the yeast likely stressed, unhealthy, or done?
- Is the gravity drop plausible for the original gravity?
- Should they rack, add more hops, raise the temperature, or wait?
Rules:
- If everything looks normal, say so briefly and say what to expect next.
- If something looks wrong, say what it is and what to do about it.
- Be specific to the numbers given. Do not give generic advice.
- If a reading looks like a typo or a mis-entry, say so and suggest \
checking it, but do not assume it - a slow or long fermentation can be \
genuine.
- Never say a reading is impossible. Beer cannot be denser than water, so \
a gravity below 1.000 is always a mis-entry or a typo, but the rest of the \
run may still be real and worth understanding.
- No preamble, no headings, no bullet-point templates, no "Great question!". \
Write two to five sentences of plain prose.
"""
def _reading_lines(readings: list[dict]) -> str:
"""The readings, as the compact list the model reads."""
lines = []
for r in readings:
parts = [f"- {r.get('date')}: gravity {r.get('sg_decimal')} ({r.get('point_scale')})"]
if r.get("comment"):
parts.append(f"brewer's comment: {r['comment']}")
if r.get("final"):
parts.append("(marked as the FINAL reading)")
lines.append(" ".join(parts))
return "\n".join(lines)
def build_prompt(run: dict, reading: dict, history: list[dict]) -> str:
"""The user-message content for one observation."""
name = run.get("canonical_name") or run.get("name") or "this brew"
og = run.get("og_decimal")
if og is None and run.get("og_points"):
og = f"{run['og_points'] / 1000:.3f}"
parts = [
f"Beer: {name}",
]
if og:
parts.append(f"Original gravity: {og}")
if run.get("tap_number"):
parts.append(f"Fermenter tap: {run['tap_number']}")
if run.get("notes"):
parts.append(f"Brewer's notes: {run['notes']}")
parts.append("")
parts.append("Readings so far (oldest first):")
parts.append(_reading_lines(history))
parts.append("")
parts.append("Give advice for the most recent reading.")
return "\n".join(parts)
def call_ollama(prompt: str, *, url: str | None = None, model: str | None = None,
timeout: float | None = None) -> str:
"""Ask the model for advice. Raises on failure."""
base = (url or DEFAULT_OLLAMA_URL).rstrip("/")
chosen_model = model or DEFAULT_MODEL
wait = timeout if timeout is not None else DEFAULT_TIMEOUT
payload = json.dumps({
"model": chosen_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"temperature": 0.4,
# The reasoning models emit their thinking first, and it comes out
# of the same budget. At 500 tokens the reasoning used the whole
# allowance and the reply came back empty - which looked like a
# broken model rather than a budget problem. 2000 leaves room for
# the reasoning AND the advice.
"max_tokens": 2000,
"stream": False,
}).encode("utf-8")
request = urllib.request.Request(
f"{base}/v1/chat/completions",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=wait) as response:
body = json.loads(response.read().decode("utf-8"))
choices = body.get("choices") or []
if not choices:
raise RuntimeError("the model returned no choices")
content = (choices[0].get("message") or {}).get("content") or ""
content = content.strip()
if not content:
raise RuntimeError("the model returned an empty response")
return content[:MAX_ADVICE_CHARS]
def generate_advice(run: dict, reading: dict, history: list[dict], *,
url: str | None = None, model: str | None = None,
timeout: float | None = None) -> tuple[bool, str]:
"""Generate the advice for one reading.
Returns ``(ok, text)``. On failure ``ok`` is False and ``text`` is a
short note explaining what went wrong, so the reading carries a
visible reason instead of silently having no advice.
"""
prompt = build_prompt(run, reading, history)
try:
return True, call_ollama(prompt, url=url, model=model, timeout=timeout)
except urllib.error.HTTPError as exc:
# HTTPError is a subclass of URLError, so it has to be caught
# FIRST or it would be swallowed by the unreachable branch.
detail = ""
try:
detail = exc.read().decode("utf-8", "replace")[:300]
except Exception:
pass
log.warning("the model returned HTTP %s: %s", exc.code, detail)
return False, (f"Advice could not be generated - the model returned "
f"HTTP {exc.code}. {detail}".strip())
except urllib.error.URLError as exc:
log.warning("the model was unreachable: %s", exc)
return False, "Advice could not be generated - the model was unreachable."
except TimeoutError:
log.warning("the model timed out")
return False, "Advice could not be generated - the model timed out."
except Exception as exc: # noqa: BLE001 - the caller must never crash
log.warning("advice generation failed: %s", exc)
return False, f"Advice could not be generated - {exc}"
def generate_advice_in_background(run: dict, reading: dict,
history: list[dict], writer) -> None:
"""Generate the advice and hand it to *writer* when it is ready.
*writer* is called as ``writer(reading_id, ok, text)``. It runs in a
background thread, so it must not touch the request context - the
caller is responsible for giving it a way to reach Mongo.
"""
ok, text = generate_advice(run, reading, history)
try:
writer(reading.get("_id"), ok, text)
except Exception: # noqa: BLE001 - never let the thread die noisily
log.exception("could not store the generated advice")
+79 -7
View File
@@ -152,6 +152,10 @@ class Reading:
comment: str = "" comment: str = ""
beer_type: str = "" beer_type: str = ""
_id: str = "" _id: str = ""
# The LLM's advice for this reading. It lives in its own field in
# Mongo, so the brewer's own comment is never overwritten.
advice: str = ""
advice_ok: bool = True
@property @property
def sg_decimal(self) -> float | None: def sg_decimal(self) -> float | None:
@@ -171,6 +175,8 @@ class Reading:
comment=(doc.get("comment") or "").strip(), comment=(doc.get("comment") or "").strip(),
beer_type=(doc.get("beer_type") or "").strip(), beer_type=(doc.get("beer_type") or "").strip(),
_id=str(doc.get("_id") or ""), _id=str(doc.get("_id") or ""),
advice=(doc.get("advice") or "").strip(),
advice_ok=bool(doc.get("advice_ok", True)),
) )
@@ -189,18 +195,55 @@ class Run:
# -- aggregate accessors ------------------------------------------------ # # -- aggregate accessors ------------------------------------------------ #
def latest_advice(self) -> str:
"""The advice for the most recent reading that has any.
The advice is generated in the background, so the newest reading
may not have any yet - in which case the newest one that does is
used, rather than showing nothing.
"""
for reading in reversed(self.readings):
if reading.advice:
return reading.advice
return ""
def latest_advice_ok(self) -> bool:
"""False when the advice is a note saying it could not be generated."""
for reading in reversed(self.readings):
if reading.advice:
return reading.advice_ok
return True
def sg_values(self) -> list[int]: def sg_values(self) -> list[int]:
"""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 usable reading of the run.""" """OG = the first *dated* usable reading of the run."""
values = self.sg_values() values = self.dated_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 usable reading of the run (if the run is finished).""" """FG = the last *dated* usable reading of the run (if it 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:
@@ -491,6 +534,11 @@ 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.
@@ -520,6 +568,17 @@ 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({
@@ -537,11 +596,18 @@ 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.
""" """
for reading in run.readings:
if reading.sg_points is not None and reading.sg_points <= target:
first = run.first_reading() first = run.first_reading()
if first is None or first.date is None or reading.date is None: if first is None or first.date is None:
# Without a start date there is no elapsed time to measure.
return None 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:
if reading.sg_points is None or reading.sg_points > target:
continue
if reading.date is None:
continue
return days_between(first.date, reading.date) return days_between(first.date, reading.date)
return None return None
@@ -581,6 +647,10 @@ def run_summary(run: Run, today: date | None = None,
"days_in_fermenter": run.days_in_fermenter(today), "days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(), "comments": run.comments(),
"last_comment": run.comments()[-1] if run.comments() else "", "last_comment": run.comments()[-1] if run.comments() else "",
# The advice the LLM gave for the most recent reading, if it has
# arrived yet.
"advice": run.latest_advice(),
"advice_ok": run.latest_advice_ok(),
"attenuation": attenuation_percent(run), "attenuation": attenuation_percent(run),
"curve": fermentation_curve(run), "curve": fermentation_curve(run),
"outliers": [r.sg_points for r in run.outlier_readings()], "outliers": [r.sg_points for r in run.outlier_readings()],
@@ -592,6 +662,8 @@ def run_summary(run: Run, today: date | None = None,
"final_reading": r.final_reading, "final_reading": r.final_reading,
"comment": r.comment, "comment": r.comment,
"beer_type": r.beer_type, "beer_type": r.beer_type,
"advice": r.advice,
"advice_ok": r.advice_ok,
} }
for r in run.readings for r in run.readings
], ],
+43
View File
@@ -188,6 +188,27 @@ class BeerRepository:
existing = self.readings.find_one(query) existing = self.readings.find_one(query)
return str(existing.get("_id")) if existing else "" return str(existing.get("_id")) if existing else ""
def set_reading_advice(self, reading_id: str, advice: str,
ok: bool = True) -> int:
"""Attach the LLM's advice to a reading.
The advice lives in its own field, so the brewer's own comment is
never overwritten. ``ok`` says whether the advice was actually
generated or is a note explaining that it could not be.
"""
doc_id = _as_doc_id(reading_id)
if doc_id is None:
return 0
result = self.readings.update_one(
{"_id": doc_id},
{"$set": {
"advice": str(advice),
"advice_ok": bool(ok),
"advice_at": _now_iso(),
}},
)
return result.modified_count
def set_final_reading(self, beer_run_id: str, reading_date: str, def set_final_reading(self, beer_run_id: str, reading_date: str,
final: bool) -> int: final: bool) -> int:
"""Flag (or unflag) a reading as the final reading of its run.""" """Flag (or unflag) a reading as the final reading of its run."""
@@ -314,6 +335,28 @@ def _as_id(value: object) -> str:
return "" return ""
def _as_doc_id(value: object):
"""Coerce a reading document id to something Mongo will match.
The ``_id`` of a reading is an ``ObjectId``, not a run id, so the
``_as_id`` coercion does not apply - it would turn the ObjectId into
an empty string and the update would silently match nothing.
Only an ObjectId or a plain string is accepted, so a dict can never
be injected as a query operator.
"""
from bson import ObjectId
if isinstance(value, ObjectId):
return value
if isinstance(value, str):
try:
return ObjectId(value)
except Exception:
return None
return None
def _as_date(value: object) -> str: def _as_date(value: object) -> str:
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string.""" """Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
if isinstance(value, str): if isinstance(value, str):
+62
View File
@@ -689,3 +689,65 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; } .reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
.reading-list__body { flex: 1; min-width: 0; } .reading-list__body { flex: 1; min-width: 0; }
.reading-list__comment { color: var(--text-muted); font-size: .84rem; } .reading-list__comment { color: var(--text-muted); font-size: .84rem; }
/* ------------------------------------------------------------- advice */
/* The LLM's advice. It sits next to the brewer's own comment, never
replaces it, and is visually distinct so the two are never confused. */
.advice {
display: block;
margin-top: .35rem;
padding: .5rem .65rem;
border-left: 2px solid var(--amber);
border-radius: 0 4px 4px 0;
background: var(--amber-dim);
color: var(--text);
font-size: .8125rem;
line-height: 1.5;
}
.advice__label {
display: block;
margin-bottom: .15rem;
font-size: .625rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--amber-text);
}
/* Advice that could not be generated - it says why. */
.advice--failed {
border-left-color: var(--danger);
background: var(--danger-dim);
color: var(--text-muted);
}
.advice--failed .advice__label {
color: var(--danger);
}
.advice-cell {
max-width: 32rem;
}
/* In the table the advice is clamped to a few lines - the full text is
on the run detail page, where there is room to read it. */
.advice-cell .advice {
margin-top: 0;
font-size: .75rem;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* The clamped text opens up on hover, so the whole thing is readable
without leaving the page. */
.advice-cell:hover .advice,
.advice-cell:focus-within .advice {
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
+59 -10
View File
@@ -179,8 +179,7 @@
var canvas = document.getElementById("abv-chart"); var canvas = document.getElementById("abv-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } }) fetchRuns()
.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
@@ -240,8 +239,7 @@
var canvas = document.getElementById("months-chart"); var canvas = document.getElementById("months-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } }) fetchRuns()
.then(function (r) { return r.json(); })
.then(function (runs) { .then(function (runs) {
var counts = {}; var counts = {};
runs.forEach(function (r) { runs.forEach(function (r) {
@@ -302,8 +300,7 @@
var canvas = document.getElementById("attenuation-chart"); var canvas = document.getElementById("attenuation-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } }) fetchRuns()
.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
@@ -384,14 +381,18 @@
var canvas = document.getElementById("curve-chart"); var canvas = document.getElementById("curve-chart");
if (!canvas) { return; } if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } }) fetchRuns()
.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.curve && r.curve.length > 1; return !r.suspect && !r.active &&
r.curve && r.curve.length > 1;
}); });
if (!withCurve.length) { return; } if (!withCurve.length) { return; }
@@ -462,6 +463,7 @@
// 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 = [];
@@ -478,6 +480,7 @@
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,
@@ -492,6 +495,12 @@
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),
@@ -501,6 +510,18 @@
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,
@@ -522,7 +543,12 @@
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: {
@@ -533,6 +559,9 @@
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;
} }
} }
@@ -569,6 +598,26 @@
.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();
+7
View File
@@ -100,6 +100,13 @@
</div> </div>
{% endif %} {% endif %}
{% if run.advice %}
<div class="advice advice--reading{% if not run.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ run.advice }}
</div>
{% endif %}
<div class="btn-row"> <div class="btn-row">
<a class="btn btn--ghost btn--sm" <a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a> href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
+3 -1
View File
@@ -164,7 +164,9 @@
<tbody> <tbody>
{% for row in suspect %} {% for row in suspect %}
<tr> <tr>
<td class="mono">{{ row.beer_run_id }}</td> <td class="mono">
<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>
+8
View File
@@ -83,6 +83,7 @@
<th class="num">Gravity</th> <th class="num">Gravity</th>
<th class="num">Est. ABV</th> <th class="num">Est. ABV</th>
<th>Comment</th> <th>Comment</th>
<th>Brewing advice</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -111,6 +112,13 @@
<span class="faint">—</span> <span class="faint">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="comment advice-cell">
{% if run.advice %}
<span class="advice{% if not run.advice_ok %} advice--failed{% endif %}">{{ run.advice }}</span>
{% else %}
<span class="faint">Thinking…</span>
{% endif %}
</td>
<td class="right"> <td class="right">
<a class="btn btn--ghost btn--sm" <a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a> href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
+6
View File
@@ -144,6 +144,12 @@
{% else %} {% else %}
<div class="faint tiny">No comment</div> <div class="faint tiny">No comment</div>
{% endif %} {% endif %}
{% if reading.advice %}
<div class="advice advice--reading{% if not reading.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ reading.advice }}
</div>
{% endif %}
</div> </div>
{% if reading.final_reading %} {% if reading.final_reading %}
<span class="chip chip--done">Final</span> <span class="chip chip--done">Final</span>
-5
View File
@@ -70,7 +70,6 @@ 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,
@@ -78,9 +77,6 @@ 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"]]
@@ -109,7 +105,6 @@ 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),
+110 -4
View File
@@ -2,15 +2,22 @@
from __future__ import annotations from __future__ import annotations
from flask import (Blueprint, flash, jsonify, redirect, render_template, import logging
request, session, url_for) import os
import threading
from flask import (Blueprint, current_app, flash, jsonify, redirect,
render_template, request, session, url_for)
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField, from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField) SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional from wtforms.validators import DataRequired, Length, NumberRange, Optional
import brew_advice
from brewing import implausible_runs, is_plausible_sg, run_summary from brewing import implausible_runs, is_plausible_sg, run_summary
log = logging.getLogger(__name__)
bp = Blueprint("brews", __name__) bp = Blueprint("brews", __name__)
@@ -34,6 +41,82 @@ def login_required(fn):
return wrapper return wrapper
def advice_enabled() -> bool:
"""Whether the LLM advice should be generated.
It is off in testing, so the suite never reaches out to a model, and
it can be turned off in production with ``LLM_ADVICE=0`` if the model
is misbehaving.
"""
if current_app.config.get("TESTING"):
return False
return os.getenv("LLM_ADVICE", "1").strip().lower() not in {"0", "false", "no"}
def _start_advice(run_id: str, reading_id: str) -> None:
"""Generate the LLM's advice for a reading, in the background.
The advice is a separate field on the reading, so the brewer's own
comment is never touched.
The background thread cannot use the request-scoped repository - that
client is closed when the request ends - so it builds its own.
"""
if not reading_id:
return
if not advice_enabled():
return
try:
repository = repo()
run = repository.run(run_id)
if run is None:
return
meta = repository.run_metadata(run_id)
summary = run_summary(run, og_override=meta.get("og_points"))
run_info = {
"canonical_name": meta.get("name") or run.display_name(),
"name": run.display_name(),
"og_points": summary.get("og_points"),
"og_decimal": summary.get("og_decimal"),
"tap_number": meta.get("tap_number"),
"notes": meta.get("notes", ""),
}
# The model's view of the run: every reading, with the gravity in
# both forms a brewer would recognise.
readings = []
for reading in run.readings:
points = reading.sg_points
readings.append({
"_id": reading._id,
"date": reading.date.isoformat() if reading.date else "",
"sg": points,
"sg_decimal": reading.sg_decimal,
"point_scale": f"{points} points" if points is not None else "",
"comment": reading.comment or "",
"final": bool(reading.final_reading),
})
latest = dict(readings[-1]) if readings else {"date": "", "sg": None}
latest["_id"] = reading_id
except Exception: # noqa: BLE001 - advice must never break the save
log.exception("could not gather the reading for the advice")
return
def write(read_id: str, ok: bool, text: str) -> None:
from mongo.repository import BeerRepository
BeerRepository().set_reading_advice(read_id, text, ok)
thread = threading.Thread(
target=brew_advice.generate_advice_in_background,
args=(run_info, latest, readings, write),
daemon=True,
)
thread.start()
def _last_reading_date(run) -> str | None: def _last_reading_date(run) -> str | None:
"""The ISO date of the most recent reading in a run.""" """The ISO date of the most recent reading in a run."""
last = run.last_reading() last = run.last_reading()
@@ -49,6 +132,10 @@ 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")
@@ -82,13 +169,27 @@ 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:
# Beer cannot be less dense than water, so this is always
# a mis-entry - and a mis-typed "1.012" as "012" is the
# likely cause. It is still saved so the mistake is
# 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( flash(
f"{sg_points} is outside the usual hydrometer range " f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.", "(990-1200). It was saved anyway — double-check the reading.",
"warning", "warning",
) )
repo().upsert_reading( run_id = str(form.beer_run_id.data)
beer_run_id=str(form.beer_run_id.data), reading_id = repo().upsert_reading(
beer_run_id=run_id,
beer_type=(form.beer_run_type.data or "").strip(), beer_type=(form.beer_run_type.data or "").strip(),
sg=sg_points, sg=sg_points,
reading_date=form.Date.data.isoformat(), reading_date=form.Date.data.isoformat(),
@@ -96,6 +197,11 @@ def index():
comment=(form.comment.data or "").strip(), comment=(form.comment.data or "").strip(),
) )
flash("Reading saved.", "success") flash("Reading saved.", "success")
# The advice is generated in the background: recording a reading
# must not block on the model. The page shows the reading straight
# away and the advice is there on a later visit.
_start_advice(run_id, reading_id)
return redirect(url_for("brews.index")) return redirect(url_for("brews.index"))
runs = repo().runs() runs = repo().runs()
+117 -1
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) fermentation_curve, implausible_runs)
from conftest import seed_readings from conftest import seed_readings
@@ -392,3 +392,119 @@ 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
+25 -1
View File
@@ -186,11 +186,16 @@ 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": "950", "sg": "1250",
}, 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
@@ -198,6 +203,25 @@ 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):
+338
View File
@@ -0,0 +1,338 @@
"""Tests for the LLM brewing advice: the prompt, the storage and the display."""
from __future__ import annotations
import json
from datetime import date
import pytest
import brew_advice
from conftest import seed_readings
# ------------------------------------------------------------ the prompt #
def test_the_prompt_gives_the_model_the_brew_and_its_readings():
"""The model needs the name, the OG and every reading."""
run = {
"canonical_name": "Nocturna Christmas beer",
"og_decimal": 1.042,
"tap_number": 3,
"notes": "dark malt, 19C",
}
reading = {"date": "2026-01-10", "sg_decimal": 1.022,
"point_scale": "1022 points", "comment": "no change since day 7",
"final": False}
history = [
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
"comment": "", "final": False},
reading,
]
prompt = brew_advice.build_prompt(run, reading, history)
assert "Nocturna Christmas beer" in prompt
assert "1.042" in prompt
assert "Fermenter tap: 3" in prompt
assert "dark malt" in prompt
# Both readings, oldest first.
assert "2026-01-01" in prompt
assert "2026-01-10" in prompt
assert "no change since day 7" in prompt
assert "1.022" in prompt
def test_the_prompt_tells_the_model_not_to_call_a_reading_impossible():
"""A reading below 1.000 is a mis-entry, but the run may still be real.
This matters: the brewer's own run 12 was a genuine long fermentation
that the app flagged as impossible. The model must not repeat that
mistake - it should point at the odd reading and keep talking about
the brew.
"""
assert "Never say a reading is impossible" in brew_advice.SYSTEM_PROMPT
assert "still be real" in brew_advice.SYSTEM_PROMPT
def test_the_prompt_marks_the_final_reading():
run = {"canonical_name": "Ale"}
reading = {"date": "2026-01-20", "sg_decimal": 1.010, "point_scale": "1010 points",
"comment": "", "final": True}
prompt = brew_advice.build_prompt(run, reading, [reading])
assert "FINAL reading" in prompt
# ---------------------------------------------------------- the LLM call #
def test_a_successful_call_returns_the_text(monkeypatch):
"""A normal reply comes back as the advice."""
captured = {}
def fake_urlopen(request, timeout=None):
captured["url"] = request.full_url
captured["body"] = json.loads(request.data.decode())
class Response:
def read(self):
return json.dumps({
"choices": [{"message": {"content": " Leave it alone, it is still fermenting. "}}]
}).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
return Response()
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", fake_urlopen)
ok, text = brew_advice.generate_advice(
{"canonical_name": "Ale"},
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
"comment": "", "final": False},
[{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
"comment": "", "final": False}],
model="glm-5.3:cloud",
)
assert ok is True
assert text == "Leave it alone, it is still fermenting."
assert captured["url"].endswith("/v1/chat/completions")
assert captured["body"]["model"] == "glm-5.3:cloud"
def test_an_unreachable_model_does_not_raise(monkeypatch):
"""A failure is recorded, not raised.
The reading has to be saved either way, and a silent failure would be
indistinguishable from "the advice is still being generated".
"""
def boom(request, timeout=None):
raise brew_advice.urllib.error.URLError("connection refused")
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", boom)
ok, text = brew_advice.generate_advice(
{"canonical_name": "Ale"},
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
"comment": "", "final": False},
[],
)
assert ok is False
assert "could not be generated" in text
def test_an_empty_reply_is_a_failure(monkeypatch):
"""A model that returns nothing has not given advice."""
class Response:
def read(self):
return json.dumps({"choices": [{"message": {"content": " "}}]}).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
monkeypatch.setattr(brew_advice.urllib.request, "urlopen",
lambda request, timeout=None: Response())
ok, text = brew_advice.generate_advice({"canonical_name": "Ale"},
{"date": "2026-01-01"}, [])
assert ok is False
assert "could not be generated" in text
def test_the_token_budget_leaves_room_for_the_reasoning(monkeypatch):
"""The reasoning models emit their thinking first, out of the same budget.
At 500 tokens the reasoning used the whole allowance and the reply
came back EMPTY - which looks like a broken model rather than a
budget problem. The budget has to cover the reasoning AND the advice.
"""
captured = {}
def fake_urlopen(request, timeout=None):
captured["body"] = json.loads(request.data.decode())
class Response:
def read(self):
return json.dumps({
"choices": [{"message": {"content": "Rouse the yeast."}}]
}).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
return Response()
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", fake_urlopen)
brew_advice.generate_advice({"canonical_name": "Ale"},
{"date": "2026-01-01"}, [])
assert captured["body"]["max_tokens"] >= 2000
def test_the_advice_is_truncated():
"""A runaway reply must not fill the reading document."""
long_text = "x" * 5000
class Response:
def read(self):
return json.dumps({"choices": [{"message": {"content": long_text}}]}).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
import brew_advice as ba
original = ba.urllib.request.urlopen
ba.urllib.request.urlopen = lambda request, timeout=None: Response()
try:
ok, text = ba.generate_advice({"canonical_name": "Ale"},
{"date": "2026-01-01"}, [])
finally:
ba.urllib.request.urlopen = original
assert ok is True
assert len(text) <= ba.MAX_ADVICE_CHARS
# ------------------------------------------------------------- storage #
def test_the_advice_is_stored_in_its_own_field(repo):
"""The brewer's comment is never overwritten."""
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my own comment")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "The advice", ok=True)
updated = repo.readings.find_one({"beer_run_id": "1"})
assert updated["advice"] == "The advice"
assert updated["advice_ok"] is True
# The brewer's own comment is untouched.
assert updated["comment"] == "my own comment"
def test_a_failed_generation_is_stored_as_a_failure(repo):
"""The note says it could not be generated, and is flagged as such."""
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Advice could not be generated.", ok=False)
updated = repo.readings.find_one({"beer_run_id": "1"})
assert updated["advice_ok"] is False
def test_the_latest_advice_comes_from_the_newest_reading_with_one():
"""The advice for the newest reading wins.
The advice is generated in the background, so the newest reading may
not have one yet - in which case the newest that does is used.
"""
from brewing import Run
run = Run.from_docs("1", [
{"_id": "a", "beer_run_id": "1", "sg": "1042", "date": "2026-01-01",
"final_reading": "False", "comment": "", "advice": "first advice"},
{"_id": "b", "beer_run_id": "1", "sg": "1030", "date": "2026-01-05",
"final_reading": "False", "comment": "", "advice": "second advice"},
{"_id": "c", "beer_run_id": "1", "sg": "1020", "date": "2026-01-10",
"final_reading": "False", "comment": "", "advice": ""},
])
assert run.latest_advice() == "second advice"
assert run.latest_advice_ok() is True
# ------------------------------------------------------------- the pages #
def test_recording_a_reading_saves_it_even_when_the_model_is_down(client, repo):
"""The model being down must never stop a reading being saved."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
response = client.post("/brews", data={
"beer_run_id": "99",
"beer_run_type": "Test ale",
"Date": "2026-01-01",
"sg": "1042",
"comment": "a comment",
}, follow_redirects=True)
assert response.status_code == 200
assert repo.readings.count_documents({"beer_run_id": "99"}) == 1
def test_the_advice_is_shown_on_the_brewing_page(client, repo):
"""The advice appears next to the comment, clearly labelled."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my comment")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Rouse the yeast and wait.", ok=True)
response = client.get("/brews")
body = response.get_data(as_text=True)
assert "Rouse the yeast and wait." in body
# The brewer's own comment is still there.
assert "my comment" in body
# And it is labelled as advice, so the two are not confused.
assert "Brewing advice" in body
def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
"""The active page is where the brewer looks during a fermentation."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my comment")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True)
response = client.get("/active")
body = response.get_data(as_text=True)
assert "Warm it to 21C and wait." in body
assert "Brewing advice" in body
def test_a_failed_advice_is_marked_as_such(client, repo):
"""A generation failure reads differently from real advice."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Advice could not be generated.",
ok=False)
response = client.get("/brews")
body = response.get_data(as_text=True)
assert "advice--failed" in body
def test_advice_is_not_generated_in_tests(client, repo):
"""The suite must never reach out to a real model."""
from views.brews import advice_enabled
with client.application.app_context():
assert advice_enabled() is False