openvibers/openvibe.live Commits

The Rundown: a line heard word for word on 3+ streams in 30 days never reaches the AI (game voice lines like "Wanna see a trick?", speech-to-text's "Thanks for watching" on silence, stock filler) (#254)

OP OpenVibers committed 25 days ago · 248c52a290 · 3 files, +35 −4

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex 4c93406a..df86b9e5 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -31,7 +31,7 @@ The first deploy reads back the last 7 days, newest streams first. At most 4 AI
Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "quote of the day" was League of Legends' announcer ("You have slain an enemy"), credited to a streamer. The desk's rules (`server/rundown/filters.js` and the prompts in OpenVibe.AI):
--- **Game lines** (League, Counter-Strike and Valorant announcer and system lines) never reach the AI and are never a quote. The prompt tells the AI that announcers, characters, lyrics, ads, video narration and articles read aloud are never the streamer; a reaction to them is the take.
++- **Game lines** (League, Counter-Strike and Valorant announcer and system lines) never reach the AI and are never a quote. Neither does a line heard word for word on 3 or more different streams in 30 days: a game's voice lines ("Wanna see a trick?"), speech-to-text's hallucinations on silence ("Thanks for watching, and I'll see you in the next video.") and stock filler. The prompt tells the AI that announcers, characters, lyrics, ads, video narration and articles read aloud are never the streamer; a reaction to them is the take.
- **A quote** is kept only when it is in the transcript the AI read, word for word.
- **No invented accusations.** A headline, account or take that uses an accusing word (threat, attack, scam, hacked…) the transcript never used drops the whole segment; in a quip, only the quip goes. Episodes and topic pages follow the same rule.
- **No looks, ever.** Every text passes `db.noLooks()`, and the prompts forbid jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, sexual content about anyone, threats and self-harm jokes. Swearing is fine.
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 43eb8bf9..9eb8ccb7 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -39,6 +39,7 @@ const ANNOUNCE_WITHIN_H = 6;
let _calls = [];
let _fails = new Map(); // stream id → { at: read_to_sec, n }
++let _repeated = { at: 0, set: new Set() };
let _announced = [];
let _busy = false;
let _job = null;
@@@ -135,16 +136,30 @@ async function setProgress(streamId, readTo, done = 0) {
ON CONFLICT (stream_id) DO UPDATE SET read_to_sec = GREATEST(rundown_progress.read_to_sec, excluded.read_to_sec), done = excluded.done, updated_at = ov_now()`, [streamId, readTo, done]);
}
--/** The next stretch: speech lines after read_to_sec, game lines out, at most MAX_WORDS. */
++/**
++ * Lines heard word for word on 3+ different streams in 30 days: a game's voice lines ("Wanna see a trick?", "An ally has
++ * been slain."), speech-to-text's hallucinations on silence ("Thanks for watching, and I'll see you in the next video.",
++ * "ご視聴ありがとうございました") and stock filler. None is anyone's take, so none reaches the AI or can be quoted.
++ */
++async function repeatedLines() {
++ if (Date.now() - _repeated.at < 3600_000) return _repeated.set;
++ const rows = await db.all(`SELECT text FROM stream_timeline_events WHERE kind = 'speech' AND created_at >= ? AND length(text) >= 8
++ GROUP BY text HAVING COUNT(DISTINCT stream_id) >= 3`, [daysAgo(30)]);
++ _repeated = { at: Date.now(), set: new Set(rows.map((r) => filters.norm(r.text))) };
++ return _repeated.set;
++}
++
++/** The next stretch: speech lines after read_to_sec, game and repeated lines out, at most MAX_WORDS. */
async function nextStretch(stream) {
const rows = await db.all(`SELECT start_sec, text, vod_id FROM stream_timeline_events
WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 800`, [stream.id, stream.read_to_sec]);
++ const repeated = await repeatedLines();
const lines = [];
let n = 0, lastSec = Number(stream.read_to_sec);
for (const r of rows) {
const text = String(r.text || '').replace(/\s+/g, ' ').trim();
const w = words(text);
-- if (text && !filters.gameLine(text)) {
++ if (text && !filters.gameLine(text) && !repeated.has(filters.norm(text))) {
if ((n + w > MAX_WORDS || lines.length >= MAX_LINES) && lines.length) break;
lines.push({ t: Math.round(Number(r.start_sec) * 10) / 10, text: text.slice(0, 1000), vod_id: r.vod_id });
n += w;
@@@ -326,4 +341,4 @@ function start() {
}
function stop() { if (typeof _job === 'function') _job(); _job = null; }
--module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _people = { at: 0, entries: [] }; } } };
++module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, MAX_LINES, _test: { reset() { _calls = []; _announced = []; _fails = new Map(); _repeated = { at: 0, set: new Set() }; _people = { at: 0, entries: [] }; } } };
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
iindex 6dc2ac74..ec0292b2 100644
---- a/test/rundown.test.js
++++ b/test/rundown.test.js
@@@ -195,6 +195,22 @@ try {
} finally { server.close(); }
console.log('✅ API: front page, topic, reactions as a signed-out reader; old /arena links');
++// ── Lines heard on 3+ streams (voice lines, hallucinations on silence) never reach the AI ──
++{
++ const echo = await mk('echoes');
++ const ghost = 'Thanks for watching, and I\'ll see you in the next video.';
++ for (let i = 0; i < 3; i++) { const old = await stream(echo, { startedAgoMin: 30 * 1440 + i, endedAgoMin: 30 * 1440 - 60 }); await speak(old, echo, [ghost]); }
++ const fresh = await mk('freshone');
++ const sf = await stream(fresh, { startedAgoMin: 200, endedAgoMin: 100, title: 'Fresh' });
++ await speak(sf, fresh, [ghost, ...filler(25, 'My own words')]);
++ desk._test.reset();
++ await desk.tick();
++ const c = calls.filter((x) => x.workflow === 'live.rundown.segment' && x.input.streamer === 'freshone');
++ assert.strictEqual(c.length, 1);
++ assert.ok(!c[0].input.lines.some((l) => /next video/.test(l.text)) && c[0].input.lines.some((l) => /My own words/.test(l.text)), 'the repeated line is out, their own words are in');
++ console.log('✅ lines heard on three streams never reach the AI');
++}
++
// ── Limits: a stretch is at most MAX_LINES lines; a stretch the AI keeps refusing is skipped after three tries ──
{
const greek = await mk('greekline');