Add smart recategorize (auto-discovered categories) and fix stale category-keyword accumulation
- New "Smart Recategorize" panel: analyzes existing journal descriptions (no vision re-run, no images touched) to propose a richer category list when one bucket dominates the library, then reclassifies every tagged photo into it using a fast local text-only model pass in batches of 25. Verified end-to-end on the real library: correctly split "Objects & Stuff" (35% of 15k photos) into specific categories like Electronics & Hardware, Cannabis Cultivation, Minerals & Crystals, Tools & Workshop based on what's actually in the descriptions. - New ollama_generate_text() for text-only local model calls (the existing ollama_generate always sent an "images" key, unsuitable for pure text classification). - FIX: write_metadata only ever appended category/photon-tagged keywords (+=) to the list-type XMP/IPTC tags, so retagging a photo left every category it ever had stacked in its metadata, and even rewriting with the same category duplicated "photon-tagged" each time. Found via a live test while building this feature (which recategorizes at library scale). Now strips old values before adding, making every write idempotent regardless of how many times a photo is retagged. Wired through all four write sites (pipeline, write_single, redo_single, bulk_recategorize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
77
index.html
77
index.html
@@ -720,6 +720,25 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Recategorize -->
|
||||
<div class="panel">
|
||||
<h2>🧠 <b>smart recategorize</b> — auto-discover categories from your library</h2>
|
||||
<div class="set">
|
||||
<div class="hint">Analyzes descriptions already sitting in your journal (no images re-scanned, no vision model) to propose a richer category list when one bucket is swallowing the library, then reclassifies every tagged photo into it using a fast local text model.</div>
|
||||
<button id="btnProposeCats" class="btn-primary" style="width:100%; margin-top:10px">✨ Propose New Categories</button>
|
||||
<div id="recatProposalBox" style="display:none; margin-top:12px">
|
||||
<label>proposed categories — edit before applying (one per line)</label>
|
||||
<textarea id="recatProposalList" rows="10" style="width:100%; font-family:var(--font-mono); font-size:11px; background:var(--panel2); border:1px solid var(--line); color:var(--txt); border-radius:4px; padding:8px" spellcheck="false"></textarea>
|
||||
<button id="btnApplyRecat" class="btn-danger" style="width:100%; margin-top:10px">⚠ Apply — Reclassify Every Photo</button>
|
||||
<div class="hint">This rewrites the category keyword in every tagged photo's metadata (descriptions are untouched). Reversible per-photo the same way any recategorize is — nothing about the image itself changes.</div>
|
||||
</div>
|
||||
<div id="recatProgressBox" style="display:none; margin-top:12px">
|
||||
<div class="pbar"><div class="fill" id="recatFill"></div><div class="glint"></div></div>
|
||||
<div class="hint" id="recatStatusText">starting…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Safety Panel -->
|
||||
<div class="panel">
|
||||
<h2>🛡 <b>trust & safety console</b></h2>
|
||||
@@ -1985,6 +2004,51 @@ $("btnScanDupes").onclick = async ()=>{
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- smart recategorize ---- */
|
||||
function applyRecatProgress(p){
|
||||
$("recatProgressBox").style.display = "block";
|
||||
const pct = p.total ? Math.round(100 * p.done / p.total) : 0;
|
||||
$("recatFill").style.width = pct + "%";
|
||||
$("recatStatusText").textContent = p.finished
|
||||
? `Done — ${p.changed} of ${p.total} photos moved to a new category.`
|
||||
: `Reclassifying… ${p.done}/${p.total} (${p.changed} changed so far)`;
|
||||
if(p.finished){
|
||||
$("btnProposeCats").disabled = false;
|
||||
$("btnApplyRecat").disabled = false;
|
||||
renderCatChips();
|
||||
doSearch(true);
|
||||
}
|
||||
}
|
||||
|
||||
$("btnProposeCats").onclick = async ()=>{
|
||||
$("btnProposeCats").disabled = true;
|
||||
$("btnProposeCats").textContent = "Analyzing your library…";
|
||||
const j = await api("/api/recategorize/propose", {model: "qwen3.5:9b"});
|
||||
$("btnProposeCats").disabled = false;
|
||||
$("btnProposeCats").textContent = "✨ Propose New Categories";
|
||||
if(j.ok && j.categories){
|
||||
$("recatProposalList").value = j.categories.join("\n");
|
||||
$("recatProposalBox").style.display = "block";
|
||||
showToast("Proposed " + j.categories.length + " categories — review before applying", "ok");
|
||||
}
|
||||
};
|
||||
|
||||
$("btnApplyRecat").onclick = async ()=>{
|
||||
const cats = $("recatProposalList").value.split("\n").map(s=>s.trim()).filter(Boolean);
|
||||
if(cats.length < 2){ showToast("Need at least a couple of categories", "error"); return; }
|
||||
if(!confirm(`This will re-read the description of every tagged photo and, using a local text model, may change its category — rewriting the category keyword in that photo's metadata (descriptions stay untouched). Proceed with ${cats.length} categories?`)) return;
|
||||
$("btnApplyRecat").disabled = true;
|
||||
$("btnProposeCats").disabled = true;
|
||||
const j = await api("/api/recategorize/apply", {categories: cats, model: "qwen3.5:9b"});
|
||||
if(j.ok){
|
||||
showToast("Recategorize started — watch progress below", "ok");
|
||||
applyRecatProgress({done:0,total:0,changed:0});
|
||||
} else {
|
||||
$("btnApplyRecat").disabled = false;
|
||||
$("btnProposeCats").disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- audit history dashboard ---- */
|
||||
async function loadAuditHistory(){
|
||||
const j = await api("/api/audit/history");
|
||||
@@ -2116,6 +2180,9 @@ function connect(){
|
||||
if(m.data.finished) loadDupeGroups();
|
||||
}
|
||||
}
|
||||
else if(m.type==="recat"){
|
||||
applyRecatProgress(m.data);
|
||||
}
|
||||
};
|
||||
es.onerror = ()=>{ es.close(); setTimeout(connect, 2000); };
|
||||
}
|
||||
@@ -2132,6 +2199,16 @@ function connect(){
|
||||
setBadge(st.status||"idle");
|
||||
if(st.total>0) scanned=true, setBadge(st.status||"idle");
|
||||
|
||||
if(st.recatProposal && st.recatProposal.length){
|
||||
$("recatProposalList").value = st.recatProposal.join("\n");
|
||||
$("recatProposalBox").style.display = "block";
|
||||
}
|
||||
if(st.recatStatus === "running"){
|
||||
$("btnProposeCats").disabled = true;
|
||||
$("btnApplyRecat").disabled = true;
|
||||
applyRecatProgress(st.recatProgress || {done:0,total:0,changed:0});
|
||||
}
|
||||
|
||||
const mj = await api("/api/models");
|
||||
const sel=$("model");
|
||||
const visionModels=(mj.models||[]).filter(m=>m.vision && m.name!=="qwen3.5:4b-mlx"); // mlx build: vision is broken
|
||||
|
||||
Reference in New Issue
Block a user