Files
beer_data/src/flask/templates/run_detail.html
T
hermes ab3050194d 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.
2026-11-09 16:03:29 +10:00

174 lines
7.4 KiB
HTML

{% extends 'base.html' %}
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">
{{ summary.canonical_name }}
{% if summary.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</h1>
<p class="page__sub">
Run <span class="mono">{{ summary.beer_run_id }}</span>
{% if summary.first_date %} &middot; started {{ summary.first_date }}{% endif %}
{% if summary.final_date %} &middot; finished {{ summary.final_date }}{% endif %}
</p>
</div>
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a>
</div>
<div class="grid grid--sidebar mb-lg">
<!-- ------------------------------------------------- run metadata -->
<section class="card">
<div class="card__head">
<h2 class="card__title">Run details</h2>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label class="field__label" for="name">Beer / cider name</label>
<input type="text" id="name" name="name" maxlength="120"
value="{{ meta_form.name.data or '' }}">
<div class="field__hint">Overrides the drifting name on each reading.</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="tap_number">Tap number</label>
<input type="number" id="tap_number" name="tap_number"
min="1" max="99" step="1"
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
</div>
<div class="field">
<label class="field__label" for="og_points">Original gravity</label>
<input type="number" id="og_points" name="og_points"
min="900" max="1300" step="1"
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
<div class="field__hint">Points, e.g. 1045.</div>
</div>
</div>
<div class="field">
<label class="field__label" for="notes">Brew notes</label>
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
</div>
<button class="btn btn--block" type="submit">Save run details</button>
</form>
</div>
</section>
<div class="grid grid--4">
<div class="stat">
<div class="stat__label">Original gravity</div>
<div class="stat__value">
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.og_points is not none %}
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
</div>
<div class="stat__value">
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.fg_points is not none %}
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">Alcohol</div>
<div class="stat__value">
{% if summary.abv is not none %}
{{ '%.2f' % summary.abv }}%
{% elif summary.projected_abv is not none %}
{{ '%.2f' % summary.projected_abv }}%
{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if summary.abv is not none %}
measured
{% elif summary.projected_abv is not none %}
projected
{% else %}
needs 2nd reading
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Days brewing</div>
<div class="stat__value">
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
</div>
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
</div>
</div>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Gravity over time</h2>
<span class="card__hint">Standard ABV: (OG &minus; FG) &times; 131.25</span>
</div>
<div class="card__body">
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Readings</h2>
<span class="card__hint">{{ summary.reading_count }} total</span>
</div>
<div class="card__body">
<ul class="reading-list">
{% for reading in summary.readings %}
<li class="reading-list__item">
<span class="reading-list__date">{{ reading.date or '—' }}</span>
<span class="reading-list__sg">
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
</span>
<div class="reading-list__body">
{% if reading.comment %}
<div class="reading-list__comment">{{ reading.comment }}</div>
{% 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>
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
</form>
{% else %}
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}