openvibers/openvibe.live Commits

The Rundown: a topic made only of words for a mood or the play ('Frustration and chaos in the match', 106 takes) is no topic, a stream keeps its best 2 takes on one topic (a better one replaces the weakest), and the announcer as speech-to-text hears it ('I've been slain.') is a game line (#260)

OP OpenVibers committed 25 days ago · 6b4d2207ca · 4 files, +55 −6

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex 0571769e..84b6bf70 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -19,9 +19,9 @@
- a **topic**, concrete ("Supermarket fridges hacked in France", never "news"), and its kind (news, games, tech, life, music, sports, money, internet, chat, other);
- a **headline**, **what** they talked about and **their take**;
- the desk's **quip**, a dry late-night-host line about the take or the situation;
-- - a 1–5 **score** (a 1 or 2 is filler and isn't kept);
++ - a 1–5 **score** for a reader who missed the stream (a 1 or 2 is filler and isn't kept; in-game reactions are a 2 at most). Play-by-play (kills, deaths, teammates, rounds as they happen) is not a segment: a game segment needs an opinion, a story, a plan, a bit or an argument, and the episode recap covers the rest;
- up to two **quotes**, copied word for word, with an English translation when they spoke another language.
--3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.
++3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic names a subject ("League autofill", "Boxing fight night"): a kind, a word for streaming or a name made only of words for a mood or the play ("Frustration and chaos in the match", "Talking with chat and teammates") is no topic (`filters.vagueTopic`), and those names are never offered back to the AI. A stream keeps its best 2 takes on one topic, and 2 with no topic: a better one replaces the weakest, the rest go. One night of League once filled a single mood topic with 106 takes of play-by-play. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.
4. **Episodes.** When an ended stream is read to the end, `live.rundown.episode` writes it up from its segments: title, logline, a recap in 2–3 short paragraphs and chapters (VOD timestamps). A stream with no segments gets no episode.
5. **Crossovers.** A segment records the streamers it named. Only a whole username or display name said as it is spelled counts (`server/arena/names.js`, `wholeOnly`), never a sound-alike or a nickname. Naming someone is talking, not a fight: there are no beefs, scores or winners.
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 3690d947..bb6a092e 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -35,6 +35,7 @@ const CALLS_PER_TICK = 4;
const CALLS_PER_HOUR = 40;
const KNOWN_TOPICS = 50;
const MIN_SCORE = 3; // a 1-2 is filler ("heading out for coffee", a garbled stretch): not kept
++const PER_STREAM_TOPIC = 2; // a stream keeps its best 2 takes on one topic (and 2 with no topic): more is play-by-play
const ANNOUNCE_PER_HOUR = 3;
const ANNOUNCE_WITHIN_H = 6;
@@@ -235,11 +236,21 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
}).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);
++ // One League night came back as a take per stretch under one topic. Past PER_STREAM_TOPIC, a better take replaces
++ // the stream's weakest on that topic, and anything else goes.
++ const score = Math.min(5, Math.max(1, Number(s.score) || 1));
++ const mine = await db.all(`SELECT id, score FROM rundown_segments WHERE stream_id = ? AND ${topic ? 'topic_id = ?' : 'topic_id IS NULL'} ORDER BY score, id`,
++ topic ? [stream.id, topic.id] : [stream.id]);
++ if (mine.length >= PER_STREAM_TOPIC) {
++ if (score <= Number(mine[0].score)) continue;
++ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ?", [mine[0].id]);
++ await db.run('DELETE FROM rundown_segments WHERE id = ?', [mine[0].id]);
++ }
const vod = (span.find((l) => l.vod_id) || stretch.lines.find((l) => l.vod_id) || {}).vod_id || null;
await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,
-- Math.min(5, Math.max(1, Number(s.score) || 1)), JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
++ score, JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
if (topic) await recountTopic(topic.id);
kept++;
}
ddiff --git a/server/rundown/filters.js b/server/rundown/filters.js
iindex 5e1b5dde..c114ac1e 100644
---- a/server/rundown/filters.js
++++ b/server/rundown/filters.js
@@@ -21,7 +21,8 @@ const GAME_LINES = [
/^(?:an? )?(?:enemy|ally) (?:has been slain|double kill|triple kill|quadra kill|penta kill)\.?$/i,
/\byou have (?:slain an? enemy|been slain)\b/i,
/\b(?:an )?(?:enemy|ally) has been slain\b/i,
-- /^(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,
++ /^(?:i'?ve|i have|you'?ve) been slain[.!]?$/i,
++ /^(?:the )?(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,
/\bwelcome to summoner'?s rift\b/i,
/\b(?:thirty seconds until minions spawn|minions have spawned)\b/i,
/\b(?:your|the enemy|our) team has (?:destroyed|taken|slain) (?:a |an |the )?(?:turret|inhibitor|dragon|baron|herald|nexus)\b/i,
@@@ -103,10 +104,17 @@ function privateDetail(text) {
const VAGUE = new Set(['news', 'games', 'game', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other', 'gaming', 'gameplay',
'chatting', 'just chatting', 'stream', 'streaming', 'the stream', 'stream talk', 'misc', 'general', 'random', 'stuff', 'irl', 'talk', 'conversation',
'banter', 'reactions', 'updates', 'life update', 'nothing']);
--/** Is this topic name too vague to be a topic? */
++// Words for a mood, the play or the stream, not a subject. A topic made only of them ("Frustration and chaos in the
++// match", "Talking with chat and teammates") gathered a night of play-by-play from every game: 106 and 84 takes.
++const GENERIC = new Set(['frustration', 'frustrated', 'chaos', 'chaotic', 'rage', 'raging', 'tilt', 'tilted', 'salt', 'salty', 'vent',
++ 'venting', 'rant', 'rants', 'ranting', 'reaction', 'reactions', 'reacting', 'hype', 'mood', 'vibes', 'energy', 'moment', 'moments',
++ 'highlights', 'talking', 'chatting', 'chat', 'teammates', 'team', 'match', 'matches', 'game', 'games', 'gaming', 'gameplay', 'round',
++ 'rounds', 'play', 'plays', 'stream', 'streaming', 'streamer', 'viewers', 'mid', 'calls', 'commands', 'builds', 'criticism', 'banter',
++ 'jokes', 'stuff', 'things', 'life', 'random', 'night', 'session', 'fun', 'drama', 'confusion', 'mistakes']);
++/** Is this topic name too vague to be a topic? A kind, a word for streaming, or only words for a mood or the play. */
function vagueTopic(title) {
const t = norm(title);
-- return !t || VAGUE.has(t) || t.length < 4;
++ return !t || VAGUE.has(t) || t.length < 4 || [...keyWords(title)].every((w) => GENERIC.has(w));
}
function slugify(title) {
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
iindex 92cb3ad9..e487d7c9 100644
---- a/test/rundown.test.js
++++ b/test/rundown.test.js
@@@ -24,6 +24,7 @@ const read = require('../server/rundown/read');
assert.ok(filters.gameLine('You have slain an enemy.'), 'the League announcer');
assert.ok(filters.gameLine('Victory!') && filters.gameLine('An enemy has been slain') && filters.gameLine('Welcome to Summoner\'s Rift'));
assert.ok(!filters.gameLine('I got first blood on that guy and chat lost it'), 'a sentence about the game is the streamer');
++assert.ok(filters.gameLine("I've been slain.") && filters.gameLine('The first blood!'), 'the announcer as speech-to-text hears it');
const src = 'This is why having the internet inside of a refrigerator is a bad idea.\nThe hackers got in with the date as the password.';
assert.strictEqual(filters.verifiedQuote('“having the internet inside of a refrigerator is a bad idea”', src), 'having the internet inside of a refrigerator is a bad idea');
assert.strictEqual(filters.verifiedQuote('internet fridges are a terrible idea', src), null, 'a paraphrase is not a quote');
@@@ -39,6 +40,8 @@ assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));
assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));
++['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));
++['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));
console.log('✅ filters: game lines, verbatim quotes, accusations, topics');
// ── People and streams ──
@@@ -293,6 +296,33 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
console.log('✅ limits: lines per stretch, refused stretches skipped after three tries');
}
++// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──
++{
++ const gamer = await mk('leaguenight');
++ const sl = await stream(gamer, { startedAgoMin: 500, endedAgoMin: 420, title: 'Ranked' });
++ await speak(sl, gamer, filler(60, 'Autofill again'));
++ const prev = answers['live.rundown.segment'];
++ const seg = (start, topic, title, score) => ({ start, end: start + 5, topic, kind: 'games', title, what: `leaguenight on ${title}.`, take: null, quip: null, score, quotes: [] });
++ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [
++ seg(0, 'League autofill', 'Autofill A', 3), seg(10, 'League autofill', 'Autofill B', 4), seg(20, 'League autofill', 'Autofill C', 3), seg(30, 'League autofill', 'Autofill D', 5),
++ ] };
++ desk._test.reset();
++ await desk.tick();
++ const titles = async (cond) => (await db.all(`SELECT title FROM rundown_segments WHERE stream_id = ? AND ${cond} ORDER BY title`, [sl])).map((r) => r.title);
++ assert.deepStrictEqual(await titles('topic_id IS NOT NULL'), ['Autofill B', 'Autofill D'], 'the best two takes on a topic stay');
++ assert.strictEqual(Number((await db.get("SELECT takes FROM rundown_topics WHERE slug = 'league-autofill'")).takes), 2, 'the topic counts what stayed');
++ await db.run('DELETE FROM rundown_progress WHERE stream_id = ?', [sl]);
++ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [
++ seg(40, 'Frustration and chaos in the match', 'Rage A', 3), seg(50, 'Mid-game rage', 'Rage B', 3), seg(60, 'Talking with chat and teammates', 'Rage C', 4),
++ ] };
++ desk._test.reset();
++ await desk.tick();
++ assert.deepStrictEqual(await titles('topic_id IS NULL'), ['Rage B', 'Rage C'], 'a mood is no topic, and 2 such takes a stream at most');
++ assert.ok(!await db.get("SELECT 1 FROM rundown_topics WHERE slug LIKE 'frustration%' OR slug LIKE 'talking-with%'"));
++ answers['live.rundown.segment'] = prev;
++ console.log('✅ play-by-play: 2 takes a stream on a topic, moods are no topic');
++}
++
console.log('rundown: ok');
process.exit(0);
})().catch((err) => { console.error(err); process.exit(1); });