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