openvibers/openvibe.ai Commits
Improve image search ranking, deduplication and filters (#461)
Changes
@@@ -5,7 +5,7 @@ module.exports = {kinds: ['images'], key_env: 'SEARCH_UNSPLASH_KEY', hosts: ['api.unsplash.com'],price_per_1000: 0, source: 'https://unsplash.com/developers', freshness: [],async search(b, { fetchImpl, key }) {-- const out = await requestFor(this.hosts, fetchImpl)(query('https://api.unsplash.com/search/photos', { query: b.q, per_page: Math.min(30, b.limit), content_filter: 'high', orientation: { landscape: 'landscape', portrait: 'portrait', square: 'squarish' }[b.orientation] }), { headers: { authorization: 'Client-ID ' + key } });++ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.unsplash.com/search/photos', { query: b.q, per_page: Math.min(30, b.limit), content_filter: 'high', color: b.color, orientation: { landscape: 'landscape', portrait: 'portrait', square: 'squarish' }[b.orientation] }), { headers: { authorization: 'Client-ID ' + key } });return out.results.map(r => ({ title: r.alt_description || r.description || '', url: r.urls.full, thumbnail: r.urls.thumb, width: r.width, height: r.height, published: r.created_at || null, license: 'unsplash', license_url: 'https://unsplash.com/license', author: r.user.name, source_page: r.links.html, attribution: `Photo by ${r.user.name} on Unsplash`, author_url: r.user.links?.html, download_location: r.links.download_location }));},};nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++++const knownLicense = (r) => Boolean(r.license && r.license_url);++const pixels = (r) => (r.width || 0) * (r.height || 0);++const key = (r) => {++ try {++ const u = new URL(r.url);++ return `${u.hostname.replace(/^www\./, '')}${decodeURIComponent(u.pathname).replace(/\/(?:thumb|thumbnail|preview)s?\//gi, '/').replace(/(?:[_-]\d{2,5}x\d{2,5})(?=\.)/g, '').toLowerCase()}`;++ } catch { return r.url; }++};++const score = (r) => (knownLicense(r) ? 100 : 0) + Math.min(50, Math.log2(Math.max(1, pixels(r)))) + (r.source_page ? 5 : 0);++++function rankImages(rows, limit) {++ const ordered = [...rows].sort((a, b) => score(b) - score(a));++ const seen = new Set();++ const out = [];++ for (const row of ordered) {++ const exact = key(row);++ const title = String(row.title || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();++ const author = String(row.author || '').toLowerCase().trim();++ const near = title.length > 15 && author && `${title}|${author}|${Math.round((row.width || 0) / Math.max(1, row.height || 1) * 10)}`;++ if (seen.has(exact) || near && seen.has(near)) continue;++ seen.add(exact);++ if (near) seen.add(near);++ out.push(row);++ if (out.length >= limit) break;++ }++ return out;++}++++module.exports = { rankImages };@@@ -5,6 +5,7 @@ const { AiError, providerSecret } = require('../util');const { validate } = require('openvibe-contracts');const net = require('node:net');const { createHash } = require('node:crypto');++const { rankImages } = require('./image-rank');const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify','deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina','openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images',@@@ -30,7 +31,11 @@ function inputFor(kind, b) {if (b.max_cost_usd != null && (!Number.isFinite(b.max_cost_usd) || b.max_cost_usd < 0)) throw invalid('max_cost_usd must be nonnegative');if (b.freshness && !['day', 'month', 'year'].includes(b.freshness)) throw invalid('freshness must be day, month or year');if (b.orientation && !['square', 'portrait', 'landscape'].includes(b.orientation)) throw invalid('orientation must be square, portrait or landscape');-- if (b.colour || b.color || b.type && b.type !== 'icon') throw invalid('This filter is not supported');++ if (b.colour) throw invalid('Use color for the colour filter');++ if (b.color && !['black_and_white', 'black', 'white', 'yellow', 'orange', 'red', 'purple', 'magenta', 'green', 'teal', 'blue'].includes(b.color)) throw invalid('color is not supported');++ if (b.type && !['icon', 'photo', 'illustration', 'vector'].includes(b.type)) throw invalid('type is not supported');++ if (b.after && (!/^\d{4}-\d{2}-\d{2}$/.test(b.after) || !Number.isFinite(Date.parse(b.after)))) throw invalid('after must be a date in YYYY-MM-DD');++ if (b.safe_search != null && b.safe_search !== 'strict') throw invalid('safe_search must be strict');if (b.size && !['small', 'medium', 'large'].includes(b.size)) throw invalid('size must be small, medium or large');return { ...b, q: kind === 'fetch' ? undefined : b.q.trim(), limit: b.limit || 10 };}@@@ -53,7 +58,11 @@ function normalize(rows, engine, kind, b) {if (b.orientation) out = out.filter(r => r.width && r.height && (b.orientation === 'square' ? r.width === r.height : b.orientation === 'portrait' ? r.height > r.width : r.width > r.height));if (b.size) out = out.filter(r => r.width && r.height && (b.size === 'small' ? Math.max(r.width, r.height) < 800 : b.size === 'medium' ? Math.max(r.width, r.height) >= 800 && Math.max(r.width, r.height) < 2000 : Math.max(r.width, r.height) >= 2000));if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);-- return out.slice(0, b.limit);++ if (b.after) out = out.filter(r => r.published && Date.parse(r.published) >= Date.parse(b.after));++ if (b.type === 'vector') out = out.filter(r => r.format === 'svg' || new URL(r.url).pathname.toLowerCase().endsWith('.svg'));++ if (b.type === 'photo') out = out.filter(r => ['unsplash', 'pexels', 'pixabay'].includes(engine));++ if (b.type === 'illustration') out = out.filter(r => !['unsplash', 'pexels'].includes(engine));++ return kind === 'images' ? rankImages(out, b.limit) : out.slice(0, b.limit);}function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, registry = null, targetGuard = publicTarget } = {}) {const health = new Map();@@@ -89,6 +98,10 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>if (a.manual_only && (!b.engine || b.engine === 'auto')) continue;if (!a.kinds.includes(kind) || b.engine && b.engine !== 'auto' && b.engine !== a.id ||b.freshness && !a.freshness.includes(b.freshness) || (a.requires || []).some(k => !env[k])) continue;++ if (kind === 'images' && b.color && a.id !== 'unsplash') continue;++ if (kind === 'images' && b.after && a.id !== 'unsplash') continue;++ if (kind === 'images' && b.type === 'photo' && !['unsplash', 'pexels', 'pixabay'].includes(a.id)) continue;++ if (kind === 'images' && b.type === 'vector' && !['openverse', 'commons'].includes(a.id)) continue;if (kind !== 'fetch' && a.max_query && [...b.q].length + (b.freshness ? 17 : 0) > a.max_query) continue;const own = a.byok && subject && credentials ? await credentials.searchForRun(subject, credentialId(a)) : null;const state = binding(a, providers);@@@ -26,7 +26,10 @@ for (const kind of ['web', 'images', 'icons']) {commercial: { type: 'boolean' }, format: { type: 'string' },orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'] },size: { type: 'string', enum: ['small', 'medium', 'large'], description: 'Longest side: below 800px, 800–1999px, or 2000px and above. Missing dimensions do not match.' },-- type: { type: 'string', enum: ['icon'] }, set: { type: 'string' },++ color: { type: 'string', enum: ['black_and_white', 'black', 'white', 'yellow', 'orange', 'red', 'purple', 'magenta', 'green', 'teal', 'blue'], description: 'Available through Unsplash. Requires a configured Unsplash key.' },++ after: { type: 'string', format: 'date', description: 'Only results with a known publication date on or after this day. Available through Unsplash.' },++ safe_search: { type: 'string', enum: ['strict'], default: 'strict' },++ type: { type: 'string', enum: ['icon', 'photo', 'illustration', 'vector'] }, set: { type: 'string' },style: { type: 'string', enum: ['any', 'line', 'solid'] },}),} }) },nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++const assert = require('node:assert/strict');++const { rankImages } = require('../server/search/image-rank');++++const rows = [++ { title: 'A small cat', url: 'https://example.org/images/cat_400x300.jpg', width: 400, height: 300 },++ { title: 'A small cat', url: 'https://example.org/images/cat_3000x2000.jpg', width: 3000, height: 2000, source_page: 'https://example.org/cat', license: 'cc0', license_url: 'https://example.org/license' },++ { title: 'Other picture', url: 'https://example.org/other.jpg', width: 1000, height: 1000 },++];++const out = rankImages(rows, 10);++assert.equal(out.length, 2);++assert.equal(out[0].width, 3000);++assert.equal(rankImages(rows, 1).length, 1);++assert.equal(rankImages([++ { title: 'A common mountain picture', author: 'Alice', url: 'https://a.example/one.jpg', width: 1200, height: 800 },++ { title: 'A common mountain picture', author: 'Bob', url: 'https://a.example/two.jpg', width: 1200, height: 800 },++], 10).length, 2, 'different artists remain distinct');++process.stdout.write('image ranking passed\n');
@@ -5,7 +5,7 @@ module.exports = {
@@ -5,7 +5,7 @@ module.exports = {
kinds: ['images'], key_env: 'SEARCH_UNSPLASH_KEY', hosts: ['api.unsplash.com'],
kinds: ['images'], key_env: 'SEARCH_UNSPLASH_KEY', hosts: ['api.unsplash.com'],
price_per_1000: 0, source: 'https://unsplash.com/developers', freshness: [],
price_per_1000: 0, source: 'https://unsplash.com/developers', freshness: [],
async search(b, { fetchImpl, key }) {
async search(b, { fetchImpl, key }) {
- const out = await requestFor(this.hosts, fetchImpl)(query('https://api.unsplash.com/search/photos', { query: b.q, per_page: Math.min(30, b.limit), content_filter: 'high', orientation: { landscape: 'landscape', portrait: 'portrait', square: 'squarish' }[b.orientation] }), { headers: { authorization: 'Client-ID ' + key } });
+ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.unsplash.com/search/photos', { query: b.q, per_page: Math.min(30, b.limit), content_filter: 'high', color: b.color, orientation: { landscape: 'landscape', portrait: 'portrait', square: 'squarish' }[b.orientation] }), { headers: { authorization: 'Client-ID ' + key } });
return out.results.map(r => ({ title: r.alt_description || r.description || '', url: r.urls.full, thumbnail: r.urls.thumb, width: r.width, height: r.height, published: r.created_at || null, license: 'unsplash', license_url: 'https://unsplash.com/license', author: r.user.name, source_page: r.links.html, attribution: `Photo by ${r.user.name} on Unsplash`, author_url: r.user.links?.html, download_location: r.links.download_location }));
return out.results.map(r => ({ title: r.alt_description || r.description || '', url: r.urls.full, thumbnail: r.urls.thumb, width: r.width, height: r.height, published: r.created_at || null, license: 'unsplash', license_url: 'https://unsplash.com/license', author: r.user.name, source_page: r.links.html, attribution: `Photo by ${r.user.name} on Unsplash`, author_url: r.user.links?.html, download_location: r.links.download_location }));
},
},
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+
+const knownLicense = (r) => Boolean(r.license && r.license_url);
+const pixels = (r) => (r.width || 0) * (r.height || 0);
+const key = (r) => {
+ try {
+ const u = new URL(r.url);
+ return `${u.hostname.replace(/^www\./, '')}${decodeURIComponent(u.pathname).replace(/\/(?:thumb|thumbnail|preview)s?\//gi, '/').replace(/(?:[_-]\d{2,5}x\d{2,5})(?=\.)/g, '').toLowerCase()}`;
+ } catch { return r.url; }
+};
+const score = (r) => (knownLicense(r) ? 100 : 0) + Math.min(50, Math.log2(Math.max(1, pixels(r)))) + (r.source_page ? 5 : 0);
+
+function rankImages(rows, limit) {
+ const ordered = [...rows].sort((a, b) => score(b) - score(a));
+ const seen = new Set();
+ const out = [];
+ for (const row of ordered) {
+ const exact = key(row);
+ const title = String(row.title || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
+ const author = String(row.author || '').toLowerCase().trim();
+ const near = title.length > 15 && author && `${title}|${author}|${Math.round((row.width || 0) / Math.max(1, row.height || 1) * 10)}`;
+ if (seen.has(exact) || near && seen.has(near)) continue;
+ seen.add(exact);
+ if (near) seen.add(near);
+ out.push(row);
+ if (out.length >= limit) break;
+ }
+ return out;
+}
+
+module.exports = { rankImages };
@@ -5,6 +5,7 @@ const { AiError, providerSecret } = require('../util');
@@ -5,6 +5,7 @@ const { AiError, providerSecret } = require('../util');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const net = require('node:net');
const net = require('node:net');
const { createHash } = require('node:crypto');
const { createHash } = require('node:crypto');
+const { rankImages } = require('./image-rank');
const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify',
const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify',
'deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina',
'deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina',
'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images',
'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images',
@@ -30,7 +31,11 @@ function inputFor(kind, b) {
@@ -30,7 +31,11 @@ function inputFor(kind, b) {
if (b.max_cost_usd != null && (!Number.isFinite(b.max_cost_usd) || b.max_cost_usd < 0)) throw invalid('max_cost_usd must be nonnegative');
if (b.max_cost_usd != null && (!Number.isFinite(b.max_cost_usd) || b.max_cost_usd < 0)) throw invalid('max_cost_usd must be nonnegative');
if (b.freshness && !['day', 'month', 'year'].includes(b.freshness)) throw invalid('freshness must be day, month or year');
if (b.freshness && !['day', 'month', 'year'].includes(b.freshness)) throw invalid('freshness must be day, month or year');
if (b.orientation && !['square', 'portrait', 'landscape'].includes(b.orientation)) throw invalid('orientation must be square, portrait or landscape');
if (b.orientation && !['square', 'portrait', 'landscape'].includes(b.orientation)) throw invalid('orientation must be square, portrait or landscape');
- if (b.colour || b.color || b.type && b.type !== 'icon') throw invalid('This filter is not supported');
+ if (b.colour) throw invalid('Use color for the colour filter');
+ if (b.color && !['black_and_white', 'black', 'white', 'yellow', 'orange', 'red', 'purple', 'magenta', 'green', 'teal', 'blue'].includes(b.color)) throw invalid('color is not supported');
+ if (b.type && !['icon', 'photo', 'illustration', 'vector'].includes(b.type)) throw invalid('type is not supported');
+ if (b.after && (!/^\d{4}-\d{2}-\d{2}$/.test(b.after) || !Number.isFinite(Date.parse(b.after)))) throw invalid('after must be a date in YYYY-MM-DD');
+ if (b.safe_search != null && b.safe_search !== 'strict') throw invalid('safe_search must be strict');
if (b.size && !['small', 'medium', 'large'].includes(b.size)) throw invalid('size must be small, medium or large');
if (b.size && !['small', 'medium', 'large'].includes(b.size)) throw invalid('size must be small, medium or large');
return { ...b, q: kind === 'fetch' ? undefined : b.q.trim(), limit: b.limit || 10 };
return { ...b, q: kind === 'fetch' ? undefined : b.q.trim(), limit: b.limit || 10 };
}
}
@@ -53,7 +58,11 @@ function normalize(rows, engine, kind, b) {
@@ -53,7 +58,11 @@ function normalize(rows, engine, kind, b) {
if (b.orientation) out = out.filter(r => r.width && r.height && (b.orientation === 'square' ? r.width === r.height : b.orientation === 'portrait' ? r.height > r.width : r.width > r.height));
if (b.orientation) out = out.filter(r => r.width && r.height && (b.orientation === 'square' ? r.width === r.height : b.orientation === 'portrait' ? r.height > r.width : r.width > r.height));
if (b.size) out = out.filter(r => r.width && r.height && (b.size === 'small' ? Math.max(r.width, r.height) < 800 : b.size === 'medium' ? Math.max(r.width, r.height) >= 800 && Math.max(r.width, r.height) < 2000 : Math.max(r.width, r.height) >= 2000));
if (b.size) out = out.filter(r => r.width && r.height && (b.size === 'small' ? Math.max(r.width, r.height) < 800 : b.size === 'medium' ? Math.max(r.width, r.height) >= 800 && Math.max(r.width, r.height) < 2000 : Math.max(r.width, r.height) >= 2000));
if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);
if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);
- return out.slice(0, b.limit);
+ if (b.after) out = out.filter(r => r.published && Date.parse(r.published) >= Date.parse(b.after));
+ if (b.type === 'vector') out = out.filter(r => r.format === 'svg' || new URL(r.url).pathname.toLowerCase().endsWith('.svg'));
+ if (b.type === 'photo') out = out.filter(r => ['unsplash', 'pexels', 'pixabay'].includes(engine));
+ if (b.type === 'illustration') out = out.filter(r => !['unsplash', 'pexels'].includes(engine));
+ return kind === 'images' ? rankImages(out, b.limit) : out.slice(0, b.limit);
}
}
function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, registry = null, targetGuard = publicTarget } = {}) {
function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, registry = null, targetGuard = publicTarget } = {}) {
const health = new Map();
const health = new Map();
@@ -89,6 +98,10 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>
@@ -89,6 +98,10 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>
if (a.manual_only && (!b.engine || b.engine === 'auto')) continue;
if (a.manual_only && (!b.engine || b.engine === 'auto')) continue;
if (!a.kinds.includes(kind) || b.engine && b.engine !== 'auto' && b.engine !== a.id ||
if (!a.kinds.includes(kind) || b.engine && b.engine !== 'auto' && b.engine !== a.id ||
b.freshness && !a.freshness.includes(b.freshness) || (a.requires || []).some(k => !env[k])) continue;
b.freshness && !a.freshness.includes(b.freshness) || (a.requires || []).some(k => !env[k])) continue;
+ if (kind === 'images' && b.color && a.id !== 'unsplash') continue;
+ if (kind === 'images' && b.after && a.id !== 'unsplash') continue;
+ if (kind === 'images' && b.type === 'photo' && !['unsplash', 'pexels', 'pixabay'].includes(a.id)) continue;
+ if (kind === 'images' && b.type === 'vector' && !['openverse', 'commons'].includes(a.id)) continue;
if (kind !== 'fetch' && a.max_query && [...b.q].length + (b.freshness ? 17 : 0) > a.max_query) continue;
if (kind !== 'fetch' && a.max_query && [...b.q].length + (b.freshness ? 17 : 0) > a.max_query) continue;
const own = a.byok && subject && credentials ? await credentials.searchForRun(subject, credentialId(a)) : null;
const own = a.byok && subject && credentials ? await credentials.searchForRun(subject, credentialId(a)) : null;
const state = binding(a, providers);
const state = binding(a, providers);
@@ -26,7 +26,10 @@ for (const kind of ['web', 'images', 'icons']) {
@@ -26,7 +26,10 @@ for (const kind of ['web', 'images', 'icons']) {
commercial: { type: 'boolean' }, format: { type: 'string' },
commercial: { type: 'boolean' }, format: { type: 'string' },
orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'] },
orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'] },
size: { type: 'string', enum: ['small', 'medium', 'large'], description: 'Longest side: below 800px, 800–1999px, or 2000px and above. Missing dimensions do not match.' },
size: { type: 'string', enum: ['small', 'medium', 'large'], description: 'Longest side: below 800px, 800–1999px, or 2000px and above. Missing dimensions do not match.' },
- type: { type: 'string', enum: ['icon'] }, set: { type: 'string' },
+ color: { type: 'string', enum: ['black_and_white', 'black', 'white', 'yellow', 'orange', 'red', 'purple', 'magenta', 'green', 'teal', 'blue'], description: 'Available through Unsplash. Requires a configured Unsplash key.' },
+ after: { type: 'string', format: 'date', description: 'Only results with a known publication date on or after this day. Available through Unsplash.' },
+ safe_search: { type: 'string', enum: ['strict'], default: 'strict' },
+ type: { type: 'string', enum: ['icon', 'photo', 'illustration', 'vector'] }, set: { type: 'string' },
style: { type: 'string', enum: ['any', 'line', 'solid'] },
style: { type: 'string', enum: ['any', 'line', 'solid'] },
}),
}),
} }) },
} }) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { rankImages } = require('../server/search/image-rank');
+
+const rows = [
+ { title: 'A small cat', url: 'https://example.org/images/cat_400x300.jpg', width: 400, height: 300 },
+ { title: 'A small cat', url: 'https://example.org/images/cat_3000x2000.jpg', width: 3000, height: 2000, source_page: 'https://example.org/cat', license: 'cc0', license_url: 'https://example.org/license' },
+ { title: 'Other picture', url: 'https://example.org/other.jpg', width: 1000, height: 1000 },
+];
+const out = rankImages(rows, 10);
+assert.equal(out.length, 2);
+assert.equal(out[0].width, 3000);
+assert.equal(rankImages(rows, 1).length, 1);
+assert.equal(rankImages([
+ { title: 'A common mountain picture', author: 'Alice', url: 'https://a.example/one.jpg', width: 1200, height: 800 },
+ { title: 'A common mountain picture', author: 'Bob', url: 'https://a.example/two.jpg', width: 1200, height: 800 },
+], 10).length, 2, 'different artists remain distinct');
+process.stdout.write('image ranking passed\n');