openvibers/openvibe.chat Commits

Room summaries wait for slow answers; the fallback timeline shows each event once (#564)

OP OpenVibers committed 24 h ago · 491c03d0e0 · 3 files, +66 −14

Changes

ddiff --git a/server/ai/client.js b/server/ai/client.js
iindex a5b3bc3..88f8471 100644
---- a/server/ai/client.js
++++ b/server/ai/client.js
@@@ -62,6 +62,9 @@ async function bearer() {
* transport error all answer null — exactly Live's ai-service.structured() contract. `force` runs it even with
* the insight job switched off (CHAT_AI_ENABLED): a room's AI members have their own switch (CHAT_AI_MEMBERS).
*/
++// The workflows Chat runs in the background (the room insight job), which may take longer than a person waits.
++const BACKGROUND = new Set(['chat.global', 'chat.profile']);
++
async function structured(workflow, input, opts = {}) {
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
_lastProblem = null;
@@@ -77,11 +80,17 @@ async function structured(workflow, input, opts = {}) {
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),
})
: await runs.create(workflow, input, {
-- wait: config.ai.waitMs,
++ // A summary is background work: ask for a short wait (inside the HTTP client's 30 s), then poll the
++ // run to its end (2026-11-13: chat.global took 12-76 s and Chat gave up at 30 s, so the room's line
++ // was the extractive fallback while AI's answer arrived after it stopped listening).
++ wait: BACKGROUND.has(workflow) ? 20000 : config.ai.waitMs,
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
});
-- const run = body && (body.run || body);
++ let run = body && (body.run || body);
++ if (run && run.id && (run.status === 'queued' || run.status === 'running') && BACKGROUND.has(workflow) && typeof runs.waitFor === 'function') {
++ run = await runs.waitFor(run.id, { intervalMs: 2000, timeoutMs: 150000 });
++ }
_lastProblem = problemOf(run);
// Meter completed failed runs too: providers can bill even when no output is returned.
if (run && !run.synthetic && opts.onRun) await opts.onRun(run);
ddiff --git a/server/ai/extractive.js b/server/ai/extractive.js
iindex 2bbd8ea..90acf6c 100644
---- a/server/ai/extractive.js
++++ b/server/ai/extractive.js
@@@ -26,19 +26,31 @@ function _minsAgo(ts, now) {
const _PLACEHOLDER = /^(short title|one sentence|label|title|detail|\.\.\.)$/i;
/** Notable moments: non-chat activity (donations, clips, sound events), else the latest messages. */
++// The window's moments, newest last: each event once (a sound played three times is one line, "×3", at its latest),
++// and the latest messages beside them, so a window of repeated sound plays still shows what was said today (the owner,
++// 11-13: "played !africa" three times, and nothing from today).
function _timeline(rows, now) {
-- const notable = rows.filter((r) => r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');
-- const picked = (notable.length ? notable : rows.slice(-5)).slice(-10);
-- return picked
-- .map((r) => {
-- const label = _oneLine(r.message, 80);
-- if (!label || _PLACEHOLDER.test(label)) return null;
-- return {
-- label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now),
-- kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),
-- };
-- })
-- .filter(Boolean);
++ const isEvent = (r) => !!(r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');
++ const byLabel = new Map();
++ for (const r of rows) {
++ if (!isEvent(r)) continue;
++ const label = _oneLine(r.message, 80);
++ if (!label || _PLACEHOLDER.test(label)) continue;
++ const key = label.toLowerCase();
++ const was = byLabel.get(key);
++ byLabel.set(key, { r, label, n: was ? was.n + 1 : 1 });
++ }
++ const events = [...byLabel.values()].slice(-6);
++ const said = rows.filter((r) => !isEvent(r) && r.message_type !== 'tts').slice(-Math.max(3, 8 - events.length));
++ const at = (r) => Date.parse(r.timestamp || r.created_at || '') || 0;
++ const picked = [...events.map((e) => ({ ...e, kind: 'event' })), ...said.map((r) => ({ r, label: _oneLine(r.message, 80), n: 1, kind: 'message' }))]
++ .filter((e) => e.label && !_PLACEHOLDER.test(e.label))
++ .sort((x, y) => at(x.r) - at(y.r))
++ .slice(-10);
++ return picked.map(({ r, label, n, kind }) => ({
++ label: n > 1 ? `${label} ×${n}` : label, detail: _oneLine(r.username || r.channel_username || '', 120),
++ mins_ago: _minsAgo(r.timestamp || r.created_at, now), kind, message_id: r.id == null ? null : Number(r.id),
++ }));
}
// The words the window keeps coming back to (the same stop-list rule as the job's topics).
ddiff --git a/test/extractive-timeline.test.js b/test/extractive-timeline.test.js
nnew file mode 100644
iindex 0000000..b8dced4
---- /dev/null
++++ b/test/extractive-timeline.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++// The fallback timeline (server/ai/extractive.js): each event once with its count, and the latest messages beside
++// the events, newest last (the owner, 11-13: "played !africa" three times, and nothing from today).
++const assert = require('assert');
++const { _timeline } = require('../server/ai/extractive');
++
++const now = Date.parse('2026-11-13T20:00:00Z');
++const at = (min) => new Date(now - min * 60000).toISOString();
++const rows = [
++ { id: 1, message: 'played !africa', message_type: 'sound', username: 'a', timestamp: at(300) },
++ { id: 2, message: 'played !africa', message_type: 'sound', username: 'b', timestamp: at(200) },
++ { id: 3, message: 'played !africa', message_type: 'sound', username: 'c', timestamp: at(100) },
++ { id: 4, message: 'good morning everyone', message_type: 'chat', username: 'd', timestamp: at(30) },
++ { id: 5, message: 'who is live today?', message_type: 'chat', username: 'e', timestamp: at(10) },
++ { id: 6, message: 'spoken words', message_type: 'tts', username: 'f', timestamp: at(5) },
++];
++const t = _timeline(rows, now);
++const labels = t.map((x) => x.label);
++assert.deepStrictEqual(labels, ['played !africa ×3', 'good morning everyone', 'who is live today?'], 'one line per event, with today\'s messages');
++assert.strictEqual(t[0].message_id, 3, 'the event line points at its latest play');
++assert.strictEqual(t[0].kind, 'event');
++assert.strictEqual(t[2].kind, 'message');
++assert.ok(!labels.includes('spoken words'), 'TTS lines are not moments');
++
++// Only messages: the latest few, in order.
++const chat = Array.from({ length: 12 }, (_, i) => ({ id: 10 + i, message: `line ${i}`, message_type: 'chat', username: 'x', timestamp: at(60 - i) }));
++const c = _timeline(chat, now);
++assert.ok(c.length <= 10 && c.length >= 3, 'a few messages');
++assert.strictEqual(c[c.length - 1].label, 'line 11', 'newest last');
++
++console.log('extractive-timeline: events once with a count, today\'s messages beside them ok');