openvibers/openvibe.ai Commits

Improve image search ranking, deduplication and filters (#461)

OP OpenVibers committed 2 days ago · 89a64cd2d2 · 5 files, +69 −4

* Rank and filter image search results with source details

* Keep distinct artists in image deduplication

Changes

ddiff --git a/server/search/adapters/unsplash.js b/server/search/adapters/unsplash.js
iindex 620177c..3b93847 100644
---- a/server/search/adapters/unsplash.js
++++ b/server/search/adapters/unsplash.js
@@@ -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 }));
},
};
ddiff --git a/server/search/image-rank.js b/server/search/image-rank.js
nnew file mode 100644
iindex 0000000..4d943fe
---- /dev/null
++++ b/server/search/image-rank.js
@@@ -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 };
ddiff --git a/server/search/index.js b/server/search/index.js
iindex 2eef78d..2f48c19 100644
---- a/server/search/index.js
++++ b/server/search/index.js
@@@ -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);
ddiff --git a/server/search/openapi.js b/server/search/openapi.js
iindex 66286a4..efa2de0 100644
---- a/server/search/openapi.js
++++ b/server/search/openapi.js
@@@ -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'] },
}),
} }) },
ddiff --git a/test/image-rank.test.js b/test/image-rank.test.js
nnew file mode 100644
iindex 0000000..c9e370c
---- /dev/null
++++ b/test/image-rank.test.js
@@@ -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');