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.
- package/LICENSE +23 -0
- package/README.md +193 -0
- package/__tests__/graphile-llm.test.d.ts +1 -0
- package/__tests__/graphile-llm.test.js +721 -0
- package/chat.d.ts +37 -0
- package/chat.js +105 -0
- package/embedder.d.ts +35 -0
- package/embedder.js +79 -0
- package/esm/__tests__/graphile-llm.test.d.ts +1 -0
- package/esm/__tests__/graphile-llm.test.js +683 -0
- package/esm/chat.d.ts +37 -0
- package/esm/chat.js +97 -0
- package/esm/embedder.d.ts +35 -0
- package/esm/embedder.js +71 -0
- package/esm/index.d.ts +39 -0
- package/esm/index.js +42 -0
- package/esm/plugins/llm-module-plugin.d.ts +38 -0
- package/esm/plugins/llm-module-plugin.js +82 -0
- package/esm/plugins/rag-plugin.d.ts +36 -0
- package/esm/plugins/rag-plugin.js +341 -0
- package/esm/plugins/text-mutation-plugin.d.ts +44 -0
- package/esm/plugins/text-mutation-plugin.js +191 -0
- package/esm/plugins/text-search-plugin.d.ts +41 -0
- package/esm/plugins/text-search-plugin.js +163 -0
- package/esm/preset.d.ts +55 -0
- package/esm/preset.js +74 -0
- package/esm/types.d.ts +173 -0
- package/esm/types.js +6 -0
- package/index.d.ts +39 -0
- package/index.js +56 -0
- package/package.json +76 -0
- package/plugins/llm-module-plugin.d.ts +38 -0
- package/plugins/llm-module-plugin.js +85 -0
- package/plugins/rag-plugin.d.ts +36 -0
- package/plugins/rag-plugin.js +344 -0
- package/plugins/text-mutation-plugin.d.ts +44 -0
- package/plugins/text-mutation-plugin.js +194 -0
- package/plugins/text-search-plugin.d.ts +41 -0
- package/plugins/text-search-plugin.js +166 -0
- package/preset.d.ts +55 -0
- package/preset.js +77 -0
- package/types.d.ts +173 -0
- 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
|
+
});
|