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:
drjones
2026-07-29 17:16:00 -07:00
parent 501bfe2916
commit 3f67efb379
2 changed files with 300 additions and 7 deletions

View File

@@ -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