openvibers/openvibe.chat Commits

Chat: a room's summary reads its public messages, leaving out people who turned AI off (#561)

OP OpenVibers committed 3 h ago · fcf657aa29 · 3 files, +49 −22

The global, room and channel summaries read only people who had allowed AI training (network.consent@1 ai.train), so
they read nobody: Global's line was three days old ("300 messages from 26 chatters…") and every 45 s the job said
"no consenting messages". The owner's rule (10-31, 11-13): opt-in is for training, public datasets, faces, looks and
apps; a room's own summary of its public messages, shown back to that room, is none of those (as the Rundown's
write-ups of what streamers said are not). consent.roomRows reads every message in the window except the people who
turned "leave my chat out of AI" on (person_chat_memory_settings.opted_out, the switch that also forgets their chat
memory), and fails closed if it cannot read that. A person's own profile still asks Network (filterRows).
Tests: chat-ai (an opted-out person is left out, one who never chose is read; Network being down does not stop it).

Changes

ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 70a037a..f63565a 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -191,16 +191,13 @@ async function _refreshGlobal() {
const windowStart = _sqlTime(startMs);
const windowLabel = _windowLabel(now - startMs);
-- // The room's read includes only people who allowed AI use of their messages (network.consent@1,
-- // ai.train on kind chat). Anon and relay authors have no Network subject and are left out; when
-- // Network cannot answer, the check fails closed and this window is skipped.
++ // The room's own summary reads its public messages, leaving out the people who turned AI off for their chat
++ // (consent.roomRows, 2026-11-13; it read only those who allowed AI training, so it read nobody and the room's line
++ // was three days old). A person's own profile still asks Network for consent (filterRows, below).
const batch = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
if (!batch.length) return false;
-- const rows = await consent.filterRows(batch);
-- if (!rows.length) {
-- console.log('[ChatAI] global window has no consenting messages; nothing summarised');
-- return false;
-- }
++ const rows = await consent.roomRows(batch);
++ if (!rows.length) return false;
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
@@@ -229,7 +226,7 @@ async function _refreshGlobal() {
people: _people(rows),
topics_json: JSON.stringify(_topics(rows)),
});
-- console.log(`[ChatAI] global refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
++ console.log(`[ChatAI] global refreshed (${newCount} new, ${rows.length} read, window=${windowLabel})`);
return true;
}
@@@ -251,7 +248,7 @@ async function _refreshRoom(room) {
const prior = await db.getChatAiSummary('room', room.id, 'rolling');
const raw = await db.getRoomMessagesForAi({ roomId: room.id, limit: ROOM_BATCH });
if (!raw.length) return false;
-- const rows = await consent.filterRows(raw);
++ const rows = await consent.roomRows(raw);
if (!rows.length) return false;
const now = Date.now();
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
@@@ -297,7 +294,7 @@ async function _refreshRoom(room) {
confidence: 45,
}).catch((e) => { console.warn(`[ChatAI] room ${room.slug} memory:`, e.message); return 0; });
}
-- console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} consenting, ${remembered} remembered, window=${windowLabel})`);
++ console.log(`[ChatAI] room ${room.slug} refreshed (${newCount} new, ${rows.length} read, ${remembered} remembered, window=${windowLabel})`);
return true;
}
@@@ -319,7 +316,7 @@ async function _refreshChannel(channel) {
const prior = await db.getChatAiSummary('channel', subjectId, 'rolling');
const raw = await db.getChannelMessagesForAi({ channelUserId: subjectId, limit: ROOM_BATCH });
if (!raw.length) return false;
-- const rows = await consent.filterRows(raw);
++ const rows = await consent.roomRows(raw);
if (!rows.length) return false;
const now = Date.now();
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
@@@ -352,7 +349,7 @@ async function _refreshChannel(channel) {
people: _people(rows),
topics_json: JSON.stringify(_topics(rows)),
});
-- console.log(`[ChatAI] channel ${subjectId} refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
++ console.log(`[ChatAI] channel ${subjectId} refreshed (${newCount} new, ${rows.length} read, window=${windowLabel})`);
return true;
}
ddiff --git a/server/net/consent.js b/server/net/consent.js
iindex 40b8cc1..4a3a4f3 100644
---- a/server/net/consent.js
++++ b/server/net/consent.js
@@@ -85,4 +85,31 @@ async function filterRows(rows) {
return list.filter((r) => r && r.subject_id && allowed.has(String(r.subject_id)));
}
--module.exports = { allowedSubjects, filterRows, _reset() { cache.clear(); blockedUntil = 0; lastLog = ''; } };
++/**
++ * What a room's own summary may read (the owner's rule, 2026-10-31 and 11-13: opt-in is for training, public datasets,
++ * faces, looks and apps; a room's summary of its public messages, shown back to that room, is none of those, as the
++ * Rundown's write-ups of what streamers said are not). Every message in the window, except the people who turned
++ * "leave my chat out of AI" on: Chat's own switch, person_chat_memory_settings.opted_out, the one that also forgets
++ * their chat memory. Fail-closed: if the switch cannot be read, nobody is read.
++ */
++async function roomRows(rows) {
++ const list = Array.isArray(rows) ? rows : [];
++ const ids = [...new Set(list.map((r) => Number(r && r.user_id)).filter((n) => Number.isInteger(n) && n > 0))];
++ if (!ids.length) return list;
++ let out;
++ try {
++ const db = require('../db/database');
++ out = new Set();
++ for (let i = 0; i < ids.length; i += MAX_BATCH) {
++ const part = ids.slice(i, i + MAX_BATCH);
++ const found = await db.all(`SELECT user_id FROM person_chat_memory_settings WHERE opted_out = 1 AND user_id IN (${part.map(() => '?').join(',')})`, part);
++ for (const r of found) out.add(Number(r.user_id));
++ }
++ } catch (err) {
++ _log(`the AI opt-out could not be read, leaving everyone out: ${(err && err.message) || err}`);
++ return [];
++ }
++ return list.filter((r) => r && !out.has(Number(r.user_id)));
++}
++
++module.exports = { allowedSubjects, filterRows, roomRows, _reset() { cache.clear(); blockedUntil = 0; lastLog = ''; } };
ddiff --git a/test/chat-ai.test.js b/test/chat-ai.test.js
iindex 3e981d6..74d9dba 100644
---- a/test/chat-ai.test.js
++++ b/test/chat-ai.test.js
@@@ -175,7 +175,7 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
assert.strictEqual(r.status, 404);
});
--t('the summariser reads only people who allowed AI use, and stores people and topics', async () => {
++t('the room\'s summary reads its public messages, leaving out people who turned AI off, and stores people and topics', async () => {
const calls = [];
aiClient._setClient({
runs: {
@@@ -188,9 +188,12 @@ t('the summariser reads only people who allowed AI use, and stores people and to
},
},
});
-- const carol = h.addUser('carol', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' }); // no consent
++ const carol = h.addUser('carol', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' }); // turned AI off for her chat
++ await db.run('INSERT INTO person_chat_memory_settings (user_id, opted_out) VALUES (?, 1)', [carol.id]);
++ const dave = h.addUser('dave', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A5' }); // never chose: his public line counts
await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
await msg({ user_id: carol.id, username: 'carol', message: 'carol secret line' });
++ await msg({ user_id: dave.id, username: 'dave', message: 'dave says zelda too' });
await msg({ user_id: alice.id, username: 'alice', message: 'zelda talk is good' });
await msg({ user_id: bob.id, username: 'bob', message: 'zelda again' });
@@@ -199,24 +202,24 @@ t('the summariser reads only people who allowed AI use, and stores people and to
const global = calls.find((c) => c.workflow === chatAi.WORKFLOW_GLOBAL);
assert.ok(global, 'the global workflow was called');
const authors = global.input.messages.map((m) => m.author);
-- assert.ok(!authors.includes('carol'), 'carol is left out');
-- assert.ok(authors.includes('alice') && authors.includes('bob'), JSON.stringify(authors));
++ assert.ok(!authors.includes('carol'), 'carol turned AI off: left out');
++ assert.ok(authors.includes('alice') && authors.includes('bob') && authors.includes('dave'), JSON.stringify(authors));
const row = await db.getChatAiSummary('global', 0, 'global');
assert.strictEqual(row.overview, 'consenting talk');
-- assert.strictEqual(Number(row.people), 2);
++ assert.strictEqual(Number(row.people), 3);
const topics = JSON.parse(row.topics_json);
assert.ok(topics.some((x) => x.label === 'zelda' && x.count >= 2), JSON.stringify(topics));
});
--t('a consent check that fails leaves everyone out (fail closed)', async () => {
++t('the room\'s summary does not wait on Network (it reads Chat\'s own opt-out)', async () => {
const consent = require('../server/net/consent');
consent._reset();
h.consent.down = true;
const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;
await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
-- await msg({ user_id: alice.id, username: 'alice', message: 'not read while Network is down' });
++ await msg({ user_id: alice.id, username: 'alice', message: 'read while Network is down' });
await chatAi._tick();
-- assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);
++ assert.ok((await db.getChatAiSummary('global', 0, 'global')).message_count > before, 'summarised');
h.consent.down = false;
consent._reset();
});