diff --git a/public/components/admin.html b/public/components/admin.html index 0005576f..a1e14d31 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -225,7 +225,10 @@ + transcription models are single defaults, so their button says + Make default rather than Add: there is one of each, and it does + not join the Roster below. "Set" read like "add to the list", + which is the one thing it does not do. -->
@@ -245,12 +248,12 @@
@@ -335,7 +338,7 @@

Loading...

-

Speech and transcription models are single defaults rather than a roster: choose them with Set under Discover & test.

+

Speech and transcription are not on this roster. There is one voice and one model for each, chosen with Make default under Discover & test.

@@ -533,6 +536,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 72b57d34..12aefd5a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1354,7 +1354,7 @@ initImageSettings(); var setType = isModel ? 'model' : 'voice'; var badge = isModel ? 'MODEL' : 'VOICE'; return '
' + - '' + + '' + '' + esc(v.name) + badge + '' + '' + esc(v.source || '') + '' + '
'; @@ -1522,7 +1522,7 @@ initImageSettings(); container.innerHTML = '

Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')

' + items.map(function(m) { return '
' + - '' + + '' + '' + esc(m.name || m.id) + '' + '' + esc(m.source || '') + '' + '
'; @@ -1951,7 +1951,7 @@ initImageSettings(); // ============================================================ // ADMIN IMAGE MODEL MANAGEMENT // ============================================================ -// Unlike TTS and STT there is no single default to Set: an image model is +// Unlike TTS and STT there is no single default to make: an image model is // chosen per workflow, so discovery here ends in a Test, and the workflow // pickers under Availability consume the same discovery call. Nothing loads // on tab entry: the workflow pickers already make the one discovery call diff --git a/src/utils/webSearch.js b/src/utils/webSearch.js index f752eb69..233cfb5a 100644 --- a/src/utils/webSearch.js +++ b/src/utils/webSearch.js @@ -9,15 +9,16 @@ // then, and only the search query leaves — never the corpus excerpts, never the // generated content, never anything about the user. // -// Four providers, one shape. Tavily and Brave are answer-oriented APIs, Serper -// fronts Google, and SearXNG is self-hosted — the only one where the query does -// not reach a commercial third party at all, which is why it is worth having -// even though it needs somewhere to run. +// Five providers, one shape. Tavily and Brave are answer-oriented APIs, Serper +// fronts Google, Exa searches by meaning rather than keywords — which suits a +// clinical question phrased as a question — and SearXNG is self-hosted, the +// only one where the query does not reach a commercial third party at all, +// which is why it is worth having even though it needs somewhere to run. // ============================================================ var db = require('../db/database'); -var PROVIDERS = ['tavily', 'serper', 'brave', 'searxng']; +var PROVIDERS = ['tavily', 'serper', 'brave', 'exa', 'searxng']; var MAX_RESULTS = 8; var TIMEOUT_MS = 15000; @@ -96,6 +97,32 @@ var ADAPTERS = { })); }, + // Exa is an embeddings search: it matches on meaning, so a question asked as + // a question works better here than the keywords the others want. contents + // asks for a text extract in the same call — without it every result would + // need a second fetch, and the snippet is the part the model actually reads. + async exa(query, s) { + var r = await fetch('https://api.exa.ai/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': s.apiKey }, + body: JSON.stringify({ + query: query, + numResults: MAX_RESULTS, + // 'auto' lets Exa fall back to keyword search when a query reads like + // keywords; pinning 'neural' would make it worse at the queries the + // other providers handle well. + type: 'auto', + contents: { text: { maxCharacters: 1200 } } + }), + signal: AbortSignal.timeout(TIMEOUT_MS) + }); + if (!r.ok) throw new Error('Exa returned ' + r.status); + var data = await r.json(); + return normalize((data.results || []).map(function (x) { + return { title: x.title, url: x.url, snippet: x.text || x.summary || '' }; + })); + }, + async searxng(query, s) { var base = s.baseUrl.replace(/\/+$/, ''); var r = await fetch(base + '/search?format=json&q=' + encodeURIComponent(query), { diff --git a/test/web-search.test.js b/test/web-search.test.js index 1edc319e..2578426f 100644 --- a/test/web-search.test.js +++ b/test/web-search.test.js @@ -50,6 +50,7 @@ test('every provider comes back in the same shape', async () => { ['tavily', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }], ['serper', { organic: [{ title: 'T', link: 'https://a', snippet: 'snippet a' }] }], ['brave', { web: { results: [{ title: 'T', url: 'https://a', description: 'snippet a' }] } }], + ['exa', { results: [{ title: 'T', url: 'https://a', text: 'snippet a' }] }], ['searxng', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }] ]; for (const [provider, payload] of cases) { @@ -191,3 +192,57 @@ test('both screens say plainly that a query leaves the network', () => { assert.match(js, /\['mr-modify-pubmed-row', 'pubmed'\]/); assert.match(js, /row\.hidden = !available\[pair\[1\]\]/); }); + +// ---- Exa ------------------------------------------------------------------- +// Embeddings search rather than keywords, which suits a clinical question asked +// as a question. It is the only provider that can return the page text in the +// same call, and the snippet is the part the model reads. + +test('Exa asks for the text extract in the search call', async () => { + // Without contents, every result would need a second fetch to be useful. + let sent = null; + const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' }, + async (url, options) => { sent = { url, options }; return { ok: true, status: 200, json: async () => ({ results: [] }) }; }); + await lib.search('does dexamethasone help croup'); + assert.equal(sent.url, 'https://api.exa.ai/search'); + const body = JSON.parse(sent.options.body); + assert.equal(body.query, 'does dexamethasone help croup'); + assert.ok(body.contents && body.contents.text, 'no text extract requested'); + // 'auto', not 'neural': pinning neural makes it worse at the keyword-shaped + // queries the other providers handle well. + assert.equal(body.type, 'auto'); + assert.equal(sent.options.headers['x-api-key'], 'k', 'Exa authenticates with x-api-key, not a bearer token'); +}); + +test('Exa falls back through its snippet fields rather than returning nothing', async () => { + const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' }, + async () => ({ ok: true, status: 200, json: async () => ({ results: [ + { title: 'A', url: 'https://a', text: 'from text' }, + { title: 'B', url: 'https://b', summary: 'from summary' }, + { title: 'C', url: 'https://c' } + ] }) })); + const out = await lib.search('x'); + assert.deepEqual(out.results.map(r => r.snippet), ['from text', 'from summary', '']); +}); + +test('Exa needs a key, like every provider but SearXNG', async () => { + const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa' }, + async () => { throw new Error('must not be called'); }); + assert.equal(await lib.isAvailable(), false); +}); + +test('the admin can choose it, and the server accepts what the admin can choose', async () => { + // The dropdown and the route validate against the same list; a provider in + // one and not the other is a setting that saves and then does nothing, or an + // option that cannot be saved at all. + const lib = load(ON, async () => ({ ok: true, status: 200, json: async () => ({ results: [] }) })); + const markup = read('public/components/admin.html'); + for (const provider of lib.PROVIDERS) { + assert.match(markup, new RegExp('