openvibers/openvibe.ai Commits

AI: the Rundown's Today card template and its postprocess (#475)

OP OpenVibers committed 2 days ago · fc69bbf17f · 5 files, +92 −1

* AI: the Rundown's Today card template and its postprocess

A new public-text workflow, live.rundown.today, beside live.stream.recap: today's takes in; a headline,
the day in two to four sentences and up to three highlights out, each highlight carrying the exact
take_id it came from. The postprocess filters the public prose, clamps every field, keeps only a
highlight whose take_id is one of the takes it was given and drops duplicates. Tests: the prompt
carries the takes and ids, the answer is clamped and filtered, and every workflow still has an example
that runs on the stub.

* The Today card waits in the background with the rest of the Rundown

Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex b932355..cd391a6 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -442,6 +442,23 @@ const POSTPROCESS = {
const one = (t, n) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, n);
return { headline: one(j.headline, 100), summary: one(j.summary, 700) };
},
++ // The day's card: public prose, so the labels and markup are filtered like any other public AI text, and a
++ // highlight survives only when its take_id is one of the takes it was given (never an invented link).
++ 'live.rundown.today'(r, input) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || typeof j !== 'object' || Array.isArray(j)) return null;
++ const one = (t, n) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, n);
++ const headline = one(j.headline, 100);
++ const summary = plainProse(j.summary, 700);
++ if (!headline || !summary) return null;
++ const ids = new Set((input && Array.isArray(input.takes) ? input.takes : []).map((x) => Number(x && x.take_id)).filter((n) => Number.isInteger(n) && n > 0));
++ const seen = new Set();
++ const highlights = (Array.isArray(j.highlights) ? j.highlights : []).slice(0, 8)
++ .map((h) => ({ take_id: Number(h && h.take_id), line: plainProse(h && h.line, 240) }))
++ .filter((h) => ids.has(h.take_id) && h.line && !seen.has(h.take_id) && seen.add(h.take_id))
++ .slice(0, 3);
++ return { headline, summary, highlights };
++ },
'live.clips.confirm'(r) {
const j = r.json || parseJsonLoose(r.text);
if (!j || typeof j !== 'object') return null;
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex acdb711..baa56b2 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -142,6 +142,15 @@ const templates = [
user_prompt: "{{json payload}}",
default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },
},
++ {
++ key: 'live.rundown.today', name: "Live: The Rundown's Today card",
++ description: "From rundown/today.js: today's takes (topic, who said it, what, their take, their own line) in; a headline, a 2-4 sentence summary of the day and up to 3 take-linked highlights out.",
++ input_schema: { type: 'object' },
++ output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "summary", "highlights"], "properties": {"headline": {"type": "string"}, "summary": {"type": "string"}, "highlights": {"type": "array", "maxItems": 3, "items": {"type": "object", "additionalProperties": false, "required": ["take_id", "line"], "properties": {"take_id": {"type": "integer"}, "line": {"type": "string"}}}}}},
++ system_prompt: "You write the top card of THE RUNDOWN, the part of a small, community-run live-streaming site that turns what streamers said today into a story. You get today's takes: each with its take_id, who said it, when, the topic, what they talked about, their opinion, and sometimes a line in their own words. Say what today was about, in a few sentences, the way a friend who watched all of it would tell you. headline: at most 80 characters, specific to today (never \"Today on The Rundown\", \"A day of streams\" or the date alone). summary: two to four sentences, at most 600 characters, naming the streamers and their topics, who said what, where they agreed or clashed, and the strongest moments; warm, concrete and a little funny, never mocking anyone. highlights: up to 3 of the day's strongest moments, each with the exact take_id it comes from (copied from the input, never invented) and one line of at most 160 characters saying why that moment matters. No stock words or filler: never \"ups and downs\", \"highs and lows\", \"emotional rollercoaster\", \"journey\", \"tapestry\", \"delve\", \"unpack\", \"it was a day\", \"what a day\" or the name of the site repeated. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone. Talk about what they said and did, never who they are: no jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), be kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
++ user_prompt: "Today's takes (oldest first; copy take_id exactly):\n{{json payload}}\n\nReturn JSON only: {\"headline\": \"...\", \"summary\": \"...\", \"highlights\": [{\"take_id\": <the exact take_id>, \"line\": \"...\"}]}.",
++ default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_today' },
++ },
{
key: 'live.clips.confirm', name: "Live: is this chat spike clip-worthy",
description: "From auto-clip-job.js (auto_clip_confirm): scene notes, transcript, sounds and chat around a spike.",
@@@ -409,6 +418,23 @@ const structured = [
steps: [{ kind: 'llm', template: 'live.rundown.topic', operation: 'generate', output: 'json', prepare: 'live.rundown.topic', postprocess: 'live.rundown.topic', params: { max_tokens: 400, temperature: 1.0, timeout_ms: 30000 } }],
cache_mode: 'none',
},
++ {
++ key: 'live.rundown.today', name: "The Rundown: today's card", namespace: 'live',
++ description: "rundown/today.js: today's takes in; a headline, the day in a few sentences and up to 3 highlights, each linked to the take it came from.",
++ input_schema: TEXT_OUT({
++ date: STR(20),
++ takes: { type: 'array', minItems: 1, maxItems: 30, items: { type: 'object', required: ['take_id', 'streamer', 'what'], additionalProperties: false, properties: {
++ take_id: { type: 'integer', minimum: 1 }, streamer: STR(80), when: STR(40), topic: { type: ['string', 'null'], maxLength: 120 },
++ what: STR(500), take: { type: ['string', 'null'], maxLength: 300 }, quote: { type: ['string', 'null'], maxLength: 400 },
++ } } },
++ }, ['takes']),
++ output_schema: TEXT_OUT({
++ headline: STR(100), summary: STR(700),
++ highlights: { type: 'array', maxItems: 3, items: { type: 'object', required: ['take_id', 'line'], additionalProperties: false, properties: { take_id: { type: 'integer' }, line: STR(240) } } },
++ }, ['headline', 'summary', 'highlights']),
++ steps: [{ kind: 'llm', template: 'live.rundown.today', operation: 'generate', output: 'json', postprocess: 'live.rundown.today', params: { max_tokens: 600, temperature: 0.9, timeout_ms: 40000 } }],
++ cache_mode: 'none',
++ },
{
key: 'live.clips.confirm', name: "Is this chat spike clip-worthy", namespace: 'live',
description: "The prompt Live used to render (auto-clip-job.js): scene notes, timed speech and sounds, and chat around a spike in; clip or not, with a title, out.",
ddiff --git a/test/latency-classes.test.js b/test/latency-classes.test.js
iindex 613057f..1f6a422 100644
---- a/test/latency-classes.test.js
++++ b/test/latency-classes.test.js
@@@ -346,7 +346,7 @@ t.test('the price book job writes the blocks it read from the listing', async ()
// ── The workflows that wait ────────────────────────────────────────────────────
--const BACKGROUND = ['trade.summarize_market_context', 'live.rundown.topic', 'live.rundown.segment', 'live.rundown.episode',
++const BACKGROUND = ['trade.summarize_market_context', 'live.rundown.today', 'live.rundown.topic', 'live.rundown.segment', 'live.rundown.episode',
'live.stream.summarize', 'live.stream.describe_frame', 'live.moments.pick', 'live.moments.rank', 'live.moments.verify',
'live.streamer.overview', 'live.person.overview', 'live.media.overview', 'live.viewers.plan', 'live.viewers.fold',
'live.hero.slogans', 'live.home.star', 'live.easter_egg', 'live.paste.describe_image', 'live.paste.summarize_text',
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex 5e14c25..adb1ff3 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -263,4 +263,48 @@ t.test('The Rundown: an episode keeps chapters that name a real segment once; a
for (const k of ['live.rundown.segment', 'live.rundown.episode', 'live.rundown.topic']) assert.ok(live.workflows.find((w) => w.key === k && w.steps[0].template === k), k);
});
++t.test("The Rundown's Today card: the prompt carries the takes and the ids, and only real takes become highlights", () => {
++ const tp = (k) => live.templates.find((x) => x.key === k);
++ const today = tp('live.rundown.today');
++ assert.ok(today, 'the today template exists');
++ assert.strictEqual(today.default_route, 'background.creative');
++ assert.match(today.system_prompt, /warm, concrete and a little funny/);
++ assert.match(today.system_prompt, /No stock words or filler/);
++ assert.match(today.system_prompt, /copied from the input, never invented/);
++ assert.match(today.system_prompt, /never guess a pronoun/);
++ assert.match(today.system_prompt, /Privacy: never write or quote where anyone is/);
++ assert.match(today.system_prompt, /seems to be under 18/);
++ const input = { date: '2026-11-11', takes: [
++ { take_id: 41, streamer: 'goosely', when: '2026-11-11T10:00:00Z', topic: 'Supermarket fridges hacked in France', what: 'They read about hacked fridges.', take: 'Internet fridges are a bad idea.', quote: 'A bad idea.' },
++ { take_id: 42, streamer: 'ann', when: '2026-11-11T12:00:00Z', topic: 'Rust builds', what: 'They fixed a bug live.', take: null, quote: 'It compiles!' },
++ ] };
++ const user = render(today.user_prompt, { payload: input });
++ assert.ok(user.includes('"take_id":41') && user.includes('"take_id":42'), user);
++ assert.match(user, /Return JSON only/);
++ const out = POSTPROCESS['live.rundown.today']({ json: {
++ headline: ' Fridges, compilers and one stubborn bug ',
++ summary: '## Summary\n- **The day:** goosely read about hacked fridges and ann fixed a build.\nIt ended with a working compiler.',
++ highlights: [
++ { take_id: 41, line: 'The fridge take that started it all.' },
++ { take_id: 41, line: 'A repeat of the same take.' },
++ { take_id: 999, line: 'Not one of the input takes.' },
++ { take_id: 42, line: '**It compiles!**' },
++ { take_id: 42, line: 'Again.' },
++ { take_id: 'nope', line: 'No id.' },
++ ],
++ } }, input);
++ assert.strictEqual(out.headline, 'Fridges, compilers and one stubborn bug');
++ assert.strictEqual(out.summary, 'The day: goosely read about hacked fridges and ann fixed a build. It ended with a working compiler.');
++ assert.deepStrictEqual(out.highlights, [
++ { take_id: 41, line: 'The fridge take that started it all.' },
++ { take_id: 42, line: 'It compiles!' },
++ ], 'one highlight per real take, in order, at most three');
++ assert.deepStrictEqual(POSTPROCESS['live.rundown.today']({ json: { headline: 'x', summary: 'y' } }, input).highlights, [], 'no highlights is an answer');
++ assert.strictEqual(POSTPROCESS['live.rundown.today']({ json: { summary: 'no headline' } }, input), null);
++ assert.strictEqual(POSTPROCESS['live.rundown.today']({ text: 'not json' }, input), null);
++ const many = { json: { headline: 'h', summary: 's', highlights: [41, 42, 43, 44].map((id) => ({ take_id: id, line: `line ${id}` })) } };
++ assert.strictEqual(POSTPROCESS['live.rundown.today'](many, { takes: [41, 42, 43, 44].map((id) => ({ take_id: id })) }).highlights.length, 3, 'up to three');
++ assert.ok(live.workflows.find((w) => w.key === 'live.rundown.today' && w.steps[0].template === 'live.rundown.today'));
++});
++
t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex e804f18..e2dadbc 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -78,6 +78,10 @@ EXAMPLES['live.viewers.clone'] = { name: 'bob', overview: 'Hypes every build.',
EXAMPLES['live.rundown.segment'] = { streamer: 'goosely', title: 'Tech news', category: 'Science & Technology', topics: ['Supermarket fridges hacked in France'], earlier: 'Opened with a router rant.', lines: [{ t: 1081, text: 'The person that deals with refrigeration, I am not going to say is stupid.' }, { t: 1096, text: 'Having the internet inside of a refrigerator is a bad idea.' }] };
EXAMPLES['live.rundown.episode'] = { streamer: 'goosely', title: 'Tech news', category: 'Science & Technology', minutes: 140, peak_viewers: 6, chat_messages: 210, segments: [{ t: 1081, topic: 'Supermarket fridges hacked in France', title: 'Fridges on the internet', what: 'They read about hacked supermarket fridges.', take: 'Internet fridges are a bad idea.', quotes: ['Having the internet inside of a refrigerator is a bad idea.'] }] };
EXAMPLES['live.rundown.topic'] = { topic: 'Supermarket fridges hacked in France', takes: [{ streamer: 'goosely', when: '2026-10-18', what: 'They read about hacked supermarket fridges.', take: 'Internet fridges are a bad idea.', quote: 'Having the internet inside of a refrigerator is a bad idea.' }] };
++EXAMPLES['live.rundown.today'] = { date: '2026-11-11', takes: [
++ { take_id: 41, streamer: 'goosely', when: '2026-11-11T10:00:00Z', topic: 'Supermarket fridges hacked in France', what: 'They read about hacked supermarket fridges.', take: 'Internet fridges are a bad idea.', quote: 'Having the internet inside of a refrigerator is a bad idea.' },
++ { take_id: 42, streamer: 'ann', when: '2026-11-11T12:00:00Z', topic: 'Rust builds', what: 'They fixed a borrow checker bug live.', take: 'The compiler was right all along.', quote: 'It compiles!' },
++] };
EXAMPLES['live.clips.confirm'] = { scene: ['streamer jumps up cheering'], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 95, label: 'Cheering', confidence: 0.8 }], chat: ['LETS GO', 'poggers'] };
EXAMPLES['live.chat.global'] = { window_label: 'past 3 hours', prior_memory: 'croutons are a running joke', recent_labels: ['the crouton war'], messages: [{ mins_ago: 12, where: 'global', author: 'goosely', kind: null, text: 'croutons are soup confetti' }, { mins_ago: 3, where: '#ann', author: 'bob', kind: 'emote', text: 'waves' }] };
EXAMPLES['live.chat.profile'] = { subject_kind: 'relay', name: 'KickFan', platform: 'kick', recent_24h: false, seen: 0, prior_memory: '', messages: [{ mins_ago: 2000, author: 'KickFan', text: 'hello from kick' }] };