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