openvibers/openvibe.live Commits

Chat popover + profile AI tab: no looks in any AI string; session titles about looks not used (#204)

OP OpenVibers committed 27 days ago · f0d4477964 · 3 files, +63 −14

* Chat popover and profile AI tab: every AI string in the answer passes noLooks (a cached whole-person overview still said 'a shirtless, reclining host')

* AI session titles about looks ('Orange Shirt Kitchen Chat') are not used: the stream's own title is stored instead

* No-looks scrub: 'a white shirt and tie' goes whole, and a removed phrase leaves no ':,' behind

* No-looks scrub: 'he wears a blue jacket and is leaning' keeps 'he is leaning', 'while the other wears a t-shirt' goes as a clause, a garment's detail ('with frog-like eyes') goes with it, 'one shirtless and sweating' keeps 'one sweating'

* No-looks scrub: 'a man in sunglasses', 'a person with a headset', 'a man with a red shirt', 'wearing a headband', 'a casually dressed NPC' (an object 'with a headset' on a desk stays)

Changes

ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex 451bf511..c3048333 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -19,6 +19,15 @@ const router = express.Router();
const db = require('../db/database');
const insights = require('../chat/insight-client');
++// These answers are public (the chat user popover and the profile's AI tab): AI text in them never says how anyone
++// looks, wherever it was written (Live's overviews, Chat's chatter insight, a cached synthesis from before the rule).
++function _noLooksDeep(v, depth = 0) {
++ if (typeof v === 'string') return db.noLooks(v);
++ if (depth > 5 || !v || typeof v !== 'object') return v;
++ if (Array.isArray(v)) return v.map((x) => _noLooksDeep(x, depth + 1));
++ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, _noLooksDeep(x, depth + 1)]));
++}
++
router.get('/user/:id', async (req, res) => {
try {
const uid = parseInt(req.params.id, 10);
@@@ -53,8 +62,8 @@ router.get('/user/:id', async (req, res) => {
} catch { /* streamer context is best-effort */ }
res.json({
-- insight: insight || null,
-- streamer,
++ insight: _noLooksDeep(insight || null),
++ streamer: _noLooksDeep(streamer),
user: user ? { id: user.id, username: user.username, display_name: user.display_name } : null,
});
} catch (err) {
@@@ -72,7 +81,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
const cOv = (chatIns && (chatIns.overview_alltime || chatIns.overview_24h)) || '';
if (!sOv && !cOv) return null;
// Only one side → that's the whole story; no synthesis (and no AI cost) needed.
-- if (!sOv || !cOv) return sOv || cOv;
++ if (!sOv || !cOv) return db.noLooks(sOv || cOv);
const key = `ai_whole_overview_${userId}`;
const srcLen = sOv.length + '|' + cOv.length; // cheap change-detector
@@@ -81,7 +90,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
const raw = await db.getState(key);
if (raw) { const j = JSON.parse(raw); cachedText = j.text ? String(j.text).replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim() : null; fresh = j.src === srcLen && (Date.now() - (j.generated_at || 0) < 24 * 60 * 60 * 1000); }
} catch { /* rebuild */ }
-- if (cachedText && fresh) return cachedText;
++ if (cachedText && fresh) return db.noLooks(cachedText);
// Regenerate in the background (best-effort) so the request never blocks on the LLM.
const ai = require('./ai-analysis');
@@@ -89,12 +98,12 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
_combinedBusy.add(userId);
// The prompt is OpenVibe.AI's versioned template live.person.overview (WS-O task 2).
Promise.resolve(require('./ai-service').structured('live.person.overview', { as_streamer: String(sOv).slice(0, 4000), as_chatter: String(cOv).slice(0, 4000) }, { meter: { kind: 'combined_overview', role: 'legacy', ownerUserId: userId } }))
-- .then(async out => { const clean = out && out.overview ? String(out.overview).trim() : ''; if (clean) { try { await db.setState(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
++ .then(async out => { const clean = out && out.overview ? db.noLooks(String(out.overview).trim()) : ''; if (clean) { try { await db.setState(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
.catch(() => { })
.finally(() => _combinedBusy.delete(userId));
}
// Return whatever we have now: last synthesis (even if stale) or a simple stitch.
-- return cachedText || `${sOv}\n\n${cOv}`;
++ return db.noLooks(cachedText || `${sOv}\n\n${cOv}`);
}
// Background: give sessions SHORT, catchy AI titles (streamers reuse literal stream titles,
@@@ -164,14 +173,14 @@ router.get('/timeline/:username', async (req, res) => {
res.json({
username: user.username,
display_name: user.display_name || user.username,
-- overview: first ? timeline.overview : undefined,
-- chatInsight,
++ overview: first ? _noLooksDeep(timeline.overview) : undefined,
++ chatInsight: _noLooksDeep(chatInsight),
combinedOverview,
sessionCount: timeline.sessionCount,
momentCount: timeline.momentCount,
generatedAt: timeline.generatedAt,
index,
-- sessions: page,
++ sessions: _noLooksDeep(page),
offset, limit,
hasMore: offset + limit < allSessions.length,
});
@@@ -314,3 +323,4 @@ router.get('/transcript/:streamId', async (req, res) => {
});
module.exports = router;
++module.exports._internals = { noLooksDeep: _noLooksDeep };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 741cb947..7b7801f1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -400,24 +400,36 @@ const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
--const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds|earphones|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?`;
--const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:a|an)\\s+(?:[\\w'-]+\\s+){0,2}?(?:zipper|logo|print|pattern|design|graphic|hood|stripes?|collar|text))?`;
++const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds|earphones|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
++const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
--const _IN_WORN = new RegExp(`\\s+in\\s+(?:a|an|the|his|her|their)\\s+(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN})\\b)*`, 'gi');
++const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
++const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
++const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
++// "…, while the other wears a dusty t-shirt;" goes as a whole clause.
++const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');
function _noClothing(text) {
return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
.replace(/\b[\w-]+-wearing\s+/gi, '')
++ .replace(_WEARS_AND, '')
++ .replace(_WEARS_CLAUSE, '')
++ .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')
++ .replace(_PERSON_WITH_WORN, '$1')
.replace(_WEARING, '')
.replace(_IN_WORN, '')
.replace(_WITH_FACE, '')
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/,(?=[.;:!?])/g, '')
++ .replace(/([:;]),/g, '$1')
.replace(/ {2,}/g, ' '))).join('');
}
// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
// clothing, hair, face, age or a person by gender is dropped.
const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
++// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
++const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
++const _hasLooks = (t) => _LOOKS_WORD.test(String(t || ''));
function _noLooksTags(tags) {
return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
}
@@@ -428,6 +440,8 @@ function _noLooks(text) {
.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(/\b(?:shirtless|topless|bare-chested)\s+and\s+/gi, '')
++ .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
.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, '');
@@@ -967,7 +981,11 @@ async function assembleStreamerAiTimeline(userId, vodIdByStream = null) {
}
async function setStreamAiTitle(streamId, title) {
-- try { return await run('UPDATE streams SET ai_title = ? WHERE id = ?', [String(title || '').slice(0, 80), streamId]); } catch { return null; }
++ try {
++ // A title about how someone looks is replaced by the stream's own title (stored, so it is not asked again).
++ if (_hasLooks(title)) title = ((await get('SELECT title FROM streams WHERE id = ?', [streamId])) || {}).title || 'Stream';
++ return await run('UPDATE streams SET ai_title = ? WHERE id = ?', [String(title || '').slice(0, 80), streamId]);
++ } catch { return null; }
}
// Sessions that have an AI overview but no short AI title yet (for background titling).
async function getUntitledAiSessions(userId, limit = 20) {
@@@ -3556,7 +3574,7 @@ module.exports = {
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
-- cleanupMalformedAiText, noLooks: _noLooks, noLooksTags: _noLooksTags, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
++ cleanupMalformedAiText, noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
ddiff --git a/test/ai-text-no-looks.test.js b/test/ai-text-no-looks.test.js
iindex c640599c..7bca0bae 100644
---- a/test/ai-text-no-looks.test.js
++++ b/test/ai-text-no-looks.test.js
@@@ -30,6 +30,16 @@ const db = require('../server/db/database');
['A kitchen studio with a headset-wearing host, microphone, and on-screen graphics.', 'A kitchen studio with a host, microphone, and on-screen graphics.'],
['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],
['A man wearing sunglasses on his head looks down at the camera.', 'A man looks down at the camera.'],
++ ['A collage of a man in a white shirt and tie wearing dark sunglasses in the left panel.', 'A collage of a man in the left panel.'],
++ ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind him a green mat; he is leaning to the left.'],
++ ['Two men lift weights, one shirtless and sweating.', 'Two men lift weights, one sweating.'],
++ ['Two men stand in an alley, one and drinking from a bottle while the other wears a dusty t-shirt; both look tired.', 'Two men stand in an alley, one drinking from a bottle; both look tired.'],
++ ['A man wearing a green bucket hat with frog-like eyes sits indoors.', 'A man sits indoors.'],
++ ['A man in sunglasses reaches toward the camera inside a car.', 'A man reaches toward the camera inside a car.'],
++ ['A live stream window shows a broadcaster with a headset on the left, a chat panel on the right.', 'A live stream window shows a broadcaster on the left, a chat panel on the right.'],
++ ['A man with a red shirt sits in a room; a desk with a headset and keyboard.', 'A man sits in a room; a desk with a headset and keyboard.'],
++ ['A gamer faces a casually dressed NPC in a grim interior.', 'A gamer faces a NPC in a grim interior.'],
++ ['A cartoon streaming setup: wearing a headset with a microphone, rugged jacket, backpack with a mat.', 'A cartoon streaming setup: backpack with a mat.'],
['A partial view of a person wearing a gray zip-up jacket with a white zipper, seated indoors.', 'A partial view of a person, seated indoors.'],
['Close-up of a person wearing a yellow vest over a gray knit top, with a backpack visible.', 'Close-up of a person, with a backpack visible.'],
['A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.', 'A Minecraft player in a top-down view builds a house; the streamer reviews new headphones.'],
@@@ -60,6 +70,17 @@ const db = require('../server/db/database');
// Tags shown with public AI items drop clothing, hair, face, age and gendered people.
assert.deepStrictEqual(db.noLooksTags(['red-beanie', 'bearded', 'man', 'tanktop', 'hula hoop', 'laptop', 'minecraft', 'outdoors', 'jacket', 'selfie']), ['hula hoop', 'laptop', 'minecraft', 'outdoors']);
++ // The chat popover and the profile's AI tab: every AI string in the answer, however nested.
++ const { noLooksDeep } = require('../server/ai/chat-ai-routes')._internals;
++ assert.deepStrictEqual(noLooksDeep({ overview_alltime: 'A shirtless host chats.', n: 3, jokes: ['the streamer wearing a hat dances'], sessions: [{ moments: [{ description: 'A man in a red shirt cooks.' }] }] }),
++ { overview_alltime: 'A host chats.', n: 3, jokes: ['the streamer dances'], sessions: [{ moments: [{ description: 'A man cooks.' }] }] });
++
++ // A session's AI title about looks is not used: the stream's own title is stored instead.
++ await db.setStreamAiTitle(sid, 'Orange Shirt Kitchen Chat');
++ assert.strictEqual((await db.get('SELECT ai_title FROM streams WHERE id = ?', [sid])).ai_title, 't');
++ await db.setStreamAiTitle(sid, 'Minecraft Castle Marathon');
++ assert.strictEqual((await db.get('SELECT ai_title FROM streams WHERE id = ?', [sid])).ai_title, 'Minecraft Castle Marathon');
++
// 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 }));