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:
@@ -152,6 +152,10 @@ 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
|
||||
|
||||
@property
|
||||
def sg_decimal(self) -> float | None:
|
||||
@@ -171,6 +175,8 @@ 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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -189,6 +195,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 +647,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 +662,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
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user