openvibers/openvibe.ai Commits

The Today card lists the takes, and never denies them (#479)

OP OpenVibers committed 2 days ago · 9f6414bf38 · 3 files, +25 −5

The prompt used a JSON blob; a small model answered that there were no takes when one was given.
The prepare hook now writes each take as a numbered [take:id] line with the streamer, topic, what,
their take and their words, and the system prompt says that one take is the day's record. The
postprocess refuses a card that says no takes were submitted when takes were given.

Checked: ov test test/live-templates.test.js test/workflows.test.js test/latency-classes.test.js.

Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex cd391a6..6dc4a12 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -124,6 +124,20 @@ const PREPARE = {
'live.viewers.clone'(input) {
return { vars: { name: input.name, overview: input.overview || '', memory: input.memory || '', samples: (input.samples || []).length ? `- ${input.samples.join('\n- ')}` : '' } };
},
++ // The Rundown's Today card (Live rundown/today.js): the day's takes as numbered lines, no JSON wrapping, so a
++ // small model cannot answer as though it were given an empty day.
++ 'live.rundown.today'(input) {
++ const takes = (Array.isArray(input.takes) ? input.takes : []).map((t, i) => {
++ const bits = [`${i + 1}. [take:${Number(t.take_id)}] ${clean(t.streamer, 80) || 'someone'}`];
++ if (t.when) bits.push(`at ${clean(t.when, 40)}`);
++ if (t.topic) bits.push(`on ${clean(t.topic, 120)}`);
++ bits.push(`— what: ${clean(t.what, 500)}`);
++ if (t.take) bits.push(`— their take: ${clean(t.take, 300)}`);
++ if (t.quote) bits.push(`— their words: "${clean(t.quote, 400)}"`);
++ return bits.join(' ');
++ }).join('\n');
++ return { vars: { date: clean(input.date, 20), count: takes ? takes.split('\n').length : 0, takes: takes || '(none given)' } };
++ },
// The Rundown (Live rundown/desk.js): transcript lines as "[seconds] text", the known topics one per line.
'live.rundown.segment'(input) {
const clean = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
@@@ -452,6 +466,8 @@ const POSTPROCESS = {
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));
++ // A card that says there were no takes or nothing was said, when takes were given, is a fabrication: no card.
++ if (ids.size && /\bno takes?\b|\bnothing (?:was|has been) (?:said|submitted|shared)\b|\bno (?:streamer|one) (?:said|submitted|shared)\b/i.test(`${headline} ${summary}`)) return null;
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) }))
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex baa56b2..5ae8b68 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -147,8 +147,8 @@ const templates = [
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\": \"...\"}]}.",
++ 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. The takes given are the day's whole record: never write that nothing was said, that there were no takes or that the feed was quiet when a take is given; if there is one take, the card is about that take. 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 is {{date}}. The day's takes ({{count}}), oldest first; these are the whole record, so if there is one take the card is about that take:\n{{takes}}\n\nReturn JSON only: {\"headline\": \"...\", \"summary\": \"...\", \"highlights\": [{\"take_id\": <the exact take_id from the brackets above>, \"line\": \"...\"}]}.",
default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_today' },
},
{
@@@ -432,7 +432,7 @@ const structured = [
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 } }],
++ steps: [{ kind: 'llm', template: 'live.rundown.today', operation: 'generate', output: 'json', prepare: 'live.rundown.today', postprocess: 'live.rundown.today', params: { max_tokens: 600, temperature: 0.9, timeout_ms: 40000 } }],
cache_mode: 'none',
},
{
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex adb1ff3..e26400d 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -278,8 +278,10 @@ t.test("The Rundown's Today card: the prompt carries the takes and the ids, and
{ 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);
++ const user = render(today.user_prompt, PREPARE['live.rundown.today'](input).vars);
++ assert.match(user, /^Today is 2026-11-11\. The day's takes \(2\), oldest first/);
++ assert.match(user, /1\. \[take:41\] goosely at 2026-11-11T10:00:00Z on Supermarket fridges hacked in France — what: They read about hacked fridges\. — their take: Internet fridges are a bad idea\. — their words: "A bad idea\."/);
++ assert.match(user, /2\. \[take:42\] ann .*— their words: "It compiles!"/);
assert.match(user, /Return JSON only/);
const out = POSTPROCESS['live.rundown.today']({ json: {
headline: ' Fridges, compilers and one stubborn bug ',
@@@ -302,6 +304,8 @@ t.test("The Rundown's Today card: the prompt carries the takes and the ids, and
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);
++ assert.strictEqual(POSTPROCESS['live.rundown.today']({ json: { headline: 'Quiet shift', summary: 'No takes were submitted today, so there are no updates.' } }, input), null, 'a card that denies the takes it was given is no card');
++ assert.deepStrictEqual(PREPARE['live.rundown.today']({ date: '2026-11-11', takes: [{ take_id: 41, streamer: 'goosely', what: 'Fridges.' }] }).vars, { date: '2026-11-11', count: 1, takes: '1. [take:41] goosely — what: Fridges.' });
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'));