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
+211
View File
@@ -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")