openvibers/openvibe.ai Commits
The range cache (item 151, C12): the same seconds of a recording are transcribed once (#279)
Changes
@@@ -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 = { nowconst 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 () => {@@@ -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 };@@@ -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',nnew file mode 100644@@@ -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');
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
+const { CACHE_INPUT } = require('./workflows/hooks');
const { preferenceLines } = require('./user-modules');
const { preferenceLines } = require('./user-modules');
const { preferredTarget } = require('./providers/task');
const { preferredTarget } = require('./providers/task');
const schemas = require('./schemas');
const schemas = require('./schemas');
@@ -298,7 +299,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -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);
const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
let cacheKey = null;
if (scope) {
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);
const hit = await cache.get(cacheKey, scope);
if (hit) {
if (hit) {
await db.tx(async () => {
await db.tx(async () => {
@@ -527,4 +527,24 @@ const POSTPROCESS = {
@@ -527,4 +527,24 @@ const POSTPROCESS = {
// Space's builder keeps its hooks beside its templates (server/workflows/space.js).
// 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); }
{ 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 };
@@ -504,7 +504,8 @@ const structured = [
@@ -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.
// 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']),
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' }],
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',
key: 'network.site_copy', name: 'Footer copy for the shared network chrome', namespace: 'network',
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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');