Files
ATOCore/src/atocore/engineering/triage_ui.py
Anto01 028d4c3594 feat: Phase 7A — semantic memory dedup ("sleep cycle" V1)
New table memory_merge_candidates + service functions to cluster
near-duplicate active memories within (project, memory_type) buckets,
draft a unified content via LLM, and merge on human approval. Source
memories become superseded (never deleted); merged memory carries
union of tags, max of confidence, sum of reference_count.

- schema migration for memory_merge_candidates
- atocore.memory.similarity: cosine + transitive clustering
- atocore.memory._dedup_prompt: stdlib-only LLM prompt preserving every specific
- service: merge_memories / create_merge_candidate / get_merge_candidates / reject_merge_candidate
- scripts/memory_dedup.py: host-side detector (HTTP-only, idempotent)
- 5 API endpoints under /admin/memory/merge-candidates* + /admin/memory/dedup-scan
- triage UI: purple "🔗 Merge Candidates" section + "🔗 Scan for duplicates" bar
- batch-extract.sh Step B3 (0.90 daily, 0.85 Sundays)
- deploy/dalidou/dedup-watcher.sh for UI-triggered scans
- 21 new tests (374 → 395)
- docs/PHASE-7-MEMORY-CONSOLIDATION.md covering 7A-7H roadmap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 10:30:49 -04:00

748 lines
31 KiB
Python

"""Human triage UI for AtoCore candidate memories.
Renders a lightweight HTML page at /admin/triage with all pending
candidate memories, each with inline Promote / Reject / Edit buttons.
No framework, no JS build, no database — reads candidates from the
AtoCore DB and posts back to the existing REST endpoints.
Design principle: the user should be able to triage 20 candidates in
60 seconds from any browser. Keyboard shortcuts (y/n/e/s) make it
feel like email triage (archive/delete).
"""
from __future__ import annotations
import html as _html
from atocore.engineering.wiki import render_html
from atocore.memory.service import get_memories
VALID_TYPES = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
def _escape(s: str | None) -> str:
return _html.escape(s or "", quote=True)
def _render_candidate_card(cand) -> str:
"""One candidate row with inline forms for promote/reject/edit."""
mid = _escape(cand.id)
content = _escape(cand.content)
memory_type = _escape(cand.memory_type)
project = _escape(cand.project or "")
project_display = project or "(global)"
confidence = f"{cand.confidence:.2f}"
refs = cand.reference_count or 0
created = _escape(str(cand.created_at or ""))
tags = cand.domain_tags or []
tags_str = _escape(", ".join(tags))
valid_until = _escape(cand.valid_until or "")
# Strip time portion for HTML date input
valid_until_date = valid_until[:10] if valid_until else ""
type_options = "".join(
f'<option value="{t}"{" selected" if t == cand.memory_type else ""}>{t}</option>'
for t in VALID_TYPES
)
# Tag badges rendered from current tags
badges_html = ""
if tags:
badges_html = '<div class="cand-tags-display">' + "".join(
f'<span class="tag-badge">{_escape(t)}</span>' for t in tags
) + '</div>'
return f"""
<div class="cand" id="cand-{mid}" data-id="{mid}">
<div class="cand-head">
<span class="cand-type">[{memory_type}]</span>
<span class="cand-project">{project_display}</span>
<span class="cand-meta">conf {confidence} · refs {refs} · {created[:16]}</span>
</div>
<div class="cand-body">
<textarea class="cand-content" id="content-{mid}">{content}</textarea>
</div>
{badges_html}
<div class="cand-meta-row">
<label class="cand-field-label">Tags:
<input type="text" class="cand-tags-input" id="tags-{mid}"
value="{tags_str}" placeholder="optics, thermal, p04" />
</label>
<label class="cand-field-label">Valid until:
<input type="date" class="cand-valid-until" id="valid-until-{mid}"
value="{valid_until_date}" />
</label>
</div>
<div class="cand-actions">
<button class="btn-promote" data-id="{mid}" title="Promote (Y)">✅ Promote</button>
<button class="btn-reject" data-id="{mid}" title="Reject (N)">❌ Reject</button>
<button class="btn-save-promote" data-id="{mid}" title="Save edits + promote (E)">✏️ Save&Promote</button>
<label class="cand-type-label">Type:
<select class="cand-type-select" id="type-{mid}">{type_options}</select>
</label>
</div>
<div class="cand-status" id="status-{mid}"></div>
</div>
"""
_TRIAGE_SCRIPT = """
<script>
async function apiCall(url, method, body) {
try {
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
return { ok: res.ok, status: res.status, json: res.ok ? await res.json().catch(()=>null) : null };
} catch (e) { return { ok: false, status: 0, error: String(e) }; }
}
async function requestAutoTriage() {
const btn = document.getElementById('auto-triage-btn');
const status = document.getElementById('auto-triage-status');
if (!btn) return;
btn.disabled = true;
btn.textContent = '⏳ Requesting...';
const r = await apiCall('/admin/triage/request-drain', 'POST');
if (r.ok) {
status.textContent = '✓ Requested. Host watcher runs every 2 min. Refresh this page in a minute to check progress.';
status.className = 'auto-triage-msg ok';
btn.textContent = '✓ Requested';
pollDrainStatus();
} else {
status.textContent = '❌ Request failed: ' + r.status;
status.className = 'auto-triage-msg err';
btn.disabled = false;
btn.textContent = '🤖 Auto-process queue';
}
}
async function pollDrainStatus() {
const status = document.getElementById('auto-triage-status');
const btn = document.getElementById('auto-triage-btn');
let polls = 0;
const timer = setInterval(async () => {
polls++;
const r = await apiCall('/admin/triage/drain-status', 'GET');
if (!r.ok || !r.json) return;
const s = r.json;
if (s.is_running) {
status.textContent = '⚙️ Auto-triage running on host... (started ' + (s.last_started_at || '?') + ')';
status.className = 'auto-triage-msg ok';
} else if (s.last_finished_at && !s.requested_at) {
status.textContent = '✅ Last run finished: ' + s.last_finished_at + '' + (s.last_result || 'complete');
status.className = 'auto-triage-msg ok';
if (btn) { btn.disabled = false; btn.textContent = '🤖 Auto-process queue'; }
clearInterval(timer);
// Reload page to pick up new queue state
setTimeout(() => window.location.reload(), 3000);
}
if (polls > 60) { clearInterval(timer); } // stop after ~10 min of polling
}, 10000); // poll every 10s
}
function setStatus(id, msg, ok) {
const el = document.getElementById('status-' + id);
if (!el) return;
el.textContent = msg;
el.className = 'cand-status ' + (ok ? 'ok' : 'err');
}
function removeCard(id) {
setTimeout(() => {
const card = document.getElementById('cand-' + id);
if (card) {
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
}
updateCount();
}, 400);
}
function updateCount() {
const n = document.querySelectorAll('.cand').length;
const el = document.getElementById('cand-count');
if (el) el.textContent = n;
const next = document.querySelector('.cand');
if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function promote(id) {
setStatus(id, 'Promoting…', true);
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
if (r.ok) { setStatus(id, '✅ Promoted', true); removeCard(id); }
else setStatus(id, '❌ Failed: ' + r.status, false);
}
async function reject(id) {
setStatus(id, 'Rejecting…', true);
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/reject', 'POST');
if (r.ok) { setStatus(id, '❌ Rejected', true); removeCard(id); }
else setStatus(id, '❌ Failed: ' + r.status, false);
}
function parseTags(str) {
return (str || '').split(/[,;]/).map(s => s.trim().toLowerCase()).filter(Boolean);
}
async function savePromote(id) {
const content = document.getElementById('content-' + id).value.trim();
const mtype = document.getElementById('type-' + id).value;
const tagsStr = document.getElementById('tags-' + id)?.value || '';
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
if (!content) { setStatus(id, 'Content is empty', false); return; }
setStatus(id, 'Saving…', true);
const body = {
content: content,
memory_type: mtype,
domain_tags: parseTags(tagsStr),
valid_until: validUntil,
};
const r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', body);
if (!r1.ok) { setStatus(id, '❌ Save failed: ' + r1.status, false); return; }
const r2 = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
if (r2.ok) { setStatus(id, '✅ Saved & Promoted', true); removeCard(id); }
else setStatus(id, '❌ Promote failed: ' + r2.status, false);
}
// Also save tag/expiry edits when plain "Promote" is clicked if fields changed
async function promoteWithMeta(id) {
const tagsStr = document.getElementById('tags-' + id)?.value || '';
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
if (tagsStr.trim() || validUntil) {
await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
domain_tags: parseTags(tagsStr),
valid_until: validUntil,
});
}
return promote(id);
}
document.addEventListener('click', (e) => {
const id = e.target.dataset?.id;
if (!id) return;
if (e.target.classList.contains('btn-promote')) promoteWithMeta(id);
else if (e.target.classList.contains('btn-reject')) reject(id);
else if (e.target.classList.contains('btn-save-promote')) savePromote(id);
});
// Keyboard shortcuts on the currently-focused card
document.addEventListener('keydown', (e) => {
// Don't intercept if user is typing in textarea/select/input
const t = e.target.tagName;
if (t === 'TEXTAREA' || t === 'INPUT' || t === 'SELECT') return;
const first = document.querySelector('.cand');
if (!first) return;
const id = first.dataset.id;
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); promoteWithMeta(id); }
else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); reject(id); }
else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
document.getElementById('content-' + id)?.focus();
}
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); first.scrollIntoView({behavior:'smooth'}); }
});
</script>
"""
_TRIAGE_CSS = """
<style>
.triage-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:1rem; }
.triage-header .count { font-size:1.4rem; font-weight:600; color:var(--accent); }
.triage-help { background:var(--card); border-left:4px solid var(--accent); padding:0.8rem 1rem; margin-bottom:1.5rem; border-radius:4px; font-size:0.9rem; }
.triage-help kbd { background:var(--hover); padding:2px 6px; border-radius:3px; font-family:monospace; font-size:0.85em; border:1px solid var(--border); }
.cand { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem; margin-bottom:1rem; transition:opacity 0.3s; }
.cand-head { display:flex; gap:0.8rem; align-items:center; margin-bottom:0.6rem; font-size:0.9rem; }
.cand-type { font-weight:600; color:var(--accent); font-family:monospace; }
.cand-project { color:var(--text); opacity:0.8; font-family:monospace; }
.cand-meta { color:var(--text); opacity:0.55; font-size:0.8rem; margin-left:auto; }
.cand-content { width:100%; min-height:80px; font-family:inherit; font-size:0.95rem; padding:0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; resize:vertical; box-sizing:border-box; }
.cand-content:focus { outline:none; border-color:var(--accent); }
.cand-actions { display:flex; gap:0.5rem; margin-top:0.8rem; align-items:center; flex-wrap:wrap; }
.cand-actions button { padding:0.4rem 0.9rem; border:1px solid var(--border); background:var(--card); color:var(--text); border-radius:4px; cursor:pointer; font-size:0.88rem; }
.cand-actions button:hover { background:var(--hover); }
.btn-promote:hover { background:#059669; color:white; border-color:#059669; }
.btn-reject:hover { background:#dc2626; color:white; border-color:#dc2626; }
.btn-save-promote:hover { background:var(--accent); color:white; border-color:var(--accent); }
.cand-type-label { font-size:0.85rem; margin-left:auto; opacity:0.7; }
.cand-type-select { padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; }
.cand-status { margin-top:0.5rem; font-size:0.85rem; min-height:1.2em; }
.cand-status.ok { color:#059669; }
.cand-status.err { color:#dc2626; }
.empty { text-align:center; padding:3rem; opacity:0.6; }
.auto-triage-bar { display:flex; gap:0.8rem; align-items:center; background:var(--card); border:1px solid var(--border); border-radius:6px; padding:0.7rem 1rem; margin-bottom:1.2rem; flex-wrap:wrap; }
.auto-triage-bar button { padding:0.55rem 1.1rem; border:1px solid var(--accent); background:var(--accent); color:white; border-radius:4px; cursor:pointer; font-weight:600; font-size:0.95rem; }
.auto-triage-bar button:hover:not(:disabled) { opacity:0.9; }
.auto-triage-bar button:disabled { opacity:0.5; cursor:not-allowed; }
.auto-triage-msg { flex:1; min-width:200px; font-size:0.85rem; opacity:0.75; }
.auto-triage-msg.ok { color:var(--accent); opacity:1; font-weight:500; }
.auto-triage-msg.err { color:#dc2626; opacity:1; font-weight:500; }
.cand-tags-display { margin-top:0.5rem; display:flex; gap:0.35rem; flex-wrap:wrap; }
.tag-badge { background:var(--accent); color:white; padding:0.15rem 0.55rem; border-radius:10px; font-size:0.72rem; font-family:monospace; font-weight:500; }
.cand-meta-row { display:flex; gap:0.8rem; margin-top:0.6rem; align-items:center; flex-wrap:wrap; }
.cand-field-label { display:flex; gap:0.3rem; align-items:center; font-size:0.85rem; opacity:0.75; }
.cand-tags-input { flex:1; min-width:200px; padding:0.3rem 0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; font-size:0.85rem; }
.cand-tags-input:focus { outline:none; border-color:var(--accent); }
.cand-valid-until { padding:0.3rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:inherit; font-size:0.85rem; }
</style>
"""
def _render_entity_card(entity) -> str:
"""Phase 5: entity candidate card with promote/reject."""
eid = _escape(entity.id)
name = _escape(entity.name)
etype = _escape(entity.entity_type)
project = _escape(entity.project or "(global)")
desc = _escape(entity.description or "")
conf = f"{entity.confidence:.2f}"
src_refs = entity.source_refs or []
source_display = _escape(", ".join(src_refs[:3])) if src_refs else "(no provenance)"
return f"""
<div class="cand cand-entity" id="ecand-{eid}" data-entity-id="{eid}">
<div class="cand-head">
<span class="cand-type entity-type">[entity · {etype}]</span>
<span class="cand-project">{project}</span>
<span class="cand-meta">conf {conf} · src: {source_display}</span>
</div>
<div class="cand-body">
<div class="entity-name">{name}</div>
<div class="entity-desc">{desc}</div>
</div>
<div class="cand-actions">
<button class="btn-entity-promote" data-entity-id="{eid}" title="Promote entity (Y)">✅ Promote Entity</button>
<button class="btn-entity-reject" data-entity-id="{eid}" title="Reject entity (N)">❌ Reject</button>
<a class="btn-link" href="/wiki/entities/{eid}">View in wiki →</a>
</div>
<div class="cand-status" id="estatus-{eid}"></div>
</div>
"""
_ENTITY_TRIAGE_SCRIPT = """
<script>
async function entityPromote(id) {
const st = document.getElementById('estatus-' + id);
st.textContent = 'Promoting…';
st.className = 'cand-status ok';
const r = await fetch('/entities/' + encodeURIComponent(id) + '/promote', {method:'POST'});
if (r.ok) {
st.textContent = '✅ Entity promoted';
setTimeout(() => {
const card = document.getElementById('ecand-' + id);
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
}, 400);
} else st.textContent = '' + r.status;
}
async function entityReject(id) {
const st = document.getElementById('estatus-' + id);
st.textContent = 'Rejecting…';
st.className = 'cand-status ok';
const r = await fetch('/entities/' + encodeURIComponent(id) + '/reject', {method:'POST'});
if (r.ok) {
st.textContent = '❌ Entity rejected';
setTimeout(() => {
const card = document.getElementById('ecand-' + id);
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
}, 400);
} else st.textContent = '' + r.status;
}
document.addEventListener('click', (e) => {
const eid = e.target.dataset?.entityId;
if (!eid) return;
if (e.target.classList.contains('btn-entity-promote')) entityPromote(eid);
else if (e.target.classList.contains('btn-entity-reject')) entityReject(eid);
});
</script>
"""
_ENTITY_TRIAGE_CSS = """
<style>
.cand-entity { border-left: 3px solid #059669; }
.entity-type { background: #059669; color: white; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75rem; }
.entity-name { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.3rem; }
.entity-desc { opacity: 0.85; font-size: 0.95rem; }
.btn-entity-promote { background: #059669; color: white; border-color: #059669; }
.btn-entity-reject:hover { background: #dc2626; color: white; border-color: #dc2626; }
.btn-link { padding: 0.4rem 0.9rem; text-decoration: none; color: var(--accent); border: 1px solid var(--border); border-radius: 4px; font-size: 0.88rem; }
.btn-link:hover { background: var(--hover); }
.section-break { border-top: 2px solid var(--border); margin: 2rem 0 1rem 0; padding-top: 1rem; }
</style>
"""
# ---------------------------------------------------------------------
# Phase 7A — Merge candidates (semantic dedup)
# ---------------------------------------------------------------------
_MERGE_TRIAGE_CSS = """
<style>
.cand-merge { border-left: 3px solid #8b5cf6; }
.merge-type { background: #8b5cf6; color: white; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75rem; }
.merge-sources { margin: 0.5rem 0 0.8rem 0; display: flex; flex-direction: column; gap: 0.35rem; }
.merge-source { background: var(--bg); border: 1px dashed var(--border); border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.85rem; }
.merge-source-meta { font-family: monospace; font-size: 0.72rem; opacity: 0.7; margin-bottom: 0.2rem; }
.merge-arrow { text-align: center; font-size: 1.1rem; opacity: 0.5; margin: 0.3rem 0; }
.merge-proposed { background: var(--card); border: 1px solid #8b5cf6; border-radius: 4px; padding: 0.5rem; }
.btn-merge-approve { background: #8b5cf6; color: white; border-color: #8b5cf6; }
.btn-merge-approve:hover { background: #7c3aed; }
</style>
"""
def _render_merge_card(cand: dict) -> str:
import json as _json
cid = _escape(cand.get("id", ""))
sim = cand.get("similarity") or 0.0
sources = cand.get("sources") or []
proposed_content = cand.get("proposed_content") or ""
proposed_tags = cand.get("proposed_tags") or []
proposed_project = cand.get("proposed_project") or ""
reason = cand.get("reason") or ""
src_html = "".join(
f"""
<div class="merge-source">
<div class="merge-source-meta">
{_escape(s.get('id','')[:8])} · [{_escape(s.get('memory_type',''))}]
· {_escape(s.get('project','') or '(global)')}
· conf {float(s.get('confidence',0)):.2f}
· refs {int(s.get('reference_count',0))}
</div>
<div>{_escape((s.get('content') or '')[:300])}</div>
</div>
"""
for s in sources
)
tags_str = ", ".join(proposed_tags)
return f"""
<div class="cand cand-merge" id="mcand-{cid}" data-merge-id="{cid}">
<div class="cand-head">
<span class="cand-type merge-type">[merge · {len(sources)} sources]</span>
<span class="cand-project">{_escape(proposed_project or '(global)')}</span>
<span class="cand-meta">sim ≥ {sim:.2f}</span>
</div>
<div class="merge-sources">{src_html}</div>
<div class="merge-arrow">↓ merged into ↓</div>
<div class="merge-proposed">
<textarea class="cand-content" id="mcontent-{cid}">{_escape(proposed_content)}</textarea>
<div class="cand-meta-row">
<label class="cand-field-label">Tags:
<input type="text" class="cand-tags-input" id="mtags-{cid}" value="{_escape(tags_str)}" placeholder="tag1, tag2">
</label>
</div>
{f'<div class="auto-triage-msg" style="margin-top:0.4rem;">💡 {_escape(reason)}</div>' if reason else ''}
</div>
<div class="cand-actions">
<button class="btn-merge-approve" data-merge-id="{cid}" title="Approve merge">✅ Approve Merge</button>
<button class="btn-reject" data-merge-id="{cid}" data-merge-reject="1" title="Keep separate">❌ Keep Separate</button>
</div>
<div class="cand-status" id="mstatus-{cid}"></div>
</div>
"""
_MERGE_TRIAGE_SCRIPT = """
<script>
async function mergeApprove(id) {
const st = document.getElementById('mstatus-' + id);
st.textContent = 'Merging…';
st.className = 'cand-status ok';
const content = document.getElementById('mcontent-' + id).value;
const tagsRaw = document.getElementById('mtags-' + id).value;
const tags = tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
const r = await fetch('/admin/memory/merge-candidates/' + encodeURIComponent(id) + '/approve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({actor: 'human-triage', content: content, domain_tags: tags}),
});
if (r.ok) {
const data = await r.json();
st.textContent = '✅ Merged → ' + (data.result_memory_id || '').slice(0, 8);
setTimeout(() => {
const card = document.getElementById('mcand-' + id);
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
}, 600);
} else {
const err = await r.text();
st.textContent = '' + r.status + ': ' + err.slice(0, 120);
st.className = 'cand-status err';
}
}
async function mergeReject(id) {
const st = document.getElementById('mstatus-' + id);
st.textContent = 'Rejecting…';
st.className = 'cand-status ok';
const r = await fetch('/admin/memory/merge-candidates/' + encodeURIComponent(id) + '/reject', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({actor: 'human-triage'}),
});
if (r.ok) {
st.textContent = '❌ Kept separate';
setTimeout(() => {
const card = document.getElementById('mcand-' + id);
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
}, 400);
} else st.textContent = '' + r.status;
}
document.addEventListener('click', (e) => {
const mid = e.target.dataset?.mergeId;
if (!mid) return;
if (e.target.classList.contains('btn-merge-approve')) mergeApprove(mid);
else if (e.target.dataset?.mergeReject) mergeReject(mid);
});
async function requestDedupScan() {
const btn = document.getElementById('dedup-btn');
const status = document.getElementById('dedup-status');
btn.disabled = true;
btn.textContent = 'Queuing…';
status.textContent = '';
status.className = 'auto-triage-msg';
const threshold = parseFloat(document.getElementById('dedup-threshold').value || '0.88');
const r = await fetch('/admin/memory/dedup-scan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: '', similarity_threshold: threshold, max_batch: 50}),
});
if (r.ok) {
status.textContent = `✓ Queued dedup scan at threshold ${threshold}. Host watcher runs every 2 min; refresh in ~3 min to see merge candidates.`;
status.className = 'auto-triage-msg ok';
} else {
status.textContent = '' + r.status;
status.className = 'auto-triage-msg err';
}
setTimeout(() => {
btn.disabled = false;
btn.textContent = '🔗 Scan for duplicates';
}, 2000);
}
</script>
"""
def _render_dedup_bar() -> str:
return """
<div class="auto-triage-bar">
<button id="dedup-btn" onclick="requestDedupScan()" title="Run semantic dedup scan on Dalidou host">
🔗 Scan for duplicates
</button>
<label class="cand-field-label" style="margin:0 0.5rem;">
Threshold:
<input id="dedup-threshold" type="number" min="0.70" max="0.99" step="0.01" value="0.88"
style="width:70px; padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px;">
</label>
<span id="dedup-status" class="auto-triage-msg">
Finds semantically near-duplicate active memories and proposes LLM-drafted merges for review. Source memories become <code>superseded</code> on approve; nothing is deleted.
</span>
</div>
"""
def _render_graduation_bar() -> str:
"""The 'Graduate memories → entity candidates' control bar."""
from atocore.projects.registry import load_project_registry
try:
projects = load_project_registry()
options = '<option value="">(all projects)</option>' + "".join(
f'<option value="{_escape(p.project_id)}">{_escape(p.project_id)}</option>'
for p in projects
)
except Exception:
options = '<option value="">(all projects)</option>'
return f"""
<div class="auto-triage-bar graduation-bar">
<button id="grad-btn" onclick="requestGraduation()" title="Run memory→entity graduation on Dalidou host">
🎓 Graduate memories
</button>
<label class="cand-field-label">Project:
<select id="grad-project" class="cand-type-select">{options}</select>
</label>
<label class="cand-field-label">Limit:
<input id="grad-limit" type="number" class="cand-tags-input" style="max-width:80px"
value="30" min="1" max="200" />
</label>
<span id="grad-status" class="auto-triage-msg">
Scans active memories, asks the LLM "does this describe a typed entity?",
and creates entity candidates. Review them in the Entity section below.
</span>
</div>
"""
_GRADUATION_SCRIPT = """
<script>
async function requestGraduation() {
const btn = document.getElementById('grad-btn');
const status = document.getElementById('grad-status');
const project = document.getElementById('grad-project').value;
const limit = parseInt(document.getElementById('grad-limit').value || '30', 10);
btn.disabled = true;
btn.textContent = '⏳ Requesting...';
const r = await fetch('/admin/graduation/request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project, limit}),
});
if (r.ok) {
const scope = project || 'all projects';
status.textContent = `✓ Queued graduation for ${scope} (limit ${limit}). Host watcher runs every 2 min; refresh this page in ~3 min to see candidates.`;
status.className = 'auto-triage-msg ok';
btn.textContent = '✓ Requested';
pollGraduationStatus();
} else {
status.textContent = '❌ Request failed: ' + r.status;
status.className = 'auto-triage-msg err';
btn.disabled = false;
btn.textContent = '🎓 Graduate memories';
}
}
async function pollGraduationStatus() {
const status = document.getElementById('grad-status');
const btn = document.getElementById('grad-btn');
let polls = 0;
const timer = setInterval(async () => {
polls++;
const r = await fetch('/admin/graduation/status');
if (!r.ok) return;
const s = await r.json();
if (s.is_running) {
status.textContent = '⚙️ Graduation running... (started ' + (s.last_started_at || '?') + ')';
status.className = 'auto-triage-msg ok';
} else if (s.last_finished_at && !s.requested) {
status.textContent = '✅ Finished: ' + s.last_finished_at + '' + (s.last_result || 'complete');
status.className = 'auto-triage-msg ok';
if (btn) { btn.disabled = false; btn.textContent = '🎓 Graduate memories'; }
clearInterval(timer);
setTimeout(() => window.location.reload(), 3000);
}
if (polls > 120) { clearInterval(timer); } // ~20 min cap
}, 10000);
}
</script>
"""
def render_triage_page(limit: int = 100) -> str:
"""Render the full triage page with pending memory + entity candidates."""
from atocore.engineering.service import get_entities
try:
mem_candidates = get_memories(status="candidate", limit=limit)
except Exception as e:
body = f"<p>Error loading memory candidates: {_escape(str(e))}</p>"
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
try:
entity_candidates = get_entities(status="candidate", limit=limit)
except Exception as e:
entity_candidates = []
try:
from atocore.memory.service import get_merge_candidates
merge_candidates = get_merge_candidates(status="pending", limit=limit)
except Exception:
merge_candidates = []
total = len(mem_candidates) + len(entity_candidates) + len(merge_candidates)
graduation_bar = _render_graduation_bar()
dedup_bar = _render_dedup_bar()
if total == 0:
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + _MERGE_TRIAGE_CSS + f"""
<div class="triage-header">
<h1>Triage Queue</h1>
</div>
{graduation_bar}
{dedup_bar}
<div class="empty">
<p>🎉 No candidates to review.</p>
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
<p>Use 🎓 Graduate memories to propose entity candidates, or 🔗 Scan for duplicates to find near-duplicate memories to merge.</p>
</div>
""" + _GRADUATION_SCRIPT + _MERGE_TRIAGE_SCRIPT
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
# Memory cards
mem_cards = "".join(_render_candidate_card(c) for c in mem_candidates)
# Merge cards (Phase 7A)
merge_cards_html = ""
if merge_candidates:
merge_cards = "".join(_render_merge_card(c) for c in merge_candidates)
merge_cards_html = f"""
<div class="section-break">
<h2>🔗 Merge Candidates ({len(merge_candidates)})</h2>
<p class="auto-triage-msg">
Semantically near-duplicate active memories. Approving merges the sources
into the proposed unified memory; sources become <code>superseded</code>
(not deleted — still queryable). You can edit the draft content and tags
before approving.
</p>
</div>
{merge_cards}
"""
# Entity cards
ent_cards_html = ""
if entity_candidates:
ent_cards = "".join(_render_entity_card(e) for e in entity_candidates)
ent_cards_html = f"""
<div class="section-break">
<h2>🔧 Entity Candidates ({len(entity_candidates)})</h2>
<p class="auto-triage-msg">
Typed graph entries awaiting review. Promoting an entity connects it to
the engineering knowledge graph (subsystems, requirements, decisions, etc.).
</p>
</div>
{ent_cards}
"""
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + _MERGE_TRIAGE_CSS + f"""
<div class="triage-header">
<h1>Triage Queue</h1>
<span class="count">
<span id="cand-count">{len(mem_candidates)}</span> memory ·
{len(merge_candidates)} merge ·
{len(entity_candidates)} entity
</span>
</div>
<div class="triage-help">
Review candidates the auto-triage wasn't sure about. Edit the content
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
</div>
<div class="auto-triage-bar">
<button id="auto-triage-btn" onclick="requestAutoTriage()" title="Run auto_triage on Dalidou host">
🤖 Auto-process queue
</button>
<span id="auto-triage-status" class="auto-triage-msg">
Sends the full memory queue through 3-tier LLM triage on the host.
Sonnet → Opus → auto-discard. Only genuinely ambiguous items land here.
</span>
</div>
{graduation_bar}
{dedup_bar}
<h2>📝 Memory Candidates ({len(mem_candidates)})</h2>
{mem_cards}
{merge_cards_html}
{ent_cards_html}
""" + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT + _GRADUATION_SCRIPT + _MERGE_TRIAGE_SCRIPT
return render_html(
"Triage — AtoCore",
body,
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
)