diff --git a/src/flask/brew_advice.py b/src/flask/brew_advice.py
new file mode 100644
index 0000000..222c21a
--- /dev/null
+++ b/src/flask/brew_advice.py
@@ -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")
diff --git a/src/flask/brewing.py b/src/flask/brewing.py
index 32126bd..8a01ee2 100644
--- a/src/flask/brewing.py
+++ b/src/flask/brewing.py
@@ -152,6 +152,10 @@ class Reading:
comment: str = ""
beer_type: 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
def sg_decimal(self) -> float | None:
@@ -171,6 +175,8 @@ class Reading:
comment=(doc.get("comment") or "").strip(),
beer_type=(doc.get("beer_type") or "").strip(),
_id=str(doc.get("_id") or ""),
+ advice=(doc.get("advice") or "").strip(),
+ advice_ok=bool(doc.get("advice_ok", True)),
)
@@ -189,6 +195,25 @@ class Run:
# -- 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]:
"""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]
@@ -581,6 +606,10 @@ def run_summary(run: Run, today: date | None = None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"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),
"curve": fermentation_curve(run),
"outliers": [r.sg_points for r in run.outlier_readings()],
@@ -592,6 +621,8 @@ def run_summary(run: Run, today: date | None = None,
"final_reading": r.final_reading,
"comment": r.comment,
"beer_type": r.beer_type,
+ "advice": r.advice,
+ "advice_ok": r.advice_ok,
}
for r in run.readings
],
diff --git a/src/flask/mongo/repository.py b/src/flask/mongo/repository.py
index 7f74d7f..0ca5649 100644
--- a/src/flask/mongo/repository.py
+++ b/src/flask/mongo/repository.py
@@ -188,6 +188,27 @@ class BeerRepository:
existing = self.readings.find_one(query)
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,
final: bool) -> int:
"""Flag (or unflag) a reading as the final reading of its run."""
@@ -314,6 +335,28 @@ def _as_id(value: object) -> str:
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:
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
if isinstance(value, str):
diff --git a/src/flask/static/css/app.css b/src/flask/static/css/app.css
index 6bc8a98..f99a72f 100644
--- a/src/flask/static/css/app.css
+++ b/src/flask/static/css/app.css
@@ -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__body { flex: 1; min-width: 0; }
.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;
+}
diff --git a/src/flask/templates/active.html b/src/flask/templates/active.html
index d100863..3180111 100644
--- a/src/flask/templates/active.html
+++ b/src/flask/templates/active.html
@@ -100,6 +100,13 @@
{% endif %}
+ {% if run.advice %}
+
+ Brewing advice
+ {{ run.advice }}
+
+ {% endif %}
+
Open run
diff --git a/src/flask/templates/brews.html b/src/flask/templates/brews.html
index 526b0fa..2ee2efa 100644
--- a/src/flask/templates/brews.html
+++ b/src/flask/templates/brews.html
@@ -83,6 +83,7 @@
Gravity |
Est. ABV |
Comment |
+
Brewing advice |
|
@@ -111,6 +112,13 @@
—
{% endif %}
+
Open
diff --git a/src/flask/templates/run_detail.html b/src/flask/templates/run_detail.html
index f1b2163..8127e85 100644
--- a/src/flask/templates/run_detail.html
+++ b/src/flask/templates/run_detail.html
@@ -144,6 +144,12 @@
{% else %}
No comment
{% endif %}
+ {% if reading.advice %}
+
+ Brewing advice
+ {{ reading.advice }}
+
+ {% endif %}
{% if reading.final_reading %}
Final
diff --git a/src/flask/views/brews.py b/src/flask/views/brews.py
index 02a24c1..b6402ce 100644
--- a/src/flask/views/brews.py
+++ b/src/flask/views/brews.py
@@ -2,15 +2,22 @@
from __future__ import annotations
-from flask import (Blueprint, flash, jsonify, redirect, render_template,
- request, session, url_for)
+import logging
+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 wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional
+import brew_advice
from brewing import implausible_runs, is_plausible_sg, run_summary
+log = logging.getLogger(__name__)
+
bp = Blueprint("brews", __name__)
@@ -34,6 +41,82 @@ def login_required(fn):
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:
"""The ISO date of the most recent reading in a run."""
last = run.last_reading()
@@ -87,8 +170,9 @@ def index():
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
- repo().upsert_reading(
- beer_run_id=str(form.beer_run_id.data),
+ 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(),
sg=sg_points,
reading_date=form.Date.data.isoformat(),
@@ -96,6 +180,11 @@ def index():
comment=(form.comment.data or "").strip(),
)
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"))
runs = repo().runs()
diff --git a/tests/test_brew_advice.py b/tests/test_brew_advice.py
new file mode 100644
index 0000000..7d807c3
--- /dev/null
+++ b/tests/test_brew_advice.py
@@ -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
|