alexa-ai 2.1.1 → 2.1.2
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 +37 -0
- package/README.md +76 -20
- package/index.js +2 -0
- package/package.json +2 -1
- package/src/AlexaAI.js +115 -32
- package/src/core/Config.js +9 -0
- package/src/services/WebAnswer.js +153 -22
- package/src/services/WebSearch.js +428 -0
- package/test/fakes.js +171 -0
- package/test/run-tests.js +1035 -0
- package/test/wrapper-methods.js +986 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test suite.
|
|
5
|
+
*
|
|
6
|
+
* node test/run-tests.js
|
|
7
|
+
*
|
|
8
|
+
* Unit tests always run. Integration tests (real Postgres + real DeepAI) run
|
|
9
|
+
* only when the matching env vars are present:
|
|
10
|
+
*
|
|
11
|
+
* POSTGRES_URL=postgres://... DEEPAI_KEY=tryit-... node test/run-tests.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
AlexaAI,
|
|
16
|
+
JidParser,
|
|
17
|
+
MemoryExtractor,
|
|
18
|
+
FactMiner,
|
|
19
|
+
MathDetector,
|
|
20
|
+
IdentityGuard,
|
|
21
|
+
AmnesiaGuard,
|
|
22
|
+
IdentityResolver,
|
|
23
|
+
ResponseFormatter,
|
|
24
|
+
TriggerDetector,
|
|
25
|
+
MemoryRepository,
|
|
26
|
+
PromptBuilder,
|
|
27
|
+
StreamParser,
|
|
28
|
+
DeepAIClient,
|
|
29
|
+
Config,
|
|
30
|
+
} = require('../index');
|
|
31
|
+
const { createFakeDb, installFakeDeepAI } = require('./fakes');
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
const failures = [];
|
|
36
|
+
|
|
37
|
+
function check(name, actual, expected) {
|
|
38
|
+
const a = JSON.stringify(actual);
|
|
39
|
+
const e = JSON.stringify(expected);
|
|
40
|
+
if (a === e) {
|
|
41
|
+
passed++;
|
|
42
|
+
console.log(` ✅ ${name}`);
|
|
43
|
+
} else {
|
|
44
|
+
failed++;
|
|
45
|
+
failures.push(`${name}\n expected: ${e}\n actual: ${a}`);
|
|
46
|
+
console.log(` ❌ ${name}\n expected: ${e}\n actual: ${a}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ok(name, condition, detail = '') {
|
|
51
|
+
if (condition) {
|
|
52
|
+
passed++;
|
|
53
|
+
console.log(` ✅ ${name}`);
|
|
54
|
+
} else {
|
|
55
|
+
failed++;
|
|
56
|
+
failures.push(`${name} ${detail}`);
|
|
57
|
+
console.log(` ❌ ${name} ${detail}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function section(title) {
|
|
62
|
+
console.log(`\n\x1b[1m${title}\x1b[0m`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ==========================================================================
|
|
66
|
+
section('JidParser — WhatsApp identifier handling');
|
|
67
|
+
// ==========================================================================
|
|
68
|
+
check('parses @lid user', JidParser.parse('78151912841263@lid').type, 'lid');
|
|
69
|
+
check('@lid is a user', JidParser.parse('78151912841263@lid').isUser, true);
|
|
70
|
+
check('@lid is not a group', JidParser.parse('78151912841263@lid').isGroup, false);
|
|
71
|
+
check('@lid yields no phone (privacy id)', JidParser.parse('78151912841263@lid').phone, null);
|
|
72
|
+
check('parses group jid', JidParser.parse('120363413125431525@g.us').isGroup, true);
|
|
73
|
+
check('parses phone jid', JidParser.parse('94771234567@s.whatsapp.net').phone, '94771234567');
|
|
74
|
+
check('strips device suffix', JidParser.normalize('94771234567:12@s.whatsapp.net'), '94771234567@s.whatsapp.net');
|
|
75
|
+
check('keeps device number', JidParser.parse('94771234567:12@s.whatsapp.net').device, 12);
|
|
76
|
+
check('legacy @c.us is a user', JidParser.parse('94771234567@c.us').isUser, true);
|
|
77
|
+
check('invalid input is not valid', JidParser.parse('').valid, false);
|
|
78
|
+
check('null input is not valid', JidParser.parse(null).valid, false);
|
|
79
|
+
check('DM context key', JidParser.contextKey('78151912841263@lid'), 'dm:78151912841263@lid');
|
|
80
|
+
check(
|
|
81
|
+
'group context key is per-user',
|
|
82
|
+
JidParser.contextKey('78151912841263@lid', '120363413125431525@g.us'),
|
|
83
|
+
'group:120363413125431525@g.us:78151912841263@lid'
|
|
84
|
+
);
|
|
85
|
+
check(
|
|
86
|
+
'shared group thread key',
|
|
87
|
+
JidParser.contextKey('78151912841263@lid', '120363413125431525@g.us', true),
|
|
88
|
+
'group:120363413125431525@g.us'
|
|
89
|
+
);
|
|
90
|
+
ok(
|
|
91
|
+
'same user has ONE identity across DM and groups',
|
|
92
|
+
JidParser.normalize('78151912841263@lid') === JidParser.normalize('78151912841263:5@lid')
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ==========================================================================
|
|
96
|
+
section('MemoryExtractor — @MEMORY tag parsing');
|
|
97
|
+
// ==========================================================================
|
|
98
|
+
{
|
|
99
|
+
const r = MemoryExtractor.extract(
|
|
100
|
+
'Nice to meet you, Nimal! Cricket is a great sport. @MEMORY: {"name": "Nimal", "hobby": "cricket"}'
|
|
101
|
+
);
|
|
102
|
+
check('extracts name', r.memories.name, 'Nimal');
|
|
103
|
+
check('extracts hobby', r.memories.hobby, 'cricket');
|
|
104
|
+
check('strips tag from visible text', r.text, 'Nice to meet you, Nimal! Cricket is a great sport.');
|
|
105
|
+
check('flags found', r.found, true);
|
|
106
|
+
}
|
|
107
|
+
{
|
|
108
|
+
const r = MemoryExtractor.extract('Got it!@MEMORY:{"city":"Galle"}');
|
|
109
|
+
check('handles no-space compact tag', r.memories.city, 'Galle');
|
|
110
|
+
}
|
|
111
|
+
{
|
|
112
|
+
const r = MemoryExtractor.extract("Sure thing. @memory: {'food': 'kottu'}");
|
|
113
|
+
check('handles single quotes + lowercase tag', r.memories.food, 'kottu');
|
|
114
|
+
}
|
|
115
|
+
{
|
|
116
|
+
const r = MemoryExtractor.extract('Hello. @MEMORY: {"a": "1"} @MEMORY: {"b": "2"}');
|
|
117
|
+
check('handles multiple tags', [r.memories.a, r.memories.b], ['1', '2']);
|
|
118
|
+
}
|
|
119
|
+
{
|
|
120
|
+
const r = MemoryExtractor.extract('Hi there! *@MEMORY:* {"name": "Sahan"}');
|
|
121
|
+
check('handles WhatsApp-bolded tag', r.memories.name, 'Sahan');
|
|
122
|
+
}
|
|
123
|
+
{
|
|
124
|
+
const r = MemoryExtractor.extract('Noted. @MEMORY: name: Kasun, city: Kandy');
|
|
125
|
+
check('handles non-JSON fallback', [r.memories.name, r.memories.city], ['Kasun', 'Kandy']);
|
|
126
|
+
}
|
|
127
|
+
{
|
|
128
|
+
const r = MemoryExtractor.extract('Just a normal reply with no tag.');
|
|
129
|
+
check('no false positive', r.found, false);
|
|
130
|
+
check('text untouched', r.text, 'Just a normal reply with no tag.');
|
|
131
|
+
}
|
|
132
|
+
{
|
|
133
|
+
const r = MemoryExtractor.extract('Reply. @MEMORY: {"Favourite Food": "Kottu Roti"}');
|
|
134
|
+
check('normalises key to snake_case', r.memories.favourite_food, 'Kottu Roti');
|
|
135
|
+
}
|
|
136
|
+
ok('strip() removes tag entirely', !/@MEMORY/i.test(MemoryExtractor.strip('Hi @MEMORY: {"x":"y"}')));
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// ==========================================================================
|
|
140
|
+
section('FactMiner — local fact extraction (fallback when model omits @MEMORY)');
|
|
141
|
+
// ==========================================================================
|
|
142
|
+
check('mines name from intro', FactMiner.mine("Hi, I'm Nimal and I love playing cricket.").name, 'Nimal');
|
|
143
|
+
check('mines hobby from intro', FactMiner.mine("Hi, I'm Nimal and I love playing cricket.").hobby, 'cricket');
|
|
144
|
+
check('mines "my name is"', FactMiner.mine('My name is Kasun Perera').name, 'Kasun Perera');
|
|
145
|
+
check('mines location', FactMiner.mine('I live in Galle').location, 'Galle');
|
|
146
|
+
check('mines favourite food', FactMiner.mine('My favourite food is kottu').favourite_food, 'kottu');
|
|
147
|
+
check('mines age', FactMiner.mine('I am 25 years old').age, '25');
|
|
148
|
+
check('mines studies', FactMiner.mine('I am studying computer science').studies, 'computer science');
|
|
149
|
+
check('mines favourite team', FactMiner.mine('I support Manchester United').favourite_team, 'Manchester United');
|
|
150
|
+
check('ignores third-party facts', FactMiner.mine('My friend lives in Kandy').location, undefined);
|
|
151
|
+
check('no false positives on plain chat', FactMiner.mine('What is the capital of France?'), {});
|
|
152
|
+
check('ignores junk value', FactMiner.mine('I am ok').name, undefined);
|
|
153
|
+
check('rejects absurd age', FactMiner.mine('I am 999 years old').age, undefined);
|
|
154
|
+
ok('handles empty input', Object.keys(FactMiner.mine('')).length === 0);
|
|
155
|
+
ok('handles null input', Object.keys(FactMiner.mine(null)).length === 0);
|
|
156
|
+
|
|
157
|
+
// ==========================================================================
|
|
158
|
+
section('MemoryRepository — key/value normalisation');
|
|
159
|
+
// ==========================================================================
|
|
160
|
+
check('lowercases + snake_cases key', MemoryRepository.normalizeKey('Favourite Food'), 'favourite_food');
|
|
161
|
+
check('rejects blocked key', MemoryRepository.normalizeKey('null'), null);
|
|
162
|
+
check('rejects empty key', MemoryRepository.normalizeKey(' '), null);
|
|
163
|
+
check('strips punctuation from key', MemoryRepository.normalizeKey('user@name!'), 'username');
|
|
164
|
+
check('stringifies object value', MemoryRepository.normalizeValue({ a: 1 }), '{"a":1}');
|
|
165
|
+
check('rejects null value', MemoryRepository.normalizeValue(null), null);
|
|
166
|
+
ok('truncates very long value', MemoryRepository.normalizeValue('x'.repeat(9999)).length === 512);
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
// ==========================================================================
|
|
171
|
+
section('IdentityGuard — Alexa never reveals the backend vendor');
|
|
172
|
+
// ==========================================================================
|
|
173
|
+
ok('detects "are you alexa?"', IdentityGuard.isIdentityQuestion('are you alexa?'));
|
|
174
|
+
ok('detects "what is your name?"', IdentityGuard.isIdentityQuestion('what is your name?'));
|
|
175
|
+
ok('detects "who created you?"', IdentityGuard.isIdentityQuestion('who created you?'));
|
|
176
|
+
ok('detects "what model are you?"', IdentityGuard.isIdentityQuestion('what model are you?'));
|
|
177
|
+
ok('detects "are you ChatGPT?"', IdentityGuard.isIdentityQuestion('are you ChatGPT?'));
|
|
178
|
+
ok('detects "which company made you"', IdentityGuard.isIdentityQuestion('which company made you'));
|
|
179
|
+
ok('ignores normal chat', !IdentityGuard.isIdentityQuestion('what is the weather today'));
|
|
180
|
+
ok('hint added for identity question', IdentityGuard.hintFor('who made you?').includes('IDENTITY LOCK'));
|
|
181
|
+
check('no hint for normal chat', IdentityGuard.hintFor('hello'), '');
|
|
182
|
+
check(
|
|
183
|
+
'replaces a leaked identity answer',
|
|
184
|
+
IdentityGuard.sanitise('I am Standard AI Chat by DeepAI.', true),
|
|
185
|
+
'I am *Alexa*, your WhatsApp assistant created by *Hansaka*. \u{1F60A}'
|
|
186
|
+
);
|
|
187
|
+
ok(
|
|
188
|
+
'scrubs vendor name mid-sentence',
|
|
189
|
+
!/deepai/i.test(IdentityGuard.sanitise('Sure! DeepAI can help with that.', false))
|
|
190
|
+
);
|
|
191
|
+
ok(
|
|
192
|
+
'scrubs "large language model"',
|
|
193
|
+
!/language model/i.test(IdentityGuard.sanitise("I'm a large language model, here to help.", false))
|
|
194
|
+
);
|
|
195
|
+
check('leaves clean replies untouched', IdentityGuard.sanitise('The weather is sunny today.', false), 'The weather is sunny today.');
|
|
196
|
+
ok('no ChatGPT leak survives', !/chatgpt/i.test(IdentityGuard.sanitise('I am ChatGPT, how can I help?', false)));
|
|
197
|
+
|
|
198
|
+
// ==========================================================================
|
|
199
|
+
section('MathDetector — concise math answers');
|
|
200
|
+
// ==========================================================================
|
|
201
|
+
ok('detects area question', MathDetector.isMath('Calculate the area of a circle with radius 7'));
|
|
202
|
+
ok('detects percentage', MathDetector.isMath('What is 15% of 240?'));
|
|
203
|
+
ok('detects raw expression', MathDetector.isMath('12 * 47'));
|
|
204
|
+
ok('detects equation solving', MathDetector.isMath('solve 2x + 5 = 15 for x'));
|
|
205
|
+
ok('ignores plain chat', !MathDetector.isMath('Tell me a joke'));
|
|
206
|
+
ok('ignores explanation requests', !MathDetector.isMath('explain why 2+2=4'));
|
|
207
|
+
ok('ignores text with no digits', !MathDetector.isMath('what is the area of a circle'));
|
|
208
|
+
ok('ignores greetings', !MathDetector.isMath('hello there'));
|
|
209
|
+
|
|
210
|
+
// ==========================================================================
|
|
211
|
+
section('TriggerDetector — exact command outputs');
|
|
212
|
+
// ==========================================================================
|
|
213
|
+
check('weather + city', TriggerDetector.detect('What is the weather in Colombo today?')?.output, 'weather Colombo');
|
|
214
|
+
check('raining phrasing', TriggerDetector.detect('Is it raining in Kandy right now?')?.output, 'weather Kandy');
|
|
215
|
+
check('city-first phrasing', TriggerDetector.detect('Galle weather')?.output, 'weather Galle');
|
|
216
|
+
check('temperature phrasing', TriggerDetector.detect('temperature in New York')?.output, 'weather New York');
|
|
217
|
+
check('menu', TriggerDetector.detect('show menu')?.output, 'menu');
|
|
218
|
+
check('menu variant', TriggerDetector.detect('What are your commands?')?.output, 'menu');
|
|
219
|
+
check('ping', TriggerDetector.detect('ping')?.output, 'ping');
|
|
220
|
+
check('ping variant', TriggerDetector.detect('are you online?')?.output, 'ping');
|
|
221
|
+
check('doc', TriggerDetector.detect('send me the docs')?.output, 'doc');
|
|
222
|
+
check('doc variant', TriggerDetector.detect('user guide')?.output, 'doc');
|
|
223
|
+
check('normal chat is not a trigger', TriggerDetector.detect('Tell me a joke about the weather man'), null);
|
|
224
|
+
check('weather with no city falls through to AI', TriggerDetector.detect('is it hot today?'), null);
|
|
225
|
+
check('long text is never a trigger', TriggerDetector.detect('menu '.repeat(60)), null);
|
|
226
|
+
check('handles WhatsApp bold markers', TriggerDetector.detect('*ping*')?.output, 'ping');
|
|
227
|
+
|
|
228
|
+
// ==========================================================================
|
|
229
|
+
section('ResponseFormatter — WhatsApp formatting enforcement');
|
|
230
|
+
// ==========================================================================
|
|
231
|
+
check('converts **bold**', ResponseFormatter.format('**Hello Sahan!**'), '*Hello Sahan!*');
|
|
232
|
+
check('converts __bold__', ResponseFormatter.format('__Hi__'), '*Hi*');
|
|
233
|
+
check('converts ***bolditalic***', ResponseFormatter.format('***wow***'), '_*wow*_');
|
|
234
|
+
check('converts ### heading', ResponseFormatter.format('### Title'), '*Title*');
|
|
235
|
+
check('converts bullets', ResponseFormatter.format('* one\n* two'), '• one\n• two');
|
|
236
|
+
check('converts markdown link', ResponseFormatter.format('[site](https://a.com)'), 'site (https://a.com)');
|
|
237
|
+
check('preserves inline code', ResponseFormatter.format('`A = π * 7²`'), '`A = π * 7²`');
|
|
238
|
+
ok(
|
|
239
|
+
'preserves fenced code untouched',
|
|
240
|
+
ResponseFormatter.format('```\nconst a = **1**;\n```').includes('**1**')
|
|
241
|
+
);
|
|
242
|
+
check('preserves single-asterisk bold', ResponseFormatter.format('*already bold*'), '*already bold*');
|
|
243
|
+
check('removes blockquote marker', ResponseFormatter.format('> quoted'), 'quoted');
|
|
244
|
+
ok('chunk() splits long text', ResponseFormatter.chunk('a '.repeat(3000), 1000).length > 1);
|
|
245
|
+
ok('chunk() keeps short text as one', ResponseFormatter.chunk('hello', 1000).length === 1);
|
|
246
|
+
ok('no ** survives formatting', !ResponseFormatter.format('**a** and **b**').includes('**'));
|
|
247
|
+
ok(
|
|
248
|
+
'strips echoed internal recall note',
|
|
249
|
+
!ResponseFormatter.format('[Remembered facts about this person - x=y]\nHello!').includes('Remembered facts')
|
|
250
|
+
);
|
|
251
|
+
check('keeps the real reply after stripping note', ResponseFormatter.format('[Remembered facts about this person - x=y]\nHello!'), 'Hello!');
|
|
252
|
+
ok(
|
|
253
|
+
'strips echoed MATH MODE hint',
|
|
254
|
+
!ResponseFormatter.format('[MATH MODE: only one line]\n`A = 5`').includes('MATH MODE')
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// ==========================================================================
|
|
258
|
+
section('Config — validation and defaults');
|
|
259
|
+
// ==========================================================================
|
|
260
|
+
ok(
|
|
261
|
+
'throws without key',
|
|
262
|
+
(() => {
|
|
263
|
+
try {
|
|
264
|
+
new Config({ postgresUrl: 'postgres://x' });
|
|
265
|
+
return false;
|
|
266
|
+
} catch {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
);
|
|
271
|
+
ok(
|
|
272
|
+
'throws without postgresUrl',
|
|
273
|
+
(() => {
|
|
274
|
+
// Config falls back to env vars, so clear them for this assertion.
|
|
275
|
+
const savedPg = process.env.POSTGRES_URL;
|
|
276
|
+
const savedDb = process.env.DATABASE_URL;
|
|
277
|
+
delete process.env.POSTGRES_URL;
|
|
278
|
+
delete process.env.DATABASE_URL;
|
|
279
|
+
try {
|
|
280
|
+
new Config({ key: 'k' });
|
|
281
|
+
return false;
|
|
282
|
+
} catch {
|
|
283
|
+
return true;
|
|
284
|
+
} finally {
|
|
285
|
+
if (savedPg !== undefined) process.env.POSTGRES_URL = savedPg;
|
|
286
|
+
if (savedDb !== undefined) process.env.DATABASE_URL = savedDb;
|
|
287
|
+
}
|
|
288
|
+
})()
|
|
289
|
+
);
|
|
290
|
+
{
|
|
291
|
+
const c = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost:5432/db' });
|
|
292
|
+
check('default model', c.model, 'standard');
|
|
293
|
+
check('localhost disables ssl', c.ssl, false);
|
|
294
|
+
check('redacts password in toJSON', c.toJSON().postgresUrl.includes('****'), true);
|
|
295
|
+
}
|
|
296
|
+
{
|
|
297
|
+
const c = new Config({ key: 'k', postgresUrl: 'postgres://u:p@db.neon.tech:5432/db' });
|
|
298
|
+
check('remote host enables relaxed ssl', c.ssl, { rejectUnauthorized: false });
|
|
299
|
+
}
|
|
300
|
+
{
|
|
301
|
+
const c = new Config({ key: 'k', postgueurl: 'postgres://u:p@localhost/db' });
|
|
302
|
+
ok('accepts postgueurl alias', c.postgresUrl.length > 0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ==========================================================================
|
|
306
|
+
section('PromptBuilder — persona delivery');
|
|
307
|
+
// ==========================================================================
|
|
308
|
+
{
|
|
309
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
310
|
+
const pb = new PromptBuilder(cfg);
|
|
311
|
+
const msgs = pb.build({
|
|
312
|
+
message: 'hello',
|
|
313
|
+
history: [
|
|
314
|
+
{ role: 'user', content: 'earlier' },
|
|
315
|
+
{ role: 'assistant', content: 'reply' },
|
|
316
|
+
],
|
|
317
|
+
memories: { name: 'Nimal', hobby: 'cricket' },
|
|
318
|
+
userName: 'Nimal',
|
|
319
|
+
isGroup: true,
|
|
320
|
+
groupName: 'Test Group',
|
|
321
|
+
});
|
|
322
|
+
const persona = msgs[1];
|
|
323
|
+
check('first turn is the system digest', msgs[0].role, 'system');
|
|
324
|
+
ok('system digest states the exact name', msgs[0].content.includes('exactly "Alexa"'));
|
|
325
|
+
check('persona turn is delivered as a user turn', persona.role, 'user');
|
|
326
|
+
ok('persona text present', persona.content.includes('You are Alexa'));
|
|
327
|
+
ok('memories injected', persona.content.includes('Nimal') && persona.content.includes('cricket'));
|
|
328
|
+
ok('group context injected', persona.content.includes('GROUP'));
|
|
329
|
+
ok('group turn states it is the same person as the DM', persona.content.includes('SAME person'));
|
|
330
|
+
check('third turn is the assistant ack', msgs[2].role, 'assistant');
|
|
331
|
+
ok('last turn contains the live message', msgs[msgs.length - 1].content.endsWith('hello'));
|
|
332
|
+
ok('recall note precedes the live message', msgs[msgs.length - 1].content.includes('Remembered facts'));
|
|
333
|
+
ok('recall note lists known facts', msgs[msgs.length - 1].content.includes('name=Nimal'));
|
|
334
|
+
check('history preserved in order', [msgs[3].content, msgs[4].content], ['earlier', 'reply']);
|
|
335
|
+
}
|
|
336
|
+
{
|
|
337
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', systemRole: false });
|
|
338
|
+
const pb = new PromptBuilder(cfg);
|
|
339
|
+
const msgs = pb.build({ message: 'hi', history: [{ role: 'assistant', content: 'orphan' }] });
|
|
340
|
+
ok('systemRole:false removes the system turn', !msgs.some((m) => m.role === 'system'));
|
|
341
|
+
check('persona is still the first turn', msgs[0].role, 'user');
|
|
342
|
+
ok('drops leading assistant turn', !msgs.slice(2, -1).some((m) => m.content === 'orphan'));
|
|
343
|
+
check('no recall note when there are no memories', msgs[msgs.length - 1].content, 'hi');
|
|
344
|
+
}
|
|
345
|
+
{
|
|
346
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
347
|
+
const pb = new PromptBuilder(cfg);
|
|
348
|
+
const msgs = pb.build({ message: 'do you remember me?', memories: { name: 'Nimal' }, userName: 'Nimal' });
|
|
349
|
+
const live = msgs[msgs.length - 1].content;
|
|
350
|
+
ok('recall question gets a MEMORY CHECK directive', live.includes('MEMORY CHECK'));
|
|
351
|
+
ok('directive carries the saved facts', live.includes('name: Nimal'));
|
|
352
|
+
const plain = pb.build({ message: 'what is 2+2?', memories: { name: 'Nimal' } });
|
|
353
|
+
ok('normal question gets no MEMORY CHECK', !plain[plain.length - 1].content.includes('MEMORY CHECK'));
|
|
354
|
+
}
|
|
355
|
+
{
|
|
356
|
+
const cfg = new Config({
|
|
357
|
+
key: 'k',
|
|
358
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
359
|
+
assistantName: 'Nova',
|
|
360
|
+
creator: 'Kasun',
|
|
361
|
+
});
|
|
362
|
+
const pb = new PromptBuilder(cfg);
|
|
363
|
+
const msgs = pb.build({ message: 'hi' });
|
|
364
|
+
ok('persona can be renamed', msgs[1].content.includes('You are Nova'));
|
|
365
|
+
ok('creator can be renamed', msgs[1].content.includes('created by Kasun'));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ==========================================================================
|
|
369
|
+
section('IdentityGuard — model-tier suffixes and self-denials');
|
|
370
|
+
// ==========================================================================
|
|
371
|
+
check(
|
|
372
|
+
'repairs "Alexa Mini, not Alexa"',
|
|
373
|
+
IdentityGuard.sanitise('I am Alexa Mini, not Alexa.', false),
|
|
374
|
+
'I am Alexa.'
|
|
375
|
+
);
|
|
376
|
+
check(
|
|
377
|
+
'repairs a lowercase variant mid-reply',
|
|
378
|
+
IdentityGuard.sanitise("I'm Alexa mini, not Alexa. How can I help?", false),
|
|
379
|
+
"I'm Alexa. How can I help?"
|
|
380
|
+
);
|
|
381
|
+
check(
|
|
382
|
+
'repairs "not Alexa, I am GPT-4.1 Nano"',
|
|
383
|
+
IdentityGuard.sanitise('I am not Alexa, I am GPT-4.1 Nano.', false),
|
|
384
|
+
'I am Alexa.'
|
|
385
|
+
);
|
|
386
|
+
ok('detects "are you alexa mini?"', IdentityGuard.isIdentityQuestion('are you alexa mini?'));
|
|
387
|
+
ok(
|
|
388
|
+
'identity lock names the exact persona',
|
|
389
|
+
IdentityGuard.hintFor('who are you?').includes('NOT "Alexa Mini"')
|
|
390
|
+
);
|
|
391
|
+
{
|
|
392
|
+
const nova = new IdentityGuard({ assistantName: 'Nova', creator: 'Kasun' });
|
|
393
|
+
check('renamed persona answer', nova.sanitise('I am ChatGPT.', true), 'I am *Nova*, your WhatsApp assistant created by *Kasun*. 😊');
|
|
394
|
+
check('renamed persona strips its own suffix', nova.sanitise('I am Nova Pro, not Nova.', false), 'I am Nova.');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ==========================================================================
|
|
398
|
+
section('AmnesiaGuard — never deny a memory we actually have');
|
|
399
|
+
// ==========================================================================
|
|
400
|
+
{
|
|
401
|
+
const guard = new AmnesiaGuard();
|
|
402
|
+
ok('detects "do you remember me"', AmnesiaGuard.isRecallQuestion('do you remember me?'));
|
|
403
|
+
ok('detects "what is my name"', AmnesiaGuard.isRecallQuestion('what is my name?'));
|
|
404
|
+
ok('detects "who am i"', AmnesiaGuard.isRecallQuestion('who am i'));
|
|
405
|
+
ok('ignores normal chat', !AmnesiaGuard.isRecallQuestion('what is the capital of France?'));
|
|
406
|
+
|
|
407
|
+
ok('detects a denial', AmnesiaGuard.isDenial("Unfortunately, as a bot I can't remember you."));
|
|
408
|
+
ok('detects "no memory of past conversations"', AmnesiaGuard.isDenial('I have no memory of our previous chats.'));
|
|
409
|
+
ok('detects "conversation just started"', AmnesiaGuard.isDenial('Our conversation just started!'));
|
|
410
|
+
ok('does not flag a normal reply', !AmnesiaGuard.isDenial('Sure, the capital of France is Paris.'));
|
|
411
|
+
|
|
412
|
+
const memories = { name: 'Nimal', location: 'Galle', hobby: 'cricket' };
|
|
413
|
+
const fixed = guard.repair("Unfortunately, as a bot I can't remember you.", {
|
|
414
|
+
memories,
|
|
415
|
+
displayName: 'Nimal',
|
|
416
|
+
isRecall: true,
|
|
417
|
+
});
|
|
418
|
+
ok('repairs the denial', fixed.repaired);
|
|
419
|
+
ok('answer uses the stored name', fixed.text.includes('Nimal'));
|
|
420
|
+
ok('answer uses the stored facts', fixed.text.includes('Galle') && fixed.text.includes('cricket'));
|
|
421
|
+
ok('no denial survives', !AmnesiaGuard.isDenial(fixed.text));
|
|
422
|
+
|
|
423
|
+
const noFacts = guard.repair("I can't remember previous conversations.", { memories: {}, isRecall: true });
|
|
424
|
+
ok('honest when nothing is stored', noFacts.repaired && /don't have any details saved/.test(noFacts.text));
|
|
425
|
+
ok('but never claims to be memoryless', !AmnesiaGuard.isDenial(noFacts.text));
|
|
426
|
+
|
|
427
|
+
const untouched = guard.repair('Paris is the capital of France.', { memories });
|
|
428
|
+
ok('leaves clean replies alone', !untouched.repaired);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ==========================================================================
|
|
432
|
+
section('IdentityResolver — one human, many WhatsApp addresses');
|
|
433
|
+
// ==========================================================================
|
|
434
|
+
check(
|
|
435
|
+
'collects the primary jid first',
|
|
436
|
+
IdentityResolver.collectAliases({ userId: '78151912841263@lid' }),
|
|
437
|
+
['78151912841263@lid']
|
|
438
|
+
);
|
|
439
|
+
check(
|
|
440
|
+
'collects a LID + phone pair (Baileys participantAlt)',
|
|
441
|
+
IdentityResolver.collectAliases({
|
|
442
|
+
userId: '78151912841263@lid',
|
|
443
|
+
participantAlt: '94771234567@s.whatsapp.net',
|
|
444
|
+
}),
|
|
445
|
+
['78151912841263@lid', '94771234567@s.whatsapp.net']
|
|
446
|
+
);
|
|
447
|
+
check(
|
|
448
|
+
'accepts a bare phone number',
|
|
449
|
+
IdentityResolver.collectAliases({ userId: '78151912841263@lid', userPhone: '+94771234567' }),
|
|
450
|
+
['78151912841263@lid', '94771234567@s.whatsapp.net']
|
|
451
|
+
);
|
|
452
|
+
check(
|
|
453
|
+
'de-duplicates device suffixes',
|
|
454
|
+
IdentityResolver.collectAliases({
|
|
455
|
+
userId: '94771234567:12@s.whatsapp.net',
|
|
456
|
+
aliases: ['94771234567@s.whatsapp.net'],
|
|
457
|
+
}),
|
|
458
|
+
['94771234567@s.whatsapp.net']
|
|
459
|
+
);
|
|
460
|
+
check('ignores group jids', IdentityResolver.collectAliases({ userId: '120363413125431525@g.us' }), []);
|
|
461
|
+
check('ignores junk', IdentityResolver.toUserJid('not a jid'), null);
|
|
462
|
+
|
|
463
|
+
// ==========================================================================
|
|
464
|
+
section('StreamParser — DeepAI wire packets never reach WhatsApp');
|
|
465
|
+
// ==========================================================================
|
|
466
|
+
{
|
|
467
|
+
const FS = '\u001C';
|
|
468
|
+
const GS = '\u001D';
|
|
469
|
+
const RS = '\u001E';
|
|
470
|
+
|
|
471
|
+
check('plain text passes through', StreamParser.parse('Hello there!').text, 'Hello there!');
|
|
472
|
+
|
|
473
|
+
const withActivity = StreamParser.parse(
|
|
474
|
+
`Search${FS}{"tool_activity":"Searching the web"}${FS}ing done.`
|
|
475
|
+
);
|
|
476
|
+
check('tool activity is stripped', withActivity.text, 'Searching done.');
|
|
477
|
+
check('tool activity is reported', withActivity.toolActivity, ['Searching the web']);
|
|
478
|
+
|
|
479
|
+
const withResults = StreamParser.parse(
|
|
480
|
+
`Here are the results.${FS}[{"title":"A","url":"https://a.example"}]`
|
|
481
|
+
);
|
|
482
|
+
check('trailing payload is removed from the text', withResults.text, 'Here are the results.');
|
|
483
|
+
check('web results are parsed', withResults.webResults[0].url, 'https://a.example');
|
|
484
|
+
|
|
485
|
+
const withImage = StreamParser.parse(
|
|
486
|
+
`Here is your image.${FS}{"type":"generated_image","share_url":"https://img.example/a.png"}`
|
|
487
|
+
);
|
|
488
|
+
check('generated image url extracted', withImage.images, ['https://img.example/a.png']);
|
|
489
|
+
|
|
490
|
+
const withThinking = StreamParser.parse(`${GS}THINKING_START12s${RS}internal reasoning${GS}THINKING_ENDFinal answer.`);
|
|
491
|
+
check('chain of thought is removed', withThinking.text, 'Final answer.');
|
|
492
|
+
check('chain of thought is captured separately', withThinking.thinking.text, 'internal reasoning');
|
|
493
|
+
check('thinking duration captured', withThinking.thinking.duration, '12s');
|
|
494
|
+
|
|
495
|
+
const truncated = StreamParser.parse(`Partial answer${FS}{"type":"generated_ima`);
|
|
496
|
+
check('truncated packet is dropped, not leaked', truncated.text, 'Partial answer');
|
|
497
|
+
|
|
498
|
+
const call = StreamParser.parse(
|
|
499
|
+
`${FS}{"function_call":{"name":"generate_image","arguments":"{\\"prompt\\":\\"a cat\\"}"}}`
|
|
500
|
+
);
|
|
501
|
+
check('function call name parsed', call.functionCall.name, 'generate_image');
|
|
502
|
+
check('function call arguments parsed', call.functionCall.arguments.prompt, 'a cat');
|
|
503
|
+
|
|
504
|
+
ok('no control characters survive', !/[\u001C\u001D\u001E]/.test(withActivity.text + withThinking.text));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ==========================================================================
|
|
508
|
+
async function deepaiClientTests() {
|
|
509
|
+
section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
510
|
+
// ==========================================================================
|
|
511
|
+
{
|
|
512
|
+
const cfg = new Config({ key: 'tryit-1-abc', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 });
|
|
513
|
+
const client = new DeepAIClient(cfg);
|
|
514
|
+
|
|
515
|
+
const calls = [];
|
|
516
|
+
const realFetch = global.fetch;
|
|
517
|
+
global.fetch = async (url, init = {}) => {
|
|
518
|
+
const fields = {};
|
|
519
|
+
if (init.body && typeof init.body.forEach === 'function') {
|
|
520
|
+
init.body.forEach((value, key) => {
|
|
521
|
+
fields[key] = typeof value === 'string' ? value : '[file]';
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
calls.push({ url: String(url), method: init.method || 'GET', headers: init.headers || {}, fields });
|
|
525
|
+
|
|
526
|
+
const respond = (body, status = 200) => ({
|
|
527
|
+
status,
|
|
528
|
+
headers: { get: () => 'text/plain' },
|
|
529
|
+
text: async () => body,
|
|
530
|
+
body: null,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
if (String(url).includes('/hacking_is_a_serious_crime')) return respond('Hi Nimal!');
|
|
534
|
+
if (String(url).includes('/chat_attachments/upload')) {
|
|
535
|
+
return respond(JSON.stringify({ success: true, attachment: { uuid: 'u-1', extraction_status: 'complete' } }));
|
|
536
|
+
}
|
|
537
|
+
if (String(url).includes('/chat_attachments/get')) {
|
|
538
|
+
return respond(JSON.stringify({ success: true, attachment: { uuid: 'u-1', extraction_status: 'complete' } }));
|
|
539
|
+
}
|
|
540
|
+
if (String(url).includes('/check_chat_task_status')) {
|
|
541
|
+
return respond(JSON.stringify({ status: 'COMPLETED', result: 'thought through it' }));
|
|
542
|
+
}
|
|
543
|
+
if (String(url).includes('/check-sensitivity')) return respond(JSON.stringify({ score: 0.1 }));
|
|
544
|
+
if (String(url).includes('/api/text2img')) {
|
|
545
|
+
return respond(JSON.stringify({ id: 'img-1', output_url: 'https://img.example/x.png' }));
|
|
546
|
+
}
|
|
547
|
+
if (String(url).includes('/chat_memory')) return respond(JSON.stringify({ enabled: true, profile: 'p' }));
|
|
548
|
+
if (String(url).includes('/save_chat_session')) return respond(JSON.stringify({ success: true }));
|
|
549
|
+
return respond(JSON.stringify({ success: true }));
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
await (async () => {
|
|
553
|
+
const text = await client.chat([{ role: 'user', content: 'hi' }]);
|
|
554
|
+
check('chat returns clean text', text, 'Hi Nimal!');
|
|
555
|
+
|
|
556
|
+
const chatCall = calls.find((c) => c.url.includes('hacking_is_a_serious_crime'));
|
|
557
|
+
check('chat posts to the DeepAI chat endpoint', chatCall.method, 'POST');
|
|
558
|
+
check('sends the api-key header', chatCall.headers['api-key'], 'tryit-1-abc');
|
|
559
|
+
check('sends an Origin header', chatCall.headers.Origin, 'https://deepai.org');
|
|
560
|
+
check('sends chat_style', chatCall.fields.chat_style, 'chat');
|
|
561
|
+
check('sends the model', chatCall.fields.model, 'standard');
|
|
562
|
+
check('sends the anti-abuse field', chatCall.fields.hacker_is_stinky, 'very_stinky');
|
|
563
|
+
ok('sends a session_uuid', Boolean(chatCall.fields.session_uuid));
|
|
564
|
+
check('advertises tool activity support', chatCall.fields.tool_activity_support, '1');
|
|
565
|
+
check('advertises the image tool', chatCall.fields.enabled_tools, '["image_generator","image_editor"]');
|
|
566
|
+
check('sends the chat history as JSON', JSON.parse(chatCall.fields.chatHistory)[0].content, 'hi');
|
|
567
|
+
|
|
568
|
+
await client.chat([{ role: 'user', content: 'hi' }], { attachmentUuids: ['u-1'] });
|
|
569
|
+
const withAttachment = calls.filter((c) => c.url.includes('hacking'))[1];
|
|
570
|
+
check('attachments are a TOP-LEVEL field', withAttachment.fields.attachment_uuids, '["u-1"]');
|
|
571
|
+
|
|
572
|
+
const attachment = await client.uploadAttachment(Buffer.from('hello'), 'a.txt', 'text/plain');
|
|
573
|
+
check('uploads attachments', attachment.uuid, 'u-1');
|
|
574
|
+
const fetched = await client.getAttachment('u-1');
|
|
575
|
+
check('reads attachment extraction status', fetched.extraction_status, 'complete');
|
|
576
|
+
|
|
577
|
+
const task = await client.taskStatus('t-1');
|
|
578
|
+
check('polls background tasks', task.status, 'COMPLETED');
|
|
579
|
+
check('sensitivity score', await client.checkSensitivity('r-1'), 0.1);
|
|
580
|
+
|
|
581
|
+
const image = await client.text2img('a cat');
|
|
582
|
+
check('text2img returns an output url', image.output_url, 'https://img.example/x.png');
|
|
583
|
+
const imageCall = calls.find((c) => c.url.includes('/api/text2img'));
|
|
584
|
+
check('text2img sends the prompt', imageCall.fields.text, 'a cat');
|
|
585
|
+
|
|
586
|
+
const memory = await client.chatMemory();
|
|
587
|
+
check('reads the DeepAI memory profile', memory.enabled, true);
|
|
588
|
+
await client.saveSession({ uuid: 's-1', messages: [{ role: 'user', content: 'hi' }] });
|
|
589
|
+
ok('saves a server-side session', calls.some((c) => c.url.includes('/save_chat_session')));
|
|
590
|
+
|
|
591
|
+
const urls = calls.map((c) => c.url);
|
|
592
|
+
ok(
|
|
593
|
+
'uses more than the generative endpoint',
|
|
594
|
+
new Set(urls.map((u) => u.split('?')[0])).size >= 7,
|
|
595
|
+
`(hit ${new Set(urls.map((u) => u.split('?')[0])).size} distinct endpoints)`
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
global.fetch = realFetch;
|
|
599
|
+
})();
|
|
600
|
+
}
|
|
601
|
+
{
|
|
602
|
+
const cfg = new Config({
|
|
603
|
+
key: 'tryit-1-abc',
|
|
604
|
+
keys: ['tryit-1-abc', 'tryit-2-def'],
|
|
605
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
606
|
+
maxRetries: 0,
|
|
607
|
+
});
|
|
608
|
+
const client = new DeepAIClient(cfg);
|
|
609
|
+
const realFetch = global.fetch;
|
|
610
|
+
const seenKeys = [];
|
|
611
|
+
global.fetch = async (url, init = {}) => {
|
|
612
|
+
seenKeys.push(init.headers['api-key']);
|
|
613
|
+
const quota = seenKeys.length === 1;
|
|
614
|
+
return {
|
|
615
|
+
status: 200,
|
|
616
|
+
headers: { get: () => 'application/json' },
|
|
617
|
+
text: async () => (quota ? JSON.stringify({ status: 'anonymous try it exceeded' }) : 'second key works'),
|
|
618
|
+
body: null,
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
await (async () => {
|
|
622
|
+
const text = await client.chat([{ role: 'user', content: 'hi' }]);
|
|
623
|
+
check('rotates to the next key on a quota refusal', text, 'second key works');
|
|
624
|
+
check('used both keys', seenKeys, ['tryit-1-abc', 'tryit-2-def']);
|
|
625
|
+
global.fetch = realFetch;
|
|
626
|
+
})();
|
|
627
|
+
}
|
|
628
|
+
{
|
|
629
|
+
ok(
|
|
630
|
+
'anonymous key generator matches the deepai.org shape',
|
|
631
|
+
/^tryit-\d{10}-[0-9a-f]{32}$/.test(DeepAIClient.generateTryItKey())
|
|
632
|
+
);
|
|
633
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
634
|
+
check('endpoint map exposes the chat route', cfg.url('chat'), 'https://api.deepai.org/hacking_is_a_serious_crime');
|
|
635
|
+
check(
|
|
636
|
+
'endpoint map builds query strings',
|
|
637
|
+
cfg.url('taskStatus', { type: 'thinking-task', task_id: 'x' }),
|
|
638
|
+
'https://api.deepai.org/check_chat_task_status?type=thinking-task&task_id=x'
|
|
639
|
+
);
|
|
640
|
+
check('endpoints are overridable', new Config({
|
|
641
|
+
key: 'k',
|
|
642
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
643
|
+
endpoints: { chat: '/v2/chat' },
|
|
644
|
+
}).url('chat'), 'https://api.deepai.org/v2/chat');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ==========================================================================
|
|
650
|
+
// End-to-end: the whole chat() pipeline on a fake database + fake DeepAI.
|
|
651
|
+
// The cross-chat identity scenario, end to end, with no network.
|
|
652
|
+
// ==========================================================================
|
|
653
|
+
async function endToEndTests() {
|
|
654
|
+
section('AlexaAI.chat() — end to end (fake database + fake DeepAI)');
|
|
655
|
+
|
|
656
|
+
const ai = new AlexaAI({ key: 'tryit-1-x', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
|
|
657
|
+
const db = createFakeDb();
|
|
658
|
+
ai.db = db;
|
|
659
|
+
ai.users.db = db;
|
|
660
|
+
ai.memories.db = db;
|
|
661
|
+
ai.conversations.db = db;
|
|
662
|
+
ai.identities.db = db;
|
|
663
|
+
|
|
664
|
+
const deepai = installFakeDeepAI();
|
|
665
|
+
try {
|
|
666
|
+
// 1. The user introduces themselves in a DM (phone jid).
|
|
667
|
+
deepai.push('Nice to meet you, Nimal! 🏏 @MEMORY: {"name":"Nimal","hobby":"cricket"}');
|
|
668
|
+
const dm = await ai.chat({
|
|
669
|
+
message: "Hi, I'm Nimal and I love cricket. I live in Galle.",
|
|
670
|
+
userId: '94771234567@s.whatsapp.net',
|
|
671
|
+
userName: 'Nimal',
|
|
672
|
+
});
|
|
673
|
+
check('DM reply is clean', dm.text, 'Nice to meet you, Nimal! 🏏');
|
|
674
|
+
check('DM learned the name', dm.memories.name, 'Nimal');
|
|
675
|
+
check('DM learned the location (FactMiner)', dm.memories.location, 'Galle');
|
|
676
|
+
check('DM thread key', dm.contextKey, 'dm:94771234567@s.whatsapp.net');
|
|
677
|
+
|
|
678
|
+
// 2. THE BUG: the same human writes in a group as @lid.
|
|
679
|
+
deepai.push("Unfortunately, as a bot I can't remember you.");
|
|
680
|
+
const group = await ai.chat({
|
|
681
|
+
message: 'do you remember me?',
|
|
682
|
+
userId: '78151912841263@lid',
|
|
683
|
+
participantAlt: '94771234567@s.whatsapp.net', // Baileys gives us both
|
|
684
|
+
groupId: '120363413125431525@g.us',
|
|
685
|
+
groupName: 'Cricket Fans',
|
|
686
|
+
userName: 'Nimal',
|
|
687
|
+
});
|
|
688
|
+
check('group message resolves to the SAME person', group.userId, dm.userId);
|
|
689
|
+
ok('both addresses are linked', group.aliases.length === 2);
|
|
690
|
+
ok('the denial was repaired', group.repairedMemory);
|
|
691
|
+
ok('the reply now recalls the name', group.text.includes('Nimal'));
|
|
692
|
+
ok('the reply recalls DM facts in the group', group.text.includes('Galle') && group.text.includes('cricket'));
|
|
693
|
+
ok('no "cannot remember" survives', !AmnesiaGuard.isDenial(group.text));
|
|
694
|
+
ok('group thread is separate from the DM', group.contextKey.startsWith('group:'));
|
|
695
|
+
|
|
696
|
+
// The prompt itself must carry the facts.
|
|
697
|
+
const groupPrompt = JSON.parse(deepai.calls[deepai.calls.length - 1].fields.chatHistory);
|
|
698
|
+
const lastTurn = groupPrompt[groupPrompt.length - 1].content;
|
|
699
|
+
ok('prompt carries the remembered facts', lastTurn.includes('name=Nimal'));
|
|
700
|
+
ok('prompt carries the memory directive', lastTurn.includes('MEMORY CHECK'));
|
|
701
|
+
|
|
702
|
+
// 3. Identity: "I'm Alexa Mini, not Alexa" must never ship.
|
|
703
|
+
deepai.push("I'm Alexa Mini, not Alexa. How can I help?");
|
|
704
|
+
const who = await ai.chat({ message: 'are you alexa?', userId: '78151912841263@lid' });
|
|
705
|
+
check('identity answer is corrected', who.text, 'I am *Alexa*, your WhatsApp assistant created by *Hansaka*. 😊');
|
|
706
|
+
const idPrompt = JSON.parse(deepai.calls[deepai.calls.length - 1].fields.chatHistory);
|
|
707
|
+
ok('identity lock was injected', idPrompt[idPrompt.length - 1].content.includes('IDENTITY LOCK'));
|
|
708
|
+
|
|
709
|
+
// 4. Triggers still bypass the model entirely.
|
|
710
|
+
const trigger = await ai.chat({ message: 'What is the weather in Colombo today?', userId: '78151912841263@lid' });
|
|
711
|
+
check('trigger output is byte-exact', trigger.text, 'weather Colombo');
|
|
712
|
+
check('trigger type reported', trigger.trigger, 'weather');
|
|
713
|
+
|
|
714
|
+
// 5. Wire packets never reach WhatsApp.
|
|
715
|
+
deepai.push('Here you go.\u001C{"type":"generated_image","share_url":"https://img.example/x.png"}');
|
|
716
|
+
const image = await ai.chat({ message: 'draw a cat', userId: '78151912841263@lid' });
|
|
717
|
+
check('payload stripped from the text', image.text, 'Here you go.');
|
|
718
|
+
check('generated image surfaced', image.images, ['https://img.example/x.png']);
|
|
719
|
+
|
|
720
|
+
// 6. A vendor leak in an ordinary reply is scrubbed.
|
|
721
|
+
deepai.push('Sure! DeepAI can help you with that.');
|
|
722
|
+
const leak = await ai.chat({ message: 'can you help me?', userId: '78151912841263@lid' });
|
|
723
|
+
ok('vendor name never ships', !/deepai/i.test(leak.text));
|
|
724
|
+
} finally {
|
|
725
|
+
deepai.restore();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ==========================================================================
|
|
730
|
+
// Integration tests
|
|
731
|
+
// ==========================================================================
|
|
732
|
+
async function integration() {
|
|
733
|
+
const pgUrl = process.env.POSTGRES_URL;
|
|
734
|
+
const key = process.env.DEEPAI_KEY;
|
|
735
|
+
|
|
736
|
+
if (!pgUrl) {
|
|
737
|
+
console.log('\n\x1b[33m⏭ Skipping DB integration tests (set POSTGRES_URL)\x1b[0m');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
section('Integration — PostgreSQL (live)');
|
|
742
|
+
const ai = new AlexaAI({
|
|
743
|
+
key: key || 'tryit-test-key',
|
|
744
|
+
postgresUrl: pgUrl,
|
|
745
|
+
autoMigrate: true,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const USER_A = '78151912841263@lid';
|
|
749
|
+
const USER_B = '94771234567@s.whatsapp.net';
|
|
750
|
+
const GROUP = '120363413125431525@g.us';
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
await ai.init();
|
|
754
|
+
const health = await ai.health();
|
|
755
|
+
ok('database reachable', health.ok, health.error || '');
|
|
756
|
+
|
|
757
|
+
// clean slate
|
|
758
|
+
await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
|
|
759
|
+
|
|
760
|
+
// --- users / groups ------------------------------------------------
|
|
761
|
+
const userA = await ai.users.upsertUser(USER_A, { pushName: 'Nimal' });
|
|
762
|
+
ok('creates @lid user', userA.jid === USER_A);
|
|
763
|
+
check('stores no phone for @lid', userA.phone, null);
|
|
764
|
+
|
|
765
|
+
const again = await ai.users.upsertUser(USER_A, { pushName: 'Nimal' });
|
|
766
|
+
check('upsert is idempotent (same id)', again.id, userA.id);
|
|
767
|
+
|
|
768
|
+
const userB = await ai.users.upsertUser(USER_B, { pushName: 'Kasun' });
|
|
769
|
+
check('stores phone for @s.whatsapp.net', userB.phone, '94771234567');
|
|
770
|
+
|
|
771
|
+
const grp = await ai.users.upsertGroup(GROUP, { subject: 'Test Group' });
|
|
772
|
+
ok('creates group', grp.jid === GROUP);
|
|
773
|
+
await ai.users.linkMember(grp.id, userA.id);
|
|
774
|
+
ok('links member to group', true);
|
|
775
|
+
|
|
776
|
+
// --- memories are GLOBAL per user ------------------------------------
|
|
777
|
+
await ai.memories.remember(userA.id, 'name', 'Nimal', { learnedIn: 'dm:' + USER_A });
|
|
778
|
+
await ai.memories.remember(userA.id, 'hobby', 'cricket', { learnedIn: 'group:' + GROUP });
|
|
779
|
+
|
|
780
|
+
const map = await ai.memories.getMap(userA.id);
|
|
781
|
+
check('memory saved in DM is readable', map.name, 'Nimal');
|
|
782
|
+
check('memory saved in group is readable', map.hobby, 'cricket');
|
|
783
|
+
|
|
784
|
+
await ai.memories.remember(userA.id, 'name', 'Nimal Perera');
|
|
785
|
+
const map2 = await ai.memories.getMap(userA.id);
|
|
786
|
+
check('re-learning a key overwrites (no duplicate)', map2.name, 'Nimal Perera');
|
|
787
|
+
const all = await ai.memories.getAll(userA.id);
|
|
788
|
+
check('still only 2 memory rows', all.length, 2);
|
|
789
|
+
|
|
790
|
+
const bMap = await ai.memories.getMap(userB.id);
|
|
791
|
+
check("user B cannot see user A's memories", Object.keys(bMap).length, 0);
|
|
792
|
+
|
|
793
|
+
// --- identity: ONE human behind two WhatsApp addresses --------------
|
|
794
|
+
// The core scenario: a DM arrives from 947…@s.whatsapp.net and
|
|
795
|
+
// the same person writes in a group as 781…@lid.
|
|
796
|
+
await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
|
|
797
|
+
|
|
798
|
+
const dmPerson = await ai.resolver.resolve([USER_B], { pushName: 'Nimal' });
|
|
799
|
+
await ai.memories.remember(dmPerson.user.id, 'name', 'Nimal');
|
|
800
|
+
await ai.memories.remember(dmPerson.user.id, 'location', 'Galle');
|
|
801
|
+
|
|
802
|
+
// Group message: @lid primary, phone supplied as an alias.
|
|
803
|
+
const groupPerson = await ai.resolver.resolve([USER_A, USER_B], { pushName: 'Nimal' });
|
|
804
|
+
check('same user row in DM and group', Number(groupPerson.user.id), Number(dmPerson.user.id));
|
|
805
|
+
const seenInGroup = await ai.memories.getMap(groupPerson.user.id);
|
|
806
|
+
check('DM memory is readable in the group', seenInGroup.name, 'Nimal');
|
|
807
|
+
check('every DM fact survives', seenInGroup.location, 'Galle');
|
|
808
|
+
|
|
809
|
+
const aliasList = await ai.getAliases(USER_A);
|
|
810
|
+
ok('both addresses are linked', aliasList.includes(USER_A) && aliasList.includes(USER_B));
|
|
811
|
+
const byLid = await ai.getMemories(USER_A);
|
|
812
|
+
check('getMemories works through an alias', byLid.name, 'Nimal');
|
|
813
|
+
|
|
814
|
+
const oneRow = await ai.stats();
|
|
815
|
+
check('no duplicate user row was created', Number(oneRow.users), 1);
|
|
816
|
+
|
|
817
|
+
// --- retro-fix: two rows already exist, then we learn they match ----
|
|
818
|
+
await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
|
|
819
|
+
const older = await ai.users.upsertUser(USER_A, { pushName: 'Nimal' });
|
|
820
|
+
await ai.memories.remember(older.id, 'hobby', 'cricket');
|
|
821
|
+
const newer = await ai.users.upsertUser(USER_B, { pushName: 'Nimal' });
|
|
822
|
+
await ai.memories.remember(newer.id, 'favourite_food', 'kottu');
|
|
823
|
+
const dmConvoBefore = await ai.conversations.upsertConversation({
|
|
824
|
+
contextKey: 'dm:' + USER_B,
|
|
825
|
+
userId: newer.id,
|
|
826
|
+
});
|
|
827
|
+
await ai.conversations.addMessage({
|
|
828
|
+
conversationId: dmConvoBefore.id,
|
|
829
|
+
userId: newer.id,
|
|
830
|
+
role: 'user',
|
|
831
|
+
content: 'hello from the phone jid',
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const survivor = await ai.linkIdentity(USER_A, USER_B);
|
|
835
|
+
ok('link keeps the older row', Number(survivor.id) === Number(older.id));
|
|
836
|
+
const mergedMemories = await ai.getMemories(USER_B);
|
|
837
|
+
check('memories from both rows survive the merge', [mergedMemories.hobby, mergedMemories.favourite_food], [
|
|
838
|
+
'cricket',
|
|
839
|
+
'kottu',
|
|
840
|
+
]);
|
|
841
|
+
const afterMerge = await ai.stats();
|
|
842
|
+
check('duplicate row is gone', Number(afterMerge.users), 1);
|
|
843
|
+
const movedThread = await ai.conversations.findByContextKey('dm:' + USER_B);
|
|
844
|
+
check('threads move to the surviving user', Number(movedThread.user_id), Number(older.id));
|
|
845
|
+
const who = await ai.whoIs(USER_A);
|
|
846
|
+
check('whoIs lists both addresses', who.aliases.sort(), [USER_A, USER_B].sort());
|
|
847
|
+
|
|
848
|
+
await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
|
|
849
|
+
const restoredA = await ai.users.upsertUser(USER_A, { pushName: 'Nimal' });
|
|
850
|
+
await ai.users.upsertUser(USER_B, { pushName: 'Kasun' });
|
|
851
|
+
ok('unrelated users stay separate', true);
|
|
852
|
+
await ai.memories.remember(restoredA.id, 'name', 'Nimal');
|
|
853
|
+
await ai.memories.remember(restoredA.id, 'hobby', 'cricket');
|
|
854
|
+
await ai.memories.rememberMany(restoredA.id, { city: 'Galle', food: 'kottu' }, {});
|
|
855
|
+
|
|
856
|
+
const map3 = await ai.memories.getMap(restoredA.id);
|
|
857
|
+
check('rememberMany stores all', [map3.city, map3.food], ['Galle', 'kottu']);
|
|
858
|
+
|
|
859
|
+
// --- conversations are per-thread -------------------------------------
|
|
860
|
+
const dmKey = JidParser.contextKey(USER_A);
|
|
861
|
+
const groupKey = JidParser.contextKey(USER_A, GROUP);
|
|
862
|
+
|
|
863
|
+
const grp2 = await ai.users.upsertGroup(GROUP, { subject: 'Test Group' });
|
|
864
|
+
const dmConvo = await ai.conversations.upsertConversation({ contextKey: dmKey, userId: restoredA.id });
|
|
865
|
+
const grpConvo = await ai.conversations.upsertConversation({
|
|
866
|
+
contextKey: groupKey,
|
|
867
|
+
userId: restoredA.id,
|
|
868
|
+
groupId: grp2.id,
|
|
869
|
+
});
|
|
870
|
+
ok('DM and group threads are distinct', dmConvo.id !== grpConvo.id);
|
|
871
|
+
check('DM thread kind', dmConvo.kind, 'dm');
|
|
872
|
+
check('group thread kind', grpConvo.kind, 'group');
|
|
873
|
+
|
|
874
|
+
await ai.conversations.addMessage({
|
|
875
|
+
conversationId: dmConvo.id,
|
|
876
|
+
userId: restoredA.id,
|
|
877
|
+
role: 'user',
|
|
878
|
+
content: 'dm message',
|
|
879
|
+
});
|
|
880
|
+
await ai.conversations.addMessage({
|
|
881
|
+
conversationId: grpConvo.id,
|
|
882
|
+
userId: restoredA.id,
|
|
883
|
+
role: 'user',
|
|
884
|
+
content: 'group message',
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const dmHist = await ai.conversations.getHistory(dmConvo.id);
|
|
888
|
+
const grpHist = await ai.conversations.getHistory(grpConvo.id);
|
|
889
|
+
check('DM history isolated', dmHist.length, 1);
|
|
890
|
+
check('group history isolated', grpHist.length, 1);
|
|
891
|
+
check('DM content correct', dmHist[0].content, 'dm message');
|
|
892
|
+
ok('no context bleed between rooms', grpHist[0].content === 'group message');
|
|
893
|
+
|
|
894
|
+
// --- dedupe -------------------------------------------------------------
|
|
895
|
+
await ai.conversations.addMessage({
|
|
896
|
+
conversationId: dmConvo.id,
|
|
897
|
+
userId: restoredA.id,
|
|
898
|
+
role: 'user',
|
|
899
|
+
content: 'dupe',
|
|
900
|
+
waMessageId: 'WAMSG1',
|
|
901
|
+
});
|
|
902
|
+
const dup = await ai.conversations.addMessage({
|
|
903
|
+
conversationId: dmConvo.id,
|
|
904
|
+
userId: restoredA.id,
|
|
905
|
+
role: 'user',
|
|
906
|
+
content: 'dupe',
|
|
907
|
+
waMessageId: 'WAMSG1',
|
|
908
|
+
});
|
|
909
|
+
check('duplicate WhatsApp message id is ignored', dup, null);
|
|
910
|
+
|
|
911
|
+
// --- history ordering + limit -------------------------------------------
|
|
912
|
+
for (let i = 0; i < 10; i++) {
|
|
913
|
+
await ai.conversations.addMessage({
|
|
914
|
+
conversationId: dmConvo.id,
|
|
915
|
+
userId: restoredA.id,
|
|
916
|
+
role: i % 2 === 0 ? 'user' : 'assistant',
|
|
917
|
+
content: `msg-${i}`,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
const limited = await ai.conversations.getHistory(dmConvo.id, 5);
|
|
921
|
+
check('history respects limit', limited.length, 5);
|
|
922
|
+
check('history is chronological (oldest first)', limited[0].content, 'msg-5');
|
|
923
|
+
check('history ends with newest', limited[4].content, 'msg-9');
|
|
924
|
+
|
|
925
|
+
// --- clearing history keeps memories -------------------------------------
|
|
926
|
+
await ai.clearHistory(USER_A);
|
|
927
|
+
const clearedHist = await ai.conversations.getHistory(dmConvo.id);
|
|
928
|
+
check('history cleared', clearedHist.length, 0);
|
|
929
|
+
const survived = await ai.getMemories(USER_A);
|
|
930
|
+
ok('memories survive history clear', Object.keys(survived).length === 4);
|
|
931
|
+
|
|
932
|
+
// --- profile + stats ------------------------------------------------------
|
|
933
|
+
const profile = await ai.getProfile(USER_A);
|
|
934
|
+
ok('profile returns user', profile.user.jid === USER_A);
|
|
935
|
+
ok('profile lists both threads', profile.conversations.length === 2);
|
|
936
|
+
|
|
937
|
+
const stats = await ai.stats();
|
|
938
|
+
ok('stats counts users', Number(stats.users) === 2);
|
|
939
|
+
|
|
940
|
+
// --- blocking ---------------------------------------------------------------
|
|
941
|
+
await ai.blockUser(USER_B);
|
|
942
|
+
ok('user blocked', await ai.users.isBlocked(USER_B));
|
|
943
|
+
await ai.unblockUser(USER_B);
|
|
944
|
+
ok('user unblocked', !(await ai.users.isBlocked(USER_B)));
|
|
945
|
+
|
|
946
|
+
// --- forget -------------------------------------------------------------------
|
|
947
|
+
await ai.forget(USER_A, 'food');
|
|
948
|
+
const afterForget = await ai.getMemories(USER_A);
|
|
949
|
+
ok('forget removes one key', afterForget.food === undefined);
|
|
950
|
+
await ai.forgetAll(USER_A);
|
|
951
|
+
const afterAll = await ai.getMemories(USER_A);
|
|
952
|
+
check('forgetAll clears everything', Object.keys(afterAll).length, 0);
|
|
953
|
+
|
|
954
|
+
// ================= LIVE DEEPAI =====================
|
|
955
|
+
if (key) {
|
|
956
|
+
section('Integration — DeepAI (live API)');
|
|
957
|
+
await ai.db.query('TRUNCATE wa_users, wa_groups RESTART IDENTITY CASCADE');
|
|
958
|
+
|
|
959
|
+
const r1 = await ai.chat({
|
|
960
|
+
message: 'Hello! Reply in one short sentence.',
|
|
961
|
+
userId: USER_A,
|
|
962
|
+
userName: 'Nimal',
|
|
963
|
+
});
|
|
964
|
+
ok('live chat returns text', r1.text.length > 0, r1.error || '');
|
|
965
|
+
ok('no ** in reply', !r1.text.includes('**'));
|
|
966
|
+
ok('no @MEMORY leak in reply', !/@MEMORY/i.test(r1.text));
|
|
967
|
+
console.log(` ↳ "${r1.text.slice(0, 90)}"`);
|
|
968
|
+
|
|
969
|
+
const r2 = await ai.chat({ message: 'ping', userId: USER_A });
|
|
970
|
+
check('trigger: ping is exact', r2.text, 'ping');
|
|
971
|
+
check('trigger type recorded', r2.trigger, 'ping');
|
|
972
|
+
|
|
973
|
+
const r3 = await ai.chat({ message: 'What is the weather in Colombo today?', userId: USER_A });
|
|
974
|
+
check('trigger: weather is exact', r3.text, 'weather Colombo');
|
|
975
|
+
|
|
976
|
+
const r4 = await ai.chat({ message: 'show menu', userId: USER_A });
|
|
977
|
+
check('trigger: menu is exact', r4.text, 'menu');
|
|
978
|
+
|
|
979
|
+
const r5 = await ai.chat({ message: 'send me the docs', userId: USER_A });
|
|
980
|
+
check('trigger: doc is exact', r5.text, 'doc');
|
|
981
|
+
|
|
982
|
+
// memory round-trip through the real model
|
|
983
|
+
const r6 = await ai.chat({
|
|
984
|
+
message: "Hi! My name is Nimal and I love playing cricket.",
|
|
985
|
+
userId: USER_A,
|
|
986
|
+
userName: 'Nimal',
|
|
987
|
+
});
|
|
988
|
+
ok('intro reply produced', r6.text.length > 0);
|
|
989
|
+
console.log(` ↳ "${r6.text.slice(0, 90)}"`);
|
|
990
|
+
const learned = await ai.getMemories(USER_A);
|
|
991
|
+
console.log(` ↳ learned: ${JSON.stringify(learned)}`);
|
|
992
|
+
ok(
|
|
993
|
+
'model-driven memory captured (name and/or hobby)',
|
|
994
|
+
Object.keys(learned).length > 0,
|
|
995
|
+
'(model-dependent)'
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
// cross-room recognition
|
|
999
|
+
const r7 = await ai.chat({
|
|
1000
|
+
message: 'Do you remember my name? Answer in one short sentence.',
|
|
1001
|
+
userId: USER_A,
|
|
1002
|
+
groupId: GROUP,
|
|
1003
|
+
groupName: 'Cricket Fans',
|
|
1004
|
+
userName: 'Nimal',
|
|
1005
|
+
});
|
|
1006
|
+
console.log(` ↳ group recall: "${r7.text.slice(0, 110)}"`);
|
|
1007
|
+
ok('group reply produced', r7.text.length > 0);
|
|
1008
|
+
ok('group thread key differs from DM', r7.contextKey.startsWith('group:'));
|
|
1009
|
+
} else {
|
|
1010
|
+
console.log('\n\x1b[33m⏭ Skipping live DeepAI tests (set DEEPAI_KEY)\x1b[0m');
|
|
1011
|
+
}
|
|
1012
|
+
} finally {
|
|
1013
|
+
await ai.close();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
deepaiClientTests()
|
|
1018
|
+
.then(endToEndTests)
|
|
1019
|
+
.then(integration)
|
|
1020
|
+
.catch((err) => {
|
|
1021
|
+
failed++;
|
|
1022
|
+
failures.push(`Integration crashed: ${err.stack || err.message}`);
|
|
1023
|
+
console.log(`\n\x1b[31m❌ Integration error: ${err.message}\x1b[0m`);
|
|
1024
|
+
if (process.env.DEBUG) console.error(err);
|
|
1025
|
+
})
|
|
1026
|
+
.finally(() => {
|
|
1027
|
+
console.log(`\n${'═'.repeat(62)}`);
|
|
1028
|
+
console.log(`\x1b[1mRESULT\x1b[0m ✅ ${passed} passed ❌ ${failed} failed`);
|
|
1029
|
+
console.log('═'.repeat(62));
|
|
1030
|
+
if (failures.length) {
|
|
1031
|
+
console.log('\nFailures:');
|
|
1032
|
+
failures.forEach((f, i) => console.log(` ${i + 1}. ${f}`));
|
|
1033
|
+
}
|
|
1034
|
+
process.exit(failed ? 1 : 0);
|
|
1035
|
+
});
|