From 1242b012864a08d4b5d442de583dcdcbe7a1092b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 01:58:20 +0200 Subject: [PATCH] feat: Exa as a search source; the speech buttons say what they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated small things in the admin. Exa searches by meaning rather than keywords, which suits a clinical question asked as a question — the shape the assistant actually sends. It is also the only provider that returns the page text in the same call, and the snippet is the part the model reads, so asking for it there saves a second fetch per result. type is 'auto' rather than 'neural': pinning neural makes it worse at the keyword-shaped queries the other four handle well. One adapter, one dropdown entry, no other change — the route already validated against webSearch.PROVIDERS, so the backend accepted it the moment the list grew. A test now walks both lists in both directions: a provider the server takes but the admin never offers is unreachable, and one the admin offers but the server rejects is a setting that cannot be saved. The speech and transcription buttons said "Set". Directly beneath them is a Roster card headed "What has been added", and Set is the one thing that does not add anything — there is a single voice and a single model, and choosing one replaces the last. They now say "Make default", and the note says plainly that nothing joins a list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/components/admin.html | 12 +++++--- public/js/admin.js | 6 ++-- src/utils/webSearch.js | 37 ++++++++++++++++++++---- test/web-search.test.js | 55 ++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) 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('