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.
This commit is contained in:
2026-11-09 16:03:29 +10:00
parent 41cf2cbb8e
commit ab3050194d
9 changed files with 799 additions and 4 deletions
+93 -4
View File
@@ -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()