Adds structural metadata that the LLM triage was already implicitly
reasoning about ("stale snapshot" → reject). Phase 3 captures that
reasoning as fields so it can DRIVE retrieval, not just rejection.
Schema (src/atocore/models/database.py):
- domain_tags TEXT DEFAULT '[]' JSON array of lowercase topic keywords
- valid_until DATETIME ISO date; null = permanent
- idx_memories_valid_until index for efficient expiry queries
Memory service (src/atocore/memory/service.py):
- Memory dataclass gains domain_tags + valid_until
- create_memory, update_memory accept/persist both
- _row_to_memory safely reads both (JSON-decode + null handling)
- _normalize_tags helper: lowercase, dedup, strip, cap at 10
- get_memories_for_context filters expired (valid_until < today UTC)
- _rank_memories_for_query adds tag-boost: memories whose domain_tags
appear as substrings in query text rank higher (tertiary key after
content-overlap density + absolute overlap, before confidence)
LLM extractor (_llm_prompt.py → llm-0.5.0):
- SYSTEM_PROMPT documents domain_tags (2-5 keywords) + valid_until
(time-bounded facts get expiry dates; durable facts stay null)
- normalize_candidate_item parses both fields from model output with
graceful fallback for string/null/missing
LLM triage (scripts/auto_triage.py):
- TRIAGE_SYSTEM_PROMPT documents same two fields
- parse_verdict extracts them from verdict JSON
- On promote: PUT /memory/{id} with tags + valid_until BEFORE
POST /memory/{id}/promote, so active memories carry them
API (src/atocore/api/routes.py):
- MemoryCreateRequest: adds domain_tags, valid_until
- MemoryUpdateRequest: adds domain_tags, valid_until, memory_type
- GET /memory response exposes domain_tags + valid_until + created_at
Triage UI (src/atocore/engineering/triage_ui.py):
- Renders existing tags as colored badges
- Adds inline text field for tags (comma-separated) + date picker for
valid_until on every candidate card
- Save&Promote button persists edits via PUT then promotes
- Plain Promote (and Y shortcut) also saves tags/expiry if edited
Wiki (src/atocore/engineering/wiki.py):
- Search now matches memory content OR domain_tags
- Search results render tags as clickable badges linking to
/wiki/search?q=<tag> for cross-project navigation
- valid_until shown as amber "valid until YYYY-MM-DD" hint
Tests: 303 → 308 (5 new for Phase 3 behavior):
- test_create_memory_with_tags_and_valid_until
- test_create_memory_normalizes_tags
- test_update_memory_sets_tags_and_valid_until
- test_get_memories_for_context_excludes_expired
- test_context_builder_tag_boost_orders_results
Deferred (explicitly): temporal_scope enum, source_refs memory graph,
HDBSCAN clustering, memory detail wiki page, backfill of existing
actives. See docs/MASTER-BRAIN-PLAN.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
345 lines
15 KiB
Python
345 lines
15 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_triage_page(limit: int = 100) -> str:
|
|
"""Render the full triage page with all pending candidates."""
|
|
try:
|
|
candidates = get_memories(status="candidate", limit=limit)
|
|
except Exception as e:
|
|
body = f"<p>Error loading candidates: {_escape(str(e))}</p>"
|
|
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
|
|
|
if not candidates:
|
|
body = _TRIAGE_CSS + """
|
|
<div class="triage-header">
|
|
<h1>Triage Queue</h1>
|
|
</div>
|
|
<div class="empty">
|
|
<p>🎉 No candidates to review.</p>
|
|
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
|
|
</div>
|
|
"""
|
|
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
|
|
|
cards_html = "".join(_render_candidate_card(c) for c in candidates)
|
|
|
|
body = _TRIAGE_CSS + f"""
|
|
<div class="triage-header">
|
|
<h1>Triage Queue</h1>
|
|
<span class="count"><span id="cand-count">{len(candidates)}</span> pending</span>
|
|
</div>
|
|
<div class="triage-help">
|
|
Review candidate memories 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 queue through LLM triage on the host. Promotes durable facts,
|
|
rejects noise, leaves only ambiguous items here for you.
|
|
</span>
|
|
</div>
|
|
{cards_html}
|
|
""" + _TRIAGE_SCRIPT
|
|
|
|
return render_html(
|
|
"Triage — AtoCore",
|
|
body,
|
|
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
|
|
)
|