openvibers/openvibe.live Commits
Channel lines: cut at a joining phrase, not inside an adjective list (#781)
Changes
@@@ -33,8 +33,12 @@ const VERB = /\b(?:streams?|broadcasts?|presents?|plays?|hosts?|covers?|shows?|bconst 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;@@@ -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;@@@ -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_nameFROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_idWHERE 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) {@@@ -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');
@@ -33,8 +33,12 @@ const VERB = /\b(?:streams?|broadcasts?|presents?|plays?|hosts?|covers?|shows?|b
@@ -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;
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.
// A full "Name: …" opener, after the name is off, can leave a leading separator.
const LEADING_JUNK = /^[\s:;,.\-–—]+/;
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. */
/** Plain text: a JSON-wrapped model answer and markdown noise become prose. */
function plain(text) {
function plain(text) {
@@ -108,10 +112,10 @@ function cutWords(s) {
@@ -108,10 +112,10 @@ function cutWords(s) {
/** One long phrase may end at a whole prepositional phrase ("… gameplay with a focus on …"). */
/** One long phrase may end at a whole prepositional phrase ("… gameplay with a focus on …"). */
function cutPhrase(s) {
function cutPhrase(s) {
if (s.length <= MAX_CONTENT) return 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();
if (m.index >= 35 && m.index <= MAX_CONTENT) return s.slice(0, m.index).trim();
}
}
return cutWords(s);
return cutWords(s);
@@ -124,14 +128,14 @@ function fitContent(content) {
@@ -124,14 +128,14 @@ function fitContent(content) {
if (s.length <= MAX_CONTENT) return s;
if (s.length <= MAX_CONTENT) return s;
const phrases = s.split(/\s*,\s*/).filter(Boolean);
const phrases = s.split(/\s*,\s*/).filter(Boolean);
if (phrases.length > 1) {
if (phrases.length > 1) {
- let out = phrases[0];
+ let out = phrases[0], best = '';
for (let i = 1; i < phrases.length; i++) {
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]}`;
const next = `${out}, ${phrases[i]}`;
if (next.length > MAX_CONTENT) break;
if (next.length > MAX_CONTENT) break;
out = next;
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);
return cutPhrase(s);
}
}
@@ -151,7 +155,7 @@ function shapeChannelLine({ username = null, displayName = null, overview = '' }
@@ -151,7 +155,7 @@ function shapeChannelLine({ username = null, displayName = null, overview = '' }
let content = contentAfterVerb(sentence);
let content = contentAfterVerb(sentence);
if (!content) {
if (!content) {
const role = roleOf(sentence);
const role = roleOf(sentence);
- if (role) content = `as a ${role}`;
+ if (role) content = `as ${/^[aeiou]/i.test(role) ? 'an' : 'a'} ${role}`;
}
}
if (!content) continue;
if (!content) continue;
@@ -28,7 +28,7 @@ async function tick() {
@@ -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
// 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.
// lines are refreshed here, not by hand.
try {
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)`);
if (reshaped) console.log(`[AI] Streamer-overview job reshaped ${reshaped} stored card line(s)`);
} catch (e) { console.warn('[AI] streamer overview reshape:', e.message); }
} catch (e) { console.warn('[AI] streamer overview reshape:', e.message); }
if (!await ai.isEnabled()) return;
if (!await ai.isEnabled()) return;
@@ -948,11 +948,12 @@ async function getStreamerOverview(userId) {
@@ -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.
* 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) {
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
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
FROM streamer_overviews o LEFT JOIN users u ON u.id = o.user_id
WHERE o.overview IS NOT NULL AND TRIM(o.overview) <> ''
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]);
ORDER BY o.generated_at ASC NULLS FIRST LIMIT ?`, [limit]);
let changed = 0;
let changed = 0;
for (const r of rows) {
for (const r of rows) {
@@ -95,6 +95,22 @@ for (const f of FIXTURES) {
@@ -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(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`);
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.match(FALLBACK_CHANNEL_LINE, CHANNEL_LINE_RE, 'the fallback is the same shape');
assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');
assert.strictEqual(shapeChannelLine(), FALLBACK_CHANNEL_LINE, 'no arguments');
assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');
assert.strictEqual(shapeChannelLine({ overview: null }), FALLBACK_CHANNEL_LINE, 'null overview');