openvibers/openvibe.live Commits

Live: public AI text never describes how a person looks (stored rows repaired) (#154)

OP OpenVibers committed 28 days ago · 9d27bc327a · 4 files, +110 −12

* Live: AI text shown publicly never describes how a person looks — frame notes and overviews that name looks or an age ("A shirtless man sits…", "a young boy at a desk") are stored with that person as "the streamer" ("someone" when only an age is named), and rows stored before are repaired at boot

* Live home hero: AI copy that mentions someone's looks or age is never stored or served (production showed "ara007 shirtless thoughts, comfy takes"); the batch regenerates once on deploy

Changes

ddiff --git a/server/ai/slogan-job.js b/server/ai/slogan-job.js
iindex b57a2f69..5000cab1 100644
---- a/server/ai/slogan-job.js
++++ b/server/ai/slogan-job.js
@@@ -36,12 +36,14 @@ function _stripAudiencePrefix(s) {
}
const _BAD_AUDIENCE = /\b(live\s*-?\s*)?stream(ing|s)?\b|\blivestream/i;
const _NO_FREE = /\b(free|no[- ]cost)\b|\$\s?0\b/i;
++// Copy about a real person never mentions their looks or age ("ara007 shirtless thoughts" made the hero once).
++const _LOOKS = /\b(shirtless|topless|bare-?chested|naked|nude|cleavage|bikini|underwear|teen(age)?|young|kids?|child(ren)?|boys?|girls?)\b/i;
function _cleanList(arr, maxLen, max) {
if (!Array.isArray(arr)) return [];
const seen = new Set(); const out = [];
for (const raw of arr) {
let s = String(raw == null ? '' : raw).trim().replace(/^["'‘’“”\-•\s]+|["'‘’“”\s]+$/g, '').replace(/[.,;:]+$/, '');
-- if (!s || s.length > maxLen) continue;
++ if (!s || s.length > maxLen || _LOOKS.test(s)) continue;
const k = s.toLowerCase();
if (seen.has(k)) continue;
seen.add(k); out.push(s);
@@@ -51,10 +53,10 @@ function _cleanList(arr, maxLen, max) {
}
function _topUp(fresh, old, cap) {
const seen = new Set(fresh.map(s => s.toLowerCase())); const out = fresh.slice();
-- for (const s of (old || [])) { const v = String(s || '').trim(); if (!v) continue; const k = v.toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(v); if (out.length >= cap) break; }
++ for (const s of (old || [])) { const v = String(s || '').trim(); if (!v || _LOOKS.test(v)) continue; const k = v.toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(v); if (out.length >= cap) break; }
return out.slice(0, cap);
}
--const SLOGAN_FORMAT = 3; // bump to force a one-time regen after a prompt/format change
++const SLOGAN_FORMAT = 4; // bump to force a one-time regen after a prompt/format change (4: no copy about looks)
async function _loadPool() {
try { const cur = await db.getState('home_hero_slogans'); const o = typeof cur === 'string' ? JSON.parse(cur) : cur; if (o) return { audiences: o.audiences || [], quips: o.quips || [], updated_at: o.updated_at || 0, v: o.v || 1 }; } catch { /* */ }
return { audiences: [], quips: [], updated_at: 0, v: 0 };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex cab7fae8..8e245a88 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -362,7 +362,7 @@ async function addStreamMemory({ stream_id, user_id = null, offset_seconds = 0,
// store a second description of a moment already captured.
return await run(`INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description, tags, thumbnail_url, transcript_json)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING id`,
-- [stream_id, user_id, Math.max(0, Math.round(offset_seconds || 0)), description || '',
++ [stream_id, user_id, Math.max(0, Math.round(offset_seconds || 0)), _noLooks(description || ''),
tags ? (typeof tags === 'string' ? tags : JSON.stringify(tags)) : null, thumbnail_url,
(transcript_json && typeof transcript_json !== 'string') ? JSON.stringify(transcript_json) : (transcript_json || null)]);
}
@@@ -386,11 +386,32 @@ function _plainProse(text) {
.map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`))
.join(' ');
}
++// Public AI text never describes how a person looks (frame notes show on VOD pages and
++// feed every overview). Text written before the prompts said so ("A shirtless man sits…",
++// "a young boy at a desk") has that person called "the streamer" (or "someone" when only
++// an age is named), so a plain "a man" or "the streamer" stays as written.
++const _LOOKS = 'shirtless|topless|bare-chested|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
++const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
++const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
++const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
++const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
++const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
++function _noLooks(text) {
++ if (!text || typeof text !== 'string') return text;
++ return text
++ // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
++ .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
++ .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\s+and\s+/gi, ' ')
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')
++ .replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
++ .replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
++}
// Derive a concise short overview from a long one — the lead sentence(s), capped
// ~150 chars at a sentence/word boundary. Deterministic + free (no AI call), so it
// can be cached at write time and shown on listing cards.
function _shortOverview(text) {
-- const t = _plainProse(text).replace(/\s+/g, ' ').trim();
++ const t = _plainProse(_noLooks(text)).replace(/\s+/g, ' ').trim();
// ' ' is the "tried, nothing to say" sentinel the AI jobs store to mark a row done.
// It must round-trip as a non-empty value — the backfill queues treat NULL/'' as
// still-pending, so collapsing the sentinel to null meant unprocessable VODs were
@@@ -411,7 +432,7 @@ function _shortOverview(text) {
return (sp > 40 ? cut.slice(0, sp) : cut).trim() + '…';
}
async function updateStreamAiOverview(streamId, text) {
-- return await run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', [text || null, _shortOverview(text), streamId]);
++ return await run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', [_noLooks(text) || null, _shortOverview(text), streamId]);
}
/** AI-inferred category/tags for a stream; the channel inherits the latest inferred category. */
async function setStreamAiCategory(streamId, category, tags) {
@@@ -471,13 +492,13 @@ async function setVodAiOverview(vodId, text) {
await _ensureVodAiState(vodId);
// Store the FULL overview alongside the derived short — the card expander swaps
// the short teaser for this full text, so losing it makes expansion pointless.
-- const full = (text || '').trim() || null;
++ const full = _noLooks((text || '').trim()) || null;
return await run('UPDATE vod_ai_state SET ai_overview = ?, ai_overview_short = ? WHERE vod_id = ?', [full, _shortOverview(text), vodId]);
}
async function setClipAiOverview(clipId, { overview = null, transcript = null, segments = null }) {
void transcript; // full transcript text lives in the segments JSON now
await _ensureClipAiState(clipId);
-- const full = (overview || '').trim() || null;
++ const full = _noLooks((overview || '').trim()) || null;
return await run('UPDATE clip_ai_state SET ai_overview = ?, ai_overview_short = ?, ai_transcript_json = COALESCE(?, ai_transcript_json) WHERE clip_id = ?',
[full, _shortOverview(overview), _segJson(segments), clipId]);
}
@@@ -644,6 +665,23 @@ async function cleanupMalformedAiText() {
fixed++;
}
} catch { /* */ }
++ // Frame notes and overviews that describe how a person looks (stored before the
++ // prompts forbade it): the person becomes "the streamer" and the card line follows.
++ const looks = "~* '(shirtless|topless|bare-chested|young|teen|little|bearded|bald|blond|haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute|boy|girl|kid|child|youth)'";
++ for (const [table, key, col, short] of [['stream_memories', 'id', 'description', null], ['streams', 'id', 'ai_overview', 'ai_overview_short'],
++ ['vod_ai_state', 'vod_id', 'ai_overview', 'ai_overview_short'], ['clip_ai_state', 'clip_id', 'ai_overview', 'ai_overview_short'],
++ ['streamer_overviews', 'user_id', 'overview', 'overview_short']]) {
++ try {
++ const rows = await all(`SELECT ${key} AS k, ${col} AS v FROM ${table} WHERE ${col} ${looks}`);
++ for (const r of rows) {
++ const clean = _noLooks(r.v);
++ if (clean === r.v) continue;
++ if (short) await run(`UPDATE ${table} SET ${col} = ?, ${short} = ? WHERE ${key} = ?`, [clean, _shortOverview(clean), r.k]);
++ else await run(`UPDATE ${table} SET ${col} = ? WHERE ${key} = ?`, [clean, r.k]);
++ fixed++;
++ }
++ } catch (e) { console.warn(`[AI] looks repair (${table}):`, e.message); }
++ }
if (fixed) console.log(`[AI] Cleaned ${fixed} malformed JSON AI text value(s)`);
return fixed;
}
@@@ -841,7 +879,7 @@ async function upsertStreamerOverview(userId, { overview, model = null, sources
ON CONFLICT(user_id) DO UPDATE SET
overview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,
sources = excluded.sources, generated_at = ov_now()`,
-- [userId, _plainProse(overview || ''), _shortOverview(overview), model, sources]);
++ [userId, _plainProse(_noLooks(overview || '')), _shortOverview(overview), model, sources]);
}
async function getStreamerOverview(userId) {
return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex cf388ebf..ae51b99c 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -135,6 +135,8 @@ function _cleanAudience(raw) {
}
const NO_FREE = /\b(free|no[- ]cost)\b|\$\s?0\b/i;
++// Nor copy about a real person's looks or age ("ara007 shirtless thoughts" was served once).
++const LOOKS = /\b(shirtless|topless|bare-?chested|naked|nude|cleavage|bikini|underwear|teen(age)?|young|kids?|child(ren)?|boys?|girls?)\b/i;
async function heroSlogans() {
let s = null;
@@@ -142,9 +144,9 @@ async function heroSlogans() {
s = await db.getState('home_hero_slogans');
if (typeof s === 'string') s = JSON.parse(s);
} catch { s = null; }
-- // No "free"/"$0" copy (the owner's rule), even in a batch stored before the slogan job filtered it.
-- const aiAud = (s && Array.isArray(s.audiences)) ? s.audiences.map(_cleanAudience).filter(a => a && a.length <= 60 && !NO_FREE.test(a)) : [];
-- const aiQuips = (s && Array.isArray(s.quips)) ? s.quips.filter(q => q && String(q).length <= 120 && !NO_FREE.test(String(q))) : [];
++ // No "free"/"$0" copy (the owner's rule) and none about looks, even in a batch stored before the slogan job filtered it.
++ const aiAud = (s && Array.isArray(s.audiences)) ? s.audiences.map(_cleanAudience).filter(a => a && a.length <= 60 && !NO_FREE.test(a) && !LOOKS.test(a)) : [];
++ const aiQuips = (s && Array.isArray(s.quips)) ? s.quips.filter(q => q && String(q).length <= 120 && !NO_FREE.test(String(q)) && !LOOKS.test(String(q))) : [];
const hasAi = aiAud.length >= 4 || aiQuips.length >= 4;
const updatedAt = (s && Number(s.updated_at)) || 0;
// The slogan job regenerates every 12h; next_at is when the current batch is due to refresh.
@@@ -594,6 +596,7 @@ module.exports = router;
module.exports.startViewerSampler = startViewerSampler;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
++module.exports.heroSlogans = heroSlogans;
// Warm the hero caches once boot has finished (~6s on production). Every deploy restarts the process with empty caches, and
// the first home page load after it used to wait on every upstream call cold (~2.3s measured right
ddiff --git a/test/ai-text-no-looks.test.js b/test/ai-text-no-looks.test.js
nnew file mode 100644
iindex 00000000..18157342
---- /dev/null
++++ b/test/ai-text-no-looks.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++// Public AI text never describes how a person looks: a frame note or an overview that names looks or an age
++// ("A shirtless man sits…", "a young boy at a desk") is stored with that person as "the streamer", a plain "a man"
++// or "a black chair" stays as written, and rows stored before the rule are repaired by the AI cleanup pass.
++const assert = require('assert');
++console.warn = () => {};
++
++const db = require('../server/db/database');
++(async () => {
++ await db.initDb();
++ await db.createUser({ username: 'lookee', email: 'lookee@x', password_hash: 'x', display_name: 'lookee', stream_key: 'lookee'.padEnd(32, '0') });
++ const uid = (await db.get("SELECT id FROM users WHERE username = 'lookee'")).id;
++ const sid = Number((await db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
++
++ const cases = [
++ ['A shirtless man sits in a room with framed pictures on the wall.', 'The streamer sits in a room with framed pictures on the wall.'],
++ ['A livestream frame showing a shirtless streamer in a small inset while playing a MOBA.', 'A livestream frame showing the streamer in a small inset while playing a MOBA.'],
++ ['Chat watches a young boy at a desk build a PC.', 'Chat watches the streamer at a desk build a PC.'],
++ ['Young, bearded man plays chess.', 'Streamer plays chess.'],
++ ['A man plays chess next to a black chair.', 'A man plays chess next to a black chair.'],
++ ['The streamer, topless, laughs at chat.', 'The streamer laughs at chat.'],
++ ['A man is shirtless and appears to be talking to chat.', 'A man appears to be talking to chat.'],
++ ['A person watches TV while a child sits nearby.', 'A person watches TV while someone sits nearby.'],
++ ['The girl waves at the camera.', 'The person waves at the camera.'],
++ ];
++ let off = 0;
++ for (const [input, want] of cases) {
++ await db.addStreamMemory({ stream_id: sid, user_id: uid, offset_seconds: (off += 60), description: input });
++ const row = await db.get('SELECT description FROM stream_memories WHERE stream_id = ? AND offset_seconds = ?', [sid, off]);
++ assert.strictEqual(row.description, want, input);
++ }
++
++ await db.updateStreamAiOverview(sid, 'A teenage girl streams Minecraft builds with a calm vibe.');
++ const st = await db.get('SELECT ai_overview, ai_overview_short FROM streams WHERE id = ?', [sid]);
++ assert.deepStrictEqual([st.ai_overview, st.ai_overview_short], ['The streamer streams Minecraft builds with a calm vibe.', 'The streamer streams Minecraft builds with a calm vibe.']);
++
++ // Rows stored before the rule.
++ await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9000, 'A shirtless man rests his chin on his hand.']);
++ await db.run('UPDATE streams SET ai_overview = ?, ai_overview_short = ? WHERE id = ?', ['A young boy plays Fortnite.', 'A young boy plays Fortnite.', sid]);
++ await db.run('INSERT INTO stream_memories (stream_id, user_id, offset_seconds, description) VALUES (?, ?, ?, ?)', [sid, uid, 9060, 'An older version of the game loads.']);
++ await db.cleanupMalformedAiText();
++ assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9000')).description, 'The streamer rests his chin on his hand.');
++ assert.deepStrictEqual(Object.values(await db.get('SELECT ai_overview, ai_overview_short FROM streams WHERE id = ?', [sid])), ['The streamer plays Fortnite.', 'The streamer plays Fortnite.']);
++ assert.strictEqual((await db.get('SELECT description FROM stream_memories WHERE offset_seconds = 9060')).description, 'An older version of the game loads.', 'looks words about things stay');
++
++ // The home hero's AI copy: a line about someone's looks is never served, even from a batch stored before.
++ const words = ['crouton connoisseurs', 'van-dwelling coders', 'ara007 shirtless thoughts', 'goosely fans', 'late-night tinkerers', 'young gamers'];
++ await db.setState('home_hero_slogans', JSON.stringify({ audiences: words, quips: ['chat is cooking', 'a shirtless take', 'goosely did it again', 'rust compiles eventually', 'one more build'], updated_at: Date.now(), v: 4 }));
++ const hero = await require('../server/home/routes').heroSlogans();
++ assert.ok(hero.ai && !hero.audiences.some((a) => /shirtless|young/.test(a)) && !hero.quips.some((q) => /shirtless/.test(q)), JSON.stringify(hero));
++ assert.strictEqual(hero.audiences.length, 4);
++
++ console.log('ai-text-no-looks: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });