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
96 changes: 96 additions & 0 deletions bridge/__tests__/schemaContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { buildSchemaContext } from "../src/ai/utils/schemaContext";
import { SchemaFile } from "../src/services/projectStore";

describe("buildSchemaContext", () => {
const mockSchema: SchemaFile = {
version: 2,
projectId: "proj_1",
databaseId: "db_1",
dialect: "postgresql",
cachedAt: "2023-01-01T00:00:00.000Z",
relwaveVersion: "1.0.0",
schemaHash: "abc",
schemas: [
{
name: "public",
enums: [{ name: "user_status", values: ["active", "inactive"] }],
tables: [
{
name: "users",
type: "BASE TABLE",
columns: [
{ name: "id", type: "uuid", isPrimaryKey: true, nullable: false, isForeignKey: false, isUnique: false, isSerial: false, ordinalPosition: 1, defaultValue: "uuid_generate_v4()" },
{ name: "email", type: "varchar", isPrimaryKey: false, nullable: false, isForeignKey: false, isUnique: true, isSerial: false, ordinalPosition: 2, defaultValue: null },
{ name: "password_hash", type: "varchar", isPrimaryKey: false, nullable: false, isForeignKey: false, isUnique: false, isSerial: false, ordinalPosition: 3, defaultValue: null },
{ name: "status", type: "user_status", isPrimaryKey: false, nullable: false, isForeignKey: false, isUnique: false, isSerial: false, ordinalPosition: 4, defaultValue: "'active'" }
],
indexes: [{ name: "users_email_idx", columns: ["email"], unique: true }],
foreignKeys: [],
checks: []
},
{
name: "orders",
type: "BASE TABLE",
columns: [
{ name: "id", type: "integer", isPrimaryKey: true, nullable: false, isForeignKey: false, isUnique: false, isSerial: true, ordinalPosition: 1, defaultValue: null },
{ name: "user_id", type: "uuid", isPrimaryKey: false, nullable: false, isForeignKey: true, isUnique: false, isSerial: false, ordinalPosition: 2, defaultValue: null, foreignKey: { schema: "public", table: "users", column: "id" } },
{ name: "total", type: "numeric", isPrimaryKey: false, nullable: false, isForeignKey: false, isUnique: false, isSerial: false, ordinalPosition: 3, defaultValue: null }
],
indexes: [],
foreignKeys: [
{ name: "orders_user_id_fkey", columns: ["user_id"], referencedSchema: "public", referencedTable: "users", referencedColumns: ["id"], onDelete: "CASCADE", onUpdate: "NO ACTION" }
],
checks: []
},
{
name: "schema_migrations",
type: "BASE TABLE",
columns: [{ name: "version", type: "varchar", isPrimaryKey: true, nullable: false, isForeignKey: false, isUnique: false, isSerial: false, ordinalPosition: 1, defaultValue: null }],
indexes: [],
foreignKeys: [],
checks: []
}
]
}
]
};

it("should build a compact context", () => {
const result = buildSchemaContext(mockSchema);

// Check header
expect(result).toContain("Database: postgresql | Schema captured: 2023-01-01T00:00:00.000Z");

// Check enums
expect(result).toContain("ENUM TYPES:");
expect(result).toContain("public.user_status = [active, inactive]");

// Check tables and columns
expect(result).toContain("TABLE public.users");
expect(result).toContain("id uuid [PK, NOT NULL, DEFAULT uuid_generate_v4()]");
expect(result).toContain("email varchar [NOT NULL, UNIQUE]");

// Check masking sensitive columns
expect(result).toContain("password_hash: [REDACTED]");

// Check foreign key column flag
expect(result).toContain("user_id uuid [FK→public.users.id, NOT NULL]");

// Check indexes
expect(result).toContain("UNIQUE INDEX (email)");

// Check relationships
expect(result).toContain("RELATIONSHIPS:");
expect(result).toContain("RELATIONSHIP public.orders.user_id → public.users.id");

// Check that excluded tables are absent
expect(result).not.toContain("TABLE public.schema_migrations");
});

it("should respect excludeTables and targetSchema options", () => {
const result = buildSchemaContext(mockSchema, { excludeTables: ["orders"], targetSchema: "public" });
expect(result).not.toContain("TABLE public.orders");
expect(result).not.toContain("RELATIONSHIP public.orders.user_id");
expect(result).toContain("TABLE public.users");
});
});
69 changes: 69 additions & 0 deletions bridge/__tests__/sqlValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { validateGeneratedSQL } from "../src/ai/utils/sqlValidator";
import { SchemaFile } from "../src/services/projectStore";

describe("validateGeneratedSQL", () => {
const mockSchema: SchemaFile = {
version: 2,
projectId: "proj_1",
databaseId: "db_1",
dialect: "postgresql",
cachedAt: "2023-01-01T00:00:00.000Z",
relwaveVersion: "1.0.0",
schemaHash: "abc",
schemas: [
{
name: "public",
tables: [
{ name: "users", type: "BASE TABLE", columns: [], indexes: [], foreignKeys: [], checks: [] },
{ name: "orders", type: "BASE TABLE", columns: [], indexes: [], foreignKeys: [], checks: [] }
]
}
]
};

it("should allow a valid SELECT query with known tables", () => {
const result = validateGeneratedSQL('SELECT * FROM "users" JOIN public.orders on users.id = orders.user_id', mockSchema);
expect(result.valid).toBe(true);
expect(result.intent).toBe("read");
});

it("should block non-SELECT queries", () => {
const result = validateGeneratedSQL('WITH cte AS (SELECT * FROM users) SELECT * FROM cte', mockSchema);
// Actually our simple check blocks WITH if it doesn't start with SELECT.
// That's acceptable for a strict auto-execution guard as per TASKS.md
expect(result.valid).toBe(false);
expect(result.reason).toContain("start with SELECT");
});

it("should block destructive operations", () => {
const result = validateGeneratedSQL("SELECT * FROM users; DROP TABLE users", mockSchema);
expect(result.valid).toBe(false);
expect(result.reason).toContain("DROP TABLE");
expect(result.intent).toBe("destructive");
});

it("should block UPDATE operations", () => {
const result = validateGeneratedSQL("UPDATE users SET name = 'test'", mockSchema);
expect(result.valid).toBe(false);
expect(result.reason).toContain("UPDATE ");
expect(result.intent).toBe("write");
});

it("should block unknown tables", () => {
const result = validateGeneratedSQL("SELECT * FROM unknown_table", mockSchema);
expect(result.valid).toBe(false);
expect(result.reason).toContain("unknown_table");
});

it("should block stacked statements", () => {
const result = validateGeneratedSQL("SELECT * FROM users; SELECT * FROM orders", mockSchema);
expect(result.valid).toBe(false);
expect(result.reason).toContain("Stacked statements");
});

it("should block comments", () => {
const result = validateGeneratedSQL("SELECT * FROM users -- comment", mockSchema);
expect(result.valid).toBe(false);
expect(result.reason).toContain("comments");
});
});
11 changes: 6 additions & 5 deletions bridge/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
packages: []

onlyBuiltDependencies:
- better-sqlite3
- "@napi-rs/keyring"
allowBuilds:
better-sqlite3: true
cpu-features: true
esbuild: true
ssh2: true
unrs-resolver: true
25 changes: 25 additions & 0 deletions bridge/src/ai/prompts/nl-sql-interpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { SYSTEM_CONTEXT } from "./shared";

export function buildResultInterpreterPrompt(question: string, sql: string, resultsJSON: string): {
system: string;
user: string;
} {
const system = `You are RelWave AI, a data analyst assistant.
Your task is to interpret the results of a SQL query and provide a clear, concise plain-English summary.

## Rules
1. Respond in 1 to 3 sentences max.
2. DO NOT use markdown. DO NOT use bullet points. DO NOT output SQL.
3. If the result is a single number (e.g., a COUNT or SUM), include context so the user understands what the number represents.
4. If the results state they were truncated, mention that you are only summarizing the first subset of results.`;

const user = `Original Question: ${question}
Executed SQL: ${sql}

Results (JSON):
${resultsJSON}

Provide the plain-English interpretation:`;

return { system, user };
}
54 changes: 54 additions & 0 deletions bridge/src/ai/prompts/nl-sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { SYSTEM_CONTEXT } from "./shared";

export function buildNLSQLPrompt(schemaContext: string, dialect: string): {
system: string;
user: (question: string) => string;
} {
let quoteRule = "";
if (dialect === "postgresql") {
quoteRule = 'Use double quotes (") for identifiers if needed. If qualifying with a schema, quote them separately like "schema"."table".';
} else if (dialect === "mysql" || dialect === "mariadb") {
quoteRule = "Use backticks (`) for identifiers if needed. If qualifying with a schema, quote them separately like `schema`.`table`.";
} else if (dialect === "sqlite") {
quoteRule = "Do not use quotes for identifiers unless necessary. If qualifying with a schema, quote them separately.";
}

const system = `You are RelWave AI, an expert SQL developer for ${dialect}.
Your task is to translate natural language questions into accurate, safe SQL queries based on the provided schema.

## Schema Context
${schemaContext}

## Rules
1. Generate ONLY valid, executable ${dialect} SQL.
2. Generate ONLY \`SELECT\` queries by default, unless the user explicitly requests data modification (e.g., delete, update, insert, drop).
3. ALWAYS include a \`LIMIT 100\` clause at the end of your query unless the user specifically asks for "all" records or a different limit.
4. ONLY use tables and columns that exist in the Schema Context. NEVER invent or guess names.
5. If your query uses a \`JOIN\`, you MUST qualify ALL column names with their table names (e.g., \`users.id\`, not just \`id\`).
6. ${quoteRule}

## Output Format
You MUST respond with ONLY a valid JSON object matching this exact schema. Do not include markdown formatting, code blocks, or any text outside the JSON object.

{
"sql": string | null, // The generated SQL query, or null if the intent is unclear
"intent": "read" | "write" | "destructive" | "schema" | "unclear", // read (SELECT), write (INSERT/UPDATE), destructive (DELETE/DROP), schema (CREATE/ALTER), unclear
"explanation": string, // One sentence in plain English describing what the query does
"confidence": number, // Float between 0 and 1 indicating your confidence in the query's accuracy
"assumptions": string[] // Array of strings describing any ambiguous terms you resolved, or empty array
}

If the question is unclear or unrelated to the schema, return:
{
"sql": null,
"intent": "unclear",
"explanation": "Brief reason why the question cannot be answered",
"confidence": 0,
"assumptions": []
}`;

return {
system,
user: (question: string) => `Question: ${question}`
};
}
6 changes: 5 additions & 1 deletion bridge/src/ai/providers/anthropic.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@ export class AnthropicProvider implements AIProvider {
try {
await this.client.messages.create({
model: this.model,
max_tokens: 10,
messages: [{ role: "user", content: "ping" }],
max_tokens: 5,
});
return "";
} catch (err) {
throw classifyError(err, "anthropic");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
4 changes: 4 additions & 0 deletions bridge/src/ai/providers/gemini.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,8 @@ export class GeminiProvider implements AIProvider {
throw classifyError(err, "gemini");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
4 changes: 4 additions & 0 deletions bridge/src/ai/providers/groq.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ export class GroqProvider implements AIProvider {
throw classifyError(err, "groq");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
4 changes: 4 additions & 0 deletions bridge/src/ai/providers/mistral.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,8 @@ export class MistralProvider implements AIProvider {
throw classifyError(err, "mistral");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
14 changes: 8 additions & 6 deletions bridge/src/ai/providers/ollama.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ export class OllamaProvider implements AIProvider {

async testConnection(): Promise<string> {
try {
// List models to verify Ollama is reachable and the model exists
const list = await this.client.list();
const available = list.models.map((m: any) => m.name);
if (!available.some((n: string) => n.startsWith(this.model.split(":")[0]))) {
throw new Error(`Model "${this.model}" not found. Available: ${available.join(", ") || "none"}`);
}
await this.client.generate({
model: this.model,
prompt: "ping",
});
return "";
} catch (err) {
throw classifyError(err, "ollama");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
4 changes: 4 additions & 0 deletions bridge/src/ai/providers/openai.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ export class OpenAIProvider implements AIProvider {
throw classifyError(err, "openai");
}
}

async generateText(system: string, user: string): Promise<string> {
return this.complete(system, user);
}
}
3 changes: 3 additions & 0 deletions bridge/src/ai/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface AIProvider {
* Resolves with an empty string on success, a user-facing message on failure.
*/
testConnection(): Promise<string>;

/** Generate raw text from a system and user prompt. Used for NL-to-SQL. */
generateText(system: string, user: string): Promise<string>;
}

// ── Standardized error type ───────────────────────────────────────────────
Expand Down
Loading
Loading