nodebb-plugin-integration 0.4.2 → 0.5.0

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/src/ai/search.js CHANGED
@@ -1,283 +1,170 @@
1
1
  "use strict";
2
2
 
3
- /*
4
-
5
- NodeBB Integration Layer
6
-
7
-
8
- Semantic Search
9
-
10
-
11
-
12
- */
13
-
14
-
15
-
16
-
17
- const Embeddings =
18
- require("./embeddings");
19
-
20
- const Qdrant =
21
- require("./qdrant");
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
- async function search(
36
-
37
- query,
38
-
39
- limit = 5
40
-
41
- ) {
42
-
43
-
44
-
45
-
46
- if(!query || !query.trim()) {
47
-
48
-
49
- return [];
50
-
51
-
3
+ const Config = require("../config");
4
+ const Embeddings = require("./embeddings");
5
+ const Qdrant = require("./qdrant");
6
+ const Posts = require("../nodebb/posts");
7
+ const Privileges = require("../nodebb/privileges");
8
+
9
+ const DEFAULT_LIMIT = 5;
10
+ const MAX_LIMIT = 20;
11
+ const OVERFETCH_FACTOR = 5;
12
+
13
+ function createAiDisabledError() {
14
+ const error = new Error("AI provider is disabled. Select OpenAI to enable semantic search.");
15
+ error.code = "AI_DISABLED";
16
+ error.statusCode = 503;
17
+ return error;
52
18
  }
53
19
 
20
+ function validateUid(uid) {
21
+ uid = Number(uid);
54
22
 
23
+ if (!Number.isSafeInteger(uid) || uid < 0) {
24
+ throw new TypeError("Invalid user ID");
25
+ }
55
26
 
27
+ return uid;
28
+ }
56
29
 
30
+ function normalizeLimit(limit) {
31
+ limit = Number(limit);
57
32
 
33
+ if (!Number.isSafeInteger(limit)) {
34
+ return DEFAULT_LIMIT;
35
+ }
58
36
 
37
+ return Math.max(1, Math.min(limit, MAX_LIMIT));
38
+ }
59
39
 
40
+ async function search(query, uid, limit = DEFAULT_LIMIT) {
41
+ const settings = await Config.get();
60
42
 
61
- try {
62
-
63
-
64
-
65
- const vector =
66
-
67
- await Embeddings.create(
68
-
69
- query
70
-
71
- );
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
- if(!vector) {
81
-
82
-
43
+ if (settings.aiProvider !== "openai") {
44
+ throw createAiDisabledError();
45
+ }
83
46
 
47
+ if (typeof query !== "string" || !query.trim()) {
84
48
  return [];
85
-
86
-
87
49
  }
88
50
 
51
+ uid = validateUid(uid);
52
+ limit = normalizeLimit(limit);
89
53
 
54
+ const excludedCategories = Array.isArray(settings.excludedCategories)
55
+ ? settings.excludedCategories
56
+ : [];
90
57
 
58
+ const vector = await Embeddings.create(query.trim());
91
59
 
60
+ if (
61
+ !Array.isArray(vector) ||
62
+ vector.length !== settings.embeddingSize ||
63
+ !vector.every(Number.isFinite)
64
+ ) {
65
+ throw new Error("Invalid query embedding");
66
+ }
92
67
 
93
-
94
-
95
-
96
- const results =
97
-
98
- await Qdrant.search(
99
-
100
- vector,
101
-
102
- limit
103
-
104
- );
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
- return results.map(
114
-
115
- item =>
116
-
117
- ({
118
-
119
-
120
- id:
121
-
122
- item.id,
123
-
124
-
125
-
126
- score:
127
-
128
- item.score,
129
-
130
-
131
-
132
- payload:
133
-
134
- item.payload || {}
135
-
136
-
137
-
138
- })
139
-
68
+ const candidates = await Qdrant.search(
69
+ vector,
70
+ Math.min(limit * OVERFETCH_FACTOR, 100)
140
71
  );
141
72
 
73
+ if (!Array.isArray(candidates) || !candidates.length) {
74
+ return [];
75
+ }
142
76
 
77
+ const allowed = [];
78
+
79
+ for (const candidate of candidates) {
80
+ const payload = candidate.payload || {};
81
+ const pid = Number(payload.pid);
82
+ const tid = Number(payload.tid);
83
+ const cid = Number(payload.cid);
84
+
85
+ if (
86
+ payload.type !== "post" ||
87
+ !Number.isSafeInteger(pid) || pid <= 0 ||
88
+ !Number.isSafeInteger(tid) || tid <= 0 ||
89
+ !Number.isSafeInteger(cid) || cid <= 0
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ if (excludedCategories.includes(cid)) {
95
+ continue;
96
+ }
97
+
98
+ const [postAllowed, topicAllowed] = await Promise.all([
99
+ Privileges.canReadPost(pid, uid),
100
+ Privileges.canReadTopic(tid, uid)
101
+ ]);
102
+
103
+ if (postAllowed && topicAllowed) {
104
+ allowed.push({
105
+ pid,
106
+ tid,
107
+ cid,
108
+ score: Number.isFinite(Number(candidate.score))
109
+ ? Number(candidate.score)
110
+ : 0
111
+ });
112
+ }
113
+
114
+ if (allowed.length >= limit) {
115
+ break;
116
+ }
117
+ }
143
118
 
144
- }
145
-
146
-
147
- catch(error) {
148
-
149
-
150
-
151
- console.error(
152
-
153
- "[Search] error",
154
-
155
- error.message
119
+ if (!allowed.length) {
120
+ return [];
121
+ }
156
122
 
123
+ const posts = await Posts.getPosts(
124
+ allowed.map(item => item.pid),
125
+ uid
157
126
  );
158
127
 
128
+ const postsById = new Map(
129
+ posts.map(post => [Number(post.pid), post])
130
+ );
159
131
 
160
-
161
- return [];
162
-
163
-
164
- }
165
-
166
-
167
-
168
-
132
+ return allowed
133
+ .map(item => {
134
+ const post = postsById.get(item.pid);
135
+
136
+ if (
137
+ !post ||
138
+ post.deleted === true ||
139
+ Number(post.deleted) === 1 ||
140
+ Number(post.pid) !== item.pid ||
141
+ Number(post.tid) !== item.tid ||
142
+ Number(post.cid) !== item.cid ||
143
+ !Number.isSafeInteger(Number(post.uid)) ||
144
+ Number(post.uid) <= 0 ||
145
+ excludedCategories.includes(Number(post.cid))
146
+ ) {
147
+ return null;
148
+ }
149
+
150
+ return {
151
+ pid: item.pid,
152
+ tid: item.tid,
153
+ uid: Number(post.uid),
154
+ cid: item.cid,
155
+ score: item.score,
156
+ content: post.content
157
+ };
158
+ })
159
+ .filter(Boolean)
160
+ .slice(0, limit);
169
161
  }
170
162
 
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
-
181
-
182
-
183
- async function searchText(
184
-
185
- text,
186
-
187
- limit = 5
188
-
189
- ) {
190
-
191
- return search(
192
-
193
- text,
194
-
195
- limit
196
-
197
- );
198
-
163
+ async function searchText(query, uid, limit = DEFAULT_LIMIT) {
164
+ return search(query, uid, limit);
199
165
  }
200
166
 
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
212
-
213
- function formatResults(results) {
214
-
215
-
216
-
217
-
218
- return results.map(
219
-
220
- result =>
221
-
222
- ({
223
-
224
-
225
- score:
226
-
227
- result.score,
228
-
229
-
230
-
231
- text:
232
-
233
- result.payload.text || "",
234
-
235
-
236
-
237
- type:
238
-
239
- result.payload.type || null,
240
-
241
-
242
-
243
- sourceId:
244
-
245
- result.payload.sourceId || null
246
-
247
-
248
-
249
- })
250
-
251
- );
252
-
253
-
254
-
255
-
256
- }
257
-
258
-
259
-
260
-
261
-
262
-
263
-
264
-
265
-
266
-
267
-
268
-
269
-
270
167
  module.exports = {
271
-
272
- search,
273
-
274
-
275
- searchText,
276
-
277
-
278
- formatResults
279
-
280
-
281
-
282
-
283
- };
168
+ search,
169
+ searchText
170
+ };