openvibers/openvibe.wiki Commits

Wiki: article pictures through the shared image task (#273)

OP OpenVibers committed 3 days ago · 8afc816d5d · 7 files, +126 −107

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e4cb7df..94b6cef 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
## Unreleased
++- Pictures come from OpenVibe.AI's one shared image task (`image.picture`): `POST /api/v1/pages/:id/images/suggest`
++ asks its find mode once (Commons/Openverse, licence and author kept) and gets Media references in the wiki tenant
++ rather than external URLs; its make mode draws an illustration only when nothing real exists, never for a real
++ person, and labels it "Illustration made with <model>". The "Vibe a wiki" writer uses the same find mode instead of
++ fetching and uploading pictures itself. `server/wiki/images.js`, `server/wiki/vibe.js`; tests `wiki-rich`, `vibe`.
++
- Rich wiki articles (rich-content plan W1): a body is Markdown plus `openvibe-publishing/blocks` (infobox, figure, gallery, timeline, quote, callout, stat, …), rendered server-side by `server/render/blocks.js` in the article, the editor's preview, the `.json` body and the Markdown twin; a body with no blocks renders as before. The API takes `blocks` on create and revision writes, serializes them to the Markdown a revision keeps, and returns `blocks` next to `body_markdown` (`?format=blocks` for the tree alone). Pins `openvibe-publishing` v1.6.1. `test/wiki-rich.test.js`.
- The image task: `POST /api/v1/pages/:id/images/suggest` asks `wiki.article.images` for the lead picture and figures — Commons and Openverse first with the licence and author kept, then OpenVibe.Pics and the page's Media; an AI illustration only when nothing real exists and never for an article about a real person ("Illustration made with <model>"). `server/wiki/images.js`.
- The wiki writer: `POST /api/v1/pages/:id/draft` runs OpenVibe.AI's `content.wiki` workflow (web search plus the article's own sources, a citation on every sentence, the infobox from structured facts) and files the revision as a pending proposal. It never publishes. `server/wiki/writer.js`.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a797bf3..4e4ca2b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -19,7 +19,7 @@
* GET /pages/:id/revisions wiki.page.read history (?before=&limit=)
* POST /pages/:id/revisions wiki.page.create { expected_revision, body?, blocks?, title?, summary?, infobox?, citations?, keep_citations?, message? }
* GET /pages/:id/revisions/:n wiki.page.read
-- * POST /pages/:id/images/suggest wiki.page.create { query?, section?, count?, allow_ai?, choice? } the wiki.article.images task
++ * POST /pages/:id/images/suggest wiki.page.create { query?, section?, count?, allow_ai?, choice? } the shared image.picture task
* POST /pages/:id/draft wiki.page.create { instruction?, choice? } content.wiki → a pending proposal
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 37ce844..408a189 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -226,8 +226,8 @@ route('post', '/pages/{id}/media/verify', {
responses: { 200: json('One result per attachment', { type: 'object', properties: { results: arrayOf({ type: 'object' }) } }), ...errors() },
});
route('post', '/pages/{id}/images/suggest', {
-- operationId: 'suggestImages', summary: 'Pictures for an article, from the wiki.article.images task', tags: ['media'], security: PERSON, parameters: [PAGE],
-- description: 'wiki.page.create. Wiki editors only. Commons and Openverse first, with licence and author; OpenVibe.Pics and the page\'s Media next; an AI illustration only when nothing real exists and the article is not about a real person.',
++ operationId: 'suggestImages', summary: 'Pictures for an article, from the shared image.picture task', tags: ['media'], security: PERSON, parameters: [PAGE],
++ description: 'wiki.page.create. Wiki editors only. Commons and Openverse first, through OpenVibe.AI\'s image.picture task and stored in OpenVibe.Media with licence and author; OpenVibe.Pics and the page\'s Media next; an AI illustration only when nothing real exists, only after the owner configures an image model, and never for an article about a real person.',
requestBody: body({ type: 'object', properties: {
query: nullable('string', { maxLength: 300 }), section: nullable('string', { maxLength: 200 }),
count: { type: 'integer', minimum: 1, maximum: 12 }, allow_ai: { type: 'boolean', default: true },
ddiff --git a/server/wiki/images.js b/server/wiki/images.js
iindex 5254d01..fac2763 100644
---- a/server/wiki/images.js
++++ b/server/wiki/images.js
@@@ -1,22 +1,22 @@
'use strict';
/**
-- * wiki.images.suggest — the article's lead picture and figures, from the wiki.article.images AI task.
++ * wiki.images.suggest — the article's lead picture and figures, from the shared AI image task
++ * (image.picture, rich-content plan §4/§5).
*
-- * The order is the plan's: Wikimedia Commons and Openverse first, with the licence and the author kept;
-- * OpenVibe.Pics' public gallery next; the page's own Media after that. An AI illustration is made only
-- * when nothing real exists, is labelled "Illustration made with <model>", and is never made for an
-- * article about a real person. A suggestion is a candidate, not an attachment: the editor picks one,
-- * it goes to Media, and only then does it become an attachment.
++ * Find: Wikimedia Commons and Openverse first, with the licence and the author kept. The task stores each
++ * picture in OpenVibe.Media (the wiki tenant), so a suggestion already names its Media object instead of an
++ * external URL. OpenVibe.Pics' public gallery and the page's own Media come next. Make: an AI illustration
++ * only when nothing real exists, labelled "Illustration made with <model>", and never for an article about a
++ * real person. A suggestion is a candidate, not an attachment: the editor picks one and attaches it.
*/
--const { IntegrationError } = require('../integrations/platform');
--
--const TASK = 'wiki.article.images';
++const TASK = 'image.picture';
const PROVIDERS = ['commons', 'openverse'];
const PERSON_KEYS = /^(born|died|death|birth|date_of_birth|place_of_birth|birth_?(date|place|name)|occupation|nationality|citizenship|spouse|partner|children|parents|mother|father|alma_mater|education|known_for|height|position|office|party|voiced_by|portrayed_by|author|director|actor|actress|singer|musician|writer|artist|politician|person)$/;
const PERSON_TEXT = /\b(was born|is an? (actor|actress|singer|musician|writer|politician|artist|director|author|person|human)|birth name|date of birth|grew up in)\b/i;
const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const creditOf = (img) => [oneLine(img.author, 120), oneLine(img.license, 80)].filter(Boolean).join(', ');
++const sourceOf = (provider) => (String(provider || '').toLowerCase().startsWith('openverse') ? 'openverse' : 'commons');
/** True when the page is about a real person: then no AI illustration is ever suggested. */
function aboutPerson(v) {
@@@ -26,25 +26,26 @@ function aboutPerson(v) {
return PERSON_TEXT.test(text);
}
--function fromResearch(images, limit) {
++/** The image task's Media references -> the suggestions the editor already knows. */
++function fromTask(media, limit) {
const out = [];
const seen = new Set();
-- for (const img of Array.isArray(images) ? images : []) {
-- if (!img || !img.url || img.free === false) continue;
-- const provider = String(img.provider || '').toLowerCase() === 'openverse' ? 'openverse' : 'commons';
-- if (!PROVIDERS.includes(provider)) continue;
-- const key = img.source_url || img.url;
++ for (const m of Array.isArray(media) ? media : []) {
++ if (!m || !m.media_id) continue;
++ const ai = m.ai === true;
++ const key = m.media_id;
if (seen.has(key)) continue;
seen.add(key);
out.push({
-- provider, role: out.length ? 'figure' : 'lead',
-- url: img.url, page_url: img.source_url || null,
-- title: oneLine(img.title, 300) || null, author: oneLine(img.author, 200) || null,
-- license: oneLine(img.license, 200) || null, license_url: img.license_url || null,
-- width: Number.isInteger(img.width) ? img.width : null, height: Number.isInteger(img.height) ? img.height : null,
-- alt: oneLine(img.description || img.title, 400) || null,
-- caption: oneLine(img.description || img.title, 400) || null,
-- credit: creditOf(img) || null, ai: false,
++ provider: ai ? 'ai' : sourceOf(m.provider), role: out.length ? 'figure' : 'lead',
++ media_id: m.media_id, url: m.url || null, page_url: ai ? null : (m.source_url || null),
++ title: oneLine(m.title, 300) || null, author: oneLine(m.author, 200) || null,
++ license: oneLine(m.license, 200) || null, license_url: m.license_url || null,
++ width: Number.isInteger(m.width) ? m.width : null, height: Number.isInteger(m.height) ? m.height : null,
++ alt: oneLine(m.alt || m.title, 400) || null,
++ caption: oneLine(m.caption || m.title, 500) || null,
++ credit: oneLine(m.credit, 300) || creditOf(m) || null,
++ ai, model: m.model || null, data_url: null,
});
if (out.length >= limit) break;
}
@@@ -75,22 +76,9 @@ function fromAttachments(v, mediaUrl) {
}));
}
--/** One run of the image task; an AI without the task yet answers 404 and the research workflow is used instead. */
--async function runTask(ctx, query, { choice = null, subject = null, limit = 8, provider = null } = {}) {
-- const input = { query, images: true, max_images: Math.max(4, limit), content_chars: 800 };
-- if (provider) {
-- // The task's choice names one engine; the plan asks Commons and Openverse, so each call names its own.
-- input.providers = [provider];
-- choice = { model: `search/${provider}`, source: 'openvibe' };
-- }
-- try {
-- return { ...(await ctx.platform.ai.runTask(TASK, input, { subject, choice })), task: TASK };
-- } catch (err) {
-- if (!(err instanceof IntegrationError) || !(err.status === 404 || err.status === 422 || err.code === 'task.not_found')) throw err;
-- // Before the AI choices system, the same task is the research workflow with both free picture sources.
-- const run = await ctx.platform.ai.run('research.gather', { ...input, providers: PROVIDERS }, { subject });
-- return { ...run, task: TASK };
-- }
++/** One run of the shared image task; the task stores the picture in Media for the wiki tenant. */
++async function runTask(ctx, input, { subject = null } = {}) {
++ return { ...(await ctx.platform.ai.runTask(TASK, input, { subject })), task: TASK };
}
async function suggest(ctx, pageId, body = {}, actor) {
@@@ -112,23 +100,24 @@ async function suggest(ctx, pageId, body = {}, actor) {
let model = null;
if (ctx.platform.ai.configured) {
-- const found = [];
-- // Commons and Openverse, both: the task's choice names one engine, so ask each in turn.
-- for (const provider of PROVIDERS) {
-- try {
-- const run = await runTask(ctx, query, { subject: actor && actor.subject, choice: body.choice, limit, provider: provider === 'openverse' ? 'openverse' : 'commons' });
-- runId = run.runId || runId;
-- found.push(...fromResearch((run.output && run.output.images) || [], limit));
-- } catch (err) {
-- ctx.log && ctx.log.warn && ctx.log.warn(`[Wiki] images ${pageId}: ${provider}: ${err && err.message}`);
-- }
++ // Find: Commons and Openverse through the one task, with the owner's engine order and guest rule.
++ try {
++ const run = await runTask(ctx, {
++ mode: 'find', query, count: limit, media_app: 'wiki',
++ ...(Array.isArray(body.providers) && body.providers.length ? { providers: body.providers.filter((p) => PROVIDERS.includes(p)) } : {}),
++ }, { subject: actor && actor.subject });
++ runId = run.runId || runId;
++ suggestions.push(...fromTask((run.output && run.output.media) || [], limit));
++ note = (run.output && run.output.note) || null;
++ } catch (err) {
++ ctx.log && ctx.log.warn && ctx.log.warn(`[Wiki] images ${pageId}: ${err && err.message}`);
++ note = 'The image task could not find a licensed picture.';
}
-- suggestions.push(...found);
} else {
note = 'OpenVibe.AI is not configured here: only Pics and the page\'s own Media are suggested.';
}
-- const seen = new Set(suggestions.map((s) => s.page_url || s.url));
++ const seen = new Set(suggestions.map((s) => s.media_id || s.page_url || s.url));
if (ctx.platform.pics.configured && suggestions.length < limit) {
try {
const pictures = await ctx.platform.pics.search(query, { limit: limit - suggestions.length });
@@@ -142,34 +131,31 @@ async function suggest(ctx, pageId, body = {}, actor) {
}
}
for (const a of fromAttachments(v, ctx.platform.media.publicUrl)) {
-- if (seen.has(a.page_url || a.url)) continue;
-- seen.add(a.page_url || a.url);
++ if (seen.has(a.media_id || a.page_url || a.url)) continue;
++ seen.add(a.media_id || a.page_url || a.url);
suggestions.push(a);
}
-- // An AI illustration only when nothing real exists, and never of a real person.
++ // An AI illustration only when nothing real exists, and never of a real person. The shared task's make
++ // mode is off until the owner picks an image model, so an unconfigured model answers 503 and is reported.
if (!suggestions.length && person) {
note = 'This article is about a real person: no AI illustration is suggested.';
} else if (!suggestions.length && body.allow_ai !== false && ctx.platform.ai.configured) {
try {
-- const run = await ctx.platform.ai.run('ai.image', {
++ const run = await runTask(ctx, {
++ mode: 'make', media_app: 'wiki', n: 1,
prompt: `A plain editorial illustration for a wiki article titled "${oneLine(v.revision.fields.title, 200)}". ${oneLine(v.revision.fields.summary, 300)} No people, no faces, no real person, no text.`,
-- n: 1,
}, { subject: actor && actor.subject });
-- model = run.model || null;
-- const image = run.output && Array.isArray(run.output.images) ? run.output.images[0] : null;
-- if (image) {
-- const label = model || 'AI';
-- suggestions.push({
-- provider: 'ai', role: 'lead', url: image.url || null, page_url: null,
-- title: `Illustration for ${oneLine(v.revision.fields.title, 200)}`, author: null,
-- license: null, license_url: null, width: null, height: null,
-- alt: `An illustration for the article ${oneLine(v.revision.fields.title, 200)}`,
-- caption: `Illustration for ${oneLine(v.revision.fields.title, 200)}`,
-- credit: `Illustration made with ${label}`, ai: true, model,
-- data_url: image.b64 ? `data:image/png;base64,${image.b64}` : null,
-- });
-- runId = run.runId || runId;
++ runId = run.runId || runId;
++ const made = fromTask((run.output && run.output.media) || [], 1);
++ if (made.length) {
++ model = made[0].model || null;
++ made[0].role = 'lead';
++ made[0].caption = `Illustration for ${oneLine(v.revision.fields.title, 200)} (Illustration made with ${model || 'AI'})`;
++ made[0].credit = `Illustration made with ${model || 'AI'}`;
++ suggestions.push(made[0]);
++ } else {
++ note = 'No illustration could be made.';
}
} catch (err) {
note = `No illustration could be made: ${oneLine(err && err.message, 200)}`;
@@@ -184,4 +170,4 @@ async function suggest(ctx, pageId, body = {}, actor) {
};
}
--module.exports = { suggest, aboutPerson, TASK, PROVIDERS };
++module.exports = { suggest, aboutPerson, fromTask, TASK, PROVIDERS };
ddiff --git a/server/wiki/vibe.js b/server/wiki/vibe.js
iindex 2d15b6b..4be287f 100644
---- a/server/wiki/vibe.js
++++ b/server/wiki/vibe.js
@@@ -7,9 +7,9 @@
* search, Commons, Openverse) for the topic as a whole
* 2. plan wiki.generate_space: the wiki's name, summary and pages (main page first), from that research
* 3. write for each page, its own research (the page's subject inside the topic) and wiki.generate_page; the
-- * pictures it picks are fetched safely (integrations/image-fetch.js), stored in OpenVibe.Media for the
-- * person (public, with where they came from), placed in the text and attached; the page is imported
-- * like any AI-assisted page (published with the disclosure, noindex until a person reviews it)
++ * pictures come from the shared image task (image.picture), already in OpenVibe.Media with their
++ * licence and credit, and are placed in the text and attached; the page is imported like any
++ * AI-assisted page (published with the disclosure, noindex until a person reviews it)
*
* The wiki's pages say the text was written with AI and cite every source they rest on, pictures included (licence and
* credit). Nothing is invented: a page the sources cannot support is not written (it stays a red link to create).
@@@ -22,7 +22,6 @@ const crypto = require('crypto');
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const importer = require('./import');
--const { fetchImage } = require('../integrations/image-fetch');
const SIZES = ['quick', 'standard', 'big'];
const ACTIVE = ['queued', 'researching', 'planning', 'writing'];
@@@ -146,7 +145,7 @@ function contentsOf(plan) {
return `## In this wiki\n\n${blocks.join('\n\n')}`;
}
--function createVibes({ db, svc, platform, config, now = () => Date.now(), log = console, fetchImg = fetchImage }) {
++function createVibes({ db, svc, platform, config, now = () => Date.now(), log = console }) {
const cfg = config.vibe || {};
const pagesFor = (size) => (cfg.pages && cfg.pages[size]) || { quick: 6, standard: 12, big: 24 }[size] || 12;
const owner = `wiki-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
@@@ -213,7 +212,7 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
return {
id: v.id, topic: v.topic, size: v.size, status: v.status,
space: { slug: space.slug, name: space.name, url: `/s/${encodeURIComponent(space.slug)}`, visibility: space.visibility },
-- research: progress.research ? { sources: progress.research.sources.length, images: progress.research.images.length, from: sourceNames(progress.research.from), topic_wiki: progress.research.topic_wiki || null } : null,
++ research: progress.research ? { sources: progress.research.sources.length, images: Object.values(progress.pictures || {}).reduce((n, list) => n + (Array.isArray(list) ? list.length : 0), 0), from: sourceNames(progress.research.from), topic_wiki: progress.research.topic_wiki || null } : null,
pages: list,
done: list.filter((p) => p.status === 'done').length,
total: list.length,
@@@ -296,11 +295,11 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
// 1. Research the topic.
if (!v.progress.research) {
await save(v, { status: 'researching', error: null });
-- const r = await ai('research.gather', { query: v.topic, topic: v.topic, lang: v.lang, max_sources: 14, max_images: 16, content_chars: 4500 }, 'research');
++ const r = await ai('research.gather', { query: v.topic, topic: v.topic, lang: v.lang, max_sources: 14, images: false, content_chars: 4500 }, 'research');
const out = r.output || {};
if (!out.sources || !out.sources.length) throw new VibeError(422, 'vibe.nothing_found', `Nothing could be found about "${v.topic}". Try its full name or another spelling.`);
v.progress.research = {
-- sources: out.sources, images: out.images || [], facts: out.facts || [], topic_wiki: out.topic_wiki || null, run: r.runId,
++ sources: out.sources, facts: out.facts || [], topic_wiki: out.topic_wiki || null, run: r.runId,
from: Object.entries(out.providers || {}).filter(([, p]) => p && p.ok && p.count).map(([k]) => k),
};
await save(v);
@@@ -366,42 +365,42 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
// The page's own research (the main page reuses the topic's).
let research = v.progress.research;
if (!isMain) {
-- const r = await ai('research.gather', { query: p.query || `${p.title} ${v.topic}`, topic: v.topic, focus: p.title, lang: v.lang, max_sources: 8, max_images: 10, content_chars: 4000 }, `research:${p.slug}`);
-- research = r.output || { sources: [], images: [] };
++ const r = await ai('research.gather', { query: p.query || `${p.title} ${v.topic}`, topic: v.topic, focus: p.title, lang: v.lang, max_sources: 8, images: false, content_chars: 4000 }, `research:${p.slug}`);
++ research = r.output || { sources: [] };
// The topic's best source helps every page: its facts and names.
const lead = v.progress.research.sources[0];
if (lead && !research.sources.some((s) => s.url === lead.url) && research.sources.length < 10) research = { ...research, sources: [...research.sources, lead] };
}
const sources = (research.sources || []).slice(0, 10).map((s) => sourceForAi(s, isMain ? 4000 : 3500));
if (!sources.length) throw new VibeError(422, 'vibe.page_no_sources', 'nothing was found about it');
-- const pictures = (research.images || []).slice(0, 12);
++ // Pictures: the shared image task finds licensed Commons/Openverse pictures and stores them in Media
++ // (the wiki tenant), so the page names a Media object directly. Cached per page for a resumed job.
++ v.progress.pictures = v.progress.pictures || {};
++ let pictures = v.progress.pictures[p.slug];
++ if (!pictures) {
++ const found = await ai('image.picture', { mode: 'find', media_app: 'wiki', query: p.query || `${p.title} ${v.topic}`, count: 12 }, `pictures:${p.slug}`);
++ pictures = (found.output && found.output.media) || [];
++ v.progress.pictures[p.slug] = pictures;
++ await save(v);
++ }
++ pictures = pictures.slice(0, 12);
const r = await ai('wiki.generate_page', {
title: p.title, topic: v.plan.title || v.topic, space: v.plan.title || v.topic, outline: p.outline, pages: titles,
-- images: pictures.map((im) => ({ title: oneLine(im.title, 300) || 'Picture', description: oneLine([im.description, im.license ? `(${im.license})` : ''].filter(Boolean).join(' '), 400) })),
++ images: pictures.map((im) => ({ title: oneLine(im.title || im.alt, 300) || 'Picture', description: oneLine([im.caption || im.alt, im.license ? `(${im.license})` : ''].filter(Boolean).join(' '), 400) })),
sources,
}, `page:${p.slug}`);
const out = r.output || {};
-- // Pictures: fetched, stored in Media for the person, placed by heading.
++ // Pictures: already Media references, placed by heading with their licence and credit.
const placed = new Map();
const usedPictures = [];
for (const pick of (Array.isArray(out.images) ? out.images : []).slice(0, MAX_IMAGES_PER_PAGE)) {
const img = Number.isInteger(pick.index) ? pictures[pick.index] : null;
-- if (!img || usedPictures.includes(img)) continue;
++ if (!img || !img.media_id || usedPictures.includes(img.media_id)) continue;
const key = /^lead$/i.test(String(pick.section || '').trim()) || !pick.section ? 'lead' : oneLine(pick.section, 120).replace(/^#+\s*/, '').toLowerCase();
if (placed.has(key)) continue;
-- try {
-- const got = await fetchImg(img.url, { timeoutMs: 15000 });
-- const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' }[got.mime];
-- const mediaId = await platform.media.upload({
-- bytes: got.bytes, mime: got.mime, filename: `${content.pageSlug(img.title || p.title).slice(0, 60) || 'picture'}.${ext}`, owner: v.requested_by,
-- metadata: { title: oneLine(img.title, 200), source_url: img.source_url || img.url, license: img.license || null, license_url: img.license_url || null, author: img.author || null, free: Boolean(img.free), origin: 'wiki.vibe', vibe: v.id },
-- });
-- const caption = clip(`${oneLine(pick.caption, 220) || oneLine(img.title, 120)} (${creditOf(img)})`, 300);
-- placed.set(key, { mediaId, caption, alt: oneLine(pick.caption || img.title, 200), img });
-- usedPictures.push(img);
-- } catch (err) {
-- log.warn(`[Vibe] ${v.id} picture for "${p.title}" skipped: ${err && err.message}`);
-- }
++ const caption = clip(`${oneLine(pick.caption, 220) || oneLine(img.caption || img.alt || img.title, 120)}${img.credit ? ` (${oneLine(img.credit, 160)})` : ''}`, 300);
++ placed.set(key, { mediaId: img.media_id, caption, alt: oneLine(pick.caption || img.alt || img.title, 200), img });
++ usedPictures.push(img.media_id);
}
const lead = placed.get('lead');
const body = composeBody(out, { placed: new Map([...placed].filter(([k]) => k !== 'lead')), contents: isMain ? contentsOf(v.plan) : null });
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex 20d0748..3d4375e 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -69,6 +69,13 @@ const LONG = 'Kim Possible is a teenager who saves the world between classes, wi
assert.deepStrictEqual(b.on_behalf_of, { type: 'user', id: A }, 'AI runs on behalf of the person');
const id = `run_${calls.length}`;
if (b.workflow === 'research.gather') return json(201, { run: { id, status: 'succeeded', output: research(b.input.focus) } });
++ if (b.workflow === 'image.picture') {
++ assert.strictEqual(b.input.mode, 'find');
++ assert.strictEqual(b.input.media_app, 'wiki');
++ return json(201, { run: { id, status: 'succeeded', output: { mode: 'find', task: 'image.picture', media: [
++ { media_id: MED(1), url: `https://media.internal/o/${MED(1)}`, width: 800, height: 600, title: b.input.query || 'Kim Possible', alt: 'from the show', caption: 'from the show', credit: 'Picture: Jane Doe, CC BY-SA 4.0', author: 'Jane Doe', license: 'CC BY-SA 4.0', source_url: 'https://commons.wikimedia.org/wiki/File:Kim.png', provider: 'commons', ai: false, model: null },
++ ], count: 1, gaps: [], note: null } } });
++ }
if (b.workflow === 'wiki.generate_space') {
assert.ok(b.input.sources.length >= 1 && b.input.max_pages === 6);
return json(201, { run: { id, status: 'succeeded', output: { title: 'Kim Possible', summary: 'An animated series about a teen hero.', pages: [
@@@ -147,7 +154,7 @@ const LONG = 'Kim Possible is a teenager who saves the world between classes, wi
assert.strictEqual(v.done, 2);
assert.strictEqual(v.main_page, '/w/kim-possible/kim-possible');
assert.deepStrictEqual(v.research.from.sort(), ['Fandom', 'Wikipedia']);
-- assert.strictEqual(fetched.length, 2, 'one picture per page; an index out of range is ignored');
++ assert.strictEqual(calls.filter((c) => c.host === 'ai.internal' && c.path === '/api/v1/runs' && /"workflow":"image.picture"/.test(c.body || '')).length, 2, 'one picture search per page');
// The wiki: named and described by the plan, the person owns it.
r = await H.req(h, 'GET', '/api/v1/spaces/kim-possible', { token: tok });
ddiff --git a/test/wiki-rich.test.js b/test/wiki-rich.test.js
iindex 371aa39..b6ea146 100644
---- a/test/wiki-rich.test.js
++++ b/test/wiki-rich.test.js
@@@ -19,12 +19,20 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
const u = new URL(String(url));
if (u.host === 'ai.test' && u.pathname === '/api/v1/runs') {
const body = JSON.parse(init.body || '{}');
-- if (body.task === 'wiki.article.images') {
-- const provider = (body.input && body.input.providers && body.input.providers[0]) || 'commons';
++ if (body.task === 'image.picture' && body.input && body.input.mode === 'find') {
++ if (String(body.input.query || '').includes('No picture')) return new Response(JSON.stringify(run({ mode: 'find', task: 'image.picture', query: body.input.query, media: [], count: 0, gaps: [], note: 'No licensed picture was found for this search.' })), { status: 200, headers: { 'Content-Type': 'application/json' } });
return new Response(JSON.stringify(run({
-- images: [{ url: `https://pictures.test/${provider}.jpg`, source_url: `https://${provider}.test/File:Machine`, title: 'A machine', description: 'A machine in a workshop', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', free: true, provider, width: 800, height: 600 }],
++ mode: 'find', task: 'image.picture', query: body.input.query, media: [
++ { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', url: 'https://media.test/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ', source_url: 'https://commons.test/File:Machine', title: 'A machine', alt: 'A machine in a workshop', caption: 'A machine in a workshop', credit: 'Ada Example, CC BY-SA 4.0', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', width: 800, height: 600, provider: 'commons', ai: false, model: null },
++ { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', url: 'https://media.test/o/med_01JAB2C3D4E5F6G7H8J9K0MNPR', source_url: 'https://openverse.test/File:Machine', title: 'A second machine', alt: 'A second machine', caption: 'A second machine', credit: 'Bo, CC BY 4.0', author: 'Bo', license: 'CC BY 4.0', license_url: 'https://creativecommons.org/licenses/by/4.0/', width: 900, height: 700, provider: 'openverse/flickr', ai: false, model: null },
++ ], count: 2, gaps: [], note: null,
})), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
++ if (body.task === 'image.picture' && body.input && body.input.mode === 'make') {
++ return new Response(JSON.stringify(run({ mode: 'make', task: 'image.picture', prompt: body.input.prompt, dry_run: false, estimate: null, media: [
++ { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPS', url: 'https://media.test/o/med_01JAB2C3D4E5F6G7H8J9K0MNPS', width: 1024, height: 1024, title: 'An illustration', alt: 'An illustration for Machines', caption: 'An illustration for Machines (Made with AI (gpt-image-1))', credit: 'Made with AI (gpt-image-1)', author: null, license: null, license_url: null, source_url: null, provider: 'image', ai: true, model: 'gpt-image-1' },
++ ], count: 1, gaps: [], note: null })), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
if (body.workflow === 'content.wiki') {
return new Response(JSON.stringify({
...run({
@@@ -42,6 +50,7 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
}
}
if (u.host === 'pics.test' && u.pathname === '/api/v1/gallery') {
++ if (String(u.searchParams.get('q') || '').includes('No picture')) return new Response(JSON.stringify({ pictures: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
return new Response(JSON.stringify({ pictures: [{ id: 'pic_1', slug: 'lathe', title: 'A lathe', description: 'A lathe in use', alt: 'A lathe', width: 1200, height: 800, author: 'Bo', links: { page: 'https://pics.test/i/lathe', direct: 'https://pics.test/i/lathe.jpg' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected outbound fetch ${url}`);
@@@ -103,17 +112,29 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
assert.strictEqual(aboutPerson({ infobox: [{ key: 'born', label: 'Born', type: 'text', value: '1970' }], revision: { fields: { title: 'Ada' }, content: '' } }), true);
assert.strictEqual(aboutPerson({ infobox: [], revision: { fields: { title: 'A lathe' }, content: 'A lathe turns wood.' } }), false);
-- // wiki.article.images: Commons and Openverse first, keeping licence and author; then Pics.
++ // image.picture: Commons and Openverse first, keeping licence and author and naming the Media object; then Pics.
const suggested = await H.req(h, 'POST', `/api/v1/pages/${made.page.id}/images/suggest`, { cookie, body: { count: 8 } });
assert.strictEqual(suggested.status, 200, suggested.text);
-- assert.strictEqual(suggested.json.task, 'wiki.article.images');
++ assert.strictEqual(suggested.json.task, 'image.picture');
assert.strictEqual(suggested.json.subject_is_person, false);
assert.deepStrictEqual([...new Set(suggested.json.suggestions.map((s) => s.provider))].sort(), ['commons', 'openverse', 'pics']);
const lead = suggested.json.suggestions.find((s) => s.role === 'lead');
++ assert.strictEqual(lead.media_id, 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ');
assert.strictEqual(lead.license, 'CC BY-SA 4.0');
assert.strictEqual(lead.author, 'Ada Example');
assert.ok(lead.credit.includes('CC BY-SA 4.0'));
++ // image.picture make: nothing real exists, the owner has a model, so one illustration comes back as a
++ // Media reference and its credit says which model made it. A real person is still never drawn.
++ const drawn = await H.req(h, 'POST', `/api/v1/pages/${made.page.id}/images/suggest`, { cookie, body: { query: 'No picture of machines', allow_ai: true, count: 2 } });
++ assert.strictEqual(drawn.status, 200, drawn.text);
++ assert.strictEqual(drawn.json.suggestions.length, 1);
++ assert.strictEqual(drawn.json.suggestions[0].provider, 'ai');
++ assert.strictEqual(drawn.json.suggestions[0].ai, true);
++ assert.strictEqual(drawn.json.suggestions[0].media_id, 'med_01JAB2C3D4E5F6G7H8J9K0MNPS');
++ assert.strictEqual(drawn.json.suggestions[0].model, 'gpt-image-1');
++ assert.ok(drawn.json.suggestions[0].credit.includes('Illustration made with gpt-image-1'));
++
// content.wiki: a draft proposal, pending, with a citation per sentence and the infobox filled.
const drafted = await H.req(h, 'POST', `/api/v1/pages/${made.page.id}/draft`, { cookie, body: { instruction: 'Add the history' } });
assert.strictEqual(drafted.status, 201, drafted.text);