Compare commits
3
Commits
c7ca9c3801
...
ab3050194d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3050194d | ||
|
|
41cf2cbb8e | ||
|
|
76a4d0805c |
@@ -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")
|
||||||
+81
-9
@@ -152,6 +152,10 @@ class Reading:
|
|||||||
comment: str = ""
|
comment: str = ""
|
||||||
beer_type: str = ""
|
beer_type: str = ""
|
||||||
_id: 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
|
@property
|
||||||
def sg_decimal(self) -> float | None:
|
def sg_decimal(self) -> float | None:
|
||||||
@@ -171,6 +175,8 @@ class Reading:
|
|||||||
comment=(doc.get("comment") or "").strip(),
|
comment=(doc.get("comment") or "").strip(),
|
||||||
beer_type=(doc.get("beer_type") or "").strip(),
|
beer_type=(doc.get("beer_type") or "").strip(),
|
||||||
_id=str(doc.get("_id") or ""),
|
_id=str(doc.get("_id") or ""),
|
||||||
|
advice=(doc.get("advice") or "").strip(),
|
||||||
|
advice_ok=bool(doc.get("advice_ok", True)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,18 +195,55 @@ class Run:
|
|||||||
|
|
||||||
# -- aggregate accessors ------------------------------------------------ #
|
# -- 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]:
|
def sg_values(self) -> list[int]:
|
||||||
"""Gravity points of every *usable* reading, in chronological order."""
|
"""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]
|
return [r.sg_points for r in self.readings if r.sg_points is not None]
|
||||||
|
|
||||||
|
def dated_sg_values(self) -> list[int]:
|
||||||
|
"""Gravity points of the readings that have a date, chronologically.
|
||||||
|
|
||||||
|
An undated reading cannot be placed in the run's timeline, so it
|
||||||
|
must not be used as the original gravity: it sorts to the front
|
||||||
|
and would put the OG after the FG.
|
||||||
|
"""
|
||||||
|
dated = [r for r in self.readings
|
||||||
|
if r.sg_points is not None and r.date is not None]
|
||||||
|
return [r.sg_points for r in dated]
|
||||||
|
|
||||||
def original_gravity(self) -> int | None:
|
def original_gravity(self) -> int | None:
|
||||||
"""OG = the first usable reading of the run."""
|
"""OG = the first *dated* usable reading of the run."""
|
||||||
values = self.sg_values()
|
values = self.dated_sg_values()
|
||||||
return values[0] if values else None
|
return values[0] if values else None
|
||||||
|
|
||||||
def final_gravity(self) -> int | None:
|
def final_gravity(self) -> int | None:
|
||||||
"""FG = the last usable reading of the run (if the run is finished)."""
|
"""FG = the last *dated* usable reading of the run (if it is finished).
|
||||||
values = self.sg_values()
|
|
||||||
|
For a finished run the final reading is the one flagged as final,
|
||||||
|
which is the reading the brewer took when they racked it.
|
||||||
|
"""
|
||||||
|
final = self.final_reading()
|
||||||
|
if final is not None and final.sg_points is not None:
|
||||||
|
return final.sg_points
|
||||||
|
values = self.dated_sg_values()
|
||||||
return values[-1] if values else None
|
return values[-1] if values else None
|
||||||
|
|
||||||
def max_sg(self) -> int | None:
|
def max_sg(self) -> int | None:
|
||||||
@@ -491,6 +534,11 @@ def attenuation_percent(run: Run) -> float | None:
|
|||||||
fg = run.final_gravity()
|
fg = run.final_gravity()
|
||||||
if og is None or fg is None or og <= 1000 or fg < 1000:
|
if og is None or fg is None or og <= 1000 or fg < 1000:
|
||||||
return None
|
return None
|
||||||
|
if fg > og:
|
||||||
|
# The gravity went UP. That is not attenuation - it is either a
|
||||||
|
# mis-ordered pair of readings or a contaminated sample, and a
|
||||||
|
# negative attenuation is not a number a brewer can act on.
|
||||||
|
return None
|
||||||
attenuation = (og - fg) / (og - 1000) * 100
|
attenuation = (og - fg) / (og - 1000) * 100
|
||||||
if attenuation > 100:
|
if attenuation > 100:
|
||||||
# Cannot ferment away more than the sugar that was there.
|
# Cannot ferment away more than the sugar that was there.
|
||||||
@@ -520,6 +568,17 @@ def implausible_runs(runs: Iterable[Run]) -> list[dict]:
|
|||||||
"is probably wrong"
|
"is probably wrong"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
elif og is not None and fg is not None and fg > og:
|
||||||
|
problems.append({
|
||||||
|
"beer_run_id": run.beer_run_id,
|
||||||
|
"og_points": og,
|
||||||
|
"fg_points": fg,
|
||||||
|
"problem": (
|
||||||
|
f"the final gravity {fg / 1000:.3f} is higher than the "
|
||||||
|
f"original {og / 1000:.3f} — the gravity went up, which "
|
||||||
|
"is usually a mis-ordered pair of readings"
|
||||||
|
),
|
||||||
|
})
|
||||||
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
|
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
|
||||||
and (og - fg) / (og - 1000) * 100 > 100:
|
and (og - fg) / (og - 1000) * 100 > 100:
|
||||||
problems.append({
|
problems.append({
|
||||||
@@ -537,12 +596,19 @@ def days_to_target(run: Run, target: int) -> int | None:
|
|||||||
Used to answer "how long does this usually take?", which is the question
|
Used to answer "how long does this usually take?", which is the question
|
||||||
a brewer actually has when planning the next batch.
|
a brewer actually has when planning the next batch.
|
||||||
"""
|
"""
|
||||||
|
first = run.first_reading()
|
||||||
|
if first is None or first.date is None:
|
||||||
|
# Without a start date there is no elapsed time to measure.
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip the readings that cannot be placed on the timeline (no date)
|
||||||
|
# rather than giving up on the whole run because of one of them.
|
||||||
for reading in run.readings:
|
for reading in run.readings:
|
||||||
if reading.sg_points is not None and reading.sg_points <= target:
|
if reading.sg_points is None or reading.sg_points > target:
|
||||||
first = run.first_reading()
|
continue
|
||||||
if first is None or first.date is None or reading.date is None:
|
if reading.date is None:
|
||||||
return None
|
continue
|
||||||
return days_between(first.date, reading.date)
|
return days_between(first.date, reading.date)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -581,6 +647,10 @@ def run_summary(run: Run, today: date | None = None,
|
|||||||
"days_in_fermenter": run.days_in_fermenter(today),
|
"days_in_fermenter": run.days_in_fermenter(today),
|
||||||
"comments": run.comments(),
|
"comments": run.comments(),
|
||||||
"last_comment": run.comments()[-1] if run.comments() else "",
|
"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),
|
"attenuation": attenuation_percent(run),
|
||||||
"curve": fermentation_curve(run),
|
"curve": fermentation_curve(run),
|
||||||
"outliers": [r.sg_points for r in run.outlier_readings()],
|
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||||
@@ -592,6 +662,8 @@ def run_summary(run: Run, today: date | None = None,
|
|||||||
"final_reading": r.final_reading,
|
"final_reading": r.final_reading,
|
||||||
"comment": r.comment,
|
"comment": r.comment,
|
||||||
"beer_type": r.beer_type,
|
"beer_type": r.beer_type,
|
||||||
|
"advice": r.advice,
|
||||||
|
"advice_ok": r.advice_ok,
|
||||||
}
|
}
|
||||||
for r in run.readings
|
for r in run.readings
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -188,6 +188,27 @@ class BeerRepository:
|
|||||||
existing = self.readings.find_one(query)
|
existing = self.readings.find_one(query)
|
||||||
return str(existing.get("_id")) if existing else ""
|
return str(existing.get("_id")) if existing else ""
|
||||||
|
|
||||||
|
def set_reading_advice(self, reading_id: str, advice: str,
|
||||||
|
ok: bool = True) -> 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.
|
||||||
|
"""
|
||||||
|
doc_id = _as_doc_id(reading_id)
|
||||||
|
if doc_id is None:
|
||||||
|
return 0
|
||||||
|
result = self.readings.update_one(
|
||||||
|
{"_id": doc_id},
|
||||||
|
{"$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,
|
def set_final_reading(self, beer_run_id: str, reading_date: str,
|
||||||
final: bool) -> int:
|
final: bool) -> int:
|
||||||
"""Flag (or unflag) a reading as the final reading of its run."""
|
"""Flag (or unflag) a reading as the final reading of its run."""
|
||||||
@@ -314,6 +335,28 @@ def _as_id(value: object) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _as_doc_id(value: object):
|
||||||
|
"""Coerce a reading document id to something Mongo will match.
|
||||||
|
|
||||||
|
The ``_id`` of a reading is an ``ObjectId``, not a run id, so the
|
||||||
|
``_as_id`` coercion does not apply - it would turn the ObjectId into
|
||||||
|
an empty string and the update would silently match nothing.
|
||||||
|
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
return ObjectId(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _as_date(value: object) -> str:
|
def _as_date(value: object) -> str:
|
||||||
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
|
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|||||||
@@ -689,3 +689,65 @@ 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__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
|
||||||
.reading-list__body { flex: 1; min-width: 0; }
|
.reading-list__body { flex: 1; min-width: 0; }
|
||||||
.reading-list__comment { color: var(--text-muted); font-size: .84rem; }
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,8 +179,7 @@
|
|||||||
var canvas = document.getElementById("abv-chart");
|
var canvas = document.getElementById("abv-chart");
|
||||||
if (!canvas) { return; }
|
if (!canvas) { return; }
|
||||||
|
|
||||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
fetchRuns()
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (runs) {
|
.then(function (runs) {
|
||||||
// Only finished brews have a measured ABV. An unfinished run
|
// Only finished brews have a measured ABV. An unfinished run
|
||||||
// has a projection, not a measurement, so it is not plotted
|
// has a projection, not a measurement, so it is not plotted
|
||||||
@@ -240,8 +239,7 @@
|
|||||||
var canvas = document.getElementById("months-chart");
|
var canvas = document.getElementById("months-chart");
|
||||||
if (!canvas) { return; }
|
if (!canvas) { return; }
|
||||||
|
|
||||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
fetchRuns()
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (runs) {
|
.then(function (runs) {
|
||||||
var counts = {};
|
var counts = {};
|
||||||
runs.forEach(function (r) {
|
runs.forEach(function (r) {
|
||||||
@@ -302,8 +300,7 @@
|
|||||||
var canvas = document.getElementById("attenuation-chart");
|
var canvas = document.getElementById("attenuation-chart");
|
||||||
if (!canvas) { return; }
|
if (!canvas) { return; }
|
||||||
|
|
||||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
fetchRuns()
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (runs) {
|
.then(function (runs) {
|
||||||
// A run with an impossible reading has no attenuation, and
|
// A run with an impossible reading has no attenuation, and
|
||||||
// is flagged as suspect. Both are excluded, so one bad
|
// is flagged as suspect. Both are excluded, so one bad
|
||||||
@@ -384,14 +381,18 @@
|
|||||||
var canvas = document.getElementById("curve-chart");
|
var canvas = document.getElementById("curve-chart");
|
||||||
if (!canvas) { return; }
|
if (!canvas) { return; }
|
||||||
|
|
||||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
fetchRuns()
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (runs) {
|
.then(function (runs) {
|
||||||
// A run with an impossible reading has a broken curve - it
|
// A run with an impossible reading has a broken curve - it
|
||||||
// plunges to a gravity below water - which would drag the
|
// plunges to a gravity below water - which would drag the
|
||||||
// median line down and stretch the envelope to cover it.
|
// median line down and stretch the envelope to cover it.
|
||||||
|
// Only FINISHED brews belong on the curve. An active run's
|
||||||
|
// curve is a partial fermentation, and the curve is meant to
|
||||||
|
// describe how a completed fermentation behaves - including
|
||||||
|
// a partial one pulls the median line off the real shape.
|
||||||
var withCurve = runs.filter(function (r) {
|
var withCurve = runs.filter(function (r) {
|
||||||
return !r.suspect && r.curve && r.curve.length > 1;
|
return !r.suspect && !r.active &&
|
||||||
|
r.curve && r.curve.length > 1;
|
||||||
});
|
});
|
||||||
if (!withCurve.length) { return; }
|
if (!withCurve.length) { return; }
|
||||||
|
|
||||||
@@ -462,6 +463,7 @@
|
|||||||
// progress point.
|
// progress point.
|
||||||
var envelope = [];
|
var envelope = [];
|
||||||
var median = [];
|
var median = [];
|
||||||
|
var envelopeMax = [];
|
||||||
for (var i = 0; i < samples; i++) {
|
for (var i = 0; i < samples; i++) {
|
||||||
var pct = (i / (samples - 1)) * 100;
|
var pct = (i / (samples - 1)) * 100;
|
||||||
var values = [];
|
var values = [];
|
||||||
@@ -478,6 +480,7 @@
|
|||||||
y: values[0],
|
y: values[0],
|
||||||
yMax: values[values.length - 1]
|
yMax: values[values.length - 1]
|
||||||
});
|
});
|
||||||
|
envelopeMax.push({ x: pct, y: values[values.length - 1] });
|
||||||
var mid = Math.floor(values.length / 2);
|
var mid = Math.floor(values.length / 2);
|
||||||
median.push({
|
median.push({
|
||||||
x: pct,
|
x: pct,
|
||||||
@@ -492,6 +495,12 @@
|
|||||||
data: {
|
data: {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
|
// The band is drawn as TWO datasets: the min
|
||||||
|
// curve filled down to the max curve. With
|
||||||
|
// a single dataset and `fill: "+1"` the band
|
||||||
|
// was filled to the NEXT dataset - which was
|
||||||
|
// the median line, not the max - so the
|
||||||
|
// tooltip and the shading disagreed.
|
||||||
label: "Range (min–max)",
|
label: "Range (min–max)",
|
||||||
data: envelope,
|
data: envelope,
|
||||||
borderColor: withAlpha(ACCENT, 0.35),
|
borderColor: withAlpha(ACCENT, 0.35),
|
||||||
@@ -501,6 +510,18 @@
|
|||||||
fill: "+1",
|
fill: "+1",
|
||||||
tension: 0.3
|
tension: 0.3
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The upper edge of the band. It is the
|
||||||
|
// dataset the one above fills to.
|
||||||
|
label: "Range max",
|
||||||
|
data: envelopeMax,
|
||||||
|
borderColor: withAlpha(ACCENT, 0.35),
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Median brew",
|
label: "Median brew",
|
||||||
data: median,
|
data: median,
|
||||||
@@ -522,7 +543,12 @@
|
|||||||
boxWidth: 10,
|
boxWidth: 10,
|
||||||
boxHeight: 10,
|
boxHeight: 10,
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
font: { size: 11 }
|
font: { size: 11 },
|
||||||
|
// The max curve is the helper the band
|
||||||
|
// fills to, not a series of its own.
|
||||||
|
filter: function (item, data) {
|
||||||
|
return item.text !== "Range max";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@@ -533,6 +559,9 @@
|
|||||||
return "range " + point.y +
|
return "range " + point.y +
|
||||||
" to " + (point.yMax || point.y);
|
" to " + (point.yMax || point.y);
|
||||||
}
|
}
|
||||||
|
if (ctx.datasetIndex === 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return "median " + point.y;
|
return "median " + point.y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,6 +598,26 @@
|
|||||||
.catch(function (err) { console.warn(err); });
|
.catch(function (err) { console.warn(err); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- shared run payload */
|
||||||
|
|
||||||
|
// The analysis page has two charts that both need /api/runs, and the
|
||||||
|
// payload contains every reading of every run. Fetch it once and
|
||||||
|
// share it rather than making two identical round-trips.
|
||||||
|
var runsPromise = null;
|
||||||
|
|
||||||
|
function fetchRuns() {
|
||||||
|
if (!runsPromise) {
|
||||||
|
runsPromise = fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn(err);
|
||||||
|
runsPromise = null;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return runsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
drawSparklines();
|
drawSparklines();
|
||||||
drawRunChart();
|
drawRunChart();
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<div class="btn-row">
|
||||||
<a class="btn btn--ghost btn--sm"
|
<a class="btn btn--ghost btn--sm"
|
||||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
|
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
|
||||||
|
|||||||
@@ -164,7 +164,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in suspect %}
|
{% for row in suspect %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ row.beer_run_id }}</td>
|
<td class="mono">
|
||||||
|
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
|
||||||
|
</td>
|
||||||
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
|
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
|
||||||
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
|
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
|
||||||
<td class="comment">{{ row.problem }}</td>
|
<td class="comment">{{ row.problem }}</td>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
<th class="num">Gravity</th>
|
<th class="num">Gravity</th>
|
||||||
<th class="num">Est. ABV</th>
|
<th class="num">Est. ABV</th>
|
||||||
<th>Comment</th>
|
<th>Comment</th>
|
||||||
|
<th>Brewing advice</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -111,6 +112,13 @@
|
|||||||
<span class="faint">—</span>
|
<span class="faint">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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">
|
<td class="right">
|
||||||
<a class="btn btn--ghost btn--sm"
|
<a class="btn btn--ghost btn--sm"
|
||||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||||
|
|||||||
@@ -144,6 +144,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="faint tiny">No comment</div>
|
<div class="faint tiny">No comment</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% if reading.final_reading %}
|
{% if reading.final_reading %}
|
||||||
<span class="chip chip--done">Final</span>
|
<span class="chip chip--done">Final</span>
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ def deep():
|
|||||||
"attenuation": attenuation_percent(run),
|
"attenuation": attenuation_percent(run),
|
||||||
"days": summary["days_in_fermenter"],
|
"days": summary["days_in_fermenter"],
|
||||||
"days_to_1010": days_to_target(run, 1010),
|
"days_to_1010": days_to_target(run, 1010),
|
||||||
"days_to_1015": days_to_target(run, 1015),
|
|
||||||
"readings": summary["reading_count"],
|
"readings": summary["reading_count"],
|
||||||
"curve": summary["curve"],
|
"curve": summary["curve"],
|
||||||
# A suspect run is shown in the table so it can be corrected,
|
# A suspect run is shown in the table so it can be corrected,
|
||||||
@@ -78,9 +77,6 @@ def deep():
|
|||||||
"suspect": run.beer_run_id in suspect_ids,
|
"suspect": run.beer_run_id in suspect_ids,
|
||||||
})
|
})
|
||||||
|
|
||||||
# The rows with a usable curve, for the overlay chart.
|
|
||||||
curves = [r for r in rows if r["curve"]]
|
|
||||||
|
|
||||||
# Only the trustworthy runs feed the statistics.
|
# Only the trustworthy runs feed the statistics.
|
||||||
good = [r for r in rows if not r["suspect"]]
|
good = [r for r in rows if not r["suspect"]]
|
||||||
|
|
||||||
@@ -109,7 +105,6 @@ def deep():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"analysis.html",
|
"analysis.html",
|
||||||
rows=rows,
|
rows=rows,
|
||||||
curves=curves,
|
|
||||||
suspect=suspect,
|
suspect=suspect,
|
||||||
stats={
|
stats={
|
||||||
"count": len(rows),
|
"count": len(rows),
|
||||||
|
|||||||
+115
-9
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import (Blueprint, flash, jsonify, redirect, render_template,
|
import logging
|
||||||
request, session, url_for)
|
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 flask_wtf import FlaskForm
|
||||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||||
SubmitField, TextAreaField)
|
SubmitField, TextAreaField)
|
||||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||||
|
|
||||||
|
import brew_advice
|
||||||
from brewing import implausible_runs, is_plausible_sg, run_summary
|
from brewing import implausible_runs, is_plausible_sg, run_summary
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint("brews", __name__)
|
bp = Blueprint("brews", __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +41,82 @@ def login_required(fn):
|
|||||||
return wrapper
|
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) -> 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 builds its own.
|
||||||
|
"""
|
||||||
|
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": reading._id,
|
||||||
|
"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),
|
||||||
|
})
|
||||||
|
|
||||||
|
latest = dict(readings[-1]) if readings else {"date": "", "sg": None}
|
||||||
|
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
|
||||||
|
|
||||||
|
def write(read_id: str, ok: bool, text: str) -> None:
|
||||||
|
from mongo.repository import BeerRepository
|
||||||
|
BeerRepository().set_reading_advice(read_id, text, ok)
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=brew_advice.generate_advice_in_background,
|
||||||
|
args=(run_info, latest, readings, write),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
def _last_reading_date(run) -> str | None:
|
def _last_reading_date(run) -> str | None:
|
||||||
"""The ISO date of the most recent reading in a run."""
|
"""The ISO date of the most recent reading in a run."""
|
||||||
last = run.last_reading()
|
last = run.last_reading()
|
||||||
@@ -49,6 +132,10 @@ class ReadingForm(FlaskForm):
|
|||||||
beer_run_type = StringField("Beer / cider name",
|
beer_run_type = StringField("Beer / cider name",
|
||||||
validators=[Optional(), Length(max=120)])
|
validators=[Optional(), Length(max=120)])
|
||||||
Date = DateField("Reading date", validators=[DataRequired()])
|
Date = DateField("Reading date", validators=[DataRequired()])
|
||||||
|
# The floor is deliberately lower than is_plausible_sg's. An
|
||||||
|
# out-of-range reading is SAVED with a warning rather than rejected:
|
||||||
|
# the brewer may have mis-typed it, and seeing it recorded is how they
|
||||||
|
# spot and correct the mistake.
|
||||||
sg = IntegerField("Gravity reading (points)",
|
sg = IntegerField("Gravity reading (points)",
|
||||||
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||||
final_reading = BooleanField("This is the final reading")
|
final_reading = BooleanField("This is the final reading")
|
||||||
@@ -82,13 +169,27 @@ def index():
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
sg_points = int(form.sg.data)
|
sg_points = int(form.sg.data)
|
||||||
if not is_plausible_sg(sg_points):
|
if not is_plausible_sg(sg_points):
|
||||||
flash(
|
if sg_points < 1000:
|
||||||
f"{sg_points} is outside the usual hydrometer range "
|
# Beer cannot be less dense than water, so this is always
|
||||||
"(990-1200). It was saved anyway — double-check the reading.",
|
# a mis-entry - and a mis-typed "1.012" as "012" is the
|
||||||
"warning",
|
# likely cause. It is still saved so the mistake is
|
||||||
)
|
# visible and can be corrected.
|
||||||
repo().upsert_reading(
|
flash(
|
||||||
beer_run_id=str(form.beer_run_id.data),
|
f"{sg_points} is below 1.000 — beer cannot be less dense "
|
||||||
|
"than water, so this is almost certainly a typo (a 1.012 "
|
||||||
|
"reading entered as 012?). It was saved anyway; correct "
|
||||||
|
"it on the run's page.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(
|
||||||
|
f"{sg_points} is outside the usual hydrometer range "
|
||||||
|
"(990-1200). It was saved anyway — double-check the reading.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
run_id = str(form.beer_run_id.data)
|
||||||
|
reading_id = repo().upsert_reading(
|
||||||
|
beer_run_id=run_id,
|
||||||
beer_type=(form.beer_run_type.data or "").strip(),
|
beer_type=(form.beer_run_type.data or "").strip(),
|
||||||
sg=sg_points,
|
sg=sg_points,
|
||||||
reading_date=form.Date.data.isoformat(),
|
reading_date=form.Date.data.isoformat(),
|
||||||
@@ -96,6 +197,11 @@ def index():
|
|||||||
comment=(form.comment.data or "").strip(),
|
comment=(form.comment.data or "").strip(),
|
||||||
)
|
)
|
||||||
flash("Reading saved.", "success")
|
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)
|
||||||
return redirect(url_for("brews.index"))
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
runs = repo().runs()
|
runs = repo().runs()
|
||||||
|
|||||||
+117
-1
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from brewing import (Run, attenuation_percent, days_to_target,
|
from brewing import (Run, attenuation_percent, days_to_target,
|
||||||
fermentation_curve)
|
fermentation_curve, implausible_runs)
|
||||||
from conftest import seed_readings
|
from conftest import seed_readings
|
||||||
|
|
||||||
|
|
||||||
@@ -392,3 +392,119 @@ def test_the_charts_skip_suspect_runs():
|
|||||||
assert charts_js.count("!r.suspect") >= 2, (
|
assert charts_js.count("!r.suspect") >= 2, (
|
||||||
"the charts must filter out the runs flagged as suspect"
|
"the charts must filter out the runs flagged as suspect"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------- rising gravity #
|
||||||
|
|
||||||
|
def test_attenuation_is_none_when_the_gravity_went_up():
|
||||||
|
"""A negative attenuation is not a number a brewer can act on.
|
||||||
|
|
||||||
|
The guard rejected a final gravity below 1.000 and one implying more
|
||||||
|
than 100% attenuation, but a run whose gravity ROSE produced a
|
||||||
|
negative figure that was averaged into the headline number.
|
||||||
|
"""
|
||||||
|
run = Run.from_docs("30", [
|
||||||
|
_doc(sg="1010", date_="2026-01-01"),
|
||||||
|
_doc(sg="1020", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rising_gravity_run_is_reported_as_implausible():
|
||||||
|
"""It must be listed for review, not just quietly dropped."""
|
||||||
|
run = Run.from_docs("31", [
|
||||||
|
_doc(sg="1010", date_="2026-01-01"),
|
||||||
|
_doc(sg="1020", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
problems = implausible_runs([run])
|
||||||
|
assert len(problems) == 1
|
||||||
|
assert problems[0]["beer_run_id"] == "31"
|
||||||
|
assert "went up" in problems[0]["problem"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- undated readings #
|
||||||
|
|
||||||
|
def test_the_original_gravity_comes_from_the_first_dated_reading():
|
||||||
|
"""An undated reading cannot be placed on the timeline.
|
||||||
|
|
||||||
|
It used to sort to the front, so it became the OG - which put the OG
|
||||||
|
after the FG and produced a negative attenuation.
|
||||||
|
"""
|
||||||
|
run = Run.from_docs("32", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
{"_id": "x", "beer_run_id": "32", "beer_type": "Ale", "sg": "1005",
|
||||||
|
"date": None, "final_reading": "False", "comment": ""},
|
||||||
|
_doc(sg="1010", date_="2026-01-25", final="True"),
|
||||||
|
])
|
||||||
|
assert run.original_gravity() == 1050
|
||||||
|
assert run.final_gravity() == 1010
|
||||||
|
assert attenuation_percent(run) is not None
|
||||||
|
assert attenuation_percent(run) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_to_target_skips_an_undated_reading():
|
||||||
|
"""One undated reading must not lose the whole figure.
|
||||||
|
|
||||||
|
The function gave up and returned None when the first reading at or
|
||||||
|
below the target had no date, even when a later reading did.
|
||||||
|
"""
|
||||||
|
run = Run.from_docs("33", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
{"_id": "y", "beer_run_id": "33", "beer_type": "Ale", "sg": "1005",
|
||||||
|
"date": None, "final_reading": "False", "comment": ""},
|
||||||
|
_doc(sg="1010", date_="2026-01-25", final="True"),
|
||||||
|
])
|
||||||
|
assert days_to_target(run, 1010) == 24
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- the charts #
|
||||||
|
|
||||||
|
def test_the_curve_chart_excludes_active_and_suspect_runs():
|
||||||
|
"""Only finished, plausible brews belong on the curve.
|
||||||
|
|
||||||
|
An active run's curve is a partial fermentation, and the curve is
|
||||||
|
meant to describe how a completed fermentation behaves.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert "!r.active" in charts_js, (
|
||||||
|
"the curve chart must leave the active runs out"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_curve_band_is_filled_to_the_max_not_the_median():
|
||||||
|
"""The shaded band has to span min to max.
|
||||||
|
|
||||||
|
With one dataset and `fill: "+1"` the band was filled to the NEXT
|
||||||
|
dataset - which was the median line, so the upper half of the range
|
||||||
|
was drawn as a hairline but never shaded, and the tooltip disagreed
|
||||||
|
with the shading.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert "envelopeMax" in charts_js, (
|
||||||
|
"the band needs a separate max dataset to fill down to"
|
||||||
|
)
|
||||||
|
assert 'label: "Range max"' in charts_js
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_charts_fetch_the_run_payload_once():
|
||||||
|
"""/api/runs carries every reading of every run.
|
||||||
|
|
||||||
|
Four charts each fetched it independently, so the analysis page made
|
||||||
|
two identical round-trips of the whole collection.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert charts_js.count('fetch("/api/runs"') == 1, (
|
||||||
|
"the run payload should be fetched once and shared"
|
||||||
|
)
|
||||||
|
assert "function fetchRuns()" in charts_js
|
||||||
|
|||||||
+25
-1
@@ -186,11 +186,16 @@ def test_logout_clears_the_session(logged_in):
|
|||||||
|
|
||||||
|
|
||||||
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
||||||
|
"""A reading outside the range is SAVED with a warning.
|
||||||
|
|
||||||
|
Rejecting it would lose the brewer's actual observation, and seeing it
|
||||||
|
recorded is how they spot and correct a typo.
|
||||||
|
"""
|
||||||
response = logged_in.post("/brews", data={
|
response = logged_in.post("/brews", data={
|
||||||
"beer_run_id": "12",
|
"beer_run_id": "12",
|
||||||
"beer_run_type": "Odd",
|
"beer_run_type": "Odd",
|
||||||
"Date": "2026-05-01",
|
"Date": "2026-05-01",
|
||||||
"sg": "950",
|
"sg": "1250",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"outside the usual hydrometer range" in response.data
|
assert b"outside the usual hydrometer range" in response.data
|
||||||
@@ -198,6 +203,25 @@ def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
|||||||
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
|
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reading_below_water_says_it_is_always_a_typo(logged_in, repo):
|
||||||
|
"""A gravity below 1.000 cannot be real, so the warning says so.
|
||||||
|
|
||||||
|
This is the run 12 case: a 1.012 reading entered as "012". The
|
||||||
|
reading is still saved, but the message names the likely cause.
|
||||||
|
"""
|
||||||
|
response = logged_in.post("/brews", data={
|
||||||
|
"beer_run_id": "13",
|
||||||
|
"beer_run_type": "Typo",
|
||||||
|
"Date": "2026-05-01",
|
||||||
|
"sg": "900",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"below 1.000" in response.data
|
||||||
|
assert b"typo" in response.data
|
||||||
|
# Still saved, so the mistake is visible and can be corrected.
|
||||||
|
assert repo.readings.find_one({"beer_run_id": "13"}) is not None
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- security: passwords #
|
# ------------------------------------------------------- security: passwords #
|
||||||
|
|
||||||
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
|
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Tests for the LLM brewing advice: the prompt, the storage and the display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import brew_advice
|
||||||
|
from conftest import seed_readings
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the prompt #
|
||||||
|
|
||||||
|
def test_the_prompt_gives_the_model_the_brew_and_its_readings():
|
||||||
|
"""The model needs the name, the OG and every reading."""
|
||||||
|
run = {
|
||||||
|
"canonical_name": "Nocturna Christmas beer",
|
||||||
|
"og_decimal": 1.042,
|
||||||
|
"tap_number": 3,
|
||||||
|
"notes": "dark malt, 19C",
|
||||||
|
}
|
||||||
|
reading = {"date": "2026-01-10", "sg_decimal": 1.022,
|
||||||
|
"point_scale": "1022 points", "comment": "no change since day 7",
|
||||||
|
"final": False}
|
||||||
|
history = [
|
||||||
|
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
|
||||||
|
"comment": "", "final": False},
|
||||||
|
reading,
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = brew_advice.build_prompt(run, reading, history)
|
||||||
|
|
||||||
|
assert "Nocturna Christmas beer" in prompt
|
||||||
|
assert "1.042" in prompt
|
||||||
|
assert "Fermenter tap: 3" in prompt
|
||||||
|
assert "dark malt" in prompt
|
||||||
|
# Both readings, oldest first.
|
||||||
|
assert "2026-01-01" in prompt
|
||||||
|
assert "2026-01-10" in prompt
|
||||||
|
assert "no change since day 7" in prompt
|
||||||
|
assert "1.022" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prompt_tells_the_model_not_to_call_a_reading_impossible():
|
||||||
|
"""A reading below 1.000 is a mis-entry, but the run may still be real.
|
||||||
|
|
||||||
|
This matters: the brewer's own run 12 was a genuine long fermentation
|
||||||
|
that the app flagged as impossible. The model must not repeat that
|
||||||
|
mistake - it should point at the odd reading and keep talking about
|
||||||
|
the brew.
|
||||||
|
"""
|
||||||
|
assert "Never say a reading is impossible" in brew_advice.SYSTEM_PROMPT
|
||||||
|
assert "still be real" in brew_advice.SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prompt_marks_the_final_reading():
|
||||||
|
run = {"canonical_name": "Ale"}
|
||||||
|
reading = {"date": "2026-01-20", "sg_decimal": 1.010, "point_scale": "1010 points",
|
||||||
|
"comment": "", "final": True}
|
||||||
|
prompt = brew_advice.build_prompt(run, reading, [reading])
|
||||||
|
assert "FINAL reading" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- the LLM call #
|
||||||
|
|
||||||
|
def test_a_successful_call_returns_the_text(monkeypatch):
|
||||||
|
"""A normal reply comes back as the advice."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=None):
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["body"] = json.loads(request.data.decode())
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def read(self):
|
||||||
|
return json.dumps({
|
||||||
|
"choices": [{"message": {"content": " Leave it alone, it is still fermenting. "}}]
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
|
||||||
|
ok, text = brew_advice.generate_advice(
|
||||||
|
{"canonical_name": "Ale"},
|
||||||
|
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
|
||||||
|
"comment": "", "final": False},
|
||||||
|
[{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
|
||||||
|
"comment": "", "final": False}],
|
||||||
|
model="glm-5.3:cloud",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert text == "Leave it alone, it is still fermenting."
|
||||||
|
assert captured["url"].endswith("/v1/chat/completions")
|
||||||
|
assert captured["body"]["model"] == "glm-5.3:cloud"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_model_does_not_raise(monkeypatch):
|
||||||
|
"""A failure is recorded, not raised.
|
||||||
|
|
||||||
|
The reading has to be saved either way, and a silent failure would be
|
||||||
|
indistinguishable from "the advice is still being generated".
|
||||||
|
"""
|
||||||
|
def boom(request, timeout=None):
|
||||||
|
raise brew_advice.urllib.error.URLError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", boom)
|
||||||
|
|
||||||
|
ok, text = brew_advice.generate_advice(
|
||||||
|
{"canonical_name": "Ale"},
|
||||||
|
{"date": "2026-01-01", "sg_decimal": 1.042, "point_scale": "1042 points",
|
||||||
|
"comment": "", "final": False},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert "could not be generated" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_reply_is_a_failure(monkeypatch):
|
||||||
|
"""A model that returns nothing has not given advice."""
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def read(self):
|
||||||
|
return json.dumps({"choices": [{"message": {"content": " "}}]}).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(brew_advice.urllib.request, "urlopen",
|
||||||
|
lambda request, timeout=None: Response())
|
||||||
|
|
||||||
|
ok, text = brew_advice.generate_advice({"canonical_name": "Ale"},
|
||||||
|
{"date": "2026-01-01"}, [])
|
||||||
|
assert ok is False
|
||||||
|
assert "could not be generated" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_token_budget_leaves_room_for_the_reasoning(monkeypatch):
|
||||||
|
"""The reasoning models emit their thinking first, out of the same budget.
|
||||||
|
|
||||||
|
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. The budget has to cover the reasoning AND the advice.
|
||||||
|
"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=None):
|
||||||
|
captured["body"] = json.loads(request.data.decode())
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def read(self):
|
||||||
|
return json.dumps({
|
||||||
|
"choices": [{"message": {"content": "Rouse the yeast."}}]
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr(brew_advice.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
|
||||||
|
brew_advice.generate_advice({"canonical_name": "Ale"},
|
||||||
|
{"date": "2026-01-01"}, [])
|
||||||
|
|
||||||
|
assert captured["body"]["max_tokens"] >= 2000
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_is_truncated():
|
||||||
|
"""A runaway reply must not fill the reading document."""
|
||||||
|
long_text = "x" * 5000
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def read(self):
|
||||||
|
return json.dumps({"choices": [{"message": {"content": long_text}}]}).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
import brew_advice as ba
|
||||||
|
original = ba.urllib.request.urlopen
|
||||||
|
ba.urllib.request.urlopen = lambda request, timeout=None: Response()
|
||||||
|
try:
|
||||||
|
ok, text = ba.generate_advice({"canonical_name": "Ale"},
|
||||||
|
{"date": "2026-01-01"}, [])
|
||||||
|
finally:
|
||||||
|
ba.urllib.request.urlopen = original
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert len(text) <= ba.MAX_ADVICE_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- storage #
|
||||||
|
|
||||||
|
def test_the_advice_is_stored_in_its_own_field(repo):
|
||||||
|
"""The brewer's comment is never overwritten."""
|
||||||
|
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my own comment")])
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
repo.set_reading_advice(str(doc["_id"]), "The advice", ok=True)
|
||||||
|
|
||||||
|
updated = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
assert updated["advice"] == "The advice"
|
||||||
|
assert updated["advice_ok"] is True
|
||||||
|
# The brewer's own comment is untouched.
|
||||||
|
assert updated["comment"] == "my own comment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_generation_is_stored_as_a_failure(repo):
|
||||||
|
"""The note says it could not be generated, and is flagged as such."""
|
||||||
|
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
repo.set_reading_advice(str(doc["_id"]), "Advice could not be generated.", ok=False)
|
||||||
|
|
||||||
|
updated = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
assert updated["advice_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_latest_advice_comes_from_the_newest_reading_with_one():
|
||||||
|
"""The advice for the newest reading wins.
|
||||||
|
|
||||||
|
The advice is generated in the background, so the newest reading may
|
||||||
|
not have one yet - in which case the newest that does is used.
|
||||||
|
"""
|
||||||
|
from brewing import Run
|
||||||
|
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
{"_id": "a", "beer_run_id": "1", "sg": "1042", "date": "2026-01-01",
|
||||||
|
"final_reading": "False", "comment": "", "advice": "first advice"},
|
||||||
|
{"_id": "b", "beer_run_id": "1", "sg": "1030", "date": "2026-01-05",
|
||||||
|
"final_reading": "False", "comment": "", "advice": "second advice"},
|
||||||
|
{"_id": "c", "beer_run_id": "1", "sg": "1020", "date": "2026-01-10",
|
||||||
|
"final_reading": "False", "comment": "", "advice": ""},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert run.latest_advice() == "second advice"
|
||||||
|
assert run.latest_advice_ok() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- the pages #
|
||||||
|
|
||||||
|
def test_recording_a_reading_saves_it_even_when_the_model_is_down(client, repo):
|
||||||
|
"""The model being down must never stop a reading being saved."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.post("/brews", data={
|
||||||
|
"beer_run_id": "99",
|
||||||
|
"beer_run_type": "Test ale",
|
||||||
|
"Date": "2026-01-01",
|
||||||
|
"sg": "1042",
|
||||||
|
"comment": "a comment",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert repo.readings.count_documents({"beer_run_id": "99"}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_is_shown_on_the_brewing_page(client, repo):
|
||||||
|
"""The advice appears next to the comment, clearly labelled."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my comment")])
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
repo.set_reading_advice(str(doc["_id"]), "Rouse the yeast and wait.", ok=True)
|
||||||
|
|
||||||
|
response = client.get("/brews")
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert "Rouse the yeast and wait." in body
|
||||||
|
# The brewer's own comment is still there.
|
||||||
|
assert "my comment" in body
|
||||||
|
# And it is labelled as advice, so the two are not confused.
|
||||||
|
assert "Brewing advice" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
|
||||||
|
"""The active page is where the brewer looks during a fermentation."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "my comment")])
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True)
|
||||||
|
|
||||||
|
response = client.get("/active")
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert "Warm it to 21C and wait." in body
|
||||||
|
assert "Brewing advice" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_advice_is_marked_as_such(client, repo):
|
||||||
|
"""A generation failure reads differently from real advice."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "1"})
|
||||||
|
repo.set_reading_advice(str(doc["_id"]), "Advice could not be generated.",
|
||||||
|
ok=False)
|
||||||
|
|
||||||
|
response = client.get("/brews")
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert "advice--failed" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_advice_is_not_generated_in_tests(client, repo):
|
||||||
|
"""The suite must never reach out to a real model."""
|
||||||
|
from views.brews import advice_enabled
|
||||||
|
|
||||||
|
with client.application.app_context():
|
||||||
|
assert advice_enabled() is False
|
||||||
Reference in New Issue
Block a user