nodebb-plugin-search-agent 0.0.92 → 0.0.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,9 @@
1
+ // ─── Token estimation helper ───────────────────────────────────────────────
2
+ function estimateTokens(str) {
3
+ // Roughly 4 chars/token for English, 2 for Hebrew/UTF-8, but 4 is safe for cost estimation
4
+ return Math.ceil(str.length / 4);
5
+ }
6
+
1
7
  'use strict';
2
8
 
3
9
  const https = require('https');
@@ -10,10 +16,42 @@ let cachedTopicMap = null;
10
16
  let cacheTs = 0;
11
17
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
18
 
19
+ // ─── Search result cache ──────────────────────────────────────────────────────
20
+ // Caches final search results by normalised query string.
21
+ // Saves all AI calls for repeated queries within the TTL window.
22
+
23
+ const _searchCache = new Map();
24
+ const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
25
+ const SEARCH_CACHE_MAX = 200;
26
+
27
+ function _normalizeQuery(q) {
28
+ return q.trim().toLowerCase().replace(/\s+/g, ' ');
29
+ }
30
+
31
+ function _getSearchCache(queryText) {
32
+ const key = _normalizeQuery(queryText);
33
+ const entry = _searchCache.get(key);
34
+ if (entry && (Date.now() - entry.ts) < SEARCH_CACHE_TTL_MS) {
35
+ return entry.results;
36
+ }
37
+ _searchCache.delete(key);
38
+ return null;
39
+ }
40
+
41
+ function _setSearchCache(queryText, results) {
42
+ const key = _normalizeQuery(queryText);
43
+ _searchCache.set(key, { results, ts: Date.now() });
44
+ if (_searchCache.size > SEARCH_CACHE_MAX) {
45
+ // Evict the oldest entry
46
+ _searchCache.delete(_searchCache.keys().next().value);
47
+ }
48
+ }
49
+
13
50
  function invalidateCache() {
14
51
  cachedIndex = null;
15
52
  cachedTopicMap = null;
16
53
  cacheTs = 0;
54
+ _searchCache.clear();
17
55
  require.main.require('winston').info('[search-agent] Topic index cache invalidated.');
18
56
  }
19
57
 
@@ -30,6 +68,8 @@ async function getSettings() {
30
68
  openaiModel: (raw.openaiModel || 'gpt-4o-mini').trim(),
31
69
  // How many TF-IDF candidates to send to AI for re-ranking
32
70
  aiCandidates: Math.min(100, Math.max(5, parseInt(raw.aiCandidates, 10) || 30)),
71
+ // HyDE: generate a hypothetical answer before embedding — improves recall but costs one extra LLM call per search
72
+ hydeEnabled: raw.hydeEnabled === 'on',
33
73
  // Visibility: 'all' = all logged-in users, 'admins' = administrators only
34
74
  visibleTo: raw.visibleTo || 'all',
35
75
  // Whether guests (non-logged-in users) may use the widget
@@ -141,18 +181,18 @@ function callOpenAI(apiKey, model, messages) {
141
181
  * @param {string} model
142
182
  * @returns {Promise<string>}
143
183
  */
144
- async function expandQueryWithHyDE(queryText, apiKey, model) {
145
- const response = await callOpenAI(apiKey, model, [
146
- {
147
- role: 'system',
148
- content:
149
- 'אתה חבר בפורום. בהינתן שאלת חיפוש, כתוב פוסט תגובה קצר וריאליסטי בפורום שעונה ישירות על השאלה. ' +
150
- 'כתוב רק את תוכן הפוסט — ללא ברכות, הערות מטא, או שורת נושא.',
151
- },
152
- { role: 'user', content: queryText },
153
- ]);
154
- return (response.choices[0].message.content || '').trim() || queryText;
155
- }
184
+ // async function expandQueryWithHyDE(queryText, apiKey, model) {
185
+ // const response = await callOpenAI(apiKey, model, [
186
+ // {
187
+ // role: 'system',
188
+ // content:
189
+ // 'אתה חבר בפורום. בהינתן שאלת חיפוש, כתוב פוסט תגובה קצר וריאליסטי בפורום שעונה ישירות על השאלה. ' +
190
+ // 'כתוב רק את תוכן הפוסט — ללא ברכות, הערות מטא, או שורת נושא.',
191
+ // },
192
+ // { role: 'user', content: queryText },
193
+ // ]);
194
+ // return (response.choices[0].message.content || '').trim() || queryText;
195
+ // }
156
196
 
157
197
  /**
158
198
  * Send candidates to OpenAI for independent per-topic relevance scoring.
@@ -163,25 +203,39 @@ async function expandQueryWithHyDE(queryText, apiKey, model) {
163
203
  */
164
204
  async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxResults, snippetByTid = {}) {
165
205
  console.log('Re-ranking with AI:', { queryText, candidates: candidates.map(c => ({ tid: c.tid, title: (topicMap[String(c.tid)] || {}).title })) });
166
- const candidateList = candidates
167
- .map((c) => {
168
- const title = (topicMap[String(c.tid)] || {}).title || '';
169
- const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
170
- const snippet = raw.length > 0 ? `\n תוכן: "${raw.slice(0, 1500)}"` : '';
171
- return `[tid:${c.tid}] ${title}${snippet}`;
172
- })
173
- .join('\n\n');
206
+
207
+
208
+ // Embed the query and all candidate post snippets
209
+
210
+ const { embed, embedBatch } = require('../services/embeddingService');
211
+ const queryEmbedding = await embed(queryText);
212
+ const postSnippets = candidates.map((c) => {
213
+ const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
214
+ return raw.slice(0, 1500);
215
+ });
216
+ const postEmbeddings = await embedBatch(postSnippets);
217
+
218
+ // Format: [tid:..., embedding: [v1, v2, ...]]
219
+ const candidateList = candidates.map((c, i) => {
220
+ return `[tid:${c.tid}]\nembedding: [${postEmbeddings[i].slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]`;
221
+ }).join('\n\n');
174
222
 
175
223
  const systemPrompt =
176
224
  'אתה מסנן חיפוש פורום מחמיר. ' +
177
- 'לכל נושא ברשימה, דרג את הרלוונטיות שלו לשאלת המשתמש בסקלה 0-10: ' +
225
+ 'לכל מועמד ברשימה, דרג את הרלוונטיות של embedding הפוסט לembedding של השאלה בסקלה 0-10: ' +
178
226
  '10 = עונה ישירות ובאופן מלא. 7-9 = עונה על חלק משמעותי. 0-6 = לא רלוונטי. ' +
179
227
  'החזר אך ורק JSON תקני במבנה: {"tid": ציון, ...} — לדוגמה: {"42": 9, "15": 3}. ' +
180
- 'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.'+
181
- 'הוסף שדה נוסף "scoreExplanation" עם משפט קצר שמסביר למה נושא עם ציון נמוך לא רלוונטי, כדי שנוכל להבין את שיקול הדעת של המודל.';
182
-
228
+ 'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.';
229
+
183
230
  const userMessage =
184
- `שאלת המשתמש: "${queryText}"\n\nנושאים:\n${candidateList}`;
231
+ `embedding של שאלת המשתמש: [${queryEmbedding.slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]\n\nפוסטים:\n${candidateList}`;
232
+
233
+ // --- Token count logging ---
234
+ const totalEmbeddingChars = queryText.length + postSnippets.reduce((sum, s) => sum + s.length, 0);
235
+ const embeddingTokens = estimateTokens(queryText) + postSnippets.reduce((sum, s) => sum + estimateTokens(s), 0);
236
+ const llmPromptTokens = estimateTokens(systemPrompt) + estimateTokens(userMessage);
237
+ const winston = require.main.require('winston');
238
+ winston.info(`[search-agent] Token usage: embedding API ≈ ${embeddingTokens} tokens, LLM prompt ≈ ${llmPromptTokens} tokens (for this search)`);
185
239
 
186
240
  const response = await callOpenAI(apiKey, model, [
187
241
  { role: 'system', content: systemPrompt },
@@ -201,12 +255,25 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
201
255
  const scores = JSON.parse(match[0]);
202
256
  const candidateByTid = Object.fromEntries(candidates.map(c => [String(c.tid), c]));
203
257
 
204
- return Object.entries(scores)
205
- .filter(([, score]) => Number(score) >= 7)
206
- .sort(([, a], [, b]) => Number(b) - Number(a))
207
- .slice(0, maxResults)
208
- .map(([tid]) => candidateByTid[tid])
209
- .filter(Boolean);
258
+ let filtered = Object.entries(scores)
259
+ .filter(([, score]) => Number(score) >= 7)
260
+ .sort(([, a], [, b]) => Number(b) - Number(a))
261
+ .slice(0, maxResults)
262
+ .map(([tid]) => candidateByTid[tid])
263
+ .filter(Boolean);
264
+
265
+ // If nothing passed the threshold, return the top scoring candidate (if any)
266
+ if (filtered.length === 0 && candidates.length > 0) {
267
+ // Find the tid with the highest score
268
+ const sortedAll = Object.entries(scores)
269
+ .sort(([, a], [, b]) => Number(b) - Number(a));
270
+ if (sortedAll.length > 0) {
271
+ const [topTid] = sortedAll[0];
272
+ const topCandidate = candidateByTid[topTid];
273
+ if (topCandidate) filtered = [topCandidate];
274
+ }
275
+ }
276
+ return filtered;
210
277
  }
211
278
 
212
279
  // ─── Public API ───────────────────────────────────────────────────────────────
@@ -220,6 +287,14 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
220
287
  */
221
288
  async function searchTopics(queryText) {
222
289
  const winston = require.main.require('winston');
290
+
291
+ // ── Search result cache ───────────────────────────────────────────────────
292
+ const cachedResults = _getSearchCache(queryText);
293
+ if (cachedResults) {
294
+ winston.verbose(`[search-agent] Search cache hit for "${queryText}" (${cachedResults.length} results)`);
295
+ return cachedResults;
296
+ }
297
+
223
298
  const settings = await getSettings();
224
299
 
225
300
  // ── Semantic search (primary) ────────────────────────────────────────────
@@ -230,16 +305,16 @@ async function searchTopics(queryText) {
230
305
  // HyDE: replace the short raw query with a hypothetical answer so the
231
306
  // embedding matches post content more closely.
232
307
  let embeddingQuery = queryText;
233
- if (useAI) {
234
- try {
235
- embeddingQuery = await expandQueryWithHyDE(
236
- queryText, settings.openaiApiKey, settings.openaiModel
237
- );
238
- winston.verbose(`[search-agent] HyDE expanded query (${embeddingQuery.length} chars)`);
239
- } catch (hydeErr) {
240
- winston.warn(`[search-agent] HyDE expansion failed, using raw query: ${hydeErr.message}`);
241
- }
242
- }
308
+ // if (useAI && settings.hydeEnabled) {
309
+ // try {
310
+ // embeddingQuery = await expandQueryWithHyDE(
311
+ // queryText, settings.openaiApiKey, settings.openaiModel
312
+ // );
313
+ // winston.verbose(`[search-agent] HyDE expanded query (${embeddingQuery.length} chars)`);
314
+ // } catch (hydeErr) {
315
+ // winston.warn(`[search-agent] HyDE expansion failed, using raw query: ${hydeErr.message}`);
316
+ // }
317
+ // }
243
318
 
244
319
  // Request more candidates when AI will re-rank them.
245
320
  const vectorLimit = useAI ? settings.aiCandidates : settings.maxResults;
@@ -314,6 +389,7 @@ async function searchTopics(queryText) {
314
389
 
315
390
  if (results.length > 0) {
316
391
  winston.info(`[search-agent] Semantic search returned ${results.length} results for "${queryText}".`);
392
+ _setSearchCache(queryText, results);
317
393
  return results;
318
394
  }
319
395
  }
@@ -369,6 +445,7 @@ async function searchTopics(queryText) {
369
445
  url: `/topic/${(topicMap[String(r.tid)] || {}).slug || r.tid}`,
370
446
  }));
371
447
  winston.info(`[search-agent] Final results: ${JSON.stringify(results.map(r => r.title))}`);
448
+ _setSearchCache(queryText, results);
372
449
  return results;
373
450
  }
374
451
 
package/library.js CHANGED
@@ -30,13 +30,13 @@ plugin.init = async (params) => {
30
30
 
31
31
  // Start initial embedding sync in the background — does not block NodeBB startup.
32
32
  winston.info('[search-agent] Starting initial embedding sync…');
33
- startSync().catch(err => winston.warn(`[search-agent] Initial sync failed: ${err.message}`));
33
+ startSync();
34
34
 
35
35
  // Re-sync every 10 minutes to pick up new posts.
36
36
  const RESYNC_INTERVAL_MS = 10 * 60 * 1000;
37
37
  setInterval(() => {
38
38
  winston.info('[search-agent] Running scheduled embedding re-sync…');
39
- startSync().catch(err => winston.warn(`[search-agent] Scheduled re-sync failed: ${err.message}`));
39
+ startSync();
40
40
  }, RESYNC_INTERVAL_MS).unref();
41
41
 
42
42
  winston.info('[plugins/search-agent] Initialised.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-search-agent",
3
- "version": "0.0.92",
3
+ "version": "0.0.94",
4
4
  "description": "NodeBB plugin that adds a floating chat assistant to help users find relevant forum topics using TF-IDF text similarity",
5
5
  "main": "library.js",
6
6
  "author": "Racheli Bayfus",