mdi-llmkit 1.1.0 → 1.1.3

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.
@@ -1,434 +0,0 @@
1
- import { OpenAI } from 'openai';
2
- import { describe, expect, it } from 'vitest';
3
- import { compareItemLists, ItemComparisonResult, } from '../../src/comparison/compareLists.js';
4
- const OPENAI_API_KEY = process.env.OPENAI_API_KEY?.trim();
5
- if (!OPENAI_API_KEY) {
6
- throw new Error('OPENAI_API_KEY is required for compareItemLists live API tests. Configure your test environment to provide it.');
7
- }
8
- const createClient = () => new OpenAI({
9
- apiKey: OPENAI_API_KEY,
10
- });
11
- const collectEvents = () => {
12
- const events = [];
13
- const callback = (item, isFromBeforeList, isStarting, result, newName, error, totalProcessedSoFar, totalLeftToProcess) => {
14
- events.push({
15
- item,
16
- isFromBeforeList,
17
- isStarting,
18
- result,
19
- newName,
20
- error,
21
- totalProcessedSoFar,
22
- totalLeftToProcess,
23
- });
24
- };
25
- return { events, callback };
26
- };
27
- const assertProcessedCountersAreSequential = (events) => {
28
- const finishes = events.filter((event) => !event.isStarting);
29
- let expectedProcessed = 1;
30
- for (const event of finishes) {
31
- expect(event.totalProcessedSoFar).toBe(expectedProcessed);
32
- expectedProcessed += 1;
33
- }
34
- if (finishes.length > 0) {
35
- expect(finishes[finishes.length - 1].totalLeftToProcess).toBe(0);
36
- }
37
- };
38
- describe('compareItemLists (live API)', () => {
39
- // IMPORTANT: These tests intentionally use live OpenAI calls and DO NOT mock GptConversation.
40
- // We are validating the real prompt+schema behavior end-to-end (including model decisions),
41
- // not just local control-flow in isolation.
42
- describe('input validation', () => {
43
- it('throws for duplicate item names (case-insensitive) within a list', async () => {
44
- await expect(compareItemLists(createClient(), ['Widget', 'widget'], ['Other'])).rejects.toThrow('Duplicate item names found in before list');
45
- });
46
- });
47
- describe('string behavior', () => {
48
- it('classifies case-insensitive exact string matches as unchanged', async () => {
49
- const { events, callback } = collectEvents();
50
- const result = await compareItemLists(createClient(), ['String Item A', 'String Item B'], ['string item a', 'STRING ITEM B'], 'Case-only differences are unchanged.', callback);
51
- expect(result.removed).toEqual([]);
52
- expect(result.added).toEqual([]);
53
- expect(result.renamed).toEqual({});
54
- expect(result.unchanged).toEqual(['String Item A', 'String Item B']);
55
- // Deterministic pruning handles all items before any LLM loop.
56
- expect(events).toHaveLength(0);
57
- }, 180000);
58
- });
59
- describe('name/description behavior', () => {
60
- it('treats same names as unchanged even when descriptions differ', async () => {
61
- const result = await compareItemLists(createClient(), [
62
- {
63
- name: 'Catalog Item 100',
64
- description: 'old description content',
65
- },
66
- ], [
67
- {
68
- name: 'catalog item 100',
69
- description: 'new description content',
70
- },
71
- ], 'Identity is the item name; description differences alone do not imply rename.');
72
- expect(result.removed).toEqual([]);
73
- expect(result.added).toEqual([]);
74
- expect(result.renamed).toEqual({});
75
- expect(result.unchanged).toEqual(['Catalog Item 100']);
76
- }, 180000);
77
- it('uses description context to support a guided rename decision', async () => {
78
- const result = await compareItemLists(createClient(), [
79
- {
80
- name: 'Plan Bronze Legacy',
81
- description: 'old tier label for the bronze offering',
82
- },
83
- ], [
84
- {
85
- name: 'Plan Bronze Modern',
86
- description: 'new tier label for the same bronze offering',
87
- },
88
- ], 'Exactly one rename occurred. ' +
89
- 'Plan Bronze Legacy was renamed to Plan Bronze Modern. ' +
90
- 'Treat as rename; do not treat as remove/add.');
91
- expect(result.removed).toEqual([]);
92
- expect(result.added).toEqual([]);
93
- expect(result.unchanged).toEqual([]);
94
- expect(result.renamed['Plan Bronze Legacy']).toBe('Plan Bronze Modern');
95
- }, 180000);
96
- });
97
- describe('rename behavior', () => {
98
- it('detects a single guided rename', async () => {
99
- const result = await compareItemLists(createClient(), ['ACME Legacy Plan'], ['ACME Modern Plan'], 'There is exactly one rename in this migration. ' +
100
- 'ACME Legacy Plan was renamed to ACME Modern Plan. ' +
101
- 'Treat this as rename, not add/remove.');
102
- expect(result.removed).toEqual([]);
103
- expect(result.added).toEqual([]);
104
- expect(result.unchanged).toEqual([]);
105
- expect(result.renamed['ACME Legacy Plan']).toBe('ACME Modern Plan');
106
- }, 180000);
107
- it('supports two independent guided renames in one run', async () => {
108
- const result = await compareItemLists(createClient(), ['Legacy Product Alpha', 'Legacy Product Beta'], ['Modern Product Alpha', 'Modern Product Beta'], 'Two renames occurred with one-to-one mapping. ' +
109
- 'Legacy Product Alpha -> Modern Product Alpha. ' +
110
- 'Legacy Product Beta -> Modern Product Beta. ' +
111
- 'No deletions or net additions in this migration.');
112
- expect(Object.keys(result.renamed).sort()).toEqual([
113
- 'Legacy Product Alpha',
114
- 'Legacy Product Beta',
115
- ]);
116
- expect(result.renamed['Legacy Product Alpha']).toBe('Modern Product Alpha');
117
- expect(result.renamed['Legacy Product Beta']).toBe('Modern Product Beta');
118
- expect(result.removed).toEqual([]);
119
- expect(result.added).toEqual([]);
120
- expect(result.unchanged).toEqual([]);
121
- }, 180000);
122
- });
123
- describe('added/deleted behavior', () => {
124
- it('classifies explicit deletion', async () => {
125
- const result = await compareItemLists(createClient(), ['Delete Me Item'], [], 'Delete Me Item was intentionally removed and has no replacement.');
126
- expect(result.removed).toEqual(['Delete Me Item']);
127
- expect(result.added).toEqual([]);
128
- expect(result.renamed).toEqual({});
129
- expect(result.unchanged).toEqual([]);
130
- }, 180000);
131
- it('classifies explicit addition', async () => {
132
- const result = await compareItemLists(createClient(), [], ['Brand New Additive Item'], 'Brand New Additive Item is newly introduced and should be treated as added.');
133
- expect(result.removed).toEqual([]);
134
- expect(result.added).toEqual(['Brand New Additive Item']);
135
- expect(result.renamed).toEqual({});
136
- expect(result.unchanged).toEqual([]);
137
- }, 180000);
138
- });
139
- describe('mixed outcomes', () => {
140
- it('handles unchanged + renamed + removed + added together', async () => {
141
- const result = await compareItemLists(createClient(), ['Shared Constant Item', 'Legacy Rename Target', 'Delete Candidate'], ['shared constant item', 'Modern Rename Target', 'Add Candidate'], 'Legacy Rename Target was renamed to Modern Rename Target. ' +
142
- 'Delete Candidate was removed. ' +
143
- 'Add Candidate was newly added. ' +
144
- 'Shared Constant Item is unchanged.');
145
- expect(result.unchanged).toEqual(['Shared Constant Item']);
146
- expect(result.renamed['Legacy Rename Target']).toBe('Modern Rename Target');
147
- expect(result.removed).toEqual(['Delete Candidate']);
148
- expect(result.added).toEqual(['Add Candidate']);
149
- }, 180000);
150
- });
151
- describe('callback reporting behavior', () => {
152
- it('emits balanced start/finish events with correct source-list flags', async () => {
153
- const { events, callback } = collectEvents();
154
- await compareItemLists(createClient(), ['Before Removed A', 'Before Removed B'], ['After Added A'], 'Before Removed A and Before Removed B were removed. ' +
155
- 'After Added A was newly added. ' +
156
- 'No renames exist in this case.', callback);
157
- const starts = events.filter((event) => event.isStarting);
158
- const finishes = events.filter((event) => !event.isStarting);
159
- expect(starts.length).toBe(finishes.length);
160
- expect(starts.length).toBe(3);
161
- expect(starts.filter((event) => event.isFromBeforeList)).toHaveLength(2);
162
- expect(starts.filter((event) => !event.isFromBeforeList)).toHaveLength(1);
163
- }, 180000);
164
- it('increments processed counters sequentially and reaches zero remaining at end', async () => {
165
- const { events, callback } = collectEvents();
166
- await compareItemLists(createClient(), ['Legacy Counter Item'], ['Modern Counter Item', 'New Counter Add'], 'Legacy Counter Item was renamed to Modern Counter Item. ' +
167
- 'New Counter Add is newly added.', callback);
168
- assertProcessedCountersAreSequential(events);
169
- }, 180000);
170
- it('populates newName only for rename finish events', async () => {
171
- const { events, callback } = collectEvents();
172
- await compareItemLists(createClient(), ['Legacy Named Item'], ['Modern Named Item'], 'Legacy Named Item was renamed to Modern Named Item.', callback);
173
- const renameFinishes = events.filter((event) => !event.isStarting && event.result === ItemComparisonResult.Renamed);
174
- expect(renameFinishes.length).toBeGreaterThan(0);
175
- for (const event of renameFinishes) {
176
- expect(event.newName).toBe('Modern Named Item');
177
- }
178
- for (const event of events.filter((entry) => !(!entry.isStarting && entry.result === ItemComparisonResult.Renamed))) {
179
- expect(event.newName).toBeUndefined();
180
- }
181
- }, 180000);
182
- it('reports live API failures through callback error field (no mocks)', async () => {
183
- const { events, callback } = collectEvents();
184
- const invalidClient = new OpenAI({
185
- apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-TEST`,
186
- });
187
- const result = await compareItemLists(invalidClient, ['Live API Error Candidate'], ['After Error Path Item'], 'If API fails, fallback should still complete with warning messages in callback.', callback);
188
- // Fallback behavior on failed before-item processing is to mark as unchanged.
189
- expect(result.unchanged).toContain('Live API Error Candidate');
190
- const finishEventsWithErrors = events.filter((event) => !event.isStarting && typeof event.error === 'string');
191
- expect(finishEventsWithErrors.length).toBeGreaterThan(0);
192
- expect(finishEventsWithErrors.some((event) => (event.error || '').includes('LLM processing failed'))).toBe(true);
193
- }, 180000);
194
- });
195
- describe('bulk list scenarios', () => {
196
- it('handles a larger mixed migration with multiple renames/additions/deletions', async () => {
197
- const beforeItems = [
198
- 'Shared Stable A',
199
- 'Shared Stable B',
200
- 'Legacy Rename One',
201
- 'Legacy Rename Two',
202
- 'Removed Batch One',
203
- 'Removed Batch Two',
204
- 'Shared Stable C',
205
- ];
206
- const afterItems = [
207
- 'shared stable a',
208
- 'SHARED STABLE B',
209
- 'Modern Rename One',
210
- 'Modern Rename Two',
211
- 'Added Batch One',
212
- 'Added Batch Two',
213
- 'shared stable c',
214
- ];
215
- const result = await compareItemLists(createClient(), beforeItems, afterItems, 'Migration map: Legacy Rename One -> Modern Rename One. ' +
216
- 'Legacy Rename Two -> Modern Rename Two. ' +
217
- 'Removed Batch One and Removed Batch Two were removed. ' +
218
- 'Added Batch One and Added Batch Two were newly added. ' +
219
- 'Shared Stable A/B/C are unchanged.');
220
- expect(result.unchanged).toEqual([
221
- 'Shared Stable A',
222
- 'Shared Stable B',
223
- 'Shared Stable C',
224
- ]);
225
- expect(result.renamed).toEqual({
226
- 'Legacy Rename One': 'Modern Rename One',
227
- 'Legacy Rename Two': 'Modern Rename Two',
228
- });
229
- expect(result.removed).toEqual([
230
- 'Removed Batch One',
231
- 'Removed Batch Two',
232
- ]);
233
- expect(result.added).toEqual(['Added Batch One', 'Added Batch Two']);
234
- }, 240000);
235
- it('maintains coherent callback counters on larger ambiguous sets', async () => {
236
- const { events, callback } = collectEvents();
237
- const result = await compareItemLists(createClient(), [
238
- 'Bulk Legacy 1',
239
- 'Bulk Legacy 2',
240
- 'Bulk Removed 1',
241
- 'Bulk Removed 2',
242
- 'Bulk Shared 1',
243
- 'Bulk Shared 2',
244
- ], [
245
- 'Bulk Modern 1',
246
- 'Bulk Modern 2',
247
- 'Bulk Added 1',
248
- 'Bulk Added 2',
249
- 'bulk shared 1',
250
- 'BULK SHARED 2',
251
- ], 'Bulk Legacy 1 -> Bulk Modern 1. ' +
252
- 'Bulk Legacy 2 -> Bulk Modern 2. ' +
253
- 'Bulk Removed 1 and Bulk Removed 2 were removed. ' +
254
- 'Bulk Added 1 and Bulk Added 2 were newly added. ' +
255
- 'Bulk Shared 1 and Bulk Shared 2 are unchanged.', callback);
256
- expect(result.renamed).toEqual({
257
- 'Bulk Legacy 1': 'Bulk Modern 1',
258
- 'Bulk Legacy 2': 'Bulk Modern 2',
259
- });
260
- expect(result.removed).toEqual(['Bulk Removed 1', 'Bulk Removed 2']);
261
- expect(result.added).toEqual(['Bulk Added 1', 'Bulk Added 2']);
262
- expect(result.unchanged).toEqual(['Bulk Shared 1', 'Bulk Shared 2']);
263
- // There are 4 ambiguous "before" items and, after rename removals, 2 remaining
264
- // "after" items for add-classification, so callback lifecycle should cover 6 items.
265
- const starts = events.filter((event) => event.isStarting);
266
- const finishes = events.filter((event) => !event.isStarting);
267
- expect(starts.length).toBe(6);
268
- expect(finishes.length).toBe(6);
269
- assertProcessedCountersAreSequential(events);
270
- }, 240000);
271
- });
272
- describe('inference without explicit mapping instructions', () => {
273
- it('infers removed string items when after list omits them', async () => {
274
- const result = await compareItemLists(createClient(), [
275
- 'Invoice Number',
276
- 'Purchase Date',
277
- 'Supplier Name',
278
- 'Legacy Tax Code',
279
- 'Deprecated Internal Note',
280
- 'Total Amount',
281
- ], ['Invoice Number', 'Purchase Date', 'Supplier Name', 'Total Amount']);
282
- expect(result.removed).toEqual([
283
- 'Deprecated Internal Note',
284
- 'Legacy Tax Code',
285
- ]);
286
- expect(result.added).toEqual([]);
287
- expect(result.renamed).toEqual({});
288
- expect(result.unchanged).toEqual([
289
- 'Invoice Number',
290
- 'Purchase Date',
291
- 'Supplier Name',
292
- 'Total Amount',
293
- ]);
294
- }, 180000);
295
- it('infers added string items when after list introduces them', async () => {
296
- const result = await compareItemLists(createClient(), ['Order ID', 'Customer Name', 'Subtotal', 'Order Date'], [
297
- 'Order ID',
298
- 'Customer Name',
299
- 'Subtotal',
300
- 'Order Date',
301
- 'Shipping Method',
302
- 'Delivery Address',
303
- ]);
304
- expect(result.removed).toEqual([]);
305
- expect(result.added?.sort()).toEqual(['Delivery Address', 'Shipping Method']);
306
- expect(result.renamed).toEqual({});
307
- expect(result.unchanged).toEqual([
308
- 'Customer Name',
309
- 'Order Date',
310
- 'Order ID',
311
- 'Subtotal',
312
- ]);
313
- }, 180000);
314
- it('infers removed name/description items without explicit guidance', async () => {
315
- const result = await compareItemLists(createClient(), [
316
- { name: 'acct_id', description: 'Unique account identifier' },
317
- { name: 'acct_name', description: 'Human-readable account name' },
318
- { name: 'acct_region', description: 'Assigned sales region' },
319
- {
320
- name: 'legacy_segment_code',
321
- description: 'Old segmentation code from prior CRM',
322
- },
323
- {
324
- name: 'legacy_priority_bucket',
325
- description: 'Obsolete account prioritization bucket',
326
- },
327
- ], [
328
- { name: 'acct_id', description: 'Unique account identifier' },
329
- { name: 'acct_name', description: 'Human-readable account name' },
330
- { name: 'acct_region', description: 'Assigned sales region' },
331
- ]);
332
- expect(result.removed).toEqual([
333
- 'legacy_priority_bucket',
334
- 'legacy_segment_code',
335
- ]);
336
- expect(result.added).toEqual([]);
337
- expect(result.renamed).toEqual({});
338
- expect(result.unchanged).toEqual(['acct_id', 'acct_name', 'acct_region']);
339
- }, 180000);
340
- it('infers added name/description items without explicit guidance', async () => {
341
- const result = await compareItemLists(createClient(), [
342
- { name: 'sku', description: 'Stock keeping unit identifier' },
343
- { name: 'title', description: 'Product display title' },
344
- { name: 'price', description: 'Current listed price' },
345
- ], [
346
- { name: 'sku', description: 'Stock keeping unit identifier' },
347
- { name: 'title', description: 'Product display title' },
348
- { name: 'price', description: 'Current listed price' },
349
- {
350
- name: 'inventory_count',
351
- description: 'Current on-hand inventory quantity',
352
- },
353
- {
354
- name: 'warehouse_location',
355
- description: 'Primary warehouse storage location code',
356
- },
357
- ]);
358
- expect(result.removed).toEqual([]);
359
- expect(result.added).toEqual(['inventory_count', 'warehouse_location']);
360
- expect(result.renamed).toEqual({});
361
- expect(result.unchanged).toEqual(['price', 'sku', 'title']);
362
- }, 180000);
363
- it('infers rename from semantic name similarity plus identical description', async () => {
364
- const result = await compareItemLists(createClient(), [
365
- {
366
- name: 'billing_address_line_1',
367
- description: 'Primary street line for billing address',
368
- },
369
- {
370
- name: 'billing_city',
371
- description: 'City associated with the billing address',
372
- },
373
- {
374
- name: 'billing_zip_code',
375
- description: 'The five-digit postal code associated with the billing address',
376
- },
377
- {
378
- name: 'billing_country_code',
379
- description: 'ISO country code for the billing address',
380
- },
381
- ], [
382
- {
383
- name: 'billing_address_line_1',
384
- description: 'Primary street line for billing address',
385
- },
386
- {
387
- name: 'billing_city',
388
- description: 'City associated with the billing address',
389
- },
390
- {
391
- name: 'billing_postal_code',
392
- description: 'The five-digit postal code associated with the billing address',
393
- },
394
- {
395
- name: 'billing_country_code',
396
- description: 'ISO country code for the billing address',
397
- },
398
- ]);
399
- expect(result.removed).toEqual([]);
400
- expect(result.added).toEqual([]);
401
- expect(result.unchanged).toEqual([
402
- 'billing_address_line_1',
403
- 'billing_city',
404
- 'billing_country_code',
405
- ]);
406
- expect(result.renamed).toEqual({
407
- billing_zip_code: 'billing_postal_code',
408
- });
409
- }, 180000);
410
- it('infers rename from semantic similarity for plain string items', async () => {
411
- const result = await compareItemLists(createClient(), [
412
- 'billing_address_line_1',
413
- 'billing_city',
414
- 'billing_zip_code',
415
- 'billing_country_code',
416
- ], [
417
- 'billing_address_line_1',
418
- 'billing_city',
419
- 'billing_postal_code',
420
- 'billing_country_code',
421
- ]);
422
- expect(result.removed).toEqual([]);
423
- expect(result.added).toEqual([]);
424
- expect(result.unchanged).toEqual([
425
- 'billing_address_line_1',
426
- 'billing_city',
427
- 'billing_country_code',
428
- ]);
429
- expect(result.renamed).toEqual({
430
- billing_zip_code: 'billing_postal_code',
431
- });
432
- }, 180000);
433
- });
434
- });