Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ jobs:
- batch: graphql
packages: 'graphql/query graphql/codegen'
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-cache graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin graphile/graphile-storage-registry'
packages: 'graphile/graphile-plugin-utils graphile/graphile-cache graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-storage-registry'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/permissions agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/dsh agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering agentic/agent-conversation agentic/pi-host agentic/run-log-client agentic/run-log-gate agentic/machine-protocol agentic/machine-runner agentic/agent-cli'
- batch: pgpm-unit
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ React Query Hooks or Prisma-like ORM Client

**`graphql/server`** - Express + PostGraphile. Supports multi-endpoint routing via subdomain/host detection (schema builder with app-public/auth/admin sub-endpoints). Uses `LISTEN/NOTIFY` for schema cache invalidation.

**`graphile/graphile-settings`** - Centralizes all PostGraphile plugin config: connection filters, full-text search, PostGIS, i18n, meta-schema, many-to-many, search, upload plugin. Single `getGraphileSettings(opts)` entry point.
**`graphile/graphile-settings`** - Centralizes all PostGraphile plugin config: connection filters, full-text search, PostGIS, i18n, meta-schema, many-to-many, search, presigned uploads. Single `getGraphileSettings(opts)` entry point.

**`packages/query-builder`** - Fluent SQL query builder for SELECT/INSERT/UPDATE/DELETE with JOINs, WHERE, GROUP BY. Schema-qualified tables.

Expand Down
1 change: 0 additions & 1 deletion graphile/graphile-llm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
},
"dependencies": {
"@agentic-kit/ollama": "workspace:*",
"@constructive-io/express-context": "workspace:^",
"@constructive-io/llm-env": "workspace:^",
"graphile-cache": "workspace:^"
},
Expand Down
18 changes: 8 additions & 10 deletions graphile/graphile-llm/src/__tests__/embed-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ const VECTORS: Record<string, number[]> = {
};
const vectorFor = (text: string) => VECTORS[text] ?? [1, 1, 1];

type EmbedderShape = 'result' | 'array' | 'null' | 'empty';
type EmbedderShape = 'result' | 'null' | 'empty';
let shape: EmbedderShape = 'result';
const calls: Array<{ text: string; overrides: unknown }> = [];

// `result` is the provider shape ({ embedding }), `array` is the metering
// wrapper's shape, `null` is the metering wrapper refusing (quota exceeded),
// `empty` is a provider answering with no vector.
// `result` is the provider shape ({ embedding }); `null` and `empty` are a
// provider answering with no vector.
function fakeEmbedderPlugin(configured: boolean): GraphileConfig.Plugin {
return {
name: 'FakeEmbedderPlugin',
Expand All @@ -55,9 +54,8 @@ function fakeEmbedderPlugin(configured: boolean): GraphileConfig.Plugin {
? async (text: string) => {
calls.push({ text, overrides: llmConfigStore.getStore() });
if (shape === 'null') return null;
if (shape === 'empty') return [];
const embedding = vectorFor(text);
return shape === 'array' ? embedding : { embedding, promptTokens: 1 };
if (shape === 'empty') return { embedding: [] as number[] };
return { embedding: vectorFor(text), promptTokens: 1 };
}
: null;
return build;
Expand Down Expand Up @@ -194,8 +192,8 @@ describe('plan-time embedding (onQuotaExceeded: degrade)', () => {
expect(errors).toEqual([[], [], []]);
});

describe.each<EmbedderShape>(['result', 'array'])('embedder returning the %s shape', (embedderShape) => {
beforeEach(() => { shape = embedderShape; });
describe('embedder returning the provider shape', () => {
beforeEach(() => { shape = 'result'; });

it('ranks a text search by the embedded vector and embeds once', async () => {
const result = await h.query<DocsResult>(BY_TEXT, { t: 'revenue' });
Expand Down Expand Up @@ -331,7 +329,7 @@ describe('plan-time embedding (onQuotaExceeded: degrade)', () => {
shape = 'null';
const result = await h.query(CREATE_FROM_TEXT, { name: 'quota.txt', t: 'rent' });

expect(result.errors?.[0]?.message).toMatch(/EMBED_QUOTA_EXCEEDED: Cannot embed embeddingText/);
expect(result.errors?.[0]?.message).toMatch(/EMBED_FAILED: Cannot embed embeddingText/);
const rows = await h.pg.query("SELECT 1 FROM llm_embed_inputs.docs WHERE name = 'quota.txt'");
expect(rows.rows).toHaveLength(0);
});
Expand Down
124 changes: 124 additions & 0 deletions graphile/graphile-llm/src/__tests__/preset-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { ConnectionFilterPreset } from 'graphile-connection-filter';
import { createPgvectorAdapter } from 'graphile-search/adapters/pgvector';
import { VectorCodecPlugin } from 'graphile-search/codecs/vector-codec';
import { createUnifiedSearchPlugin } from 'graphile-search/plugin';
import type { GraphQLResponse } from 'graphile-test';
import { getConnections, seed } from 'graphile-test';
import { join } from 'path';
import type { PgTestClient } from 'pgsql-test';

import { GraphileLlmPreset } from '../preset';

// The preset as production loads it (graphile-settings calls `GraphileLlmPreset()`
// and the dev stack adds an embedder), built into a real schema with the
// plugins it enriches, and queried through grafast.

type QueryFn = <TResult = unknown>(
query: string,
variables?: Record<string, unknown>
) => Promise<GraphQLResponse<TResult>>;

const OLLAMA_URL = 'http://localhost:11434';

async function ollamaReachable(): Promise<boolean> {
try {
const res = await fetch(`${OLLAMA_URL}/api/tags`);
return res.ok;
} catch {
return false;
}
}

describe('GraphileLlmPreset builds a real schema', () => {
let db: PgTestClient;
let teardown: () => Promise<void>;
let query: QueryFn;

beforeAll(async () => {
const connections = await getConnections(
{
schemas: ['llm_test'],
preset: {
extends: [
ConnectionFilterPreset(),
GraphileLlmPreset({
defaultEmbedder: { provider: 'ollama', model: 'nomic-embed-text', baseUrl: OLLAMA_URL },
enableRag: true
})
],
plugins: [
VectorCodecPlugin,
createUnifiedSearchPlugin({ adapters: [createPgvectorAdapter()] })
]
},
useRoot: true,
authRole: 'postgres'
},
[seed.sqlfile([join(__dirname, './setup.sql')])]
);
db = connections.db;
teardown = connections.teardown;
query = connections.query;
});

afterAll(async () => {
if (teardown) await teardown();
});

beforeEach(async () => {
await db.beforeEach();
});

afterEach(async () => {
await db.afterEach();
});

it('has exactly the module, text search, text mutation and RAG plugins', () => {
const names = GraphileLlmPreset({ enableRag: true }).plugins!.map((p) => p.name);
expect(names).toEqual(['LlmModulePlugin', 'LlmTextSearchPlugin', 'LlmTextMutationPlugin', 'LlmRagPlugin']);
});

it('takes no metering option', () => {
// @ts-expect-error there is no metering plugin to turn on
expect(GraphileLlmPreset({ metering: true }).plugins!.map((p) => p.name)).toEqual([
'LlmModulePlugin',
'LlmTextSearchPlugin',
'LlmTextMutationPlugin'
]);
});

it('exposes the text search field, the text companion field and embedText', async () => {
const result = await query<{
nearby: { inputFields: Array<{ name: string }> };
input: { inputFields: Array<{ name: string }> };
root: { fields: Array<{ name: string }> };
}>(`
query {
nearby: __type(name: "VectorNearbyInput") { inputFields { name } }
input: __type(name: "ArticleInput") { inputFields { name } }
root: __type(name: "Query") { fields { name } }
}
`);

expect(result.errors).toBeUndefined();
const names = (fields: Array<{ name: string }>) => fields.map((f) => f.name);
expect(names(result.data!.nearby.inputFields)).toContain('text');
expect(names(result.data!.input.inputFields)).toContain('embeddingText');
expect(names(result.data!.root.fields)).toContain('embedText');
});

it('embedText reads the embedder result through the one embedder shape', async () => {
if (!(await ollamaReachable())) {
console.log('Ollama not reachable; skipping the live embed');
return;
}

const result = await query<{ embedText: { vector: number[]; dimensions: number } }>(`
query { embedText(text: "warehouse lease") { vector dimensions } }
`);

expect(result.errors).toBeUndefined();
expect(result.data!.embedText.dimensions).toBe(768);
expect(result.data!.embedText.vector).toHaveLength(768);
});
});
Loading
Loading