openvibers/openvibe.ai Commits
AI: Live's streamer overview is the public channel description it is shown as (channel page, search, home cards): plain prose about what they stream, never about anyone's looks, body, clothing or age, no moderation notes; lists, headings and labels are dropped before it is stored (#102)
Changes
@@@ -74,6 +74,18 @@ const MOMENT_FLAVOR = {clip: 'This moment becomes a 25-SECOND VIDEO CLIP: pick a beat that plays out over time — a line that lands, a reaction, a sound, chat exploding, something HAPPENING — not a static pretty frame.',};const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);++// Text shown to the public as a description: one paragraph of prose. A model that answers with a list, headings or++// bold labels ("- Streamer handle: x (@x).") has its markup and labels dropped, line by line, and headings left out,++// before the lines are joined.++const PROSE_LABEL = /^(?:streamer(?: handle)?|handle|(?:profile )?overview|summary|profile|content(?: niche)?|niche|tone(?:\/vibe)?|vibe|(?:recurring )?themes|activities|moderation(?: notes)?|notes?|bio|category)\s*:\s*/i;++function plainProse(text, max) {++ const lines = String(text || '').split(/\r?\n/)++ .filter((l) => !/^\s*#{1,6}\s/.test(l))++ .map((l) => l.replace(/^\s*(?:[-*•]\s+|\d+[.)]\s+|>\s*)/, '').replace(/\*\*|__/g, '').trim().replace(PROSE_LABEL, '').trim())++ .filter(Boolean)++ .map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`));++ return lines.join(' ').replace(/\s+/g, ' ').trim().slice(0, max);++}/** Chat messages as Live formatted them: "[Xm ago] [#channel] author: (kind) text", the freshest 9000 characters. */function chatLines(messages, withWhere) {@@@ -448,8 +460,8 @@ const POSTPROCESS = {const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (extractDescription(r.text, 2000) || r.text.slice(0, 2000));return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };},-- 'live.streamer.overview'(r) { return r.text ? { overview: r.text.slice(0, 4000) } : null; },-- 'live.media.overview'(r) { return r.text ? { overview: r.text.slice(0, 2000) } : null; },++ 'live.streamer.overview'(r) { const overview = plainProse(r.text, 4000); return overview ? { overview } : null; },++ 'live.media.overview'(r) { const overview = plainProse(r.text, 2000); return overview ? { overview } : null; },'live.stream.recap'(r) {const out = r.json || parseJsonLoose(r.text);if (!out || !out.headline || !out.summary) return null;@@@ -64,11 +64,11 @@ const templates = [default_route: 'live.legacy', owner: 'live',},{-- key: 'live.streamer.overview', name: 'Live: internal streamer profile for staff',++ key: 'live.streamer.overview', name: 'Live: public channel description',description: 'From ai-analysis.generateStreamerOverview.',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren\'t supported by the signals below.\n\n{{ctx}}',++ user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender, and never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',default_route: 'live.legacy', owner: 'live',},{@@@ -76,7 +76,7 @@ const templates = [description: 'From media-analysis.analyzeMedia (media_overview).',input_schema: { type: 'object' }, output_schema: { type: 'object' },system_prompt: '',-- user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',++ user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown. Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender.\n\n{{parts}}',default_route: 'live.legacy', owner: 'live',},{@@@ -343,7 +343,7 @@ const structured = [cache_mode: 'private',},{-- key: 'live.streamer.overview', name: 'Internal streamer overview for staff', namespace: 'live',++ key: 'live.streamer.overview', name: 'Public channel description', namespace: 'live',input_schema: TEXT_OUT({streamer: { type: 'object', required: ['username'], properties: { username: STR(80), display_name: STR(120), bio: STR(2000), category: STR(40), category_inferred: { type: 'boolean' } } },memories: { type: 'array', items: STR(1000), maxItems: 100 },@@@ -133,4 +133,17 @@ t.test('the AI viewers render as Live rendered them', () => {assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);});++t.test("a channel description is public prose: never about looks, no lists or labels reach the page", () => {++ const tp = (key) => live.templates.find((x) => x.key === key);++ const prompt = tp('live.streamer.overview').user_prompt;++ assert.match(prompt, /public description/);++ assert.match(prompt, /Never describe anyone's appearance, body, clothing, age/);++ assert.ok(!/moderation\.?$|notable for moderation/m.test(prompt), 'no staff-only moderation notes');++ assert.match(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);++ const out = POSTPROCESS['live.streamer.overview']({ text: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios\n- Tone: calm, patient\n\nAnn streams games: mostly Rust.' });++ assert.deepStrictEqual(out, { overview: 'finditfixit (@finditfixit). repairs old TVs and radios. calm, patient. Ann streams games: mostly Rust.' });++ assert.strictEqual(POSTPROCESS['live.streamer.overview']({ text: '## Overview\n' }), null, 'nothing but a heading is no overview');++ assert.deepStrictEqual(POSTPROCESS['live.media.overview']({ text: 'They build a PC.\nThen they test it' }), { overview: 'They build a PC. Then they test it.' });++});++t.run();
@@ -74,6 +74,18 @@ const MOMENT_FLAVOR = {
@@ -74,6 +74,18 @@ const MOMENT_FLAVOR = {
clip: 'This moment becomes a 25-SECOND VIDEO CLIP: pick a beat that plays out over time — a line that lands, a reaction, a sound, chat exploding, something HAPPENING — not a static pretty frame.',
clip: 'This moment becomes a 25-SECOND VIDEO CLIP: pick a beat that plays out over time — a line that lands, a reaction, a sound, chat exploding, something HAPPENING — not a static pretty frame.',
};
};
const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);
const oneLine = (t, max) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, max);
+// Text shown to the public as a description: one paragraph of prose. A model that answers with a list, headings or
+// bold labels ("- Streamer handle: x (@x).") has its markup and labels dropped, line by line, and headings left out,
+// before the lines are joined.
+const PROSE_LABEL = /^(?:streamer(?: handle)?|handle|(?:profile )?overview|summary|profile|content(?: niche)?|niche|tone(?:\/vibe)?|vibe|(?:recurring )?themes|activities|moderation(?: notes)?|notes?|bio|category)\s*:\s*/i;
+function plainProse(text, max) {
+ const lines = String(text || '').split(/\r?\n/)
+ .filter((l) => !/^\s*#{1,6}\s/.test(l))
+ .map((l) => l.replace(/^\s*(?:[-*•]\s+|\d+[.)]\s+|>\s*)/, '').replace(/\*\*|__/g, '').trim().replace(PROSE_LABEL, '').trim())
+ .filter(Boolean)
+ .map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`));
+ return lines.join(' ').replace(/\s+/g, ' ').trim().slice(0, max);
+}
/** Chat messages as Live formatted them: "[Xm ago] [#channel] author: (kind) text", the freshest 9000 characters. */
/** Chat messages as Live formatted them: "[Xm ago] [#channel] author: (kind) text", the freshest 9000 characters. */
function chatLines(messages, withWhere) {
function chatLines(messages, withWhere) {
@@ -448,8 +460,8 @@ const POSTPROCESS = {
@@ -448,8 +460,8 @@ const POSTPROCESS = {
const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (extractDescription(r.text, 2000) || r.text.slice(0, 2000));
const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (extractDescription(r.text, 2000) || r.text.slice(0, 2000));
return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };
return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };
},
},
- 'live.streamer.overview'(r) { return r.text ? { overview: r.text.slice(0, 4000) } : null; },
- 'live.media.overview'(r) { return r.text ? { overview: r.text.slice(0, 2000) } : null; },
+ 'live.streamer.overview'(r) { const overview = plainProse(r.text, 4000); return overview ? { overview } : null; },
+ 'live.media.overview'(r) { const overview = plainProse(r.text, 2000); return overview ? { overview } : null; },
'live.stream.recap'(r) {
'live.stream.recap'(r) {
const out = r.json || parseJsonLoose(r.text);
const out = r.json || parseJsonLoose(r.text);
if (!out || !out.headline || !out.summary) return null;
if (!out || !out.headline || !out.summary) return null;
@@ -64,11 +64,11 @@ const templates = [
@@ -64,11 +64,11 @@ const templates = [
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
- key: 'live.streamer.overview', name: 'Live: internal streamer profile for staff',
+ key: 'live.streamer.overview', name: 'Live: public channel description',
description: 'From ai-analysis.generateStreamerOverview.',
description: 'From ai-analysis.generateStreamerOverview.',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren\'t supported by the signals below.\n\n{{ctx}}',
+ user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender, and never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
@@ -76,7 +76,7 @@ const templates = [
@@ -76,7 +76,7 @@ const templates = [
description: 'From media-analysis.analyzeMedia (media_overview).',
description: 'From media-analysis.analyzeMedia (media_overview).',
input_schema: { type: 'object' }, output_schema: { type: 'object' },
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
system_prompt: '',
- user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',
+ user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown. Never describe anyone\'s appearance, body, clothing, age, ethnicity or perceived gender.\n\n{{parts}}',
default_route: 'live.legacy', owner: 'live',
default_route: 'live.legacy', owner: 'live',
},
},
{
{
@@ -343,7 +343,7 @@ const structured = [
@@ -343,7 +343,7 @@ const structured = [
cache_mode: 'private',
cache_mode: 'private',
},
},
{
{
- key: 'live.streamer.overview', name: 'Internal streamer overview for staff', namespace: 'live',
+ key: 'live.streamer.overview', name: 'Public channel description', namespace: 'live',
input_schema: TEXT_OUT({
input_schema: TEXT_OUT({
streamer: { type: 'object', required: ['username'], properties: { username: STR(80), display_name: STR(120), bio: STR(2000), category: STR(40), category_inferred: { type: 'boolean' } } },
streamer: { type: 'object', required: ['username'], properties: { username: STR(80), display_name: STR(120), bio: STR(2000), category: STR(40), category_inferred: { type: 'boolean' } } },
memories: { type: 'array', items: STR(1000), maxItems: 100 },
memories: { type: 'array', items: STR(1000), maxItems: 100 },
@@ -133,4 +133,17 @@ t.test('the AI viewers render as Live rendered them', () => {
@@ -133,4 +133,17 @@ t.test('the AI viewers render as Live rendered them', () => {
assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);
assert.deepStrictEqual([p.skip, p.lines.length, p.lines[0].target, p.lines[0].delay_ms, p.threads_close], [false, 1, 'ambient', 45000, ['3']]);
});
});
+t.test("a channel description is public prose: never about looks, no lists or labels reach the page", () => {
+ const tp = (key) => live.templates.find((x) => x.key === key);
+ const prompt = tp('live.streamer.overview').user_prompt;
+ assert.match(prompt, /public description/);
+ assert.match(prompt, /Never describe anyone's appearance, body, clothing, age/);
+ assert.ok(!/moderation\.?$|notable for moderation/m.test(prompt), 'no staff-only moderation notes');
+ assert.match(tp('live.media.overview').user_prompt, /Never describe anyone's appearance/);
+ const out = POSTPROCESS['live.streamer.overview']({ text: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios\n- Tone: calm, patient\n\nAnn streams games: mostly Rust.' });
+ assert.deepStrictEqual(out, { overview: 'finditfixit (@finditfixit). repairs old TVs and radios. calm, patient. Ann streams games: mostly Rust.' });
+ assert.strictEqual(POSTPROCESS['live.streamer.overview']({ text: '## Overview\n' }), null, 'nothing but a heading is no overview');
+ assert.deepStrictEqual(POSTPROCESS['live.media.overview']({ text: 'They build a PC.\nThen they test it' }), { overview: 'They build a PC. Then they test it.' });
+});
+
t.run();
t.run();