diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json index 99d3da3c3..42262acc4 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json +++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/en_US.json @@ -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..." } diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json index 89df3e9a8..c300deeba 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json +++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/i18n/zh_Hans.json @@ -77,5 +77,16 @@ "Traits": "特征", "Preferences": "偏好", "Notes": "备注", - "Updated": "更新时间" + "Updated": "更新时间", + "consolidationReview": "审核记忆摘要", + "consolidationHint": "摘要是推断,不是已核实事实。应用前请逐条核对完整来源。模型和执行开关在记忆知识库中配置。", + "previewConsolidation": "预览摘要", + "consolidationReviewed": "我已将推断摘要与来源记录核对。", + "applyConsolidation": "应用已审核摘要", + "consolidationLoading": "正在生成待审核提案……", + "consolidationInference": "推断摘要——请与来源核对", + "consolidationSources": "完整来源记录", + "consolidationApplied": "已应用审核后的摘要,来源记录仍保留。", + "consolidationUnavailable": "没有可审核的摘要提案。请检查知识库配置与风险说明。", + "consolidationApplying": "正在应用已审核摘要……" } diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html index 24817ece8..b9bebdd04 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html +++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/index.html @@ -511,6 +511,16 @@ An ambiguous Host write fences its memory space until it is reconciled.
+
+

Review Memory Summary

+
+

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.

+
+ + + +
+

Raw summary

@@ -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); diff --git a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py index 87f897730..cbf466ed5 100644 --- a/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py +++ b/plugins/KnowledgeEngine/LongTermMemory/components/pages/memory_console/memory_console.py @@ -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) diff --git a/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py new file mode 100644 index 000000000..cb6a013a0 --- /dev/null +++ b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review.py @@ -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 = "\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 not HTML", + "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 not HTML" + 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() diff --git a/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py new file mode 100644 index 000000000..7325f95ec --- /dev/null +++ b/plugins/KnowledgeEngine/LongTermMemory/tests/pages/test_consolidation_review_ui.py @@ -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(/