nodebb-plugin-search-agent 0.0.935 → 0.0.936

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.
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.935",
3
+ "version": "0.0.936",
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",
@@ -155,41 +155,52 @@ async function embed(text) {
155
155
  throw new Error('OPENAI_API_KEY environment variable is not set');
156
156
  }
157
157
 
158
- // Remove non-text content
159
- const pureText = extractPureText(text);
160
- if (!pureText) {
161
- throw new Error('embed() received no usable text after filtering');
162
- }
163
- // Split into chunks if too long
164
- const chunks = splitIntoChunks(pureText, MAX_CHARS, CHUNK_OVERLAP);
165
- if (chunks.length === 1) {
166
- const safe = truncate(text);
167
- if (_embedCache.has(safe)) {
168
- winston().verbose('[search-agent] embeddingService: embedding cache hit');
169
- return _embedCache.get(safe);
170
- }
171
- winston().verbose(`[search-agent] embeddingService: generating embedding for text (${safe.length} chars)`);
172
- const response = await withRetry(() => requestEmbeddings(apiKey, safe));
173
- winston().verbose('[search-agent] embeddingService: embedding generated successfully');
174
- const embedding = response.data[0].embedding;
175
- if (_embedCache.size >= EMBED_CACHE_MAX) {
176
- _embedCache.delete(_embedCache.keys().next().value);
177
- }
178
- _embedCache.set(safe, embedding);
179
- return embedding;
180
- } else {
181
- // For multi-chunk, embed all and average
182
- winston().verbose(`[search-agent] embeddingService: splitting long text into ${chunks.length} chunks for embedding`);
183
- const vectors = await embedBatch(chunks);
184
- const avg = averageVectors(vectors);
185
- // Optionally cache the average for the full text
186
- const safe = truncate(text);
187
- if (_embedCache.size >= EMBED_CACHE_MAX) {
188
- _embedCache.delete(_embedCache.keys().next().value);
189
- }
190
- _embedCache.set(safe, avg);
191
- return avg;
192
- }
158
+ // Remove non-text content
159
+ const pureText = extractPureText(text);
160
+ if (!pureText) {
161
+ throw new Error('embed() received no usable text after filtering');
162
+ }
163
+ // Split into chunks if too long
164
+ const chunks = splitIntoChunks(pureText, MAX_CHARS, CHUNK_OVERLAP);
165
+ // Estimate tokens (roughly 1.5 chars/token for non-ASCII, 4 chars/token for ASCII)
166
+ const estimateTokens = (str) => {
167
+ // If mostly ASCII, use 4 chars/token, else 1.5
168
+ const ascii = /^[\x00-\x7F]*$/.test(str);
169
+ return ascii ? Math.ceil(str.length / 4) : Math.ceil(str.length / 1.5);
170
+ };
171
+ if (chunks.length === 1) {
172
+ const safe = truncate(text);
173
+ if (_embedCache.has(safe)) {
174
+ winston().verbose('[search-agent] embeddingService: embedding cache hit');
175
+ return _embedCache.get(safe);
176
+ }
177
+ const tokenCount = estimateTokens(safe);
178
+ winston().info(`[search-agent] embeddingService: generating embedding for text (${safe.length} chars, ~${tokenCount} tokens)`);
179
+ const response = await withRetry(() => requestEmbeddings(apiKey, safe));
180
+ winston().verbose('[search-agent] embeddingService: embedding generated successfully');
181
+ const embedding = response.data[0].embedding;
182
+ if (_embedCache.size >= EMBED_CACHE_MAX) {
183
+ _embedCache.delete(_embedCache.keys().next().value);
184
+ }
185
+ _embedCache.set(safe, embedding);
186
+ return embedding;
187
+ } else {
188
+ // For multi-chunk, embed all and average
189
+ winston().info(`[search-agent] embeddingService: splitting long text into ${chunks.length} chunks for embedding`);
190
+ chunks.forEach((chunk, i) => {
191
+ const tokenCount = estimateTokens(chunk);
192
+ winston().info(`[search-agent] embeddingService: chunk ${i+1}/${chunks.length} — ${chunk.length} chars, ~${tokenCount} tokens`);
193
+ });
194
+ const vectors = await embedBatch(chunks);
195
+ const avg = averageVectors(vectors);
196
+ // Optionally cache the average for the full text
197
+ const safe = truncate(text);
198
+ if (_embedCache.size >= EMBED_CACHE_MAX) {
199
+ _embedCache.delete(_embedCache.keys().next().value);
200
+ }
201
+ _embedCache.set(safe, avg);
202
+ return avg;
203
+ }
193
204
  }
194
205
 
195
206
  /**
@@ -213,20 +224,35 @@ async function embedBatch(texts) {
213
224
  throw new Error('OPENAI_API_KEY environment variable is not set');
214
225
  }
215
226
 
216
- // For each text, filter to pure text, then split and average embeddings
217
- const allChunks = [];
218
- const chunkMap = [];
219
- for (const text of texts) {
220
- const pureText = extractPureText(text);
221
- if (!pureText) {
222
- chunkMap.push({ count: 0 });
223
- continue;
224
- }
225
- const chunks = splitIntoChunks(pureText, MAX_CHARS, CHUNK_OVERLAP);
226
- chunkMap.push({ count: chunks.length });
227
- allChunks.push(...chunks);
228
- }
229
- winston().verbose(`[search-agent] embeddingService: batch embedding ${allChunks.length} chunk(s) from ${texts.length} input(s)`);
227
+ // For each text, filter to pure text, then split and average embeddings
228
+ const allChunks = [];
229
+ const chunkMap = [];
230
+ // Estimate tokens (roughly 1.5 chars/token for non-ASCII, 4 chars/token for ASCII)
231
+ const estimateTokens = (str) => {
232
+ const ascii = /^[\x00-\x7F]*$/.test(str);
233
+ return ascii ? Math.ceil(str.length / 4) : Math.ceil(str.length / 1.5);
234
+ };
235
+ for (const [textIdx, text] of texts.entries()) {
236
+ const pureText = extractPureText(text);
237
+ if (!pureText) {
238
+ chunkMap.push({ count: 0 });
239
+ continue;
240
+ }
241
+ const chunks = splitIntoChunks(pureText, MAX_CHARS, CHUNK_OVERLAP);
242
+ chunkMap.push({ count: chunks.length });
243
+ allChunks.push(...chunks);
244
+ if (chunks.length === 1) {
245
+ const tokenCount = estimateTokens(chunks[0]);
246
+ winston().info(`[search-agent] embeddingService: batch input ${textIdx+1}/${texts.length} — 1 chunk, ${chunks[0].length} chars, ~${tokenCount} tokens`);
247
+ } else {
248
+ winston().info(`[search-agent] embeddingService: batch input ${textIdx+1}/${texts.length} — ${chunks.length} chunks`);
249
+ chunks.forEach((chunk, i) => {
250
+ const tokenCount = estimateTokens(chunk);
251
+ winston().info(`[search-agent] embeddingService: chunk ${i+1}/${chunks.length} — ${chunk.length} chars, ~${tokenCount} tokens`);
252
+ });
253
+ }
254
+ }
255
+ winston().verbose(`[search-agent] embeddingService: batch embedding ${allChunks.length} chunk(s) from ${texts.length} input(s)`);
230
256
  if (allChunks.length === 0) {
231
257
  // All texts were filtered out
232
258
  return chunkMap.map(({ count }) => count === 0 ? [] : null);
@@ -13,7 +13,7 @@ const TOP_K = 50;
13
13
  // Absolute minimum cosine similarity — only filters pure noise (near-zero similarity).
14
14
  // Do NOT raise this: the relevant result often scores lower than irrelevant ones.
15
15
  // The AI re-ranker (which reads content) is the precision gate, not this floor.
16
- const MIN_SCORE = 0.10;
16
+ const MIN_SCORE = 0.15;
17
17
  // Rebuild the Orama index after this interval (mirrors TF-IDF cache TTL)
18
18
  const INDEX_TTL_MS = 5 * 60 * 1000;
19
19