@khirby/plugin-ai-compose 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/LICENSE +21 -0
- package/package.json +24 -0
- package/src/ai-compose-crypto.ts +60 -0
- package/src/ai-compose-generate.controller.ts +69 -0
- package/src/ai-compose-settings.controller.ts +95 -0
- package/src/ai-compose-settings.service.ts +139 -0
- package/src/ai-compose-suggest.controller.ts +37 -0
- package/src/ai-compose-suggest.service.ts +619 -0
- package/src/ai-compose.module.ts +17 -0
- package/src/ai-compose.plugin.ts +32 -0
- package/src/ai-compose.spec.ts +691 -0
- package/src/index.ts +7 -0
- package/src/migrations.ts +16 -0
- package/src/schema.ts +14 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { encrypt, decrypt, isAiComposeSecretsKeyConfigured } from './ai-compose-crypto';
|
|
2
|
+
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
3
|
+
import {
|
|
4
|
+
AiComposeSuggestService,
|
|
5
|
+
stripCodeFences,
|
|
6
|
+
parsePokeloRoute,
|
|
7
|
+
} from './ai-compose-suggest.service';
|
|
8
|
+
import { AppException } from '../../../packages/plugin-host/src';
|
|
9
|
+
|
|
10
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Crypto round-trip
|
|
12
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('ai-compose-crypto', () => {
|
|
15
|
+
const HEX_KEY = 'a'.repeat(64);
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
process.env.AI_COMPOSE_SECRETS_KEY = HEX_KEY;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('encrypts and decrypts back to the same plaintext', () => {
|
|
26
|
+
const plaintext = 'sk-super-secret-key';
|
|
27
|
+
const cipher = encrypt(plaintext);
|
|
28
|
+
expect(cipher).not.toEqual(plaintext);
|
|
29
|
+
expect(decrypt(cipher)).toEqual(plaintext);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('isAiComposeSecretsKeyConfigured returns false when key is unset', () => {
|
|
33
|
+
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
34
|
+
expect(isAiComposeSecretsKeyConfigured()).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('isAiComposeSecretsKeyConfigured returns true when key is correct', () => {
|
|
38
|
+
process.env.AI_COMPOSE_SECRETS_KEY = HEX_KEY;
|
|
39
|
+
expect(isAiComposeSecretsKeyConfigured()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('throws on missing key at encrypt time', () => {
|
|
43
|
+
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
44
|
+
expect(() => encrypt('anything')).toThrow('AI_COMPOSE_SECRETS_KEY is not set');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Settings service — allowlist / model resolution
|
|
50
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function makeSelectChain(returnValue: unknown[]) {
|
|
53
|
+
// Drizzle chain: select().from().where?().limit() resolves to array
|
|
54
|
+
const chain: Record<string, unknown> = {};
|
|
55
|
+
const resolved = Promise.resolve(returnValue);
|
|
56
|
+
// Add a then so the chain itself is awaitable (matches `const [x] = await chain`)
|
|
57
|
+
(chain as any).then = resolved.then.bind(resolved);
|
|
58
|
+
(chain as any).catch = resolved.catch.bind(resolved);
|
|
59
|
+
chain.select = () => chain;
|
|
60
|
+
chain.from = () => chain;
|
|
61
|
+
chain.where = () => chain;
|
|
62
|
+
chain.limit = () => chain;
|
|
63
|
+
return chain;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeInsertChain() {
|
|
67
|
+
const chain: Record<string, unknown> = {};
|
|
68
|
+
const resolved = Promise.resolve([]);
|
|
69
|
+
(chain as any).then = resolved.then.bind(resolved);
|
|
70
|
+
(chain as any).catch = resolved.catch.bind(resolved);
|
|
71
|
+
chain.values = () => chain;
|
|
72
|
+
return chain;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeUpdateChain() {
|
|
76
|
+
const chain: Record<string, unknown> = {};
|
|
77
|
+
const resolved = Promise.resolve([]);
|
|
78
|
+
(chain as any).then = resolved.then.bind(resolved);
|
|
79
|
+
(chain as any).catch = resolved.catch.bind(resolved);
|
|
80
|
+
chain.set = () => chain;
|
|
81
|
+
chain.where = () => chain;
|
|
82
|
+
return chain;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeMockDb(row?: unknown) {
|
|
86
|
+
const rows = row !== undefined ? [row] : [];
|
|
87
|
+
return {
|
|
88
|
+
select: () => makeSelectChain(rows),
|
|
89
|
+
insert: () => makeInsertChain(),
|
|
90
|
+
update: () => makeUpdateChain(),
|
|
91
|
+
delete: () => Promise.resolve([]),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeMockRegistry(enabled = true) {
|
|
96
|
+
return {
|
|
97
|
+
findByName: jest.fn().mockResolvedValue({ name: 'crm_ai_compose', enabled, config: null }),
|
|
98
|
+
isEnabled: jest.fn().mockReturnValue(enabled),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('AiComposeSettingsService', () => {
|
|
103
|
+
const HEX_KEY = 'b'.repeat(64);
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
process.env.AI_COMPOSE_SECRETS_KEY = HEX_KEY;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('getSettings returns apiKeyConfigured: false when no row', async () => {
|
|
114
|
+
const db = makeMockDb(); // empty result
|
|
115
|
+
const registry = makeMockRegistry(true);
|
|
116
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
117
|
+
const settings = await service.getSettings();
|
|
118
|
+
expect(settings.apiKeyConfigured).toBe(false);
|
|
119
|
+
expect(settings.baseUrl).toBe('https://api.openai.com/v1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('getSettings returns apiKeyConfigured: true when row has apiKeyEnc', async () => {
|
|
123
|
+
const enc = encrypt('sk-test');
|
|
124
|
+
const db = makeMockDb({
|
|
125
|
+
apiKeyEnc: enc,
|
|
126
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
127
|
+
defaultModel: null,
|
|
128
|
+
allowedModels: [],
|
|
129
|
+
systemPrompt: null,
|
|
130
|
+
});
|
|
131
|
+
const registry = makeMockRegistry(true);
|
|
132
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
133
|
+
const settings = await service.getSettings();
|
|
134
|
+
expect(settings.apiKeyConfigured).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('throws 503 when plugin is disabled', async () => {
|
|
138
|
+
const db = makeMockDb();
|
|
139
|
+
const registry = makeMockRegistry(false);
|
|
140
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
141
|
+
await expect(service.getSettings()).rejects.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('updateSettings rejects http baseUrl (not localhost)', async () => {
|
|
145
|
+
const db = makeMockDb();
|
|
146
|
+
const registry = makeMockRegistry(true);
|
|
147
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
148
|
+
await expect(service.updateSettings({ baseUrl: 'http://evil.example.com' })).rejects.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('allows http://localhost baseUrl', async () => {
|
|
152
|
+
const db = makeMockDb();
|
|
153
|
+
const registry = makeMockRegistry(true);
|
|
154
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
155
|
+
await expect(service.updateSettings({ baseUrl: 'http://localhost' })).resolves.toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
// Suggest service — prompt assembly + provider call
|
|
161
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function makeSettingsService(
|
|
164
|
+
overrides: Partial<{
|
|
165
|
+
apiKey: string;
|
|
166
|
+
baseUrl: string;
|
|
167
|
+
allowedModels: string[];
|
|
168
|
+
defaultModel: string | null;
|
|
169
|
+
systemPrompt: string | null;
|
|
170
|
+
}> = {},
|
|
171
|
+
) {
|
|
172
|
+
const cfg = {
|
|
173
|
+
apiKey: 'sk-test',
|
|
174
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
175
|
+
allowedModels: ['gpt-4o', 'gpt-3.5-turbo'],
|
|
176
|
+
defaultModel: 'gpt-4o',
|
|
177
|
+
systemPrompt: null,
|
|
178
|
+
...overrides,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
getDecryptedApiKey: jest.fn().mockResolvedValue({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl }),
|
|
183
|
+
getAllowedModels: jest.fn().mockResolvedValue(cfg.allowedModels),
|
|
184
|
+
getDefaultModel: jest.fn().mockResolvedValue(cfg.defaultModel),
|
|
185
|
+
getSystemPrompt: jest.fn().mockResolvedValue(cfg.systemPrompt),
|
|
186
|
+
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const MOCK_THREAD = {
|
|
191
|
+
id: 'thread-1',
|
|
192
|
+
subject: 'Hello',
|
|
193
|
+
contactId: 'c-1',
|
|
194
|
+
leadId: 'l-1',
|
|
195
|
+
contactEmail: 'client@example.com',
|
|
196
|
+
contactName: 'Jane Doe',
|
|
197
|
+
messages: [
|
|
198
|
+
{
|
|
199
|
+
id: 'm-1',
|
|
200
|
+
direction: 'inbound' as const,
|
|
201
|
+
bodyText: 'Hi, I need help with my order.',
|
|
202
|
+
sentAt: '2024-01-01T10:00:00Z',
|
|
203
|
+
receivedAt: null,
|
|
204
|
+
fromAddress: 'client@example.com',
|
|
205
|
+
toAddresses: ['support@company.com'],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
describe('AiComposeSuggestService', () => {
|
|
211
|
+
const mockMailThreads = {
|
|
212
|
+
getThread: jest.fn().mockResolvedValue(MOCK_THREAD),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const mockLeads = {
|
|
216
|
+
findById: jest.fn().mockResolvedValue({
|
|
217
|
+
id: 'l-1',
|
|
218
|
+
title: 'Jane Doe lead',
|
|
219
|
+
contactEmail: 'client@example.com',
|
|
220
|
+
contactName: 'Jane Doe',
|
|
221
|
+
formName: 'Contact form',
|
|
222
|
+
value: '1000',
|
|
223
|
+
priority: 'high',
|
|
224
|
+
submission: {
|
|
225
|
+
data: {
|
|
226
|
+
name: 'Jane Doe',
|
|
227
|
+
email: 'client@example.com',
|
|
228
|
+
message: 'I need a full CRM rollout for my company.',
|
|
229
|
+
_hp: 'bot',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
jest.clearAllMocks();
|
|
237
|
+
global.fetch = jest.fn();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
afterEach(() => {
|
|
241
|
+
jest.restoreAllMocks();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns a draft from the provider', async () => {
|
|
245
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
246
|
+
ok: true,
|
|
247
|
+
json: async () => ({
|
|
248
|
+
choices: [{ message: { content: 'Draft reply here.' } }],
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const settings = makeSettingsService();
|
|
253
|
+
const service = new AiComposeSuggestService(
|
|
254
|
+
settings as any,
|
|
255
|
+
mockMailThreads as any,
|
|
256
|
+
mockLeads as any,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const result = await service.suggest({ threadId: 'thread-1', leadId: 'l-1' });
|
|
260
|
+
expect(result.draft).toBe('Draft reply here.');
|
|
261
|
+
expect(result.modelUsed).toBe('gpt-4o');
|
|
262
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
263
|
+
expect.stringContaining('/chat/completions'),
|
|
264
|
+
expect.objectContaining({ method: 'POST' }),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('throws 400 when model not in allowlist', async () => {
|
|
269
|
+
const settings = makeSettingsService({ allowedModels: ['gpt-4o'] });
|
|
270
|
+
const service = new AiComposeSuggestService(
|
|
271
|
+
settings as any,
|
|
272
|
+
mockMailThreads as any,
|
|
273
|
+
mockLeads as any,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await expect(service.suggest({ threadId: 'thread-1', model: 'claude-3' })).rejects.toThrow();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('throws 400 when API key is not configured', async () => {
|
|
280
|
+
const settings = {
|
|
281
|
+
...makeSettingsService(),
|
|
282
|
+
getDecryptedApiKey: jest
|
|
283
|
+
.fn()
|
|
284
|
+
.mockRejectedValue(AppException.badRequest('AI Compose API key is not configured')),
|
|
285
|
+
};
|
|
286
|
+
const service = new AiComposeSuggestService(
|
|
287
|
+
settings as any,
|
|
288
|
+
mockMailThreads as any,
|
|
289
|
+
mockLeads as any,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow(
|
|
293
|
+
'AI Compose API key is not configured',
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('throws 400 when provider returns error', async () => {
|
|
298
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
299
|
+
ok: false,
|
|
300
|
+
status: 401,
|
|
301
|
+
text: async () => 'Unauthorized',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const settings = makeSettingsService();
|
|
305
|
+
const service = new AiComposeSuggestService(
|
|
306
|
+
settings as any,
|
|
307
|
+
mockMailThreads as any,
|
|
308
|
+
mockLeads as any,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('uses instruction parameter when provided', async () => {
|
|
315
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
316
|
+
ok: true,
|
|
317
|
+
json: async () => ({ choices: [{ message: { content: 'With instruction.' } }] }),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const settings = makeSettingsService();
|
|
321
|
+
const service = new AiComposeSuggestService(
|
|
322
|
+
settings as any,
|
|
323
|
+
mockMailThreads as any,
|
|
324
|
+
mockLeads as any,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await service.suggest({ threadId: 'thread-1', instruction: 'Be formal' });
|
|
328
|
+
|
|
329
|
+
const callArgs = (global.fetch as jest.Mock).mock.calls[0];
|
|
330
|
+
const body = JSON.parse(callArgs[1].body);
|
|
331
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
332
|
+
expect(systemMsg.content).toContain('Be formal');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('drafts a first outbound from lead context without a thread', async () => {
|
|
336
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
337
|
+
ok: true,
|
|
338
|
+
json: async () => ({
|
|
339
|
+
choices: [{ message: { content: 'Hello Jane, …' } }],
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const settings = makeSettingsService();
|
|
344
|
+
const service = new AiComposeSuggestService(
|
|
345
|
+
settings as any,
|
|
346
|
+
mockMailThreads as any,
|
|
347
|
+
mockLeads as any,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const result = await service.suggest({ leadId: 'l-1' });
|
|
351
|
+
expect(result.draft).toBe('Hello Jane, …');
|
|
352
|
+
expect(mockMailThreads.getThread).not.toHaveBeenCalled();
|
|
353
|
+
|
|
354
|
+
const callArgs = (global.fetch as jest.Mock).mock.calls[0];
|
|
355
|
+
const body = JSON.parse(callArgs[1].body);
|
|
356
|
+
const userMsg = body.messages.find((m: { role: string }) => m.role === 'user');
|
|
357
|
+
expect(userMsg.content).toContain('Lead context:');
|
|
358
|
+
expect(userMsg.content).toContain('Jane Doe');
|
|
359
|
+
expect(userMsg.content).toContain('Form submission:');
|
|
360
|
+
expect(userMsg.content).toContain('I need a full CRM rollout for my company.');
|
|
361
|
+
expect(userMsg.content).not.toContain('bot');
|
|
362
|
+
expect(userMsg.content).toContain('form submission');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('throws 400 when neither threadId nor leadId is provided', async () => {
|
|
366
|
+
const settings = makeSettingsService();
|
|
367
|
+
const service = new AiComposeSuggestService(
|
|
368
|
+
settings as any,
|
|
369
|
+
mockMailThreads as any,
|
|
370
|
+
mockLeads as any,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await expect(service.suggest({})).rejects.toThrow('Either threadId or leadId is required');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('appends Pokelo snippets to the system message when context service is present', async () => {
|
|
377
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
378
|
+
ok: true,
|
|
379
|
+
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const pokelo = {
|
|
383
|
+
fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\nPricing is X'),
|
|
384
|
+
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
385
|
+
};
|
|
386
|
+
const settings = makeSettingsService();
|
|
387
|
+
const service = new AiComposeSuggestService(
|
|
388
|
+
settings as any,
|
|
389
|
+
mockMailThreads as any,
|
|
390
|
+
mockLeads as any,
|
|
391
|
+
pokelo as any,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
await service.suggest({ threadId: 'thread-1', leadId: 'l-1', instruction: 'Be brief' });
|
|
395
|
+
|
|
396
|
+
expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.any(String), {
|
|
397
|
+
projectIds: ['p1'],
|
|
398
|
+
});
|
|
399
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
400
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
401
|
+
expect(systemMsg.content).toContain('Kontekst z Pokelo');
|
|
402
|
+
expect(systemMsg.content).toContain('Pricing is X');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('routes across multiple Pokelo projects then fetches follow-up', async () => {
|
|
406
|
+
(global.fetch as jest.Mock).mockImplementation(async (_url: string, init: { body: string }) => {
|
|
407
|
+
const body = JSON.parse(init.body);
|
|
408
|
+
const system = body.messages?.[0]?.content ?? '';
|
|
409
|
+
if (typeof system === 'string' && system.includes('route knowledge-base')) {
|
|
410
|
+
// Router payload must match draft call (no max_tokens / no temperature:0)
|
|
411
|
+
expect(body.max_tokens).toBeUndefined();
|
|
412
|
+
expect(body.temperature).toBe(0.7);
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
json: async () => ({
|
|
416
|
+
choices: [
|
|
417
|
+
{
|
|
418
|
+
message: {
|
|
419
|
+
content: JSON.stringify({
|
|
420
|
+
primary: ['crm'],
|
|
421
|
+
followUp: ['finsly'],
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const pokelo = {
|
|
436
|
+
listBoundProjects: jest.fn().mockResolvedValue([
|
|
437
|
+
{ id: 'crm', name: 'Bearly CRM' },
|
|
438
|
+
{ id: 'finsly', name: 'Finsly' },
|
|
439
|
+
{ id: 'pokelo', name: 'Pokelo' },
|
|
440
|
+
]),
|
|
441
|
+
fetchContext: jest
|
|
442
|
+
.fn()
|
|
443
|
+
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Bearly CRM] CRM facts')
|
|
444
|
+
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Finsly] Billing facts'),
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const service = new AiComposeSuggestService(
|
|
448
|
+
makeSettingsService() as any,
|
|
449
|
+
mockMailThreads as any,
|
|
450
|
+
mockLeads as any,
|
|
451
|
+
pokelo as any,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
await service.suggest({ threadId: 'thread-1', instruction: 'Mention Finsly pricing' });
|
|
455
|
+
|
|
456
|
+
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(1, expect.any(String), {
|
|
457
|
+
projectIds: ['crm'],
|
|
458
|
+
});
|
|
459
|
+
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(2, expect.any(String), {
|
|
460
|
+
projectIds: ['finsly'],
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const composeCall = (global.fetch as jest.Mock).mock.calls.find((c) => {
|
|
464
|
+
const body = JSON.parse(c[1].body);
|
|
465
|
+
return !String(body.messages?.[0]?.content ?? '').includes('route knowledge-base');
|
|
466
|
+
});
|
|
467
|
+
const systemMsg = JSON.parse(composeCall[1].body).messages.find(
|
|
468
|
+
(m: { role: string }) => m.role === 'system',
|
|
469
|
+
);
|
|
470
|
+
expect(systemMsg.content).toContain('CRM facts');
|
|
471
|
+
expect(systemMsg.content).toContain('Billing facts');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('searches both projects directly when exactly two are bound (no router)', async () => {
|
|
475
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
476
|
+
ok: true,
|
|
477
|
+
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const pokelo = {
|
|
481
|
+
listBoundProjects: jest.fn().mockResolvedValue([
|
|
482
|
+
{ id: 'crm', name: 'Bearly CRM' },
|
|
483
|
+
{ id: 'finsly', name: 'Finsly' },
|
|
484
|
+
]),
|
|
485
|
+
fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\n[CRM] a\n[Finsly] b'),
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const service = new AiComposeSuggestService(
|
|
489
|
+
makeSettingsService() as any,
|
|
490
|
+
mockMailThreads as any,
|
|
491
|
+
mockLeads as any,
|
|
492
|
+
pokelo as any,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
await service.suggest({ threadId: 'thread-1', instruction: 'Hello' });
|
|
496
|
+
|
|
497
|
+
expect(pokelo.fetchContext).toHaveBeenCalledTimes(1);
|
|
498
|
+
expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.any(String), {
|
|
499
|
+
projectIds: ['crm', 'finsly'],
|
|
500
|
+
});
|
|
501
|
+
// Only the compose completion — no router call
|
|
502
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('works without Pokelo when context service is null', async () => {
|
|
506
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
507
|
+
ok: true,
|
|
508
|
+
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const settings = makeSettingsService();
|
|
512
|
+
const service = new AiComposeSuggestService(
|
|
513
|
+
settings as any,
|
|
514
|
+
mockMailThreads as any,
|
|
515
|
+
mockLeads as any,
|
|
516
|
+
null,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
520
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
521
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
522
|
+
expect(systemMsg.content).not.toContain('Kontekst z Pokelo');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
527
|
+
const mockMailThreads = { getThread: jest.fn() };
|
|
528
|
+
const mockLeads = { findById: jest.fn() };
|
|
529
|
+
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
jest.clearAllMocks();
|
|
532
|
+
global.fetch = jest.fn();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
afterEach(() => {
|
|
536
|
+
jest.restoreAllMocks();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
function service(settings = makeSettingsService()) {
|
|
540
|
+
return new AiComposeSuggestService(settings as any, mockMailThreads as any, mockLeads as any);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
it('asks the model for HTML and strips fences', async () => {
|
|
544
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
545
|
+
ok: true,
|
|
546
|
+
json: async () => ({
|
|
547
|
+
choices: [{ message: { content: '```html\n<p>Hello list</p>\n```' } }],
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const result = await service().generateNewsletter({
|
|
552
|
+
contentType: 'html',
|
|
553
|
+
subject: 'March update',
|
|
554
|
+
instruction: 'Friendly product update',
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(result.draft).toBe('<p>Hello list</p>');
|
|
558
|
+
expect(result.modelUsed).toBe('gpt-4o');
|
|
559
|
+
|
|
560
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
561
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
562
|
+
const userMsg = body.messages.find((m: { role: string }) => m.role === 'user');
|
|
563
|
+
expect(systemMsg.content).toContain('Output format: HTML fragment');
|
|
564
|
+
expect(systemMsg.content).toContain('note-box');
|
|
565
|
+
expect(systemMsg.content).toContain('@TrackLink');
|
|
566
|
+
expect(userMsg.content).toContain('Required output format: html');
|
|
567
|
+
expect(userMsg.content).toContain('March update');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('mentions the selected Listmonk template in the prompt', async () => {
|
|
571
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
572
|
+
ok: true,
|
|
573
|
+
json: async () => ({ choices: [{ message: { content: '<p>Hi</p>' } }] }),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await service().generateNewsletter({
|
|
577
|
+
contentType: 'html',
|
|
578
|
+
instruction: 'Product update',
|
|
579
|
+
templateName: 'Finsly',
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
583
|
+
const userMsg = body.messages.find((m: { role: string }) => m.role === 'user');
|
|
584
|
+
expect(userMsg.content).toContain('Finsly');
|
|
585
|
+
expect(userMsg.content).toContain('template');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it.each([
|
|
589
|
+
['markdown', 'Output format: Markdown body fragment'],
|
|
590
|
+
['plain', 'Output format: plain text body fragment'],
|
|
591
|
+
['richtext', 'Output format: simple HTML richtext fragment'],
|
|
592
|
+
] as const)('includes format spec for %s', async (contentType, needle) => {
|
|
593
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
594
|
+
ok: true,
|
|
595
|
+
json: async () => ({ choices: [{ message: { content: 'Body' } }] }),
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
await service().generateNewsletter({ contentType, instruction: 'Say hello' });
|
|
599
|
+
|
|
600
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
601
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
602
|
+
expect(systemMsg.content).toContain(needle);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('throws when there is no brief or campaign context', async () => {
|
|
606
|
+
await expect(service().generateNewsletter({ contentType: 'html' })).rejects.toThrow(
|
|
607
|
+
'Provide an instruction',
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('passes existingBody into the user prompt', async () => {
|
|
612
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
613
|
+
ok: true,
|
|
614
|
+
json: async () => ({ choices: [{ message: { content: 'Improved' } }] }),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await service().generateNewsletter({
|
|
618
|
+
contentType: 'markdown',
|
|
619
|
+
existingBody: '## Old draft',
|
|
620
|
+
instruction: 'Make it punchier',
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
624
|
+
const userMsg = body.messages.find((m: { role: string }) => m.role === 'user');
|
|
625
|
+
expect(userMsg.content).toContain('## Old draft');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('appends Pokelo snippets for newsletter generate', async () => {
|
|
629
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
630
|
+
ok: true,
|
|
631
|
+
json: async () => ({ choices: [{ message: { content: '<p>Hi</p>' } }] }),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const pokelo = {
|
|
635
|
+
fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\nBrand voice: warm'),
|
|
636
|
+
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
637
|
+
};
|
|
638
|
+
const svc = new AiComposeSuggestService(
|
|
639
|
+
makeSettingsService() as any,
|
|
640
|
+
mockMailThreads as any,
|
|
641
|
+
mockLeads as any,
|
|
642
|
+
pokelo as any,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
await svc.generateNewsletter({
|
|
646
|
+
contentType: 'html',
|
|
647
|
+
name: 'March',
|
|
648
|
+
subject: 'Update',
|
|
649
|
+
instruction: 'Product news',
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.stringContaining('Product news'), {
|
|
653
|
+
projectIds: ['p1'],
|
|
654
|
+
});
|
|
655
|
+
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
656
|
+
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
657
|
+
expect(systemMsg.content).toContain('Brand voice: warm');
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('parsePokeloRoute', () => {
|
|
662
|
+
it('extracts primary and followUp IDs from JSON', () => {
|
|
663
|
+
const route = parsePokeloRoute('Here you go:\n{"primary":["a","b"],"followUp":["c"]}\n', [
|
|
664
|
+
'a',
|
|
665
|
+
'b',
|
|
666
|
+
'c',
|
|
667
|
+
'd',
|
|
668
|
+
]);
|
|
669
|
+
expect(route).toEqual({ primary: ['a', 'b'], followUp: ['c'] });
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('drops unknown IDs and caps lengths', () => {
|
|
673
|
+
const route = parsePokeloRoute('{"primary":["a","b","x","y"],"followUp":["c","d"]}', [
|
|
674
|
+
'a',
|
|
675
|
+
'b',
|
|
676
|
+
'c',
|
|
677
|
+
]);
|
|
678
|
+
expect(route.primary).toEqual(['a', 'b']);
|
|
679
|
+
expect(route.followUp).toEqual(['c']);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe('stripCodeFences', () => {
|
|
684
|
+
it('unwraps fenced blocks', () => {
|
|
685
|
+
expect(stripCodeFences('```md\n# Hi\n```')).toBe('# Hi');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('leaves plain text alone', () => {
|
|
689
|
+
expect(stripCodeFences('Just text')).toBe('Just text');
|
|
690
|
+
});
|
|
691
|
+
});
|
package/src/index.ts
ADDED