graphile-llm 0.2.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.
Files changed (43) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +193 -0
  3. package/__tests__/graphile-llm.test.d.ts +1 -0
  4. package/__tests__/graphile-llm.test.js +721 -0
  5. package/chat.d.ts +37 -0
  6. package/chat.js +105 -0
  7. package/embedder.d.ts +35 -0
  8. package/embedder.js +79 -0
  9. package/esm/__tests__/graphile-llm.test.d.ts +1 -0
  10. package/esm/__tests__/graphile-llm.test.js +683 -0
  11. package/esm/chat.d.ts +37 -0
  12. package/esm/chat.js +97 -0
  13. package/esm/embedder.d.ts +35 -0
  14. package/esm/embedder.js +71 -0
  15. package/esm/index.d.ts +39 -0
  16. package/esm/index.js +42 -0
  17. package/esm/plugins/llm-module-plugin.d.ts +38 -0
  18. package/esm/plugins/llm-module-plugin.js +82 -0
  19. package/esm/plugins/rag-plugin.d.ts +36 -0
  20. package/esm/plugins/rag-plugin.js +341 -0
  21. package/esm/plugins/text-mutation-plugin.d.ts +44 -0
  22. package/esm/plugins/text-mutation-plugin.js +191 -0
  23. package/esm/plugins/text-search-plugin.d.ts +41 -0
  24. package/esm/plugins/text-search-plugin.js +163 -0
  25. package/esm/preset.d.ts +55 -0
  26. package/esm/preset.js +74 -0
  27. package/esm/types.d.ts +173 -0
  28. package/esm/types.js +6 -0
  29. package/index.d.ts +39 -0
  30. package/index.js +56 -0
  31. package/package.json +76 -0
  32. package/plugins/llm-module-plugin.d.ts +38 -0
  33. package/plugins/llm-module-plugin.js +85 -0
  34. package/plugins/rag-plugin.d.ts +36 -0
  35. package/plugins/rag-plugin.js +344 -0
  36. package/plugins/text-mutation-plugin.d.ts +44 -0
  37. package/plugins/text-mutation-plugin.js +194 -0
  38. package/plugins/text-search-plugin.d.ts +41 -0
  39. package/plugins/text-search-plugin.js +166 -0
  40. package/preset.d.ts +55 -0
  41. package/preset.js +77 -0
  42. package/types.d.ts +173 -0
  43. package/types.js +7 -0
@@ -0,0 +1,683 @@
1
+ import { join } from 'path';
2
+ import OllamaClient from '@agentic-kit/ollama';
3
+ import { getConnections, seed } from 'graphile-test';
4
+ import { ConnectionFilterPreset } from 'graphile-connection-filter';
5
+ import { VectorCodecPlugin } from 'graphile-search/codecs/vector-codec';
6
+ import { createUnifiedSearchPlugin } from 'graphile-search/plugin';
7
+ import { createPgvectorAdapter } from 'graphile-search/adapters/pgvector';
8
+ import { createLlmModulePlugin } from '../../src/plugins/llm-module-plugin';
9
+ import { createLlmTextSearchPlugin } from '../../src/plugins/text-search-plugin';
10
+ import { createLlmTextMutationPlugin } from '../../src/plugins/text-mutation-plugin';
11
+ import { createLlmRagPlugin } from '../../src/plugins/rag-plugin';
12
+ import { buildEmbedder, buildEmbedderFromModule, buildEmbedderFromEnv, } from '../../src/embedder';
13
+ import { buildChatCompleter, buildChatCompleterFromModule, buildChatCompleterFromEnv, } from '../../src/chat';
14
+ // ─── @agentic-kit/ollama client ─────────────────────────────────────────────
15
+ const ollamaClient = new OllamaClient('http://localhost:11434');
16
+ async function ensureNomicModel() {
17
+ const models = await ollamaClient.listModels();
18
+ const hasModel = models.some((m) => m.includes('nomic-embed-text'));
19
+ if (!hasModel) {
20
+ console.log('Pulling nomic-embed-text model...');
21
+ await ollamaClient.pullModel('nomic-embed-text');
22
+ }
23
+ }
24
+ // =============================================================================
25
+ // Suite 1: Embedder abstraction unit tests
26
+ // =============================================================================
27
+ describe('Embedder abstraction', () => {
28
+ describe('buildEmbedder()', () => {
29
+ it('returns an EmbedderFunction for ollama provider', () => {
30
+ const embedder = buildEmbedder({
31
+ provider: 'ollama',
32
+ model: 'nomic-embed-text',
33
+ baseUrl: 'http://localhost:11434',
34
+ });
35
+ expect(embedder).not.toBeNull();
36
+ expect(typeof embedder).toBe('function');
37
+ });
38
+ it('returns null for unknown provider', () => {
39
+ const embedder = buildEmbedder({
40
+ provider: 'unknown-provider',
41
+ });
42
+ expect(embedder).toBeNull();
43
+ });
44
+ it('uses default model and baseUrl for ollama when not specified', () => {
45
+ const embedder = buildEmbedder({ provider: 'ollama' });
46
+ expect(embedder).not.toBeNull();
47
+ expect(typeof embedder).toBe('function');
48
+ });
49
+ });
50
+ describe('buildEmbedderFromModule()', () => {
51
+ it('builds embedder from LlmModuleData', () => {
52
+ const moduleData = {
53
+ embedding_provider: 'ollama',
54
+ embedding_model: 'nomic-embed-text',
55
+ embedding_base_url: 'http://localhost:11434',
56
+ };
57
+ const embedder = buildEmbedderFromModule(moduleData);
58
+ expect(embedder).not.toBeNull();
59
+ expect(typeof embedder).toBe('function');
60
+ });
61
+ it('returns null for unsupported provider in module data', () => {
62
+ const moduleData = {
63
+ embedding_provider: 'unsupported',
64
+ };
65
+ const embedder = buildEmbedderFromModule(moduleData);
66
+ expect(embedder).toBeNull();
67
+ });
68
+ });
69
+ describe('buildEmbedderFromEnv()', () => {
70
+ const originalEnv = process.env;
71
+ afterEach(() => {
72
+ process.env = originalEnv;
73
+ });
74
+ it('returns null when EMBEDDER_PROVIDER is not set', () => {
75
+ process.env = { ...originalEnv };
76
+ delete process.env.EMBEDDER_PROVIDER;
77
+ const embedder = buildEmbedderFromEnv();
78
+ expect(embedder).toBeNull();
79
+ });
80
+ it('builds embedder from environment variables', () => {
81
+ process.env = {
82
+ ...originalEnv,
83
+ EMBEDDER_PROVIDER: 'ollama',
84
+ EMBEDDER_MODEL: 'nomic-embed-text',
85
+ EMBEDDER_BASE_URL: 'http://localhost:11434',
86
+ };
87
+ const embedder = buildEmbedderFromEnv();
88
+ expect(embedder).not.toBeNull();
89
+ expect(typeof embedder).toBe('function');
90
+ });
91
+ });
92
+ });
93
+ // =============================================================================
94
+ // Suite 2: Schema enrichment — plugin adds text fields to GraphQL schema
95
+ // Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
96
+ // =============================================================================
97
+ describe('graphile-llm schema enrichment', () => {
98
+ let db;
99
+ let teardown;
100
+ let query;
101
+ beforeAll(async () => {
102
+ const unifiedPlugin = createUnifiedSearchPlugin({
103
+ adapters: [createPgvectorAdapter()],
104
+ });
105
+ const testPreset = {
106
+ extends: [ConnectionFilterPreset()],
107
+ plugins: [
108
+ // Search infrastructure (provides VectorNearbyInput)
109
+ VectorCodecPlugin,
110
+ unifiedPlugin,
111
+ // LLM plugins under test
112
+ createLlmModulePlugin({
113
+ defaultEmbedder: {
114
+ provider: 'ollama',
115
+ model: 'nomic-embed-text',
116
+ baseUrl: 'http://localhost:11434',
117
+ },
118
+ }),
119
+ createLlmTextSearchPlugin(),
120
+ createLlmTextMutationPlugin(),
121
+ ],
122
+ };
123
+ const connections = await getConnections({
124
+ schemas: ['llm_test'],
125
+ preset: testPreset,
126
+ useRoot: true,
127
+ authRole: 'postgres',
128
+ }, [seed.sqlfile([join(__dirname, './setup.sql')])]);
129
+ db = connections.db;
130
+ teardown = connections.teardown;
131
+ query = connections.query;
132
+ });
133
+ afterAll(async () => {
134
+ if (teardown) {
135
+ await teardown();
136
+ }
137
+ });
138
+ beforeEach(async () => {
139
+ await db.beforeEach();
140
+ });
141
+ afterEach(async () => {
142
+ await db.afterEach();
143
+ });
144
+ // ─── VectorNearbyInput text field ────────────────────────────────────────
145
+ describe('VectorNearbyInput text field', () => {
146
+ it('adds text field to VectorNearbyInput type', async () => {
147
+ const result = await query(`
148
+ query {
149
+ __type(name: "VectorNearbyInput") {
150
+ inputFields {
151
+ name
152
+ type { name }
153
+ }
154
+ }
155
+ }
156
+ `);
157
+ expect(result.errors).toBeUndefined();
158
+ const inputType = result.data?.__type;
159
+ expect(inputType).toBeDefined();
160
+ const fieldNames = inputType.inputFields.map((f) => f.name);
161
+ // Original fields from graphile-search
162
+ expect(fieldNames).toContain('vector');
163
+ expect(fieldNames).toContain('metric');
164
+ expect(fieldNames).toContain('distance');
165
+ // New field from LlmTextSearchPlugin
166
+ expect(fieldNames).toContain('text');
167
+ // text field should be a String
168
+ const textField = inputType.inputFields.find((f) => f.name === 'text');
169
+ expect(textField.type.name).toBe('String');
170
+ });
171
+ it('still allows vector-based queries (existing behavior unchanged)', async () => {
172
+ const result = await query(`
173
+ query {
174
+ allArticles(where: {
175
+ vectorEmbedding: {
176
+ vector: [1, 0, 0]
177
+ metric: COSINE
178
+ }
179
+ }) {
180
+ nodes {
181
+ title
182
+ embeddingVectorDistance
183
+ }
184
+ }
185
+ }
186
+ `);
187
+ expect(result.errors).toBeUndefined();
188
+ const nodes = result.data?.allArticles?.nodes;
189
+ expect(nodes).toBeDefined();
190
+ expect(nodes.length).toBeGreaterThan(0);
191
+ for (const node of nodes) {
192
+ expect(typeof node.embeddingVectorDistance).toBe('number');
193
+ expect(node.embeddingVectorDistance).toBeGreaterThanOrEqual(0);
194
+ }
195
+ });
196
+ });
197
+ // ─── Mutation text companion fields ──────────────────────────────────────
198
+ describe('Mutation text companion fields', () => {
199
+ it('adds embeddingText field to CreateArticleInput', async () => {
200
+ const result = await query(`
201
+ query {
202
+ __type(name: "CreateArticleInput") {
203
+ inputFields {
204
+ name
205
+ type { name }
206
+ }
207
+ }
208
+ }
209
+ `);
210
+ expect(result.errors).toBeUndefined();
211
+ const inputType = result.data?.__type;
212
+ expect(inputType).toBeDefined();
213
+ const fieldNames = inputType.inputFields.map((f) => f.name);
214
+ // Original embedding field
215
+ expect(fieldNames).toContain('embedding');
216
+ // Companion text field from LlmTextMutationPlugin
217
+ expect(fieldNames).toContain('embeddingText');
218
+ const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
219
+ expect(textField).toBeDefined();
220
+ expect(textField.type.name).toBe('String');
221
+ });
222
+ it('adds embeddingText field to UpdateArticleInput (patch)', async () => {
223
+ const result = await query(`
224
+ query {
225
+ __type(name: "ArticlePatch") {
226
+ inputFields {
227
+ name
228
+ type { name }
229
+ }
230
+ }
231
+ }
232
+ `);
233
+ expect(result.errors).toBeUndefined();
234
+ const inputType = result.data?.__type;
235
+ expect(inputType).toBeDefined();
236
+ const fieldNames = inputType.inputFields.map((f) => f.name);
237
+ expect(fieldNames).toContain('embeddingText');
238
+ const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
239
+ expect(textField).toBeDefined();
240
+ expect(textField.type.name).toBe('String');
241
+ });
242
+ });
243
+ });
244
+ // =============================================================================
245
+ // Suite 3: Real Ollama embedding via @agentic-kit/ollama
246
+ // Requires Ollama running with nomic-embed-text. Tests WILL fail if unavailable.
247
+ // =============================================================================
248
+ describe('graphile-llm with real Ollama embedding', () => {
249
+ beforeAll(async () => {
250
+ await ensureNomicModel();
251
+ });
252
+ it('should embed text to a real vector via Ollama nomic-embed-text', async () => {
253
+ const embedder = buildEmbedder({
254
+ provider: 'ollama',
255
+ model: 'nomic-embed-text',
256
+ baseUrl: 'http://localhost:11434',
257
+ });
258
+ expect(embedder).not.toBeNull();
259
+ const vector = await embedder('Machine learning is transforming AI');
260
+ // nomic-embed-text produces 768-dimensional vectors
261
+ expect(Array.isArray(vector)).toBe(true);
262
+ expect(vector.length).toBe(768);
263
+ // All elements should be numbers
264
+ for (const v of vector) {
265
+ expect(typeof v).toBe('number');
266
+ expect(Number.isFinite(v)).toBe(true);
267
+ }
268
+ // Vector should not be all zeros
269
+ const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
270
+ expect(magnitude).toBeGreaterThan(0);
271
+ });
272
+ it('should produce different vectors for semantically different text', async () => {
273
+ const embedder = buildEmbedder({
274
+ provider: 'ollama',
275
+ model: 'nomic-embed-text',
276
+ baseUrl: 'http://localhost:11434',
277
+ });
278
+ expect(embedder).not.toBeNull();
279
+ const [vecA, vecB] = await Promise.all([
280
+ embedder('Artificial intelligence and machine learning'),
281
+ embedder('Cooking recipes for Italian pasta dishes'),
282
+ ]);
283
+ expect(vecA.length).toBe(768);
284
+ expect(vecB.length).toBe(768);
285
+ // Compute cosine similarity
286
+ let dotProduct = 0;
287
+ let normA = 0;
288
+ let normB = 0;
289
+ for (let i = 0; i < vecA.length; i++) {
290
+ dotProduct += vecA[i] * vecB[i];
291
+ normA += vecA[i] * vecA[i];
292
+ normB += vecB[i] * vecB[i];
293
+ }
294
+ const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
295
+ // Semantically different texts should have lower similarity
296
+ expect(cosineSimilarity).toBeLessThan(0.95);
297
+ });
298
+ it('should produce similar vectors for semantically similar text', async () => {
299
+ const embedder = buildEmbedder({
300
+ provider: 'ollama',
301
+ model: 'nomic-embed-text',
302
+ baseUrl: 'http://localhost:11434',
303
+ });
304
+ expect(embedder).not.toBeNull();
305
+ const [vecA, vecB] = await Promise.all([
306
+ embedder('Machine learning and artificial intelligence'),
307
+ embedder('AI and ML are subfields of computer science'),
308
+ ]);
309
+ expect(vecA.length).toBe(768);
310
+ expect(vecB.length).toBe(768);
311
+ // Compute cosine similarity
312
+ let dotProduct = 0;
313
+ let normA = 0;
314
+ let normB = 0;
315
+ for (let i = 0; i < vecA.length; i++) {
316
+ dotProduct += vecA[i] * vecB[i];
317
+ normA += vecA[i] * vecA[i];
318
+ normB += vecB[i] * vecB[i];
319
+ }
320
+ const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
321
+ // Semantically similar texts should have high similarity
322
+ expect(cosineSimilarity).toBeGreaterThan(0.5);
323
+ });
324
+ it('should produce embeddings via @agentic-kit/ollama OllamaClient directly', async () => {
325
+ const vector = await ollamaClient.generateEmbedding('Testing the agentic-kit Ollama client directly', 'nomic-embed-text');
326
+ expect(Array.isArray(vector)).toBe(true);
327
+ expect(vector.length).toBe(768);
328
+ for (const v of vector) {
329
+ expect(typeof v).toBe('number');
330
+ expect(Number.isFinite(v)).toBe(true);
331
+ }
332
+ });
333
+ });
334
+ // =============================================================================
335
+ // Suite 4: Chat completion abstraction unit tests
336
+ // =============================================================================
337
+ describe('Chat completion abstraction', () => {
338
+ describe('buildChatCompleter()', () => {
339
+ it('returns a ChatFunction for ollama provider', () => {
340
+ const chat = buildChatCompleter({
341
+ provider: 'ollama',
342
+ model: 'llama3',
343
+ baseUrl: 'http://localhost:11434',
344
+ });
345
+ expect(chat).not.toBeNull();
346
+ expect(typeof chat).toBe('function');
347
+ });
348
+ it('returns null for unknown provider', () => {
349
+ const chat = buildChatCompleter({
350
+ provider: 'unknown-provider',
351
+ });
352
+ expect(chat).toBeNull();
353
+ });
354
+ it('uses default model and baseUrl for ollama when not specified', () => {
355
+ const chat = buildChatCompleter({ provider: 'ollama' });
356
+ expect(chat).not.toBeNull();
357
+ expect(typeof chat).toBe('function');
358
+ });
359
+ });
360
+ describe('buildChatCompleterFromModule()', () => {
361
+ it('builds chat completer from LlmModuleData with chat_provider', () => {
362
+ const moduleData = {
363
+ embedding_provider: 'ollama',
364
+ chat_provider: 'ollama',
365
+ chat_model: 'llama3',
366
+ chat_base_url: 'http://localhost:11434',
367
+ };
368
+ const chat = buildChatCompleterFromModule(moduleData);
369
+ expect(chat).not.toBeNull();
370
+ expect(typeof chat).toBe('function');
371
+ });
372
+ it('returns null when chat_provider is not set', () => {
373
+ const moduleData = {
374
+ embedding_provider: 'ollama',
375
+ };
376
+ const chat = buildChatCompleterFromModule(moduleData);
377
+ expect(chat).toBeNull();
378
+ });
379
+ });
380
+ describe('buildChatCompleterFromEnv()', () => {
381
+ const originalEnv = process.env;
382
+ afterEach(() => {
383
+ process.env = originalEnv;
384
+ });
385
+ it('returns null when CHAT_PROVIDER is not set', () => {
386
+ process.env = { ...originalEnv };
387
+ delete process.env.CHAT_PROVIDER;
388
+ const chat = buildChatCompleterFromEnv();
389
+ expect(chat).toBeNull();
390
+ });
391
+ it('builds chat completer from environment variables', () => {
392
+ process.env = {
393
+ ...originalEnv,
394
+ CHAT_PROVIDER: 'ollama',
395
+ CHAT_MODEL: 'llama3',
396
+ CHAT_BASE_URL: 'http://localhost:11434',
397
+ };
398
+ const chat = buildChatCompleterFromEnv();
399
+ expect(chat).not.toBeNull();
400
+ expect(typeof chat).toBe('function');
401
+ });
402
+ });
403
+ });
404
+ // =============================================================================
405
+ // Suite 5: RAG plugin schema enrichment
406
+ // Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
407
+ // =============================================================================
408
+ /**
409
+ * Smart tag injection plugin for test tables.
410
+ * Injects @hasChunks on the articles codec so the RAG plugin can discover it.
411
+ */
412
+ function makeTestSmartTagsPlugin(tagsByTable) {
413
+ return {
414
+ name: 'TestSmartTagsPlugin',
415
+ version: '1.0.0',
416
+ schema: {
417
+ hooks: {
418
+ init: {
419
+ before: ['UnifiedSearchPlugin', 'LlmRagPlugin'],
420
+ callback(_, build) {
421
+ for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
422
+ const c = codec;
423
+ if (!c.attributes || !c.name)
424
+ continue;
425
+ const tags = tagsByTable[c.name];
426
+ if (!tags)
427
+ continue;
428
+ if (!c.extensions)
429
+ c.extensions = {};
430
+ if (!c.extensions.tags)
431
+ c.extensions.tags = {};
432
+ Object.assign(c.extensions.tags, tags);
433
+ }
434
+ return _;
435
+ },
436
+ },
437
+ },
438
+ },
439
+ };
440
+ }
441
+ describe('RAG plugin schema enrichment', () => {
442
+ let db;
443
+ let teardown;
444
+ let query;
445
+ beforeAll(async () => {
446
+ const unifiedPlugin = createUnifiedSearchPlugin({
447
+ adapters: [createPgvectorAdapter()],
448
+ });
449
+ const smartTagsPlugin = makeTestSmartTagsPlugin({
450
+ articles: {
451
+ hasChunks: {
452
+ chunksTable: 'articles_chunks',
453
+ parentFk: 'parent_id',
454
+ parentPk: 'id',
455
+ embeddingField: 'embedding',
456
+ contentField: 'content',
457
+ },
458
+ },
459
+ });
460
+ // Mock embedder that returns a fixed 3-dim vector
461
+ const mockEmbedder = async (_text) => [1, 0, 0];
462
+ // Mock chat completer that returns a canned response
463
+ const mockChatCompleter = async (messages) => {
464
+ const userMessage = messages.find((m) => m.role === 'user');
465
+ return `Mock answer for: ${userMessage?.content || 'unknown'}`;
466
+ };
467
+ const testPreset = {
468
+ extends: [ConnectionFilterPreset()],
469
+ plugins: [
470
+ VectorCodecPlugin,
471
+ unifiedPlugin,
472
+ smartTagsPlugin,
473
+ createLlmModulePlugin({
474
+ defaultEmbedder: {
475
+ provider: 'ollama',
476
+ model: 'nomic-embed-text',
477
+ baseUrl: 'http://localhost:11434',
478
+ },
479
+ }),
480
+ createLlmTextSearchPlugin(),
481
+ createLlmTextMutationPlugin(),
482
+ createLlmRagPlugin(),
483
+ ],
484
+ };
485
+ // Override the embedder and chat completer on the build context
486
+ // by wrapping the LlmModulePlugin's build hook
487
+ const overridePlugin = {
488
+ name: 'TestOverridePlugin',
489
+ version: '1.0.0',
490
+ after: ['LlmModulePlugin'],
491
+ schema: {
492
+ hooks: {
493
+ build(build) {
494
+ return build.extend(build, {
495
+ llmEmbedder: mockEmbedder,
496
+ llmChatCompleter: mockChatCompleter,
497
+ }, 'TestOverridePlugin overriding embedder and chat completer');
498
+ },
499
+ },
500
+ },
501
+ };
502
+ const connections = await getConnections({
503
+ schemas: ['llm_test'],
504
+ preset: {
505
+ ...testPreset,
506
+ plugins: [...testPreset.plugins, overridePlugin],
507
+ },
508
+ useRoot: true,
509
+ authRole: 'postgres',
510
+ }, [seed.sqlfile([join(__dirname, './setup.sql')])]);
511
+ db = connections.db;
512
+ teardown = connections.teardown;
513
+ query = connections.query;
514
+ });
515
+ afterAll(async () => {
516
+ if (teardown) {
517
+ await teardown();
518
+ }
519
+ });
520
+ beforeEach(async () => {
521
+ await db.beforeEach();
522
+ });
523
+ afterEach(async () => {
524
+ await db.afterEach();
525
+ });
526
+ it('adds ragQuery field to the Query type', async () => {
527
+ const result = await query(`
528
+ query {
529
+ __type(name: "Query") {
530
+ fields {
531
+ name
532
+ }
533
+ }
534
+ }
535
+ `);
536
+ expect(result.errors).toBeUndefined();
537
+ const queryType = result.data?.__type;
538
+ expect(queryType).toBeDefined();
539
+ const fieldNames = queryType.fields.map((f) => f.name);
540
+ expect(fieldNames).toContain('ragQuery');
541
+ });
542
+ it('adds embedText field to the Query type', async () => {
543
+ const result = await query(`
544
+ query {
545
+ __type(name: "Query") {
546
+ fields {
547
+ name
548
+ }
549
+ }
550
+ }
551
+ `);
552
+ expect(result.errors).toBeUndefined();
553
+ const fieldNames = result.data.__type.fields.map((f) => f.name);
554
+ expect(fieldNames).toContain('embedText');
555
+ });
556
+ it('ragQuery returns RagResponse type with answer and sources', async () => {
557
+ const result = await query(`
558
+ query {
559
+ __type(name: "RagResponse") {
560
+ fields {
561
+ name
562
+ type { name kind }
563
+ }
564
+ }
565
+ }
566
+ `);
567
+ expect(result.errors).toBeUndefined();
568
+ const ragResponseType = result.data?.__type;
569
+ expect(ragResponseType).toBeDefined();
570
+ const fieldNames = ragResponseType.fields.map((f) => f.name);
571
+ expect(fieldNames).toContain('answer');
572
+ expect(fieldNames).toContain('sources');
573
+ expect(fieldNames).toContain('tokensUsed');
574
+ });
575
+ it('ragQuery executes and returns mock answer with sources', async () => {
576
+ const result = await query(`
577
+ query {
578
+ ragQuery(prompt: "What is machine learning?", contextLimit: 3) {
579
+ answer
580
+ sources {
581
+ content
582
+ similarity
583
+ tableName
584
+ parentId
585
+ }
586
+ tokensUsed
587
+ }
588
+ }
589
+ `);
590
+ expect(result.errors).toBeUndefined();
591
+ const rag = result.data?.ragQuery;
592
+ expect(rag).toBeDefined();
593
+ // Mock chat completer returns a canned response
594
+ expect(rag.answer).toContain('Mock answer for');
595
+ expect(rag.answer).toContain('What is machine learning?');
596
+ // Sources should come from the chunks table
597
+ expect(rag.sources.length).toBeGreaterThan(0);
598
+ expect(rag.sources.length).toBeLessThanOrEqual(3);
599
+ for (const source of rag.sources) {
600
+ expect(typeof source.content).toBe('string');
601
+ expect(source.content.length).toBeGreaterThan(0);
602
+ expect(typeof source.similarity).toBe('number');
603
+ expect(source.similarity).toBeGreaterThanOrEqual(0);
604
+ expect(source.similarity).toBeLessThanOrEqual(1);
605
+ expect(source.tableName).toBe('articles');
606
+ expect(source.parentId).toBeTruthy();
607
+ }
608
+ });
609
+ it('embedText executes and returns vector with dimensions', async () => {
610
+ const result = await query(`
611
+ query {
612
+ embedText(text: "test embedding") {
613
+ vector
614
+ dimensions
615
+ }
616
+ }
617
+ `);
618
+ expect(result.errors).toBeUndefined();
619
+ const embed = result.data?.embedText;
620
+ expect(embed).toBeDefined();
621
+ // Mock embedder returns [1, 0, 0]
622
+ expect(embed.vector).toEqual([1, 0, 0]);
623
+ expect(embed.dimensions).toBe(3);
624
+ });
625
+ });
626
+ // =============================================================================
627
+ // Suite 6: GraphileLlmPreset toggle tests
628
+ // =============================================================================
629
+ describe('GraphileLlmPreset toggles', () => {
630
+ it('enableRag=false excludes RAG plugin (no ragQuery field)', async () => {
631
+ const { GraphileLlmPreset } = await import('../../src/preset');
632
+ const preset = GraphileLlmPreset({
633
+ enableRag: false,
634
+ });
635
+ const pluginNames = preset.plugins.map((p) => p.name);
636
+ expect(pluginNames).not.toContain('LlmRagPlugin');
637
+ });
638
+ it('enableRag=true includes RAG plugin', async () => {
639
+ const { GraphileLlmPreset } = await import('../../src/preset');
640
+ const preset = GraphileLlmPreset({
641
+ enableRag: true,
642
+ });
643
+ const pluginNames = preset.plugins.map((p) => p.name);
644
+ expect(pluginNames).toContain('LlmRagPlugin');
645
+ });
646
+ it('enableTextSearch=false excludes text search plugin', async () => {
647
+ const { GraphileLlmPreset } = await import('../../src/preset');
648
+ const preset = GraphileLlmPreset({
649
+ enableTextSearch: false,
650
+ });
651
+ const pluginNames = preset.plugins.map((p) => p.name);
652
+ expect(pluginNames).not.toContain('LlmTextSearchPlugin');
653
+ // Module plugin is always included
654
+ expect(pluginNames).toContain('LlmModulePlugin');
655
+ });
656
+ it('enableTextMutations=false excludes text mutation plugin', async () => {
657
+ const { GraphileLlmPreset } = await import('../../src/preset');
658
+ const preset = GraphileLlmPreset({
659
+ enableTextMutations: false,
660
+ });
661
+ const pluginNames = preset.plugins.map((p) => p.name);
662
+ expect(pluginNames).not.toContain('LlmTextMutationPlugin');
663
+ });
664
+ it('all toggles false leaves only LlmModulePlugin', async () => {
665
+ const { GraphileLlmPreset } = await import('../../src/preset');
666
+ const preset = GraphileLlmPreset({
667
+ enableTextSearch: false,
668
+ enableTextMutations: false,
669
+ enableRag: false,
670
+ });
671
+ const pluginNames = preset.plugins.map((p) => p.name);
672
+ expect(pluginNames).toEqual(['LlmModulePlugin']);
673
+ });
674
+ it('default options include text search and mutations but not RAG', async () => {
675
+ const { GraphileLlmPreset } = await import('../../src/preset');
676
+ const preset = GraphileLlmPreset();
677
+ const pluginNames = preset.plugins.map((p) => p.name);
678
+ expect(pluginNames).toContain('LlmModulePlugin');
679
+ expect(pluginNames).toContain('LlmTextSearchPlugin');
680
+ expect(pluginNames).toContain('LlmTextMutationPlugin');
681
+ expect(pluginNames).not.toContain('LlmRagPlugin');
682
+ });
683
+ });