openvibers/openvibe.live Commits
The home hero keeps its moments when a run finds few; the job scripts open the database (#590)
Changes
@@@ -407,6 +407,23 @@ async function _momentPool(limit) {return [];}++/**++ * The hero set after a run: this run's moments first, then the last set's (not the same VOD), up to the target. A run++ * that found little or nothing used to replace the set with what it found, and the home hero went empty (11-03: a run++ * where the AI declined every VOD left 0 moments for six hours).++ */++function keepHeroFull(fresh, previous, target) {++ const out = [...(fresh || [])];++ const vods = new Set(out.map((m) => m && m.vodId));++ for (const m of Array.isArray(previous) ? previous : []) {++ if (out.length >= target) break;++ if (!m || vods.has(m.vodId)) continue;++ vods.add(m.vodId);++ out.push(m);++ }++ return out;++}++async function tick(opts = {}) {// opts: { force } bypass the daily gate, { fresh } ignore the recently-used log,// { target } how many moments, { vodPool } how many VODs to consider, { perUser } cap.@@@ -596,8 +613,10 @@ async function tick(opts = {}) {}const usedLog = [...newUsedVods, ...(prev.usedVods || [])].slice(0, 300);-- await db.setState(SETTING, JSON.stringify({ moments, usedVods: usedLog, rankCache: _rankCacheOut, updated_at: Date.now() }));-- console.log(`[AI-Moments] ${moments.length} moment(s) from ${chosen.length}/${totalEligible} ranked VODs + pastes created`);++ const found = moments.length;++ const shown = keepHeroFull(moments, prev.moments, TARGET_N);++ await db.setState(SETTING, JSON.stringify({ moments: shown, usedVods: usedLog, rankCache: _rankCacheOut, updated_at: Date.now() }));++ console.log(`[AI-Moments] ${found} moment(s) from ${chosen.length}/${totalEligible} ranked VODs + pastes created${shown.length > found ? `; ${shown.length - found} kept from the last set` : ''}`);} catch (e) {console.warn('[AI-Moments] tick error:', e.message);} finally {@@@ -617,7 +636,7 @@ function start() {setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);}--module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle, evidenceAt: _evidenceAt } };++module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle, evidenceAt: _evidenceAt, keepHeroFull } };// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2@@@ -633,7 +652,7 @@ if (require.main === module) {perUser: num('perUser', 1),};console.log('[AI-Moments] Manual run:', JSON.stringify(opts));-- tick(opts)++ db.initDb({ script: true }).then(() => tick(opts)).then(async () => { const p = await _load(); console.log(`[AI-Moments] Done — ${(p.moments || []).length} moment(s) in the hero set.`); process.exit(0); }).catch((e) => { console.error('[AI-Moments] Manual run failed:', e); process.exit(1); });}@@@ -497,7 +497,7 @@ if (require.main === module) {if (argv.includes('--backfill')) {const limit = num('limit', 4);console.log(`[AutoClip] Manual backfill (limit ${limit})…`);-- backfillVodClips({ limit, force: true })++ db.initDb({ script: true }).then(() => backfillVodClips({ limit, force: true })).then((n) => { console.log(`[AutoClip] Manual backfill done — ${n} clip(s) created.`); process.exit(0); }).catch((e) => { console.error('[AutoClip] Manual backfill failed:', e); process.exit(1); });} else {@@@ -206,5 +206,5 @@ module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, reve// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`if (require.main === module) {-- tick({ force: true }).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });++ require('../db/database').initDb({ script: true }).then(() => tick({ force: true })).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });}@@@ -19,10 +19,17 @@ let db = null;* serving pool. Tests adopt the database test/helpers/pg-preload.mjs migrated. Development without DATABASE_URL gets* an embedded PGlite database under the data directory (one process only); production refuses to boot without it.*/--async function initDb({ log = console } = {}) {++async function initDb({ log = console, script = false } = {}) {if (db) return db;if (globalThis.__ovLiveTestDb) { db = globalThis.__ovLiveTestDb; return db; }const url = process.env.DATABASE_URL;++ // A one-off script (`node server/ai/ai-moments-job.js`, …) beside the running service: the serving pool only. No++ // migrations (the service ran them) and no boot recovery, which would re-queue the transcripts the service is++ // working on. Without this the scripts' queries all failed and they reported nothing done (11-03).++ if (script && url) {++ db = createDb({ url, service: 'live-script', max: 2, log });++ return db;++ }if (!url) {if (process.env.NODE_ENV === 'production') throw new Error('DATABASE_URL is not set: Live serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh live)');const dir = require('../paths').data('pglite');nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++// The home hero never goes empty because one run found little (11-03: a run where the AI declined every VOD replaced++// the set with nothing for six hours), and the job's own command line opens the database (it had silently done nothing++// since the move to PostgreSQL: every query failed with "the database is not open").++const assert = require('assert');++const fs = require('fs');++const path = require('path');++console.warn = () => {};++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const { keepHeroFull } = require('../server/ai/ai-moments-job')._internals;++ const m = (vodId, offset = 10) => ({ vodId, offset, title: `t${vodId}`, pasteSlug: `p-${vodId}` });++++ // Nothing new: the last set stays.++ assert.deepStrictEqual(keepHeroFull([], [m(1), m(2), m(3)], 6).map((x) => x.vodId), [1, 2, 3]);++ // New moments first, then the last set's, never the same VOD twice, up to the target.++ assert.deepStrictEqual(keepHeroFull([m(5), m(2, 99)], [m(1), m(2), m(3), m(4)], 4).map((x) => x.vodId), [5, 2, 1, 3]);++ assert.strictEqual(keepHeroFull([m(5), m(2, 99)], [m(2)], 4)[1].offset, 99, 'the fresh moment wins over the old one of that VOD');++ // A full fresh set is left alone.++ assert.deepStrictEqual(keepHeroFull([m(7), m(8)], [m(1)], 2).map((x) => x.vodId), [7, 8]);++ // Missing or odd previous sets.++ assert.deepStrictEqual(keepHeroFull([m(7)], undefined, 4).map((x) => x.vodId), [7]);++ assert.deepStrictEqual(keepHeroFull([], [null, m(1)], 4).map((x) => x.vodId), [1]);++++ // The save goes through it.++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', 'ai-moments-job.js'), 'utf8');++ assert.match(src, /const shown = keepHeroFull\(moments, prev\.moments, TARGET_N\);\s*await db\.setState\(SETTING, JSON\.stringify\(\{ moments: shown,/);++++ // Every job's command line opens the database first, as a script: the serving pool, no migrations, no boot recovery.++ for (const f of ['ai-moments-job.js', 'auto-clip-job.js', 'easter-egg-job.js']) {++ const s = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');++ const cli = s.slice(s.indexOf('if (require.main === module)'));++ assert.match(cli, /initDb\(\{ script: true \}\)\.then\(/, `${f}: its command line opens the database`);++ }++ const dbSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'db', 'database.js'), 'utf8');++ const init = /async function initDb\([\s\S]*?\n\}/.exec(dbSrc)[0];++ const scriptBranch = /if \(script && url\) \{([\s\S]*?)\n \}/.exec(init);++ assert.ok(scriptBranch, 'initDb has a script branch');++ assert.ok(!/migrate|bootRecovery|setTimeout/.test(scriptBranch[1]), 'a script runs no migrations, recovery or timers');++ assert.ok(init.indexOf('if (script && url)') < init.indexOf('owner.migrate'), 'the script branch returns before the migrations');++++ console.log('moments keep the hero: all checks passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -407,6 +407,23 @@ async function _momentPool(limit) {
@@ -407,6 +407,23 @@ async function _momentPool(limit) {
return [];
return [];
}
}
+/**
+ * The hero set after a run: this run's moments first, then the last set's (not the same VOD), up to the target. A run
+ * that found little or nothing used to replace the set with what it found, and the home hero went empty (11-03: a run
+ * where the AI declined every VOD left 0 moments for six hours).
+ */
+function keepHeroFull(fresh, previous, target) {
+ const out = [...(fresh || [])];
+ const vods = new Set(out.map((m) => m && m.vodId));
+ for (const m of Array.isArray(previous) ? previous : []) {
+ if (out.length >= target) break;
+ if (!m || vods.has(m.vodId)) continue;
+ vods.add(m.vodId);
+ out.push(m);
+ }
+ return out;
+}
+
async function tick(opts = {}) {
async function tick(opts = {}) {
// opts: { force } bypass the daily gate, { fresh } ignore the recently-used log,
// opts: { force } bypass the daily gate, { fresh } ignore the recently-used log,
// { target } how many moments, { vodPool } how many VODs to consider, { perUser } cap.
// { target } how many moments, { vodPool } how many VODs to consider, { perUser } cap.
@@ -596,8 +613,10 @@ async function tick(opts = {}) {
@@ -596,8 +613,10 @@ async function tick(opts = {}) {
}
}
const usedLog = [...newUsedVods, ...(prev.usedVods || [])].slice(0, 300);
const usedLog = [...newUsedVods, ...(prev.usedVods || [])].slice(0, 300);
- await db.setState(SETTING, JSON.stringify({ moments, usedVods: usedLog, rankCache: _rankCacheOut, updated_at: Date.now() }));
- console.log(`[AI-Moments] ${moments.length} moment(s) from ${chosen.length}/${totalEligible} ranked VODs + pastes created`);
+ const found = moments.length;
+ const shown = keepHeroFull(moments, prev.moments, TARGET_N);
+ await db.setState(SETTING, JSON.stringify({ moments: shown, usedVods: usedLog, rankCache: _rankCacheOut, updated_at: Date.now() }));
+ console.log(`[AI-Moments] ${found} moment(s) from ${chosen.length}/${totalEligible} ranked VODs + pastes created${shown.length > found ? `; ${shown.length - found} kept from the last set` : ''}`);
} catch (e) {
} catch (e) {
console.warn('[AI-Moments] tick error:', e.message);
console.warn('[AI-Moments] tick error:', e.message);
} finally {
} finally {
@@ -617,7 +636,7 @@ function start() {
@@ -617,7 +636,7 @@ function start() {
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
}
}
-module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle, evidenceAt: _evidenceAt } };
+module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, publicTitle: _publicTitle, _internals: { momentPool: _momentPool, fallbackTitle: _fallbackTitle, captionTitle: _captionTitle, evidenceAt: _evidenceAt, keepHeroFull } };
// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:
// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
@@ -633,7 +652,7 @@ if (require.main === module) {
@@ -633,7 +652,7 @@ if (require.main === module) {
perUser: num('perUser', 1),
perUser: num('perUser', 1),
};
};
console.log('[AI-Moments] Manual run:', JSON.stringify(opts));
console.log('[AI-Moments] Manual run:', JSON.stringify(opts));
- tick(opts)
+ db.initDb({ script: true }).then(() => tick(opts))
.then(async () => { const p = await _load(); console.log(`[AI-Moments] Done — ${(p.moments || []).length} moment(s) in the hero set.`); process.exit(0); })
.then(async () => { const p = await _load(); console.log(`[AI-Moments] Done — ${(p.moments || []).length} moment(s) in the hero set.`); process.exit(0); })
.catch((e) => { console.error('[AI-Moments] Manual run failed:', e); process.exit(1); });
.catch((e) => { console.error('[AI-Moments] Manual run failed:', e); process.exit(1); });
}
}
@@ -497,7 +497,7 @@ if (require.main === module) {
@@ -497,7 +497,7 @@ if (require.main === module) {
if (argv.includes('--backfill')) {
if (argv.includes('--backfill')) {
const limit = num('limit', 4);
const limit = num('limit', 4);
console.log(`[AutoClip] Manual backfill (limit ${limit})…`);
console.log(`[AutoClip] Manual backfill (limit ${limit})…`);
- backfillVodClips({ limit, force: true })
+ db.initDb({ script: true }).then(() => backfillVodClips({ limit, force: true }))
.then((n) => { console.log(`[AutoClip] Manual backfill done — ${n} clip(s) created.`); process.exit(0); })
.then((n) => { console.log(`[AutoClip] Manual backfill done — ${n} clip(s) created.`); process.exit(0); })
.catch((e) => { console.error('[AutoClip] Manual backfill failed:', e); process.exit(1); });
.catch((e) => { console.error('[AutoClip] Manual backfill failed:', e); process.exit(1); });
} else {
} else {
@@ -206,5 +206,5 @@ module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, reve
@@ -206,5 +206,5 @@ module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, reve
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
if (require.main === module) {
if (require.main === module) {
- tick({ force: true }).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });
+ require('../db/database').initDb({ script: true }).then(() => tick({ force: true })).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });
}
}
@@ -19,10 +19,17 @@ let db = null;
@@ -19,10 +19,17 @@ let db = null;
* serving pool. Tests adopt the database test/helpers/pg-preload.mjs migrated. Development without DATABASE_URL gets
* serving pool. Tests adopt the database test/helpers/pg-preload.mjs migrated. Development without DATABASE_URL gets
* an embedded PGlite database under the data directory (one process only); production refuses to boot without it.
* an embedded PGlite database under the data directory (one process only); production refuses to boot without it.
*/
*/
-async function initDb({ log = console } = {}) {
+async function initDb({ log = console, script = false } = {}) {
if (db) return db;
if (db) return db;
if (globalThis.__ovLiveTestDb) { db = globalThis.__ovLiveTestDb; return db; }
if (globalThis.__ovLiveTestDb) { db = globalThis.__ovLiveTestDb; return db; }
const url = process.env.DATABASE_URL;
const url = process.env.DATABASE_URL;
+ // A one-off script (`node server/ai/ai-moments-job.js`, …) beside the running service: the serving pool only. No
+ // migrations (the service ran them) and no boot recovery, which would re-queue the transcripts the service is
+ // working on. Without this the scripts' queries all failed and they reported nothing done (11-03).
+ if (script && url) {
+ db = createDb({ url, service: 'live-script', max: 2, log });
+ return db;
+ }
if (!url) {
if (!url) {
if (process.env.NODE_ENV === 'production') throw new Error('DATABASE_URL is not set: Live serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh live)');
if (process.env.NODE_ENV === 'production') throw new Error('DATABASE_URL is not set: Live serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh live)');
const dir = require('../paths').data('pglite');
const dir = require('../paths').data('pglite');
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+// The home hero never goes empty because one run found little (11-03: a run where the AI declined every VOD replaced
+// the set with nothing for six hours), and the job's own command line opens the database (it had silently done nothing
+// since the move to PostgreSQL: every query failed with "the database is not open").
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+console.warn = () => {};
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const { keepHeroFull } = require('../server/ai/ai-moments-job')._internals;
+ const m = (vodId, offset = 10) => ({ vodId, offset, title: `t${vodId}`, pasteSlug: `p-${vodId}` });
+
+ // Nothing new: the last set stays.
+ assert.deepStrictEqual(keepHeroFull([], [m(1), m(2), m(3)], 6).map((x) => x.vodId), [1, 2, 3]);
+ // New moments first, then the last set's, never the same VOD twice, up to the target.
+ assert.deepStrictEqual(keepHeroFull([m(5), m(2, 99)], [m(1), m(2), m(3), m(4)], 4).map((x) => x.vodId), [5, 2, 1, 3]);
+ assert.strictEqual(keepHeroFull([m(5), m(2, 99)], [m(2)], 4)[1].offset, 99, 'the fresh moment wins over the old one of that VOD');
+ // A full fresh set is left alone.
+ assert.deepStrictEqual(keepHeroFull([m(7), m(8)], [m(1)], 2).map((x) => x.vodId), [7, 8]);
+ // Missing or odd previous sets.
+ assert.deepStrictEqual(keepHeroFull([m(7)], undefined, 4).map((x) => x.vodId), [7]);
+ assert.deepStrictEqual(keepHeroFull([], [null, m(1)], 4).map((x) => x.vodId), [1]);
+
+ // The save goes through it.
+ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', 'ai-moments-job.js'), 'utf8');
+ assert.match(src, /const shown = keepHeroFull\(moments, prev\.moments, TARGET_N\);\s*await db\.setState\(SETTING, JSON\.stringify\(\{ moments: shown,/);
+
+ // Every job's command line opens the database first, as a script: the serving pool, no migrations, no boot recovery.
+ for (const f of ['ai-moments-job.js', 'auto-clip-job.js', 'easter-egg-job.js']) {
+ const s = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');
+ const cli = s.slice(s.indexOf('if (require.main === module)'));
+ assert.match(cli, /initDb\(\{ script: true \}\)\.then\(/, `${f}: its command line opens the database`);
+ }
+ const dbSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'db', 'database.js'), 'utf8');
+ const init = /async function initDb\([\s\S]*?\n\}/.exec(dbSrc)[0];
+ const scriptBranch = /if \(script && url\) \{([\s\S]*?)\n \}/.exec(init);
+ assert.ok(scriptBranch, 'initDb has a script branch');
+ assert.ok(!/migrate|bootRecovery|setTimeout/.test(scriptBranch[1]), 'a script runs no migrations, recovery or timers');
+ assert.ok(init.indexOf('if (script && url)') < init.indexOf('owner.migrate'), 'the script branch returns before the migrations');
+
+ console.log('moments keep the hero: all checks passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });