feat(advice): LLM brewing advice on every observation (#14)
Build and Push Image / Build and push image (push) Successful in 9m8s

feat(advice): LLM brewing advice on every observation (#14)
This commit was merged in pull request #14.
This commit is contained in:
2026-11-09 20:23:32 +10:00
9 changed files with 1850 additions and 5 deletions
+368
View File
@@ -0,0 +1,368 @@
"""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 re
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")
#: The model to use. Override with the ``OLLAMA_MODEL`` environment
#: variable.
#:
#: A model that does NOT reason is much better here. The reasoning
#: models emit their thinking out of the same token allowance as the
#: answer, and several of them burned the whole budget on thinking and
#: returned an EMPTY reply - which looks like a broken model rather than
#: a budget problem. With `think: false` the model still shows its
#: working, but inline in the content, so the answer can be taken from
#: the end of it.
#:
#: `deepseek-v4-flash:cloud`, `deepseek-v3.2:cloud` and `qwen3.5:cloud`
#: are all retired and fail at call time while still appearing in the
#: model list, so the default is one that was probed.
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "mistral-large-4: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", "120"))
#: 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:
- Your reply must have this exact shape:
ADVICE:
<the advice itself, two to five sentences of plain prose>
The line "ADVICE:" comes FIRST, on its own line, with nothing before
it. Do not explain your reasoning and do not show your working.
- Advise about the reading that is marked as the one to advise on. It
may not be the most recent one - the brewer may be correcting an older
reading - and the advice must be about the reading they asked about.
- 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], subject_id: str | None = None) -> str:
"""The readings, as the compact list the model reads.
The reading being advised about is marked, so the model does not have
to work out which one from the dates - the brewer may be correcting
an older reading.
"""
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)")
if subject_id is not None and str(r.get("_id")) == str(subject_id):
parts.append("<<< THIS IS THE READING TO ADVISE ON")
lines.append(" ".join(parts))
return "\n".join(lines)
def build_prompt(run: dict, reading: dict, history: list[dict],
corrected: bool = False) -> str:
"""The user-message content for one observation.
*corrected* says the reading has just been edited, so the advice
should take that into account.
"""
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, subject_id=reading.get("_id")))
parts.append("")
# The reading being advised about is NAMED, not just described as
# "the most recent one". The brewer may be editing an older reading,
# and the advice is about THAT reading - so the prompt has to say
# which one, or the model advises about the newest instead.
parts.append(
f"Give advice about the reading dated {reading.get('date') or 'unknown'}"
f" (gravity {reading.get('sg_decimal')}),"
+ (" which the brewer has just corrected." if corrected else "")
)
if corrected:
parts.append(
"It is the reading they have just corrected, so it may differ "
"from what it said before."
)
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 as the answer. 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.
#
# `think: false` does NOT help: the models still think, they just
# put the working in the content instead of a separate field.
# So the budget has to cover the reasoning AND the advice.
"max_tokens": 8000,
"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")
message = choices[0].get("message") or {}
# Some models put their working in a separate field. It is ignored -
# the advice is the content.
content = message.get("content") or ""
content = content.strip()
if not content:
raise RuntimeError("the model returned an empty response")
# A reply cut off at the token ceiling is INCOMPLETE. It may be
# advice that stops mid-sentence, so it is marked as truncated rather
# than stored as if it were the whole answer.
finish_reason = choices[0].get("finish_reason") or body.get("done_reason")
truncated = finish_reason == "length"
advice = _trim_advice(_strip_reasoning(content))
if truncated:
advice = _trim_advice(advice + " (the reply was cut off)")
return advice, truncated
def _trim_advice(text: str) -> str:
"""Cut the advice down to the readable length, keeping the END.
The advice is at the end of the reply, so a plain truncation from the
front would cut the advice off and leave only the model's working -
which is worse than no advice at all, because it looks like advice.
So an over-long reply is cut at a sentence boundary near the end.
"""
if len(text) <= MAX_ADVICE_CHARS:
return text
tail = text[-MAX_ADVICE_CHARS:]
# Start at the first sentence break in the tail, so it does not begin
# mid-word.
for separator in (". ", "? ", "! "):
index = tail.find(separator)
if index != -1 and index < 200:
return tail[index + 2:].strip()
return tail.strip()
#: The marker the model is asked to put at the start of its advice.
#:
#: Guessing where the advice begins by classifying prose is unreliable
#: in both directions - it emptied legitimate advice, and it let the
#: model's working through in full. A marker the model writes itself is
#: deterministic: everything before it is working, everything from it on
#: is the advice.
_ADVICE_MARKER = "ADVICE:"
def _strip_reasoning(text: str) -> str:
"""Take the advice out of a reply.
The model is asked to put ``ADVICE:`` on its own line at the start
of the advice, so the working before it can be dropped exactly, and
the advice kept exactly.
A reply with no marker is used as it stands. That is the safe
direction: the model was asked for the marker, so a reply without
one is one where it wrote the advice straight away - and if the
working did leak in, the brewer sees a slightly rambling reply
rather than no advice at all.
"""
index = _find_marker(text)
if index is None:
return text.strip()
advice = text[index:].strip()
# The model may wrap the marker in markdown or a code block. Strip
# the leftover formatting so the advice reads as prose.
advice = re.sub(r"^\*\*|\*\*$", "", advice).strip()
if advice.startswith("```"):
advice = advice[3:]
if advice.endswith("```"):
advice = advice[:-3]
if advice.startswith("##"):
advice = advice.lstrip("#").strip()
return advice.strip() or ""
def _find_marker(text: str) -> int | None:
"""Where the advice starts, or ``None`` if there is no marker.
The marker is matched case-insensitively and with the markdown
decoration the model might put around it - ``**ADVICE:**``,
``## ADVICE:`` - because the instruction asks for "ADVICE:" but the
model formats it its own way often enough to matter.
"""
match = re.search(r"^[\s#>\*]*advice\s*:[\s\*>]*", text,
re.IGNORECASE | re.MULTILINE)
if match:
return match.end()
return None
def generate_advice(run: dict, reading: dict, history: list[dict], *,
corrected: bool = False,
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, corrected=corrected)
try:
advice, _truncated = call_ollama(prompt, url=url, model=model,
timeout=timeout)
if not advice:
# The reply had no advice in it - the model wrote only its
# working, or the marker was missing and nothing followed it.
# Storing an empty string with advice_ok=True would render
# as "Thinking..." for ever, so it is recorded as a failure
# with a note the brewer can see.
log.warning("the model returned no advice")
return False, ("Advice could not be generated - the model "
"returned only its reasoning.")
return True, advice
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,
corrected: bool = False) -> 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, corrected=corrected)
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")
+36
View File
@@ -152,6 +152,14 @@ 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
# Marks this generation of the reading. Every edit writes a fresh
# one, so an advice thread working on an earlier version of the
# reading can be told apart from the current one.
advice_token: str = ""
@property
def sg_decimal(self) -> float | None:
@@ -171,6 +179,9 @@ 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)),
advice_token=str(doc.get("advice_token") or ""),
)
@@ -189,6 +200,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 +652,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 +667,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
],
+194 -1
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import os
import threading
from typing import Any
from pymongo import MongoClient
@@ -46,6 +47,33 @@ class BeerRepository:
self.run_meta = self.db["beer_runs"]
self._ensure_indexes()
def close(self) -> None:
"""Close the underlying Mongo client and release it.
A ``MongoClient`` keeps a connection pool and spawns three
monitoring threads. ``close()`` alone does NOT reap them - they
go when the client object is garbage collected - so the
reference is dropped as well.
The request-scoped repository is closed by the app's teardown, but
a caller that builds its own - the advice background thread, for
instance - has to close it itself or the threads and the pool
accumulate, three per reading.
"""
client = self.client
try:
client.close()
except Exception:
pass
# Drop the reference so the monitor threads can be reaped.
self.client = None
def __enter__(self) -> "BeerRepository":
return self
def __exit__(self, *exc_info) -> None:
self.close()
def _ensure_indexes(self) -> None:
"""Make sure the lookup keys are indexed.
@@ -180,14 +208,96 @@ class BeerRepository:
"date": _as_date(reading_date),
"final_reading": "True" if final_reading else "False",
"comment": str(comment),
}
},
# The advice was written for the reading that was there when
# it was generated. If the reading has been edited, that
# advice no longer describes it, so it is cleared and a fresh
# one is generated. (Advice is only ever cleared here - never
# the brewer's comment.)
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""},
}
# A fresh token marks this generation of the reading. The advice
# thread carries the token it was given, so a thread that is
# still working on an EARLIER version of the reading finds a
# token that no longer matches and its write is refused. The
# gravity alone is not enough - a comment-only correction leaves
# the gravity unchanged.
update["$set"]["advice_token"] = _new_token()
result = self.readings.update_one(query, update, upsert=True)
if result.upserted_id is not None:
return str(result.upserted_id)
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,
expected_sg: int | None = None,
expected_token: str | None = None) -> 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.
The write is CONDITIONAL, so a slow advice thread cannot
overwrite a correction:
- ``expected_token`` is the editing token of the reading at the
moment the advice was generated. Every edit writes a fresh
token, so a late thread's token no longer matches.
- ``expected_sg`` is the gravity the advice was generated for,
as a second check.
This matters because the advice is generated in the background: if
the brewer corrects a reading while the advice for the old value
is still being generated, the slow thread would land afterwards
and overwrite the fresh advice with advice about a reading that
no longer exists.
The token is needed as well as the gravity, because a
comment-only correction leaves the gravity unchanged - the token
is what tells the two generations apart.
"""
doc_id = _as_doc_id(reading_id)
if not doc_id:
return 0
query: dict = {"_id": {"$in": doc_id}}
if expected_token:
# The editing token is the PRIMARY guard: it changes on every
# edit, so it catches a stale generation whatever else
# changed - the gravity, the comment, or nothing.
query["advice_token"] = str(expected_token)
elif expected_token == "":
# The reading has NEVER been edited, so it has no token at
# all. Match on the ABSENCE of one, which still tells the
# generations apart: an edit writes a token, so a stale
# thread's "" stops matching the moment the reading is
# corrected.
#
# Falling back to the gravity here would re-open the hole
# this guard exists to close - a comment-only correction
# leaves the gravity unchanged.
# `$in` with null also matches a MISSING field, so this
# covers a reading with no token AND one whose token field
# is present but empty.
query["advice_token"] = {"$in": [None, ""]}
elif expected_sg is not None:
# A direct call with a gravity to check against.
query["sg"] = str(expected_sg)
# `$in` matches either form. A plain list would be an exact
# ARRAY match in Mongo, which is not what is wanted here.
result = self.readings.update_one(
query,
{"$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 +424,82 @@ def _as_id(value: object) -> str:
return ""
#: A process-wide repository, for the code that runs outside a request.
#:
#: A ``MongoClient`` spawns three monitor threads and pymongo does not
#: reap them on ``close()`` - they go only when the client is garbage
#: collected. So building a client per advice write accumulated three
#: threads and a connection pool for every reading recorded. One shared
#: client for the life of the process is what the request-scoped
#: repository does too.
_shared_repository: "BeerRepository | None" = None
#: Guards the check-then-set above.
#:
#: Without it, concurrent advice threads each saw ``None`` and each
#: built their own repository - so the fix reintroduced the very leak it
#: was meant to remove, just bounded by a race instead of by the number
#: of readings. Eight threads on a cold process built eight clients and
#: twenty-five threads.
_shared_repository_lock = threading.Lock()
def get_shared_repository() -> "BeerRepository":
"""The process-wide repository, built on first use.
Thread-safe: the first caller builds it, the rest wait and get the
same one.
"""
global _shared_repository
if _shared_repository is None:
with _shared_repository_lock:
# Checked again inside the lock - another thread may have
# built it while this one was waiting.
if _shared_repository is None:
_shared_repository = BeerRepository()
return _shared_repository
def _as_doc_id(value: object):
"""Coerce a reading document id to something Mongo will match.
The ``_id`` of a reading is not a run id, so the ``_as_id`` coercion
does not apply - it would turn the id into an empty string and the
update would silently match nothing.
Two shapes exist in this database:
- the ORIGINAL app's ids are 24 random CHARACTERS stored as a string
(``l78ZidBJ3QaYn98tvB88x4uM``), and
- ids written by Mongo itself are ``ObjectId`` objects.
So the id is returned in BOTH forms and the query matches either.
Accepting only the ObjectId form would mean the advice was never
stored for any of the existing readings.
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) and value:
try:
# A 24-character hex string may or may not be the stored
# ObjectId or the stored string, so try both.
return [ObjectId(value), value]
except Exception:
# Not hex - it can only be the original app's string id.
return [value]
# Anything else - a dict, a list, an int, None - is not a reading
# id. An EMPTY LIST is returned rather than None: it is a valid
# `$in` argument that matches nothing, so the helper cannot be
# misused into a query error or into matching every document.
return []
def _as_date(value: object) -> str:
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
if isinstance(value, str):
@@ -336,6 +522,13 @@ def hash_password(password: str) -> str:
return generate_password_hash(password)
def _new_token() -> str:
"""A fresh token, to mark one generation of a reading from the next."""
import secrets
return secrets.token_hex(8)
def _now_iso() -> str:
from datetime import datetime, timezone
+68
View File
@@ -689,3 +689,71 @@ 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);
}
/* The advice on the active-beers card and the run detail page - the
full text, not the clamped table cell. */
.advice--reading {
margin-top: .5rem;
}
/* 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;
}
+7
View File
@@ -100,6 +100,13 @@
</div>
{% endif %}
{% if run.advice %}
<div class="advice advice--reading{% if not run.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ run.advice }}
</div>
{% endif %}
<div class="btn-row">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
+8
View File
@@ -83,6 +83,7 @@
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th>Brewing advice</th>
<th></th>
</tr>
</thead>
@@ -111,6 +112,13 @@
<span class="faint">—</span>
{% endif %}
</td>
<td class="comment advice-cell">
{% if run.advice %}
<span class="advice{% if not run.advice_ok %} advice--failed{% endif %}">{{ run.advice }}</span>
{% else %}
<span class="faint">Thinking…</span>
{% endif %}
</td>
<td class="right">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
+6
View File
@@ -144,6 +144,12 @@
{% else %}
<div class="faint tiny">No comment</div>
{% endif %}
{% if reading.advice %}
<div class="advice advice--reading{% if not reading.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ reading.advice }}
</div>
{% endif %}
</div>
{% if reading.final_reading %}
<span class="chip chip--done">Final</span>
+144 -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,125 @@ 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, 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:
saved = dict(readings[-1]) if readings else {"date": "", "sg": None}
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")
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,
)
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 the "
"advice no longer matches it",
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()
@@ -104,8 +230,17 @@ 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)
# 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(),
@@ -113,6 +248,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, corrected=was_existing)
return redirect(url_for("brews.index"))
runs = repo().runs()