openvibers/openvibe.publishing Commits

0.3.0: openvibe-publishing/ai, how a content product asks OpenVibe.AI for a draft (run a registered workflow with its service token, poll a slow run, read its citations, return the workflow/run record authorship needs; coded AiRunError, never a partial draft; one token retry; traceparent carried). test/ai.test.js

OP OpenVibers committed 24 Sept 2026 · 91cbe0b5c0 · 8 files, +192 −7


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e05d025..f6ece6b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 0.3.0 — 2026-09-24
++
++- `openvibe-publishing/ai`: how a content product asks OpenVibe.AI for a draft. `createAiClient({
++ baseUrl, tokenClient })` runs a registered workflow (`POST /api/v1/runs?wait=`, polling a run still
++ going), reads its citations, and returns the output with the `{ id, version, runId, model }` an
++ AI authorship record needs. Failures throw `AiRunError` with a code (`ai.not_configured`,
++ `ai.refused`, `ai.run_failed`, `ai.timeout`, `ai.unreachable`), never a partial draft; a refused
++ token is invalidated and retried once; a traceparent passed in goes along. Additive.
++
## 0.2.2 — 2026-09-24
- `openvibe-shared` v1.0.0 -> v1.5.1 (the runtime dependency; `seo` is unchanged, so consumers that
ddiff --git a/README.md b/README.md
iindex d666acc..65f0aff 100644
---- a/README.md
++++ b/README.md
@@@ -39,6 +39,7 @@ Every module is its own entry point and can be used alone.
| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |
| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |
| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |
++| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
ddiff --git a/index.js b/index.js
iindex c500a70..a0eaa7c 100644
---- a/index.js
++++ b/index.js
@@@ -2,7 +2,7 @@
/**
* openvibe-publishing — every module is also its own entry point and can be used alone:
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
-- * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr
++ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai
* This root export loads each one lazily, on first access.
*/
const MODULES = {
@@@ -16,6 +16,7 @@ const MODULES = {
authorship: './lib/authorship',
indexHooks: './lib/index-hooks',
ssr: './lib/ssr',
++ ai: './lib/ai',
};
const api = {};
ddiff --git a/lib/ai.js b/lib/ai.js
nnew file mode 100644
iindex 0000000..d20165a
---- /dev/null
++++ b/lib/ai.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * openvibe-publishing/ai — how a content product asks OpenVibe.AI for a draft (roadmap W13, §12.4):
++ * run one of its registered workflows (wiki.generate_page, blog.draft_post, reviews.summarize_entity,
++ * deals.enrich_deal, trade.summarize_market_context, …) with the product's service token, and get
++ * back the output, the run id and version for the authorship record, and the run's citations.
++ * The product then files the result as an AI-authored draft (openvibe-publishing/authorship): it is
++ * never published or indexed until a person reviews it.
++ *
++ * const { createAiClient } = require('openvibe-publishing/ai');
++ * const ai = createAiClient({ baseUrl: 'http://127.0.0.1:4700', tokenClient }); // audience openvibe.ai
++ * const r = await ai.run('blog.draft_post', { topic: 'Why we self-host' },
++ * { onBehalfOf: { type: 'user', id: subject }, target: { service: 'blog', type: 'blog', id: blog.id } });
++ * r.output the workflow's output (its output_schema)
++ * r.workflow { id, version, runId, model } for authorship.record({ mode: 'ai', workflow })
++ * r.synthetic true when a stub provider answered (the draft must say so)
++ * r.citations [{ url, title, snippet, … }] the run recorded
++ *
++ * POST {baseUrl}/api/v1/runs?wait=<ms> (capability ai.run.create in the product's namespace) and
++ * GET /api/v1/runs/:id/citations (ai.run.read). A run still going after `waitMs` is polled until
++ * `timeoutMs`. Every failure throws AiRunError with a code (ai.not_configured, ai.refused,
++ * ai.run_failed, ai.timeout, ai.unreachable) and never returns a partial draft. tokenClient is
++ * anything with authHeaders() (and optionally invalidate()): openvibe-contracts serviceAuth or
++ * openvibe-sdk/auth. The request's traceparent goes along when given.
++ */
++
++class AiRunError extends Error {
++ constructor(code, message, { status = 502, runId = null } = {}) {
++ super(message);
++ this.name = 'AiRunError';
++ this.code = code;
++ this.status = status;
++ this.runId = runId;
++ }
++}
++
++const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
++
++function createAiClient({ baseUrl, tokenClient = null, fetch: fetchImpl = globalThis.fetch, waitMs = 55_000, timeoutMs = 120_000, pollMs = 2000 } = {}) {
++ const base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : '';
++ const enabled = Boolean(base && tokenClient && typeof tokenClient.authHeaders === 'function');
++
++ async function call(method, path, body, { traceparent, retried = false } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method,
++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(traceparent ? { traceparent } : {}), ...(await tokenClient.authHeaders()) },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(Math.min(waitMs + 10_000, timeoutMs)),
++ });
++ } catch (err) {
++ throw new AiRunError(err && err.name === 'TimeoutError' ? 'ai.timeout' : 'ai.unreachable', `OpenVibe.AI could not be reached: ${err.message}`, { status: 503 });
++ }
++ if (res.status === 401 && !retried && typeof tokenClient.invalidate === 'function') {
++ tokenClient.invalidate();
++ return call(method, path, body, { traceparent, retried: true });
++ }
++ const json = await res.json().catch(() => ({}));
++ return { status: res.status, ok: res.ok, body: json };
++ }
++
++ function unwrap(runBody) {
++ const run = runBody && runBody.run ? runBody.run : runBody;
++ if (!run || !run.id) throw new AiRunError('ai.bad_answer', 'OpenVibe.AI answered without a run');
++ return run;
++ }
++
++ async function run(workflow, input = {}, { onBehalfOf = null, target = null, attribution = null, idempotencyKey = null, version = null, traceparent = null } = {}) {
++ if (!enabled) throw new AiRunError('ai.not_configured', 'OpenVibe.AI is not configured for this product (its URL or service secret is missing)', { status: 503 });
++ if (!WORKFLOW_RE.test(String(workflow || ''))) throw new AiRunError('ai.bad_workflow', `not a workflow key: ${workflow}`, { status: 400 });
++ const body = { workflow, input, ...(version ? { version } : {}), ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), ...(target ? { target } : {}), ...(attribution ? { attribution } : {}), ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) };
++ const created = await call('POST', `/api/v1/runs?wait=${Math.max(0, Math.min(waitMs, 60_000))}`, body, { traceparent });
++ if (!created.ok) {
++ const code = created.body && (created.body.code || created.body.error) || `http_${created.status}`;
++ throw new AiRunError(created.status === 403 || created.status === 401 ? 'ai.refused' : 'ai.run_failed', `OpenVibe.AI refused the run (${code}): ${(created.body && created.body.detail) || ''}`.trim(), { status: created.status >= 500 ? 502 : created.status });
++ }
++ let r = unwrap(created.body);
++ const deadline = Date.now() + timeoutMs;
++ while (r.status === 'queued' || r.status === 'running') {
++ if (Date.now() > deadline) throw new AiRunError('ai.timeout', `run ${r.id} did not finish in time`, { status: 504, runId: r.id });
++ await new Promise((ok) => setTimeout(ok, pollMs));
++ const got = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, { traceparent });
++ if (!got.ok) throw new AiRunError('ai.run_failed', `reading run ${r.id} failed (${got.status})`, { runId: r.id });
++ r = unwrap(got.body);
++ }
++ if (r.status !== 'succeeded' && r.status !== 'cached') {
++ const e = r.error || {};
++ throw new AiRunError('ai.run_failed', `run ${r.id} ${r.status}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}`, { runId: r.id });
++ }
++ let citations = [];
++ const c = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}/citations`, null, { traceparent });
++ if (c.ok && c.body && Array.isArray(c.body.citations)) citations = c.body.citations;
++ const prov = r.provenance || {};
++ return {
++ runId: r.id,
++ status: r.status,
++ output: r.output,
++ synthetic: Boolean(r.synthetic),
++ citations,
++ workflow: { id: (r.workflow && r.workflow.key) || workflow, version: r.workflow && r.workflow.version, runId: r.id, ...(prov.model ? { model: prov.model } : {}) },
++ };
++ }
++
++ return { enabled, run };
++}
++
++module.exports = { createAiClient, AiRunError };
ddiff --git a/package-lock.json b/package-lock.json
iindex c850841..f7e8090 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-publishing",
-- "version": "0.2.2",
++ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "0.2.2",
++ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
ddiff --git a/package.json b/package.json
iindex 616b5a5..b9d9ecb 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-publishing",
-- "version": "0.2.2",
++ "version": "0.3.0",
"description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
"main": "index.js",
"exports": {
@@@ -15,7 +15,8 @@
"./seo": "./lib/seo.js",
"./authorship": "./lib/authorship.js",
"./index-hooks": "./lib/index-hooks.js",
-- "./ssr": "./lib/ssr.js"
++ "./ssr": "./lib/ssr.js",
++ "./ai": "./lib/ai.js"
},
"files": [
"index.js",
ddiff --git a/test/ai.test.js b/test/ai.test.js
nnew file mode 100644
iindex 0000000..4b34013
---- /dev/null
++++ b/test/ai.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// openvibe-publishing/ai: a product asks OpenVibe.AI for a draft with its service token; a slow run is
++// polled, failures throw coded errors and never a partial draft, and the authorship workflow is filled.
++const assert = require('assert');
++const { createAiClient, AiRunError } = require('../lib/ai');
++
++const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++function stub(script) {
++ const calls = [];
++ const fetchImpl = async (url, opts) => {
++ const u = new URL(url);
++ calls.push({ method: opts.method, path: u.pathname, search: u.search, auth: opts.headers.Authorization, tp: opts.headers.traceparent || null, body: opts.body ? JSON.parse(opts.body) : null });
++ const step = script(u, opts, calls.length);
++ return { status: step.status, ok: step.status < 300, json: async () => step.body };
++ };
++ return { calls, fetchImpl };
++}
++const done = (status = 'succeeded') => ({ run: { id: RUN, status, workflow: { key: 'blog.draft_post', version: 2 }, output: { title: 'T', body_markdown: 'B' }, synthetic: false, provenance: { model: 'm-1' } } });
++const tokens = { n: 0, invalidated: 0, async authHeaders() { return { Authorization: `Bearer t${++this.n}` }; }, invalidate() { this.invalidated++; } };
++
++(async () => {
++ let n = 0; const ok = () => n++;
++
++ { // finished within wait
++ const s = stub((u) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [{ url: 'https://example.org/a', title: 'A' }] } } : { status: 201, body: done() }));
++ const ai = createAiClient({ baseUrl: 'http://ai.test/', tokenClient: tokens, fetch: s.fetchImpl });
++ const r = await ai.run('blog.draft_post', { topic: 'x' }, { onBehalfOf: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' }, traceparent: '00-' + 'a'.repeat(32) + '-' + 'b'.repeat(16) + '-01' });
++ assert.deepStrictEqual(r.workflow, { id: 'blog.draft_post', version: 2, runId: RUN, model: 'm-1' });
++ assert.deepStrictEqual(r.output, { title: 'T', body_markdown: 'B' });
++ assert.strictEqual(r.citations.length, 1);
++ assert.deepStrictEqual(s.calls[0].body, { workflow: 'blog.draft_post', input: { topic: 'x' }, on_behalf_of: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' } });
++ assert.ok(/^\?wait=\d+$/.test(s.calls[0].search));
++ assert.ok(s.calls.every((c) => c.tp && c.auth), 'trace and token on every call');
++ ok();
++ }
++ { // still running: polled until done
++ const s = stub((u, _o, i) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : i === 1 ? { status: 202, body: { run: { id: RUN, status: 'running' } } } : { status: 200, body: done() }));
++ const r = await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl, pollMs: 5 }).run('blog.draft_post', {});
++ assert.strictEqual(r.status, 'succeeded');
++ assert.deepStrictEqual(s.calls.map((c) => c.method + ' ' + c.path), ['POST /api/v1/runs', `GET /api/v1/runs/${RUN}`, `GET /api/v1/runs/${RUN}/citations`]);
++ ok();
++ }
++ { // failures are coded errors
++ const cases = [
++ [() => ({ status: 403, body: { code: 'capability.denied' } }), 'ai.refused'],
++ [() => ({ status: 201, body: { run: { id: RUN, status: 'failed', error: { code: 'provider.unavailable', detail: 'x' } } } }), 'ai.run_failed'],
++ [() => { throw new Error('ECONNREFUSED'); }, 'ai.unreachable'],
++ ];
++ for (const [script, code] of cases) {
++ const fetchImpl = async () => { const st = script(); return { status: st.status, ok: st.status < 300, json: async () => st.body }; };
++ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: fetchImpl }).run('blog.draft_post', {}), (e) => e instanceof AiRunError && e.code === code, code);
++ }
++ await assert.rejects(createAiClient({ baseUrl: '', tokenClient: tokens }).run('blog.draft_post', {}), (e) => e.code === 'ai.not_configured');
++ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: async () => ({}) }).run('../evil', {}), (e) => e.code === 'ai.bad_workflow');
++ ok();
++ }
++ { // a refused token is invalidated and retried once
++ const before = tokens.invalidated;
++ const s = stub((u, _o, i) => (i === 1 ? { status: 401, body: {} } : u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : { status: 201, body: done() }));
++ await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl }).run('blog.draft_post', {});
++ assert.strictEqual(tokens.invalidated, before + 1);
++ ok();
++ }
++ console.log(`ai: ${n} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 7494b36..835da96 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
test('every subpath export resolves to an existing module', () => {
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
-- ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
++ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
});
@@@ -36,7 +36,7 @@ test('the root export is lazy and exposes every module', () => {
assert.strictEqual(out.before, 0);
assert.ok(out.after > 0);
assert.strictEqual(out.v, pkg.version);
-- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
});
test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {