mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-04 14:52:12 +08:00
feat(LongTermMemory): review consolidation proposals in Memory Console
This commit is contained in:
+12
-1
@@ -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..."
|
||||
}
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
+59
@@ -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
|
||||
Reference in New Issue
Block a user