openvibers/openvibe.blog Commits

Fallback when changelog tagging is synthetic (#274)

OP OpenVibers committed 3 days ago · da0b55cb24 · 5 files, +43 −19

* Fallback when changelog tagging is synthetic

* Allow fallback backfill without AI

Changes

ddiff --git a/scripts/backfill-changelog-tags.js b/scripts/backfill-changelog-tags.js
iindex 830ba02..d001ed5 100644
---- a/scripts/backfill-changelog-tags.js
++++ b/scripts/backfill-changelog-tags.js
@@@ -66,11 +66,7 @@ async function main() {
return 0;
}
const tagger = createChangelogTagger({ config, log: console });
-- if (needModel.length && !tagger.enabled) {
-- console.error('[backfill] blog.changelog.tag is not configured (OV_AI_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET)');
-- await store.close();
-- return 1;
-- }
++ if (needModel.length && !tagger.enabled) console.log('[backfill] AI tagger is disabled; ambiguous subjects will use the Improved fallback');
const tagStmt = db.prepare(`UPDATE changelog_entries SET kind = ?, areas = ?, plain = ?, tagged_at = ?, tagged_by = ? WHERE id = ?${retag ? '' : ' AND kind IS NULL'}`);
let tagged = 0;
for (const row of housekeeping) tagged += (await tagStmt.run(row.tag.kind, row.tag.areas, row.tag.plain, store.now(), retag ? 'retagged-subject' : row.tag.kind === 'internal' ? 'housekeeping' : 'subject-rule', row.id)).changes;
@@@ -79,13 +75,17 @@ async function main() {
try {
const answer = await tagger.tag(batch);
for (const row of batch) {
-- const t = answer.get(String(row.id));
-- if (!t) continue;
-- tagged += (await tagStmt.run(t.kind, t.areas, t.plain, store.now(), retag ? 'retagged-ai' : 'ai', row.id)).changes;
++ const t = answer.get(String(row.id)) || tags.fallbackTag(row.subject);
++ const source = answer.has(String(row.id)) ? 'ai' : 'fallback';
++ tagged += (await tagStmt.run(t.kind, t.areas, t.plain, store.now(), retag ? `retagged-${source}` : source, row.id)).changes;
}
console.log(`[backfill] tagged ${Math.min(i + chunk, needModel.length)}/${needModel.length} model entries`);
} catch (err) {
-- console.error(`[backfill] batch ${i / chunk + 1} failed, left untagged: ${err.message}`);
++ console.error(`[backfill] batch ${i / chunk + 1} failed; applying subject fallback: ${err.message}`);
++ for (const row of batch) {
++ const t = tags.fallbackTag(row.subject);
++ tagged += (await tagStmt.run(t.kind, t.areas, t.plain, store.now(), retag ? 'retagged-fallback' : 'fallback', row.id)).changes;
++ }
}
}
console.log(`[backfill] wrote ${tagged} of ${rows.length} entries`);
ddiff --git a/server/changelog.js b/server/changelog.js
iindex da591c0..9da874e 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -154,20 +154,20 @@ function createChangelog({ config, store, blogs, posts, reading = null, tagger =
* hundreds of commits per site): the backfill tags it.
*/
async function tagPending(list) {
-- if (!list.length || !tagger || !tagger.enabled) return 0;
++ if (!list.length) return 0;
let n = 0;
for (let i = 0; i < list.length; i += TAG_BATCH) {
const chunk = list.slice(i, i + TAG_BATCH);
++ let answer = new Map();
try {
-- const answer = await tagger.tag(chunk);
-- for (const e of chunk) {
-- const t = answer.get(String(e.id));
-- if (!t) continue;
-- n += (await q.tag.run(t.kind, t.areas, t.plain, nowMs(), 'ai', e.id)).changes;
-- }
++ if (tagger && tagger.enabled) answer = await tagger.tag(chunk);
} catch (err) {
stats.lastError = `tag: ${err.message}`;
-- log.warn && log.warn(`[changelog] ${list.length} entries left untagged: ${err.message}`);
++ log.warn && log.warn(`[changelog] ${list.length} entries using subject fallbacks: ${err.message}`);
++ }
++ for (const e of chunk) {
++ const t = answer.get(String(e.id)) || tags.fallbackTag(e.subject);
++ n += (await q.tag.run(t.kind, t.areas, t.plain, nowMs(), answer.has(String(e.id)) ? 'ai' : 'subject-fallback', e.id)).changes;
}
}
stats.tagged += n;
ddiff --git a/server/changelog/tagger.js b/server/changelog/tagger.js
iindex 84f721b..a9a3056 100644
---- a/server/changelog/tagger.js
++++ b/server/changelog/tagger.js
@@@ -80,7 +80,7 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = con
if (!run || !['succeeded', 'cached'].includes(run.status)) {
throw Object.assign(new Error(`run ${run && run.id} ${run && run.status}`), { status: 502 });
}
-- return { output: run.output || {}, workflow: run.workflow || null, runId: run.id };
++ return { output: run.output || {}, workflow: run.workflow || null, runId: run.id, synthetic: Boolean(run.synthetic) };
}
/**
@@@ -103,6 +103,10 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = con
idempotency_key: `changelog-tags:${idempotency}`,
});
const out = new Map();
++ if (answer.synthetic) {
++ log.warn && log.warn('[changelog] AI returned synthetic output; use subject fallback');
++ return out;
++ }
for (const t of tagsFrom(answer.output)) {
const safe = normalizeTag(t);
if (safe && input.entries.some((e) => e.id === String(t.id))) out.set(String(t.id), safe);
ddiff --git a/server/changelog/tags.js b/server/changelog/tags.js
iindex bdbfba9..970078f 100644
---- a/server/changelog/tags.js
++++ b/server/changelog/tags.js
@@@ -105,6 +105,12 @@ function subjectAreas(subject) {
return AREA_IDS.filter((id) => new RegExp(`(^|[^a-z0-9])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`).test(s)).slice(0, 3);
}
++/** Useful public kind when the model cannot answer: obvious subjects keep their rule, the rest are Improved. */
++function fallbackTag(subject) {
++ const tag = initial(subject);
++ return { kind: tag.kind || 'better', areas: tag.areas.length ? tag.areas : subjectAreas(subject), plain: tag.plain };
++}
++
/**
* What is stored when an entry is first collected: plain words always, and kind internal for
* housekeeping. `kind: null` means the AI task has not answered yet.
@@@ -136,4 +142,4 @@ function vocabulary() {
return { kinds: [...KINDS], areas: AREAS.map((a) => ({ ...a })) };
}
--module.exports = { KINDS, AREAS, AREA_IDS, AREA_LABELS, vocabulary, isKind, isArea, prOf, plainOf, isHousekeeping, subjectKind, subjectAreas, initial, normalizeTag };
++module.exports = { KINDS, AREAS, AREA_IDS, AREA_LABELS, vocabulary, isKind, isArea, prOf, plainOf, isHousekeeping, subjectKind, subjectAreas, initial, fallbackTag, normalizeTag };
ddiff --git a/test/changelog-tags.test.js b/test/changelog-tags.test.js
iindex dbdfc34..530fb30 100644
---- a/test/changelog-tags.test.js
++++ b/test/changelog-tags.test.js
@@@ -64,6 +64,7 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
for (const subject of ['Add a watch page', 'feat: Add project sharing', 'Introduce saved searches']) assert.strictEqual(tags.initial(subject).kind, 'new', subject);
for (const subject of ['Fix the player controls', 'fix(chat): stop duplicate messages', 'No longer drops the last frame', 'Keep the image whole']) assert.strictEqual(tags.initial(subject).kind, 'fixed', subject);
assert.strictEqual(tags.initial('Improve the chat history').kind, null, 'ambiguous improvements are left to the AI task');
++ assert.deepStrictEqual(tags.fallbackTag('Improve the chat history'), { kind: 'better', areas: ['chat'], plain: 'Improve the chat history' }, 'ambiguous or unavailable AI output defaults to Improved');
});
await check('a model answer is made safe: a known kind, up to three known areas, plain words', async () => {
@@@ -118,6 +119,19 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
assert.strictEqual((await createChangelogTagger({ config: { aiUrl: '', networkInternalUrl: '', oauth: {}, changelog: { tag: true } }, fetchImpl }).tag([{ id: 1 }])).size, 0, 'unconfigured is off');
});
++ await check('synthetic AI output is discarded so it cannot overwrite changelog subjects', async () => {
++ const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
++ const fetchImpl = async (url, opts = {}) => {
++ if (String(url).includes('/oauth/token')) return await json({ access_token: 'svc', token_type: 'Bearer', expires_in: 300 });
++ if (String(url).includes('/api/v1/runs')) return await json({ run: { id: 'run_synth', status: 'succeeded', synthetic: true, output: { tags: [{ id: '7', kind: 'internal', areas: [], plain: 'synthetic' }] } } }, 201);
++ return await json({}, 404);
++ };
++ const tagger = createChangelogTagger({ config: {
++ aiUrl: 'http://ai.test', networkInternalUrl: 'http://network.test', oauth: { clientId: 'blog', clientSecret: 'shh' }, changelog: { tag: true },
++ }, fetchImpl, log: { warn() {} } });
++ assert.strictEqual((await tagger.tag([{ id: '7', service: 'blog', subject: 'Improve the page' }])).size, 0);
++ });
++
await check('the backfill script is safe by default: it prints what it would do and writes nothing', async () => {
const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'backfill-changelog-tags.js'), '--help'], { encoding: 'utf8' });
assert.match(out, /--apply/);