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)

OP OpenVibers committed 28 days ago · 81610ba5e6 · 3 files, +31 −6

Changes

ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex f713f89..6ab2263 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -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;
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 0ef408b..68b7738 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -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 },
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex ee43083..7ce90d6 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -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();