openvibers/openvibe.chat Commits
Chat: a room's summary reads its public messages, leaving out people who turned AI off (#561)
Changes
@@@ -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;}@@@ -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 = ''; } };@@@ -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 countsawait 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 toconst 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();});
@@ -191,16 +191,13 @@ async function _refreshGlobal() {
@@ -191,16 +191,13 @@ async function _refreshGlobal() {
const windowStart = _sqlTime(startMs);
const windowStart = _sqlTime(startMs);
const windowLabel = _windowLabel(now - 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 });
const batch = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
if (!batch.length) return false;
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 || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
@@ -229,7 +226,7 @@ async function _refreshGlobal() {
@@ -229,7 +226,7 @@ async function _refreshGlobal() {
people: _people(rows),
people: _people(rows),
topics_json: JSON.stringify(_topics(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;
return true;
}
}
@@ -251,7 +248,7 @@ async function _refreshRoom(room) {
@@ -251,7 +248,7 @@ async function _refreshRoom(room) {
const prior = await db.getChatAiSummary('room', room.id, 'rolling');
const prior = await db.getChatAiSummary('room', room.id, 'rolling');
const raw = await db.getRoomMessagesForAi({ roomId: room.id, limit: ROOM_BATCH });
const raw = await db.getRoomMessagesForAi({ roomId: room.id, limit: ROOM_BATCH });
if (!raw.length) return false;
if (!raw.length) return false;
- const rows = await consent.filterRows(raw);
+ const rows = await consent.roomRows(raw);
if (!rows.length) return false;
if (!rows.length) return false;
const now = Date.now();
const now = Date.now();
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
@@ -297,7 +294,7 @@ async function _refreshRoom(room) {
@@ -297,7 +294,7 @@ async function _refreshRoom(room) {
confidence: 45,
confidence: 45,
}).catch((e) => { console.warn(`[ChatAI] room ${room.slug} memory:`, e.message); return 0; });
}).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;
return true;
}
}
@@ -319,7 +316,7 @@ async function _refreshChannel(channel) {
@@ -319,7 +316,7 @@ async function _refreshChannel(channel) {
const prior = await db.getChatAiSummary('channel', subjectId, 'rolling');
const prior = await db.getChatAiSummary('channel', subjectId, 'rolling');
const raw = await db.getChannelMessagesForAi({ channelUserId: subjectId, limit: ROOM_BATCH });
const raw = await db.getChannelMessagesForAi({ channelUserId: subjectId, limit: ROOM_BATCH });
if (!raw.length) return false;
if (!raw.length) return false;
- const rows = await consent.filterRows(raw);
+ const rows = await consent.roomRows(raw);
if (!rows.length) return false;
if (!rows.length) return false;
const now = Date.now();
const now = Date.now();
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
const { label: windowLabel, start: windowStart } = _windowOf(rows, now);
@@ -352,7 +349,7 @@ async function _refreshChannel(channel) {
@@ -352,7 +349,7 @@ async function _refreshChannel(channel) {
people: _people(rows),
people: _people(rows),
topics_json: JSON.stringify(_topics(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;
return true;
}
}
@@ -85,4 +85,31 @@ async function filterRows(rows) {
@@ -85,4 +85,31 @@ async function filterRows(rows) {
return list.filter((r) => r && r.subject_id && allowed.has(String(r.subject_id)));
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 = ''; } };
@@ -175,7 +175,7 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
@@ -175,7 +175,7 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
assert.strictEqual(r.status, 404);
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 = [];
const calls = [];
aiClient._setClient({
aiClient._setClient({
runs: {
runs: {
@@ -188,9 +188,12 @@ t('the summariser reads only people who allowed AI use, and stores people and to
@@ -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 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: 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: alice.id, username: 'alice', message: 'zelda talk is good' });
await msg({ user_id: bob.id, username: 'bob', message: 'zelda again' });
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
@@ -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);
const global = calls.find((c) => c.workflow === chatAi.WORKFLOW_GLOBAL);
assert.ok(global, 'the global workflow was called');
assert.ok(global, 'the global workflow was called');
const authors = global.input.messages.map((m) => m.author);
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');
const row = await db.getChatAiSummary('global', 0, 'global');
assert.strictEqual(row.overview, 'consenting talk');
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);
const topics = JSON.parse(row.topics_json);
assert.ok(topics.some((x) => x.label === 'zelda' && x.count >= 2), JSON.stringify(topics));
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');
const consent = require('../server/net/consent');
consent._reset();
consent._reset();
h.consent.down = true;
h.consent.down = true;
const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;
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 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();
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;
h.consent.down = false;
consent._reset();
consent._reset();
});
});