strapi-plugin-faqchatbot 1.0.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/README.md +3 -0
- package/dist/_chunks/App-B02Agl-6.mjs +25759 -0
- package/dist/_chunks/App-Cn0wk9Bd.js +25758 -0
- package/dist/_chunks/en-B4KWt_jN.js +4 -0
- package/dist/_chunks/en-Byx4XI2L.mjs +4 -0
- package/dist/admin/index.js +64 -0
- package/dist/admin/index.mjs +65 -0
- package/dist/server/index.js +903 -0
- package/dist/server/index.mjs +902 -0
- package/package.json +79 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const OpenAI = require("openai");
|
|
3
|
+
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
4
|
+
const OpenAI__default = /* @__PURE__ */ _interopDefault(OpenAI);
|
|
5
|
+
const bootstrap = ({ strapi }) => {
|
|
6
|
+
const UID = "plugin::faqchatbot.faqqa";
|
|
7
|
+
const updateEmbedding = async (params, existingEntry) => {
|
|
8
|
+
const { data } = params;
|
|
9
|
+
const question = data.question ?? existingEntry?.question;
|
|
10
|
+
const answer = data.answer ?? existingEntry?.answer;
|
|
11
|
+
if (!question || !answer) return;
|
|
12
|
+
const textToEmbed = `Q: ${question}
|
|
13
|
+
A: ${answer}`;
|
|
14
|
+
const embedding = await strapi.plugin("faqchatbot").service("embed").generateEmbedding(textToEmbed);
|
|
15
|
+
if (embedding) {
|
|
16
|
+
data.embedding = embedding;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
strapi.db.lifecycles.subscribe({
|
|
20
|
+
models: [UID],
|
|
21
|
+
async beforeCreate(event) {
|
|
22
|
+
await updateEmbedding(event.params);
|
|
23
|
+
},
|
|
24
|
+
async beforeUpdate(event) {
|
|
25
|
+
const { where } = event.params;
|
|
26
|
+
const existingEntry = await strapi.db.query(UID).findOne({ where });
|
|
27
|
+
await updateEmbedding(event.params, existingEntry);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const destroy = ({ strapi }) => {
|
|
32
|
+
};
|
|
33
|
+
const register = ({ strapi }) => {
|
|
34
|
+
};
|
|
35
|
+
const config$2 = {
|
|
36
|
+
default: {},
|
|
37
|
+
validator() {
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const kind = "collectionType";
|
|
41
|
+
const collectionName = "chatbot_config_faqqas";
|
|
42
|
+
const info = {
|
|
43
|
+
singularName: "faqqa",
|
|
44
|
+
pluralName: "faqqas",
|
|
45
|
+
displayName: "Chatbot-FAQ"
|
|
46
|
+
};
|
|
47
|
+
const options = {
|
|
48
|
+
draftAndPublish: true
|
|
49
|
+
};
|
|
50
|
+
const attributes = {
|
|
51
|
+
question: {
|
|
52
|
+
type: "string",
|
|
53
|
+
required: true
|
|
54
|
+
},
|
|
55
|
+
answer: {
|
|
56
|
+
type: "richtext",
|
|
57
|
+
required: true
|
|
58
|
+
},
|
|
59
|
+
embedding: {
|
|
60
|
+
type: "json"
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const schema = {
|
|
64
|
+
kind,
|
|
65
|
+
collectionName,
|
|
66
|
+
info,
|
|
67
|
+
options,
|
|
68
|
+
attributes
|
|
69
|
+
};
|
|
70
|
+
const faqqa = { schema };
|
|
71
|
+
const contentTypes = {
|
|
72
|
+
faqqa
|
|
73
|
+
};
|
|
74
|
+
const controller = ({ strapi }) => ({
|
|
75
|
+
index(ctx) {
|
|
76
|
+
ctx.body = strapi.plugin("faqchatbot").service("service").getWelcomeMessage();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const config$1 = ({ strapi }) => ({
|
|
80
|
+
async index(ctx) {
|
|
81
|
+
const settings = await strapi.plugin("faqchatbot").service("config").getConfig();
|
|
82
|
+
const contentTypes2 = Object.values(strapi.contentTypes).filter((ct) => ct.uid.startsWith("api::")).map((ct) => ({
|
|
83
|
+
uid: ct.uid,
|
|
84
|
+
displayName: ct.info.displayName,
|
|
85
|
+
attributes: Object.keys(ct.attributes).map((attr) => ({
|
|
86
|
+
name: attr
|
|
87
|
+
}))
|
|
88
|
+
}));
|
|
89
|
+
ctx.body = {
|
|
90
|
+
settings,
|
|
91
|
+
contentTypes: contentTypes2
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
async update(ctx) {
|
|
95
|
+
const settings = ctx.request.body;
|
|
96
|
+
const data = await strapi.plugin("faqchatbot").service("config").setConfig(settings);
|
|
97
|
+
ctx.body = data;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const openai$1 = new OpenAI__default.default({
|
|
101
|
+
apiKey: process.env.OPENAI_API_KEY
|
|
102
|
+
});
|
|
103
|
+
async function getContactLink(strapi) {
|
|
104
|
+
const pluginStore = strapi.store({
|
|
105
|
+
environment: null,
|
|
106
|
+
type: "plugin",
|
|
107
|
+
name: "faqchatbot"
|
|
108
|
+
});
|
|
109
|
+
const settings = await pluginStore.get({ key: "settings" });
|
|
110
|
+
return settings?.contactLink || null;
|
|
111
|
+
}
|
|
112
|
+
async function getInstructions(strapi) {
|
|
113
|
+
const pluginStore = strapi.store({
|
|
114
|
+
environment: null,
|
|
115
|
+
type: "plugin",
|
|
116
|
+
name: "faqchatbot"
|
|
117
|
+
});
|
|
118
|
+
const settings = await pluginStore.get({ key: "settings" });
|
|
119
|
+
return {
|
|
120
|
+
system: settings?.systemInstructions || "",
|
|
121
|
+
response: settings?.responseInstructions || ""
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function getActiveCollections(strapi) {
|
|
125
|
+
try {
|
|
126
|
+
const pluginStore = strapi.store({
|
|
127
|
+
environment: null,
|
|
128
|
+
type: "plugin",
|
|
129
|
+
name: "faqchatbot"
|
|
130
|
+
});
|
|
131
|
+
const settings = await pluginStore.get({ key: "collections" });
|
|
132
|
+
if (!settings) return [];
|
|
133
|
+
const activeList = [];
|
|
134
|
+
for (const item of settings) {
|
|
135
|
+
const ignored = ["faqitem", "item"];
|
|
136
|
+
const name = item.name.toLowerCase();
|
|
137
|
+
const hasEnabledFields = item.fields?.some((f) => f.enabled);
|
|
138
|
+
if (!hasEnabledFields || ignored.includes(name)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const uid = `api::${item.name}.${item.name}`;
|
|
142
|
+
const contentType = strapi.contentTypes[uid];
|
|
143
|
+
if (!contentType) {
|
|
144
|
+
console.warn(` [WARNING] Content type not found for UID: ${uid}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const fields = Object.keys(contentType.attributes).filter((key) => {
|
|
148
|
+
const attr = contentType.attributes[key];
|
|
149
|
+
return [
|
|
150
|
+
"string",
|
|
151
|
+
"text",
|
|
152
|
+
"email",
|
|
153
|
+
"uid",
|
|
154
|
+
"richtext",
|
|
155
|
+
"enumeration",
|
|
156
|
+
"integer",
|
|
157
|
+
"biginteger",
|
|
158
|
+
"decimal",
|
|
159
|
+
"float",
|
|
160
|
+
"date",
|
|
161
|
+
"datetime",
|
|
162
|
+
"time",
|
|
163
|
+
"relation"
|
|
164
|
+
].includes(attr.type);
|
|
165
|
+
});
|
|
166
|
+
activeList.push({ name: item.name, fields });
|
|
167
|
+
}
|
|
168
|
+
return activeList;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error(" [ERROR] Error loading active collections:", err);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function rephraseQuestion(history, question) {
|
|
175
|
+
if (!history || !Array.isArray(history) || history.length === 0) {
|
|
176
|
+
return question;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const response = await openai$1.chat.completions.create({
|
|
180
|
+
model: "gpt-4o-mini",
|
|
181
|
+
temperature: 0,
|
|
182
|
+
messages: [
|
|
183
|
+
{
|
|
184
|
+
role: "system",
|
|
185
|
+
content: `You are a Search Query Optimizer.
|
|
186
|
+
Your task is to determine if the user's new message is a **Follow-up** or a **New Topic** and if a follow-up just rewrite the question .
|
|
187
|
+
Do NOT return any explanations, only the optimized search string.
|
|
188
|
+
|
|
189
|
+
### RULES
|
|
190
|
+
1. **Dependency Check (The "Pronoun" Rule):**
|
|
191
|
+
- ONLY combine with history if the new question contains **Pronouns** ("it", "that", "they") or is **Grammatically Incomplete** ("How much?", "Where do I buy?", "Is it refundable?").
|
|
192
|
+
|
|
193
|
+
2. **Independence Check (The "Specifics" Rule):**
|
|
194
|
+
- If the user asks a complete question containing a **New Specific Noun** or **Scenario** (e.g., "Group of 7 people", "Booking for pets"), treat it as a **Standalone Query**.
|
|
195
|
+
- **Do NOT** attach the previous topic to it.
|
|
196
|
+
- *Example:* History="Commuter Pass", Input="Can I book for a group of 7?" -> Output="Group booking for 7 people" (Correct).
|
|
197
|
+
- *Bad Output:* "Group booking for Commuter Pass" (Incorrect).
|
|
198
|
+
|
|
199
|
+
3. **Output:**
|
|
200
|
+
- Return ONLY the optimized search string.`
|
|
201
|
+
},
|
|
202
|
+
...history.slice(-4),
|
|
203
|
+
{ role: "user", content: question }
|
|
204
|
+
]
|
|
205
|
+
});
|
|
206
|
+
const rewritten = response.choices[0].message.content?.trim();
|
|
207
|
+
if (!rewritten) return question;
|
|
208
|
+
const lower = rewritten.toLowerCase();
|
|
209
|
+
if (lower.includes("unavailable") || lower.includes("sorry") || lower.includes("i am") || lower.includes("cannot") || rewritten.length > 120) {
|
|
210
|
+
return question;
|
|
211
|
+
}
|
|
212
|
+
return rewritten;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("Error in rephraseQuestion:", err);
|
|
215
|
+
return question;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function sanitizeFilters(filters) {
|
|
219
|
+
if (!filters || typeof filters !== "object") return filters;
|
|
220
|
+
if (Array.isArray(filters)) {
|
|
221
|
+
return filters.map(sanitizeFilters);
|
|
222
|
+
}
|
|
223
|
+
const operators = [
|
|
224
|
+
"eq",
|
|
225
|
+
"ne",
|
|
226
|
+
"lt",
|
|
227
|
+
"gt",
|
|
228
|
+
"lte",
|
|
229
|
+
"gte",
|
|
230
|
+
"in",
|
|
231
|
+
"notIn",
|
|
232
|
+
"contains",
|
|
233
|
+
"notContains",
|
|
234
|
+
"containsi",
|
|
235
|
+
"notContainsi",
|
|
236
|
+
"null",
|
|
237
|
+
"notNull",
|
|
238
|
+
"between",
|
|
239
|
+
"startsWith",
|
|
240
|
+
"endsWith",
|
|
241
|
+
"or",
|
|
242
|
+
"and",
|
|
243
|
+
"not"
|
|
244
|
+
];
|
|
245
|
+
const newFilters = {};
|
|
246
|
+
for (const key in filters) {
|
|
247
|
+
let newKey = key;
|
|
248
|
+
if (operators.includes(key) && !key.startsWith("$")) {
|
|
249
|
+
newKey = `$${key}`;
|
|
250
|
+
}
|
|
251
|
+
newFilters[newKey] = sanitizeFilters(filters[key]);
|
|
252
|
+
}
|
|
253
|
+
return newFilters;
|
|
254
|
+
}
|
|
255
|
+
function updateJsonContext(prevContext, question) {
|
|
256
|
+
const MAX_HISTORY = 10;
|
|
257
|
+
const ctx = { ...prevContext || {} };
|
|
258
|
+
ctx.history = Array.isArray(ctx.history) ? ctx.history : [];
|
|
259
|
+
ctx.history.push(question);
|
|
260
|
+
if (ctx.history.length > MAX_HISTORY) ctx.history.shift();
|
|
261
|
+
const words = question.toLowerCase().replace(/[^\w\s]/g, "").split(" ").filter((w) => w.length > 3);
|
|
262
|
+
ctx.keywords = [.../* @__PURE__ */ new Set([...ctx.keywords || [], ...words])];
|
|
263
|
+
ctx.lastQuestion = question;
|
|
264
|
+
return ctx;
|
|
265
|
+
}
|
|
266
|
+
async function searchRealtime(strapi, plan, activeCollections) {
|
|
267
|
+
if (!plan || !plan.collection) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const sanitizedFilters = sanitizeFilters(plan.filters || {});
|
|
271
|
+
const config2 = activeCollections.find((c) => c.name === plan.collection);
|
|
272
|
+
if (!config2) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const uid = `api::${plan.collection}.${plan.collection}`;
|
|
276
|
+
try {
|
|
277
|
+
if (plan.operation === "count") {
|
|
278
|
+
const count = await strapi.entityService.count(uid, {
|
|
279
|
+
filters: sanitizedFilters
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
type: "count",
|
|
283
|
+
collection: plan.collection,
|
|
284
|
+
value: count
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const result = await strapi.entityService.findMany(uid, {
|
|
288
|
+
filters: sanitizedFilters,
|
|
289
|
+
sort: plan.sort,
|
|
290
|
+
limit: 10
|
|
291
|
+
});
|
|
292
|
+
const cleaned = result.map((row) => {
|
|
293
|
+
const clean = {};
|
|
294
|
+
for (const f of config2.fields) clean[f] = row[f];
|
|
295
|
+
return clean;
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
type: "list",
|
|
299
|
+
collection: plan.collection,
|
|
300
|
+
schema: config2.fields,
|
|
301
|
+
items: cleaned
|
|
302
|
+
};
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error("Realtime search error:", err);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function cosineSimilarity(a, b) {
|
|
309
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
310
|
+
let dot = 0;
|
|
311
|
+
let normA = 0;
|
|
312
|
+
let normB = 0;
|
|
313
|
+
for (let i = 0; i < a.length; i++) {
|
|
314
|
+
dot += a[i] * b[i];
|
|
315
|
+
normA += a[i] * a[i];
|
|
316
|
+
normB += b[i] * b[i];
|
|
317
|
+
}
|
|
318
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
319
|
+
}
|
|
320
|
+
async function searchFAQ(question, strapi) {
|
|
321
|
+
const embedding = await openai$1.embeddings.create({
|
|
322
|
+
model: "text-embedding-3-small",
|
|
323
|
+
input: question
|
|
324
|
+
});
|
|
325
|
+
let queryVector = embedding.data[0].embedding;
|
|
326
|
+
if (!queryVector || !queryVector.length) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const faqs = await strapi.db.connection("chatbot_config_faqqas").select("answer", "embedding").whereNotNull("embedding").whereNotNull("published_at");
|
|
330
|
+
if (!faqs.length) return [];
|
|
331
|
+
const scored = faqs.map((f) => {
|
|
332
|
+
let dbVector = f.embedding;
|
|
333
|
+
try {
|
|
334
|
+
if (typeof dbVector === "string") {
|
|
335
|
+
dbVector = JSON.parse(dbVector);
|
|
336
|
+
}
|
|
337
|
+
dbVector = Array.isArray(dbVector) ? dbVector.map((n) => Number(n)) : [];
|
|
338
|
+
if (!Array.isArray(dbVector) || dbVector.length !== queryVector.length) {
|
|
339
|
+
return { answer: f.answer, similarity: 0 };
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
answer: f.answer,
|
|
343
|
+
similarity: cosineSimilarity(queryVector, dbVector)
|
|
344
|
+
};
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return { answer: f.answer, similarity: 0 };
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
scored.sort((a, b) => b.similarity - a.similarity);
|
|
350
|
+
if (!scored.length || scored[0].similarity < 0.4) {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
return scored.slice(0, 3).map((s) => s.answer);
|
|
354
|
+
}
|
|
355
|
+
async function simplePlanner(question, activeCollections, instructions) {
|
|
356
|
+
const response = await openai$1.chat.completions.create({
|
|
357
|
+
model: "gpt-4o-mini",
|
|
358
|
+
temperature: 0,
|
|
359
|
+
messages: [
|
|
360
|
+
{
|
|
361
|
+
role: "system",
|
|
362
|
+
content: `
|
|
363
|
+
${instructions.system || ""}
|
|
364
|
+
You are a STRICT database query planner that converts user questions into Strapi query JSON.
|
|
365
|
+
|
|
366
|
+
--------------------------------
|
|
367
|
+
CORE TASK
|
|
368
|
+
--------------------------------
|
|
369
|
+
Return ONLY valid JSON. No text. No explanation.
|
|
370
|
+
|
|
371
|
+
--------------------------------
|
|
372
|
+
COLLECTION SELECTION
|
|
373
|
+
--------------------------------
|
|
374
|
+
- Choose the most relevant collection from the available list.
|
|
375
|
+
- Never invent collection names.
|
|
376
|
+
|
|
377
|
+
--------------------------------
|
|
378
|
+
FIELD RULES
|
|
379
|
+
--------------------------------
|
|
380
|
+
- Only use fields that exist in the selected collection schema.
|
|
381
|
+
- Never hallucinate fields.
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
--------------------------------
|
|
385
|
+
LOCATION NORMALIZATION (CRITICAL)
|
|
386
|
+
--------------------------------
|
|
387
|
+
The database stores locations in the format:
|
|
388
|
+
City Name (AIRPORT_CODE)
|
|
389
|
+
|
|
390
|
+
Before generating filters, you MUST normalize
|
|
391
|
+
all user-provided places into the nearest
|
|
392
|
+
major city or airport name.
|
|
393
|
+
|
|
394
|
+
RULES:
|
|
395
|
+
|
|
396
|
+
1. SMALL TOWNS / VILLAGES
|
|
397
|
+
- Convert to nearest major airport city.
|
|
398
|
+
Example:
|
|
399
|
+
"Kalveerampalayam" → "Coimbatore"
|
|
400
|
+
"Kollam" → "Trivandrum"
|
|
401
|
+
"Alappuzha" → "Kochi"
|
|
402
|
+
|
|
403
|
+
2. OLD OR LOCAL NAMES
|
|
404
|
+
- Convert to modern official city name.
|
|
405
|
+
Example:
|
|
406
|
+
"Madras" → "Chennai"
|
|
407
|
+
"Cochin" → "Kochi"
|
|
408
|
+
"Bombay" → "Mumbai"
|
|
409
|
+
|
|
410
|
+
3. SUBURBS / DISTRICTS
|
|
411
|
+
- Convert to main metro city.
|
|
412
|
+
Example:
|
|
413
|
+
"Brooklyn" → "New York"
|
|
414
|
+
"Noida" → "Delhi"
|
|
415
|
+
|
|
416
|
+
4. AIRPORT CODES
|
|
417
|
+
- If user provides code (COK, MAA, JFK),
|
|
418
|
+
search using containsi for that code.
|
|
419
|
+
|
|
420
|
+
Example:
|
|
421
|
+
User: "flight from COK"
|
|
422
|
+
Filter:
|
|
423
|
+
{ "origin": { "containsi": "COK" } }
|
|
424
|
+
|
|
425
|
+
5. ALWAYS MATCH DATABASE STRINGS
|
|
426
|
+
- Use containsi
|
|
427
|
+
- Never use raw spelling if DB format differs
|
|
428
|
+
- Prefer airport code if available
|
|
429
|
+
|
|
430
|
+
--------------------------------
|
|
431
|
+
TEXT FILTER RULES (VERY IMPORTANT)
|
|
432
|
+
--------------------------------
|
|
433
|
+
- For city names, titles, destinations, names → ALWAYS use "containsi"
|
|
434
|
+
- NEVER use "eq" for text
|
|
435
|
+
- NEVER use "in" for text arrays
|
|
436
|
+
- For multiple text values use "$or" with containsi
|
|
437
|
+
|
|
438
|
+
Example:
|
|
439
|
+
User: "flight to paris or amsterdam"
|
|
440
|
+
Filters:
|
|
441
|
+
{
|
|
442
|
+
"$or": [
|
|
443
|
+
{ "destination": { "containsi": "paris" } },
|
|
444
|
+
{ "destination": { "containsi": "amsterdam" } }
|
|
445
|
+
]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
--------------------------------
|
|
449
|
+
NUMBER FILTER RULES
|
|
450
|
+
--------------------------------
|
|
451
|
+
- For price, fare, amount → use lt, lte, gt, gte, between
|
|
452
|
+
- "under" → lte
|
|
453
|
+
- "above" → gte
|
|
454
|
+
- "between" → between
|
|
455
|
+
|
|
456
|
+
--------------------------------
|
|
457
|
+
OPERATION RULES
|
|
458
|
+
--------------------------------
|
|
459
|
+
- "how many", "count" → operation = "count"
|
|
460
|
+
- otherwise → operation = "list"
|
|
461
|
+
|
|
462
|
+
--------------------------------
|
|
463
|
+
SORT RULES
|
|
464
|
+
--------------------------------
|
|
465
|
+
- "cheapest", "lowest" → sort ["fare:asc"]
|
|
466
|
+
- "highest", "expensive" → sort ["fare:desc"]
|
|
467
|
+
- Only add sort if user implies ranking
|
|
468
|
+
|
|
469
|
+
--------------------------------
|
|
470
|
+
INTENT CLASSIFICATION (CRITICAL)
|
|
471
|
+
--------------------------------
|
|
472
|
+
First decide intent:
|
|
473
|
+
|
|
474
|
+
INTENT = "realtime"
|
|
475
|
+
- User asks about availability, price, list, count, search, show items
|
|
476
|
+
- Mentions data stored in collections
|
|
477
|
+
|
|
478
|
+
INTENT = "faq"
|
|
479
|
+
- User asks "who is", "what is", "explain", "details about"
|
|
480
|
+
- General knowledge
|
|
481
|
+
- No clear database entity
|
|
482
|
+
|
|
483
|
+
If no clear database match → ALWAYS choose "faq"
|
|
484
|
+
NEVER force a collection.
|
|
485
|
+
|
|
486
|
+
OUTPUT FORMAT
|
|
487
|
+
|
|
488
|
+
Return ONLY JSON.
|
|
489
|
+
|
|
490
|
+
If no database match exists, return:
|
|
491
|
+
|
|
492
|
+
{
|
|
493
|
+
"collection": null
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
Otherwise return:
|
|
497
|
+
|
|
498
|
+
{
|
|
499
|
+
"collection": "name",
|
|
500
|
+
"operation": "list" | "count",
|
|
501
|
+
"filters": {},
|
|
502
|
+
"sort": []
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
--------------------------------
|
|
507
|
+
AVAILABLE COLLECTIONS
|
|
508
|
+
--------------------------------
|
|
509
|
+
${JSON.stringify(activeCollections, null, 2)}
|
|
510
|
+
`
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
role: "user",
|
|
514
|
+
content: question
|
|
515
|
+
}
|
|
516
|
+
]
|
|
517
|
+
});
|
|
518
|
+
try {
|
|
519
|
+
const raw = response.choices[0].message.content || "{}";
|
|
520
|
+
const cleaned = raw.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
521
|
+
const plan = JSON.parse(cleaned);
|
|
522
|
+
return plan;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function realtimeInterpreterAI(question, realtimeData) {
|
|
528
|
+
if (!realtimeData) return null;
|
|
529
|
+
const response = await openai$1.chat.completions.create({
|
|
530
|
+
model: "gpt-4o-mini",
|
|
531
|
+
temperature: 0.2,
|
|
532
|
+
messages: [
|
|
533
|
+
{
|
|
534
|
+
role: "system",
|
|
535
|
+
content: `
|
|
536
|
+
You are a realtime data interpreter.
|
|
537
|
+
|
|
538
|
+
Convert database JSON into a SHORT natural language summary.
|
|
539
|
+
|
|
540
|
+
Rules:
|
|
541
|
+
- Do NOT output JSON
|
|
542
|
+
- Do NOT hallucinate
|
|
543
|
+
- If count → say number
|
|
544
|
+
- If list → summarize important fields only
|
|
545
|
+
- Max 3–4 lines
|
|
546
|
+
`
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
role: "user",
|
|
550
|
+
content: `
|
|
551
|
+
QUESTION: ${question}
|
|
552
|
+
|
|
553
|
+
REALTIME DATA:
|
|
554
|
+
${JSON.stringify(realtimeData)}
|
|
555
|
+
`
|
|
556
|
+
}
|
|
557
|
+
]
|
|
558
|
+
});
|
|
559
|
+
const text = response.choices[0].message.content;
|
|
560
|
+
return text;
|
|
561
|
+
}
|
|
562
|
+
async function finalAggregator(ctx, question, faq, realtimeMeta, realtimeText, contactLink, instructions) {
|
|
563
|
+
ctx.set("Content-Type", "text/event-stream");
|
|
564
|
+
ctx.set("Cache-Control", "no-cache");
|
|
565
|
+
ctx.set("Connection", "keep-alive");
|
|
566
|
+
ctx.status = 200;
|
|
567
|
+
ctx.res.flushHeaders?.();
|
|
568
|
+
const stream = await openai$1.chat.completions.create({
|
|
569
|
+
model: "gpt-4o-mini",
|
|
570
|
+
temperature: 0.3,
|
|
571
|
+
stream: true,
|
|
572
|
+
messages: [
|
|
573
|
+
{
|
|
574
|
+
role: "system",
|
|
575
|
+
content: `
|
|
576
|
+
|
|
577
|
+
${instructions.response || ""}
|
|
578
|
+
You are an intelligent AI Assistant for a website chatbot.
|
|
579
|
+
|
|
580
|
+
INPUTS:
|
|
581
|
+
- FAQ semantic answers
|
|
582
|
+
- REALTIME_META (structured database info)
|
|
583
|
+
- REALTIME_TEXT (human summary)
|
|
584
|
+
- User question
|
|
585
|
+
|
|
586
|
+
--------------------------------
|
|
587
|
+
RESPONSE LENGTH RULE
|
|
588
|
+
--------------------------------
|
|
589
|
+
Default → SHORT & PRECISE (2–3 lines max)
|
|
590
|
+
|
|
591
|
+
If the user's question contains:
|
|
592
|
+
"explain", "details", "more", "elaborate", "why", "how"
|
|
593
|
+
→ Provide LONGER detailed answer.
|
|
594
|
+
|
|
595
|
+
If FAQ answer is long:
|
|
596
|
+
→ Summarize unless user asked for detail.
|
|
597
|
+
|
|
598
|
+
--------------------------------
|
|
599
|
+
CORE RULE
|
|
600
|
+
--------------------------------
|
|
601
|
+
REALTIME_META decides logic.
|
|
602
|
+
REALTIME_TEXT decides wording.
|
|
603
|
+
|
|
604
|
+
--------------------------------
|
|
605
|
+
CONTACT INTENT RULE
|
|
606
|
+
--------------------------------
|
|
607
|
+
If user asks about contacting support, customer service, help, or similar:
|
|
608
|
+
|
|
609
|
+
AND contactLink is provided:
|
|
610
|
+
Return ONLY this link in a short sentence.
|
|
611
|
+
|
|
612
|
+
Example:
|
|
613
|
+
"You can contact us here: https://example.com/contact"
|
|
614
|
+
|
|
615
|
+
--------------------------------
|
|
616
|
+
ANSWER LOGIC
|
|
617
|
+
--------------------------------
|
|
618
|
+
|
|
619
|
+
CASE 1 — REALTIME_META.type = "count"
|
|
620
|
+
Return ONE sentence with the number.
|
|
621
|
+
|
|
622
|
+
CASE 2 — REALTIME_META.type = "list"
|
|
623
|
+
Use REALTIME_TEXT as main answer.
|
|
624
|
+
|
|
625
|
+
CASE 3 — REALTIME_META = null
|
|
626
|
+
Use FAQ.
|
|
627
|
+
|
|
628
|
+
CASE 4 — BOTH EXIST
|
|
629
|
+
Use REALTIME_TEXT as main + FAQ as support.
|
|
630
|
+
|
|
631
|
+
CASE 5 — NOTHING
|
|
632
|
+
Say information unavailable.
|
|
633
|
+
|
|
634
|
+
Never show JSON.
|
|
635
|
+
Never hallucinate.
|
|
636
|
+
Max 5 lines.
|
|
637
|
+
`
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
role: "user",
|
|
641
|
+
content: `
|
|
642
|
+
QUESTION: ${question}
|
|
643
|
+
|
|
644
|
+
CONTACT_LINK:
|
|
645
|
+
${contactLink || "NOT_AVAILABLE"}
|
|
646
|
+
|
|
647
|
+
FAQ:
|
|
648
|
+
${JSON.stringify(faq)}
|
|
649
|
+
|
|
650
|
+
REALTIME_META:
|
|
651
|
+
${JSON.stringify(realtimeMeta)}
|
|
652
|
+
|
|
653
|
+
REALTIME_TEXT:
|
|
654
|
+
${realtimeText}
|
|
655
|
+
`
|
|
656
|
+
}
|
|
657
|
+
]
|
|
658
|
+
});
|
|
659
|
+
for await (const chunk of stream) {
|
|
660
|
+
const token = chunk.choices?.[0]?.delta?.content;
|
|
661
|
+
if (token) {
|
|
662
|
+
ctx.res.write(`data: ${token}
|
|
663
|
+
|
|
664
|
+
`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (realtimeMeta && realtimeMeta.type === "list") {
|
|
668
|
+
const cardsPayload = {
|
|
669
|
+
title: realtimeMeta.collection,
|
|
670
|
+
schema: realtimeMeta.schema,
|
|
671
|
+
items: realtimeMeta.items
|
|
672
|
+
};
|
|
673
|
+
ctx.res.write(`event: cards
|
|
674
|
+
`);
|
|
675
|
+
ctx.res.write(`data: ${JSON.stringify(cardsPayload)}
|
|
676
|
+
|
|
677
|
+
`);
|
|
678
|
+
}
|
|
679
|
+
ctx.res.write("data: [DONE]\n\n");
|
|
680
|
+
ctx.res.end();
|
|
681
|
+
}
|
|
682
|
+
async function validateOpenAIKey(key) {
|
|
683
|
+
try {
|
|
684
|
+
const temp = new OpenAI__default.default({ apiKey: key });
|
|
685
|
+
await temp.models.list();
|
|
686
|
+
return true;
|
|
687
|
+
} catch {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const ask = ({ strapi }) => ({
|
|
692
|
+
async validateKey(ctx) {
|
|
693
|
+
const { key } = ctx.request.body;
|
|
694
|
+
const isValid = await validateOpenAIKey(key);
|
|
695
|
+
ctx.body = { valid: isValid };
|
|
696
|
+
},
|
|
697
|
+
async ask(ctx) {
|
|
698
|
+
const { question, history = [] } = ctx.request.body;
|
|
699
|
+
const instructions = await getInstructions(strapi);
|
|
700
|
+
let jsonContext = ctx.request.body.context || {};
|
|
701
|
+
jsonContext = updateJsonContext(jsonContext, question);
|
|
702
|
+
ctx.set("X-User-Context", JSON.stringify(jsonContext));
|
|
703
|
+
try {
|
|
704
|
+
const activeCollections = await getActiveCollections(strapi);
|
|
705
|
+
if (!activeCollections || activeCollections.length === 0) {
|
|
706
|
+
}
|
|
707
|
+
const rewritten = await rephraseQuestion(history, question);
|
|
708
|
+
const contactLink = await getContactLink(strapi);
|
|
709
|
+
const faqResults = await searchFAQ(rewritten, strapi);
|
|
710
|
+
const plan = await simplePlanner(rewritten, activeCollections, instructions);
|
|
711
|
+
let realtimeResults = null;
|
|
712
|
+
let realtimeAIText = null;
|
|
713
|
+
if (plan && plan.collection) {
|
|
714
|
+
realtimeResults = await searchRealtime(strapi, plan, activeCollections);
|
|
715
|
+
realtimeAIText = await realtimeInterpreterAI(rewritten, realtimeResults);
|
|
716
|
+
} else {
|
|
717
|
+
}
|
|
718
|
+
await finalAggregator(
|
|
719
|
+
ctx,
|
|
720
|
+
rewritten,
|
|
721
|
+
faqResults,
|
|
722
|
+
realtimeResults,
|
|
723
|
+
realtimeAIText,
|
|
724
|
+
contactLink,
|
|
725
|
+
instructions
|
|
726
|
+
);
|
|
727
|
+
return;
|
|
728
|
+
} catch (err) {
|
|
729
|
+
console.error("[ERROR]", err);
|
|
730
|
+
ctx.body = { type: "text", content: "Error occurred." };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
const suggestQuestions = ({ strapi }) => ({
|
|
735
|
+
async getSuggested(ctx) {
|
|
736
|
+
const pluginStore = strapi.store({
|
|
737
|
+
environment: null,
|
|
738
|
+
type: "plugin",
|
|
739
|
+
name: "faqchatbot"
|
|
740
|
+
});
|
|
741
|
+
const settings = await pluginStore.get({ key: "settings" });
|
|
742
|
+
ctx.body = {
|
|
743
|
+
suggestedQuestions: settings?.suggestedQuestions || []
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
const cardMapping = ({ strapi }) => ({
|
|
748
|
+
async index(ctx) {
|
|
749
|
+
const pluginStore = strapi.store({
|
|
750
|
+
environment: null,
|
|
751
|
+
type: "plugin",
|
|
752
|
+
name: "faqchatbot"
|
|
753
|
+
});
|
|
754
|
+
const settings = await pluginStore.get({ key: "settings" });
|
|
755
|
+
ctx.body = {
|
|
756
|
+
cardStyles: settings?.cardStyles || {}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
const controllers = {
|
|
761
|
+
controller,
|
|
762
|
+
config: config$1,
|
|
763
|
+
ask,
|
|
764
|
+
suggestQuestions,
|
|
765
|
+
cardMapping
|
|
766
|
+
};
|
|
767
|
+
const middlewares = {};
|
|
768
|
+
const policies = {};
|
|
769
|
+
const admin = {
|
|
770
|
+
type: "admin",
|
|
771
|
+
routes: [
|
|
772
|
+
{
|
|
773
|
+
method: "GET",
|
|
774
|
+
path: "/collections",
|
|
775
|
+
handler: "config.index",
|
|
776
|
+
config: {
|
|
777
|
+
auth: false
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
method: "POST",
|
|
782
|
+
path: "/collections",
|
|
783
|
+
handler: "config.update",
|
|
784
|
+
config: {
|
|
785
|
+
auth: false
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
]
|
|
789
|
+
};
|
|
790
|
+
const contentApi = () => ({
|
|
791
|
+
type: "content-api",
|
|
792
|
+
routes: [
|
|
793
|
+
{
|
|
794
|
+
method: "GET",
|
|
795
|
+
path: "/",
|
|
796
|
+
handler: "controller.index",
|
|
797
|
+
config: {
|
|
798
|
+
auth: false
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
method: "POST",
|
|
803
|
+
path: "/ask",
|
|
804
|
+
handler: "ask.ask",
|
|
805
|
+
config: {
|
|
806
|
+
auth: false
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
method: "GET",
|
|
811
|
+
path: "/suggested-questions",
|
|
812
|
+
handler: "suggestQuestions.getSuggested",
|
|
813
|
+
config: {
|
|
814
|
+
auth: false
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
method: "GET",
|
|
819
|
+
path: "/card-mapping",
|
|
820
|
+
handler: "cardMapping.index",
|
|
821
|
+
config: { auth: false }
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
method: "POST",
|
|
825
|
+
path: "/validate-key",
|
|
826
|
+
handler: "ask.validateKey",
|
|
827
|
+
config: {
|
|
828
|
+
auth: false
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
]
|
|
832
|
+
});
|
|
833
|
+
const routes = {
|
|
834
|
+
admin,
|
|
835
|
+
"content-api": contentApi
|
|
836
|
+
};
|
|
837
|
+
const openai = new OpenAI__default.default({
|
|
838
|
+
apiKey: process.env.OPENAI_API_KEY
|
|
839
|
+
});
|
|
840
|
+
const embed = ({ strapi }) => ({
|
|
841
|
+
async generateEmbedding(text) {
|
|
842
|
+
try {
|
|
843
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
const response = await openai.embeddings.create({
|
|
847
|
+
model: "text-embedding-3-small",
|
|
848
|
+
input: text
|
|
849
|
+
});
|
|
850
|
+
return response.data[0].embedding;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
strapi.log.error("Error generating embedding via OpenAI:");
|
|
853
|
+
console.error(error);
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
const config = ({ strapi }) => ({
|
|
859
|
+
async getConfig() {
|
|
860
|
+
const pluginStore = strapi.store({
|
|
861
|
+
environment: null,
|
|
862
|
+
type: "plugin",
|
|
863
|
+
name: "faqchatbot"
|
|
864
|
+
});
|
|
865
|
+
const settings = await pluginStore.get({ key: "settings" });
|
|
866
|
+
return settings && typeof settings === "object" ? settings : {};
|
|
867
|
+
},
|
|
868
|
+
async setConfig(newSettings) {
|
|
869
|
+
const pluginStore = strapi.store({
|
|
870
|
+
environment: null,
|
|
871
|
+
type: "plugin",
|
|
872
|
+
name: "faqchatbot"
|
|
873
|
+
});
|
|
874
|
+
const existingRaw = await pluginStore.get({ key: "settings" });
|
|
875
|
+
const existingSettings = existingRaw && typeof existingRaw === "object" ? existingRaw : {};
|
|
876
|
+
const mergedSettings = {
|
|
877
|
+
...existingSettings,
|
|
878
|
+
...newSettings
|
|
879
|
+
};
|
|
880
|
+
await pluginStore.set({
|
|
881
|
+
key: "settings",
|
|
882
|
+
value: mergedSettings
|
|
883
|
+
});
|
|
884
|
+
return mergedSettings;
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
const services = {
|
|
888
|
+
config,
|
|
889
|
+
embed
|
|
890
|
+
};
|
|
891
|
+
const index = {
|
|
892
|
+
register,
|
|
893
|
+
bootstrap,
|
|
894
|
+
destroy,
|
|
895
|
+
config: config$2,
|
|
896
|
+
controllers,
|
|
897
|
+
routes,
|
|
898
|
+
services,
|
|
899
|
+
contentTypes,
|
|
900
|
+
policies,
|
|
901
|
+
middlewares
|
|
902
|
+
};
|
|
903
|
+
module.exports = index;
|