feat: Phase 3 V1 — Auto-Organization (domain_tags + valid_until)
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>
This commit is contained in:
@@ -35,12 +35,24 @@ def _render_candidate_card(cand) -> str:
|
||||
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">
|
||||
@@ -51,10 +63,21 @@ def _render_candidate_card(cand) -> str:
|
||||
<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="Edit + Promote (E)">✏️ Save&Promote</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>
|
||||
@@ -162,24 +185,47 @@ async function reject(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 r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
|
||||
content: content, memory_type: mtype
|
||||
});
|
||||
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')) promote(id);
|
||||
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);
|
||||
});
|
||||
@@ -192,7 +238,7 @@ document.addEventListener('keydown', (e) => {
|
||||
const first = document.querySelector('.cand');
|
||||
if (!first) return;
|
||||
const id = first.dataset.id;
|
||||
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); promote(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();
|
||||
@@ -236,6 +282,13 @@ _TRIAGE_CSS = """
|
||||
.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>
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user