The editing token catches every edit the application makes, because upsert_reading writes a fresh token on every write. A document edited outside the application - by a script, or by hand - keeps its empty token, so a stale thread's write would still match on the gravity alone and could put advice about the old comment back. The advice is generated from the comment as well as the gravity, so the comment is now a second, independent guard on the write. A stale write can only land if BOTH the gravity and the comment are unchanged. The stored comment and the one the view sees can differ by trailing whitespace - two of the live readings are padded - so the match is a stripped, anchored regex rather than an exact one. Also: the log message said the reading 'no longer holds the gravity it was written for', but the guard is the token and the comment. It now says what actually happened. 194 tests pass.
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""The brew-process pages: record readings and review the runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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__)
|
|
|
|
|
|
def repo():
|
|
"""The request-scoped repository (one Mongo client per request)."""
|
|
from flask import current_app
|
|
|
|
return current_app.extensions["beer_repo"]()
|
|
|
|
|
|
def login_required(fn):
|
|
"""Redirect to the login page when there is no session."""
|
|
from functools import wraps
|
|
|
|
@wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
if not session.get("logged_in"):
|
|
return redirect(url_for("auth.login", next=request.path))
|
|
return fn(*args, **kwargs)
|
|
|
|
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, corrected: bool = False) -> 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 uses the shared
|
|
process-wide one.
|
|
"""
|
|
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": str(reading._id),
|
|
"advice_token": reading.advice_token,
|
|
"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),
|
|
})
|
|
|
|
# The reading that is actually being advised about. It is not
|
|
# necessarily the newest one: the brewer may be editing an older
|
|
# reading, and advice about the newest reading would be about the
|
|
# wrong number.
|
|
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
|
|
if saved is None:
|
|
# The reading is not in the run. Advising about the newest
|
|
# reading instead would describe the wrong number, and the
|
|
# conditional write would then refuse it anyway - so it is
|
|
# better to say so plainly and stop.
|
|
log.info(
|
|
"no advice for reading %s: it is not one of the run's readings",
|
|
reading_id,
|
|
)
|
|
return
|
|
latest = dict(saved)
|
|
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
|
|
|
|
# The gravity and the editing token of the reading being advised
|
|
# about. The write is conditional on both, so a slow advice thread
|
|
# cannot overwrite a correction with advice about the old value.
|
|
advice_sg = latest.get("sg")
|
|
advice_token = latest.get("advice_token")
|
|
# The comment is part of what the advice describes, so it is a
|
|
# second, independent guard on the write. The token covers every
|
|
# edit the application makes; this also covers a document edited
|
|
# outside it.
|
|
advice_comment = latest.get("comment") or ""
|
|
|
|
def write(read_id: str, ok: bool, text: str) -> None:
|
|
# The client is SHARED, not built per reading. A MongoClient
|
|
# spawns three monitor threads and pymongo does not reap them
|
|
# on close(), so building one per advice write accumulated three
|
|
# threads and a connection pool for every reading recorded.
|
|
#
|
|
# The shared client is created on first use and lives for the
|
|
# life of the process, which is what the request-scoped
|
|
# repository does too.
|
|
from mongo.repository import get_shared_repository
|
|
|
|
try:
|
|
shared = get_shared_repository()
|
|
written = shared.set_reading_advice(
|
|
read_id, text, ok,
|
|
expected_sg=advice_sg,
|
|
expected_token=advice_token,
|
|
expected_comment=advice_comment,
|
|
)
|
|
if not written:
|
|
# The reading was corrected while the advice was being
|
|
# generated, so the advice no longer matches it. That is
|
|
# the guard working - the fresh advice for the corrected
|
|
# reading will land in its own turn.
|
|
log.info(
|
|
"the advice for reading %s was not stored: the reading "
|
|
"has been edited since the advice was generated, so its "
|
|
"editing token or comment no longer matches the advice",
|
|
read_id,
|
|
)
|
|
except Exception: # noqa: BLE001 - never let the thread die noisily
|
|
log.exception("could not store the generated advice")
|
|
|
|
thread = threading.Thread(
|
|
target=brew_advice.generate_advice_in_background,
|
|
args=(run_info, latest, readings, write, corrected),
|
|
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()
|
|
if last is None or last.date is None:
|
|
return None
|
|
return last.date.isoformat()
|
|
|
|
|
|
class ReadingForm(FlaskForm):
|
|
"""Add or update a gravity reading."""
|
|
|
|
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
|
|
beer_run_type = StringField("Beer / cider name",
|
|
validators=[Optional(), Length(max=120)])
|
|
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)",
|
|
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
|
final_reading = BooleanField("This is the final reading")
|
|
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
|
submit = SubmitField("Save reading")
|
|
|
|
|
|
class RunMetaForm(FlaskForm):
|
|
"""Edit the canonical metadata of a run.
|
|
|
|
These are the details that cannot be derived from the readings: the name
|
|
the brew is actually known by, which tap it is on, and its original
|
|
gravity.
|
|
"""
|
|
|
|
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
|
|
tap_number = IntegerField("Tap number",
|
|
validators=[Optional(), NumberRange(min=1, max=99)])
|
|
og_points = IntegerField("Original gravity (points)",
|
|
validators=[Optional(), NumberRange(min=900, max=1500)])
|
|
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
|
submit = SubmitField("Save run")
|
|
|
|
|
|
@bp.route("/brews", methods=["GET", "POST"])
|
|
@login_required
|
|
def index():
|
|
"""The brew-process page: record a reading, then review the runs."""
|
|
form = ReadingForm()
|
|
|
|
if form.validate_on_submit():
|
|
sg_points = int(form.sg.data)
|
|
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(
|
|
f"{sg_points} is outside the usual hydrometer range "
|
|
"(990-1200). It was saved anyway — double-check the reading.",
|
|
"warning",
|
|
)
|
|
run_id = str(form.beer_run_id.data)
|
|
# Whether the reading was already there: a re-submitted reading
|
|
# is a CORRECTION, and the advice should say so.
|
|
existing = repo().reading_docs_for_run(run_id)
|
|
was_existing = any(
|
|
str(doc.get("date")) == form.Date.data.isoformat()
|
|
for doc in existing
|
|
)
|
|
|
|
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(),
|
|
final_reading=bool(form.final_reading.data),
|
|
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, corrected=was_existing)
|
|
return redirect(url_for("brews.index"))
|
|
|
|
runs = repo().runs()
|
|
metadata = repo().all_run_metadata()
|
|
|
|
summaries = []
|
|
for run in runs:
|
|
meta = metadata.get(run.beer_run_id, {})
|
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
|
summary["tap_number"] = meta.get("tap_number")
|
|
summary["notes"] = meta.get("notes", "")
|
|
summary["last_date"] = _last_reading_date(run)
|
|
summaries.append(summary)
|
|
|
|
# Active runs: most recent activity first — that is what is fermenting now.
|
|
active = sorted([s for s in summaries if s["active"]],
|
|
key=lambda s: s["last_date"] or "", reverse=True)
|
|
# Finished runs: most recently finished first.
|
|
completed = sorted([s for s in summaries if not s["active"]],
|
|
key=lambda s: s["final_date"] or "", reverse=True)
|
|
|
|
return render_template(
|
|
"brews.html",
|
|
form=form,
|
|
summaries=summaries,
|
|
active_runs=active,
|
|
completed_runs=completed,
|
|
next_tap=repo().next_free_tap_number(),
|
|
)
|
|
|
|
|
|
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
|
|
@login_required
|
|
def run_detail(run_id: str):
|
|
"""One run in detail, with its readings and its editable metadata."""
|
|
run = repo().run(run_id)
|
|
if run is None:
|
|
flash(f"No run numbered {run_id}.", "error")
|
|
return redirect(url_for("brews.index"))
|
|
|
|
meta = repo().run_metadata(run_id)
|
|
meta_form = RunMetaForm()
|
|
|
|
if meta_form.validate_on_submit():
|
|
repo().upsert_run_metadata(
|
|
run_id,
|
|
name=(meta_form.name.data or "").strip() or None,
|
|
tap_number=meta_form.tap_number.data,
|
|
og_points=meta_form.og_points.data,
|
|
notes=(meta_form.notes.data or "").strip() or None,
|
|
)
|
|
flash("Run details saved.", "success")
|
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|
|
|
|
if not meta_form.is_submitted():
|
|
meta_form.name.data = meta.get("name") or run.display_name()
|
|
meta_form.tap_number.data = meta.get("tap_number")
|
|
meta_form.og_points.data = meta.get("og_points")
|
|
meta_form.notes.data = meta.get("notes", "")
|
|
|
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
|
summary["tap_number"] = meta.get("tap_number")
|
|
summary["notes"] = meta.get("notes", "")
|
|
|
|
return render_template("run_detail.html", run=run, summary=summary,
|
|
meta_form=meta_form, meta=meta)
|
|
|
|
|
|
@bp.route("/api/runs")
|
|
@login_required
|
|
def api_runs():
|
|
"""JSON feed of the run summaries, used by the charts on the analysis
|
|
pages.
|
|
|
|
The charts fetch this rather than having the data inlined into the page,
|
|
so one fetch serves every chart on a page.
|
|
"""
|
|
runs = repo().runs()
|
|
metadata = repo().all_run_metadata()
|
|
|
|
# The runs whose readings are physically impossible. They are flagged
|
|
# rather than dropped: the charts use the flag to leave them out of the
|
|
# averages and the curve envelope, but a caller can still see them.
|
|
#
|
|
# The flag is computed over EVERY run, not just the finished ones, so
|
|
# it agrees with what the charts do. An active run with a bad reading
|
|
# is still a run with a bad reading.
|
|
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
|
|
|
payload = []
|
|
for run in runs:
|
|
meta = metadata.get(run.beer_run_id, {})
|
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
|
summary["tap_number"] = meta.get("tap_number")
|
|
summary["last_date"] = _last_reading_date(run)
|
|
summary["suspect"] = run.beer_run_id in suspect_ids
|
|
payload.append(summary)
|
|
|
|
return jsonify(payload)
|
|
|
|
|
|
@bp.route("/api/runs/<run_id>")
|
|
@login_required
|
|
def api_run(run_id: str):
|
|
"""JSON detail of a single run."""
|
|
run = repo().run(run_id)
|
|
if run is None:
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
meta = repo().run_metadata(run_id)
|
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
|
summary["tap_number"] = meta.get("tap_number")
|
|
# The same flag the list endpoint carries, so a consumer of the detail
|
|
# endpoint can tell an impossible run from a good one.
|
|
summary["suspect"] = bool(implausible_runs([run]))
|
|
summary["active"] = run.is_active()
|
|
return jsonify(summary)
|
|
|
|
|
|
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
|
@login_required
|
|
def mark_final(run_id: str):
|
|
"""Flag a reading as the final one for its run."""
|
|
reading_date = request.form.get("date", "")
|
|
if not reading_date:
|
|
flash("No reading date supplied.", "error")
|
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|
|
|
|
changed = repo().set_final_reading(run_id, reading_date, True)
|
|
if changed:
|
|
flash("Final reading recorded.", "success")
|
|
else:
|
|
flash("That reading could not be updated.", "error")
|
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|
|
|
|
|
|
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
|
|
@login_required
|
|
def unfinal(run_id: str):
|
|
"""Un-flag a final reading, putting the run back into the active list."""
|
|
reading_date = request.form.get("date", "")
|
|
if not reading_date:
|
|
flash("No reading date supplied.", "error")
|
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|
|
|
|
repo().set_final_reading(run_id, reading_date, False)
|
|
flash("Reading is no longer marked final.", "info")
|
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|