alexa-ai 2.1.1
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/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MathDetector = require('./MathDetector');
|
|
4
|
+
const IdentityGuard = require('./IdentityGuard');
|
|
5
|
+
const AmnesiaGuard = require('./AmnesiaGuard');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PromptBuilder
|
|
9
|
+
* -------------
|
|
10
|
+
* Assembles the `chatHistory` array sent to DeepAI.
|
|
11
|
+
*
|
|
12
|
+
* HOW THE PERSONA IS DELIVERED
|
|
13
|
+
* ----------------------------
|
|
14
|
+
* DeepAI's chat endpoint has historically ignored `role: "system"` turns —
|
|
15
|
+
* verified live:
|
|
16
|
+
*
|
|
17
|
+
* [{role:'system', content:'Reply only ALEXA-OK'}, {role:'user', content:'hi'}]
|
|
18
|
+
* -> "Hello! How can I assist you today?" (persona ignored)
|
|
19
|
+
*
|
|
20
|
+
* [{role:'user', content:'<persona>'},
|
|
21
|
+
* {role:'assistant', content:'Understood...'},
|
|
22
|
+
* {role:'user', content:'hi'}]
|
|
23
|
+
* -> "ALEXA-OK" (persona respected)
|
|
24
|
+
*
|
|
25
|
+
* So the persona rides as a priming user/assistant pair, which the live API
|
|
26
|
+
* does honour. A short system digest is sent as well (config `systemRole`,
|
|
27
|
+
* default on): it costs a few tokens, is ignored by DeepAI, and is respected
|
|
28
|
+
* by every other backend the host bot may point this engine at.
|
|
29
|
+
*
|
|
30
|
+
* Three reinforcement notes are attached to the LIVE message, because facts
|
|
31
|
+
* placed only at the top of a long persona get diluted (measured: 0/4 recall
|
|
32
|
+
* from the top, 4/4 when repeated next to the question):
|
|
33
|
+
*
|
|
34
|
+
* • recall note — the facts we know about this person
|
|
35
|
+
* • memory directive — only when they ask a "do you remember…" question
|
|
36
|
+
* • identity lock — only when they ask who/what the assistant is
|
|
37
|
+
*/
|
|
38
|
+
class PromptBuilder {
|
|
39
|
+
/** @param {import('../core/Config')} config */
|
|
40
|
+
constructor(config) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
this.identity = new IdentityGuard({
|
|
43
|
+
assistantName: config.assistantName,
|
|
44
|
+
creator: config.creator,
|
|
45
|
+
});
|
|
46
|
+
this.amnesia = new AmnesiaGuard({ assistantName: config.assistantName });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {string} params.message current user text
|
|
52
|
+
* @param {Array<{role:string,content:string}>} [params.history]
|
|
53
|
+
* @param {Record<string,string>} [params.memories]
|
|
54
|
+
* @param {string} [params.userName]
|
|
55
|
+
* @param {boolean} [params.isGroup]
|
|
56
|
+
* @param {string} [params.groupName]
|
|
57
|
+
* @param {string} [params.imageContext] description of an attached image
|
|
58
|
+
* @param {boolean} [params.knownFromOtherRooms] the person is known from another thread
|
|
59
|
+
* @returns {Array<{role:string, content:string}>}
|
|
60
|
+
*/
|
|
61
|
+
build({
|
|
62
|
+
message,
|
|
63
|
+
history = [],
|
|
64
|
+
memories = {},
|
|
65
|
+
userName = null,
|
|
66
|
+
isGroup = false,
|
|
67
|
+
groupName = null,
|
|
68
|
+
imageContext = null,
|
|
69
|
+
knownFromOtherRooms = false,
|
|
70
|
+
}) {
|
|
71
|
+
const messages = [];
|
|
72
|
+
|
|
73
|
+
// 0) System digest — ignored by DeepAI, honoured by everyone else.
|
|
74
|
+
if (this.config.systemRole) {
|
|
75
|
+
messages.push({ role: 'system', content: this._systemDigest() });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1) Persona + live context, delivered as a user turn.
|
|
79
|
+
messages.push({
|
|
80
|
+
role: 'user',
|
|
81
|
+
content: this._personaBlock({ memories, userName, isGroup, groupName, knownFromOtherRooms }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 2) Assistant acknowledgement locks the role in.
|
|
85
|
+
messages.push({ role: 'assistant', content: this._acknowledgement() });
|
|
86
|
+
|
|
87
|
+
// 3) Prior turns of this thread.
|
|
88
|
+
for (const turn of PromptBuilder._sanitiseHistory(history, this.config.historyLimit)) {
|
|
89
|
+
messages.push(turn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4) The live message, with its reinforcement notes.
|
|
93
|
+
let current = String(message ?? '').trim();
|
|
94
|
+
if (imageContext) {
|
|
95
|
+
current = current
|
|
96
|
+
? `[Image attached — visual description: ${imageContext}]\n\n${current}`
|
|
97
|
+
: `[Image attached — visual description: ${imageContext}]\n\nPlease describe this image warmly for the user.`;
|
|
98
|
+
}
|
|
99
|
+
if (current.length > this.config.maxMessageLength) {
|
|
100
|
+
current = `${current.slice(0, this.config.maxMessageLength)}\n…[truncated]`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const recallNote = PromptBuilder._recallNote(memories);
|
|
104
|
+
const isRecall = AmnesiaGuard.isRecallQuestion(message);
|
|
105
|
+
const memoryDirective = this.config.amnesiaGuard
|
|
106
|
+
? this.amnesia.directiveFor({ memories, displayName: userName, isRecall, isGroup })
|
|
107
|
+
: '';
|
|
108
|
+
// Maths questions get the "one line only" rule restated next to the
|
|
109
|
+
// question; the free-tier model otherwise emits a full derivation.
|
|
110
|
+
const mathHint = MathDetector.isMath(current) ? MathDetector.HINT : '';
|
|
111
|
+
// DeepAI injects its own identity server-side ("Standard AI Chat by
|
|
112
|
+
// DeepAI", "Alexa Mini"), which overrides the persona. A lock next to
|
|
113
|
+
// the question is the only thing that reliably keeps her in character.
|
|
114
|
+
const idHint = this.config.identityLock ? this.identity.hintFor(current) : '';
|
|
115
|
+
|
|
116
|
+
messages.push({
|
|
117
|
+
role: 'user',
|
|
118
|
+
content: recallNote + memoryDirective + idHint + mathHint + (current || '(empty message)'),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return messages;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @private Short, high-signal rules for backends that honour `system`. */
|
|
125
|
+
_systemDigest() {
|
|
126
|
+
const { assistantName, creator } = this.config;
|
|
127
|
+
return [
|
|
128
|
+
`You are ${assistantName}, a warm, friendly female WhatsApp assistant created by ${creator}.`,
|
|
129
|
+
`Your name is exactly "${assistantName}" — never a variant such as "${assistantName} Mini" or "${assistantName} AI".`,
|
|
130
|
+
'Never mention DeepAI, ChatGPT, OpenAI, GPT, Llama, Gemini or any model/company name, and never call yourself a language model.',
|
|
131
|
+
'Use WhatsApp formatting only: *bold*, _italic_, ~strike~, `code`. Never use ** or markdown headers.',
|
|
132
|
+
'You have a permanent memory database: facts you are given about a person are things you genuinely remember, in private chats and in every group. Never claim you cannot remember.',
|
|
133
|
+
'Append new personal facts at the very end as @MEMORY: {"key": "value"} and never mention that tag.',
|
|
134
|
+
'Reply with exactly "weather <city>", "menu", "ping" or "doc" for those four intents, and give maths answers as a single formula + result in `code`.',
|
|
135
|
+
].join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @private */
|
|
139
|
+
_acknowledgement() {
|
|
140
|
+
const { assistantName, creator } = this.config;
|
|
141
|
+
return (
|
|
142
|
+
`Understood. I am ${assistantName}, created by ${creator}. I will follow every rule exactly — ` +
|
|
143
|
+
'WhatsApp formatting only, exact trigger outputs, concise math, silent memory tracking, ' +
|
|
144
|
+
'and I will always use the facts I remember about this person instead of claiming I cannot remember.'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @private Compact "facts you already know" line placed directly above the
|
|
150
|
+
* live message. Kept short and inline so it reads as context, not content.
|
|
151
|
+
*/
|
|
152
|
+
static _recallNote(memories) {
|
|
153
|
+
const keys = Object.keys(memories || {});
|
|
154
|
+
if (!keys.length) return '';
|
|
155
|
+
const pairs = keys
|
|
156
|
+
.slice(0, 20)
|
|
157
|
+
.map((k) => `${k.replace(/_/g, ' ')}=${memories[k]}`)
|
|
158
|
+
.join(', ');
|
|
159
|
+
return `[Remembered facts about this person — use them naturally when relevant, and never mention or repeat this note: ${pairs}]\n\n`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @private Persona text + runtime context block. */
|
|
163
|
+
_personaBlock({ memories, userName, isGroup, groupName, knownFromOtherRooms }) {
|
|
164
|
+
const parts = [this.config.systemPrompt];
|
|
165
|
+
const context = [];
|
|
166
|
+
|
|
167
|
+
if (userName) context.push(`- You are currently talking to: ${userName}`);
|
|
168
|
+
if (isGroup) {
|
|
169
|
+
context.push(
|
|
170
|
+
`- Setting: WhatsApp GROUP chat${groupName ? ` named "${groupName}"` : ''}. Other people can read your reply, so address ${userName || 'the user'} directly and keep it concise.`
|
|
171
|
+
);
|
|
172
|
+
context.push(
|
|
173
|
+
'- This is the SAME person you talk to in private chat. Their saved facts below were learned wherever you met them, and they apply here too.'
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
context.push('- Setting: private one-to-one WhatsApp chat (DM).');
|
|
177
|
+
}
|
|
178
|
+
if (knownFromOtherRooms) {
|
|
179
|
+
context.push(
|
|
180
|
+
'- You have spoken with this person before in another chat. Do not act as if you have just met.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const memoryKeys = Object.keys(memories || {});
|
|
185
|
+
if (memoryKeys.length) {
|
|
186
|
+
const lines = memoryKeys.map((k) => ` • ${k.replace(/_/g, ' ')}: ${memories[k]}`).join('\n');
|
|
187
|
+
context.push(
|
|
188
|
+
`- What you already know about this person (remember it naturally; never list it back unprompted, and do NOT re-save unchanged facts):\n${lines}`
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
context.push('- You have no saved facts about this person yet.');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
parts.push(`\n[CURRENT CONTEXT]\n${context.join('\n')}`);
|
|
195
|
+
return parts.join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @private Keep the transcript well-formed: valid roles, non-empty content,
|
|
200
|
+
* no leading assistant turn, and alternating-ish order.
|
|
201
|
+
*/
|
|
202
|
+
static _sanitiseHistory(history, limit) {
|
|
203
|
+
if (!Array.isArray(history) || !history.length) return [];
|
|
204
|
+
|
|
205
|
+
const cleaned = history
|
|
206
|
+
.filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
|
|
207
|
+
.map((m) => ({ role: m.role, content: String(m.content ?? '').trim() }))
|
|
208
|
+
.filter((m) => m.content.length > 0);
|
|
209
|
+
|
|
210
|
+
const trimmed = cleaned.slice(-limit);
|
|
211
|
+
while (trimmed.length && trimmed[0].role === 'assistant') trimmed.shift();
|
|
212
|
+
return trimmed;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = PromptBuilder;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ResponseFormatter
|
|
5
|
+
* -----------------
|
|
6
|
+
* Enforces the persona's WhatsApp-only formatting rules on the model output.
|
|
7
|
+
*
|
|
8
|
+
* Live testing confirmed the model DOES emit forbidden Markdown (`**Hello
|
|
9
|
+
* Sahan!**`, `### Heading`) despite explicit instructions, so this pass is a
|
|
10
|
+
* hard guarantee rather than a nicety.
|
|
11
|
+
*
|
|
12
|
+
* Conversions:
|
|
13
|
+
* **bold** -> *bold*
|
|
14
|
+
* __bold__ -> *bold*
|
|
15
|
+
* ### Heading -> *Heading*
|
|
16
|
+
* * bullet -> • bullet (a leading "* " would render as bold in WA)
|
|
17
|
+
* [txt](url) -> txt (url)
|
|
18
|
+
*
|
|
19
|
+
* Fenced code blocks are protected and restored verbatim.
|
|
20
|
+
*/
|
|
21
|
+
class ResponseFormatter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} reply
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
static format(reply) {
|
|
27
|
+
let text = String(reply ?? '');
|
|
28
|
+
if (!text.trim()) return '';
|
|
29
|
+
|
|
30
|
+
// Safety net: if the model ever echoes the internal recall note or an
|
|
31
|
+
// image-context marker back at the user, strip those lines.
|
|
32
|
+
text = text
|
|
33
|
+
.replace(/^\s*\[Remembered facts about this person[^\]]*\]\s*/gim, '')
|
|
34
|
+
.replace(/^\s*\[Image attached[^\]]*\]\s*/gim, '')
|
|
35
|
+
.replace(/^\s*\[MATH MODE:[^\]]*\]\s*/gim, '')
|
|
36
|
+
.replace(/^\s*\[IDENTITY LOCK:[^\]]*\]\s*/gim, '');
|
|
37
|
+
|
|
38
|
+
// --- protect fenced code blocks ------------------------------------
|
|
39
|
+
const blocks = [];
|
|
40
|
+
text = text.replace(/```[\s\S]*?```/g, (match) => {
|
|
41
|
+
blocks.push(match);
|
|
42
|
+
return `\u0000CODE${blocks.length - 1}\u0000`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- protect inline code -------------------------------------------
|
|
46
|
+
const inline = [];
|
|
47
|
+
text = text.replace(/`[^`\n]+`/g, (match) => {
|
|
48
|
+
inline.push(match);
|
|
49
|
+
return `\u0000INL${inline.length - 1}\u0000`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- markdown links -> "text (url)" --------------------------------
|
|
53
|
+
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1 ($2)');
|
|
54
|
+
|
|
55
|
+
// --- bold/italic normalisation --------------------------------------
|
|
56
|
+
// ***x*** or ___x___ -> _*x*_ (WhatsApp bold-italic)
|
|
57
|
+
text = text.replace(/\*\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*\*/g, '_*$1*_');
|
|
58
|
+
text = text.replace(/___(?!\s)([^_\n]+?)(?<!\s)___/g, '_*$1*_');
|
|
59
|
+
// **x** -> *x*
|
|
60
|
+
text = text.replace(/\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g, '*$1*');
|
|
61
|
+
// __x__ -> *x*
|
|
62
|
+
text = text.replace(/__(?!\s)([^_\n]+?)(?<!\s)__/g, '*$1*');
|
|
63
|
+
|
|
64
|
+
// --- headings -> bold line ------------------------------------------
|
|
65
|
+
text = text.replace(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm, (_m, heading) => {
|
|
66
|
+
const clean = heading.replace(/[*_~]/g, '').trim();
|
|
67
|
+
return clean ? `*${clean}*` : '';
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- bullets: "* item" / "- item" / "+ item" -> "• item" ------------
|
|
71
|
+
text = text.replace(/^(\s*)[*+-]\s+(?=\S)/gm, '$1• ');
|
|
72
|
+
|
|
73
|
+
// --- horizontal rules -------------------------------------------------
|
|
74
|
+
text = text.replace(/^\s*([-*_])\1{2,}\s*$/gm, '──────────');
|
|
75
|
+
|
|
76
|
+
// --- blockquote markers are not supported ----------------------------
|
|
77
|
+
text = text.replace(/^\s{0,3}>\s?/gm, '');
|
|
78
|
+
|
|
79
|
+
// --- tidy whitespace ---------------------------------------------------
|
|
80
|
+
text = text
|
|
81
|
+
.replace(/[ \t]+$/gm, '')
|
|
82
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
// --- restore protected segments ---------------------------------------
|
|
86
|
+
text = text.replace(/\u0000INL(\d+)\u0000/g, (_m, i) => inline[Number(i)] ?? '');
|
|
87
|
+
text = text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => blocks[Number(i)] ?? '');
|
|
88
|
+
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split an over-long reply on natural boundaries so the bot can send it as
|
|
94
|
+
* sequential WhatsApp messages.
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {number} [limit=4000]
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
static chunk(text, limit = 4000) {
|
|
100
|
+
const input = String(text ?? '');
|
|
101
|
+
if (input.length <= limit) return input ? [input] : [];
|
|
102
|
+
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let remaining = input;
|
|
105
|
+
|
|
106
|
+
while (remaining.length > limit) {
|
|
107
|
+
let cut = remaining.lastIndexOf('\n\n', limit);
|
|
108
|
+
if (cut < limit * 0.5) cut = remaining.lastIndexOf('\n', limit);
|
|
109
|
+
if (cut < limit * 0.5) cut = remaining.lastIndexOf('. ', limit);
|
|
110
|
+
if (cut < limit * 0.5) cut = remaining.lastIndexOf(' ', limit);
|
|
111
|
+
if (cut <= 0) cut = limit;
|
|
112
|
+
|
|
113
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
114
|
+
remaining = remaining.slice(cut).trim();
|
|
115
|
+
}
|
|
116
|
+
if (remaining) chunks.push(remaining);
|
|
117
|
+
return chunks;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = ResponseFormatter;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TriggerDetector
|
|
5
|
+
* ---------------
|
|
6
|
+
* The persona demands four EXACT outputs (`weather <city>`, `menu`, `ping`,
|
|
7
|
+
* `doc`) that the WhatsApp bot parses as commands. Small models are not
|
|
8
|
+
* reliable enough for that: live testing showed DeepAI's `standard` model
|
|
9
|
+
* answering "What is the weather in Colombo today?" with a chatty forecast,
|
|
10
|
+
* and "send me the docs" with a 200-word essay, instead of the required
|
|
11
|
+
* one-word outputs.
|
|
12
|
+
*
|
|
13
|
+
* Because a wrong string here breaks the host bot's command routing, these
|
|
14
|
+
* four intents are detected deterministically in code and short-circuit the
|
|
15
|
+
* model entirely. Everything else goes to DeepAI as normal.
|
|
16
|
+
*
|
|
17
|
+
* Strategy: strip politeness/filler words, then match what remains against a
|
|
18
|
+
* small core vocabulary. This is far more robust than one giant regex.
|
|
19
|
+
*
|
|
20
|
+
* Set `triggers: false` in the constructor options to disable.
|
|
21
|
+
*/
|
|
22
|
+
class TriggerDetector {
|
|
23
|
+
/** Leading filler stripped before matching ("can you please show me the …"). */
|
|
24
|
+
static FILLER = new RegExp(
|
|
25
|
+
'^(?:' +
|
|
26
|
+
[
|
|
27
|
+
'hey', 'hi', 'hello', 'ok', 'okay', 'so', 'now', 'just', 'please', 'pls', 'plz',
|
|
28
|
+
'kindly', 'can', 'could', 'would', 'will', 'you', 'u', 'i', 'we', 'want', 'wanna',
|
|
29
|
+
'need', 'like', 'to', 'get', 'give', 'send', 'show', 'display', 'share', 'tell',
|
|
30
|
+
'let', 'see', 'view', 'open', 'read', 'fetch', 'bring', 'me', 'us', 'my', 'the',
|
|
31
|
+
'a', 'an', 'your', 'ur', 'bot', 'this', 'that', 'what', 'whats', 'which', 'is',
|
|
32
|
+
'are', 'do', 'does', 'have', 'has', 'any', 'all', 'some', 'about', 'of', 'for',
|
|
33
|
+
'alexa', 'main', 'full', 'list', 'help',
|
|
34
|
+
].join('|') +
|
|
35
|
+
')\\b[\\s,]*',
|
|
36
|
+
'i'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
static MENU_CORE = /^(?:menu|menus|option|options|command|commands|cmd|cmds|commandlist|feature|features|functions?|capabilities)\b/i;
|
|
40
|
+
static PING_CORE = /^(?:ping|pong|alive|online|up|working|status|uptime|systemstatus|serverstatus|botstatus|test|testing|speedtest)\b/i;
|
|
41
|
+
static DOC_CORE = /^(?:(?:user\s+|usage\s+|quick\s+|start(?:er)?\s+)?(?:doc|docs|documentation|documentations|guide|guides|manual|readme|instruction|instructions|tutorial))\b/i;
|
|
42
|
+
|
|
43
|
+
/** Whole-phrase forms that filler-stripping would mangle. */
|
|
44
|
+
static PING_PHRASE = /^(?:are\s+you\s+(?:alive|online|there|up|working|ok)|is\s+(?:the\s+)?(?:bot|server|system)\s+(?:up|online|working|alive)|how\s+to\s+use(?:\s+.*)?)$/i;
|
|
45
|
+
static DOC_PHRASE = /^(?:how\s+(?:do\s+i|to)\s+use(?:\s+(?:you|this|the\s+bot|it))?|where\s+(?:are|is)\s+the\s+(?:docs?|documentation|guide))$/i;
|
|
46
|
+
|
|
47
|
+
static WEATHER_WORD = /\b(weather|forecast|temperature|temp|raining|humidity|climate)\b/i;
|
|
48
|
+
|
|
49
|
+
/** Never a city name. */
|
|
50
|
+
static STOPWORDS = new Set([
|
|
51
|
+
'today', 'tomorrow', 'now', 'right', 'currently', 'current', 'the', 'a', 'an', 'is', 'it',
|
|
52
|
+
'in', 'at', 'on', 'for', 'of', 'like', 'there', 'here', 'this', 'that', 'what', 'whats',
|
|
53
|
+
'hows', 'how', 'please', 'pls', 'tell', 'me', 'you', 'know', 'check', 'give', 'show',
|
|
54
|
+
'weather', 'forecast', 'temperature', 'temp', 'raining', 'rain', 'sunny', 'humidity',
|
|
55
|
+
'climate', 'hot', 'cold', 'snow', 'windy', 'outside', 'morning', 'evening', 'night',
|
|
56
|
+
'afternoon', 'week', 'weekend', 'and', 'be', 'will', 'going', 'to', 'my', 'area',
|
|
57
|
+
'city', 'town', 'condition', 'conditions', 'report', 'update', 'degrees', 'joke',
|
|
58
|
+
'about', 'man', 'story', 'song', 'poem', 'write', 'explain', 'why', 'when', 'who',
|
|
59
|
+
'talk', 'say', 'said', 'think', 'feel', 'love', 'hate', 'good', 'bad', 'nice',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Words implying a creative/verbose request — never a trigger. */
|
|
63
|
+
static CREATIVE = /\b(joke|story|poem|song|essay|write|explain|compose|imagine|pretend|translate|summar|meaning|difference|recipe)\b/i;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} message
|
|
67
|
+
* @returns {{ type:'weather'|'menu'|'ping'|'doc', output:string }|null}
|
|
68
|
+
*/
|
|
69
|
+
static detect(message) {
|
|
70
|
+
const raw = String(message ?? '').trim();
|
|
71
|
+
if (!raw || raw.length > 160) return null;
|
|
72
|
+
|
|
73
|
+
// Strip WhatsApp formatting + leading command prefixes + trailing punctuation.
|
|
74
|
+
let text = raw
|
|
75
|
+
.replace(/[*_~`]/g, '')
|
|
76
|
+
.replace(/^[/!.#]+\s*/, '')
|
|
77
|
+
.replace(/[?!.]+$/g, '')
|
|
78
|
+
.replace(/\s{2,}/g, ' ')
|
|
79
|
+
.trim();
|
|
80
|
+
if (!text) return null;
|
|
81
|
+
|
|
82
|
+
// A creative request is never a command, even if it mentions "weather".
|
|
83
|
+
if (TriggerDetector.CREATIVE.test(text)) return null;
|
|
84
|
+
|
|
85
|
+
// Whole-phrase checks before filler stripping.
|
|
86
|
+
if (TriggerDetector.DOC_PHRASE.test(text)) return { type: 'doc', output: 'doc' };
|
|
87
|
+
if (TriggerDetector.PING_PHRASE.test(text)) return { type: 'ping', output: 'ping' };
|
|
88
|
+
|
|
89
|
+
// --- weather needs the raw text (city may be a stripped filler word) ---
|
|
90
|
+
if (TriggerDetector.WEATHER_WORD.test(text)) {
|
|
91
|
+
const city = TriggerDetector._extractCity(text);
|
|
92
|
+
if (city) return { type: 'weather', output: `weather ${city}` };
|
|
93
|
+
return null; // no city -> let the model ask which city
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- strip politeness/filler, then match a short core phrase ----------
|
|
97
|
+
const core = TriggerDetector._stripFiller(text);
|
|
98
|
+
if (!core) return null;
|
|
99
|
+
|
|
100
|
+
// Only accept short residues: "menu", "commands list", "docs please".
|
|
101
|
+
const wordCount = core.split(/\s+/).length;
|
|
102
|
+
if (wordCount > 3) return null;
|
|
103
|
+
|
|
104
|
+
if (TriggerDetector.MENU_CORE.test(core)) return { type: 'menu', output: 'menu' };
|
|
105
|
+
if (TriggerDetector.DOC_CORE.test(core)) return { type: 'doc', output: 'doc' };
|
|
106
|
+
if (TriggerDetector.PING_CORE.test(core)) return { type: 'ping', output: 'ping' };
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @private Repeatedly remove leading filler words. */
|
|
112
|
+
static _stripFiller(text) {
|
|
113
|
+
let out = text.toLowerCase().trim();
|
|
114
|
+
let guard = 0;
|
|
115
|
+
while (guard++ < 15) {
|
|
116
|
+
const next = out.replace(TriggerDetector.FILLER, '').trim();
|
|
117
|
+
if (next === out) break;
|
|
118
|
+
out = next;
|
|
119
|
+
}
|
|
120
|
+
// Drop trailing politeness.
|
|
121
|
+
return out.replace(/\b(?:please|pls|plz|now|list|thanks|thank\s+you)\b\s*$/i, '').trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @private Pull the location out of a weather question.
|
|
126
|
+
* Prefers explicit "in/at/for <City>", then a strict "<City> weather" form.
|
|
127
|
+
*/
|
|
128
|
+
static _extractCity(text) {
|
|
129
|
+
const cleaned = text.replace(/[?!.,]+$/g, '').trim();
|
|
130
|
+
|
|
131
|
+
// "weather in Colombo", "forecast for New York"
|
|
132
|
+
const prep = cleaned.match(
|
|
133
|
+
/\b(?:in|at|for|near|around)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,40})$/i
|
|
134
|
+
);
|
|
135
|
+
if (prep) {
|
|
136
|
+
const city = TriggerDetector._clean(prep[1]);
|
|
137
|
+
if (city) return city;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// "weather in Kandy right now"
|
|
141
|
+
const prepMid = cleaned.match(
|
|
142
|
+
/\b(?:in|at|for|near|around)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,40}?)\s+(?:today|tomorrow|now|right\s+now|currently|please|this\s+\w+|tonight)\b/i
|
|
143
|
+
);
|
|
144
|
+
if (prepMid) {
|
|
145
|
+
const city = TriggerDetector._clean(prepMid[1]);
|
|
146
|
+
if (city) return city;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Strict "<City> weather" — at most 3 leading words, none of them verbs.
|
|
150
|
+
const leading = cleaned.match(
|
|
151
|
+
/^((?:[A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20}\s+){0,2}[A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20})\s+(?:weather|forecast|temperature|temp|climate)\b/i
|
|
152
|
+
);
|
|
153
|
+
if (leading) {
|
|
154
|
+
const city = TriggerDetector._clean(leading[1]);
|
|
155
|
+
if (city) return city;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Capitalised proper nouns elsewhere in the sentence.
|
|
159
|
+
const capitals = cleaned.match(/\b[A-Z][a-z\u00C0-\u024F]{2,}\b/g);
|
|
160
|
+
if (capitals) {
|
|
161
|
+
const candidates = capitals.filter((w) => !TriggerDetector.STOPWORDS.has(w.toLowerCase()));
|
|
162
|
+
if (candidates.length) return candidates.join(' ').slice(0, 60);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @private Remove stopwords; reject if nothing meaningful remains. */
|
|
169
|
+
static _clean(value) {
|
|
170
|
+
const words = String(value)
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/\s+/)
|
|
173
|
+
.map((w) => w.replace(/[^A-Za-z'\u00C0-\u024F-]/g, ''))
|
|
174
|
+
.filter((w) => w && !TriggerDetector.STOPWORDS.has(w.toLowerCase()));
|
|
175
|
+
|
|
176
|
+
if (!words.length || words.length > 4) return null;
|
|
177
|
+
const city = words.join(' ').replace(/\s{2,}/g, ' ').trim();
|
|
178
|
+
return city.length >= 2 ? city.slice(0, 60) : null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = TriggerDetector;
|