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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user