openvibers/openvibe.blog Commits
Fallback when changelog tagging is synthetic (#274)
Changes
@@@ -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`);@@@ -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;@@@ -80,7 +80,7 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = conif (!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 = conidempotency_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);@@@ -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 };@@@ -64,6 +64,7 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/changfor (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/changassert.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/);
@@ -66,11 +66,7 @@ async function main() {
@@ -66,11 +66,7 @@ async function main() {
return 0;
return 0;
}
}
const tagger = createChangelogTagger({ config, log: console });
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'}`);
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;
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;
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() {
@@ -79,13 +75,17 @@ async function main() {
try {
try {
const answer = await tagger.tag(batch);
const answer = await tagger.tag(batch);
for (const row of 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`);
console.log(`[backfill] tagged ${Math.min(i + chunk, needModel.length)}/${needModel.length} model entries`);
} catch (err) {
} 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`);
console.log(`[backfill] wrote ${tagged} of ${rows.length} entries`);
@@ -154,20 +154,20 @@ function createChangelog({ config, store, blogs, posts, reading = null, tagger =
@@ -154,20 +154,20 @@ function createChangelog({ config, store, blogs, posts, reading = null, tagger =
* hundreds of commits per site): the backfill tags it.
* hundreds of commits per site): the backfill tags it.
*/
*/
async function tagPending(list) {
async function tagPending(list) {
- if (!list.length || !tagger || !tagger.enabled) return 0;
+ if (!list.length) return 0;
let n = 0;
let n = 0;
for (let i = 0; i < list.length; i += TAG_BATCH) {
for (let i = 0; i < list.length; i += TAG_BATCH) {
const chunk = list.slice(i, i + TAG_BATCH);
const chunk = list.slice(i, i + TAG_BATCH);
+ let answer = new Map();
try {
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) {
} catch (err) {
stats.lastError = `tag: ${err.message}`;
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;
stats.tagged += n;
@@ -80,7 +80,7 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = con
@@ -80,7 +80,7 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = con
if (!run || !['succeeded', 'cached'].includes(run.status)) {
if (!run || !['succeeded', 'cached'].includes(run.status)) {
throw Object.assign(new Error(`run ${run && run.id} ${run && run.status}`), { status: 502 });
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
@@ -103,6 +103,10 @@ function createChangelogTagger({ config, fetchImpl = globalThis.fetch, log = con
idempotency_key: `changelog-tags:${idempotency}`,
idempotency_key: `changelog-tags:${idempotency}`,
});
});
const out = new Map();
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)) {
for (const t of tagsFrom(answer.output)) {
const safe = normalizeTag(t);
const safe = normalizeTag(t);
if (safe && input.entries.some((e) => e.id === String(t.id))) out.set(String(t.id), safe);
if (safe && input.entries.some((e) => e.id === String(t.id))) out.set(String(t.id), safe);
@@ -105,6 +105,12 @@ function subjectAreas(subject) {
@@ -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);
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
* 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.
* housekeeping. `kind: null` means the AI task has not answered yet.
@@ -136,4 +142,4 @@ function vocabulary() {
@@ -136,4 +142,4 @@ function vocabulary() {
return { kinds: [...KINDS], areas: AREAS.map((a) => ({ ...a })) };
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 };
@@ -64,6 +64,7 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
@@ -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 ['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);
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.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 () => {
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
@@ -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');
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 () => {
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' });
const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'backfill-changelog-tags.js'), '--help'], { encoding: 'utf8' });
assert.match(out, /--apply/);
assert.match(out, /--apply/);