feat(advice): LLM brewing advice on every observation (#14)
Build and Push Image / Build and push image (push) Successful in 9m8s
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:
@@ -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")
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user