cauldron/cauldron/templates/sterilize.html
Kayos 9368b64a81 v0.3 step 6: bulk sterilizer — automate mealie's per-recipe Parse toil
Mealie's "Parse" button on every recipe is per-recipe; clicking through
226 of them is the toil Cobb explicitly hates. The bulk sterilizer wraps
cauldron's existing per-recipe Sterilizer (clawdforge → Sonnet, parses
free-form ingredient strings into structured form) into a household-wide
job with progress tracking, batch human review, and one-shot apply.

Flow:
  1. POST /api/sterilize/bulk-start — creates cauldron_sterilize_jobs row,
     spawns a daemon thread that walks every recipe in the user's
     household. Recipes whose ingredients all already have food.id are
     skipped (no point re-parsing Cobb's manual cleanup). For each
     recipe needing work, Sonnet returns a structured proposal that gets
     persisted to cauldron_sterilize_proposals.
  2. GET /api/sterilize/bulk-status — polled every 2s by the UI for
     {state, processed_count, skipped_count, error_count, current_slug}.
  3. After the walk completes, state moves to 'review'. UI loads
     /api/sterilize/bulk-jobs/<id>/proposals and renders one card per
     recipe with a was→becomes diff per ingredient. User toggles
     approve/skip per recipe.
  4. POST /api/sterilize/bulk-apply/<id> with {approved_slugs: [...]}.
     A second daemon thread iterates approved proposals, calls
     Sterilizer.apply_recipe (which resolves food/unit IDs in Mealie,
     creating any missing rows, then PUT /api/recipes/<slug>).

Job state machine: running → review → applying → done (or 'cancelled' /
'failed' along the way). At app startup, fail_stuck_sterilize_jobs()
recovers any 'running' / 'applying' rows older than 10 min with no
progress — covers the case where the daemon thread's gunicorn worker
died mid-job. New job state lives in DB; thread is just a runner.

Concurrency: the start endpoint blocks if the household already has a
running/applying job. Sterilize calls cost clawdforge time and parallel
jobs would race on Mealie writes, so one-at-a-time per household is the
right ceiling.

UI lives at /sterilize. Linked from /me → "tools → bulk sterilize" since
it's a one-off admin action, not part of the daily flow. Mobile-aware,
diff cards expand on click. Disabled "apply" button when no recipes
are selected; preview-error rows can't be approved.

DB: two new tables (migrations 015+016). cauldron_sterilize_jobs tracks
overall job state with last_progress_at for the stuck-job recovery;
cauldron_sterilize_proposals holds per-recipe JSON proposals with the
approval flag and the final apply_at/apply_error.
2026-04-29 22:26:10 -07:00

464 lines
16 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
sonnet, 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 tbl = document.createElement('table');
tbl.className = 'diff-table';
tbl.innerHTML = `
<thead><tr>
<th>was</th><th>→</th><th>becomes</th>
</tr></thead>
<tbody>
${p.proposal_json.proposals.map(rowProposal).join('')}
</tbody>`;
card.appendChild(tbl);
}
return card;
}
function rowProposal(rp) {
const pa = rp.parsed || {};
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 newParts = [];
if (pa.quantity !== null && pa.quantity !== undefined) newParts.push(pa.quantity);
if (pa.unit) newParts.push(pa.unit);
if (pa.food) newParts.push(pa.food);
if (pa.note) newParts.push(`(${pa.note})`);
const newStr = newParts.length ? newParts.join(' ') : '—';
return `<tr>
<td class="orig">${escapeHtml(wasStr)}</td>
<td>→</td>
<td class="new">${escapeHtml(newStr)}</td>
</tr>`;
}
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 %}