cauldron/cauldron/templates/sterilize.html
Kayos a6a28ef6e4 plan: multi-meal slots (breakfast/lunch/dinner) + rename agent → Sage
Big plan-generator addition: each plan can now span multiple meal types,
not just dinner. Default stays dinner-only for back-compat; opt-in via
checkboxes on /plan.

Schema (migrations 028-031):
- cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch',
  'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'.
- Old UNIQUE key (plan_id, day) → (plan_id, day, meal_type) so a
  Monday can have breakfast AND lunch AND dinner slots.
- cauldron_meal_plans gains meal_types_json (which meals to plan
  for that week — list of strings, defaults to ["dinner"]).

Forge:
- generate_plan accepts meal_types list. Output schema gains meal_type
  per slot. Validates expected_total = slots * len(meal_types) and
  rejects duplicate (day, meal_type) pairs.
- _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing
  Sonnet to match recipe meta.meal_type to slot type (breakfast slot
  → recipe whose meta tags it as breakfast). Falls back gracefully
  when the pool is thin for a particular meal type.

Server:
- /api/plan/generate + regenerate accept body.meal_types, persist via
  db.set_plan_meal_types.
- plan_view decodes meal_types_json into plan["meal_types_list"] and
  builds plan["meal_types_label"] for the readout.

UI (/plan):
- New checkbox row at the top of the pref-block: 🍳 breakfast / 🥪 lunch
  / 🍽️ dinner. Defaults to whatever's persisted (or just dinner).
- Day cards now group multiple meal_type slots per day with small
  meal-type tags above each recipe row. Single-meal plans render the
  same way they always did (no tag shown when only one meal_type).
- readMealTypes() in JS reads checkboxes and ships in the body.

DB:
- save_plan_slots accepts meal_type per slot, defaults to 'dinner'.
- list_plan_slots orders by day then meal_type via MEAL_ORDER.

==

UX rename: "claude" / "sonnet" → "Sage" across all user-visible copy.
Sage doubles as kitchen-herb (theme fit) and wise advisor (planner
role). The internal field name `sonnet_decision` on consolidate +
dedupe proposals is unchanged (it's a data field, not user-facing).
Renames touched plan, consolidate, dedupe_recipes, list, me,
enrich_recipes, sterilize templates. Cobb can swap to Mim or his own
name later — easy global s/sage/whatever/g.

==

The /list 'clear' button removed earlier today (b4cb48b) — not
re-introduced.
2026-04-30 21:44:56 -07:00

503 lines
17 KiB
HTML

{% extends "_base.html" %}
{% block title %}Sterilize · Cauldron{% endblock %}
{% block content %}
<style>
.progress-rail {
width: 100%; height: 14px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
margin: 12px 0 6px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--purple-deep), var(--purple-bright));
transition: width .3s ease;
box-shadow: 0 0 12px -2px var(--purple-glow);
}
.progress-meta {
color: var(--bone-dim);
font-family: var(--mono); font-size: 12px;
letter-spacing: .1em;
display: flex; gap: 18px; flex-wrap: wrap;
}
.progress-meta strong { color: var(--bone); }
.review-bar {
position: sticky; top: 70px; z-index: 5;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
padding: 12px 14px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 14px;
}
.review-bar .left { display: flex; gap: 14px; align-items: center; }
.review-bar .right { display: flex; gap: 8px; }
.proposals-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
.proposal-card {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--purple-dim);
border-radius: 6px;
padding: 12px 14px;
}
.proposal-card.rejected { border-left-color: var(--muted); opacity: .55; }
.proposal-card.approved { border-left-color: var(--green-bright); }
.proposal-card.errored { border-left-color: var(--crit); }
.proposal-head {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; flex-wrap: wrap;
cursor: pointer; user-select: none;
}
.proposal-name {
color: var(--bone); font-family: var(--serif);
font-size: 1.05em; line-height: 1.2;
}
.proposal-meta {
color: var(--muted); font-family: var(--mono);
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
}
.toggle {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--bone-dim);
font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
cursor: pointer; min-height: 28px;
}
.toggle.on { background: rgba(110,168,72,.18); border-color: var(--green-dim); color: var(--green-bright); }
.toggle.off { background: rgba(232,96,106,.12); border-color: rgba(232,96,106,.3); color: var(--crit); }
.diff-table {
width: 100%; border-collapse: collapse;
margin-top: 10px; font-size: 13px;
display: none;
}
.proposal-card.expanded .diff-table { display: table; }
.diff-table th {
text-align: left; color: var(--purple); font-family: var(--mono);
font-size: 10px; letter-spacing: .15em; text-transform: uppercase;
padding: 4px 8px; border-bottom: 1px solid var(--line);
}
.diff-table td {
padding: 4px 8px; vertical-align: top;
border-bottom: 1px solid var(--line-soft);
color: var(--bone-dim);
}
.diff-table .orig { color: var(--bone-dim); font-style: italic; }
.diff-table .new { color: var(--bone); }
.pill-error { background: rgba(232,96,106,.15); color: var(--crit); }
.empty-state {
padding: 28px 14px; text-align: center;
color: var(--bone-dim);
}
</style>
<div class="page-head">
<div class="crumb">// sterilize · bulk recipe parser</div>
<h1>bulk <span class="accent">sterilize</span></h1>
<div class="lede">
walk every recipe in the household, send the unparsed ingredients to
sage, review the proposals, apply the good ones. mealie's food
table gets cleaner; the shopping list math gets sharper.
</div>
</div>
<section class="panel" id="sterilize-shell">
<div class="panel-head">
<h2>state</h2>
<span class="pill" id="state-pill">loading…</span>
<span class="ctx" id="state-ctx"></span>
</div>
<div id="empty-pane" style="display: none;">
<p>nothing's running. kick off a bulk parse?</p>
<button class="btn btn-purple" id="start-btn" type="button" onclick="startBulk()">🪄 start bulk sterilize</button>
<p class="muted" style="margin-top: 8px;">
cost: ~5s/recipe via clawdforge. your 226-recipe sweep takes ~15-20 min.
</p>
</div>
<div id="progress-pane" style="display: none;">
<div class="progress-rail"><div class="progress-fill" id="bar" style="width: 0%;"></div></div>
<div class="progress-meta">
<span><strong id="processed">0</strong> processed</span>
<span><strong id="skipped">0</strong> already-clean</span>
<span><strong id="errors">0</strong> errors</span>
<span>of <strong id="total">?</strong></span>
<span class="muted" id="current-slug"></span>
</div>
<div class="btn-row" style="margin-top: 12px;">
<button class="btn" type="button" onclick="cancelJob()">cancel</button>
</div>
</div>
<div id="review-pane" style="display: none;">
<div class="review-bar">
<div class="left">
<span><strong id="approved-count">0</strong> selected</span>
<span class="muted" id="review-meta"></span>
</div>
<div class="right">
<button class="btn" type="button" onclick="setAll(true)">select all</button>
<button class="btn" type="button" onclick="setAll(false)">clear</button>
<button class="btn btn-purple" type="button" id="apply-btn" onclick="applyApproved()">apply selected →</button>
</div>
</div>
<div class="proposals-grid" id="proposals-grid"></div>
</div>
<div id="done-pane" style="display: none;">
<p id="done-line"></p>
<button class="btn btn-purple" type="button" onclick="startBulk()">↻ start a new run</button>
</div>
<div id="failed-pane" style="display: none;">
<p style="color: var(--crit);" id="failed-line"></p>
<button class="btn btn-purple" type="button" onclick="startBulk()">↻ retry</button>
</div>
</section>
<script>
let job = {{ (latest_job | tojson) if latest_job else 'null' }};
let pollTimer = null;
let proposals = [];
function $(id) { return document.getElementById(id); }
function showPane(name) {
for (const p of ['empty', 'progress', 'review', 'done', 'failed']) {
$(`${p}-pane`).style.display = (p === name) ? '' : 'none';
}
}
function setStatePill(text, klass) {
const el = $('state-pill');
el.textContent = text;
el.className = 'pill ' + (klass || 'pill-mute');
}
function paintProgress() {
if (!job) return;
const total = job.total_recipes || 0;
const done = (job.processed_count || 0) + (job.skipped_count || 0) + (job.error_count || 0);
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
$('bar').style.width = pct + '%';
$('processed').textContent = job.processed_count || 0;
$('skipped').textContent = job.skipped_count || 0;
$('errors').textContent = job.error_count || 0;
$('total').textContent = total || '?';
$('current-slug').textContent = job.current_slug ? `· ${job.current_slug}` : '';
}
async function fetchJob() {
try {
const r = await fetch('/api/sterilize/bulk-status');
const data = await r.json();
job = data.job || null;
route();
} catch (e) {
console.error('status poll failed', e);
}
}
function route() {
if (!job) {
stopPolling();
setStatePill('idle', 'pill-mute');
$('state-ctx').textContent = '';
showPane('empty');
return;
}
const state = job.state;
$('state-ctx').textContent = `started ${new Date(job.started_at).toLocaleString()}`;
if (state === 'running') {
setStatePill('walking', 'pill-ok');
paintProgress();
showPane('progress');
startPolling();
} else if (state === 'review') {
setStatePill('review', 'pill-ok');
paintProgress();
showPane('review');
stopPolling();
loadProposals();
} else if (state === 'applying') {
setStatePill('applying', 'pill-ok');
paintProgress();
showPane('progress');
startPolling();
} else if (state === 'done') {
setStatePill('done', 'pill-mute');
const total = job.processed_count || 0;
$('done-line').textContent = `applied ${total} recipe${total === 1 ? '' : 's'}. mealie should look cleaner.`;
showPane('done');
stopPolling();
} else if (state === 'failed') {
setStatePill('failed', 'pill-mute');
$('failed-line').textContent = job.last_error || 'job failed.';
showPane('failed');
stopPolling();
} else if (state === 'cancelled') {
setStatePill('cancelled', 'pill-mute');
$('done-line').textContent = 'job cancelled.';
showPane('done');
stopPolling();
}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(fetchJob, 2000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
async function startBulk() {
const btn = $('start-btn');
if (btn) { btn.disabled = true; btn.textContent = 'kicking off…'; }
try {
const r = await fetch('/api/sterilize/bulk-start', { method: 'POST' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || r.status);
}
await fetchJob();
} catch (e) {
alert('start failed: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = '🪄 start bulk sterilize'; }
}
}
async function cancelJob() {
if (!job) return;
if (!confirm('cancel this bulk run?')) return;
try {
await fetch('/api/sterilize/bulk-cancel/' + job.id, { method: 'POST' });
await fetchJob();
} catch (e) {
alert('cancel failed: ' + e.message);
}
}
async function loadProposals() {
if (!job) return;
try {
const r = await fetch('/api/sterilize/bulk-jobs/' + job.id + '/proposals');
const data = await r.json();
proposals = data.proposals || [];
// default: approve everything that has no preview_error
for (const p of proposals) {
if (p.approved === null || p.approved === undefined) {
p.approved = !p.preview_error;
}
}
renderProposals();
} catch (e) {
console.error('proposals load failed', e);
}
}
function renderProposals() {
const grid = $('proposals-grid');
grid.innerHTML = '';
let approvedCount = 0;
if (!proposals.length) {
grid.innerHTML = '<div class="empty-state">no proposals — every recipe was already clean.</div>';
$('apply-btn').disabled = true;
$('approved-count').textContent = 0;
$('review-meta').textContent = '';
return;
}
for (const p of proposals) {
if (p.approved) approvedCount++;
grid.appendChild(renderOne(p));
}
$('approved-count').textContent = approvedCount;
const total = proposals.filter(p => !p.preview_error).length;
$('review-meta').textContent = `· ${total} parseable, ${proposals.length - total} errored`;
$('apply-btn').disabled = approvedCount === 0;
}
function renderOne(p) {
const card = document.createElement('div');
card.className = 'proposal-card';
if (p.preview_error) card.classList.add('errored');
else if (p.approved) card.classList.add('approved');
else card.classList.add('rejected');
const head = document.createElement('div');
head.className = 'proposal-head';
const left = document.createElement('div');
left.style.flex = '1';
const nm = document.createElement('div');
nm.className = 'proposal-name';
nm.textContent = p.recipe_name || p.recipe_slug;
const meta = document.createElement('div');
meta.className = 'proposal-meta';
if (p.preview_error) {
meta.innerHTML = '<span class="pill pill-error">parse failed</span> ' + escapeHtml(p.preview_error);
} else {
meta.textContent = `${p.ingredient_count} ingredient${p.ingredient_count === 1 ? '' : 's'}` +
(p.applied_at ? ` · applied ${new Date(p.applied_at).toLocaleString()}` : '');
}
left.appendChild(nm);
left.appendChild(meta);
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle ' + (p.approved ? 'on' : 'off');
toggle.textContent = p.approved ? 'approved' : 'skip';
toggle.disabled = !!p.preview_error;
toggle.onclick = (e) => { e.stopPropagation(); flipApproval(p, card, toggle); };
head.appendChild(left);
head.appendChild(toggle);
head.onclick = () => { card.classList.toggle('expanded'); };
card.appendChild(head);
if (!p.preview_error && p.proposal_json && p.proposal_json.proposals) {
const rows = p.proposal_json.proposals.map(rowProposal).join('');
if (rows.trim()) {
const tbl = document.createElement('table');
tbl.className = 'diff-table';
tbl.innerHTML = `
<thead><tr><th>was</th><th>→</th><th>becomes</th></tr></thead>
<tbody>${rows}</tbody>`;
card.appendChild(tbl);
} else {
// All rows were identity matches — sage thinks this recipe is
// already clean. Show a marker; user can still skip/approve as
// a no-op apply (which will be cheap, just refreshes food.id
// resolution if any food row got renamed in Mealie).
const note = document.createElement('div');
note.className = 'proposal-meta';
note.style.marginTop = '6px';
note.textContent = 'no changes proposed (all ingredients already matched)';
card.appendChild(note);
}
}
return card;
}
function renderItem(pa) {
const parts = [];
if (pa.quantity !== null && pa.quantity !== undefined) parts.push(pa.quantity);
if (pa.unit) parts.push(pa.unit);
if (pa.food) parts.push(pa.food);
if (pa.note) parts.push(`(${pa.note})`);
return parts.length ? parts.join(' ') : '—';
}
function rowProposal(rp) {
// Render one or more rows for a single proposal. Fan-out shows
// multiple "becomes" rows under one "was" cell; identity rows
// (parse matches original verbatim) are dropped so the table
// only displays actual changes.
const wasParts = [];
if (rp.original_quantity !== null && rp.original_quantity !== undefined) wasParts.push(rp.original_quantity);
if (rp.original_unit_name) wasParts.push(rp.original_unit_name);
if (rp.original_food_name) wasParts.push(rp.original_food_name);
if (rp.original_note) wasParts.push(`(${rp.original_note})`);
const wasStr = wasParts.length ? wasParts.join(' ') : (rp.original_display || '—');
const items = Array.isArray(rp.parsed_items) ? rp.parsed_items
: (rp.parsed ? [rp.parsed] : []);
const fanout = items.length > 1;
const renderedNew = items.map(renderItem);
// Decide which rows to keep. In the 1→1 case, drop if was==new.
// In the fan-out case, always show every child (even if one happens
// to match a piece of the original).
let rows;
if (!fanout) {
if (renderedNew.length === 0 || renderedNew[0] === wasStr) return '';
rows = renderedNew;
} else {
rows = renderedNew;
}
if (!rows.length) return '';
const arrow = fanout
? `→<sup style="color:var(--purple-bright); margin-left:2px;">${rows.length}</sup>`
: '→';
return rows.map((newStr, idx) => {
const wasCell = (idx === 0)
? `<td class="orig" rowspan="${rows.length}">${escapeHtml(wasStr)}</td>`
: '';
const arrowCell = (idx === 0)
? `<td rowspan="${rows.length}">${arrow}</td>`
: '';
return `<tr>${wasCell}${arrowCell}<td class="new">${escapeHtml(newStr)}</td></tr>`;
}).join('');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[m]));
}
function flipApproval(p, card, toggle) {
p.approved = !p.approved;
card.classList.toggle('approved', p.approved);
card.classList.toggle('rejected', !p.approved);
toggle.classList.toggle('on', p.approved);
toggle.classList.toggle('off', !p.approved);
toggle.textContent = p.approved ? 'approved' : 'skip';
const cnt = proposals.filter(x => x.approved).length;
$('approved-count').textContent = cnt;
$('apply-btn').disabled = cnt === 0;
}
function setAll(on) {
for (const p of proposals) {
if (p.preview_error) continue;
p.approved = on;
}
renderProposals();
}
async function applyApproved() {
const slugs = proposals.filter(p => p.approved && !p.preview_error).map(p => p.recipe_slug);
if (!slugs.length) return;
if (!confirm(`apply ${slugs.length} recipe${slugs.length === 1 ? '' : 's'} to mealie?`)) return;
const btn = $('apply-btn');
btn.disabled = true; btn.textContent = 'applying…';
try {
const r = await fetch('/api/sterilize/bulk-apply/' + job.id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_slugs: slugs }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || r.status);
}
await fetchJob();
} catch (e) {
alert('apply failed: ' + e.message);
btn.disabled = false; btn.textContent = 'apply selected →';
}
}
// Boot
route();
// If we landed on a page mid-run, start polling immediately
if (job && (job.state === 'running' || job.state === 'applying')) startPolling();
</script>
{% endblock %}