openvibers/openvibe.live Commits

Channel lines: cut at a joining phrase, not inside an adjective list (#781)

OP OpenVibers committed 3 days ago · 24fcf48b94 · 4 files, +34 −13

Changes

ddiff --git a/server/ai/channel-line.js b/server/ai/channel-line.js
iindex 68a389ea..50ae8aa0 100644
---- a/server/ai/channel-line.js
++++ b/server/ai/channel-line.js
@@@ -33,8 +33,12 @@ const VERB = /\b(?:streams?|broadcasts?|presents?|plays?|hosts?|covers?|shows?|b
const PAST = /\b(?:streamed|broadcast|used to|was|were|had|played|hosted|presented|built|created|chatted)\b/i;
// A full "Name: …" opener, after the name is off, can leave a leading separator.
const LEADING_JUNK = /^[\s:;,.\-–—]+/;
--// Stop a single long phrase at the last whole prepositional phrase that still fits.
--const TAIL_PREP = /\s(?:with|from|in|on|at|by|for|about|over|under|into)\s/gi;
++// Stop a single long phrase at a whole joining word ("… lighting and …", "… gameplay with …").
++const TAIL_CUT = /\s(?:with|from|in|on|at|by|for|about|over|under|into|and|or|but)\s/gi;
++// A comma-separated phrase is a whole cut when it is long enough to stand alone or when the next
++// phrase is a new clause ("…, with …", "…, often …"), not the rest of the same adjective list
++// ("… and blocky, imaginative builds …").
++const NEXT_CLAUSE = /^(?:and|or|but|with|from|in|on|at|by|for|about|over|under|into|often|usually|frequently|typically|mostly|generally|sometimes|while|when|before|after|plus)\b/i;
/** Plain text: a JSON-wrapped model answer and markdown noise become prose. */
function plain(text) {
@@@ -108,10 +112,10 @@ function cutWords(s) {
/** One long phrase may end at a whole prepositional phrase ("… gameplay with a focus on …"). */
function cutPhrase(s) {
if (s.length <= MAX_CONTENT) return s;
-- TAIL_PREP.lastIndex = 0;
-- for (let m; (m = TAIL_PREP.exec(s));) {
-- // The first whole prepositional phrase that still leaves a real line is the cut: after it
-- // comes a new phrase the card line cannot hold ("… gameplay with a focus on immersive worlds").
++ TAIL_CUT.lastIndex = 0;
++ for (let m; (m = TAIL_CUT.exec(s));) {
++ // The first whole joining phrase that still leaves a real line is the cut: after it comes a
++ // new phrase the card line cannot hold ("… lighting and blocky, imaginative builds").
if (m.index >= 35 && m.index <= MAX_CONTENT) return s.slice(0, m.index).trim();
}
return cutWords(s);
@@@ -124,14 +128,14 @@ function fitContent(content) {
if (s.length <= MAX_CONTENT) return s;
const phrases = s.split(/\s*,\s*/).filter(Boolean);
if (phrases.length > 1) {
-- let out = phrases[0];
++ let out = phrases[0], best = '';
for (let i = 1; i < phrases.length; i++) {
++ if (out.length <= MAX_CONTENT && (out.length >= 50 || NEXT_CLAUSE.test(phrases[i]))) best = out;
const next = `${out}, ${phrases[i]}`;
if (next.length > MAX_CONTENT) break;
out = next;
}
-- if (out.length <= MAX_CONTENT && out !== s) return out;
-- if (out.length > MAX_CONTENT) return cutPhrase(out);
++ if (best.length >= 35) return best;
}
return cutPhrase(s);
}
@@@ -151,7 +155,7 @@ function shapeChannelLine({ username = null, displayName = null, overview = '' }
let content = contentAfterVerb(sentence);
if (!content) {
const role = roleOf(sentence);
-- if (role) content = `as a ${role}`;
++ if (role) content = `as ${/^[aeiou]/i.test(role) ? 'an' : 'a'} ${role}`;
}
if (!content) continue;
ddiff --git a/server/ai/streamer-overview-job.js b/server/ai/streamer-overview-job.js
iindex eafd63fe..b7885292 100644
---- a/server/ai/streamer-overview-job.js
++++ b/server/ai/streamer-overview-job.js
@@@ -28,7 +28,7 @@ async function tick() {
// overview is already stored). This is the job that rewrites the stored rows, so the old name-first
// lines are refreshed here, not by hand.
try {
-- const reshaped = await db.shapeStoredStreamerOverviewShorts(100);
++ const reshaped = await db.shapeStoredStreamerOverviewShorts(500);
if (reshaped) console.log(`[AI] Streamer-overview job reshaped ${reshaped} stored card line(s)`);
} catch (e) { console.warn('[AI] streamer overview reshape:', e.message); }
if (!await ai.isEnabled()) return;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a31c4ebb..f4256c0b 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -948,11 +948,12 @@ async function getStreamerOverview(userId) {
* the read path also shapes, so no page shows an old shape while this catches up. Returns how many changed.
*/
async function shapeStoredStreamerOverviewShorts(limit = 20) {
++ // Every stored row is compared with the deterministic shape, not only the obviously old ones: a
++ // line written by an earlier version of the shaper can start with "Streams" and still be the wrong
++ // cut ("Streams with a casual."). A batch of 500 is a few pure-JS shapes, no AI call.
const rows = await all(`SELECT o.user_id, o.overview, o.overview_short, u.username, u.display_name
FROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_id
WHERE o.overview IS NOT NULL AND TRIM(o.overview) <> ''
-- AND (o.overview_short IS NULL OR LENGTH(o.overview_short) > 90 OR o.overview_short LIKE '%:%'
-- OR o.overview_short NOT LIKE 'Streams %')
ORDER BY o.generated_at ASC NULLS FIRST LIMIT ?`, [limit]);
let changed = 0;
for (const r of rows) {
ddiff --git a/test/channel-line-shape.test.js b/test/channel-line-shape.test.js
iindex f6fabf7a..564840e6 100644
---- a/test/channel-line-shape.test.js
++++ b/test/channel-line-shape.test.js
@@@ -95,6 +95,22 @@ for (const f of FIXTURES) {
assert.ok(!line.toLowerCase().startsWith(f.username.toLowerCase()), `${f.name}: no name at the start`);
assert.ok(!line.toLowerCase().startsWith(String(f.displayName || '').toLowerCase()), `${f.name}: no display name at the start`);
}
++// A comma inside one adjective list is not a sentence break: the line cuts at a whole joining phrase
++// instead of ending on "blocky." or "dark." (the home card showed those on 2026-11-10).
++const TRIM = [
++ { overview: 'moosely streams with a casual, homey vibe centered around acoustic guitar sessions and laid-back chats.', line: 'Streams with a casual, homey vibe centered around acoustic guitar sessions.' },
++ { overview: 'drbagelsmp streams Minecraft with bold purple lighting and blocky, imaginative builds that draw you into a colorful, creative world.', line: 'Streams Minecraft with bold purple lighting.' },
++ { overview: 'mrcodygraves streams as an IRL creator, often centered in a dark, minimalist setup with a headset and a Champion shirt.', line: 'Streams as an IRL creator, often centered in a dark, minimalist setup.' },
++ { overview: '1n1xx streams a mix of fast-paced first-person shooters and arcade-style Roblox-style obstacle courses, with on-screen chat.', line: 'Streams a mix of fast-paced first-person shooters.' },
++ { overview: 'Bobdope streams as an irl content creator sharing underwater marine life footage, turtle and reef scenes, and other real-world aquatic views.', line: 'Streams as an irl content creator sharing underwater marine life footage.' },
++];
++for (const t of TRIM) {
++ const line = shapeChannelLine({ username: 'x', displayName: 'x', overview: t.overview });
++ assert.strictEqual(line, t.line, t.overview.slice(0, 40));
++ assert.match(line, CHANNEL_LINE_RE);
++ assert.ok(line.length <= 90);
++}
++
assert.match(FALLBACK_CHANNEL_LINE, CHANNEL_LINE_RE, 'the fallback is the same shape');
assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');
assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');