openvibers/openvibe.ai Commits

The range cache (item 151, C12): the same seconds of a recording are transcribed once (#279)

OP OpenVibers committed 10 days ago · db95b70841 · 4 files, +63 −3

live.media.transcribe was cached by its whole input, which carries a signed media URL
(exp, sig: new on every call) and low_power (only how fast whisper runs), so a repeated
range always ran whisper again: 30 of 317 transcriptions in the week to 11-03. Its cache
key now leaves those out (hooks.js CACHE_INPUT, used by runs.js for the key only; the
run's input and input_hash are unchanged), still scoped to the requester; a recording's
words are kept 14 days.

Changes

ddiff --git a/server/runs.js b/server/runs.js
iindex 932d185..c575f2e 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -18,6 +18,7 @@
*/
const { ids } = require('openvibe-contracts');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
++const { CACHE_INPUT } = require('./workflows/hooks');
const { preferenceLines } = require('./user-modules');
const { preferredTarget } = require('./providers/task');
const schemas = require('./schemas');
@@@ -298,7 +299,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
if (scope) {
-- cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
++ // Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
++ const keyInput = CACHE_INPUT[wf.key] ? sha256(stableStringify(CACHE_INPUT[wf.key](input))) : inputHash;
++ cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash: keyInput });
const hit = await cache.get(cacheKey, scope);
if (hit) {
await db.tx(async () => {
ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex 7d12a78..6988617 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -527,4 +527,24 @@ const POSTPROCESS = {
// Space's builder keeps its hooks beside its templates (server/workflows/space.js).
{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }
--module.exports = { PREPARE, POSTPROCESS, CATEGORIES, normalizeCategory, translatable, sourcesBlock, LANG_NAMES };
++/**
++ * What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:
++ * the range cache). live.media.transcribe gets a signed media URL (exp, sig) that differs on every call and a
++ * low_power flag that only changes how fast whisper runs, so the same seconds of the same recording were transcribed
++ * again (30 of 317 runs in the week to 11-03). Its key is the recording, the range, the language and words: the cache
++ * stays scoped to the requester as before.
++ */
++const CACHE_INPUT = {
++ 'live.media.transcribe'(input) {
++ let url = String(input.media_url || '');
++ try {
++ const u = new URL(url);
++ for (const k of ['exp', 'sig', 'expires', 'signature', 'token']) u.searchParams.delete(k);
++ url = u.toString();
++ } catch { /* not a URL: kept as given */ }
++ const { low_power: _lowPower, ...rest } = input;
++ return { ...rest, media_url: url };
++ },
++};
++
++module.exports = { PREPARE, POSTPROCESS, CACHE_INPUT, CATEGORIES, normalizeCategory, translatable, sourcesBlock, LANG_NAMES };
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 2dde701..c844330 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -504,7 +504,8 @@ const structured = [
// words: true adds each segment's words with their times (item 151, C12), so a clip can start and end on a word.
output_schema: TEXT_OUT({ text: { type: 'string' }, language: STR(8), segments: { type: 'array', items: { type: 'object', required: ['start', 'end', 'text'], properties: { start: { type: 'number' }, end: { type: 'number' }, text: { type: 'string' }, words: { type: 'array', items: { type: 'object', required: ['start', 'end', 'text'], properties: { start: { type: 'number' }, end: { type: 'number' }, text: { type: 'string' } } } } } } } }, ['text', 'segments']),
steps: [{ kind: 'transcribe', route: 'live.stt' }],
-- cache_mode: 'private',
++ // A recording's words do not change: a range is kept two weeks (keyed without the URL's signature, hooks.js).
++ cache_mode: 'private', cache_ttl_sec: 14 * 86400,
},
{
key: 'network.site_copy', name: 'Footer copy for the shared network chrome', namespace: 'network',
ddiff --git a/test/transcribe-range-cache.test.js b/test/transcribe-range-cache.test.js
nnew file mode 100644
iindex 0000000..dfb5225
---- /dev/null
++++ b/test/transcribe-range-cache.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++// The range cache (item 151, C12): the same seconds of the same recording are transcribed once. live.media.transcribe's
++// cache key leaves out what does not change the words: the signed URL's exp and sig (new on every call) and low_power
++// (only how fast whisper runs). The recording, the range, the language and whether words were asked for still count.
++// In the week to 11-03, 30 of 317 transcriptions repeated a range they had already done.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { CACHE_INPUT } = require('../server/workflows/hooks');
++const { sha256, stableStringify } = require('../server/util');
++const live = require('../server/workflows/live');
++
++const key = (input) => sha256(stableStringify(CACHE_INPUT['live.media.transcribe'](input)));
++const base = { media_url: 'https://openvibe.media/v/6146?exp=1762146000&sig=aa11', language: 'en', start_sec: 300, seconds: 300, low_power: false };
++
++assert.strictEqual(key(base), key({ ...base, media_url: 'https://openvibe.media/v/6146?exp=1762149999&sig=bb22', low_power: true }), 'a new signature and low power: the same range');
++assert.strictEqual(key({ ...base, media_url: 'https://openvibe.media/v/6146?sig=x&exp=1' }), key({ ...base, media_url: 'https://openvibe.media/v/6146' }), 'the order of the query and its absence do not matter');
++assert.notStrictEqual(key(base), key({ ...base, start_sec: 600 }), 'another range');
++assert.notStrictEqual(key(base), key({ ...base, seconds: 200 }));
++assert.notStrictEqual(key(base), key({ ...base, media_url: 'https://openvibe.media/v/6147?exp=1&sig=2' }), 'another recording');
++assert.notStrictEqual(key(base), key({ ...base, language: 'ja' }), 'another language');
++assert.notStrictEqual(key(base), key({ ...base, words: true }), 'words asked for');
++assert.notStrictEqual(key({ ...base, media_url: 'https://openvibe.media/c/9?part=1&sig=z' }), key({ ...base, media_url: 'https://openvibe.media/c/9?part=2&sig=z' }), 'other query parameters still count');
++assert.doesNotThrow(() => key({ ...base, media_url: 'not a url' }));
++const kept = CACHE_INPUT['live.media.transcribe'](base);
++assert.ok(!('low_power' in kept));
++assert.strictEqual(base.low_power, false, 'the run\'s own input is not changed');
++
++// The run cache keys on it; a recording's words are kept two weeks.
++const runs = fs.readFileSync(path.join(__dirname, '..', 'server', 'runs.js'), 'utf8');
++assert.match(runs, /const keyInput = CACHE_INPUT\[wf\.key\] \? sha256\(stableStringify\(CACHE_INPUT\[wf\.key\]\(input\)\)\) : inputHash;/);
++assert.match(runs, /cache\.keyFor\(\{[^}]*inputHash: keyInput \}\)/);
++const wf = live.workflows.find((w) => w.key === 'live.media.transcribe');
++assert.strictEqual(wf.cache_mode, 'private', 'still scoped to the requester');
++assert.strictEqual(wf.cache_ttl_sec, 14 * 86400);
++console.log('transcribe range cache: all checks passed');