openvibers/openvibe.live Commits
Chat popover + profile AI tab: no looks in any AI string; session titles about looks not used (#204)
Changes
@@@ -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 };@@@ -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,@@@ -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 }));
@@ -19,6 +19,15 @@ const router = express.Router();
@@ -19,6 +19,15 @@ const router = express.Router();
const db = require('../db/database');
const db = require('../db/database');
const insights = require('../chat/insight-client');
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) => {
router.get('/user/:id', async (req, res) => {
try {
try {
const uid = parseInt(req.params.id, 10);
const uid = parseInt(req.params.id, 10);
@@ -53,8 +62,8 @@ router.get('/user/:id', async (req, res) => {
@@ -53,8 +62,8 @@ router.get('/user/:id', async (req, res) => {
} catch { /* streamer context is best-effort */ }
} catch { /* streamer context is best-effort */ }
res.json({
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,
user: user ? { id: user.id, username: user.username, display_name: user.display_name } : null,
});
});
} catch (err) {
} catch (err) {
@@ -72,7 +81,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
@@ -72,7 +81,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
const cOv = (chatIns && (chatIns.overview_alltime || chatIns.overview_24h)) || '';
const cOv = (chatIns && (chatIns.overview_alltime || chatIns.overview_24h)) || '';
if (!sOv && !cOv) return null;
if (!sOv && !cOv) return null;
// Only one side → that's the whole story; no synthesis (and no AI cost) needed.
// 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 key = `ai_whole_overview_${userId}`;
const srcLen = sOv.length + '|' + cOv.length; // cheap change-detector
const srcLen = sOv.length + '|' + cOv.length; // cheap change-detector
@@ -81,7 +90,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
@@ -81,7 +90,7 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
const raw = await db.getState(key);
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); }
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 */ }
} 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.
// Regenerate in the background (best-effort) so the request never blocks on the LLM.
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
@@ -89,12 +98,12 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
@@ -89,12 +98,12 @@ async function _combinedOverview(userId, streamerOv, chatIns) {
_combinedBusy.add(userId);
_combinedBusy.add(userId);
// The prompt is OpenVibe.AI's versioned template live.person.overview (WS-O task 2).
// 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 } }))
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(() => { })
.catch(() => { })
.finally(() => _combinedBusy.delete(userId));
.finally(() => _combinedBusy.delete(userId));
}
}
// Return whatever we have now: last synthesis (even if stale) or a simple stitch.
// 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,
// Background: give sessions SHORT, catchy AI titles (streamers reuse literal stream titles,
@@ -164,14 +173,14 @@ router.get('/timeline/:username', async (req, res) => {
@@ -164,14 +173,14 @@ router.get('/timeline/:username', async (req, res) => {
res.json({
res.json({
username: user.username,
username: user.username,
display_name: user.display_name || 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,
combinedOverview,
sessionCount: timeline.sessionCount,
sessionCount: timeline.sessionCount,
momentCount: timeline.momentCount,
momentCount: timeline.momentCount,
generatedAt: timeline.generatedAt,
generatedAt: timeline.generatedAt,
index,
index,
- sessions: page,
+ sessions: _noLooksDeep(page),
offset, limit,
offset, limit,
hasMore: offset + limit < allSessions.length,
hasMore: offset + limit < allSessions.length,
});
});
@@ -314,3 +323,4 @@ router.get('/transcript/:streamId', async (req, res) => {
@@ -314,3 +323,4 @@ router.get('/transcript/:streamId', async (req, res) => {
});
});
module.exports = router;
module.exports = router;
+module.exports._internals = { noLooksDeep: _noLooksDeep };
@@ -400,24 +400,36 @@ const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
@@ -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 _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 _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 _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 _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 _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) {
function _noClothing(text) {
return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
.replace(/\b[\w-]+-wearing\s+/gi, '')
.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(_WEARING, '')
.replace(_IN_WORN, '')
.replace(_IN_WORN, '')
.replace(_WITH_FACE, '')
.replace(_WITH_FACE, '')
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/,(?=[.;:!?])/g, '')
.replace(/,(?=[.;:!?])/g, '')
+ .replace(/([:;]),/g, '$1')
.replace(/ {2,}/g, ' '))).join('');
.replace(/ {2,}/g, ' '))).join('');
}
}
// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
// 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.
// 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');
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) {
function _noLooksTags(tags) {
return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
}
}
@@ -428,6 +440,8 @@ function _noLooks(text) {
@@ -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, (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(_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)\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+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')
.replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
.replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
.replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
.replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
@@ -967,7 +981,11 @@ async function assembleStreamerAiTimeline(userId, vodIdByStream = null) {
@@ -967,7 +981,11 @@ async function assembleStreamerAiTimeline(userId, vodIdByStream = null) {
}
}
async function setStreamAiTitle(streamId, title) {
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).
// Sessions that have an AI overview but no short AI title yet (for background titling).
async function getUntitledAiSessions(userId, limit = 20) {
async function getUntitledAiSessions(userId, limit = 20) {
@@ -3556,7 +3574,7 @@ module.exports = {
@@ -3556,7 +3574,7 @@ module.exports = {
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
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,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
@@ -30,6 +30,16 @@ const db = require('../server/db/database');
@@ -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.'],
['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.'],
['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 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.'],
['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.'],
['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.'],
['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');
@@ -60,6 +70,17 @@ const db = require('../server/db/database');
// Tags shown with public AI items drop clothing, hair, face, age and gendered people.
// 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']);
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.
// 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'];
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 }));
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 }));