graphile-llm 0.11.5 → 0.12.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 (41) hide show
  1. package/__tests__/graphile-llm.test.js +318 -37
  2. package/__tests__/unified-search-embedding.test.d.ts +10 -0
  3. package/__tests__/unified-search-embedding.test.js +176 -0
  4. package/embedder.d.ts +24 -4
  5. package/embedder.js +36 -7
  6. package/env.d.ts +6 -28
  7. package/env.js +10 -47
  8. package/esm/__tests__/graphile-llm.test.js +319 -38
  9. package/esm/__tests__/unified-search-embedding.test.d.ts +10 -0
  10. package/esm/__tests__/unified-search-embedding.test.js +174 -0
  11. package/esm/embedder.d.ts +24 -4
  12. package/esm/embedder.js +35 -7
  13. package/esm/env.d.ts +6 -28
  14. package/esm/env.js +5 -46
  15. package/esm/index.d.ts +5 -4
  16. package/esm/index.js +3 -4
  17. package/esm/metering.d.ts +1 -3
  18. package/esm/metering.js +48 -108
  19. package/esm/plugins/llm-module-plugin.d.ts +9 -4
  20. package/esm/plugins/llm-module-plugin.js +9 -4
  21. package/esm/plugins/rag-plugin.js +30 -20
  22. package/esm/plugins/text-mutation-plugin.js +15 -2
  23. package/esm/plugins/text-search-plugin.d.ts +15 -3
  24. package/esm/plugins/text-search-plugin.js +94 -23
  25. package/esm/preset.d.ts +13 -2
  26. package/esm/preset.js +15 -4
  27. package/esm/types.d.ts +13 -0
  28. package/index.d.ts +5 -4
  29. package/index.js +6 -2
  30. package/metering.d.ts +1 -3
  31. package/metering.js +48 -108
  32. package/package.json +6 -4
  33. package/plugins/llm-module-plugin.d.ts +9 -4
  34. package/plugins/llm-module-plugin.js +9 -4
  35. package/plugins/rag-plugin.js +30 -20
  36. package/plugins/text-mutation-plugin.js +15 -2
  37. package/plugins/text-search-plugin.d.ts +15 -3
  38. package/plugins/text-search-plugin.js +95 -23
  39. package/preset.d.ts +13 -2
  40. package/preset.js +15 -4
  41. package/types.d.ts +13 -0
@@ -39,6 +39,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  const ollama_1 = __importDefault(require("@agentic-kit/ollama"));
40
40
  const graphile_connection_filter_1 = require("graphile-connection-filter");
41
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");
42
44
  const vector_codec_1 = require("graphile-search/codecs/vector-codec");
43
45
  const plugin_1 = require("graphile-search/plugin");
44
46
  const graphile_test_1 = require("graphile-test");
@@ -235,10 +237,10 @@ describe('graphile-llm schema enrichment', () => {
235
237
  });
236
238
  // ─── Mutation text companion fields ──────────────────────────────────────
237
239
  describe('Mutation text companion fields', () => {
238
- it('adds embeddingText field to CreateArticleInput', async () => {
240
+ it('adds embeddingText field to ArticleInput (create input)', async () => {
239
241
  const result = await query(`
240
242
  query {
241
- __type(name: "CreateArticleInput") {
243
+ __type(name: "ArticleInput") {
242
244
  inputFields {
243
245
  name
244
246
  type { name }
@@ -250,9 +252,7 @@ describe('graphile-llm schema enrichment', () => {
250
252
  const inputType = result.data?.__type;
251
253
  expect(inputType).toBeDefined();
252
254
  const fieldNames = inputType.inputFields.map((f) => f.name);
253
- // Original embedding field
254
255
  expect(fieldNames).toContain('embedding');
255
- // Companion text field from LlmTextMutationPlugin
256
256
  expect(fieldNames).toContain('embeddingText');
257
257
  const textField = inputType.inputFields.find((f) => f.name === 'embeddingText');
258
258
  expect(textField).toBeDefined();
@@ -461,12 +461,16 @@ function makeTestSmartTagsPlugin(tagsByTable) {
461
461
  return {
462
462
  name: 'TestSmartTagsPlugin',
463
463
  version: '1.0.0',
464
+ before: ['LlmRagPlugin'],
464
465
  schema: {
465
466
  hooks: {
466
- init: {
467
- before: ['UnifiedSearchPlugin', 'LlmRagPlugin'],
468
- callback(_, build) {
469
- for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
467
+ build: {
468
+ before: ['LlmRagPlugin'],
469
+ callback(build) {
470
+ const pgRegistry = build.input?.pgRegistry ?? build.pgRegistry;
471
+ if (!pgRegistry)
472
+ return build;
473
+ for (const codec of Object.values(pgRegistry.pgCodecs || {})) {
470
474
  const c = codec;
471
475
  if (!c.attributes || !c.name)
472
476
  continue;
@@ -479,7 +483,7 @@ function makeTestSmartTagsPlugin(tagsByTable) {
479
483
  c.extensions.tags = {};
480
484
  Object.assign(c.extensions.tags, tags);
481
485
  }
482
- return _;
486
+ return build;
483
487
  }
484
488
  }
485
489
  }
@@ -518,47 +522,39 @@ describe('RAG plugin schema enrichment', () => {
518
522
  usage: { input: 10, output: 15, reasoning: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 25 }
519
523
  };
520
524
  };
525
+ // Provide mock embedder/chat directly instead of using createLlmModulePlugin
526
+ // to avoid build.extend naming conflicts when overriding
527
+ const mockLlmPlugin = {
528
+ name: 'TestMockLlmPlugin',
529
+ version: '1.0.0',
530
+ schema: {
531
+ hooks: {
532
+ build(build) {
533
+ return build.extend(build, {
534
+ llmEmbedder: mockEmbedder,
535
+ llmChatCompleter: mockChatCompleter,
536
+ llmEmbeddingModel: 'test-mock-model',
537
+ llmChatModel: 'test-mock-chat'
538
+ }, 'TestMockLlmPlugin providing mock embedder and chat completer');
539
+ }
540
+ }
541
+ }
542
+ };
521
543
  const testPreset = {
522
544
  extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
523
545
  plugins: [
524
546
  vector_codec_1.VectorCodecPlugin,
525
547
  unifiedPlugin,
526
548
  smartTagsPlugin,
527
- (0, llm_module_plugin_1.createLlmModulePlugin)({
528
- defaultEmbedder: {
529
- provider: 'ollama',
530
- model: 'nomic-embed-text',
531
- baseUrl: 'http://localhost:11434'
532
- }
533
- }),
549
+ mockLlmPlugin,
534
550
  (0, text_search_plugin_1.createLlmTextSearchPlugin)(),
535
551
  (0, text_mutation_plugin_1.createLlmTextMutationPlugin)(),
536
552
  (0, rag_plugin_1.createLlmRagPlugin)()
537
553
  ]
538
554
  };
539
- // Override the embedder and chat completer on the build context
540
- // by wrapping the LlmModulePlugin's build hook
541
- const overridePlugin = {
542
- name: 'TestOverridePlugin',
543
- version: '1.0.0',
544
- after: ['LlmModulePlugin'],
545
- schema: {
546
- hooks: {
547
- build(build) {
548
- return build.extend(build, {
549
- llmEmbedder: mockEmbedder,
550
- llmChatCompleter: mockChatCompleter
551
- }, 'TestOverridePlugin overriding embedder and chat completer');
552
- }
553
- }
554
- }
555
- };
556
555
  const connections = await (0, graphile_test_1.getConnections)({
557
556
  schemas: ['llm_test'],
558
- preset: {
559
- ...testPreset,
560
- plugins: [...testPreset.plugins, overridePlugin]
561
- },
557
+ preset: testPreset,
562
558
  useRoot: true,
563
559
  authRole: 'postgres'
564
560
  }, [graphile_test_1.seed.sqlfile([(0, path_1.join)(__dirname, './setup.sql')])]);
@@ -735,3 +731,288 @@ describe('GraphileLlmPreset toggles', () => {
735
731
  expect(pluginNames).not.toContain('LlmRagPlugin');
736
732
  });
737
733
  });
734
+ // =============================================================================
735
+ // Suite 7: Real Ollama + unifiedSearch + pgvector RRF integration
736
+ //
737
+ // End-to-end: embeds article text with real Ollama nomic-embed-text (768 dims),
738
+ // seeds the DB, then runs hybrid text+vector queries verifying RRF fusion,
739
+ // semantic relevance, and the embedTextInWhere pipeline.
740
+ //
741
+ // Requires: PostgreSQL with pgvector + Ollama with nomic-embed-text.
742
+ // =============================================================================
743
+ const INTEGRATION_ARTICLES = [
744
+ { id: 1, title: 'Introduction to Machine Learning', body: 'Machine learning is a subset of artificial intelligence that focuses on algorithms and statistical models.' },
745
+ { id: 2, title: 'Cooking Italian Pasta', body: 'The best Italian pasta is made with fresh ingredients like San Marzano tomatoes and homemade egg noodles.' },
746
+ { id: 3, title: 'Deep Learning with Neural Networks', body: 'Deep learning uses neural networks with multiple layers to learn hierarchical representations of data.' },
747
+ { 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.' },
748
+ { id: 5, title: 'Natural Language Processing', body: 'NLP combines computational linguistics with machine learning to enable computers to understand human language.' },
749
+ ];
750
+ const INTEGRATION_SETUP_SQL = `
751
+ CREATE EXTENSION IF NOT EXISTS vector;
752
+ CREATE SCHEMA IF NOT EXISTS llm_integration;
753
+
754
+ CREATE TABLE llm_integration.articles (
755
+ id serial PRIMARY KEY,
756
+ title text NOT NULL,
757
+ body text NOT NULL,
758
+ tsv tsvector GENERATED ALWAYS AS (
759
+ setweight(to_tsvector('english', title), 'A') ||
760
+ setweight(to_tsvector('english', body), 'B')
761
+ ) STORED,
762
+ embedding vector(768) NOT NULL
763
+ );
764
+
765
+ CREATE INDEX idx_int_articles_tsv ON llm_integration.articles USING gin(tsv);
766
+ CREATE INDEX idx_int_articles_embedding ON llm_integration.articles
767
+ USING hnsw(embedding vector_cosine_ops);
768
+ `;
769
+ describe('Real Ollama + unifiedSearch + pgvector RRF integration', () => {
770
+ let db;
771
+ let teardown;
772
+ let query;
773
+ let embedder;
774
+ // Pre-computed query vectors (embedded once in beforeAll)
775
+ let mlQueryVector;
776
+ let cookingQueryVector;
777
+ beforeAll(async () => {
778
+ // Build a real Ollama embedder
779
+ const built = (0, embedder_1.buildEmbedder)({
780
+ provider: 'ollama',
781
+ model: 'nomic-embed-text',
782
+ baseUrl: 'http://localhost:11434',
783
+ });
784
+ if (!built)
785
+ throw new Error('Failed to build Ollama embedder');
786
+ embedder = built;
787
+ await ensureNomicModel();
788
+ // Embed all article bodies in parallel
789
+ const embeddings = await Promise.all(INTEGRATION_ARTICLES.map((a) => embedder(`${a.title}. ${a.body}`)));
790
+ // Build seed SQL with real 768-dim embeddings
791
+ const values = INTEGRATION_ARTICLES.map((a, i) => {
792
+ const vecStr = `'[${embeddings[i].embedding.join(',')}]'`;
793
+ const titleEsc = a.title.replace(/'/g, "''");
794
+ const bodyEsc = a.body.replace(/'/g, "''");
795
+ return `(${a.id}, '${titleEsc}', '${bodyEsc}', ${vecStr})`;
796
+ }).join(',\n ');
797
+ const seedSql = `
798
+ INSERT INTO llm_integration.articles (id, title, body, embedding)
799
+ VALUES ${values};
800
+ SELECT setval('llm_integration.articles_id_seq', ${INTEGRATION_ARTICLES.length});
801
+ `;
802
+ // Pre-compute search query vectors
803
+ const [mlResult, cookResult] = await Promise.all([
804
+ embedder('machine learning artificial intelligence algorithms'),
805
+ embedder('Italian cooking pasta recipes tomatoes'),
806
+ ]);
807
+ mlQueryVector = mlResult.embedding;
808
+ cookingQueryVector = cookResult.embedding;
809
+ // Set up PostGraphile with tsvector + pgvector adapters + LLM plugin
810
+ const unifiedPlugin = (0, plugin_1.createUnifiedSearchPlugin)({
811
+ adapters: [
812
+ (0, tsvector_1.createTsvectorAdapter)(),
813
+ (0, pgvector_1.createPgvectorAdapter)(),
814
+ ],
815
+ enableSearchScore: true,
816
+ enableUnifiedSearch: true,
817
+ rrfK: 60,
818
+ });
819
+ const testPreset = {
820
+ extends: [(0, graphile_connection_filter_1.ConnectionFilterPreset)()],
821
+ plugins: [
822
+ tsvector_codec_1.TsvectorCodecPlugin,
823
+ vector_codec_1.VectorCodecPlugin,
824
+ unifiedPlugin,
825
+ (0, llm_module_plugin_1.createLlmModulePlugin)({
826
+ defaultEmbedder: {
827
+ provider: 'ollama',
828
+ model: 'nomic-embed-text',
829
+ baseUrl: 'http://localhost:11434',
830
+ },
831
+ }),
832
+ (0, text_search_plugin_1.createLlmTextSearchPlugin)(),
833
+ ],
834
+ };
835
+ const connections = await (0, graphile_test_1.getConnections)({
836
+ schemas: ['llm_integration'],
837
+ preset: testPreset,
838
+ useRoot: true,
839
+ authRole: 'postgres',
840
+ }, [
841
+ graphile_test_1.seed.fn(async (ctx) => {
842
+ await ctx.pg.query(INTEGRATION_SETUP_SQL);
843
+ await ctx.pg.query(seedSql);
844
+ }),
845
+ ]);
846
+ db = connections.db;
847
+ teardown = connections.teardown;
848
+ query = connections.query;
849
+ await db.client.query('BEGIN');
850
+ }, 120_000);
851
+ afterAll(async () => {
852
+ if (db) {
853
+ try {
854
+ await db.client.query('ROLLBACK');
855
+ }
856
+ catch { }
857
+ }
858
+ if (teardown)
859
+ await teardown();
860
+ });
861
+ beforeEach(async () => { await db.beforeEach(); });
862
+ afterEach(async () => { await db.afterEach(); });
863
+ // ─── embedTextInWhere with real Ollama ──────────────────────────────────
864
+ it('embedTextInWhere transforms unifiedSearch with real 768-dim Ollama vector', async () => {
865
+ const realEmbedder = async (text) => {
866
+ const result = await embedder(text);
867
+ return result.embedding;
868
+ };
869
+ const where = { unifiedSearch: 'machine learning algorithms' };
870
+ await (0, text_search_plugin_1.embedTextInWhere)(where, realEmbedder, true);
871
+ expect(where.unifiedSearch).toHaveProperty('__text', 'machine learning algorithms');
872
+ expect(where.unifiedSearch).toHaveProperty('__vector');
873
+ expect(Array.isArray(where.unifiedSearch.__vector)).toBe(true);
874
+ expect(where.unifiedSearch.__vector.length).toBe(768);
875
+ for (const v of where.unifiedSearch.__vector) {
876
+ expect(typeof v).toBe('number');
877
+ expect(Number.isFinite(v)).toBe(true);
878
+ }
879
+ }, 30_000);
880
+ // ─── unifiedSearch text-only (no vector injection) ─────────────────────
881
+ it('unifiedSearch text-only uses tsvector without pgvector', async () => {
882
+ const result = await query(`
883
+ query {
884
+ allArticles(where: { unifiedSearch: "machine learning" }) {
885
+ nodes { rowId title tsvRank embeddingVectorDistance searchScore }
886
+ }
887
+ }
888
+ `);
889
+ expect(result.errors).toBeUndefined();
890
+ const nodes = result.data?.allArticles?.nodes ?? [];
891
+ expect(nodes.length).toBeGreaterThan(0);
892
+ // tsvector should be active
893
+ const hasTsv = nodes.some((n) => n.tsvRank !== null);
894
+ expect(hasTsv).toBe(true);
895
+ // pgvector should NOT participate (no vector injection in graphile-test)
896
+ for (const node of nodes) {
897
+ expect(node.embeddingVectorDistance).toBeNull();
898
+ }
899
+ });
900
+ // ─── unifiedSearch + real pgvector: hybrid RRF ─────────────────────────
901
+ it('unifiedSearch + real vector embedding fuses tsvector + pgvector via RRF', async () => {
902
+ const vecStr = `[${mlQueryVector.join(',')}]`;
903
+ const result = await query(`
904
+ query($vec: [Float!]!) {
905
+ allArticles(where: {
906
+ unifiedSearch: "machine learning"
907
+ vectorEmbedding: { vector: $vec, metric: COSINE }
908
+ }) {
909
+ nodes { rowId title tsvRank embeddingVectorDistance searchScore }
910
+ }
911
+ }
912
+ `, { vec: mlQueryVector });
913
+ expect(result.errors).toBeUndefined();
914
+ const nodes = result.data?.allArticles?.nodes ?? [];
915
+ expect(nodes.length).toBeGreaterThan(0);
916
+ // Both adapters should be active
917
+ const hasTsv = nodes.some((n) => n.tsvRank !== null);
918
+ const hasVector = nodes.some((n) => n.embeddingVectorDistance !== null);
919
+ expect(hasTsv).toBe(true);
920
+ expect(hasVector).toBe(true);
921
+ // searchScore must be normalized [0,1]
922
+ for (const node of nodes) {
923
+ expect(node.searchScore).toBeGreaterThanOrEqual(0);
924
+ expect(node.searchScore).toBeLessThanOrEqual(1);
925
+ }
926
+ });
927
+ // ─── Semantic relevance with real embeddings ───────────────────────────
928
+ it('ML query ranks ML articles higher than cooking/gardening with real embeddings', async () => {
929
+ const result = await query(`
930
+ query($vec: [Float!]!) {
931
+ allArticles(where: {
932
+ unifiedSearch: "machine learning"
933
+ vectorEmbedding: { vector: $vec, metric: COSINE }
934
+ }) {
935
+ nodes { rowId title searchScore }
936
+ }
937
+ }
938
+ `, { vec: mlQueryVector });
939
+ expect(result.errors).toBeUndefined();
940
+ const nodes = result.data?.allArticles?.nodes ?? [];
941
+ // ML-related articles (1, 3, 5) should score higher than non-ML (2, 4)
942
+ const mlArticles = nodes.filter((n) => [1, 3, 5].includes(n.rowId));
943
+ const nonMlArticles = nodes.filter((n) => [2, 4].includes(n.rowId));
944
+ if (mlArticles.length > 0 && nonMlArticles.length > 0) {
945
+ const bestMlScore = Math.max(...mlArticles.map((n) => n.searchScore));
946
+ const bestNonMlScore = Math.max(...nonMlArticles.map((n) => n.searchScore));
947
+ expect(bestMlScore).toBeGreaterThan(bestNonMlScore);
948
+ }
949
+ });
950
+ it('cooking query ranks cooking article higher than ML articles with real embeddings', async () => {
951
+ const result = await query(`
952
+ query($vec: [Float!]!) {
953
+ allArticles(where: {
954
+ unifiedSearch: "Italian pasta cooking"
955
+ vectorEmbedding: { vector: $vec, metric: COSINE }
956
+ }) {
957
+ nodes { rowId title searchScore }
958
+ }
959
+ }
960
+ `, { vec: cookingQueryVector });
961
+ expect(result.errors).toBeUndefined();
962
+ const nodes = result.data?.allArticles?.nodes ?? [];
963
+ // Cooking article (id=2) should rank highest
964
+ const cookingArticle = nodes.find((n) => n.rowId === 2);
965
+ const otherArticles = nodes.filter((n) => n.rowId !== 2);
966
+ if (cookingArticle && otherArticles.length > 0) {
967
+ const bestOtherScore = Math.max(...otherArticles.map((n) => n.searchScore));
968
+ expect(cookingArticle.searchScore).toBeGreaterThan(bestOtherScore);
969
+ }
970
+ });
971
+ // ─── Vector-only search with real embeddings ───────────────────────────
972
+ it('VectorNearbyInput with real embedding returns semantically relevant results', async () => {
973
+ const result = await query(`
974
+ query($vec: [Float!]!) {
975
+ allArticles(where: {
976
+ vectorEmbedding: { vector: $vec, metric: COSINE }
977
+ }) {
978
+ nodes { rowId title embeddingVectorDistance }
979
+ }
980
+ }
981
+ `, { vec: mlQueryVector });
982
+ expect(result.errors).toBeUndefined();
983
+ const nodes = result.data?.allArticles?.nodes ?? [];
984
+ expect(nodes.length).toBe(5);
985
+ // Nearest to ML query should be ML articles
986
+ const sorted = [...nodes].sort((a, b) => a.embeddingVectorDistance - b.embeddingVectorDistance);
987
+ // Top result should be an ML-related article (1, 3, or 5)
988
+ expect([1, 3, 5]).toContain(sorted[0].rowId);
989
+ });
990
+ // ─── RRF boost: text+vector scores higher than text-only ───────────────
991
+ it('hybrid text+vector search scores higher than text-only for relevant docs', async () => {
992
+ const textOnly = await query(`
993
+ query {
994
+ allArticles(where: { unifiedSearch: "machine learning" }) {
995
+ nodes { rowId searchScore }
996
+ }
997
+ }
998
+ `);
999
+ const hybrid = await query(`
1000
+ query($vec: [Float!]!) {
1001
+ allArticles(where: {
1002
+ unifiedSearch: "machine learning"
1003
+ vectorEmbedding: { vector: $vec, metric: COSINE }
1004
+ }) {
1005
+ nodes { rowId searchScore }
1006
+ }
1007
+ }
1008
+ `, { vec: mlQueryVector });
1009
+ expect(textOnly.errors).toBeUndefined();
1010
+ expect(hybrid.errors).toBeUndefined();
1011
+ const doc1TextOnly = textOnly.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
1012
+ const doc1Hybrid = hybrid.data?.allArticles?.nodes?.find((n) => n.rowId === 1);
1013
+ // Doc 1 (ML article) should score at least as high with hybrid
1014
+ if (doc1TextOnly && doc1Hybrid) {
1015
+ expect(doc1Hybrid.searchScore).toBeGreaterThanOrEqual(doc1TextOnly.searchScore);
1016
+ }
1017
+ });
1018
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unit tests for LlmTextSearchPlugin's embedTextInWhere function.
3
+ *
4
+ * Tests the transformation logic:
5
+ * - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] }
6
+ * - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior)
7
+ *
8
+ * Pure unit tests — no database or Ollama required.
9
+ */
10
+ export {};
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * Unit tests for LlmTextSearchPlugin's embedTextInWhere function.
4
+ *
5
+ * Tests the transformation logic:
6
+ * - unifiedSearch: "text" → unifiedSearch: { __text: "text", __vector: [...] }
7
+ * - VectorNearbyInput.text → VectorNearbyInput.vector (existing behavior)
8
+ *
9
+ * Pure unit tests — no database or Ollama required.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const text_search_plugin_1 = require("../../src/plugins/text-search-plugin");
13
+ describe('unifiedSearch embedding integration', () => {
14
+ const mockVector = [0.1, 0.2, 0.3, 0.4, 0.5];
15
+ const mockEmbedder = jest.fn(async (_text) => mockVector);
16
+ const nullEmbedder = jest.fn(async (_text) => null);
17
+ beforeEach(() => {
18
+ mockEmbedder.mockClear();
19
+ nullEmbedder.mockClear();
20
+ });
21
+ describe('unifiedSearch text → { __text, __vector } transformation', () => {
22
+ it('transforms unifiedSearch string to object with __text and __vector', async () => {
23
+ const where = { unifiedSearch: 'HIPAA compliance' };
24
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
25
+ expect(where.unifiedSearch).toEqual({
26
+ __text: 'HIPAA compliance',
27
+ __vector: mockVector,
28
+ });
29
+ expect(mockEmbedder).toHaveBeenCalledWith('HIPAA compliance');
30
+ });
31
+ it('leaves unifiedSearch as plain string when embedder returns null (graceful degradation)', async () => {
32
+ const where = { unifiedSearch: 'database normalization' };
33
+ await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true);
34
+ // Should remain as string — text adapters handle it
35
+ expect(where.unifiedSearch).toBe('database normalization');
36
+ expect(nullEmbedder).toHaveBeenCalledWith('database normalization');
37
+ });
38
+ it('throws when embedder returns null and no text adapters available', async () => {
39
+ const where = { unifiedSearch: 'vector only query' };
40
+ await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, false)).rejects.toThrow('embedding failed');
41
+ });
42
+ it('does not embed empty unifiedSearch strings', async () => {
43
+ const where = { unifiedSearch: ' ' };
44
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
45
+ expect(where.unifiedSearch).toBe(' ');
46
+ expect(mockEmbedder).not.toHaveBeenCalled();
47
+ });
48
+ it('does not embed null unifiedSearch', async () => {
49
+ const where = { unifiedSearch: null };
50
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
51
+ expect(where.unifiedSearch).toBeNull();
52
+ expect(mockEmbedder).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+ describe('VectorNearbyInput text → vector (existing behavior)', () => {
56
+ it('transforms VectorNearbyInput text to vector', async () => {
57
+ const where = { vectorEmbedding: { text: 'semantic query' } };
58
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
59
+ expect(where.vectorEmbedding).toEqual({ vector: mockVector });
60
+ expect(mockEmbedder).toHaveBeenCalledWith('semantic query');
61
+ });
62
+ it('removes text field when embedder returns null', async () => {
63
+ const where = { vectorEmbedding: { text: 'failed query' } };
64
+ await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true);
65
+ expect(where.vectorEmbedding).toEqual({});
66
+ expect(nullEmbedder).toHaveBeenCalledWith('failed query');
67
+ });
68
+ it('does not modify VectorNearbyInput with existing vector', async () => {
69
+ const existingVector = [1, 2, 3];
70
+ const where = { vectorEmbedding: { vector: existingVector, text: 'ignored' } };
71
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
72
+ // text + vector present → not modified (vector takes precedence)
73
+ expect(where.vectorEmbedding.vector).toBe(existingVector);
74
+ expect(mockEmbedder).not.toHaveBeenCalled();
75
+ });
76
+ });
77
+ describe('combined unifiedSearch + VectorNearbyInput', () => {
78
+ it('embeds both unifiedSearch and VectorNearbyInput.text in parallel', async () => {
79
+ const where = {
80
+ unifiedSearch: 'hybrid search query',
81
+ vectorEmbedding: { text: 'vector part' },
82
+ };
83
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
84
+ expect(where.unifiedSearch).toEqual({
85
+ __text: 'hybrid search query',
86
+ __vector: mockVector,
87
+ });
88
+ expect(where.vectorEmbedding).toEqual({ vector: mockVector });
89
+ expect(mockEmbedder).toHaveBeenCalledTimes(2);
90
+ });
91
+ });
92
+ describe('nested filter structures (AND, OR)', () => {
93
+ it('handles unifiedSearch inside nested AND/OR filters', async () => {
94
+ const where = {
95
+ AND: [
96
+ { unifiedSearch: 'first query' },
97
+ { unifiedSearch: 'second query' },
98
+ ],
99
+ };
100
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true);
101
+ expect(where.AND[0].unifiedSearch).toEqual({
102
+ __text: 'first query',
103
+ __vector: mockVector,
104
+ });
105
+ expect(where.AND[1].unifiedSearch).toEqual({
106
+ __text: 'second query',
107
+ __vector: mockVector,
108
+ });
109
+ });
110
+ });
111
+ describe('onQuotaExceeded option', () => {
112
+ it('degrade mode (default): leaves unifiedSearch as string when embedder returns null', async () => {
113
+ const where = { unifiedSearch: 'some query' };
114
+ await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'degrade');
115
+ expect(where.unifiedSearch).toBe('some query');
116
+ });
117
+ it('throw mode: throws on unifiedSearch when embedder returns null even with text adapters', async () => {
118
+ const where = { unifiedSearch: 'some query' };
119
+ await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'throw')).rejects.toThrow('onQuotaExceeded is set to \'throw\'');
120
+ });
121
+ it('throw mode: throws on VectorNearbyInput.text when embedder returns null', async () => {
122
+ const where = { vectorEmbedding: { text: 'semantic query' } };
123
+ await expect((0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'throw')).rejects.toThrow('VectorNearbyInput: embedding failed');
124
+ });
125
+ it('degrade mode: silently removes VectorNearbyInput.text when embedder returns null', async () => {
126
+ const where = { vectorEmbedding: { text: 'failed query' } };
127
+ await (0, text_search_plugin_1.embedTextInWhere)(where, nullEmbedder, true, 'degrade');
128
+ expect(where.vectorEmbedding).toEqual({});
129
+ });
130
+ it('throw mode: still works normally when embedder succeeds', async () => {
131
+ const where = { unifiedSearch: 'good query' };
132
+ await (0, text_search_plugin_1.embedTextInWhere)(where, mockEmbedder, true, 'throw');
133
+ expect(where.unifiedSearch).toEqual({
134
+ __text: 'good query',
135
+ __vector: mockVector,
136
+ });
137
+ });
138
+ it('no text adapters: throws regardless of onQuotaExceeded setting', async () => {
139
+ const where1 = { unifiedSearch: 'query1' };
140
+ await expect((0, text_search_plugin_1.embedTextInWhere)(where1, nullEmbedder, false, 'degrade')).rejects.toThrow('No text search adapters available');
141
+ const where2 = { unifiedSearch: 'query2' };
142
+ await expect((0, text_search_plugin_1.embedTextInWhere)(where2, nullEmbedder, false, 'throw')).rejects.toThrow('No text search adapters available');
143
+ });
144
+ });
145
+ describe('apply function object shape handling', () => {
146
+ it('plugin.ts apply function handles { __text, __vector } correctly', () => {
147
+ // Simulate what the apply function does with the transformed value
148
+ const val = { __text: 'HIPAA compliance', __vector: [0.1, 0.2, 0.3] };
149
+ let text;
150
+ let vector = null;
151
+ if (typeof val === 'object' && val.__text) {
152
+ text = val.__text;
153
+ vector = val.__vector ?? null;
154
+ }
155
+ else {
156
+ text = typeof val === 'string' ? val : String(val);
157
+ }
158
+ expect(text).toBe('HIPAA compliance');
159
+ expect(vector).toEqual([0.1, 0.2, 0.3]);
160
+ });
161
+ it('plugin.ts apply function handles plain string correctly', () => {
162
+ const val = 'plain text search';
163
+ let text;
164
+ let vector = null;
165
+ if (typeof val === 'object' && val.__text) {
166
+ text = val.__text;
167
+ vector = val.__vector ?? null;
168
+ }
169
+ else {
170
+ text = typeof val === 'string' ? val : String(val);
171
+ }
172
+ expect(text).toBe('plain text search');
173
+ expect(vector).toBeNull();
174
+ });
175
+ });
176
+ });
package/embedder.d.ts CHANGED
@@ -4,12 +4,32 @@
4
4
  * Provides a provider-based architecture for converting text into vector
5
5
  * embeddings. Currently supports Ollama via @agentic-kit/ollama.
6
6
  *
7
- * The embedder is resolved at request time from:
8
- * 1. The `llm_module` api_modules configuration (per-database)
9
- * 2. The preset's `defaultEmbedder` option (fallback for dev/testing)
10
- * 3. Environment variables (EMBEDDER_PROVIDER, EMBEDDER_MODEL, EMBEDDER_BASE_URL)
7
+ * Per-request model/baseUrl overrides are read from AsyncLocalStorage
8
+ * (`llmConfigStore`). The text-search-plugin sets these overrides from
9
+ * the database's `llm_module` config via `ctx.useLlm()`. This means
10
+ * the same embedder function — and its metering wrapper — handle every
11
+ * request, regardless of which tenant's model config applies.
11
12
  */
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
12
14
  import type { EmbedderConfig, EmbedderFunction, LlmModuleData } from './types';
15
+ /**
16
+ * Per-request LLM config overrides from the database's llm_module.
17
+ * Only string parameters — the embedder client itself stays the same.
18
+ */
19
+ export interface LlmConfigOverrides {
20
+ embeddingModel?: string;
21
+ embeddingBaseUrl?: string;
22
+ chatModel?: string;
23
+ chatBaseUrl?: string;
24
+ }
25
+ /**
26
+ * AsyncLocalStorage for per-request LLM config overrides.
27
+ *
28
+ * Set by the text-search-plugin resolver wrapper (from ctx.useLlm()),
29
+ * read by the embedder at call time. The metering wrapper sits between
30
+ * them and is completely unaware — it just wraps the same function.
31
+ */
32
+ export declare const llmConfigStore: AsyncLocalStorage<LlmConfigOverrides>;
13
33
  /**
14
34
  * Build an embedder function from a config object.
15
35
  *