openvibers/openvibe.live Commits

The Today card falls back to the day's takes when the AI answers nothing usable (#838)

OP OpenVibers committed 2 days ago · 211213882d · 3 files, +44 −5

The live model has answered the card prompt with no output (0 tokens) on a take about a public
allegation. The card is still the day's: when the AI answer is empty or filtered, a plain card is
built from the takes themselves (who, the topic, their take or what they said), with the same
explicit guard, and is labelled From today's takes rather than Written by AI.

Checked: ov test test/rundown-today.test.js (the fallback's words, links, label and explicit guard).

Changes

ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex 721c4563..80292250 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -982,7 +982,9 @@ function _rdTodayCard(today) {
});
card.append(list);
}
-- card.append(_rdDayEl('p', 'rd-day-sum-by', 'Written by AI from today\u2019s takes. Each highlight links to the take it came from.'));
++ card.append(_rdDayEl('p', 'rd-day-sum-by', today.written_by === 'live'
++ ? 'From today\u2019s takes. Each highlight links to the take it came from.'
++ : 'Written by AI from today\u2019s takes. Each highlight links to the take it came from.'));
return card;
}
function _rdDayDraw(day) {
ddiff --git a/server/rundown/today.js b/server/rundown/today.js
iindex 44ce4687..7d071167 100644
---- a/server/rundown/today.js
++++ b/server/rundown/today.js
@@@ -138,6 +138,28 @@ async function aiOn() {
try { const llm = require('../ai/llm'); return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; }
}
++/** A card from today's takes themselves, used only when the AI answers nothing usable: honest, if plainer. */
++function fallbackCard(date, items, total, now, latest) {
++ const sample = sampleTakes(items, 3);
++ const who = [...new Set(items.map((t) => t.channel && t.channel.username).filter(Boolean))].slice(0, 4);
++ const topics = [...new Set(items.map((t) => t.topic && t.topic.title).filter(Boolean))].slice(0, 3);
++ const headline = clamp(topics.length ? topics.slice(0, 2).join(' and ') : `${who.join(', ') || 'Today'} on the Rundown`, 100);
++ const sentences = sample.map((t) => {
++ const name = (t.channel && t.channel.username) || 'A streamer';
++ const about = t.topic && t.topic.title ? ` talked about ${t.topic.title}` : ' was on';
++ const said = t.take || t.what;
++ return said ? `${name}${about}: ${said}` : `${name}${about}.`;
++ });
++ const summary = clamp(sentences.join(' '), 700);
++ if (!headline || !summary || explicit.isExplicit(headline, summary, ...sample.map((t) => t.take || t.what || ''))) return null;
++ const highlights = sample.map((t) => ({
++ take_id: Number(t.id), line: clamp(t.take || t.what, 240), stream_id: Number(t.stream_id),
++ username: (t.channel && t.channel.username) || null, topic: (t.topic && t.topic.title) || null,
++ url: takeUrl(t.stream_id, t.id),
++ })).filter((h) => h.line);
++ return { status: 'ready', version: CARD_VERSION, written_by: 'live', date, headline, summary, highlights, generated_at: now, latest_take_id: latest, take_count: total, take_sample: sample.length };
++}
++
/**
* Write today's card if it is due. ai is injectable for tests (a function like ai-service.structured).
* Returns the stored card, the existing card when not due, or null when there was nothing to write.
@@@ -158,8 +180,11 @@ async function refresh(date, { now = Date.now(), force = false, ai = null } = {}
catch { out = null; }
if (!out) { console.log(`[Rundown] today: no AI answer for ${date}`); return null; }
const text = cardText(out);
-- if (!text) { console.log(`[Rundown] today: the AI answer for ${date} was filtered`); return null; }
-- const card = { status: 'ready', version: CARD_VERSION, date, ...enrich(text, sample, total), generated_at: now, latest_take_id: latest, take_count: total, take_sample: sample.length };
++ const card = text
++ ? { status: 'ready', version: CARD_VERSION, written_by: 'ai', date, ...enrich(text, sample, total), generated_at: now, latest_take_id: latest, take_count: total, take_sample: sample.length }
++ : fallbackCard(date, sample, total, now, latest);
++ if (!card) { console.log(`[Rundown] today: the AI answer for ${date} was filtered and the takes cannot make a card`); return null; }
++ if (!text) console.log(`[Rundown] today: no usable AI answer for ${date}; wrote the card from its takes`);
try { await db.setSetting(KEY(date), JSON.stringify(card)); } catch { return null; }
return card;
}
@@@ -189,13 +214,13 @@ function ssrHtml(card) {
sub: [h.username, h.topic].filter(Boolean).join(' · '),
}));
return kit.section({
-- title: "Today's rundown", icon: 'spark', note: 'Written by AI from today’s takes.',
++ title: "Today's rundown", icon: 'spark', note: card.written_by === 'live' ? 'From today’s takes.' : 'Written by AI from today’s takes.',
body: `<p class="rd-today-headline"><strong>${kit.esc(card.headline)}</strong></p><p class="rd-today-sum">${kit.esc(card.summary)}</p>`
+ (rows.length ? kit.list(rows, { title: 'Highlights' }) : ''),
});
}
module.exports = {
-- refresh, get, tick, start, stop, ssrHtml, isDue, sampleTakes, cardInput, cardText, takesFor, utcDay,
++ refresh, get, tick, start, stop, ssrHtml, isDue, sampleTakes, cardInput, cardText, fallbackCard, takesFor, utcDay,
REFRESH_MS, CHECK_MS, MIN_TAKES, MAX_TAKES, CARD_VERSION, KEY,
};
ddiff --git a/test/rundown-today.test.js b/test/rundown-today.test.js
iindex 74ea57c5..5b53d40b 100644
---- a/test/rundown-today.test.js
++++ b/test/rundown-today.test.js
@@@ -40,6 +40,18 @@ const today = require('../server/rundown/today');
assert.strictEqual(today.cardText({ headline: 'A night', summary: 'They watched porn.', highlights: [] }), null, 'the explicit guard');
assert.strictEqual(today.cardText({ headline: 'No summary', highlights: [] }), null);
++ // ── The fallback: when the AI answers nothing usable, the takes themselves still make an honest card ──
++ const fb = today.fallbackCard('2026-11-11', [
++ { id: 41, stream_id: 12, take: 'Internet fridges are a bad idea.', what: 'They read about hacked fridges.', channel: { username: 'goosely' }, topic: { title: 'Fridges hacked' } },
++ { id: 42, stream_id: 13, take: null, what: 'ann fixed a build live.', channel: { username: 'ann' }, topic: { title: 'Rust builds' } },
++ ], 2, now, 42);
++ assert.strictEqual(fb.written_by, 'live');
++ assert.strictEqual(fb.headline, 'Fridges hacked and Rust builds');
++ assert.strictEqual(fb.summary, 'goosely talked about Fridges hacked: Internet fridges are a bad idea. ann talked about Rust builds: ann fixed a build live.');
++ assert.deepStrictEqual(fb.highlights.map((h) => h.url), ['/rundown/e/12#rd-take-41', '/rundown/e/13#rd-take-42']);
++ assert.match(today.ssrHtml(fb), /From today’s takes\./, 'the fallback says where its words came from');
++ assert.strictEqual(today.fallbackCard('2026-11-11', [{ id: 1, stream_id: 1, take: 'porn', what: '', channel: { username: 'a' }, topic: null }], 1, now, 1), null, 'the explicit guard covers the fallback');
++
// ── The server-rendered card: kit markup, every highlight a link to its take ──
const html = today.ssrHtml({ status: 'ready', headline: 'Fridges & builds', summary: 'A day about fridges and one stubborn compiler.', highlights: [
{ take_id: 41, line: 'The fridge line', url: '/rundown/e/12#rd-take-41', username: 'goosely', topic: 'Fridges' },