openvibers/openvibe.ai Commits

Add blog.changelog.tag: tag shipped changes for the updates feed (#407)

OP OpenVibers committed 4 days ago · 13ffb0f203 · 6 files, +79 −4

* Add blog.changelog.tag: tag shipped changes for the updates feed

One run for a batch of commit subjects: kind (new, better, fixed,
internal), one to three areas from the fixed list of 30, and the plain
words without the PR number or stage tag. Blog calls it through the AI
task blog.changelog.tag; housekeeping never reaches it.

* Merge remote-tracking branch 'origin/main' into rc-ai-changelog-tag

* Point blog.changelog.tag at its workflow, cheapest by default

The task now runs blog.changelog.tag (not generic ai.generate), and its
seeded owner default is openvibe/cheapest signed out and in, with a
$0.10 per-batch ceiling. The owner can change it on Exchange.

* Merge origin/main into rc-ai-changelog-tag

Changes

ddiff --git a/docs/task-choices.md b/docs/task-choices.md
iindex 3ce9fd5..8136fb4 100644
---- a/docs/task-choices.md
++++ b/docs/task-choices.md
@@@ -12,4 +12,4 @@
Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.
--The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.
++The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Blog's `blog.changelog.tag` task runs the `blog.changelog.tag` workflow on the cheapest good model signed out and in, with a $0.10 per-batch ceiling; the owner can change it like any other task. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.
ddiff --git a/server/choices/index.js b/server/choices/index.js
iindex ba599e3..1fbb4d6 100644
---- a/server/choices/index.js
++++ b/server/choices/index.js
@@@ -30,7 +30,7 @@ const SEEDS = [
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
-- ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'ai.generate'],
++ ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
];
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
@@@ -110,6 +110,10 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
// signed out the cheapest good model, signed in Claude.
await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
.run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));
++ // Blog's changelog tagger runs on every batch of collected commits: the cheapest good model, signed out
++ // and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.
++ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
++ .run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));
}
async function register(body, service) {
ddiff --git a/server/workflows/changelog.js b/server/workflows/changelog.js
nnew file mode 100644
iindex 0000000..2c5119a
---- /dev/null
++++ b/server/workflows/changelog.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * The network changelog's tagger (rich-content plan §9): one run reads a batch of shipped commit
++ * subjects and answers, for each, what kind of change it is, one to three areas from OpenVibe's
++ * fixed list and the subject in plain words (no PR number, no stage tag). OpenVibe.Blog calls it
++ * through the AI task `blog.changelog.tag`, so the owner's choice for that task decides the model
++ * (the cheapest good one by default) and the person can change it on Exchange.
++ *
++ * Housekeeping never reaches this workflow: Blog classifies library pins, merges and test-only,
++ * docs-only or CI-only commits as internal itself (server/changelog/tags.js).
++ */
++const STR = (n) => ({ type: 'string', maxLength: n });
++const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, properties: props, required });
++const AREAS = [
++ 'phone', 'chat', 'go-live', 'video', 'menu', 'accessibility', 'speed', 'sign-in', 'wallet', 'ai',
++ 'search', 'files', 'pictures', 'games', 'design', 'developer', 'machines', 'money', 'privacy', 'moderation',
++ 'notifications', 'feed', 'wiki', 'blog', 'community', 'meme', 'shop', 'tools', 'exchange', 'git',
++];
++const KINDS = ['new', 'better', 'fixed', 'internal'];
++
++const ENTRY = OBJ({
++ id: STR(80),
++ service: STR(40),
++ subject: STR(300),
++ repo: STR(200),
++ plain: STR(240),
++}, ['id', 'service', 'subject']);
++
++const template = {
++ key: 'blog.changelog.tag',
++ name: 'Blog: tag a shipped change',
++ owner: 'blog',
++ visibility: 'internal',
++ description: 'Tag shipped commit subjects for the OpenVibe changelog: kind, areas and plain words.',
++ input_schema: OBJ({ entries: { type: 'array', minItems: 1, maxItems: 25, items: ENTRY } }, ['entries']),
++ output_schema: OBJ({ tags: { type: 'array', maxItems: 25, items: OBJ({
++ id: STR(80),
++ kind: { enum: KINDS },
++ areas: { type: 'array', minItems: 1, maxItems: 3, uniqueItems: true, items: { enum: AREAS } },
++ plain: STR(240),
++ }, ['id', 'kind', 'areas', 'plain']) } }, ['tags']),
++ system_prompt: `You tag the changes OpenVibe ships, for the public "recent changes" feed. For every entry, answer:
++- kind: "new" when people can now do something they could not before; "better" when an existing thing was improved; "fixed" when a defect was repaired; "internal" only for housekeeping a person would not notice (never for a feature or a fix).
++- areas: one to three of exactly these ids, the parts of the product the change touches: ${AREAS.join(', ')}. Choose the closest; never invent one. A change can be about one area only.
++- plain: the subject in plain words a member would understand, without the pull request number ("(#714)"), without a stage tag ("[A6]", "[C2]"), without a version prefix and without hype. Keep the names, numbers and facts the subject states; add none. At most 240 characters, one line.
++Answer with JSON only: {"tags":[{"id":"<the entry's id>","kind":"...","areas":["..."],"plain":"..."}]}, one object per entry, in the same order. Never drop or add an entry.`,
++ user_prompt: 'Entries to tag:\n{{json entries}}\n\nAnswer with the JSON object only.',
++ default_route: 'default.json',
++ metadata: { json_name: 'tags' },
++};
++
++const workflow = {
++ key: 'blog.changelog.tag',
++ name: 'Tag a batch of shipped changes',
++ namespace: 'blog',
++ owner: 'blog',
++ description: 'Tag up to 25 shipped commit subjects with a kind, one to three areas and plain words.',
++ input_schema: template.input_schema,
++ output_schema: template.output_schema,
++ default_route: 'default.json',
++ steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],
++};
++
++module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex f59005f..ea0d9df 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -23,6 +23,7 @@ const devblog = require('./devblog');
const chat = require('./chat');
const research = require('./research');
const meme = require('./meme');
++const changelog = require('./changelog');
const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
@@@ -259,11 +260,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates]) {
++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates]) {
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows]) {
++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows]) {
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
ddiff --git a/test/task-choices.test.js b/test/task-choices.test.js
iindex 62d587c..44a4afc 100644
---- a/test/task-choices.test.js
++++ b/test/task-choices.test.js
@@@ -11,11 +11,13 @@ const c=model=>({model,source:'openvibe'});
t.test('catalogue, personal isolation and admin role gate',async()=>{
h=await boot({dir:tmpDir()});
let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));
++ const changelogTask=r.body.tasks.find(t=>t.name==='blog.changelog.tag');assert.equal(changelogTask.workflow,'blog.changelog.tag');
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');
++ assert.equal(r.body.defaults['blog.changelog.tag'].signed_out.model,'openvibe/cheapest');assert.equal(r.body.defaults['blog.changelog.tag'].signed_in.model,'openvibe/cheapest');
r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);
});
t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 06c5f1e..8134fec 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -15,6 +15,10 @@ const SRC = (n) => Array.from({ length: n }, (_, i) => ({ source_type: 'news.art
const PASS = { role: 'chat', kind: 'chat_global', system: [{ text: 'You summarize chat.', cache: true }], user: 'Summarize: hi all', max_tokens: 80 };
const EXAMPLES = {
++ 'blog.changelog.tag': { entries: [
++ { id: '1', service: 'live', subject: 'Recent changes card: show the newest devblog with its art (#714)' },
++ { id: '2', service: 'chat', subject: 'Fix scroll jump when a message arrives while reading history (#361)' },
++ ] },
'content.devblog': require('./fixtures/devblog').input,
'meme.caption': { format: 'Two choices', topic: 'choosing whether to fix the bug before lunch' },
'ai.messages': { max_tokens: 64, messages: [{ role: 'user', content: 'Say hello' }] },