@askdb/studio 0.2.0-beta.2 → 0.2.0-beta.20
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 +8 -2
- package/bin/askdb-studio.js +3 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +8 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -10
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-2gPMwDon.js +68 -0
- package/dist/client/assets/index-DY6BxO-7.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +501 -104
- package/dist/server.js.map +1 -1
- package/dist/shared/api.d.ts +61 -1
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +14 -9
- package/dist/client/assets/index-Bn3ZXmWq.js +0 -8
- package/dist/client/assets/index-Bsn2S5X2.css +0 -2
package/dist/server.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import { extname, relative, join, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { generateText as defaultGenerateText } from "ai";
|
|
6
7
|
import { getAskDbRuntimeConfig } from "@askdb/config";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { aiKeyMissingMessage, createAiRegistry, resolveAiConfig, resolveEmbeddingConfig, } from "@askdb/ai";
|
|
9
|
+
import { azureProvider } from "@askdb/ai-azure";
|
|
10
|
+
import { googleProvider } from "@askdb/ai-google";
|
|
11
|
+
import { openaiProvider } from "@askdb/ai-openai";
|
|
12
|
+
import { ask, isBuiltInDialectId, loadSchema, suggestEnrichment, tenantScopeSchema, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
|
|
13
|
+
import { buildSchemaIndex, chunkContentHash, chunkSchema, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
|
|
14
|
+
import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
|
|
15
|
+
const ai = createAiRegistry([openaiProvider, azureProvider, googleProvider]);
|
|
12
16
|
const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
|
|
13
17
|
const STUDIO_RAG_MOCK_DIMENSIONS = 64;
|
|
14
18
|
const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
|
|
15
19
|
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
20
|
+
let studioPgvectorStoreFactoryForTests;
|
|
21
|
+
export function setStudioPgvectorStoreFactoryForTests(factory) {
|
|
22
|
+
studioPgvectorStoreFactoryForTests = factory;
|
|
23
|
+
}
|
|
16
24
|
export function createStudioServer(options) {
|
|
17
25
|
const schemaDir = resolve(options.schema);
|
|
18
26
|
if (!existsSync(schemaDir)) {
|
|
@@ -41,6 +49,22 @@ export function createStudioServer(options) {
|
|
|
41
49
|
saveDraft(state, tableId, draft);
|
|
42
50
|
return writeJson(res, 200, serializeWorkspace(state.workspace));
|
|
43
51
|
}
|
|
52
|
+
if (req.method === "POST" && url.pathname === "/api/concepts") {
|
|
53
|
+
const body = await readJson(req);
|
|
54
|
+
const concepts = parseConceptsBody(body);
|
|
55
|
+
saveConceptsDraft(state, concepts);
|
|
56
|
+
return writeJson(res, 200, serializeWorkspace(state.workspace));
|
|
57
|
+
}
|
|
58
|
+
if (req.method === "POST" && url.pathname === "/api/tenant-policy") {
|
|
59
|
+
const body = await readJson(req);
|
|
60
|
+
const { frontmatter, bodyText } = parseTenantPolicyBody(body);
|
|
61
|
+
saveTenantPolicy(state, frontmatter, bodyText);
|
|
62
|
+
return writeJson(res, 200, serializeWorkspace(state.workspace));
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "POST" && url.pathname === "/api/suggest-tenant-policy") {
|
|
65
|
+
const draft = await suggestTenantPolicyDraft(state);
|
|
66
|
+
return writeJson(res, 200, draft);
|
|
67
|
+
}
|
|
44
68
|
if (req.method === "POST" && url.pathname === "/api/suggest") {
|
|
45
69
|
const body = await readJson(req);
|
|
46
70
|
const source = parseSuggestSource(body);
|
|
@@ -48,22 +72,41 @@ export function createStudioServer(options) {
|
|
|
48
72
|
return writeJson(res, 200, { candidates });
|
|
49
73
|
}
|
|
50
74
|
if (req.method === "GET" && url.pathname === "/api/rag/status") {
|
|
51
|
-
return writeJson(res, 200, getRagStatus(state
|
|
75
|
+
return writeJson(res, 200, await getRagStatus(state));
|
|
52
76
|
}
|
|
53
77
|
if (req.method === "POST" && url.pathname === "/api/rag/index") {
|
|
54
|
-
const result = await indexRag(state
|
|
78
|
+
const result = await indexRag(state);
|
|
55
79
|
return writeJson(res, 200, result);
|
|
56
80
|
}
|
|
57
81
|
if (req.method === "POST" && url.pathname === "/api/rag/query") {
|
|
58
82
|
const body = await readJson(req);
|
|
59
83
|
const query = parseRagQuery(body);
|
|
60
|
-
const result = await queryRag(state
|
|
84
|
+
const result = await queryRag(state, query);
|
|
61
85
|
return writeJson(res, 200, result);
|
|
62
86
|
}
|
|
63
87
|
if (req.method === "POST" && url.pathname === "/api/ask") {
|
|
64
88
|
const body = await readJson(req);
|
|
65
89
|
const options = parseAskBody(body);
|
|
66
|
-
const result = await askSampleQuestion(state
|
|
90
|
+
const result = await askSampleQuestion(state, options);
|
|
91
|
+
return writeJson(res, 200, result);
|
|
92
|
+
}
|
|
93
|
+
if (req.method === "GET" && url.pathname === "/api/history") {
|
|
94
|
+
return writeJson(res, 200, readPlaygroundHistory(state.schemaDir));
|
|
95
|
+
}
|
|
96
|
+
if (req.method === "POST" && url.pathname === "/api/history") {
|
|
97
|
+
const body = await readJson(req);
|
|
98
|
+
const entry = parsePlaygroundHistoryEntry(body);
|
|
99
|
+
appendPlaygroundHistory(state.schemaDir, entry);
|
|
100
|
+
return writeJson(res, 200, { ok: true });
|
|
101
|
+
}
|
|
102
|
+
if (req.method === "DELETE" && url.pathname.startsWith("/api/history/")) {
|
|
103
|
+
const id = decodeURIComponent(url.pathname.slice("/api/history/".length));
|
|
104
|
+
const result = deletePlaygroundHistoryEntry(state.schemaDir, id);
|
|
105
|
+
return writeJson(res, 200, result);
|
|
106
|
+
}
|
|
107
|
+
if (req.method === "POST" && url.pathname === "/api/execute") {
|
|
108
|
+
const body = await readJson(req);
|
|
109
|
+
const result = await executeQuery(body);
|
|
67
110
|
return writeJson(res, 200, result);
|
|
68
111
|
}
|
|
69
112
|
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
@@ -83,9 +126,7 @@ export function serializeWorkspace(workspace) {
|
|
|
83
126
|
const rt = getAskDbRuntimeConfig();
|
|
84
127
|
const aiConfig = (() => {
|
|
85
128
|
try {
|
|
86
|
-
return
|
|
87
|
-
modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
|
|
88
|
-
});
|
|
129
|
+
return resolveAiConfig(rt.ai.aiEnv);
|
|
89
130
|
}
|
|
90
131
|
catch {
|
|
91
132
|
// A misconfigured AI env (e.g. azure without resourceName) shouldn't crash the workspace
|
|
@@ -113,8 +154,33 @@ export function serializeWorkspace(workspace) {
|
|
|
113
154
|
};
|
|
114
155
|
}),
|
|
115
156
|
concepts: workspace.concepts?.frontmatter.concepts ?? [],
|
|
157
|
+
tenantPolicy: (() => {
|
|
158
|
+
const schema = loadSchema(workspace.schemaDir);
|
|
159
|
+
return schema.tenantPolicy ?? null;
|
|
160
|
+
})(),
|
|
116
161
|
};
|
|
117
162
|
}
|
|
163
|
+
function saveConceptsDraft(state, concepts) {
|
|
164
|
+
saveConcepts(state.workspace, { concepts });
|
|
165
|
+
state.workspace = loadWorkspace(state.schemaDir);
|
|
166
|
+
}
|
|
167
|
+
function saveTenantPolicy(state, frontmatter, body) {
|
|
168
|
+
const filePath = join(state.schemaDir, "tenant-policy.md");
|
|
169
|
+
const md = writeTenantPolicyMarkdown(frontmatter, body);
|
|
170
|
+
writeFileSync(filePath, md, "utf8");
|
|
171
|
+
state.workspace = loadWorkspace(state.schemaDir);
|
|
172
|
+
}
|
|
173
|
+
function parseTenantPolicyBody(body) {
|
|
174
|
+
if (!isRecord(body) || !isRecord(body.frontmatter)) {
|
|
175
|
+
throw new StudioHttpError(400, "`frontmatter` object is required.");
|
|
176
|
+
}
|
|
177
|
+
const parsed = tenantPolicyFrontmatterSchema.safeParse(body.frontmatter);
|
|
178
|
+
if (!parsed.success) {
|
|
179
|
+
throw new StudioHttpError(400, `Invalid tenant policy: ${parsed.error.message}`);
|
|
180
|
+
}
|
|
181
|
+
const bodyText = typeof body.body === "string" ? body.body : "";
|
|
182
|
+
return { frontmatter: parsed.data, bodyText };
|
|
183
|
+
}
|
|
118
184
|
function saveDraft(state, tableId, draft) {
|
|
119
185
|
const table = state.workspace.tables.find((candidate) => candidate.physical.id === tableId);
|
|
120
186
|
if (!table)
|
|
@@ -134,38 +200,118 @@ function saveDraft(state, tableId, draft) {
|
|
|
134
200
|
}
|
|
135
201
|
async function suggestForSource(workspace, source) {
|
|
136
202
|
const rt = getAskDbRuntimeConfig();
|
|
137
|
-
const
|
|
138
|
-
? "ASKDB_STUDIO_MODEL"
|
|
139
|
-
: rt.ai.tuiModel
|
|
140
|
-
? "ASKDB_TUI_MODEL"
|
|
141
|
-
: undefined;
|
|
142
|
-
const model = await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
|
|
143
|
-
modelEnvVar,
|
|
144
|
-
});
|
|
203
|
+
const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
|
|
145
204
|
if (!model) {
|
|
146
|
-
throw new StudioHttpError(400,
|
|
205
|
+
throw new StudioHttpError(400, aiKeyMissingMessage("AI enrichment suggestions"));
|
|
147
206
|
}
|
|
148
207
|
const candidates = await suggestEnrichment(buildSuggestionTarget(workspace, source), buildSuggestionContext(workspace, source.tableId), model);
|
|
149
208
|
return candidates.map((candidate) => ({ text: candidate.text }));
|
|
150
209
|
}
|
|
151
|
-
async function
|
|
210
|
+
async function suggestTenantPolicyDraft(state) {
|
|
211
|
+
const rt = getAskDbRuntimeConfig();
|
|
212
|
+
const model = await ai.createLanguageModelFromEnv(rt.ai.aiEnv);
|
|
213
|
+
if (!model) {
|
|
214
|
+
throw new StudioHttpError(400, aiKeyMissingMessage("AI tenant policy suggestion"));
|
|
215
|
+
}
|
|
216
|
+
// Build a compact DDL-like representation of the schema for the prompt
|
|
217
|
+
const workspace = state.workspace;
|
|
218
|
+
const schemaId = workspace.physical.schemaId;
|
|
219
|
+
const schemaDdl = workspace.tables
|
|
220
|
+
.map((t) => {
|
|
221
|
+
const cols = t.physical.columns
|
|
222
|
+
.map((c) => {
|
|
223
|
+
const parts = [c.name, c.type];
|
|
224
|
+
if (c.primaryKey)
|
|
225
|
+
parts.push("PRIMARY KEY");
|
|
226
|
+
if (!c.nullable)
|
|
227
|
+
parts.push("NOT NULL");
|
|
228
|
+
return ` ${parts.join(" ")}`;
|
|
229
|
+
})
|
|
230
|
+
.join(",\n");
|
|
231
|
+
const rels = (t.physical.relationships ?? [])
|
|
232
|
+
.map((r) => ` -- FK: ${r.from} -> ${r.to}`)
|
|
233
|
+
.join("\n");
|
|
234
|
+
return `CREATE TABLE ${t.physical.schema}.${t.physical.name} (\n${cols}\n);\n${rels}`.trim();
|
|
235
|
+
})
|
|
236
|
+
.join("\n\n");
|
|
237
|
+
const tableIds = workspace.tables.map((t) => t.physical.id);
|
|
238
|
+
const systemPrompt = `You are an expert database architect specializing in multi-tenant SaaS systems.
|
|
239
|
+
You analyze database schemas and produce a tenant-policy configuration in a specific JSON format.
|
|
240
|
+
|
|
241
|
+
The tenant policy identifies:
|
|
242
|
+
1. **roots** — Tables whose rows represent tenants (organizations, companies, accounts, etc).
|
|
243
|
+
Each root has: id (table ID like "table:public.orgs"), tenantIdColumn (column ref like "table:public.orgs#id"), label (human-readable name).
|
|
244
|
+
If a root has a parent root (e.g. sub_org belongs to org), include parent: { root: "<parent-root-id>", foreignKey: "<fk-column-ref>" }.
|
|
245
|
+
2. **hierarchy** — Edges showing parent→child relationships between roots or tenant-scoped tables.
|
|
246
|
+
Each edge: { parent: "<table-id>", child: "<table-id>", foreignKey: "<child-fk-column-ref>" }.
|
|
247
|
+
3. **scopedTables** — Tables that belong to a tenant via a direct column or a JOIN chain.
|
|
248
|
+
Each entry: { id: "<table-id>", scopeThrough: [{ root: "<root-id>", column: "<column-ref>" }] } for direct FK,
|
|
249
|
+
or { id: "<table-id>", scopeThrough: [{ root: "<root-id>", join: [{ from: "<col-ref>", to: "<col-ref>" }] }] } for indirect.
|
|
250
|
+
4. **polymorphicTables** — Tables with a type/id polymorphic association pattern.
|
|
251
|
+
Each entry: { id: "<table-id>", typeColumn: "<col-ref>", idColumn: "<col-ref>", mapping: { "<type_value>": "<target-table-id>" } }.
|
|
252
|
+
5. **globalTables** — Tables shared across all tenants (lookup tables, reference data). Array of table IDs.
|
|
253
|
+
6. **enforcement** — "strict" (reject queries on unknown tables) or "warn" (allow with warnings).
|
|
254
|
+
|
|
255
|
+
Column references use the format "table:<schema>.<table>#<column>".
|
|
256
|
+
Table IDs use the format "table:<schema>.<table>".
|
|
257
|
+
|
|
258
|
+
Available table IDs: ${JSON.stringify(tableIds)}
|
|
259
|
+
|
|
260
|
+
Return a JSON object with two keys:
|
|
261
|
+
- "frontmatter": the TenantPolicyFrontmatter object (schemaId, enforcement, roots, hierarchy?, scopedTables?, polymorphicTables?, globalTables?)
|
|
262
|
+
- "body": a markdown body string with sections "## Hierarchy", "## Scope rules", "## Sensitive interactions" explaining the policy in plain English.
|
|
263
|
+
|
|
264
|
+
Return ONLY valid JSON, no markdown fences or explanations.`;
|
|
265
|
+
const userPrompt = `Analyze this database schema and draft a complete multi-tenant policy.
|
|
266
|
+
|
|
267
|
+
Schema ID: ${schemaId}
|
|
268
|
+
|
|
269
|
+
${schemaDdl}
|
|
270
|
+
|
|
271
|
+
Look for:
|
|
272
|
+
- Tables that represent organizations, companies, accounts, tenants, or workspaces — these are roots.
|
|
273
|
+
- Foreign key columns like org_id, company_id, tenant_id, account_id that scope data to a tenant.
|
|
274
|
+
- Tables with no tenant FK that are clearly shared/global (lookup tables, enums, config).
|
|
275
|
+
- Polymorphic association patterns (type_column + id_column pointing to multiple parent tables).
|
|
276
|
+
- Multi-level hierarchies (org → team → user, company → department → employee).
|
|
277
|
+
|
|
278
|
+
If the schema has no clear multi-tenant pattern, still make your best guess at what would be the tenant root.`;
|
|
279
|
+
const result = await defaultGenerateText({
|
|
280
|
+
model,
|
|
281
|
+
system: systemPrompt,
|
|
282
|
+
prompt: userPrompt,
|
|
283
|
+
});
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(result.text);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw new StudioHttpError(500, `AI returned invalid JSON for tenant policy suggestion. Raw text: ${result.text.slice(0, 500)}`);
|
|
290
|
+
}
|
|
291
|
+
// Validate frontmatter shape
|
|
292
|
+
const validation = tenantPolicyFrontmatterSchema.safeParse(parsed.frontmatter);
|
|
293
|
+
if (!validation.success) {
|
|
294
|
+
throw new StudioHttpError(500, `AI-generated tenant policy frontmatter is invalid: ${validation.error.issues.map((i) => i.message).join("; ")}`);
|
|
295
|
+
}
|
|
296
|
+
parsed.frontmatter = validation.data;
|
|
297
|
+
return parsed;
|
|
298
|
+
}
|
|
299
|
+
async function askSampleQuestion(state, options) {
|
|
152
300
|
const rt = getAskDbRuntimeConfig();
|
|
153
301
|
const mockSql = rt.dev.mockSql;
|
|
154
|
-
const aiConfig = mockSql ? undefined :
|
|
302
|
+
const aiConfig = mockSql ? undefined : resolveAiConfig(rt.ai.aiEnv);
|
|
155
303
|
if (!mockSql && !aiConfig) {
|
|
156
|
-
throw new StudioHttpError(400, `${
|
|
304
|
+
throw new StudioHttpError(400, `${aiKeyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
|
|
157
305
|
}
|
|
158
|
-
const schema = loadSchema(schemaDir);
|
|
306
|
+
const schema = loadSchema(state.schemaDir);
|
|
159
307
|
const retrievedChunks = [];
|
|
160
308
|
const usage = createRequestUsageCollector();
|
|
161
309
|
const ragIndex = options.useRag
|
|
162
|
-
? await createCurrentStudioRagIndex(
|
|
310
|
+
? await createCurrentStudioRagIndex(state, "using RAG for sample generation", usage)
|
|
163
311
|
: undefined;
|
|
164
312
|
const model = mockSql
|
|
165
313
|
? undefined
|
|
166
|
-
: (await
|
|
167
|
-
modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
|
|
168
|
-
}));
|
|
314
|
+
: (await ai.createLanguageModelFromEnv(rt.ai.aiEnv));
|
|
169
315
|
const schemaProvider = "provider" in schema && typeof schema.provider === "string"
|
|
170
316
|
? schema.provider
|
|
171
317
|
: undefined;
|
|
@@ -177,6 +323,8 @@ async function askSampleQuestion(schemaDir, options) {
|
|
|
177
323
|
model,
|
|
178
324
|
dialect,
|
|
179
325
|
explain: true,
|
|
326
|
+
...(options.tenantScope ? { tenantScope: options.tenantScope } : {}),
|
|
327
|
+
...(options.tenantSqlMode ? { tenantSqlMode: options.tenantSqlMode } : {}),
|
|
180
328
|
...(ragIndex
|
|
181
329
|
? {
|
|
182
330
|
retriever: async (params) => {
|
|
@@ -185,7 +333,7 @@ async function askSampleQuestion(schemaDir, options) {
|
|
|
185
333
|
results = await ragIndex.retriever(params);
|
|
186
334
|
}
|
|
187
335
|
catch (error) {
|
|
188
|
-
throw
|
|
336
|
+
throw formatStudioRagOperationError(error, ragIndex.config);
|
|
189
337
|
}
|
|
190
338
|
retrievedChunks.splice(0, retrievedChunks.length, ...results);
|
|
191
339
|
return results;
|
|
@@ -202,6 +350,8 @@ async function askSampleQuestion(schemaDir, options) {
|
|
|
202
350
|
: {
|
|
203
351
|
generateText: createTrackedGenerateText(usage),
|
|
204
352
|
},
|
|
353
|
+
}).finally(async () => {
|
|
354
|
+
await ragIndex?.dispose();
|
|
205
355
|
});
|
|
206
356
|
return {
|
|
207
357
|
sql: result.sql,
|
|
@@ -211,90 +361,115 @@ async function askSampleQuestion(schemaDir, options) {
|
|
|
211
361
|
enabled: options.useRag,
|
|
212
362
|
chunks: options.useRag ? retrievedChunks.map(serializeRagResult) : [],
|
|
213
363
|
},
|
|
364
|
+
tenant: options.tenantScope
|
|
365
|
+
? {
|
|
366
|
+
enabled: true,
|
|
367
|
+
sqlMode: options.tenantSqlMode ?? "sql-only",
|
|
368
|
+
bindings: result.tenantBindings ?? [],
|
|
369
|
+
params: result.tenantParams ?? [],
|
|
370
|
+
}
|
|
371
|
+
: null,
|
|
214
372
|
usage: usage.toDto(),
|
|
215
373
|
};
|
|
216
374
|
}
|
|
217
|
-
function getRagStatus(
|
|
375
|
+
async function getRagStatus(state) {
|
|
218
376
|
const config = resolveStudioRagEmbedderConfig();
|
|
219
|
-
const sources = loadChunkerSourcesFromDir(schemaDir);
|
|
377
|
+
const sources = loadChunkerSourcesFromDir(state.schemaDir);
|
|
220
378
|
const chunkResult = chunkSchema(sources);
|
|
221
|
-
const lockPath = join(schemaDir, "schema.lock.json");
|
|
222
|
-
const embeddingsJsonPath = join(schemaDir, "schema.embeddings.json");
|
|
223
|
-
const embeddingsBinPath = join(schemaDir, "schema.embeddings.bin");
|
|
379
|
+
const lockPath = join(state.schemaDir, "schema.lock.json");
|
|
224
380
|
const lock = readOptionalJson(lockPath);
|
|
225
|
-
const embeddings = readOptionalJson(embeddingsJsonPath);
|
|
226
381
|
const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
|
|
227
382
|
const lockHashes = lock?.hashes ?? {};
|
|
228
383
|
const hashIds = Object.keys(currentHashes);
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
384
|
+
const store = await openStudioRagStore(state, config.dimensions);
|
|
385
|
+
try {
|
|
386
|
+
const chunksIndexed = await countStudioRagStoreChunks(store, sources.schema.schemaId);
|
|
387
|
+
const stale = !lock ||
|
|
388
|
+
lock.embedderId !== config.embedderId ||
|
|
389
|
+
Object.keys(lockHashes).length !== hashIds.length ||
|
|
390
|
+
hashIds.some((id) => lockHashes[id] !== currentHashes[id]) ||
|
|
391
|
+
chunksIndexed !== hashIds.length;
|
|
392
|
+
const fileArtifacts = store.kind === "file" && store.basePath
|
|
393
|
+
? {
|
|
394
|
+
lock: existsSync(lockPath),
|
|
395
|
+
embeddingsJson: existsSync(`${store.basePath}.embeddings.json`),
|
|
396
|
+
embeddingsBin: existsSync(`${store.basePath}.embeddings.bin`),
|
|
397
|
+
}
|
|
398
|
+
: {
|
|
399
|
+
lock: existsSync(lockPath),
|
|
400
|
+
embeddingsJson: false,
|
|
401
|
+
embeddingsBin: false,
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
schemaId: sources.schema.schemaId,
|
|
405
|
+
store: {
|
|
406
|
+
kind: store.kind,
|
|
407
|
+
basePath: store.basePath ?? null,
|
|
408
|
+
table: store.table ?? null,
|
|
409
|
+
indexStrategy: store.indexStrategy ?? null,
|
|
410
|
+
},
|
|
411
|
+
embedder: {
|
|
412
|
+
kind: config.kind,
|
|
413
|
+
label: config.label,
|
|
414
|
+
configured: config.configured,
|
|
415
|
+
expectedId: config.embedderId,
|
|
416
|
+
indexedId: lock?.embedderId ?? null,
|
|
417
|
+
provider: config.kind === "ai-sdk" ? config.provider : null,
|
|
418
|
+
model: config.kind === "ai-sdk" ? config.model : null,
|
|
419
|
+
baseUrl: config.kind === "ai-sdk" ? config.baseUrl ?? null : null,
|
|
420
|
+
},
|
|
421
|
+
embedderId: lock?.embedderId ?? config.embedderId,
|
|
422
|
+
expectedEmbedderId: config.embedderId,
|
|
423
|
+
hasIndex: Boolean(lock && chunksIndexed > 0),
|
|
424
|
+
stale,
|
|
425
|
+
updatedAt: lock?.updatedAt ?? null,
|
|
426
|
+
chunksTotal: chunkResult.chunks.length,
|
|
427
|
+
chunksIndexed,
|
|
428
|
+
dimensions: config.dimensions,
|
|
429
|
+
expectedDimensions: config.dimensions,
|
|
430
|
+
sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
|
|
431
|
+
sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
|
|
432
|
+
files: fileArtifacts,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await store.dispose();
|
|
437
|
+
}
|
|
265
438
|
}
|
|
266
|
-
async function indexRag(
|
|
439
|
+
async function indexRag(state) {
|
|
267
440
|
const config = resolveStudioRagEmbedderConfig();
|
|
268
441
|
if (!config.configured) {
|
|
269
442
|
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
270
443
|
}
|
|
271
444
|
const usage = createRequestUsageCollector();
|
|
272
|
-
clearIncompatibleRagStore(
|
|
273
|
-
const sources = loadChunkerSourcesFromDir(schemaDir);
|
|
274
|
-
const store =
|
|
445
|
+
clearIncompatibleRagStore(state, config);
|
|
446
|
+
const sources = loadChunkerSourcesFromDir(state.schemaDir);
|
|
447
|
+
const store = await openStudioRagStore(state, config.dimensions);
|
|
275
448
|
let result;
|
|
276
449
|
try {
|
|
277
450
|
result = await buildSchemaIndex({
|
|
278
451
|
schema: sources,
|
|
279
452
|
embedder: await createStudioRagEmbedder(config, usage),
|
|
280
|
-
store,
|
|
453
|
+
store: store.store,
|
|
281
454
|
embedderId: config.embedderId,
|
|
282
|
-
lockFilePath: join(schemaDir, "schema.lock.json"),
|
|
455
|
+
lockFilePath: join(state.schemaDir, "schema.lock.json"),
|
|
283
456
|
});
|
|
284
457
|
}
|
|
285
458
|
catch (error) {
|
|
286
|
-
throw
|
|
459
|
+
throw formatStudioRagOperationError(error, config);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
await store.dispose();
|
|
287
463
|
}
|
|
288
|
-
store.flush();
|
|
289
464
|
return {
|
|
290
|
-
status: getRagStatus(
|
|
465
|
+
status: await getRagStatus(state),
|
|
291
466
|
stats: result.stats,
|
|
292
467
|
usage: usage.toDto(),
|
|
293
468
|
};
|
|
294
469
|
}
|
|
295
|
-
async function queryRag(
|
|
470
|
+
async function queryRag(state, query) {
|
|
296
471
|
const usage = createRequestUsageCollector();
|
|
297
|
-
const ragIndex = await createCurrentStudioRagIndex(
|
|
472
|
+
const ragIndex = await createCurrentStudioRagIndex(state, "querying chunks", usage);
|
|
298
473
|
let results;
|
|
299
474
|
try {
|
|
300
475
|
results = await ragIndex.retriever({
|
|
@@ -307,7 +482,10 @@ async function queryRag(schemaDir, query) {
|
|
|
307
482
|
});
|
|
308
483
|
}
|
|
309
484
|
catch (error) {
|
|
310
|
-
throw
|
|
485
|
+
throw formatStudioRagOperationError(error, ragIndex.config);
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await ragIndex.dispose();
|
|
311
489
|
}
|
|
312
490
|
return {
|
|
313
491
|
question: query.question,
|
|
@@ -316,12 +494,12 @@ async function queryRag(schemaDir, query) {
|
|
|
316
494
|
usage: usage.toDto(),
|
|
317
495
|
};
|
|
318
496
|
}
|
|
319
|
-
async function createCurrentStudioRagIndex(
|
|
497
|
+
async function createCurrentStudioRagIndex(state, action, usage) {
|
|
320
498
|
const config = resolveStudioRagEmbedderConfig();
|
|
321
499
|
if (!config.configured) {
|
|
322
500
|
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
323
501
|
}
|
|
324
|
-
const status = getRagStatus(
|
|
502
|
+
const status = (await getRagStatus(state));
|
|
325
503
|
if (!status.hasIndex) {
|
|
326
504
|
throw new StudioHttpError(400, `Build the RAG index before ${action}.`);
|
|
327
505
|
}
|
|
@@ -331,6 +509,7 @@ async function createCurrentStudioRagIndex(schemaDir, action, usage) {
|
|
|
331
509
|
if (!status.schemaId || typeof status.chunksTotal !== "number") {
|
|
332
510
|
throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
|
|
333
511
|
}
|
|
512
|
+
const store = await openStudioRagStore(state, config.dimensions);
|
|
334
513
|
return {
|
|
335
514
|
config,
|
|
336
515
|
status: {
|
|
@@ -339,10 +518,85 @@ async function createCurrentStudioRagIndex(schemaDir, action, usage) {
|
|
|
339
518
|
},
|
|
340
519
|
retriever: createRetriever({
|
|
341
520
|
embedder: await createStudioRagEmbedder(config, usage),
|
|
342
|
-
store:
|
|
521
|
+
store: store.store,
|
|
343
522
|
}),
|
|
523
|
+
dispose: async () => {
|
|
524
|
+
await store.dispose();
|
|
525
|
+
},
|
|
344
526
|
};
|
|
345
527
|
}
|
|
528
|
+
function resolveStudioRagStoreConfig(state) {
|
|
529
|
+
const rt = getAskDbRuntimeConfig();
|
|
530
|
+
const kind = rt.structured.rag.store;
|
|
531
|
+
if (kind === "memory")
|
|
532
|
+
return { kind };
|
|
533
|
+
if (kind === "file") {
|
|
534
|
+
const basePath = rt.structured.rag.storeConfig.file?.basePath?.trim();
|
|
535
|
+
return { kind, basePath: basePath ? resolve(basePath) : join(state.schemaDir, "schema") };
|
|
536
|
+
}
|
|
537
|
+
const connectionString = pickFlat(rt.flat, "ASKDB_PGVECTOR_URL");
|
|
538
|
+
return {
|
|
539
|
+
kind,
|
|
540
|
+
connectionString,
|
|
541
|
+
table: rt.structured.rag.storeConfig.pgvector?.table?.trim() || undefined,
|
|
542
|
+
indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async function openStudioRagStore(state, dimensions) {
|
|
546
|
+
const config = resolveStudioRagStoreConfig(state);
|
|
547
|
+
if (config.kind === "memory") {
|
|
548
|
+
state.ragMemoryStore ??= createMemoryStore();
|
|
549
|
+
return {
|
|
550
|
+
kind: "memory",
|
|
551
|
+
store: state.ragMemoryStore,
|
|
552
|
+
dispose: async () => { },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (config.kind === "file") {
|
|
556
|
+
const store = createFileStore({ basePath: config.basePath });
|
|
557
|
+
return {
|
|
558
|
+
kind: "file",
|
|
559
|
+
store,
|
|
560
|
+
basePath: config.basePath,
|
|
561
|
+
dispose: async () => {
|
|
562
|
+
store.flush();
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (!config.connectionString) {
|
|
567
|
+
throw new StudioHttpError(400, 'Studio pgvector RAG requires `ASKDB_PGVECTOR_URL` via `askdb.config.ts`.');
|
|
568
|
+
}
|
|
569
|
+
const pgvectorFactory = studioPgvectorStoreFactoryForTests ?? createPgvectorStore;
|
|
570
|
+
const store = pgvectorFactory({
|
|
571
|
+
connectionString: config.connectionString,
|
|
572
|
+
table: config.table,
|
|
573
|
+
dimensions,
|
|
574
|
+
...(config.indexStrategy ? { indexStrategy: config.indexStrategy } : {}),
|
|
575
|
+
});
|
|
576
|
+
await store.ensureSchema();
|
|
577
|
+
return {
|
|
578
|
+
kind: "pgvector",
|
|
579
|
+
store,
|
|
580
|
+
table: config.table,
|
|
581
|
+
indexStrategy: config.indexStrategy,
|
|
582
|
+
dispose: async () => {
|
|
583
|
+
await store.close();
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
async function countStudioRagStoreChunks(store, schemaId) {
|
|
588
|
+
if (typeof store.store.count === "function") {
|
|
589
|
+
return store.store.count({ schemaId });
|
|
590
|
+
}
|
|
591
|
+
if (typeof store.store.size === "function") {
|
|
592
|
+
return store.store.size();
|
|
593
|
+
}
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
function pickFlat(flat, key) {
|
|
597
|
+
const value = flat[key];
|
|
598
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
599
|
+
}
|
|
346
600
|
function pickEnv(env, key) {
|
|
347
601
|
const v = env[key];
|
|
348
602
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
@@ -350,7 +604,7 @@ function pickEnv(env, key) {
|
|
|
350
604
|
function resolveStudioRagEmbedderConfig() {
|
|
351
605
|
const rt = getAskDbRuntimeConfig();
|
|
352
606
|
const base = rt.ai.aiEnv;
|
|
353
|
-
const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER")
|
|
607
|
+
const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER");
|
|
354
608
|
const kind = explicitKind?.toLowerCase();
|
|
355
609
|
if (kind === "mock") {
|
|
356
610
|
return {
|
|
@@ -365,8 +619,8 @@ function resolveStudioRagEmbedderConfig() {
|
|
|
365
619
|
throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
|
|
366
620
|
}
|
|
367
621
|
const env = buildStudioRagEmbeddingEnv(kind, base);
|
|
368
|
-
const aiConfig =
|
|
369
|
-
modelEnvVar: "
|
|
622
|
+
const aiConfig = resolveEmbeddingConfig(env, {
|
|
623
|
+
modelEnvVar: "ASKDB_RAG_EMBEDDER_MODEL",
|
|
370
624
|
modelDefault: DEFAULT_EMBEDDING_MODEL,
|
|
371
625
|
});
|
|
372
626
|
if (!aiConfig && kind === undefined) {
|
|
@@ -380,8 +634,7 @@ function resolveStudioRagEmbedderConfig() {
|
|
|
380
634
|
}
|
|
381
635
|
const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
|
|
382
636
|
const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
|
|
383
|
-
const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS")
|
|
384
|
-
pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_DIMENSIONS"));
|
|
637
|
+
const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS"));
|
|
385
638
|
const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
|
|
386
639
|
return {
|
|
387
640
|
kind: "ai-sdk",
|
|
@@ -397,14 +650,12 @@ function resolveStudioRagEmbedderConfig() {
|
|
|
397
650
|
};
|
|
398
651
|
}
|
|
399
652
|
function buildStudioRagEmbeddingEnv(kind, base) {
|
|
400
|
-
const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY")
|
|
401
|
-
const
|
|
402
|
-
const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_BASE_URL");
|
|
653
|
+
const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY");
|
|
654
|
+
const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL");
|
|
403
655
|
return {
|
|
404
656
|
...base,
|
|
405
657
|
...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
|
|
406
658
|
...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
|
|
407
|
-
...(modelOverride ? { ASKDB_STUDIO_RAG_EMBEDDER_MODEL: modelOverride } : {}),
|
|
408
659
|
...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
|
|
409
660
|
};
|
|
410
661
|
}
|
|
@@ -426,7 +677,7 @@ async function createStudioRagEmbedder(config, usage) {
|
|
|
426
677
|
if (!config.aiConfig) {
|
|
427
678
|
throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
|
|
428
679
|
}
|
|
429
|
-
const model = await
|
|
680
|
+
const model = await ai.createEmbeddingModel(config.aiConfig, {
|
|
430
681
|
dimensions: config.requestDimensions,
|
|
431
682
|
});
|
|
432
683
|
return createAiSdkEmbedder({
|
|
@@ -441,11 +692,16 @@ async function createStudioRagEmbedder(config, usage) {
|
|
|
441
692
|
},
|
|
442
693
|
});
|
|
443
694
|
}
|
|
444
|
-
function
|
|
695
|
+
function formatStudioRagOperationError(error, config) {
|
|
696
|
+
if (error instanceof StudioHttpError)
|
|
697
|
+
return error;
|
|
445
698
|
if (config.kind === "mock") {
|
|
446
699
|
return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
|
|
447
700
|
}
|
|
448
701
|
const apiError = findApiCallError(error);
|
|
702
|
+
if (!apiError) {
|
|
703
|
+
return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
|
|
704
|
+
}
|
|
449
705
|
const parts = [
|
|
450
706
|
`Studio RAG embedding request failed for provider ${config.provider}, model ${config.model}.`,
|
|
451
707
|
];
|
|
@@ -488,15 +744,18 @@ function truncateForMessage(value) {
|
|
|
488
744
|
return undefined;
|
|
489
745
|
return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
|
|
490
746
|
}
|
|
491
|
-
function clearIncompatibleRagStore(
|
|
492
|
-
const
|
|
747
|
+
function clearIncompatibleRagStore(state, config) {
|
|
748
|
+
const store = resolveStudioRagStoreConfig(state);
|
|
749
|
+
if (store.kind !== "file")
|
|
750
|
+
return;
|
|
751
|
+
const embeddingsJsonPath = `${store.basePath}.embeddings.json`;
|
|
493
752
|
const embeddings = readOptionalJson(embeddingsJsonPath);
|
|
494
753
|
if (embeddings?.dimensions === undefined || embeddings.dimensions === config.dimensions)
|
|
495
754
|
return;
|
|
496
755
|
for (const path of [
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
join(schemaDir, "schema.lock.json"),
|
|
756
|
+
`${store.basePath}.embeddings.json`,
|
|
757
|
+
`${store.basePath}.embeddings.bin`,
|
|
758
|
+
join(state.schemaDir, "schema.lock.json"),
|
|
500
759
|
]) {
|
|
501
760
|
rmSync(path, { force: true });
|
|
502
761
|
}
|
|
@@ -546,6 +805,116 @@ function stableTokenHash(token) {
|
|
|
546
805
|
}
|
|
547
806
|
return hash >>> 0;
|
|
548
807
|
}
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// Playground history helpers
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
const HISTORY_MAX_STORED = 200;
|
|
812
|
+
const HISTORY_MAX_RETURNED = 50;
|
|
813
|
+
function playgroundHistoryPath(schemaDir) {
|
|
814
|
+
return join(schemaDir, "playground-history.json");
|
|
815
|
+
}
|
|
816
|
+
function readPlaygroundHistory(schemaDir) {
|
|
817
|
+
const raw = readOptionalJson(playgroundHistoryPath(schemaDir));
|
|
818
|
+
const entries = Array.isArray(raw) ? raw : [];
|
|
819
|
+
return { entries: entries.slice(0, HISTORY_MAX_RETURNED) };
|
|
820
|
+
}
|
|
821
|
+
function appendPlaygroundHistory(schemaDir, entry) {
|
|
822
|
+
const histPath = playgroundHistoryPath(schemaDir);
|
|
823
|
+
const raw = readOptionalJson(histPath);
|
|
824
|
+
const existing = Array.isArray(raw)
|
|
825
|
+
? raw
|
|
826
|
+
: [];
|
|
827
|
+
const newEntry = {
|
|
828
|
+
...entry,
|
|
829
|
+
id: randomUUID(),
|
|
830
|
+
timestamp: new Date().toISOString(),
|
|
831
|
+
};
|
|
832
|
+
const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
|
|
833
|
+
writeFileSync(histPath, JSON.stringify(updated, null, 2), "utf8");
|
|
834
|
+
}
|
|
835
|
+
function deletePlaygroundHistoryEntry(schemaDir, id) {
|
|
836
|
+
const histPath = playgroundHistoryPath(schemaDir);
|
|
837
|
+
const raw = readOptionalJson(histPath);
|
|
838
|
+
const existing = Array.isArray(raw)
|
|
839
|
+
? raw
|
|
840
|
+
: [];
|
|
841
|
+
const filtered = existing.filter((e) => e.id !== id);
|
|
842
|
+
if (filtered.length === existing.length) {
|
|
843
|
+
return { ok: false, error: "not found" };
|
|
844
|
+
}
|
|
845
|
+
writeFileSync(histPath, JSON.stringify(filtered, null, 2), "utf8");
|
|
846
|
+
return { ok: true };
|
|
847
|
+
}
|
|
848
|
+
function parsePlaygroundHistoryEntry(body) {
|
|
849
|
+
if (!isRecord(body)) {
|
|
850
|
+
throw new StudioHttpError(400, "Request body must be a JSON object.");
|
|
851
|
+
}
|
|
852
|
+
if (typeof body.question !== "string" || body.question.trim() === "") {
|
|
853
|
+
throw new StudioHttpError(400, "`question` is required.");
|
|
854
|
+
}
|
|
855
|
+
if (body.mode !== "full" && body.mode !== "rag") {
|
|
856
|
+
throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
|
|
857
|
+
}
|
|
858
|
+
if (typeof body.sqlMode !== "string") {
|
|
859
|
+
throw new StudioHttpError(400, "`sqlMode` is required.");
|
|
860
|
+
}
|
|
861
|
+
if (typeof body.sql !== "string") {
|
|
862
|
+
throw new StudioHttpError(400, "`sql` is required.");
|
|
863
|
+
}
|
|
864
|
+
return body;
|
|
865
|
+
}
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// Execute endpoint helper
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
async function executeQuery(body) {
|
|
870
|
+
const databaseUrl = process.env["ASKDB_STUDIO_DATABASE_URL"];
|
|
871
|
+
if (!databaseUrl) {
|
|
872
|
+
return { ok: false, error: "ASKDB_STUDIO_DATABASE_URL is not configured" };
|
|
873
|
+
}
|
|
874
|
+
if (!isRecord(body) || typeof body.sql !== "string" || body.sql.trim() === "") {
|
|
875
|
+
return { ok: false, error: "`sql` is required." };
|
|
876
|
+
}
|
|
877
|
+
const sql = body.sql;
|
|
878
|
+
const params = Array.isArray(body.params) ? body.params : [];
|
|
879
|
+
let pgMod;
|
|
880
|
+
try {
|
|
881
|
+
const mod = await import("pg");
|
|
882
|
+
pgMod = (mod.default ?? mod);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return { ok: false, error: "The `pg` package is required for query execution. Install it with `pnpm add pg`." };
|
|
886
|
+
}
|
|
887
|
+
const client = new pgMod.Client({ connectionString: databaseUrl });
|
|
888
|
+
await client.connect();
|
|
889
|
+
const startMs = Date.now();
|
|
890
|
+
try {
|
|
891
|
+
await client.query("BEGIN READ ONLY");
|
|
892
|
+
const result = await client.query({ text: sql, values: params });
|
|
893
|
+
await client.query("COMMIT");
|
|
894
|
+
const durationMs = Date.now() - startMs;
|
|
895
|
+
const columns = result.fields.map((f) => f.name);
|
|
896
|
+
const rows = result.rows.map((r) => columns.map((c) => r[c]));
|
|
897
|
+
const truncated = rows.length > 500;
|
|
898
|
+
return {
|
|
899
|
+
ok: true,
|
|
900
|
+
columns,
|
|
901
|
+
rows: rows.slice(0, 500),
|
|
902
|
+
rowCount: result.rowCount ?? rows.length,
|
|
903
|
+
durationMs,
|
|
904
|
+
truncated,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
catch (err) {
|
|
908
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
error: err instanceof Error ? err.message : String(err),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
finally {
|
|
915
|
+
await client.end();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
549
918
|
function readOptionalJson(path) {
|
|
550
919
|
if (!existsSync(path))
|
|
551
920
|
return undefined;
|
|
@@ -556,6 +925,17 @@ function readOptionalJson(path) {
|
|
|
556
925
|
return undefined;
|
|
557
926
|
}
|
|
558
927
|
}
|
|
928
|
+
function parseConceptsBody(body) {
|
|
929
|
+
if (!isRecord(body) || !Array.isArray(body.concepts)) {
|
|
930
|
+
throw new StudioHttpError(400, "`concepts` array is required.");
|
|
931
|
+
}
|
|
932
|
+
for (const concept of body.concepts) {
|
|
933
|
+
if (!isRecord(concept) || typeof concept.id !== "string" || typeof concept.label !== "string") {
|
|
934
|
+
throw new StudioHttpError(400, "Each concept requires a string `id` and `label`.");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return body.concepts;
|
|
938
|
+
}
|
|
559
939
|
function parseTableDraftBody(body) {
|
|
560
940
|
if (!isRecord(body) || !isRecord(body.draft)) {
|
|
561
941
|
throw new StudioHttpError(400, "`draft` is required.");
|
|
@@ -604,9 +984,26 @@ function parseAskBody(body) {
|
|
|
604
984
|
if (mode !== "full" && mode !== "rag") {
|
|
605
985
|
throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
|
|
606
986
|
}
|
|
987
|
+
let tenantScope;
|
|
988
|
+
if (body.tenantScope !== undefined && body.tenantScope !== null) {
|
|
989
|
+
const parsed = tenantScopeSchema.safeParse(body.tenantScope);
|
|
990
|
+
if (!parsed.success) {
|
|
991
|
+
throw new StudioHttpError(400, `Invalid tenantScope: ${parsed.error.message}`);
|
|
992
|
+
}
|
|
993
|
+
tenantScope = parsed.data;
|
|
994
|
+
}
|
|
995
|
+
let tenantSqlMode;
|
|
996
|
+
if (typeof body.tenantSqlMode === "string") {
|
|
997
|
+
if (body.tenantSqlMode !== "sql-only" && body.tenantSqlMode !== "sql-params") {
|
|
998
|
+
throw new StudioHttpError(400, "`tenantSqlMode` must be `sql-only` or `sql-params`.");
|
|
999
|
+
}
|
|
1000
|
+
tenantSqlMode = body.tenantSqlMode;
|
|
1001
|
+
}
|
|
607
1002
|
return {
|
|
608
1003
|
question: body.question.trim(),
|
|
609
1004
|
useRag: mode === "rag",
|
|
1005
|
+
tenantScope,
|
|
1006
|
+
tenantSqlMode,
|
|
610
1007
|
};
|
|
611
1008
|
}
|
|
612
1009
|
function parseRagQuery(body) {
|