nodebb-plugin-search-agent 0.0.939 → 0.0.942

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.
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Computes the cosine similarity between two numeric vectors.
5
+ * Handles mismatched lengths by using the shorter vector's dimension.
6
+ * Returns 0 if either vector has zero magnitude.
7
+ *
8
+ * @param {number[]} a
9
+ * @param {number[]} b
10
+ * @returns {number} similarity in [-1, 1]
11
+ */
12
+ function cosineSimilarity(a, b) {
13
+ const len = Math.min(a.length, b.length);
14
+ let dot = 0;
15
+ let magA = 0;
16
+ let magB = 0;
17
+
18
+ for (let i = 0; i < len; i++) {
19
+ dot += a[i] * b[i];
20
+ magA += a[i] * a[i];
21
+ magB += b[i] * b[i];
22
+ }
23
+
24
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
25
+ return denom === 0 ? 0 : dot / denom;
26
+ }
27
+
28
+ /**
29
+ * Ranks items by cosine similarity to a query embedding.
30
+ * Each item must have an `embedding` property (number[]).
31
+ *
32
+ * @param {number[]} queryEmbedding
33
+ * @param {Array<{embedding: number[], [key: string]: any}>} items
34
+ * @returns {Array<{item: object, score: number}>} sorted descending by score
35
+ */
36
+ function rankBySimilarity(queryEmbedding, items) {
37
+ return items
38
+ .map(item => ({ item, score: cosineSimilarity(queryEmbedding, item.embedding) }))
39
+ .sort((a, b) => b.score - a.score);
40
+ }
41
+
42
+ module.exports = { cosineSimilarity, rankBySimilarity };
@@ -1,9 +1,3 @@
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
-
7
1
  'use strict';
8
2
 
9
3
  const https = require('https');
@@ -181,18 +175,18 @@ function callOpenAI(apiKey, model, messages) {
181
175
  * @param {string} model
182
176
  * @returns {Promise<string>}
183
177
  */
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
- // }
178
+ async function expandQueryWithHyDE(queryText, apiKey, model) {
179
+ const response = await callOpenAI(apiKey, model, [
180
+ {
181
+ role: 'system',
182
+ content:
183
+ 'אתה חבר בפורום. בהינתן שאלת חיפוש, כתוב פוסט תגובה קצר וריאליסטי בפורום שעונה ישירות על השאלה. ' +
184
+ 'כתוב רק את תוכן הפוסט — ללא ברכות, הערות מטא, או שורת נושא.',
185
+ },
186
+ { role: 'user', content: queryText },
187
+ ]);
188
+ return (response.choices[0].message.content || '').trim() || queryText;
189
+ }
196
190
 
197
191
  /**
198
192
  * Send candidates to OpenAI for independent per-topic relevance scoring.
@@ -204,38 +198,25 @@ function callOpenAI(apiKey, model, messages) {
204
198
  async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxResults, snippetByTid = {}) {
205
199
  console.log('Re-ranking with AI:', { queryText, candidates: candidates.map(c => ({ tid: c.tid, title: (topicMap[String(c.tid)] || {}).title })) });
206
200
 
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');
201
+ // Only send the embedded query and the matched post snippet for each candidate
202
+ const candidateList = candidates
203
+ .map((c) => {
204
+ const raw = (snippetByTid[String(c.tid)] || '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim();
205
+ // Only send the snippet, not the title
206
+ return `[tid:${c.tid}]\n${raw.slice(0, 1500)}`;
207
+ })
208
+ .join('\n\n');
222
209
 
223
210
  const systemPrompt =
224
211
  'אתה מסנן חיפוש פורום מחמיר. ' +
225
- 'לכל מועמד ברשימה, דרג את הרלוונטיות של embedding הפוסט לembedding של השאלה בסקלה 0-10: ' +
212
+ 'לכל מועמד ברשימה, דרג את הרלוונטיות של קטע הפוסט לשאלת המשתמש בסקלה 0-10: ' +
226
213
  '10 = עונה ישירות ובאופן מלא. 7-9 = עונה על חלק משמעותי. 0-6 = לא רלוונטי. ' +
227
214
  'החזר אך ורק JSON תקני במבנה: {"tid": ציון, ...} — לדוגמה: {"42": 9, "15": 3}. ' +
228
- 'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.';
229
-
230
- const userMessage =
231
- `embedding של שאלת המשתמש: [${queryEmbedding.slice(0, 8).map(x => x.toFixed(4)).join(', ')} ...]\n\nפוסטים:\n${candidateList}`;
215
+ 'אין להוסיף הסברים, טקסט נוסף, או עיצוב מחוץ ל-JSON.'+
216
+ 'הוסף שדה נוסף "scoreExplanation" עם משפט קצר שמסביר למה קטע עם ציון נמוך לא רלוונטי.';
232
217
 
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)`);
218
+ const userMessage =
219
+ `שאלת המשתמש (מוטמעת): "${queryText}"\n\nפוסטים:\n${candidateList}`;
239
220
 
240
221
  const response = await callOpenAI(apiKey, model, [
241
222
  { role: 'system', content: systemPrompt },
@@ -255,25 +236,12 @@ async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxR
255
236
  const scores = JSON.parse(match[0]);
256
237
  const candidateByTid = Object.fromEntries(candidates.map(c => [String(c.tid), c]));
257
238
 
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;
239
+ return Object.entries(scores)
240
+ .filter(([, score]) => Number(score) >= 7)
241
+ .sort(([, a], [, b]) => Number(b) - Number(a))
242
+ .slice(0, maxResults)
243
+ .map(([tid]) => candidateByTid[tid])
244
+ .filter(Boolean);
277
245
  }
278
246
 
279
247
  // ─── Public API ───────────────────────────────────────────────────────────────
@@ -305,16 +273,16 @@ async function searchTopics(queryText) {
305
273
  // HyDE: replace the short raw query with a hypothetical answer so the
306
274
  // embedding matches post content more closely.
307
275
  let embeddingQuery = queryText;
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
- // }
276
+ if (useAI && settings.hydeEnabled) {
277
+ try {
278
+ embeddingQuery = await expandQueryWithHyDE(
279
+ queryText, settings.openaiApiKey, settings.openaiModel
280
+ );
281
+ winston.verbose(`[search-agent] HyDE expanded query (${embeddingQuery.length} chars)`);
282
+ } catch (hydeErr) {
283
+ winston.warn(`[search-agent] HyDE expansion failed, using raw query: ${hydeErr.message}`);
284
+ }
285
+ }
318
286
 
319
287
  // Request more candidates when AI will re-rank them.
320
288
  const vectorLimit = useAI ? settings.aiCandidates : settings.maxResults;
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();
33
+ startSync().catch(err => winston.warn(`[search-agent] Initial sync failed: ${err.message}`));
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();
39
+ startSync().catch(err => winston.warn(`[search-agent] Scheduled re-sync failed: ${err.message}`));
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.939",
3
+ "version": "0.0.942",
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",
@@ -490,4 +490,4 @@ module.exports = {
490
490
  embedBatch,
491
491
  extractPureText,
492
492
  splitIntoSemanticChunks,
493
- };
493
+ };
@@ -147,26 +147,6 @@ const STOP_WORDS = new Set([
147
147
  ]);
148
148
 
149
149
  function normalizeHebrew(text) {
150
- if(!text) return text;
151
-
152
- // Remove common prefixes
153
- const prefixes = ['ה', 'ו', 'ב', 'ל', 'מ', 'ש', 'כ'];
154
- for (const prefix of prefixes) {
155
- if (text.startsWith(prefix) && text.length > 3) {
156
- text = text.slice(1);
157
- break;
158
- }
159
- }
160
-
161
- // Remove common plural suffixes
162
- const pluralSuffixes = ['ים', 'ות'];
163
- for (const suffix of pluralSuffixes) {
164
- if (text.endsWith(suffix) && text.length > 3) {
165
- text = text.slice(0, -suffix.length);
166
- break;
167
- }
168
- }
169
-
170
150
  return String(text || '')
171
151
  // remove niqqud / cantillation
172
152
  .replace(/[\u0591-\u05C7]/g, '')
@@ -373,8 +353,8 @@ async function search(query, limit = TOP_K) {
373
353
  ]);
374
354
 
375
355
  const results = await oramaSearch(db, {
376
- // mode: 'hybrid',
377
- query: expanded.term,
356
+ mode: 'hybrid',
357
+ term: expanded.term,
378
358
  properties: SEARCH_PROPERTIES,
379
359
  boost: FIELD_BOOSTS,
380
360
  vector: {
@@ -409,5 +389,5 @@ async function search(query, limit = TOP_K) {
409
389
 
410
390
  module.exports = {
411
391
  search,
412
- invalidateIndex,
392
+ invalidateIndex
413
393
  };
@@ -0,0 +1,15 @@
1
+ const { cosineSimilarity } = require('../lib/cosineSimilarity');
2
+
3
+ function testCosine() {
4
+ console.log("Testing cosine similarity...");
5
+
6
+ const a = cosineSimilarity([1, 0], [1, 0]);
7
+ const b = cosineSimilarity([1, 0], [0, 1]);
8
+
9
+ if (a < 0.9) throw new Error("Expected high similarity");
10
+ if (b > 0.1) throw new Error("Expected low similarity");
11
+
12
+ console.log("✅ Cosine OK");
13
+ }
14
+
15
+ module.exports = testCosine;