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.
@@ -0,0 +1,177 @@
1
+ import { OpenAI } from 'openai';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compareItemLists, ItemComparisonClassification, } from '../../src/semanticMatch/compareLists.js';
4
+ import { getItemName } from '../../src/semanticMatch/semanticItem.js';
5
+ const OPENAI_API_KEY = process.env.OPENAI_API_KEY?.trim();
6
+ if (!OPENAI_API_KEY) {
7
+ throw new Error('OPENAI_API_KEY is required for compareItemLists live API tests. Configure your test environment to provide it.');
8
+ }
9
+ const createClient = () => new OpenAI({
10
+ apiKey: OPENAI_API_KEY,
11
+ });
12
+ const getByClassification = (results, classification) => results.filter((entry) => entry.classification === classification);
13
+ const getNamesByClassification = (results, classification) => getByClassification(results, classification).map((entry) => getItemName(entry.item));
14
+ const getRenamedMap = (results) => {
15
+ const renamed = {};
16
+ for (const entry of getByClassification(results, ItemComparisonClassification.Renamed)) {
17
+ renamed[getItemName(entry.item)] = entry.newName || '';
18
+ }
19
+ return renamed;
20
+ };
21
+ describe('compareItemLists (live API)', () => {
22
+ // IMPORTANT: These tests intentionally use live OpenAI calls and DO NOT mock findSemanticMatch.
23
+ // We are validating end-to-end behavior for the current record-based result contract.
24
+ describe('current return contract', () => {
25
+ it('returns per-item records with classification and optional newName', async () => {
26
+ const results = await compareItemLists(createClient(), ['Legacy Plan Alpha'], ['Modern Plan Alpha'], 'Legacy Plan Alpha was renamed to Modern Plan Alpha.');
27
+ expect(Array.isArray(results)).toBe(true);
28
+ expect(results.length).toBe(1);
29
+ expect(results[0].item).toBe('Legacy Plan Alpha');
30
+ expect(results[0].classification).toBe(ItemComparisonClassification.Renamed);
31
+ expect(results[0].newName).toBe('Modern Plan Alpha');
32
+ }, 180000);
33
+ });
34
+ describe('deterministic exact-match behavior', () => {
35
+ it('classifies exact string matches as unchanged and does not mutate inputs', async () => {
36
+ const before = ['String Item A', 'String Item B'];
37
+ const after = ['string item a', 'STRING ITEM B'];
38
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
39
+ const afterSnapshot = JSON.parse(JSON.stringify(after));
40
+ const results = await compareItemLists(createClient(), before, after);
41
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([
42
+ 'String Item A',
43
+ 'String Item B',
44
+ ]);
45
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([]);
46
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([]);
47
+ expect(getRenamedMap(results)).toEqual({});
48
+ expect(before).toEqual(beforeSnapshot);
49
+ expect(after).toEqual(afterSnapshot);
50
+ }, 180000);
51
+ it('classifies object-vs-string same-name pair as unchanged', async () => {
52
+ const results = await compareItemLists(createClient(), [
53
+ {
54
+ name: 'Georgia',
55
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
56
+ },
57
+ ], ['georgia']);
58
+ expect(results).toHaveLength(1);
59
+ expect(results[0].classification).toBe(ItemComparisonClassification.Unchanged);
60
+ expect(results[0].newName).toBeUndefined();
61
+ }, 180000);
62
+ });
63
+ describe('added and removed behavior', () => {
64
+ it('classifies before-only item as removed', async () => {
65
+ const results = await compareItemLists(createClient(), ['Delete Me Item'], [], 'Delete Me Item was intentionally removed and has no replacement.');
66
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([
67
+ 'Delete Me Item',
68
+ ]);
69
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([]);
70
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([]);
71
+ expect(getRenamedMap(results)).toEqual({});
72
+ }, 180000);
73
+ it('classifies after-only items as added', async () => {
74
+ const results = await compareItemLists(createClient(), [], ['Brand New Additive Item', 'Second New Item']);
75
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([
76
+ 'Brand New Additive Item',
77
+ 'Second New Item',
78
+ ]);
79
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([]);
80
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([]);
81
+ expect(getRenamedMap(results)).toEqual({});
82
+ }, 180000);
83
+ });
84
+ describe('rename behavior', () => {
85
+ it('detects a single guided rename', async () => {
86
+ const results = await compareItemLists(createClient(), ['ACME Legacy Plan'], ['ACME Modern Plan'], 'There is exactly one rename in this migration. ' +
87
+ 'ACME Legacy Plan was renamed to ACME Modern Plan. ' +
88
+ 'Treat this as rename, not add/remove.');
89
+ expect(getRenamedMap(results)).toEqual({
90
+ 'ACME Legacy Plan': 'ACME Modern Plan',
91
+ });
92
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([]);
93
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([]);
94
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([]);
95
+ }, 180000);
96
+ it('handles two guided renames in one run', async () => {
97
+ const results = await compareItemLists(createClient(), ['Legacy Product Alpha', 'Legacy Product Beta'], ['Modern Product Alpha', 'Modern Product Beta'], 'Two renames occurred with one-to-one mapping. ' +
98
+ 'Legacy Product Alpha -> Modern Product Alpha. ' +
99
+ 'Legacy Product Beta -> Modern Product Beta. ' +
100
+ 'No deletions or net additions in this migration.');
101
+ expect(getRenamedMap(results)).toEqual({
102
+ 'Legacy Product Alpha': 'Modern Product Alpha',
103
+ 'Legacy Product Beta': 'Modern Product Beta',
104
+ });
105
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([]);
106
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([]);
107
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([]);
108
+ }, 180000);
109
+ });
110
+ describe('mixed outcomes', () => {
111
+ it('returns records for unchanged + renamed + removed + added in one run', async () => {
112
+ const results = 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. ' +
113
+ 'Delete Candidate was removed. ' +
114
+ 'Add Candidate was newly added. ' +
115
+ 'Shared Constant Item is unchanged.');
116
+ expect(getNamesByClassification(results, ItemComparisonClassification.Unchanged)).toEqual([
117
+ 'Shared Constant Item',
118
+ ]);
119
+ expect(getRenamedMap(results)).toEqual({
120
+ 'Legacy Rename Target': 'Modern Rename Target',
121
+ });
122
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([
123
+ 'Delete Candidate',
124
+ ]);
125
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([
126
+ 'Add Candidate',
127
+ ]);
128
+ }, 180000);
129
+ it('returns one result record per before-item plus unmatched after-items', async () => {
130
+ const before = ['A', 'B', 'C'];
131
+ const after = ['a', 'B-NEW', 'D'];
132
+ const results = await compareItemLists(createClient(), before, after, 'A is unchanged (case only). B was renamed to B-NEW. C was removed. D was added.');
133
+ expect(results.length).toBe(4);
134
+ }, 180000);
135
+ });
136
+ describe('additional behavior coverage', () => {
137
+ it('consumes a matched after-item once, leaving subsequent similar item as removed', async () => {
138
+ const results = await compareItemLists(createClient(), ['Legacy Item Alpha', 'Legacy Item Alpha Copy'], ['Modern Item Alpha'], 'Only Legacy Item Alpha was renamed to Modern Item Alpha. Legacy Item Alpha Copy was removed.');
139
+ expect(getRenamedMap(results)).toEqual({
140
+ 'Legacy Item Alpha': 'Modern Item Alpha',
141
+ });
142
+ expect(getNamesByClassification(results, ItemComparisonClassification.Removed)).toEqual([
143
+ 'Legacy Item Alpha Copy',
144
+ ]);
145
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([]);
146
+ }, 180000);
147
+ it('preserves order of added records from remaining after-list items', async () => {
148
+ const results = await compareItemLists(createClient(), [], [
149
+ { name: 'Added First', description: 'first' },
150
+ { name: 'Added Second', description: 'second' },
151
+ 'Added Third',
152
+ ]);
153
+ expect(getNamesByClassification(results, ItemComparisonClassification.Added)).toEqual([
154
+ 'Added First',
155
+ 'Added Second',
156
+ 'Added Third',
157
+ ]);
158
+ }, 180000);
159
+ it('does not mutate before or after lists in mixed classification scenarios', async () => {
160
+ const before = [
161
+ 'Stable Name',
162
+ 'Legacy Rename Candidate',
163
+ 'Legacy Removed Candidate',
164
+ ];
165
+ const after = [
166
+ 'stable name',
167
+ 'Modern Rename Candidate',
168
+ 'Newly Added Candidate',
169
+ ];
170
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
171
+ const afterSnapshot = JSON.parse(JSON.stringify(after));
172
+ await compareItemLists(createClient(), before, after, 'Legacy Rename Candidate was renamed to Modern Rename Candidate. Legacy Removed Candidate was removed. Newly Added Candidate was added.');
173
+ expect(before).toEqual(beforeSnapshot);
174
+ expect(after).toEqual(afterSnapshot);
175
+ }, 180000);
176
+ });
177
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,234 @@
1
+ import { OpenAI } from 'openai';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { findSemanticMatch } from '../../src/semanticMatch/find.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 findSemanticMatch live API tests. Configure your test environment to provide it.');
7
+ }
8
+ const createClient = () => new OpenAI({
9
+ apiKey: OPENAI_API_KEY,
10
+ });
11
+ const expectMatch = async (list, testItem, expectedIndex, explanation) => {
12
+ const result = await findSemanticMatch(createClient(), list, testItem, explanation);
13
+ expect(result).toBe(expectedIndex);
14
+ };
15
+ const expectNoMatch = async (list, testItem, explanation) => {
16
+ const result = await findSemanticMatch(createClient(), list, testItem, explanation);
17
+ expect(result).toBe(-1);
18
+ };
19
+ const expectOneOfMatches = async (list, testItem, expectedIndexes, explanation) => {
20
+ const result = await findSemanticMatch(createClient(), list, testItem, explanation);
21
+ expect(expectedIndexes).toContain(result);
22
+ };
23
+ describe('findSemanticMatch (live API)', () => {
24
+ // IMPORTANT: These tests intentionally use live OpenAI calls and DO NOT mock GptConversation.
25
+ // We are validating real prompt+schema behavior end-to-end.
26
+ describe('exact-match short-circuit behavior', () => {
27
+ it('returns case-insensitive exact match without needing LLM resolution', async () => {
28
+ const invalidClient = new OpenAI({
29
+ apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-EXACT-MATCH-TEST`,
30
+ });
31
+ const result = await findSemanticMatch(invalidClient, ['Chickenpox', 'Measles', 'Cold sore'], 'measles');
32
+ expect(result).toBe(1);
33
+ }, 180000);
34
+ it('returns the first index when multiple string items match case-insensitively', async () => {
35
+ const invalidClient = new OpenAI({
36
+ apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-DUPLICATE-INDEX-TEST`,
37
+ });
38
+ const result = await findSemanticMatch(invalidClient, ['Georgia', 'France', 'GEORGIA'], 'georgia');
39
+ expect(result).toBe(0);
40
+ }, 180000);
41
+ it('short-circuits when list item has description but test item is string', async () => {
42
+ const invalidClient = new OpenAI({
43
+ apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-STRING-OBJECT-SHORTCUT-TEST`,
44
+ });
45
+ const result = await findSemanticMatch(invalidClient, [
46
+ {
47
+ name: 'Georgia',
48
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
49
+ },
50
+ {
51
+ name: 'France',
52
+ description: 'A country in Western Europe. Capital: Paris.',
53
+ },
54
+ ], 'Georgia');
55
+ expect(result).toBe(0);
56
+ }, 180000);
57
+ it('short-circuits when both name/desc items have equal descriptions after trimming', async () => {
58
+ const invalidClient = new OpenAI({
59
+ apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-EQUAL-DESCRIPTION-SHORTCUT-TEST`,
60
+ });
61
+ const result = await findSemanticMatch(invalidClient, [
62
+ {
63
+ name: 'Georgia',
64
+ description: 'A U.S. state in the southeastern U.S. Capital: Atlanta.',
65
+ },
66
+ {
67
+ name: 'France',
68
+ description: 'A country in Western Europe. Capital: Paris.',
69
+ },
70
+ ], {
71
+ name: 'Georgia',
72
+ description: ' A U.S. state in the southeastern U.S. Capital: Atlanta. ',
73
+ });
74
+ expect(result).toBe(0);
75
+ }, 180000);
76
+ it('does not short-circuit when names match but descriptions conflict', async () => {
77
+ const invalidClient = new OpenAI({
78
+ apiKey: `${OPENAI_API_KEY}-INTENTIONALLY-INVALID-FOR-CONFLICTING-DESCRIPTION-TEST`,
79
+ });
80
+ await expect(findSemanticMatch(invalidClient, [
81
+ {
82
+ name: 'Georgia',
83
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
84
+ },
85
+ {
86
+ name: 'France',
87
+ description: 'A country in Western Europe. Capital: Paris.',
88
+ },
89
+ ], {
90
+ name: 'Georgia',
91
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
92
+ })).rejects.toThrow();
93
+ }, 180000);
94
+ });
95
+ describe('medicine colloquial vs clinical names', () => {
96
+ it('maps Varicella to Chickenpox', async () => {
97
+ await expectMatch(['Chickenpox', 'Measles', 'Cold sore'], 'Varicella', 0);
98
+ }, 180000);
99
+ it('maps Pertussis to Whooping cough', async () => {
100
+ await expectMatch(['Whooping cough', 'Mumps', 'Tetanus'], 'Pertussis', 0);
101
+ }, 180000);
102
+ it('maps Rubella to German measles', async () => {
103
+ await expectMatch(['German measles', 'Scarlet fever', 'Shingles'], 'Rubella', 0);
104
+ }, 180000);
105
+ it('maps Conjunctivitis to Pink eye', async () => {
106
+ await expectMatch(['Pink eye', 'Flu', 'Strep throat'], 'Conjunctivitis', 0);
107
+ }, 180000);
108
+ it('maps Infectious mononucleosis to Mono', async () => {
109
+ await expectMatch(['Mono', 'Chickenpox', 'Bronchitis'], 'Infectious mononucleosis', 0);
110
+ }, 180000);
111
+ it('returns -1 for unrelated clinical condition', async () => {
112
+ await expectNoMatch(['Migraine', 'Asthma', 'Eczema'], 'Appendicitis');
113
+ }, 180000);
114
+ });
115
+ describe('geography modern vs historical names', () => {
116
+ it('maps Nippon to Japan', async () => {
117
+ await expectMatch(['China', 'Japan', 'Singapore'], 'Nippon', 1);
118
+ }, 180000);
119
+ it('maps Persia to Iran', async () => {
120
+ await expectMatch(['Iran', 'Iraq', 'Turkey'], 'Persia', 0);
121
+ }, 180000);
122
+ it('maps Siam to Thailand', async () => {
123
+ await expectMatch(['Thailand', 'Vietnam', 'Laos'], 'Siam', 0);
124
+ }, 180000);
125
+ it('maps Ceylon to Sri Lanka', async () => {
126
+ await expectMatch(['Sri Lanka', 'India', 'Nepal'], 'Ceylon', 0);
127
+ }, 180000);
128
+ it('maps Burma to Myanmar', async () => {
129
+ await expectMatch(['Myanmar', 'Bangladesh', 'Bhutan'], 'Burma', 0);
130
+ }, 180000);
131
+ it('returns -1 when no country in list is semantically related', async () => {
132
+ await expectNoMatch(['Canada', 'Mexico', 'Brazil'], 'Prussia');
133
+ }, 180000);
134
+ });
135
+ describe('geography same-name disambiguation (Georgia)', () => {
136
+ it('chooses Georgia the country when both state and country are present as name/desc items', async () => {
137
+ await expectMatch([
138
+ {
139
+ name: 'Georgia',
140
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
141
+ },
142
+ {
143
+ name: 'Georgia',
144
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
145
+ },
146
+ {
147
+ name: 'France',
148
+ description: 'A country in Western Europe. Capital: Paris.',
149
+ },
150
+ ], {
151
+ name: 'Georgia',
152
+ description: 'A country in the South Caucasus bordered by Turkey, Armenia, and Azerbaijan. Capital: Tbilisi.',
153
+ }, 1);
154
+ }, 180000);
155
+ it('chooses Georgia the U.S. state when both state and country are present as name/desc items', async () => {
156
+ await expectMatch([
157
+ {
158
+ name: 'Georgia',
159
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
160
+ },
161
+ {
162
+ name: 'Georgia',
163
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
164
+ },
165
+ {
166
+ name: 'France',
167
+ description: 'A country in Western Europe. Capital: Paris.',
168
+ },
169
+ ], {
170
+ name: 'Georgia',
171
+ description: 'A U.S. state in the southeastern U.S. with Atlanta as its capital.',
172
+ }, 0);
173
+ }, 180000);
174
+ it('accepts either Georgia index for string-only test item with several red herrings', async () => {
175
+ await expectOneOfMatches([
176
+ {
177
+ name: 'Georgia',
178
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
179
+ },
180
+ {
181
+ name: 'Georgia',
182
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
183
+ },
184
+ {
185
+ name: 'France',
186
+ description: 'A country in Western Europe. Capital: Paris.',
187
+ },
188
+ {
189
+ name: 'Florida',
190
+ description: 'A U.S. state in the southeastern U.S. Capital: Tallahassee.',
191
+ },
192
+ {
193
+ name: 'Armenia',
194
+ description: 'A country in the South Caucasus. Capital: Yerevan.',
195
+ },
196
+ ], 'Georgia', [0, 1]);
197
+ }, 180000);
198
+ it('matches the single Georgia string item when test item provides state description', async () => {
199
+ await expectMatch(['Georgia', 'France', 'Japan'], {
200
+ name: 'Georgia',
201
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
202
+ }, 0);
203
+ }, 180000);
204
+ it('returns -1 when list contains Georgia country but test item describes Georgia state', async () => {
205
+ await expectNoMatch([
206
+ {
207
+ name: 'Georgia',
208
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
209
+ },
210
+ {
211
+ name: 'France',
212
+ description: 'A country in Western Europe. Capital: Paris.',
213
+ },
214
+ {
215
+ name: 'Alabama',
216
+ description: 'A U.S. state in the southeastern United States. Capital: Montgomery.',
217
+ },
218
+ ], {
219
+ name: 'Georgia',
220
+ description: 'A U.S. state in the southeastern United States. Capital: Atlanta.',
221
+ });
222
+ }, 180000);
223
+ });
224
+ describe('context-guided disambiguation', () => {
225
+ it('uses explanation to choose the correct Congo variant', async () => {
226
+ await expectMatch([
227
+ 'Republic of the Congo',
228
+ 'Democratic Republic of the Congo',
229
+ 'Gabon',
230
+ ], 'Congo-Brazzaville', 0, 'Interpret Congo-Brazzaville as the country whose capital is Brazzaville. ' +
231
+ 'Do not map it to the Democratic Republic of the Congo.');
232
+ }, 180000);
233
+ });
234
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { areItemsEqual, compareItems, getItemDescription, getItemName, itemToPromptString, removeItemFromList, } from '../../src/semanticMatch/semanticItem.js';
3
+ describe('semanticItem helpers', () => {
4
+ describe('getItemName', () => {
5
+ it('returns the raw value for string items', () => {
6
+ expect(getItemName('Widget')).toBe('Widget');
7
+ });
8
+ it('returns the name field for object items', () => {
9
+ expect(getItemName({ name: 'Widget', description: 'A part' })).toBe('Widget');
10
+ });
11
+ });
12
+ describe('getItemDescription', () => {
13
+ it('returns undefined for string items', () => {
14
+ expect(getItemDescription('Widget')).toBeUndefined();
15
+ });
16
+ it('returns undefined when object description is missing', () => {
17
+ expect(getItemDescription({ name: 'Widget' })).toBeUndefined();
18
+ });
19
+ it('returns undefined when description equals name ignoring case and whitespace', () => {
20
+ expect(getItemDescription({
21
+ name: 'Product Alpha',
22
+ description: ' product alpha ',
23
+ })).toBeUndefined();
24
+ });
25
+ it('returns description when it provides additional context', () => {
26
+ expect(getItemDescription({
27
+ name: 'Product Alpha',
28
+ description: 'Legacy tier retained for existing contracts',
29
+ })).toBe('Legacy tier retained for existing contracts');
30
+ });
31
+ it('preserves original description text when returning value', () => {
32
+ expect(getItemDescription({
33
+ name: 'Product Alpha',
34
+ description: ' Legacy tier retained for existing contracts ',
35
+ })).toBe(' Legacy tier retained for existing contracts ');
36
+ });
37
+ });
38
+ describe('itemToPromptString', () => {
39
+ it('formats string items as a bullet with JSON-escaped content', () => {
40
+ expect(itemToPromptString('Line "A"\nLine B')).toBe('- "Line \\"A\\"\\nLine B"');
41
+ });
42
+ it('formats object items with only the name when description is absent', () => {
43
+ expect(itemToPromptString({ name: 'Product Alpha' })).toBe('- "Product Alpha"');
44
+ });
45
+ it('omits details when description equals name ignoring case and whitespace', () => {
46
+ expect(itemToPromptString({
47
+ name: 'Product Alpha',
48
+ description: ' product alpha ',
49
+ })).toBe('- "Product Alpha"');
50
+ });
51
+ it('includes details when description is meaningfully different', () => {
52
+ expect(itemToPromptString({
53
+ name: 'Product Alpha',
54
+ description: 'Replaces legacy alpha tier',
55
+ })).toBe('- "Product Alpha" (details: "Replaces legacy alpha tier")');
56
+ });
57
+ });
58
+ describe('compareItems', () => {
59
+ it('returns 0 for names equal ignoring case', () => {
60
+ expect(compareItems('Widget', 'widget')).toBe(0);
61
+ });
62
+ it('sorts by case-insensitive names', () => {
63
+ const items = [
64
+ 'zeta',
65
+ { name: 'Bravo' },
66
+ 'alpha',
67
+ { name: 'charlie' },
68
+ ];
69
+ const sortedNames = [...items].sort(compareItems).map(getItemName);
70
+ expect(sortedNames).toEqual(['alpha', 'Bravo', 'charlie', 'zeta']);
71
+ });
72
+ it('trims names before comparing', () => {
73
+ expect(compareItems('name', ' name')).toBe(0);
74
+ });
75
+ it('uses descriptions as case-insensitive tie-breaker when names are equal', () => {
76
+ expect(compareItems({ name: 'Georgia', description: 'zebra context' }, { name: 'Georgia', description: 'alpha context' })).toBeGreaterThan(0);
77
+ });
78
+ it('treats description case differences as equal in tie-break comparison', () => {
79
+ expect(compareItems({ name: 'Georgia', description: 'Country in caucasus' }, { name: 'Georgia', description: 'country in caucasus' })).toBe(0);
80
+ });
81
+ });
82
+ describe('areItemsEqual', () => {
83
+ it('is true for items with equal names ignoring case and whitespace', () => {
84
+ expect(areItemsEqual(' Catalog Item ', { name: 'catalog item' })).toBe(true);
85
+ });
86
+ it('is false for different names', () => {
87
+ expect(areItemsEqual('Catalog Item A', { name: 'Catalog Item B' })).toBe(false);
88
+ });
89
+ it('is false when names match but meaningful descriptions differ', () => {
90
+ expect(areItemsEqual({ name: 'Catalog Item', description: 'old' }, { name: 'catalog item', description: 'new' })).toBe(false);
91
+ });
92
+ it('treats name+description Georgia as equal to string Georgia', () => {
93
+ expect(areItemsEqual({
94
+ name: 'Georgia',
95
+ description: 'A sovereign country in the South Caucasus. Capital: Tbilisi.',
96
+ }, 'georgia')).toBe(true);
97
+ });
98
+ });
99
+ describe('removeItemFromList', () => {
100
+ it('removes matching string items case-insensitively', () => {
101
+ const original = ['Alpha', 'Bravo', 'alpha'];
102
+ const result = removeItemFromList(original, 'ALPHA');
103
+ expect(result).toEqual(['Bravo']);
104
+ });
105
+ it('removes object items when both name and description are equivalent', () => {
106
+ const original = [
107
+ { name: 'Catalog Item', description: 'legacy details' },
108
+ { name: 'Catalog Item', description: 'LEGACY DETAILS' },
109
+ { name: 'Other Item' },
110
+ ];
111
+ const result = removeItemFromList(original, {
112
+ name: 'catalog item',
113
+ description: ' legacy details ',
114
+ });
115
+ expect(result).toEqual([{ name: 'Other Item' }]);
116
+ });
117
+ it('removes the item that does not have a description when name is ambiguous', () => {
118
+ const original = [
119
+ { name: 'Catalog Item', description: 'first copy' },
120
+ 'catalog item',
121
+ { name: 'Other Item' },
122
+ ];
123
+ const result = removeItemFromList(original, {
124
+ name: 'CATALOG ITEM',
125
+ description: 'query description does not matter',
126
+ });
127
+ expect(result).toEqual([
128
+ { name: 'Catalog Item', description: 'first copy' },
129
+ { name: 'Other Item' },
130
+ ]);
131
+ expect(result).not.toBe(original);
132
+ });
133
+ it('does not remove items that only match by name when descriptions differ', () => {
134
+ const original = [
135
+ { name: 'Catalog Item', description: 'first copy' },
136
+ { name: 'catalog item', description: 'second copy' },
137
+ { name: 'Other Item' },
138
+ ];
139
+ const result = removeItemFromList(original, {
140
+ name: 'CATALOG ITEM',
141
+ description: 'query description does not matter',
142
+ });
143
+ expect(result).toEqual(original);
144
+ expect(result).not.toBe(original);
145
+ });
146
+ it('returns a new list and does not mutate the input array', () => {
147
+ const original = ['Alpha', 'Bravo'];
148
+ const result = removeItemFromList(original, 'alpha');
149
+ expect(result).toEqual(['Bravo']);
150
+ expect(original).toEqual(['Alpha', 'Bravo']);
151
+ expect(result).not.toBe(original);
152
+ });
153
+ it('returns unchanged items when there is no equivalent item', () => {
154
+ const original = ['Alpha', { name: 'Bravo' }];
155
+ const result = removeItemFromList(original, 'Charlie');
156
+ expect(result).toEqual(['Alpha', { name: 'Bravo' }]);
157
+ });
158
+ });
159
+ });
@@ -5,16 +5,16 @@ import path from 'node:path';
5
5
  import { beforeAll, describe, expect, it } from 'vitest';
6
6
  const DIST_GPTAPI_INDEX = path.resolve('dist/src/gptApi/index.js');
7
7
  const DIST_JSON_SURGERY = path.resolve('dist/src/jsonSurgery/index.js');
8
- const DIST_COMPARISON_INDEX = path.resolve('dist/src/comparison/index.js');
8
+ const DIST_SEMANTIC_MATCH_INDEX = path.resolve('dist/src/semanticMatch/index.js');
9
9
  beforeAll(() => {
10
10
  if (!existsSync(DIST_GPTAPI_INDEX) ||
11
11
  !existsSync(DIST_JSON_SURGERY) ||
12
- !existsSync(DIST_COMPARISON_INDEX)) {
12
+ !existsSync(DIST_SEMANTIC_MATCH_INDEX)) {
13
13
  execSync('npm run build', { stdio: 'inherit' });
14
14
  }
15
15
  });
16
16
  describe('package subpath exports', () => {
17
- it('declares gptApi, jsonSurgery, and comparison in package exports', async () => {
17
+ it('declares gptApi, jsonSurgery, and semanticMatch in package exports', async () => {
18
18
  const packageJsonPath = path.resolve('package.json');
19
19
  const packageJsonRaw = await readFile(packageJsonPath, 'utf8');
20
20
  const packageJson = JSON.parse(packageJsonRaw);
@@ -24,9 +24,9 @@ describe('package subpath exports', () => {
24
24
  expect(packageJson.exports?.['./jsonSurgery']).toBeDefined();
25
25
  expect(packageJson.exports?.['./jsonSurgery']?.types).toBe('./dist/src/jsonSurgery/index.d.ts');
26
26
  expect(packageJson.exports?.['./jsonSurgery']?.import).toBe('./dist/src/jsonSurgery/index.js');
27
- expect(packageJson.exports?.['./comparison']).toBeDefined();
28
- expect(packageJson.exports?.['./comparison']?.types).toBe('./dist/src/comparison/index.d.ts');
29
- expect(packageJson.exports?.['./comparison']?.import).toBe('./dist/src/comparison/index.js');
27
+ expect(packageJson.exports?.['./semanticMatch']).toBeDefined();
28
+ expect(packageJson.exports?.['./semanticMatch']?.types).toBe('./dist/src/semanticMatch/index.d.ts');
29
+ expect(packageJson.exports?.['./semanticMatch']?.import).toBe('./dist/src/semanticMatch/index.js');
30
30
  });
31
31
  it('imports GPT API symbols from "mdi-llmkit/gptApi"', async () => {
32
32
  const mod = await import('mdi-llmkit/gptApi');
@@ -39,9 +39,13 @@ describe('package subpath exports', () => {
39
39
  expect(typeof mod.jsonSurgery).toBe('function');
40
40
  expect(typeof mod.JSONSurgeryError).toBe('function');
41
41
  });
42
- it('imports comparison symbols from "mdi-llmkit/comparison"', async () => {
43
- const mod = await import('mdi-llmkit/comparison');
42
+ it('imports semanticMatch symbols from "mdi-llmkit/semanticMatch"', async () => {
43
+ const mod = await import('mdi-llmkit/semanticMatch');
44
44
  expect(typeof mod.compareItemLists).toBe('function');
45
45
  expect(typeof mod.ItemComparisonResult).toBe('object');
46
+ expect(typeof mod.getItemName).toBe('function');
47
+ expect(typeof mod.itemToPromptString).toBe('function');
48
+ expect(typeof mod.compareItems).toBe('function');
49
+ expect(typeof mod.areItemsEqual).toBe('function');
46
50
  });
47
51
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdi-llmkit",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "description": "Utilities for managing multi-shot conversations and structured data handling in LLM applications",
5
5
  "author": "Mikhail Voloshin",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "types": "./dist/src/jsonSurgery/index.d.ts",
26
26
  "import": "./dist/src/jsonSurgery/index.js"
27
27
  },
28
- "./comparison": {
29
- "types": "./dist/src/comparison/index.d.ts",
30
- "import": "./dist/src/comparison/index.js"
28
+ "./semanticMatch": {
29
+ "types": "./dist/src/semanticMatch/index.d.ts",
30
+ "import": "./dist/src/semanticMatch/index.js"
31
31
  }
32
32
  },
33
33
  "files": [