openvibers/openvibe.wiki Commits
Wiki: article pictures through the shared image task (#273)
Changes
@@@ -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`.@@@ -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@@@ -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 },@@@ -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 };@@@ -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 });@@@ -69,6 +69,13 @@ const LONG = 'Kim Possible is a teenager who saves the world between classes, wiassert.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, wiassert.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 });@@@ -19,12 +19,20 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeededconst 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: 'succeededassert.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);
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## 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`.
- 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 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`.
- 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`.
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
* GET /pages/:id/revisions wiki.page.read history (?before=&limit=)
* 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? }
* 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
* 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
* 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/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
@@ -226,8 +226,8 @@ route('post', '/pages/{id}/media/verify', {
@@ -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() },
responses: { 200: json('One result per attachment', { type: 'object', properties: { results: arrayOf({ type: 'object' }) } }), ...errors() },
});
});
route('post', '/pages/{id}/images/suggest', {
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: {
requestBody: body({ type: 'object', properties: {
query: nullable('string', { maxLength: 300 }), section: nullable('string', { maxLength: 200 }),
query: nullable('string', { maxLength: 300 }), section: nullable('string', { maxLength: 200 }),
count: { type: 'integer', minimum: 1, maximum: 12 }, allow_ai: { type: 'boolean', default: true },
count: { type: 'integer', minimum: 1, maximum: 12 }, allow_ai: { type: 'boolean', default: true },
@@ -1,22 +1,22 @@
@@ -1,22 +1,22 @@
'use strict';
'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 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_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 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 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 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. */
/** True when the page is about a real person: then no AI illustration is ever suggested. */
function aboutPerson(v) {
function aboutPerson(v) {
@@ -26,25 +26,26 @@ function aboutPerson(v) {
@@ -26,25 +26,26 @@ function aboutPerson(v) {
return PERSON_TEXT.test(text);
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 out = [];
const seen = new Set();
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;
if (seen.has(key)) continue;
seen.add(key);
seen.add(key);
out.push({
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;
if (out.length >= limit) break;
}
}
@@ -75,22 +76,9 @@ function fromAttachments(v, mediaUrl) {
@@ -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) {
async function suggest(ctx, pageId, body = {}, actor) {
@@ -112,23 +100,24 @@ async function suggest(ctx, pageId, body = {}, actor) {
@@ -112,23 +100,24 @@ async function suggest(ctx, pageId, body = {}, actor) {
let model = null;
let model = null;
if (ctx.platform.ai.configured) {
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 {
} else {
note = 'OpenVibe.AI is not configured here: only Pics and the page\'s own Media are suggested.';
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) {
if (ctx.platform.pics.configured && suggestions.length < limit) {
try {
try {
const pictures = await ctx.platform.pics.search(query, { limit: limit - suggestions.length });
const pictures = await ctx.platform.pics.search(query, { limit: limit - suggestions.length });
@@ -142,34 +131,31 @@ async function suggest(ctx, pageId, body = {}, actor) {
@@ -142,34 +131,31 @@ async function suggest(ctx, pageId, body = {}, actor) {
}
}
}
}
for (const a of fromAttachments(v, ctx.platform.media.publicUrl)) {
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);
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) {
if (!suggestions.length && person) {
note = 'This article is about a real person: no AI illustration is suggested.';
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) {
} else if (!suggestions.length && body.allow_ai !== false && ctx.platform.ai.configured) {
try {
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.`,
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 });
}, { 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) {
} catch (err) {
note = `No illustration could be made: ${oneLine(err && err.message, 200)}`;
note = `No illustration could be made: ${oneLine(err && err.message, 200)}`;
@@ -184,4 +170,4 @@ async function suggest(ctx, pageId, body = {}, actor) {
@@ -184,4 +170,4 @@ async function suggest(ctx, pageId, body = {}, actor) {
};
};
}
}
-module.exports = { suggest, aboutPerson, TASK, PROVIDERS };
+module.exports = { suggest, aboutPerson, fromTask, TASK, PROVIDERS };
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
* search, Commons, Openverse) for the topic as a whole
* 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
* 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
* 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
* 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).
* 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');
@@ -22,7 +22,6 @@ const crypto = require('crypto');
const { ulid } = require('openvibe-contracts').ids;
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const content = require('./content');
const importer = require('./import');
const importer = require('./import');
-const { fetchImage } = require('../integrations/image-fetch');
const SIZES = ['quick', 'standard', 'big'];
const SIZES = ['quick', 'standard', 'big'];
const ACTIVE = ['queued', 'researching', 'planning', 'writing'];
const ACTIVE = ['queued', 'researching', 'planning', 'writing'];
@@ -146,7 +145,7 @@ function contentsOf(plan) {
@@ -146,7 +145,7 @@ function contentsOf(plan) {
return `## In this wiki\n\n${blocks.join('\n\n')}`;
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 cfg = config.vibe || {};
const pagesFor = (size) => (cfg.pages && cfg.pages[size]) || { quick: 6, standard: 12, big: 24 }[size] || 12;
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')}`;
const owner = `wiki-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
@@ -213,7 +212,7 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
@@ -213,7 +212,7 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
return {
return {
id: v.id, topic: v.topic, size: v.size, status: v.status,
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 },
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,
pages: list,
done: list.filter((p) => p.status === 'done').length,
done: list.filter((p) => p.status === 'done').length,
total: list.length,
total: list.length,
@@ -296,11 +295,11 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
@@ -296,11 +295,11 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
// 1. Research the topic.
// 1. Research the topic.
if (!v.progress.research) {
if (!v.progress.research) {
await save(v, { status: 'researching', error: null });
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 || {};
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.`);
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 = {
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),
from: Object.entries(out.providers || {}).filter(([, p]) => p && p.ok && p.count).map(([k]) => k),
};
};
await save(v);
await save(v);
@@ -366,42 +365,42 @@ function createVibes({ db, svc, platform, config, now = () => Date.now(), log =
@@ -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).
// The page's own research (the main page reuses the topic's).
let research = v.progress.research;
let research = v.progress.research;
if (!isMain) {
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.
// The topic's best source helps every page: its facts and names.
const lead = v.progress.research.sources[0];
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] };
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));
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');
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', {
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,
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,
sources,
}, `page:${p.slug}`);
}, `page:${p.slug}`);
const out = r.output || {};
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 placed = new Map();
const usedPictures = [];
const usedPictures = [];
for (const pick of (Array.isArray(out.images) ? out.images : []).slice(0, MAX_IMAGES_PER_PAGE)) {
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;
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();
const key = /^lead$/i.test(String(pick.section || '').trim()) || !pick.section ? 'lead' : oneLine(pick.section, 120).replace(/^#+\s*/, '').toLowerCase();
if (placed.has(key)) continue;
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 lead = placed.get('lead');
const body = composeBody(out, { placed: new Map([...placed].filter(([k]) => k !== 'lead')), contents: isMain ? contentsOf(v.plan) : null });
const body = composeBody(out, { placed: new Map([...placed].filter(([k]) => k !== 'lead')), contents: isMain ? contentsOf(v.plan) : null });
@@ -69,6 +69,13 @@ const LONG = 'Kim Possible is a teenager who saves the world between classes, wi
@@ -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');
assert.deepStrictEqual(b.on_behalf_of, { type: 'user', id: A }, 'AI runs on behalf of the person');
const id = `run_${calls.length}`;
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 === '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') {
if (b.workflow === 'wiki.generate_space') {
assert.ok(b.input.sources.length >= 1 && b.input.max_pages === 6);
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: [
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
@@ -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.done, 2);
assert.strictEqual(v.main_page, '/w/kim-possible/kim-possible');
assert.strictEqual(v.main_page, '/w/kim-possible/kim-possible');
assert.deepStrictEqual(v.research.from.sort(), ['Fandom', 'Wikipedia']);
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.
// 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 });
r = await H.req(h, 'GET', '/api/v1/spaces/kim-possible', { token: tok });
@@ -19,12 +19,20 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
@@ -19,12 +19,20 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
const u = new URL(String(url));
const u = new URL(String(url));
if (u.host === 'ai.test' && u.pathname === '/api/v1/runs') {
if (u.host === 'ai.test' && u.pathname === '/api/v1/runs') {
const body = JSON.parse(init.body || '{}');
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({
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' } });
})), { 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') {
if (body.workflow === 'content.wiki') {
return new Response(JSON.stringify({
return new Response(JSON.stringify({
...run({
...run({
@@ -42,6 +50,7 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
@@ -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 (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' } });
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}`);
throw new Error(`unexpected outbound fetch ${url}`);
@@ -103,17 +112,29 @@ const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded
@@ -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: [{ 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);
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 } });
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.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.strictEqual(suggested.json.subject_is_person, false);
assert.deepStrictEqual([...new Set(suggested.json.suggestions.map((s) => s.provider))].sort(), ['commons', 'openverse', 'pics']);
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');
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.license, 'CC BY-SA 4.0');
assert.strictEqual(lead.author, 'Ada Example');
assert.strictEqual(lead.author, 'Ada Example');
assert.ok(lead.credit.includes('CC BY-SA 4.0'));
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.
// 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' } });
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);
assert.strictEqual(drafted.status, 201, drafted.text);