openvibers/openvibe.live Commits

Recently Online cards carry the AI's own line when it reads as a person wrote it (#811)

OP OpenVibers committed 2 days ago · 24eff72e67 · 5 files, +60 −9

Ask 363 (the owner: the channel descriptions are "all so generic sounding … make it a little more human feeling and
nice"). OpenVibe.AI #466 has live.streamer.overview answer a `line` for the card. Live keeps it when it is one
sentence of 20 to 100 characters, without the name, a colon or a handle, without the stock words (focus, content,
engaging, various, a mix of, dive into, showcasing, vibrant, dynamic, journey, immersive, a blend of), not explicit,
and not in the reshaped "Streams …" shape. Otherwise the card falls back to the overview reshaped, as before.
- server/ai/channel-line.js: humanLine() and cardLine().
- The write (db.upsertStreamerOverview takes `line`), the reshape pass (it leaves a human line alone) and the read
  path (GET /api/streams/recently-online) all keep it.
- test/channel-line-shape.test.js: what passes and what falls back.

Changes

ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex a081706f..d2d671ed 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -126,6 +126,8 @@ async function generateStreamerOverview(userId) {
try {
await db.upsertStreamerOverview(userId, {
overview,
++ // The card's own line (OpenVibe.AI #466); kept only when it passes channel-line.js humanLine.
++ line: o && typeof o.line === 'string' ? o.line.slice(0, 140) : null,
model: 'openvibe-ai',
sources: JSON.stringify({ memories: memories.length, vods: vods.length, pastes: summarized.length }),
});
ddiff --git a/server/ai/channel-line.js b/server/ai/channel-line.js
iindex 3de6b9ab..1dc5c38a 100644
---- a/server/ai/channel-line.js
++++ b/server/ai/channel-line.js
@@@ -174,4 +174,33 @@ function shapeChannelLine({ username = null, displayName = null, overview = '' }
return FALLBACK_CHANNEL_LINE;
}
--module.exports = { shapeChannelLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE, MAX_LEN };
++/*
++ * The AI's own card line (2026-11-11, ask 363: the owner, "not all so generic sounding … more human feeling and nice").
++ * OpenVibe.AI's live.streamer.overview answers a `line` written for the card ("Robots, cameras and late-night
++ * tinkering, with chat riding along."); it is kept when it is one sentence of 20 to 100 characters, without the name, a
++ * colon or a handle, without the stock words that make every channel sound the same, not explicit, and not in the
++ * reshaped "Streams …" shape (that shape is the fallback's, so the reshape pass knows its own lines). Otherwise null,
++ * and the card falls back to the overview reshaped.
++ */
++const STOCK = /\b(?:focus|content|engaging|various|a mix of|dive into|diving into|showcas\w*|vibrant|dynamic|journey|immersive|a blend of)\b/i;
++function humanLine(raw, { username = null, displayName = null } = {}) {
++ let s = plain(raw).replace(/^["'\u201c\u201d]+|["'\u201c\u201d]+$/g, '').trim();
++ if (!s) return null;
++ if (!/[.!?\u2026]$/.test(s)) s += '.';
++ if (s.length < 20 || s.length > 100) return null;
++ if (/^Streams\b/.test(s) || s.includes(':') || /@\w/.test(s)) return null;
++ if (sentences(s).length > 1) return null;
++ for (const n of [displayName, username]) {
++ const name = String(n || '').trim();
++ if (name && new RegExp(`^${escRe(name)}\\b`, 'i').test(s)) return null;
++ }
++ if (STOCK.test(s) || isExplicit(s)) return null;
++ return s.charAt(0).toUpperCase() + s.slice(1);
++}
++
++/** The card's line: the AI's own when it passes, else the overview in the one shape. */
++function cardLine({ username = null, displayName = null, overview = '', line = null } = {}) {
++ return humanLine(line, { username, displayName }) || shapeChannelLine({ username, displayName, overview });
++}
++
++module.exports = { shapeChannelLine, humanLine, cardLine, CHANNEL_LINE_RE, FALLBACK_CHANNEL_LINE, MAX_LEN };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 5671aa62..c1759a34 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -464,14 +464,14 @@ function _shortOverview(text) {
return (sp > 40 ? cut.slice(0, sp) : cut).trim() + '…';
}
/** A streamer's card line in the one channel shape (server/ai/channel-line.js); the name comes off via their row. */
--async function _shapedChannelLine(userId, overview) {
++async function _shapedChannelLine(userId, overview, line = null) {
let username = null, displayName = null;
try {
const u = await get('SELECT username, display_name FROM users WHERE id = ?', [userId]);
if (u) { username = u.username; displayName = u.display_name; }
} catch { /* without the name the line still shapes, minus the name strip */ }
try {
-- return require('../ai/channel-line').shapeChannelLine({ username, displayName, overview: _plainProse(overview || '') });
++ return require('../ai/channel-line').cardLine({ username, displayName, overview: _plainProse(overview || ''), line });
} catch {
return _shortOverview(overview);
}
@@@ -928,10 +928,10 @@ async function getStreamTranscriptSegments(streamId) {
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
--async function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
-- // The card line is stored in the one channel shape, so the streamer-overview job refreshes stored lines
-- // through this write; rows it has not rewritten yet are shaped on read (GET /api/streams/recently-online).
-- const short = await _shapedChannelLine(userId, overview);
++async function upsertStreamerOverview(userId, { overview, line = null, model = null, sources = null }) {
++ // The card line: the AI's own line when it passes (server/ai/channel-line.js humanLine, ask 363), else the overview
++ // in the one channel shape; rows the job has not rewritten yet are shaped on read (GET /api/streams/recently-online).
++ const short = await _shapedChannelLine(userId, overview, line);
return await run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
VALUES (?, ?, ?, ?, ?, ov_now())
ON CONFLICT(user_id) DO UPDATE SET
@@@ -955,8 +955,11 @@ async function shapeStoredStreamerOverviewShorts(limit = 20) {
FROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_id
WHERE o.overview IS NOT NULL AND TRIM(o.overview) <> ''
ORDER BY o.generated_at ASC NULLS FIRST LIMIT ?`, [limit]);
++ const { humanLine } = require('../ai/channel-line');
let changed = 0;
for (const r of rows) {
++ // The AI's own card line stays (it is never in the reshaped "Streams …" shape).
++ if (humanLine(r.overview_short, { username: r.username, displayName: r.display_name })) continue;
const short = await _shapedChannelLine(r.user_id, r.overview);
if (short && short !== r.overview_short) {
await run('UPDATE streamer_overviews SET overview_short = ? WHERE user_id = ?', [short, r.user_id]);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 22f51094..29eb7bda 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1452,11 +1452,13 @@ router.get('/recently-online', async (req, res) => {
}
// Every card carries the same numbers, present or null, and its line in the one channel shape
// (stored lines the job has not rewritten yet are shaped here).
-- const { shapeChannelLine } = require('../ai/channel-line');
++ const { shapeChannelLine, humanLine } = require('../ai/channel-line');
for (const s of streamers) {
if (!s.recent_facts) { s.recent_facts = { streams: [] }; for (const k of RECENT_FACT_KEYS) s.recent_facts[k] = null; }
try {
-- s.ai_overview_short = shapeChannelLine({ username: s.username, displayName: s.display_name, overview: s.ai_overview || s.ai_overview_short });
++ // The AI's own line when it is stored (ask 363), else the overview in the one shape.
++ const names = { username: s.username, displayName: s.display_name };
++ s.ai_overview_short = humanLine(s.ai_overview_short, names) || shapeChannelLine({ ...names, overview: s.ai_overview || s.ai_overview_short });
} catch { /* the stored line stands */ }
}
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
ddiff --git a/test/channel-line-shape.test.js b/test/channel-line-shape.test.js
iindex d1d4783a..1797b195 100644
---- a/test/channel-line-shape.test.js
++++ b/test/channel-line-shape.test.js
@@@ -116,4 +116,19 @@ assert.match(FALLBACK_CHANNEL_LINE, CHANNEL_LINE_RE, 'the fallback is the same s
assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');
assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');
assert.strictEqual(shapeChannelLine({ username: 'Maticus', displayName: 'Maticus', overview: '{"overview":"Maticus streams chess and cooking."}' }), 'Streams chess and cooking.', 'JSON-wrapped model text');
++// The AI's own card line (ask 363, 2026-11-11): kept when it reads as a person wrote it, else the shape above.
++{
++ const { humanLine, cardLine } = require('../server/ai/channel-line');
++ const names = { username: 'Maticus', displayName: 'Maticus' };
++ assert.strictEqual(humanLine('Robots, cameras and late-night tinkering, with chat riding along.', names), 'Robots, cameras and late-night tinkering, with chat riding along.');
++ assert.strictEqual(humanLine('cozy acoustic sets and requests from a kitchen table', names), 'Cozy acoustic sets and requests from a kitchen table.', 'a capital and a full stop');
++ assert.strictEqual(humanLine('Maticus builds robots in his kitchen.', names), null, 'the card shows the name already');
++ assert.strictEqual(humanLine('Streams robots and cameras.', names), null, 'the reshaped shape is the fallback\'s own');
++ assert.strictEqual(humanLine('A vibrant mix of tech content.', names), null, 'the stock words');
++ assert.strictEqual(humanLine('Chess. Then cooking for everyone.', names), null, 'one sentence');
++ assert.strictEqual(humanLine('Robots: built, broken, built again.', names), null, 'no colon');
++ assert.strictEqual(humanLine('Short.', names), null, 'too short to say anything');
++ assert.strictEqual(cardLine({ ...names, overview: 'Maticus streams chess and cooking.', line: 'A dynamic journey.' }), 'Streams chess and cooking.', 'a stock line falls back to the shape');
++ assert.strictEqual(cardLine({ ...names, overview: 'Maticus streams chess.', line: 'Chess openings argued over with chat, one blunder at a time.' }), 'Chess openings argued over with chat, one blunder at a time.');
++}
console.log('channel-line shape: all checks passed');