outlet-orm 7.0.0 → 9.0.1
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/README.md +130 -2
- package/docs/skills/outlet-orm/AI.md +452 -102
- package/docs/skills/outlet-orm/API.md +108 -0
- package/docs/skills/outlet-orm/QUERIES.md +64 -0
- package/docs/skills/outlet-orm/SEEDS.md +47 -0
- package/docs/skills/outlet-orm/SKILL.md +15 -7
- package/package.json +1 -1
- package/src/AI/AIPromptEnhancer.js +170 -0
- package/src/AI/AIQueryBuilder.js +234 -0
- package/src/AI/AIQueryOptimizer.js +185 -0
- package/src/AI/AISeeder.js +181 -0
- package/src/AI/AiBridgeManager.js +287 -0
- package/src/AI/Builders/TextBuilder.js +170 -0
- package/src/AI/Contracts/AudioProviderContract.js +29 -0
- package/src/AI/Contracts/ChatProviderContract.js +38 -0
- package/src/AI/Contracts/EmbeddingsProviderContract.js +19 -0
- package/src/AI/Contracts/ImageProviderContract.js +19 -0
- package/src/AI/Contracts/ModelsProviderContract.js +26 -0
- package/src/AI/Contracts/ToolContract.js +25 -0
- package/src/AI/Facades/AiBridge.js +79 -0
- package/src/AI/MCPServer.js +113 -0
- package/src/AI/Providers/ClaudeProvider.js +64 -0
- package/src/AI/Providers/CustomOpenAIProvider.js +238 -0
- package/src/AI/Providers/GeminiProvider.js +68 -0
- package/src/AI/Providers/GrokProvider.js +46 -0
- package/src/AI/Providers/MistralProvider.js +21 -0
- package/src/AI/Providers/OllamaProvider.js +249 -0
- package/src/AI/Providers/OllamaTurboProvider.js +32 -0
- package/src/AI/Providers/OnnProvider.js +46 -0
- package/src/AI/Providers/OpenAIProvider.js +471 -0
- package/src/AI/Support/AudioNormalizer.js +37 -0
- package/src/AI/Support/ChatNormalizer.js +42 -0
- package/src/AI/Support/Document.js +77 -0
- package/src/AI/Support/DocumentAttachmentMapper.js +101 -0
- package/src/AI/Support/EmbeddingsNormalizer.js +30 -0
- package/src/AI/Support/Exceptions/ProviderError.js +22 -0
- package/src/AI/Support/FileSecurity.js +56 -0
- package/src/AI/Support/ImageNormalizer.js +62 -0
- package/src/AI/Support/JsonSchemaValidator.js +73 -0
- package/src/AI/Support/Message.js +40 -0
- package/src/AI/Support/StreamChunk.js +45 -0
- package/src/AI/Support/ToolChatRunner.js +160 -0
- package/src/AI/Support/ToolRegistry.js +62 -0
- package/src/AI/Tools/SystemInfoTool.js +25 -0
- package/src/index.js +67 -1
- package/types/index.d.ts +326 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AIQueryBuilder
|
|
5
|
+
* Natural Language → SQL conversion using any AiBridge provider.
|
|
6
|
+
* Introspects the database schema, sends it with the NL prompt to an LLM,
|
|
7
|
+
* and returns a safe, parameterized SQL query.
|
|
8
|
+
*
|
|
9
|
+
* @since 8.0.0
|
|
10
|
+
*/
|
|
11
|
+
class AIQueryBuilder {
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('./AiBridgeManager')} manager - AiBridge manager instance
|
|
14
|
+
* @param {Object} connection - DatabaseConnection instance (outlet-orm)
|
|
15
|
+
*/
|
|
16
|
+
constructor(manager, connection) {
|
|
17
|
+
this._manager = manager;
|
|
18
|
+
this._connection = connection;
|
|
19
|
+
this._provider = 'openai';
|
|
20
|
+
this._model = 'gpt-4o-mini';
|
|
21
|
+
this._safeMode = true; // Only SELECT by default
|
|
22
|
+
this._maxTokens = 1024;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set the provider and model to use.
|
|
27
|
+
* @param {string} provider
|
|
28
|
+
* @param {string} model
|
|
29
|
+
* @returns {this}
|
|
30
|
+
*/
|
|
31
|
+
using(provider, model) {
|
|
32
|
+
this._provider = provider;
|
|
33
|
+
this._model = model;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enable/disable safe mode (SELECT only).
|
|
39
|
+
* @param {boolean} safe
|
|
40
|
+
* @returns {this}
|
|
41
|
+
*/
|
|
42
|
+
safeMode(safe) {
|
|
43
|
+
this._safeMode = safe;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert a natural language question to SQL and execute it.
|
|
49
|
+
* @param {string} question
|
|
50
|
+
* @param {Object} [options={}]
|
|
51
|
+
* @returns {Promise<{sql: string, params: Array, results: Array, raw_response: Object}>}
|
|
52
|
+
*/
|
|
53
|
+
async query(question, options = {}) {
|
|
54
|
+
const schema = await this._introspectSchema();
|
|
55
|
+
const systemPrompt = this._buildSystemPrompt(schema);
|
|
56
|
+
const messages = [
|
|
57
|
+
{ role: 'system', content: systemPrompt },
|
|
58
|
+
{ role: 'user', content: question },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const chatOptions = {
|
|
62
|
+
model: options.model || this._model,
|
|
63
|
+
max_tokens: options.max_tokens || this._maxTokens,
|
|
64
|
+
temperature: options.temperature || 0.1, // Low temp for SQL accuracy
|
|
65
|
+
response_format: 'json',
|
|
66
|
+
json_schema: {
|
|
67
|
+
name: 'sql_response',
|
|
68
|
+
schema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
sql: { type: 'string' },
|
|
72
|
+
params: { type: 'array', items: {} },
|
|
73
|
+
explanation: { type: 'string' },
|
|
74
|
+
},
|
|
75
|
+
required: ['sql'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const res = await this._manager.chat(this._provider, messages, chatOptions);
|
|
81
|
+
const parsed = this._extractSqlFromResponse(res);
|
|
82
|
+
|
|
83
|
+
// Safety check
|
|
84
|
+
if (this._safeMode && parsed.sql) {
|
|
85
|
+
const upper = parsed.sql.trim().toUpperCase();
|
|
86
|
+
if (!upper.startsWith('SELECT') && !upper.startsWith('WITH')) {
|
|
87
|
+
throw new Error(`AIQueryBuilder safe mode: only SELECT/WITH queries are allowed. Got: ${upper.slice(0, 20)}...`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Execute the query
|
|
92
|
+
let results = [];
|
|
93
|
+
if (parsed.sql && this._connection) {
|
|
94
|
+
try {
|
|
95
|
+
results = await this._connection.raw(parsed.sql, parsed.params || []);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { sql: parsed.sql, params: parsed.params || [], results: [], error: err.message, raw_response: res };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
sql: parsed.sql || '',
|
|
103
|
+
params: parsed.params || [],
|
|
104
|
+
results,
|
|
105
|
+
explanation: parsed.explanation || '',
|
|
106
|
+
raw_response: res,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate SQL without executing it.
|
|
112
|
+
* @param {string} question
|
|
113
|
+
* @param {Object} [options={}]
|
|
114
|
+
* @returns {Promise<{sql: string, params: Array, explanation: string}>}
|
|
115
|
+
*/
|
|
116
|
+
async toSql(question, options = {}) {
|
|
117
|
+
const schema = await this._introspectSchema();
|
|
118
|
+
const systemPrompt = this._buildSystemPrompt(schema);
|
|
119
|
+
const messages = [
|
|
120
|
+
{ role: 'system', content: systemPrompt },
|
|
121
|
+
{ role: 'user', content: question },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const chatOptions = {
|
|
125
|
+
model: options.model || this._model,
|
|
126
|
+
max_tokens: options.max_tokens || this._maxTokens,
|
|
127
|
+
temperature: 0.1,
|
|
128
|
+
response_format: 'json',
|
|
129
|
+
json_schema: {
|
|
130
|
+
name: 'sql_response',
|
|
131
|
+
schema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
sql: { type: 'string' },
|
|
135
|
+
params: { type: 'array', items: {} },
|
|
136
|
+
explanation: { type: 'string' },
|
|
137
|
+
},
|
|
138
|
+
required: ['sql'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const res = await this._manager.chat(this._provider, messages, chatOptions);
|
|
144
|
+
const parsed = this._extractSqlFromResponse(res);
|
|
145
|
+
return { sql: parsed.sql || '', params: parsed.params || [], explanation: parsed.explanation || '' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @private */
|
|
149
|
+
async _introspectSchema() {
|
|
150
|
+
if (!this._connection) return 'No database connection available.';
|
|
151
|
+
try {
|
|
152
|
+
const dialect = this._connection.config?.client || 'mysql';
|
|
153
|
+
let tables = [];
|
|
154
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
155
|
+
const res = await this._connection.raw(
|
|
156
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"
|
|
157
|
+
);
|
|
158
|
+
tables = (res.rows || res).map(r => r.table_name);
|
|
159
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
160
|
+
const res = await this._connection.raw(
|
|
161
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
162
|
+
);
|
|
163
|
+
tables = (Array.isArray(res) ? res : (res.rows || [])).map(r => r.name);
|
|
164
|
+
} else {
|
|
165
|
+
// MySQL
|
|
166
|
+
const res = await this._connection.raw('SHOW TABLES');
|
|
167
|
+
tables = (Array.isArray(res) ? res[0] || res : res).map(r => Object.values(r)[0]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const schemaInfo = {};
|
|
171
|
+
for (const table of tables) {
|
|
172
|
+
try {
|
|
173
|
+
let cols;
|
|
174
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
175
|
+
const cRes = await this._connection.raw(
|
|
176
|
+
`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = '${table}'`
|
|
177
|
+
);
|
|
178
|
+
cols = (cRes.rows || cRes).map(c => `${c.column_name} ${c.data_type}${c.is_nullable === 'YES' ? ' NULL' : ''}`);
|
|
179
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
180
|
+
const cRes = await this._connection.raw(`PRAGMA table_info("${table}")`);
|
|
181
|
+
cols = (Array.isArray(cRes) ? cRes : []).map(c => `${c.name} ${c.type}${c.notnull ? '' : ' NULL'}`);
|
|
182
|
+
} else {
|
|
183
|
+
const cRes = await this._connection.raw(`DESCRIBE \`${table}\``);
|
|
184
|
+
cols = (Array.isArray(cRes) ? cRes[0] || cRes : cRes).map(c => `${c.Field} ${c.Type}${c.Null === 'YES' ? ' NULL' : ''}`);
|
|
185
|
+
}
|
|
186
|
+
schemaInfo[table] = cols;
|
|
187
|
+
} catch { schemaInfo[table] = ['(unable to read columns)']; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return Object.entries(schemaInfo)
|
|
191
|
+
.map(([t, cols]) => `TABLE ${t}:\n ${cols.join('\n ')}`)
|
|
192
|
+
.join('\n\n');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return `Schema introspection error: ${err.message}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @private */
|
|
199
|
+
_buildSystemPrompt(schema) {
|
|
200
|
+
let prompt = `You are a SQL assistant. Given a natural language question and a database schema, generate a single SQL query that answers the question.
|
|
201
|
+
|
|
202
|
+
DATABASE SCHEMA:
|
|
203
|
+
${schema}
|
|
204
|
+
|
|
205
|
+
RULES:
|
|
206
|
+
- Return ONLY a JSON object with keys: "sql" (the query), "params" (array of parameterized values, can be empty), "explanation" (brief explanation).
|
|
207
|
+
- Use parameterized queries (? placeholders) when appropriate for safety.
|
|
208
|
+
- Do NOT use DROP, TRUNCATE, or ALTER statements.`;
|
|
209
|
+
|
|
210
|
+
if (this._safeMode) {
|
|
211
|
+
prompt += '\n- ONLY generate SELECT or WITH (CTE) queries. No INSERT, UPDATE, DELETE.';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return prompt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @private */
|
|
218
|
+
_extractSqlFromResponse(res) {
|
|
219
|
+
// Try various response formats
|
|
220
|
+
let content = res?.output_text || res?.choices?.[0]?.message?.content || res?.content?.[0]?.text || res?.message?.content || '';
|
|
221
|
+
if (typeof content !== 'string') content = JSON.stringify(content);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(content);
|
|
225
|
+
return { sql: parsed.sql || '', params: parsed.params || [], explanation: parsed.explanation || '' };
|
|
226
|
+
} catch {
|
|
227
|
+
// Try to extract SQL from plain text
|
|
228
|
+
const sqlMatch = content.match(/```sql\s*([\s\S]*?)```/i) || content.match(/SELECT[\s\S]+?;/i);
|
|
229
|
+
return { sql: sqlMatch ? (sqlMatch[1] || sqlMatch[0]).trim() : content.trim(), params: [], explanation: '' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = AIQueryBuilder;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AIQueryOptimizer
|
|
5
|
+
* Uses LLM to analyze and optimize SQL queries.
|
|
6
|
+
* Sends the query + schema to an AI provider and returns optimization suggestions.
|
|
7
|
+
*
|
|
8
|
+
* @since 8.0.0
|
|
9
|
+
*/
|
|
10
|
+
class AIQueryOptimizer {
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('./AiBridgeManager')} manager
|
|
13
|
+
* @param {Object} [connection] - DatabaseConnection instance (optional, for schema introspection)
|
|
14
|
+
*/
|
|
15
|
+
constructor(manager, connection = null) {
|
|
16
|
+
this._manager = manager;
|
|
17
|
+
this._connection = connection;
|
|
18
|
+
this._provider = 'openai';
|
|
19
|
+
this._model = 'gpt-4o-mini';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set the provider and model to use.
|
|
24
|
+
* @param {string} provider
|
|
25
|
+
* @param {string} model
|
|
26
|
+
* @returns {this}
|
|
27
|
+
*/
|
|
28
|
+
using(provider, model) {
|
|
29
|
+
this._provider = provider;
|
|
30
|
+
this._model = model;
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Analyze a SQL query and return optimization suggestions.
|
|
36
|
+
* @param {string} sql - The SQL query to optimize
|
|
37
|
+
* @param {Object} [options={}]
|
|
38
|
+
* @returns {Promise<{original: string, optimized: string, suggestions: Array, explanation: string}>}
|
|
39
|
+
*/
|
|
40
|
+
async optimize(sql, options = {}) {
|
|
41
|
+
const schema = options.schema || await this._introspectSchema();
|
|
42
|
+
const dialect = options.dialect || this._connection?.config?.client || 'mysql';
|
|
43
|
+
|
|
44
|
+
const systemPrompt = `You are a senior database performance engineer. Analyze SQL queries and provide optimizations.
|
|
45
|
+
|
|
46
|
+
DATABASE DIALECT: ${dialect}
|
|
47
|
+
${schema ? `\nDATABASE SCHEMA:\n${schema}` : ''}
|
|
48
|
+
|
|
49
|
+
RULES:
|
|
50
|
+
- Return a JSON object with keys:
|
|
51
|
+
"optimized" (the optimized SQL query),
|
|
52
|
+
"suggestions" (array of optimization suggestions, each with "type", "description", "impact"),
|
|
53
|
+
"explanation" (brief overall explanation),
|
|
54
|
+
"indexes" (array of recommended CREATE INDEX statements, if any).
|
|
55
|
+
- Preserve query semantics — the optimized query must return the same results.
|
|
56
|
+
- Consider: indexes, query rewriting, subquery elimination, JOIN optimization, proper use of LIMIT/OFFSET.
|
|
57
|
+
- Rate impact as "high", "medium", or "low".`;
|
|
58
|
+
|
|
59
|
+
const messages = [
|
|
60
|
+
{ role: 'system', content: systemPrompt },
|
|
61
|
+
{ role: 'user', content: `Optimize this SQL query:\n\n${sql}` },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const res = await this._manager.chat(this._provider, messages, {
|
|
65
|
+
model: options.model || this._model,
|
|
66
|
+
temperature: 0.2,
|
|
67
|
+
max_tokens: 2048,
|
|
68
|
+
response_format: 'json',
|
|
69
|
+
json_schema: {
|
|
70
|
+
name: 'optimization_result',
|
|
71
|
+
schema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
optimized: { type: 'string' },
|
|
75
|
+
suggestions: {
|
|
76
|
+
type: 'array',
|
|
77
|
+
items: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
type: { type: 'string' },
|
|
81
|
+
description: { type: 'string' },
|
|
82
|
+
impact: { type: 'string' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
explanation: { type: 'string' },
|
|
87
|
+
indexes: { type: 'array', items: { type: 'string' } },
|
|
88
|
+
},
|
|
89
|
+
required: ['optimized', 'suggestions'],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const parsed = this._extractResult(res);
|
|
95
|
+
return {
|
|
96
|
+
original: sql,
|
|
97
|
+
optimized: parsed.optimized || sql,
|
|
98
|
+
suggestions: parsed.suggestions || [],
|
|
99
|
+
explanation: parsed.explanation || '',
|
|
100
|
+
indexes: parsed.indexes || [],
|
|
101
|
+
raw_response: res,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Analyze query execution plan using EXPLAIN.
|
|
107
|
+
* @param {string} sql
|
|
108
|
+
* @returns {Promise<{plan: Array, analysis: string}>}
|
|
109
|
+
*/
|
|
110
|
+
async explain(sql) {
|
|
111
|
+
if (!this._connection) throw new Error('Database connection required for EXPLAIN.');
|
|
112
|
+
const dialect = this._connection.config?.client || 'mysql';
|
|
113
|
+
|
|
114
|
+
let plan;
|
|
115
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
116
|
+
plan = await this._connection.raw(`EXPLAIN (FORMAT JSON) ${sql}`);
|
|
117
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
118
|
+
plan = await this._connection.raw(`EXPLAIN QUERY PLAN ${sql}`);
|
|
119
|
+
} else {
|
|
120
|
+
plan = await this._connection.raw(`EXPLAIN ${sql}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ask LLM to analyze the execution plan
|
|
124
|
+
const messages = [
|
|
125
|
+
{ role: 'system', content: 'You are a database performance expert. Analyze this EXPLAIN plan and provide actionable insights.' },
|
|
126
|
+
{ role: 'user', content: `EXPLAIN output for query "${sql}":\n\n${JSON.stringify(plan, null, 2)}` },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const res = await this._manager.chat(this._provider, messages, {
|
|
130
|
+
model: this._model,
|
|
131
|
+
temperature: 0.2,
|
|
132
|
+
max_tokens: 1024,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const analysis = res?.output_text || res?.choices?.[0]?.message?.content || res?.content?.[0]?.text || '';
|
|
136
|
+
return { plan: Array.isArray(plan) ? plan : [plan], analysis };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @private */
|
|
140
|
+
async _introspectSchema() {
|
|
141
|
+
if (!this._connection) return '';
|
|
142
|
+
try {
|
|
143
|
+
const dialect = this._connection.config?.client || 'mysql';
|
|
144
|
+
let tables = [];
|
|
145
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
146
|
+
const res = await this._connection.raw("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'");
|
|
147
|
+
tables = (res.rows || res).map(r => r.table_name);
|
|
148
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
149
|
+
const res = await this._connection.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
|
150
|
+
tables = (Array.isArray(res) ? res : []).map(r => r.name);
|
|
151
|
+
} else {
|
|
152
|
+
const res = await this._connection.raw('SHOW TABLES');
|
|
153
|
+
tables = (Array.isArray(res) ? res[0] || res : res).map(r => Object.values(r)[0]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parts = [];
|
|
157
|
+
for (const table of tables.slice(0, 30)) { // Limit to 30 tables
|
|
158
|
+
try {
|
|
159
|
+
let cols;
|
|
160
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
161
|
+
const cRes = await this._connection.raw(`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${table}'`);
|
|
162
|
+
cols = (cRes.rows || cRes).map(c => `${c.column_name} ${c.data_type}`);
|
|
163
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
164
|
+
const cRes = await this._connection.raw(`PRAGMA table_info("${table}")`);
|
|
165
|
+
cols = (Array.isArray(cRes) ? cRes : []).map(c => `${c.name} ${c.type}`);
|
|
166
|
+
} else {
|
|
167
|
+
const cRes = await this._connection.raw(`DESCRIBE \`${table}\``);
|
|
168
|
+
cols = (Array.isArray(cRes) ? cRes[0] || cRes : cRes).map(c => `${c.Field} ${c.Type}`);
|
|
169
|
+
}
|
|
170
|
+
parts.push(`TABLE ${table}: ${cols.join(', ')}`);
|
|
171
|
+
} catch { /* skip */ }
|
|
172
|
+
}
|
|
173
|
+
return parts.join('\n');
|
|
174
|
+
} catch { return ''; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @private */
|
|
178
|
+
_extractResult(res) {
|
|
179
|
+
let content = res?.output_text || res?.choices?.[0]?.message?.content || res?.content?.[0]?.text || res?.message?.content || '';
|
|
180
|
+
if (typeof content !== 'string') content = JSON.stringify(content);
|
|
181
|
+
try { return JSON.parse(content); } catch { return {}; }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = AIQueryOptimizer;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AISeeder
|
|
5
|
+
* AI-powered data seeding that uses LLM to generate realistic, contextual seed data.
|
|
6
|
+
* Instead of hard-coded Faker data, asks the LLM to produce domain-specific records.
|
|
7
|
+
*
|
|
8
|
+
* @since 8.0.0
|
|
9
|
+
*/
|
|
10
|
+
class AISeeder {
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('./AiBridgeManager')} manager
|
|
13
|
+
* @param {Object} connection - DatabaseConnection instance
|
|
14
|
+
*/
|
|
15
|
+
constructor(manager, connection) {
|
|
16
|
+
this._manager = manager;
|
|
17
|
+
this._connection = connection;
|
|
18
|
+
this._provider = 'openai';
|
|
19
|
+
this._model = 'gpt-4o-mini';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set the provider and model to use.
|
|
24
|
+
* @param {string} provider
|
|
25
|
+
* @param {string} model
|
|
26
|
+
* @returns {this}
|
|
27
|
+
*/
|
|
28
|
+
using(provider, model) {
|
|
29
|
+
this._provider = provider;
|
|
30
|
+
this._model = model;
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate and insert seed data for a table.
|
|
36
|
+
* @param {string} table - Table name
|
|
37
|
+
* @param {number} [count=10] - Number of records to generate
|
|
38
|
+
* @param {Object} [context={}] - Additional context (e.g., domain, constraints)
|
|
39
|
+
* @returns {Promise<{records: Array, inserted: number}>}
|
|
40
|
+
*/
|
|
41
|
+
async seed(table, count = 10, context = {}) {
|
|
42
|
+
const schema = await this._getTableSchema(table);
|
|
43
|
+
const systemPrompt = this._buildSystemPrompt(table, schema, count, context);
|
|
44
|
+
const messages = [
|
|
45
|
+
{ role: 'system', content: systemPrompt },
|
|
46
|
+
{ role: 'user', content: `Generate ${count} realistic seed records for the "${table}" table.${context.description ? ' Context: ' + context.description : ''}` },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const res = await this._manager.chat(this._provider, messages, {
|
|
50
|
+
model: this._model,
|
|
51
|
+
temperature: 0.8, // Higher temp for creative data
|
|
52
|
+
max_tokens: 4096,
|
|
53
|
+
response_format: 'json',
|
|
54
|
+
json_schema: {
|
|
55
|
+
name: 'seed_data',
|
|
56
|
+
schema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
records: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
items: { type: 'object' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['records'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const records = this._extractRecords(res);
|
|
70
|
+
|
|
71
|
+
// Insert records
|
|
72
|
+
let inserted = 0;
|
|
73
|
+
if (records.length > 0 && this._connection) {
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
try {
|
|
76
|
+
const columns = Object.keys(record);
|
|
77
|
+
const values = Object.values(record);
|
|
78
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
79
|
+
const sql = `INSERT INTO \`${table}\` (${columns.map(c => `\`${c}\``).join(', ')}) VALUES (${placeholders})`;
|
|
80
|
+
await this._connection.raw(sql, values);
|
|
81
|
+
inserted++;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Skip records that fail (FK violations, etc.)
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { records, inserted };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate seed records without inserting them.
|
|
94
|
+
* @param {string} table
|
|
95
|
+
* @param {number} [count=10]
|
|
96
|
+
* @param {Object} [context={}]
|
|
97
|
+
* @returns {Promise<Array>}
|
|
98
|
+
*/
|
|
99
|
+
async generate(table, count = 10, context = {}) {
|
|
100
|
+
const schema = await this._getTableSchema(table);
|
|
101
|
+
const systemPrompt = this._buildSystemPrompt(table, schema, count, context);
|
|
102
|
+
const messages = [
|
|
103
|
+
{ role: 'system', content: systemPrompt },
|
|
104
|
+
{ role: 'user', content: `Generate ${count} realistic seed records for the "${table}" table.${context.description ? ' Context: ' + context.description : ''}` },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const res = await this._manager.chat(this._provider, messages, {
|
|
108
|
+
model: this._model,
|
|
109
|
+
temperature: 0.8,
|
|
110
|
+
max_tokens: 4096,
|
|
111
|
+
response_format: 'json',
|
|
112
|
+
json_schema: {
|
|
113
|
+
name: 'seed_data',
|
|
114
|
+
schema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
records: { type: 'array', items: { type: 'object' } },
|
|
118
|
+
},
|
|
119
|
+
required: ['records'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return this._extractRecords(res);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @private */
|
|
128
|
+
async _getTableSchema(table) {
|
|
129
|
+
if (!this._connection) return '(no connection)';
|
|
130
|
+
try {
|
|
131
|
+
const dialect = this._connection.config?.client || 'mysql';
|
|
132
|
+
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
133
|
+
const res = await this._connection.raw(
|
|
134
|
+
`SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '${table}' ORDER BY ordinal_position`
|
|
135
|
+
);
|
|
136
|
+
return (res.rows || res).map(c => `${c.column_name} ${c.data_type}${c.is_nullable === 'YES' ? ' NULL' : ' NOT NULL'}${c.column_default ? ' DEFAULT ' + c.column_default : ''}`).join('\n');
|
|
137
|
+
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
138
|
+
const res = await this._connection.raw(`PRAGMA table_info("${table}")`);
|
|
139
|
+
return (Array.isArray(res) ? res : []).map(c => `${c.name} ${c.type}${c.notnull ? ' NOT NULL' : ' NULL'}${c.dflt_value ? ' DEFAULT ' + c.dflt_value : ''}`).join('\n');
|
|
140
|
+
} else {
|
|
141
|
+
const res = await this._connection.raw(`DESCRIBE \`${table}\``);
|
|
142
|
+
return (Array.isArray(res) ? res[0] || res : res).map(c => `${c.Field} ${c.Type}${c.Null === 'YES' ? ' NULL' : ' NOT NULL'}${c.Default ? ' DEFAULT ' + c.Default : ''}${c.Extra ? ' ' + c.Extra : ''}`).join('\n');
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return `(error: ${err.message})`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @private */
|
|
150
|
+
_buildSystemPrompt(table, schema, count, context) {
|
|
151
|
+
return `You are a database seed data generator. Generate realistic, diverse, and contextually appropriate test data.
|
|
152
|
+
|
|
153
|
+
TABLE: ${table}
|
|
154
|
+
SCHEMA:
|
|
155
|
+
${schema}
|
|
156
|
+
|
|
157
|
+
RULES:
|
|
158
|
+
- Return a JSON object with a "records" key containing an array of ${count} objects.
|
|
159
|
+
- Each object should have keys matching the column names (exclude auto-increment id columns).
|
|
160
|
+
- Use realistic names, emails, dates, etc. — not lorem ipsum.
|
|
161
|
+
- Respect data types and constraints (NOT NULL, defaults).
|
|
162
|
+
- Foreign key values should use integers 1-${Math.max(5, count)}.
|
|
163
|
+
- Dates should be in ISO format (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS).
|
|
164
|
+
${context.locale ? `- Use locale: ${context.locale}` : ''}
|
|
165
|
+
${context.domain ? `- Domain context: ${context.domain}` : ''}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @private */
|
|
169
|
+
_extractRecords(res) {
|
|
170
|
+
let content = res?.output_text || res?.choices?.[0]?.message?.content || res?.content?.[0]?.text || res?.message?.content || '';
|
|
171
|
+
if (typeof content !== 'string') content = JSON.stringify(content);
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(content);
|
|
174
|
+
return Array.isArray(parsed.records) ? parsed.records : (Array.isArray(parsed) ? parsed : []);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = AISeeder;
|