feat(LongTermMemory): review consolidation proposals in Memory Console

This commit is contained in:
huanghuoguoguo
2026-11-02 20:52:56 +08:00
parent 8a3657430c
commit f207965e68
6 changed files with 440 additions and 2 deletions
@@ -77,5 +77,16 @@
"Traits": "Traits",
"Preferences": "Preferences",
"Notes": "Notes",
"Updated": "Updated"
"Updated": "Updated",
"consolidationReview": "Review Memory Summary",
"consolidationHint": "The summary is an inference, not a verified fact. Compare it with every complete source record before applying. The model and run policy are configured on the memory KB.",
"previewConsolidation": "Preview Summary",
"consolidationReviewed": "I checked the inference against the source records.",
"applyConsolidation": "Apply Reviewed Summary",
"consolidationLoading": "Generating a proposal for review...",
"consolidationInference": "Inferred summary — verify against the sources",
"consolidationSources": "Complete source records",
"consolidationApplied": "Reviewed summary applied. Source records remain available.",
"consolidationUnavailable": "No reviewable summary proposal is available. Check the KB configuration and risk notes.",
"consolidationApplying": "Applying reviewed summary..."
}
@@ -77,5 +77,16 @@
"Traits": "特征",
"Preferences": "偏好",
"Notes": "备注",
"Updated": "更新时间"
"Updated": "更新时间",
"consolidationReview": "审核记忆摘要",
"consolidationHint": "摘要是推断,不是已核实事实。应用前请逐条核对完整来源。模型和执行开关在记忆知识库中配置。",
"previewConsolidation": "预览摘要",
"consolidationReviewed": "我已将推断摘要与来源记录核对。",
"applyConsolidation": "应用已审核摘要",
"consolidationLoading": "正在生成待审核提案……",
"consolidationInference": "推断摘要——请与来源核对",
"consolidationSources": "完整来源记录",
"consolidationApplied": "已应用审核后的摘要,来源记录仍保留。",
"consolidationUnavailable": "没有可审核的摘要提案。请检查知识库配置与风险说明。",
"consolidationApplying": "正在应用已审核摘要……"
}
@@ -511,6 +511,16 @@
<span class="muted" data-i18n="fenceHint">An ambiguous Host write fences its memory space until it is reconciled.</span>
</div>
<div id="fenceOutput" class="list"></div>
<div class="section-head" style="margin-top: 16px;">
<h3 data-i18n="consolidationReview">Review Memory Summary</h3>
</div>
<p class="muted" data-i18n="consolidationHint">The summary is an inference, not a verified fact. Compare it with every complete source record before applying. The model and run policy are configured on the memory KB.</p>
<div class="actions">
<button id="previewConsolidationBtn" data-i18n="previewConsolidation">Preview Summary</button>
<label><input id="reviewConsolidation" type="checkbox" disabled> <span data-i18n="consolidationReviewed">I checked the inference against the source records.</span></label>
<button id="applyConsolidationBtn" disabled data-i18n="applyConsolidation">Apply Reviewed Summary</button>
</div>
<div id="consolidationOutput" class="list" style="margin-top:12px;"></div>
<div class="section-head" style="margin-top: 16px;">
<h3 data-i18n="rawSummary">Raw summary</h3>
</div>
@@ -526,8 +536,16 @@
const EPISODE_STATUSES = ['active', 'superseded', 'archived', 'deleted'];
let summary = null;
let lastEpisodeMode = 'list';
let consolidationProposal = null;
let consolidationEpoch = 0;
const text = {
consolidationLoading: 'Generating a proposal for review...',
consolidationApplying: 'Applying reviewed summary...',
consolidationInference: 'Inferred summary — verify against the sources',
consolidationSources: 'Complete source records',
consolidationApplied: 'Reviewed summary applied. Source records remain available.',
consolidationUnavailable: 'No reviewable summary proposal is available. Check the KB configuration and risk notes.',
empty: 'No data',
noScope: 'No memory space selected.',
noKb: 'No memory knowledge base is configured.',
@@ -739,6 +757,7 @@
function applyKb(kb) {
if (!kb) return;
clearConsolidation();
setValue('collectionId', kb.id || '');
setValue('embeddingModel', kb.embedding_model_uuid || '');
setValue('isolation', kb.isolation || 'session');
@@ -779,6 +798,7 @@
}
function updateScopePreview() {
clearConsolidation();
$('scopePreview').textContent = value('scopeKey') || '-';
$('userPreview').textContent = value('userKey') || '-';
}
@@ -1162,6 +1182,91 @@
}
}
function consolidationKey() {
return JSON.stringify([value('collectionId'), value('scopeKey'), value('userKey')]);
}
function clearConsolidation() {
consolidationEpoch += 1;
consolidationProposal = null;
$('reviewConsolidation').checked = false;
$('reviewConsolidation').disabled = true;
$('applyConsolidationBtn').disabled = true;
$('previewConsolidationBtn').disabled = false;
$('consolidationOutput').replaceChildren();
}
function consolidationText(parent, tag, content) {
const element = document.createElement(tag);
element.textContent = String(content == null ? '' : content);
parent.appendChild(element);
return element;
}
async function previewConsolidation() {
clearConsolidation();
const target = $('consolidationOutput');
const scope = requireScope(target);
const kb = requireKb(target, false);
if (!scope || !kb) return;
const epoch = consolidationEpoch;
const key = consolidationKey();
$('previewConsolidationBtn').disabled = true;
target.textContent = text.consolidationLoading;
try {
const data = await api('/consolidation/preview', {
collection_id: kb.collectionId, scope_key: scope.scopeKey, user_key: scope.userKey,
});
if (epoch !== consolidationEpoch || key !== consolidationKey()) return;
target.replaceChildren();
consolidationText(target, 'h4', text.consolidationInference);
consolidationText(target, 'pre', data.summary_episode || text.consolidationUnavailable);
consolidationText(target, 'h4', text.consolidationSources);
(data.source_records || []).forEach((source) => {
consolidationText(target, 'pre', JSON.stringify({
id: source.id, sender_id: source.sender_id, timestamp: source.timestamp,
event_at: source.event_at, valid_from: source.valid_from, valid_until: source.valid_until,
claim_type: source.claim_type, source_ref: source.source_ref,
}, null, 2));
consolidationText(target, 'pre', source.content);
});
(data.risk_notes || []).forEach((note) => consolidationText(target, 'p', note));
if (data.proposal_id && data.summary_episode) {
consolidationProposal = { id: data.proposal_id, key: key };
$('reviewConsolidation').disabled = false;
}
} catch (err) {
if (epoch === consolidationEpoch) target.textContent = readableError(err);
} finally {
if (epoch === consolidationEpoch) $('previewConsolidationBtn').disabled = false;
}
}
async function applyConsolidation() {
const proposal = consolidationProposal;
if (!proposal || proposal.key !== consolidationKey() || !$('reviewConsolidation').checked) return;
const scope = selectedScope();
const kb = selectedKb();
const key = consolidationKey();
clearConsolidation();
const epoch = consolidationEpoch;
$('previewConsolidationBtn').disabled = true;
$('consolidationOutput').textContent = text.consolidationApplying;
try {
await api('/consolidation/run', {
collection_id: kb.collectionId, scope_key: scope.scopeKey, user_key: scope.userKey,
proposal_id: proposal.id,
});
if (epoch === consolidationEpoch && key === consolidationKey()) {
$('consolidationOutput').textContent = text.consolidationApplied;
}
} catch (err) {
if (epoch === consolidationEpoch) $('consolidationOutput').textContent = readableError(err);
} finally {
if (epoch === consolidationEpoch) $('previewConsolidationBtn').disabled = false;
}
}
function bindTabs() {
document.querySelectorAll('.tab').forEach((button) => {
button.addEventListener('click', () => {
@@ -1183,6 +1288,14 @@
$('searchEpisodesBtn').addEventListener('click', searchEpisodes);
$('loadAuditBtn').addEventListener('click', loadAudit);
$('runHealthBtn').addEventListener('click', runHealth);
$('previewConsolidationBtn').addEventListener('click', previewConsolidation);
$('applyConsolidationBtn').addEventListener('click', applyConsolidation);
$('reviewConsolidation').addEventListener('change', () => {
$('applyConsolidationBtn').disabled = !consolidationProposal || !$('reviewConsolidation').checked;
});
['collectionId', 'scopeKey', 'userKey'].forEach((id) => {
$(id).addEventListener('input', clearConsolidation);
});
$('clearFenceBtn').addEventListener('click', clearFence);
$('loadInjectionBtn').addEventListener('click', loadInjection);
$('exportProfilesBtn').addEventListener('click', exportProfiles);
@@ -32,6 +32,10 @@ class MemoryConsolePage(Page):
return PageResponse.ok(await self._list_audit(self._body(request)))
if request.endpoint == "/audit/export" and request.method == "POST":
return PageResponse.ok(await self._export_audit(self._body(request)))
if request.endpoint == "/consolidation/preview" and request.method == "POST":
return PageResponse.ok(await self._consolidation_preview(self._body(request)))
if request.endpoint == "/consolidation/run" and request.method == "POST":
return PageResponse.ok(await self._consolidation_run(self._body(request)))
if request.endpoint == "/health" and request.method == "POST":
return PageResponse.ok(await self._health(self._body(request)))
if request.endpoint == "/injection" and request.method == "POST":
@@ -386,6 +390,61 @@ class MemoryConsolePage(Page):
)
return {"episode_id": episode_id, "deleted": deleted}
async def _consolidation_context(self, body: dict[str, Any], *, run: bool = False):
allowed = {"collection_id", "scope_key", "user_key"}
if run:
allowed.add("proposal_id")
if set(body) - allowed:
raise ValueError("unsupported consolidation fields; model and policy come from KB configuration")
collection_id = self._required_string(body, "collection_id")
scope_key = self._required_string(body, "scope_key")
user_key = self._required_string(body, "user_key")
config = (await self._store.get_kb_configs()).get(collection_id)
if config is None:
raise ValueError("memory knowledge base is not configured")
isolation = self._string(config, "isolation", "session")
if isolation not in {"session", "bot"}:
raise ValueError("KB isolation must be session or bot")
expected_key = self._infer_user_key(scope_key, isolation)
if user_key != expected_key:
raise ValueError("user_key does not match the selected scope and KB isolation")
if run:
if config.get("consolidation_enabled") is not True:
raise ValueError("consolidation run is disabled in the memory KB")
self._required_string(config, "embedding_model_uuid")
return collection_id, scope_key, user_key, config
async def _consolidation_preview(self, body: dict[str, Any]) -> dict[str, Any]:
collection_id, _scope_key, user_key, config = await self._consolidation_context(body)
return await self._store.preview_consolidation(
collection_id=collection_id,
user_key=user_key,
min_age_days=self._int(config, "consolidation_min_age_days", 7, 0, 36500),
max_candidates=self._int(config, "consolidation_max_candidates", 20, 1, 100),
summary_model_uuid=self._string(config, "consolidation_model_uuid"),
)
async def _consolidation_run(self, body: dict[str, Any]) -> dict[str, Any]:
proposal_id = self._required_string(body, "proposal_id")
collection_id, scope_key, user_key, config = await self._consolidation_context(body, run=True)
result = await self._store.apply_consolidation(
collection_id=collection_id,
embedding_model_uuid=self._required_string(config, "embedding_model_uuid"),
user_key=user_key,
proposal_id=proposal_id,
)
summary = result.get("summary_episode") or {}
await self._store.append_audit_entry(
scope_key=scope_key,
user_key=user_key,
operation="consolidate_run",
target_type="episode",
target_id=summary.get("id", proposal_id),
summary="Applied a reviewed inference summary from Memory Console",
metadata={"collection_id": collection_id, "proposal_id": proposal_id},
)
return result
async def _list_audit(self, body: dict[str, Any]) -> dict[str, Any]:
scope_key = self._required_string(body, "scope_key")
page = self._int(body, "page", 1, 1, 100000)
@@ -0,0 +1,160 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from components.pages.memory_console.memory_console import MemoryConsolePage
from langbot_plugin.api.definition.components.page import PageRequest
@pytest.fixture
def page():
component = MemoryConsolePage()
config = {
"isolation": "session", "consolidation_enabled": True,
"consolidation_model_uuid": "trusted-summary-model", "embedding_model_uuid": "trusted-embedding-model",
"consolidation_min_age_days": 10, "consolidation_max_candidates": 8,
}
source = "<script>alert('untrusted source')</script>\n" + "Complete source fact. " * 500
store = SimpleNamespace(
configs={"kb": config},
get_kb_configs=AsyncMock(return_value={"kb": config}),
preview_consolidation=AsyncMock(return_value={
"proposal_id": "proposal-1", "summary_episode": "An inferred summary <b>not HTML</b>",
"source_records": [{"id": "source-1", "content": source}],
"risk_notes": ["Review the inference"],
}),
apply_consolidation=AsyncMock(return_value={"summary_episode": {"id": "summary-1"}, "archived_episode_ids": []}),
append_audit_entry=AsyncMock(),
)
component.plugin = SimpleNamespace(memory_store=store)
return component
def body(**changes):
return {"collection_id": "kb", "scope_key": "bot:group_room", "user_key": "bot:group_room", **changes}
async def request(page, endpoint, payload):
return await page.handle_api(PageRequest(endpoint=endpoint, method="POST", body=payload))
@pytest.mark.asyncio
async def test_preview_uses_trusted_kb_model_and_preserves_complete_sources(page):
response = await request(page, "/consolidation/preview", body())
assert response.error is None
assert response.data["source_records"][0]["content"].endswith("Complete source fact. " * 500)
assert response.data["summary_episode"] == "An inferred summary <b>not HTML</b>"
page._store.preview_consolidation.assert_awaited_once_with(
collection_id="kb", user_key="bot:group_room", min_age_days=10, max_candidates=8,
summary_model_uuid="trusted-summary-model",
)
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_preview_is_available_when_run_is_disabled(page):
page._store.configs["kb"]["consolidation_enabled"] = False
response = await request(page, "/consolidation/preview", body())
assert response.error is None
page._store.preview_consolidation.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_requires_reviewed_proposal_and_trusted_embedding_model(page):
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is None
page._store.apply_consolidation.assert_awaited_once_with(
collection_id="kb", embedding_model_uuid="trusted-embedding-model",
user_key="bot:group_room", proposal_id="proposal-1",
)
audit = page._store.append_audit_entry.call_args.kwargs
assert audit["scope_key"] == "bot:group_room"
assert audit["user_key"] == "bot:group_room"
assert audit["metadata"] == {"collection_id": "kb", "proposal_id": "proposal-1"}
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"summary_model_uuid": "browser-model"}, {"embedding_model_uuid": "browser-model"}, {"consolidation_enabled": True}, {"min_age_days": 0}])
@pytest.mark.parametrize("endpoint", ["preview", "run"])
async def test_browser_cannot_override_models_or_policy(page, extra, endpoint):
payload = body(**extra)
if endpoint == "run":
payload["proposal_id"] = "proposal-1"
response = await request(page, "/consolidation/" + endpoint, payload)
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("changes", [{"proposal_id": ""}, {"proposal_id": None}, {"proposal_id": 123}, {"proposal_id": "proposal-1", "user_key": "another-space"}, {"proposal_id": "proposal-1", "collection_id": "unknown-kb"}])
async def test_invalid_run_never_reaches_store(page, changes):
response = await request(page, "/consolidation/run", body(**changes))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
page._store.append_audit_entry.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("disabled", [False, "false", None])
async def test_run_is_fail_closed_when_not_explicitly_enabled(page, disabled):
page._store.configs["kb"]["consolidation_enabled"] = disabled
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_requires_configured_embedding_model(page):
page._store.configs["kb"].pop("embedding_model_uuid")
response = await request(page, "/consolidation/run", body(proposal_id="proposal-1"))
assert response.error is not None
page._store.apply_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_preview_scope_is_checked_against_kb_isolation(page):
page._store.configs["kb"]["isolation"] = "bot"
response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
assert response.error is None
assert page._store.preview_consolidation.call_args.kwargs["user_key"] == "bot:bot"
rejected = await request(page, "/consolidation/preview", body())
assert rejected.error is not None
assert page._store.preview_consolidation.await_count == 1
@pytest.mark.asyncio
async def test_store_rejects_cross_scope_or_stale_proposal_without_success_audit(page):
page._store.apply_consolidation.side_effect = ValueError("proposal scope changed; preview again")
response = await request(page, "/consolidation/run", body(proposal_id="proposal-from-another-space"))
assert response.error is not None
assert "preview again" in response.error
page._store.apply_consolidation.assert_awaited_once()
page._store.append_audit_entry.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [[], "body", 123])
async def test_consolidation_body_must_be_an_object(page, payload):
response = await request(page, "/consolidation/preview", payload)
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("changes", [{"collection_id": ""}, {"scope_key": ""}, {"user_key": ""}, {"collection_id": 123}, {"user_key": []}])
async def test_preview_requires_string_kb_scope_and_user_identity(page, changes):
response = await request(page, "/consolidation/preview", body(**changes))
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_kb_isolation_fails_closed(page):
page._store.configs["kb"]["isolation"] = "unknown"
response = await request(page, "/consolidation/preview", body(user_key="bot:bot"))
assert response.error is not None
page._store.preview_consolidation.assert_not_awaited()
@@ -0,0 +1,84 @@
"""Exercise the Console review flow without adding a browser dependency."""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import pytest
NODE_CHECK = r"""
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const html = fs.readFileSync(process.argv[1], 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
const nodes = new Map();
function element(id = '') {
return {value: '', checked: false, disabled: false, children: [], _text: '',
set textContent(text) { this._text = String(text); this.children = []; },
get textContent() { return this._text; },
set innerHTML(text) { throw new Error('Review data must never be inserted as HTML: ' + text); },
replaceChildren() { this.children = []; this._text = ''; },
appendChild(child) { this.children.push(child); },
};
}
function get(id) { if (!nodes.has(id)) nodes.set(id, element(id)); return nodes.get(id); }
const calls = [];
let handler;
const context = {
document: {getElementById: get, createElement: () => element()},
langbot: {onReady() {}, api: async (endpoint, body) => { calls.push({endpoint, body}); return handler(endpoint, body); }},
};
vm.createContext(context);
vm.runInContext(script, context);
get('scopeKey').value = 'bot:group_room';
get('userKey').value = 'bot:group_room';
get('collectionId').value = 'kb';
const untrusted = '<img src=x onerror=alert(1)>\nComplete source text';
const proposal = {proposal_id: 'p-1', summary_episode: untrusted, source_records: [{id: 'source', content: untrusted}], risk_notes: ['Verify sources']};
(async () => {
handler = () => proposal;
await context.previewConsolidation();
assert.equal(get('reviewConsolidation').disabled, false);
assert.equal(get('applyConsolidationBtn').disabled, true);
assert.equal(get('consolidationOutput').children.filter(child => child.textContent === untrusted).length, 2);
await context.applyConsolidation();
assert.equal(calls.length, 1, 'Unreviewed proposal cannot run');
get('reviewConsolidation').checked = true;
handler = () => ({summary_episode: {id: 'summary'}});
await context.applyConsolidation();
assert.equal(calls.length, 2);
assert.equal(calls[1].endpoint, '/consolidation/run');
assert.equal(calls[1].body.proposal_id, 'p-1');
assert.equal(calls[1].body.user_key, 'bot:group_room');
assert.equal(calls[1].body.model_uuid, undefined);
assert.equal(get('reviewConsolidation').disabled, true);
let resolve;
handler = () => new Promise(done => { resolve = done; });
const pending = context.previewConsolidation();
get('collectionId').value = 'another-kb';
context.clearConsolidation();
resolve(proposal);
await pending;
assert.equal(get('reviewConsolidation').disabled, true, 'Old KB response cannot restore proposal');
assert.equal(get('consolidationOutput').children.length, 0);
handler = () => ({summary_episode: 'No proposal ID'});
await context.previewConsolidation();
get('reviewConsolidation').checked = true;
const before = calls.length;
await context.applyConsolidation();
assert.equal(calls.length, before, 'Missing proposal ID cannot run');
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
def test_review_flow_handles_untrusted_text_explicit_approval_and_stale_responses():
node = shutil.which("node")
if node is None:
pytest.skip("Node is needed for the Console DOM behavior check")
root = Path(__file__).parents[2]
html = root / "components/pages/memory_console/index.html"
result = subprocess.run([node, "-e", NODE_CHECK, str(html)], capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stdout + result.stderr