graphile-llm 1.15.9 → 1.17.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.
@@ -1,1060 +0,0 @@
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 ollama_1 = __importDefault(require("@agentic-kit/ollama"));
40
- const graphile_connection_filter_1 = require("graphile-connection-filter");
41
- const pgvector_1 = require("graphile-search/adapters/pgvector");
42
- const tsvector_1 = require("graphile-search/adapters/tsvector");
43
- const tsvector_codec_1 = require("graphile-search/codecs/tsvector-codec");
44
- const vector_codec_1 = require("graphile-search/codecs/vector-codec");
45
- const plugin_1 = require("graphile-search/plugin");
46
- const graphile_test_1 = require("graphile-test");
47
- const path_1 = require("path");
48
- const chat_1 = require("../../src/chat");
49
- const embedder_1 = require("../../src/embedder");
50
- const llm_module_plugin_1 = require("../../src/plugins/llm-module-plugin");
51
- const rag_plugin_1 = require("../../src/plugins/rag-plugin");
52
- const text_mutation_plugin_1 = require("../../src/plugins/text-mutation-plugin");
53
- const text_search_plugin_1 = require("../../src/plugins/text-search-plugin");
54
- // ─── @agentic-kit/ollama client ─────────────────────────────────────────────
55
- const ollamaClient = new ollama_1.default('http://localhost:11434');
56
- async function ensureNomicModel() {
57
- const models = await ollamaClient.listModels();
58
- const hasModel = models.some((m) => m.includes('nomic-embed-text'));
59
- if (!hasModel) {
60
- console.log('Pulling nomic-embed-text model...');
61
- await ollamaClient.pullModel('nomic-embed-text');
62
- }
63
- }
64
- // =============================================================================
65
- // Suite 1: Embedder abstraction unit tests
66
- // =============================================================================
67
- describe('Embedder abstraction', () => {
68
- describe('buildEmbedder()', () => {
69
- it('returns an EmbedderFunction for ollama provider', () => {
70
- const embedder = (0, embedder_1.buildEmbedder)({
71
- provider: 'ollama',
72
- model: 'nomic-embed-text',
73
- baseUrl: 'http://localhost:11434'
74
- });
75
- expect(embedder).not.toBeNull();
76
- expect(typeof embedder).toBe('function');
77
- });
78
- it('returns null for unknown provider', () => {
79
- const embedder = (0, embedder_1.buildEmbedder)({
80
- provider: 'unknown-provider'
81
- });
82
- expect(embedder).toBeNull();
83
- });
84
- it('uses default model and baseUrl for ollama when not specified', () => {
85
- const embedder = (0, embedder_1.buildEmbedder)({ provider: 'ollama' });
86
- expect(embedder).not.toBeNull();
87
- expect(typeof embedder).toBe('function');
88
- });
89
- });
90
- describe('buildEmbedderFromModule()', () => {
91
- it('builds embedder from LlmModuleData', () => {
92
- const moduleData = {
93
- embedding_provider: 'ollama',
94
- embedding_model: 'nomic-embed-text',
95
- embedding_base_url: 'http://localhost:11434'
96
- };
97
- const embedder = (0, embedder_1.buildEmbedderFromModule)(moduleData);
98
- expect(embedder).not.toBeNull();
99
- expect(typeof embedder).toBe('function');
100
- });
101
- it('returns null for unsupported provider in module data', () => {
102
- const moduleData = {
103
- embedding_provider: 'unsupported'
104
- };
105
- const embedder = (0, embedder_1.buildEmbedderFromModule)(moduleData);
106
- expect(embedder).toBeNull();
107
- });
108
- });
109
- describe('buildEmbedderFromEnv()', () => {
110
- const originalEnv = process.env;
111
- afterEach(() => {
112
- process.env = originalEnv;
113
- });
114
- it('returns default ollama embedder when EMBEDDER_PROVIDER is not set', () => {
115
- process.env = { ...originalEnv };
116
- delete process.env.EMBEDDER_PROVIDER;
117
- const embedder = (0, embedder_1.buildEmbedderFromEnv)();
118
- expect(embedder).not.toBeNull();
119
- expect(typeof embedder).toBe('function');
120
- });
121
- it('builds embedder from environment variables', () => {
122
- process.env = {
123
- ...originalEnv,
124
- EMBEDDER_PROVIDER: 'ollama',
125
- EMBEDDER_MODEL: 'nomic-embed-text',
126
- EMBEDDER_BASE_URL: 'http://localhost:11434'
127
- };
128
- const embedder = (0, embedder_1.buildEmbedderFromEnv)();
129
- expect(embedder).not.toBeNull();
130
- expect(typeof embedder).toBe('function');
131
- });
132
- });
133
- });
134
- // =============================================================================
135
- // Suite 2: Schema enrichment — plugin adds text fields to GraphQL schema
136
- // Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
137
- // =============================================================================
138
- describe('graphile-llm schema enrichment', () => {
139
- let db;
140
- let teardown;
141
- let query;
142
- beforeAll(async () => {
143
- const unifiedPlugin = (0, plugin_1.createUnifiedSearchPlugin)({
144
- adapters: [(0, pgvector_1.createPgvectorAdapter)()]
145
- });
146
- const testPreset = {
147
- extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
148
- plugins: [
149
- // Search infrastructure (provides VectorNearbyInput)
150
- vector_codec_1.VectorCodecPlugin,
151
- unifiedPlugin,
152
- // LLM plugins under test
153
- (0, llm_module_plugin_1.createLlmModulePlugin)({
154
- defaultEmbedder: {
155
- provider: 'ollama',
156
- model: 'nomic-embed-text',
157
- baseUrl: 'http://localhost:11434'
158
- }
159
- }),
160
- (0, text_search_plugin_1.createLlmTextSearchPlugin)(),
161
- (0, text_mutation_plugin_1.createLlmTextMutationPlugin)()
162
- ]
163
- };
164
- const connections = await (0, graphile_test_1.getConnections)({
165
- schemas: ['llm_test'],
166
- preset: testPreset,
167
- useRoot: true,
168
- authRole: 'postgres'
169
- }, [graphile_test_1.seed.sqlfile([(0, path_1.join)(__dirname, './setup.sql')])]);
170
- db = connections.db;
171
- teardown = connections.teardown;
172
- query = connections.query;
173
- });
174
- afterAll(async () => {
175
- if (teardown) {
176
- await teardown();
177
- }
178
- });
179
- beforeEach(async () => {
180
- await db.beforeEach();
181
- });
182
- afterEach(async () => {
183
- await db.afterEach();
184
- });
185
- // ─── VectorNearbyInput text field ────────────────────────────────────────
186
- describe('VectorNearbyInput text field', () => {
187
- it('adds text field to VectorNearbyInput type', async () => {
188
- const result = await query(`
189
- query {
190
- __type(name: "VectorNearbyInput") {
191
- inputFields {
192
- name
193
- type { name }
194
- }
195
- }
196
- }
197
- `);
198
- expect(result.errors).toBeUndefined();
199
- const inputType = result.data?.__type;
200
- expect(inputType).toBeDefined();
201
- const fieldNames = inputType.inputFields.map((f) => f.name);
202
- // Original fields from graphile-search
203
- expect(fieldNames).toContain('vector');
204
- expect(fieldNames).toContain('metric');
205
- expect(fieldNames).toContain('distance');
206
- // New field from LlmTextSearchPlugin
207
- expect(fieldNames).toContain('text');
208
- // text field should be a String
209
- const textField = inputType.inputFields.find((f) => f.name === 'text');
210
- expect(textField.type.name).toBe('String');
211
- });
212
- it('still allows vector-based queries (existing behavior unchanged)', async () => {
213
- const result = await query(`
214
- query {
215
- allArticles(where: {
216
- vectorEmbedding: {
217
- vector: [1, 0, 0]
218
- metric: COSINE
219
- }
220
- }) {
221
- nodes {
222
- title
223
- embeddingVectorDistance
224
- }
225
- }
226
- }
227
- `);
228
- expect(result.errors).toBeUndefined();
229
- const nodes = result.data?.allArticles?.nodes;
230
- expect(nodes).toBeDefined();
231
- expect(nodes.length).toBeGreaterThan(0);
232
- for (const node of nodes) {
233
- expect(typeof node.embeddingVectorDistance).toBe('number');
234
- expect(node.embeddingVectorDistance).toBeGreaterThanOrEqual(0);
235
- }
236
- });
237
- });
238
- // ─── Mutation text companion fields ──────────────────────────────────────
239
- describe('Mutation text companion fields', () => {
240
- it('adds embeddingText field to ArticleInput (create input)', async () => {
241
- const result = await query(`
242
- query {
243
- __type(name: "ArticleInput") {
244
- inputFields {
245
- name
246
- type { name }
247
- }
248
- }
249
- }
250
- `);
251
- expect(result.errors).toBeUndefined();
252
- const inputType = result.data?.__type;
253
- expect(inputType).toBeDefined();
254
- const fieldNames = inputType.inputFields.map((f) => f.name);
255
- expect(fieldNames).toContain('embedding');
256
- expect(fieldNames).toContain('embeddingText');
257
- const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
258
- expect(textField).toBeDefined();
259
- expect(textField.type.name).toBe('String');
260
- });
261
- it('adds embeddingText field to UpdateArticleInput (patch)', async () => {
262
- const result = await query(`
263
- query {
264
- __type(name: "ArticlePatch") {
265
- inputFields {
266
- name
267
- type { name }
268
- }
269
- }
270
- }
271
- `);
272
- expect(result.errors).toBeUndefined();
273
- const inputType = result.data?.__type;
274
- expect(inputType).toBeDefined();
275
- const fieldNames = inputType.inputFields.map((f) => f.name);
276
- expect(fieldNames).toContain('embeddingText');
277
- const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
278
- expect(textField).toBeDefined();
279
- expect(textField.type.name).toBe('String');
280
- });
281
- it('does not conflict with a physical embedding_text column (NoteInput)', async () => {
282
- const result = await query(`
283
- query {
284
- __type(name: "NoteInput") {
285
- inputFields {
286
- name
287
- type { name }
288
- }
289
- }
290
- }
291
- `);
292
- expect(result.errors).toBeUndefined();
293
- const inputType = result.data?.__type;
294
- expect(inputType).toBeDefined();
295
- // Exactly one embeddingText field — the physical column, not a
296
- // plugin-synthesized companion.
297
- const textFields = inputType.inputFields.filter((f) => f.name === 'embeddingText');
298
- expect(textFields).toHaveLength(1);
299
- expect(textFields[0].type.name).toBe('String');
300
- });
301
- it('stores raw text in the physical embedding_text column (not consumed as companion)', async () => {
302
- const result = await query(`
303
- mutation {
304
- createNote(input: { note: { title: "Physical column", embeddingText: "keep me as text" } }) {
305
- note {
306
- id
307
- title
308
- embeddingText
309
- embedding
310
- }
311
- }
312
- }
313
- `);
314
- expect(result.errors).toBeUndefined();
315
- const note = result.data?.createNote?.note;
316
- expect(note).toBeDefined();
317
- expect(note.embeddingText).toBe('keep me as text');
318
- // The vector column must NOT have been populated by the plugin.
319
- expect(note.embedding).toBeNull();
320
- });
321
- });
322
- });
323
- // =============================================================================
324
- // Suite 3: Real Ollama embedding via @agentic-kit/ollama
325
- // Requires Ollama running with nomic-embed-text. Tests WILL fail if unavailable.
326
- // =============================================================================
327
- describe('graphile-llm with real Ollama embedding', () => {
328
- beforeAll(async () => {
329
- await ensureNomicModel();
330
- });
331
- it('should embed text to a real vector via Ollama nomic-embed-text', async () => {
332
- const embedder = (0, embedder_1.buildEmbedder)({
333
- provider: 'ollama',
334
- model: 'nomic-embed-text',
335
- baseUrl: 'http://localhost:11434'
336
- });
337
- expect(embedder).not.toBeNull();
338
- const result = await embedder('Machine learning is transforming AI');
339
- // nomic-embed-text produces 768-dimensional vectors
340
- expect(Array.isArray(result.embedding)).toBe(true);
341
- expect(result.embedding.length).toBe(768);
342
- expect(result.promptTokens).toBeGreaterThan(0);
343
- // All elements should be numbers
344
- for (const v of result.embedding) {
345
- expect(typeof v).toBe('number');
346
- expect(Number.isFinite(v)).toBe(true);
347
- }
348
- // Vector should not be all zeros
349
- const magnitude = Math.sqrt(result.embedding.reduce((sum, v) => sum + v * v, 0));
350
- expect(magnitude).toBeGreaterThan(0);
351
- });
352
- it('should produce different vectors for semantically different text', async () => {
353
- const embedder = (0, embedder_1.buildEmbedder)({
354
- provider: 'ollama',
355
- model: 'nomic-embed-text',
356
- baseUrl: 'http://localhost:11434'
357
- });
358
- expect(embedder).not.toBeNull();
359
- const [resultA, resultB] = await Promise.all([
360
- embedder('Artificial intelligence and machine learning'),
361
- embedder('Cooking recipes for Italian pasta dishes')
362
- ]);
363
- const vecA = resultA.embedding;
364
- const vecB = resultB.embedding;
365
- expect(vecA.length).toBe(768);
366
- expect(vecB.length).toBe(768);
367
- // Compute cosine similarity
368
- let dotProduct = 0;
369
- let normA = 0;
370
- let normB = 0;
371
- for (let i = 0; i < vecA.length; i++) {
372
- dotProduct += vecA[i] * vecB[i];
373
- normA += vecA[i] * vecA[i];
374
- normB += vecB[i] * vecB[i];
375
- }
376
- const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
377
- // Semantically different texts should have lower similarity
378
- expect(cosineSimilarity).toBeLessThan(0.95);
379
- });
380
- it('should produce similar vectors for semantically similar text', async () => {
381
- const embedder = (0, embedder_1.buildEmbedder)({
382
- provider: 'ollama',
383
- model: 'nomic-embed-text',
384
- baseUrl: 'http://localhost:11434'
385
- });
386
- expect(embedder).not.toBeNull();
387
- const [resultA, resultB] = await Promise.all([
388
- embedder('Machine learning and artificial intelligence'),
389
- embedder('AI and ML are subfields of computer science')
390
- ]);
391
- const vecA = resultA.embedding;
392
- const vecB = resultB.embedding;
393
- expect(vecA.length).toBe(768);
394
- expect(vecB.length).toBe(768);
395
- // Compute cosine similarity
396
- let dotProduct = 0;
397
- let normA = 0;
398
- let normB = 0;
399
- for (let i = 0; i < vecA.length; i++) {
400
- dotProduct += vecA[i] * vecB[i];
401
- normA += vecA[i] * vecA[i];
402
- normB += vecB[i] * vecB[i];
403
- }
404
- const cosineSimilarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
405
- // Semantically similar texts should have high similarity
406
- expect(cosineSimilarity).toBeGreaterThan(0.5);
407
- });
408
- it('should produce embeddings with token count via @agentic-kit/ollama OllamaClient directly', async () => {
409
- const result = await ollamaClient.generateEmbedding('Testing the agentic-kit Ollama client directly', 'nomic-embed-text');
410
- expect(result).toHaveProperty('embedding');
411
- expect(result).toHaveProperty('promptTokens');
412
- expect(Array.isArray(result.embedding)).toBe(true);
413
- expect(result.embedding.length).toBe(768);
414
- expect(result.promptTokens).toBeGreaterThan(0);
415
- for (const v of result.embedding) {
416
- expect(typeof v).toBe('number');
417
- expect(Number.isFinite(v)).toBe(true);
418
- }
419
- });
420
- });
421
- // =============================================================================
422
- // Suite 4: Chat completion abstraction unit tests
423
- // =============================================================================
424
- describe('Chat completion abstraction', () => {
425
- describe('buildChatCompleter()', () => {
426
- it('returns a ChatFunction for ollama provider', () => {
427
- const chat = (0, chat_1.buildChatCompleter)({
428
- provider: 'ollama',
429
- model: 'llama3',
430
- baseUrl: 'http://localhost:11434'
431
- });
432
- expect(chat).not.toBeNull();
433
- expect(typeof chat).toBe('function');
434
- });
435
- it('returns null for unknown provider', () => {
436
- const chat = (0, chat_1.buildChatCompleter)({
437
- provider: 'unknown-provider'
438
- });
439
- expect(chat).toBeNull();
440
- });
441
- it('uses default model and baseUrl for ollama when not specified', () => {
442
- const chat = (0, chat_1.buildChatCompleter)({ provider: 'ollama' });
443
- expect(chat).not.toBeNull();
444
- expect(typeof chat).toBe('function');
445
- });
446
- });
447
- describe('buildChatCompleterFromModule()', () => {
448
- it('builds chat completer from LlmModuleData with chat_provider', () => {
449
- const moduleData = {
450
- embedding_provider: 'ollama',
451
- chat_provider: 'ollama',
452
- chat_model: 'llama3',
453
- chat_base_url: 'http://localhost:11434'
454
- };
455
- const chat = (0, chat_1.buildChatCompleterFromModule)(moduleData);
456
- expect(chat).not.toBeNull();
457
- expect(typeof chat).toBe('function');
458
- });
459
- it('returns null when chat_provider is not set', () => {
460
- const moduleData = {
461
- embedding_provider: 'ollama'
462
- };
463
- const chat = (0, chat_1.buildChatCompleterFromModule)(moduleData);
464
- expect(chat).toBeNull();
465
- });
466
- });
467
- describe('buildChatCompleterFromEnv()', () => {
468
- const originalEnv = process.env;
469
- afterEach(() => {
470
- process.env = originalEnv;
471
- });
472
- it('returns default ollama chat completer when CHAT_PROVIDER is not set', () => {
473
- process.env = { ...originalEnv };
474
- delete process.env.CHAT_PROVIDER;
475
- const chat = (0, chat_1.buildChatCompleterFromEnv)();
476
- expect(chat).not.toBeNull();
477
- expect(typeof chat).toBe('function');
478
- });
479
- it('builds chat completer from environment variables', () => {
480
- process.env = {
481
- ...originalEnv,
482
- CHAT_PROVIDER: 'ollama',
483
- CHAT_MODEL: 'llama3',
484
- CHAT_BASE_URL: 'http://localhost:11434'
485
- };
486
- const chat = (0, chat_1.buildChatCompleterFromEnv)();
487
- expect(chat).not.toBeNull();
488
- expect(typeof chat).toBe('function');
489
- });
490
- });
491
- });
492
- // =============================================================================
493
- // Suite 5: RAG plugin schema enrichment
494
- // Requires PostgreSQL + pgvector. Tests WILL fail if database is unavailable.
495
- // =============================================================================
496
- /**
497
- * Smart tag injection plugin for test tables.
498
- * Injects @hasChunks on the articles codec so the RAG plugin can discover it.
499
- */
500
- function makeTestSmartTagsPlugin(tagsByTable) {
501
- return {
502
- name: 'TestSmartTagsPlugin',
503
- version: '1.0.0',
504
- before: ['LlmRagPlugin'],
505
- schema: {
506
- hooks: {
507
- build: {
508
- before: ['LlmRagPlugin'],
509
- callback(build) {
510
- const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
511
- if (!pgRegistry)
512
- return build;
513
- for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
514
- const c = codec;
515
- if (!c.attributes || !c.name)
516
- continue;
517
- const tags = tagsByTable[c.name];
518
- if (!tags)
519
- continue;
520
- if (!c.extensions)
521
- c.extensions = {};
522
- if (!c.extensions.tags)
523
- c.extensions.tags = {};
524
- Object.assign(c.extensions.tags, tags);
525
- }
526
- return build;
527
- }
528
- }
529
- }
530
- }
531
- };
532
- }
533
- describe('RAG plugin schema enrichment', () => {
534
- let db;
535
- let teardown;
536
- let query;
537
- beforeAll(async () => {
538
- const unifiedPlugin = (0, plugin_1.createUnifiedSearchPlugin)({
539
- adapters: [(0, pgvector_1.createPgvectorAdapter)()]
540
- });
541
- const smartTagsPlugin = makeTestSmartTagsPlugin({
542
- articles: {
543
- hasChunks: {
544
- chunksTable: 'articles_chunks',
545
- parentFk: 'parent_id',
546
- parentPk: 'id',
547
- embeddingField: 'embedding',
548
- contentField: 'content'
549
- }
550
- }
551
- });
552
- // Mock embedder that returns a fixed 3-dim vector with token count
553
- const mockEmbedder = async (_text) => ({
554
- embedding: [1, 0, 0],
555
- promptTokens: 5
556
- });
557
- // Mock chat completer that returns a canned response with usage
558
- const mockChatCompleter = async (messages) => {
559
- const userMessage = messages.find((m) => m.role === 'user');
560
- return {
561
- content: `Mock answer for: ${userMessage?.content || 'unknown'}`,
562
- usage: { input: 10, output: 15, reasoning: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 25 }
563
- };
564
- };
565
- // Provide mock embedder/chat directly instead of using createLlmModulePlugin
566
- // to avoid build.extend naming conflicts when overriding
567
- const mockLlmPlugin = {
568
- name: 'TestMockLlmPlugin',
569
- version: '1.0.0',
570
- schema: {
571
- hooks: {
572
- build(build) {
573
- return build.extend(build, {
574
- llmEmbedder: mockEmbedder,
575
- llmChatCompleter: mockChatCompleter,
576
- llmEmbeddingModel: 'test-mock-model',
577
- llmChatModel: 'test-mock-chat'
578
- }, 'TestMockLlmPlugin providing mock embedder and chat completer');
579
- }
580
- }
581
- }
582
- };
583
- const testPreset = {
584
- extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
585
- plugins: [
586
- vector_codec_1.VectorCodecPlugin,
587
- unifiedPlugin,
588
- smartTagsPlugin,
589
- mockLlmPlugin,
590
- (0, text_search_plugin_1.createLlmTextSearchPlugin)(),
591
- (0, text_mutation_plugin_1.createLlmTextMutationPlugin)(),
592
- (0, rag_plugin_1.createLlmRagPlugin)()
593
- ]
594
- };
595
- const connections = await (0, graphile_test_1.getConnections)({
596
- schemas: ['llm_test'],
597
- preset: testPreset,
598
- useRoot: true,
599
- authRole: 'postgres'
600
- }, [graphile_test_1.seed.sqlfile([(0, path_1.join)(__dirname, './setup.sql')])]);
601
- db = connections.db;
602
- teardown = connections.teardown;
603
- query = connections.query;
604
- });
605
- afterAll(async () => {
606
- if (teardown) {
607
- await teardown();
608
- }
609
- });
610
- beforeEach(async () => {
611
- await db.beforeEach();
612
- });
613
- afterEach(async () => {
614
- await db.afterEach();
615
- });
616
- it('adds ragQuery field to the Query type', async () => {
617
- const result = await query(`
618
- query {
619
- __type(name: "Query") {
620
- fields {
621
- name
622
- }
623
- }
624
- }
625
- `);
626
- expect(result.errors).toBeUndefined();
627
- const queryType = result.data?.__type;
628
- expect(queryType).toBeDefined();
629
- const fieldNames = queryType.fields.map((f) => f.name);
630
- expect(fieldNames).toContain('ragQuery');
631
- });
632
- it('adds embedText field to the Query type', async () => {
633
- const result = await query(`
634
- query {
635
- __type(name: "Query") {
636
- fields {
637
- name
638
- }
639
- }
640
- }
641
- `);
642
- expect(result.errors).toBeUndefined();
643
- const fieldNames = result.data.__type.fields.map((f) => f.name);
644
- expect(fieldNames).toContain('embedText');
645
- });
646
- it('ragQuery returns RagResponse type with answer and sources', async () => {
647
- const result = await query(`
648
- query {
649
- __type(name: "RagResponse") {
650
- fields {
651
- name
652
- type { name kind }
653
- }
654
- }
655
- }
656
- `);
657
- expect(result.errors).toBeUndefined();
658
- const ragResponseType = result.data?.__type;
659
- expect(ragResponseType).toBeDefined();
660
- const fieldNames = ragResponseType.fields.map((f) => f.name);
661
- expect(fieldNames).toContain('answer');
662
- expect(fieldNames).toContain('sources');
663
- expect(fieldNames).toContain('tokensUsed');
664
- });
665
- it('ragQuery executes and returns mock answer with sources', async () => {
666
- const result = await query(`
667
- query {
668
- ragQuery(prompt: "What is machine learning?", contextLimit: 3) {
669
- answer
670
- sources {
671
- content
672
- similarity
673
- tableName
674
- parentId
675
- }
676
- tokensUsed
677
- }
678
- }
679
- `);
680
- expect(result.errors).toBeUndefined();
681
- const rag = result.data?.ragQuery;
682
- expect(rag).toBeDefined();
683
- // Mock chat completer returns a canned response
684
- expect(rag.answer).toContain('Mock answer for');
685
- expect(rag.answer).toContain('What is machine learning?');
686
- // Sources should come from the chunks table
687
- expect(rag.sources.length).toBeGreaterThan(0);
688
- expect(rag.sources.length).toBeLessThanOrEqual(3);
689
- for (const source of rag.sources) {
690
- expect(typeof source.content).toBe('string');
691
- expect(source.content.length).toBeGreaterThan(0);
692
- expect(typeof source.similarity).toBe('number');
693
- expect(source.similarity).toBeGreaterThanOrEqual(0);
694
- expect(source.similarity).toBeLessThanOrEqual(1);
695
- expect(source.tableName).toBe('articles');
696
- expect(source.parentId).toBeTruthy();
697
- }
698
- });
699
- it('embedText executes and returns vector with dimensions', async () => {
700
- const result = await query(`
701
- query {
702
- embedText(text: "test embedding") {
703
- vector
704
- dimensions
705
- }
706
- }
707
- `);
708
- expect(result.errors).toBeUndefined();
709
- const embed = result.data?.embedText;
710
- expect(embed).toBeDefined();
711
- // Mock embedder returns [1, 0, 0]
712
- expect(embed.vector).toEqual([1, 0, 0]);
713
- expect(embed.dimensions).toBe(3);
714
- });
715
- });
716
- // =============================================================================
717
- // Suite 6: GraphileLlmPreset toggle tests
718
- // =============================================================================
719
- describe('GraphileLlmPreset toggles', () => {
720
- it('enableRag=false excludes RAG plugin (no ragQuery field)', async () => {
721
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
722
- const preset = GraphileLlmPreset({
723
- enableRag: false
724
- });
725
- const pluginNames = preset.plugins.map((p) => p.name);
726
- expect(pluginNames).not.toContain('LlmRagPlugin');
727
- });
728
- it('enableRag=true includes RAG plugin', async () => {
729
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
730
- const preset = GraphileLlmPreset({
731
- enableRag: true
732
- });
733
- const pluginNames = preset.plugins.map((p) => p.name);
734
- expect(pluginNames).toContain('LlmRagPlugin');
735
- });
736
- it('enableTextSearch=false excludes text search plugin', async () => {
737
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
738
- const preset = GraphileLlmPreset({
739
- enableTextSearch: false
740
- });
741
- const pluginNames = preset.plugins.map((p) => p.name);
742
- expect(pluginNames).not.toContain('LlmTextSearchPlugin');
743
- // Module plugin is always included
744
- expect(pluginNames).toContain('LlmModulePlugin');
745
- });
746
- it('enableTextMutations=false excludes text mutation plugin', async () => {
747
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
748
- const preset = GraphileLlmPreset({
749
- enableTextMutations: false
750
- });
751
- const pluginNames = preset.plugins.map((p) => p.name);
752
- expect(pluginNames).not.toContain('LlmTextMutationPlugin');
753
- });
754
- it('all toggles false leaves only LlmModulePlugin', async () => {
755
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
756
- const preset = GraphileLlmPreset({
757
- enableTextSearch: false,
758
- enableTextMutations: false,
759
- enableRag: false
760
- });
761
- const pluginNames = preset.plugins.map((p) => p.name);
762
- expect(pluginNames).toEqual(['LlmModulePlugin']);
763
- });
764
- it('default options include text search and mutations but not RAG', async () => {
765
- const { GraphileLlmPreset } = await Promise.resolve().then(() => __importStar(require('../../src/preset')));
766
- const preset = GraphileLlmPreset();
767
- const pluginNames = preset.plugins.map((p) => p.name);
768
- expect(pluginNames).toContain('LlmModulePlugin');
769
- expect(pluginNames).toContain('LlmTextSearchPlugin');
770
- expect(pluginNames).toContain('LlmTextMutationPlugin');
771
- expect(pluginNames).not.toContain('LlmRagPlugin');
772
- });
773
- });
774
- // =============================================================================
775
- // Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration
776
- //
777
- // End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims),
778
- // seeds the DB, then runs hybrid text+vector queries verifying RRF fusion,
779
- // semantic relevance, and the embedTextInWhere pipeline.
780
- //
781
- // Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text.
782
- // =============================================================================
783
- const INTEGRATION_ARTICLES = [
784
- { id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' },
785
- { id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' },
786
- { id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' },
787
- { id: 4, title: 'Gardening Tips for Spring', body: 'Start your spring garden by preparing soil, choosing the right seeds, and ensuring proper sunlight and watering.' },
788
- { id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' },
789
- ];
790
- const INTEGRATION_SETUP_SQL = `
791
- CREATE EXTENSION IF NOT EXISTS vector;
792
- CREATE SCHEMA IF NOT EXISTS llm_integration;
793
-
794
- CREATE TABLE llm_integration.articles (
795
- id serial PRIMARY KEY,
796
- title text NOT NULL,
797
- body text NOT NULL,
798
- tsv tsvector GENERATED ALWAYS AS (
799
- setweight(to_tsvector('english', title), 'A') ||
800
- setweight(to_tsvector('english', body), 'B')
801
- ) STORED,
802
- embedding vector(768) NOT NULL
803
- );
804
-
805
- CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv);
806
- CREATE INDEX idx_int_articles_embedding ON llm_integration.articles
807
- USING hnsw(embedding vector_cosine_ops);
808
- `;
809
- describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => {
810
- let db;
811
- let teardown;
812
- let query;
813
- let embedder;
814
- // Pre-computed query vectors (embedded once in beforeAll)
815
- let mlQueryVector;
816
- let cookingQueryVector;
817
- beforeAll(async () => {
818
- // Build a real Ollama embedder
819
- const built = (0, embedder_1.buildEmbedder)({
820
- provider: 'ollama',
821
- model: 'nomic-embed-text',
822
- baseUrl: 'http://localhost:11434',
823
- });
824
- if (!built)
825
- throw new Error('Failed to build Ollama embedder');
826
- embedder = built;
827
- await ensureNomicModel();
828
- // Embed all article bodies in parallel
829
- const embeddings = await Promise.all(INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)));
830
- // Build seed SQL with real 768-dim embeddings
831
- const values = INTEGRATION_ARTICLES.map((a, i) => {
832
- const vecStr = `'[${embeddings[i].embedding.join(',')}]'`;
833
- const titleEsc = a.title.replace(/'/g, "''");
834
- const bodyEsc = a.body.replace(/'/g, "''");
835
- return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`;
836
- }).join(',\n ');
837
- const seedSql = `
838
- INSERT INTO llm_integration.articles (id, title, body, embedding)
839
- VALUES ${values};
840
- SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length});
841
- `;
842
- // Pre-compute search query vectors
843
- const [mlResult, cookResult] = await Promise.all([
844
- embedder('machine learning artificial intelligence algorithms'),
845
- embedder('Italian cooking pasta recipes tomatoes'),
846
- ]);
847
- mlQueryVector = mlResult.embedding;
848
- cookingQueryVector = cookResult.embedding;
849
- // Set up PostGraphile with tsvector + pgvector adapters + LLM plugin
850
- const unifiedPlugin = (0, plugin_1.createUnifiedSearchPlugin)({
851
- adapters: [
852
- (0, tsvector_1.createTsvectorAdapter)(),
853
- (0, pgvector_1.createPgvectorAdapter)(),
854
- ],
855
- enableSearchScore: true,
856
- enableUnifiedSearch: true,
857
- rrfK: 60,
858
- });
859
- const testPreset = {
860
- extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
861
- plugins: [
862
- tsvector_codec_1.TsvectorCodecPlugin,
863
- vector_codec_1.VectorCodecPlugin,
864
- unifiedPlugin,
865
- (0, llm_module_plugin_1.createLlmModulePlugin)({
866
- defaultEmbedder: {
867
- provider: 'ollama',
868
- model: 'nomic-embed-text',
869
- baseUrl: 'http://localhost:11434',
870
- },
871
- }),
872
- (0, text_search_plugin_1.createLlmTextSearchPlugin)(),
873
- ],
874
- };
875
- const connections = await (0, graphile_test_1.getConnections)({
876
- schemas: ['llm_integration'],
877
- preset: testPreset,
878
- useRoot: true,
879
- authRole: 'postgres',
880
- }, [
881
- graphile_test_1.seed.fn(async (ctx) => {
882
- await ctx.pg.query(INTEGRATION_SETUP_SQL);
883
- await ctx.pg.query(seedSql);
884
- }),
885
- ]);
886
- db = connections.db;
887
- teardown = connections.teardown;
888
- query = connections.query;
889
- await db.client.query('BEGIN');
890
- }, 120_000);
891
- afterAll(async () => {
892
- if (db) {
893
- try {
894
- await db.client.query('ROLLBACK');
895
- }
896
- catch {
897
- // ignore
898
- }
899
- }
900
- if (teardown)
901
- await teardown();
902
- });
903
- beforeEach(async () => { await db.beforeEach(); });
904
- afterEach(async () => { await db.afterEach(); });
905
- // ─── embedTextInWhere with real Ollama ──────────────────────────────────
906
- it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => {
907
- const realEmbedder = async (text) => {
908
- const result = await embedder(text);
909
- return result.embedding;
910
- };
911
- const where = { unifiedSearch: 'machine learning algorithms' };
912
- await (0, text_search_plugin_1.embedTextInWhere)(where, realEmbedder, true);
913
- expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms');
914
- expect(where.unifiedSearch).toHaveProperty('__vector');
915
- expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true);
916
- expect(where.unifiedSearch.__vector.length).toBe(768);
917
- for (const v of where.unifiedSearch.__vector) {
918
- expect(typeof v).toBe('number');
919
- expect(Number.isFinite(v)).toBe(true);
920
- }
921
- }, 30_000);
922
- // ─── unifiedSearch text-only (no vector injection) ─────────────────────
923
- it('unifiedSearch text-only uses tsvector without pgvector', async () => {
924
- const result = await query(`
925
- query {
926
- allArticles(where: { unifiedSearch: "machine learning" }) {
927
- nodes { rowId title tsvRank embeddingVectorDistance searchScore }
928
- }
929
- }
930
- `);
931
- expect(result.errors).toBeUndefined();
932
- const nodes = result.data?.allArticles?.nodes ?? [];
933
- expect(nodes.length).toBeGreaterThan(0);
934
- // tsvector should be active
935
- const hasTsv = nodes.some((n) => n.tsvRank !== null);
936
- expect(hasTsv).toBe(true);
937
- // pgvector should NOT participate (no vector injection in graphile-test)
938
- for (const node of nodes) {
939
- expect(node.embeddingVectorDistance).toBeNull();
940
- }
941
- });
942
- // ─── unifiedSearch + real pgvector: hybrid RRF ─────────────────────────
943
- it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => {
944
- const vecStr = `[${mlQueryVector.join(',')}]`;
945
- const result = await query(`
946
- query($vec: [Float!]!) {
947
- allArticles(where: {
948
- unifiedSearch: "machine learning"
949
- vectorEmbedding: { vector: $vec, metric: COSINE }
950
- }) {
951
- nodes { rowId title tsvRank embeddingVectorDistance searchScore }
952
- }
953
- }
954
- `, { vec: mlQueryVector });
955
- expect(result.errors).toBeUndefined();
956
- const nodes = result.data?.allArticles?.nodes ?? [];
957
- expect(nodes.length).toBeGreaterThan(0);
958
- // Both adapters should be active
959
- const hasTsv = nodes.some((n) => n.tsvRank !== null);
960
- const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null);
961
- expect(hasTsv).toBe(true);
962
- expect(hasVector).toBe(true);
963
- // searchScore must be normalized [0,1]
964
- for (const node of nodes) {
965
- expect(node.searchScore).toBeGreaterThanOrEqual(0);
966
- expect(node.searchScore).toBeLessThanOrEqual(1);
967
- }
968
- });
969
- // ─── Semantic relevance with real embeddings ───────────────────────────
970
- it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => {
971
- const result = await query(`
972
- query($vec: [Float!]!) {
973
- allArticles(where: {
974
- unifiedSearch: "machine learning"
975
- vectorEmbedding: { vector: $vec, metric: COSINE }
976
- }) {
977
- nodes { rowId title searchScore }
978
- }
979
- }
980
- `, { vec: mlQueryVector });
981
- expect(result.errors).toBeUndefined();
982
- const nodes = result.data?.allArticles?.nodes ?? [];
983
- // ML-related articles (1, 3, 5) should score higher than non-ML (2, 4)
984
- const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId));
985
- const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId));
986
- if (mlArticles.length > 0 && nonMlArticles.length > 0) {
987
- const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore));
988
- const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore));
989
- expect(bestMlScore).toBeGreaterThan(bestNonMlScore);
990
- }
991
- });
992
- it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => {
993
- const result = await query(`
994
- query($vec: [Float!]!) {
995
- allArticles(where: {
996
- unifiedSearch: "Italian pasta cooking"
997
- vectorEmbedding: { vector: $vec, metric: COSINE }
998
- }) {
999
- nodes { rowId title searchScore }
1000
- }
1001
- }
1002
- `, { vec: cookingQueryVector });
1003
- expect(result.errors).toBeUndefined();
1004
- const nodes = result.data?.allArticles?.nodes ?? [];
1005
- // Cooking article (id=2) should rank highest
1006
- const cookingArticle = nodes.find((n) => n.rowId === 2);
1007
- const otherArticles = nodes.filter((n) => n.rowId !== 2);
1008
- if (cookingArticle && otherArticles.length > 0) {
1009
- const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore));
1010
- expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore);
1011
- }
1012
- });
1013
- // ─── Vector-only search with real embeddings ───────────────────────────
1014
- it('VectorNearbyInput with real embedding returns semantically relevant results', async () => {
1015
- const result = await query(`
1016
- query($vec: [Float!]!) {
1017
- allArticles(where: {
1018
- vectorEmbedding: { vector: $vec, metric: COSINE }
1019
- }) {
1020
- nodes { rowId title embeddingVectorDistance }
1021
- }
1022
- }
1023
- `, { vec: mlQueryVector });
1024
- expect(result.errors).toBeUndefined();
1025
- const nodes = result.data?.allArticles?.nodes ?? [];
1026
- expect(nodes.length).toBe(5);
1027
- // Nearest to ML query should be ML articles
1028
- const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance);
1029
- // Top result should be an ML-related article (1, 3, or 5)
1030
- expect([1, 3, 5]).toContain(sorted[0].rowId);
1031
- });
1032
- // ─── RRF boost: text+vector scores higher than text-only ───────────────
1033
- it('hybrid text+vector search scores higher than text-only for relevant docs', async () => {
1034
- const textOnly = await query(`
1035
- query {
1036
- allArticles(where: { unifiedSearch: "machine learning" }) {
1037
- nodes { rowId searchScore }
1038
- }
1039
- }
1040
- `);
1041
- const hybrid = await query(`
1042
- query($vec: [Float!]!) {
1043
- allArticles(where: {
1044
- unifiedSearch: "machine learning"
1045
- vectorEmbedding: { vector: $vec, metric: COSINE }
1046
- }) {
1047
- nodes { rowId searchScore }
1048
- }
1049
- }
1050
- `, { vec: mlQueryVector });
1051
- expect(textOnly.errors).toBeUndefined();
1052
- expect(hybrid.errors).toBeUndefined();
1053
- const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
1054
- const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
1055
- // Doc 1 (ML article) should score at least as high with hybrid
1056
- if (doc1TextOnly && doc1Hybrid) {
1057
- expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore);
1058
- }
1059
- });
1060
- });