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 @@
Loading...Loading...
- Set makes a voice or model the default for Read Aloud.
+ There is one voice and one model for Read Aloud. Make default chooses them; nothing is added to a list.
Loading...Loading...
- Set makes a model the default for dictation.
+ There is one model for dictation. Make default chooses it; nothing is added to a list.
@@ -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.
';
@@ -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('