openvibers/openvibe.live Commits

The Rundown never keeps a detail that places someone: a segment, quote, quip, episode text or topic page naming a time zone, a school, a street address, a phone number or an email is dropped (a teen streamer's stream was written up with a time zone and school details) (#257)

OP OpenVibers committed 25 days ago · 38f79f0e32 · 4 files, +51 −6

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex beb59372..d66f07ce 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -36,6 +36,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
- **Who speaks each line.** The AI first returns `others`, the times of the lines that aren't the streamer (game characters, videos, songs, other people). A quote from one of those lines is dropped, and so is a segment whose lines are mostly theirs. A game character's boasts ("Kneel before me or bleed out.") are the game even in English.
- **A quote** is kept only when it is in the streamer's own lines 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.
++- **Nothing that places a person.** The prompts forbid writing or quoting where anyone is, lives or will be (named places, events, schools, towns, time zones), their school, workplace or family. After the AI, `filters.privateDetail` drops any segment, quote, quip, episode text or topic page that names a time zone, a school, a street address, a phone number or an email address. When someone seems to be under 18, the desk is kind: no roasting and nothing about school.
- **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.
- **Names and pronouns.** Names are written exactly as given, and people are called by their name or "they".
- **Opting out.** A streamer can keep themselves out ("Keep me out of The Rundown" on their page, `PUT /api/rundown/opt-out`; the Arena's opt-outs carry over). The desk stops reading their streams, and what was written about them is set aside (status `out`). Coming back restores it, never anything staff took down.
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex c081daea..ac3993e3 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -220,13 +220,16 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
if (!title || !what || !s.topic) continue;
// An accusation the transcript never made is the desk's invention: the segment goes (its quip alone, the quip).
if (filters.addsAccusation(`${title} ${what} ${take || ''}`, source)) continue;
-- if (quip && filters.addsAccusation(quip, source)) quip = null;
++ // A detail that places someone (a time zone, a school, an address…) drops the segment; in a quip, the quip.
++ if (filters.privateDetail(`${s.topic} ${title} ${what} ${take || ''}`)) continue;
++ if (quip && (filters.addsAccusation(quip, source) || filters.privateDetail(quip))) quip = null;
const span = stretch.lines.filter((l) => l.t >= Number(s.start) - 1 && l.t <= Number(s.end) + 1);
if (span.length && span.filter(isOther).length / span.length > 0.6) continue;
const spanText = (span.length ? span.filter((l) => !isOther(l)) : own).map((l) => l.text).join('\n');
const quotes = (s.quotes || []).map((q) => {
const text = filters.verifiedQuote(q.text, source);
-- return text ? { text, sec: Number(q.t) || Number(s.start) || 0, en: q.en ? clean(q.en, 400) : null } : null;
++ if (!text || filters.privateDetail(text) || (q.en && filters.privateDetail(q.en))) return null;
++ return { text, sec: Number(q.t) || Number(s.start) || 0, en: q.en ? clean(q.en, 400) : null };
}).filter(Boolean).slice(0, 2);
const at = sqlTime(startedMs + (Number(s.start) || 0) * 1000);
const topic = await topicFor(clean(s.topic, 120), s.kind, at);
@@@ -282,9 +285,9 @@ async function writeEpisode(stream, user) {
}, user.id);
if (!out || !out.title || !out.recap) return { called: true, failed: true };
const source = segs.map((s) => `${s.title} ${s.what} ${s.take || ''} ${parseJson(s.quotes_json, []).map((q) => q.text).join(' ')}`).join('\n');
-- const recap = filters.addsAccusation(out.recap, source) ? null : clean(out.recap.replace(/\n\s*\n/g, ' ¶ '), 1400);
++ const recap = filters.addsAccusation(out.recap, source) || filters.privateDetail(out.recap) ? null : clean(out.recap.replace(/\n\s*\n/g, ' ¶ '), 1400);
const title = clean(out.title, 80);
-- if (!title || filters.addsAccusation(`${out.title} ${out.logline || ''}`, source)) { await setProgress(stream.id, stream.read_to_sec, 1); return { called: true, failed: true }; }
++ if (!title || filters.addsAccusation(`${out.title} ${out.logline || ''}`, source) || filters.privateDetail(`${out.title} ${out.logline || ''}`)) { await setProgress(stream.id, stream.read_to_sec, 1); return { called: true, failed: true }; }
const chapters = (out.chapters || []).map((c) => segs[c.index] ? { sec: Math.round(Number(segs[c.index].start_sec)), title: clean(c.title, 60), segment_id: segs[c.index].id } : null).filter((c) => c && c.title);
const vod = (segs.find((s) => s.vod_id) || {}).vod_id || null;
await db.run(`INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at)
@@@ -320,7 +323,7 @@ async function refreshTopic(topicId) {
if (!takes.length) return { called: false };
const out = await ai('live.rundown.topic', { topic: topic.title, takes: takes.map((t) => ({ streamer: t.display_name || t.username, when: String(t.said_at).slice(0, 10), what: t.what, take: t.take || null, quote: (parseJson(t.quotes_json, [])[0] || {}).text || null })) }, null);
const source = takes.map((t) => `${t.what} ${t.take || ''} ${parseJson(t.quotes_json, []).map((q) => q.text).join(' ')}`).join('\n');
-- if (!out || !out.summary || filters.addsAccusation(`${out.headline || ''} ${out.summary}`, source)) {
++ if (!out || !out.summary || filters.addsAccusation(`${out.headline || ''} ${out.summary}`, source) || filters.privateDetail(`${out.headline || ''} ${out.summary}`)) {
await db.run('UPDATE rundown_topics SET summary_takes = ?, summary_at = ov_now() WHERE id = ?', [takes.length, topicId]);
return { called: true, failed: true };
}
ddiff --git a/server/rundown/filters.js b/server/rundown/filters.js
iindex 3db5f89b..ec696a39 100644
---- a/server/rundown/filters.js
++++ b/server/rundown/filters.js
@@@ -8,6 +8,7 @@
* gameLine(text) a line a game says, never a streamer's own words (also dropped before the AI reads them)
* verifiedQuote(q, text) a quote is kept only when it is in the transcript the AI read, word for word, and is no game line
* addsAccusation(t, src) an AI line may use an accusing word (threat, attack, scam…) only when the transcript did
++ * privateDetail(text) a time zone, school, street address, phone number or email: never kept
* slugify / sameTopic topic identity
* lineLang(text) a line's language, roughly (desk.js keeps a streamer's own languages, so a clip in Portuguese
* playing on a Greek streamer's stream is never theirs)
@@@ -80,6 +81,23 @@ function addsAccusation(text, source) {
return (String(text || '').match(ACCUSING) || []).some((w) => !have.has(root(w)));
}
++// Details that place a person (the prompts forbid them; this is the check after): a time zone, school, a street address,
++// a phone number or an email address. A teen streamer's theme-park stream was written up with "around eight mountain
++// time" and school details on the first night (2026-10-19).
++const PRIVATE = [
++ /\b(?:mountain|pacific|eastern|central|atlantic|alaska|hawaii)\s+(?:standard\s+|daylight\s+)?time\b/i,
++ /\b(?:[ECMP][SD]T|AKST|HST)\b/,
++ /\b(?:my|our|at|his|her|their|the|to|from|after)\s+(?:high\s+|middle\s+|elementary\s+)?school\b|\bschool\s+(?:newspaper|bus|trip|kids)\b|\bclassmates?\b|\bhomeroom\b|\bmy\s+teacher\b/i,
++ /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way)\b/,
++ /(?:\+?\d[\s().-]?){10,}/,
++ /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/,
++];
++/** Does `text` name a time zone, a school, a street address, a phone number or an email address? */
++function privateDetail(text) {
++ const t = String(text || '');
++ return PRIVATE.some((re) => re.test(t));
++}
++
function slugify(title) {
return norm(title).replace(/ /g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 80).replace(/-$/, '');
}
@@@ -95,4 +113,4 @@ function sameTopic(a, b) {
return both / (x.size + y.size - both) >= 0.6;
}
--module.exports = { gameLine, verifiedQuote, addsAccusation, slugify, sameTopic, norm, lineLang, GAME_LINES };
++module.exports = { gameLine, verifiedQuote, addsAccusation, privateDetail, slugify, sameTopic, norm, lineLang, GAME_LINES };
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
iindex 9c7fa40a..1d22245a 100644
---- a/test/rundown.test.js
++++ b/test/rundown.test.js
@@@ -33,6 +33,8 @@ assert.ok(filters.addsAccusation('goosely threatened the fridge company', src),
assert.ok(!filters.addsAccusation('hackers got into French fridges', src), 'the transcript said hackers');
assert.ok(!filters.addsAccusation('French fridges got hacked', src), 'hacked: the same root as the transcript\'s hackers');
assert.strictEqual(filters.slugify('Supermarket fridges hacked in France!'), 'supermarket-fridges-hacked-in-france');
++for (const t of ['back around eight mountain time', 'famous because of the school newspaper', 'I went to school today', 'call 555 123 4567', 'mail me at [email protected]', 'we live at 1420 Maple Street']) assert.ok(filters.privateDetail(t), `private: ${t}`);
++for (const t of ['First blood was mine', 'This is my favourite ride', 'Supermarket fridges hacked in France']) assert.ok(!filters.privateDetail(t), `not private: ${t}`);
assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in League'));
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
console.log('✅ filters: game lines, verbatim quotes, accusations, topics');
@@@ -245,6 +247,27 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
console.log('✅ own languages and the AI\'s speaker labels');
}
++// ── Details that place a person are never kept ──
++{
++ const teen = await mk('teenstreamer');
++ const st = await stream(teen, { startedAgoMin: 200, endedAgoMin: 120, title: 'Theme park' });
++ await speak(st, teen, ['I might live again later around eight mountain time okay chat', 'this ride is honestly the best ride in the whole park chat', ...filler(25, 'Ride talk')]);
++ const prev = answers['live.rundown.segment'];
++ answers['live.rundown.segment'] = (input) => input.streamer !== 'teenstreamer' ? prev(input) : { others: [], segments: [
++ { start: 0, end: 5, topic: 'Streaming later tonight', kind: 'chat', title: 'Back at eight mountain time', what: 'teenstreamer will stream again around eight mountain time.', take: null, quip: 'x', score: 3, quotes: [] },
++ { start: 5, end: 300, topic: 'Theme park rides', kind: 'life', title: 'The best ride in the park', what: 'teenstreamer loved a ride.', take: 'This ride is the best.', quip: 'See you after school.', score: 3,
++ quotes: [{ t: 0, text: 'I might live again later around eight mountain time', en: null }, { t: 10, text: 'this ride is honestly the best ride in the whole park', en: null }] },
++ ] };
++ desk._test.reset();
++ await desk.tick();
++ const kept = await db.all('SELECT title, quip, quotes_json FROM rundown_segments WHERE stream_id = ?', [st]);
++ assert.deepStrictEqual(kept.map((k) => k.title), ['The best ride in the park'], 'a segment naming a time zone is dropped');
++ assert.strictEqual(kept[0].quip, null, 'a quip about school is dropped');
++ assert.deepStrictEqual(JSON.parse(kept[0].quotes_json).map((q) => q.text), ['this ride is honestly the best ride in the whole park'], 'a quote with a time zone is dropped');
++ answers['live.rundown.segment'] = prev;
++ console.log('✅ no time zones, schools, addresses or contact details');
++}
++
// ── 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');