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 65ec584..8332f01 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] @@ -622,6 +647,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()], @@ -633,6 +662,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 %} + + {% if run.advice %} + {{ run.advice }} + {% else %} + Thinking… + {% 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 e1c4d30..7aea1c6 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() @@ -104,8 +187,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(), @@ -113,6 +197,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