openvibers/openvibe.live Commits

The home hero keeps its moments when a run finds few; the job scripts open the database (#590)

OP OpenVibers committed 10 days ago · 62219f27fc · 5 files, +79 −7

- A moments run that found nothing replaced the hero set with nothing (11-03: the AI
  declined every VOD and the home showed no moments). The set is now this run's
  moments, then the last set's (other VODs), up to the target.
- node server/ai/ai-moments-job.js, auto-clip-job.js --backfill and easter-egg-job.js
  never opened the database after the move to PostgreSQL: every query failed with
  "the database is not open" and the scripts reported nothing done. They now call
  initDb({ script: true }): the serving pool only, no migrations and no boot recovery
  (which would re-queue transcripts the running service is working on).

Changes

ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 6919ff6d..2dfbac2d 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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); });
}
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex ea8fe5cb..c31e9013 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -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 {
ddiff --git a/server/ai/easter-egg-job.js b/server/ai/easter-egg-job.js
iindex f94ca294..9ebf130e 100644
---- a/server/ai/easter-egg-job.js
++++ b/server/ai/easter-egg-job.js
@@@ -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); });
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 14b75d0b..4edbb4ae 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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');
ddiff --git a/test/moments-keep-hero.test.js b/test/moments-keep-hero.test.js
nnew file mode 100644
iindex 00000000..7a4329f5
---- /dev/null
++++ b/test/moments-keep-hero.test.js
@@@ -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); });