@askdb/studio 0.2.0-beta.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/dist/server.js ADDED
@@ -0,0 +1,780 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { extname, relative, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { generateText as defaultGenerateText } from "ai";
6
+ import { getAskDbRuntimeConfig } from "@askdb/config";
7
+ import { ask, askDbAiKeyMissingMessage, createAskDbEmbeddingModel, createAskDbLanguageModelFromEnv, loadSchema, resolveAskDbAiConfig, resolveAskDbEmbeddingConfig, suggestEnrichment, } from "@askdb/core";
8
+ import { postgresDialect } from "@askdb/postgres";
9
+ import { buildSchemaIndex, chunkContentHash, chunkSchema, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
10
+ import { createAiSdkEmbedder } from "@askdb/rag/embedders/ai-sdk";
11
+ import { createFileStore } from "@askdb/rag/stores/file";
12
+ import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveTable, } from "@askdb/enrich";
13
+ const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
14
+ const STUDIO_RAG_MOCK_DIMENSIONS = 64;
15
+ const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
16
+ const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
17
+ export function createStudioServer(options) {
18
+ const schemaDir = resolve(options.schema);
19
+ if (!existsSync(schemaDir)) {
20
+ throw new Error(`schema directory not found: ${schemaDir}`);
21
+ }
22
+ const state = {
23
+ schemaDir,
24
+ workspace: loadWorkspace(schemaDir),
25
+ };
26
+ return createServer(async (req, res) => {
27
+ try {
28
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
29
+ if (req.method === "GET" && url.pathname === "/") {
30
+ return serveClientFile(res, "index.html");
31
+ }
32
+ if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
33
+ return serveClientFile(res, decodeURIComponent(url.pathname.slice(1)));
34
+ }
35
+ if (req.method === "GET" && url.pathname === "/api/workspace") {
36
+ return writeJson(res, 200, serializeWorkspace(state.workspace));
37
+ }
38
+ if (req.method === "POST" && url.pathname.startsWith("/api/tables/")) {
39
+ const tableId = decodeURIComponent(url.pathname.slice("/api/tables/".length));
40
+ const body = await readJson(req);
41
+ const draft = parseTableDraftBody(body);
42
+ saveDraft(state, tableId, draft);
43
+ return writeJson(res, 200, serializeWorkspace(state.workspace));
44
+ }
45
+ if (req.method === "POST" && url.pathname === "/api/suggest") {
46
+ const body = await readJson(req);
47
+ const source = parseSuggestSource(body);
48
+ const candidates = await suggestForSource(state.workspace, source);
49
+ return writeJson(res, 200, { candidates });
50
+ }
51
+ if (req.method === "GET" && url.pathname === "/api/rag/status") {
52
+ return writeJson(res, 200, getRagStatus(state.schemaDir));
53
+ }
54
+ if (req.method === "POST" && url.pathname === "/api/rag/index") {
55
+ const result = await indexRag(state.schemaDir);
56
+ return writeJson(res, 200, result);
57
+ }
58
+ if (req.method === "POST" && url.pathname === "/api/rag/query") {
59
+ const body = await readJson(req);
60
+ const query = parseRagQuery(body);
61
+ const result = await queryRag(state.schemaDir, query);
62
+ return writeJson(res, 200, result);
63
+ }
64
+ if (req.method === "POST" && url.pathname === "/api/ask") {
65
+ const body = await readJson(req);
66
+ const options = parseAskBody(body);
67
+ const result = await askSampleQuestion(state.schemaDir, options);
68
+ return writeJson(res, 200, result);
69
+ }
70
+ if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
71
+ return serveClientFile(res, "index.html");
72
+ }
73
+ writeJson(res, 404, { error: { message: "Not found" } });
74
+ }
75
+ catch (error) {
76
+ const status = error instanceof StudioHttpError ? error.status : 500;
77
+ writeJson(res, status, {
78
+ error: { message: error instanceof Error ? error.message : String(error) },
79
+ });
80
+ }
81
+ });
82
+ }
83
+ export function serializeWorkspace(workspace) {
84
+ const rt = getAskDbRuntimeConfig();
85
+ const aiConfig = (() => {
86
+ try {
87
+ return resolveAskDbAiConfig(rt.ai.aiEnv, {
88
+ modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
89
+ });
90
+ }
91
+ catch {
92
+ // A misconfigured AI env (e.g. azure without resourceName) shouldn't crash the workspace
93
+ // listing — surface it as "not configured" in the UI and let the user fix .env.
94
+ return undefined;
95
+ }
96
+ })();
97
+ return {
98
+ schemaDir: workspace.schemaDir,
99
+ schemaId: workspace.physical.schemaId,
100
+ warnings: workspace.warnings,
101
+ aiConfigured: Boolean(aiConfig),
102
+ model: aiConfig?.model ?? "gpt-4o-mini",
103
+ aiProvider: aiConfig?.provider ?? "openai",
104
+ tables: workspace.tables.map((table) => {
105
+ const draft = buildTableDraft(table.physical, table.parsed);
106
+ return {
107
+ physical: table.physical,
108
+ filename: table.filename,
109
+ hasDescribableFile: Boolean(table.parsed),
110
+ draft,
111
+ missingColumnIds: workspace.warnings
112
+ .filter((warning) => warning.kind === "missing_column_md" && warning.tableId === table.physical.id)
113
+ .map((warning) => warning.columnId),
114
+ };
115
+ }),
116
+ concepts: workspace.concepts?.frontmatter.concepts ?? [],
117
+ };
118
+ }
119
+ function saveDraft(state, tableId, draft) {
120
+ const table = state.workspace.tables.find((candidate) => candidate.physical.id === tableId);
121
+ if (!table)
122
+ throw new StudioHttpError(404, `No such table: ${tableId}`);
123
+ const frontmatter = buildFrontmatter(table.physical, state.workspace.physical.schemaId, draft);
124
+ let body = table.parsed
125
+ ? replaceTableDescription(table.parsed.body, draft.description)
126
+ : buildDefaultTableBody(table.physical.name, draft.description);
127
+ if (draft.commonQueryLanguage !== undefined) {
128
+ body = replaceH2Section(body, "Common query language", draft.commonQueryLanguage);
129
+ }
130
+ if (draft.exampleQuestions !== undefined) {
131
+ body = replaceH2Section(body, "Example questions", draft.exampleQuestions);
132
+ }
133
+ saveTable(state.workspace, tableId, frontmatter, body);
134
+ state.workspace = loadWorkspace(state.schemaDir);
135
+ }
136
+ async function suggestForSource(workspace, source) {
137
+ const rt = getAskDbRuntimeConfig();
138
+ const modelEnvVar = rt.ai.studioModel
139
+ ? "ASKDB_STUDIO_MODEL"
140
+ : rt.ai.tuiModel
141
+ ? "ASKDB_TUI_MODEL"
142
+ : undefined;
143
+ const model = await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
144
+ modelEnvVar,
145
+ });
146
+ if (!model) {
147
+ throw new StudioHttpError(400, askDbAiKeyMissingMessage("AI enrichment suggestions"));
148
+ }
149
+ const candidates = await suggestEnrichment(buildSuggestionTarget(workspace, source), buildSuggestionContext(workspace, source.tableId), model);
150
+ return candidates.map((candidate) => ({ text: candidate.text }));
151
+ }
152
+ async function askSampleQuestion(schemaDir, options) {
153
+ const rt = getAskDbRuntimeConfig();
154
+ const mockSql = rt.dev.mockSql;
155
+ const aiConfig = mockSql ? undefined : resolveAskDbAiConfig(rt.ai.aiEnv);
156
+ if (!mockSql && !aiConfig) {
157
+ throw new StudioHttpError(400, `${askDbAiKeyMissingMessage("Sample NL-to-SQL generation")} Set ASKDB_MOCK_SQL to bypass the live model.`);
158
+ }
159
+ const schema = loadSchema(schemaDir);
160
+ const retrievedChunks = [];
161
+ const usage = createRequestUsageCollector();
162
+ const ragIndex = options.useRag
163
+ ? await createCurrentStudioRagIndex(schemaDir, "using RAG for sample generation", usage)
164
+ : undefined;
165
+ const model = mockSql
166
+ ? undefined
167
+ : (await createAskDbLanguageModelFromEnv(rt.ai.aiEnv, {
168
+ modelEnvVar: rt.ai.studioModel ? "ASKDB_STUDIO_MODEL" : undefined,
169
+ }));
170
+ const result = await ask({
171
+ question: options.question,
172
+ schema,
173
+ model,
174
+ dialect: postgresDialect,
175
+ explain: true,
176
+ ...(ragIndex
177
+ ? {
178
+ retriever: async (params) => {
179
+ let results;
180
+ try {
181
+ results = await ragIndex.retriever(params);
182
+ }
183
+ catch (error) {
184
+ throw formatStudioRagEmbeddingError(error, ragIndex.config);
185
+ }
186
+ retrievedChunks.splice(0, retrievedChunks.length, ...results);
187
+ return results;
188
+ },
189
+ retrievalK: 8,
190
+ retrievalThresholdChunks: 0,
191
+ totalSchemaChunkCount: ragIndex.status.chunksTotal,
192
+ }
193
+ : {}),
194
+ deps: mockSql !== undefined
195
+ ? {
196
+ generateText: (async () => ({ text: mockSql })),
197
+ }
198
+ : {
199
+ generateText: createTrackedGenerateText(usage),
200
+ },
201
+ });
202
+ return {
203
+ sql: result.sql,
204
+ explain: result.explain ?? null,
205
+ warnings: schema.warnings,
206
+ rag: {
207
+ enabled: options.useRag,
208
+ chunks: options.useRag ? retrievedChunks.map(serializeRagResult) : [],
209
+ },
210
+ usage: usage.toDto(),
211
+ };
212
+ }
213
+ function getRagStatus(schemaDir) {
214
+ const config = resolveStudioRagEmbedderConfig();
215
+ const sources = loadChunkerSourcesFromDir(schemaDir);
216
+ const chunkResult = chunkSchema(sources);
217
+ const lockPath = join(schemaDir, "schema.lock.json");
218
+ const embeddingsJsonPath = join(schemaDir, "schema.embeddings.json");
219
+ const embeddingsBinPath = join(schemaDir, "schema.embeddings.bin");
220
+ const lock = readOptionalJson(lockPath);
221
+ const embeddings = readOptionalJson(embeddingsJsonPath);
222
+ const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
223
+ const lockHashes = lock?.hashes ?? {};
224
+ const hashIds = Object.keys(currentHashes);
225
+ const stale = !lock ||
226
+ !existsSync(embeddingsJsonPath) ||
227
+ !existsSync(embeddingsBinPath) ||
228
+ lock.embedderId !== config.embedderId ||
229
+ embeddings?.dimensions !== config.dimensions ||
230
+ Object.keys(lockHashes).length !== hashIds.length ||
231
+ hashIds.some((id) => lockHashes[id] !== currentHashes[id]);
232
+ return {
233
+ schemaId: sources.schema.schemaId,
234
+ embedder: {
235
+ kind: config.kind,
236
+ label: config.label,
237
+ configured: config.configured,
238
+ expectedId: config.embedderId,
239
+ indexedId: lock?.embedderId ?? null,
240
+ provider: config.kind === "ai-sdk" ? config.provider : null,
241
+ model: config.kind === "ai-sdk" ? config.model : null,
242
+ baseUrl: config.kind === "ai-sdk" ? config.baseUrl ?? null : null,
243
+ },
244
+ embedderId: lock?.embedderId ?? config.embedderId,
245
+ expectedEmbedderId: config.embedderId,
246
+ hasIndex: Boolean(lock && existsSync(embeddingsJsonPath) && existsSync(embeddingsBinPath)),
247
+ stale,
248
+ updatedAt: lock?.updatedAt ?? null,
249
+ chunksTotal: chunkResult.chunks.length,
250
+ chunksIndexed: Array.isArray(embeddings?.records) ? embeddings.records.length : 0,
251
+ dimensions: typeof embeddings?.dimensions === "number" ? embeddings.dimensions : config.dimensions,
252
+ expectedDimensions: config.dimensions,
253
+ sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
254
+ sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
255
+ files: {
256
+ lock: existsSync(lockPath),
257
+ embeddingsJson: existsSync(embeddingsJsonPath),
258
+ embeddingsBin: existsSync(embeddingsBinPath),
259
+ },
260
+ };
261
+ }
262
+ async function indexRag(schemaDir) {
263
+ const config = resolveStudioRagEmbedderConfig();
264
+ if (!config.configured) {
265
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
266
+ }
267
+ const usage = createRequestUsageCollector();
268
+ clearIncompatibleRagStore(schemaDir, config);
269
+ const sources = loadChunkerSourcesFromDir(schemaDir);
270
+ const store = createFileStore({ basePath: join(schemaDir, "schema") });
271
+ let result;
272
+ try {
273
+ result = await buildSchemaIndex({
274
+ schema: sources,
275
+ embedder: await createStudioRagEmbedder(config, usage),
276
+ store,
277
+ embedderId: config.embedderId,
278
+ lockFilePath: join(schemaDir, "schema.lock.json"),
279
+ });
280
+ }
281
+ catch (error) {
282
+ throw formatStudioRagEmbeddingError(error, config);
283
+ }
284
+ store.flush();
285
+ return {
286
+ status: getRagStatus(schemaDir),
287
+ stats: result.stats,
288
+ usage: usage.toDto(),
289
+ };
290
+ }
291
+ async function queryRag(schemaDir, query) {
292
+ const usage = createRequestUsageCollector();
293
+ const ragIndex = await createCurrentStudioRagIndex(schemaDir, "querying chunks", usage);
294
+ let results;
295
+ try {
296
+ results = await ragIndex.retriever({
297
+ question: query.question,
298
+ k: query.k,
299
+ filter: {
300
+ schemaId: ragIndex.status.schemaId,
301
+ ...(query.types && query.types.length > 0 ? { types: query.types } : {}),
302
+ },
303
+ });
304
+ }
305
+ catch (error) {
306
+ throw formatStudioRagEmbeddingError(error, ragIndex.config);
307
+ }
308
+ return {
309
+ question: query.question,
310
+ k: query.k,
311
+ results: results.map(serializeRagResult),
312
+ usage: usage.toDto(),
313
+ };
314
+ }
315
+ async function createCurrentStudioRagIndex(schemaDir, action, usage) {
316
+ const config = resolveStudioRagEmbedderConfig();
317
+ if (!config.configured) {
318
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
319
+ }
320
+ const status = getRagStatus(schemaDir);
321
+ if (!status.hasIndex) {
322
+ throw new StudioHttpError(400, `Build the RAG index before ${action}.`);
323
+ }
324
+ if (status.stale) {
325
+ throw new StudioHttpError(400, `Reindex before ${action}. The current index is stale or uses a different embedder.`);
326
+ }
327
+ if (!status.schemaId || typeof status.chunksTotal !== "number") {
328
+ throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
329
+ }
330
+ return {
331
+ config,
332
+ status: {
333
+ schemaId: status.schemaId,
334
+ chunksTotal: status.chunksTotal,
335
+ },
336
+ retriever: createRetriever({
337
+ embedder: await createStudioRagEmbedder(config, usage),
338
+ store: createFileStore({ basePath: join(schemaDir, "schema") }),
339
+ }),
340
+ };
341
+ }
342
+ function pickEnv(env, key) {
343
+ const v = env[key];
344
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
345
+ }
346
+ function resolveStudioRagEmbedderConfig() {
347
+ const rt = getAskDbRuntimeConfig();
348
+ const base = rt.ai.aiEnv;
349
+ const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER");
350
+ const kind = explicitKind?.toLowerCase();
351
+ if (kind === "mock") {
352
+ return {
353
+ kind: "mock",
354
+ embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
355
+ dimensions: STUDIO_RAG_MOCK_DIMENSIONS,
356
+ configured: true,
357
+ label: "Mock lexical",
358
+ };
359
+ }
360
+ if (kind !== undefined && kind !== "ai-sdk" && kind !== "openai") {
361
+ throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
362
+ }
363
+ const env = buildStudioRagEmbeddingEnv(kind, base);
364
+ const aiConfig = resolveAskDbEmbeddingConfig(env, {
365
+ modelEnvVar: "ASKDB_STUDIO_RAG_EMBEDDER_MODEL",
366
+ modelDefault: DEFAULT_EMBEDDING_MODEL,
367
+ });
368
+ if (!aiConfig && kind === undefined) {
369
+ return {
370
+ kind: "mock",
371
+ embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
372
+ dimensions: STUDIO_RAG_MOCK_DIMENSIONS,
373
+ configured: true,
374
+ label: "Mock lexical",
375
+ };
376
+ }
377
+ const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
378
+ const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
379
+ const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS") ??
380
+ pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_DIMENSIONS"));
381
+ const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
382
+ return {
383
+ kind: "ai-sdk",
384
+ provider,
385
+ embedderId: `ai-sdk:${provider}:${model}:${dimensions}`,
386
+ dimensions,
387
+ configured: Boolean(aiConfig),
388
+ label: `AI SDK (${provider})`,
389
+ model,
390
+ baseUrl: aiConfig?.baseURL,
391
+ aiConfig,
392
+ requestDimensions: dimensionOverride,
393
+ };
394
+ }
395
+ function buildStudioRagEmbeddingEnv(kind, base) {
396
+ const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_API_KEY");
397
+ const modelOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_MODEL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_MODEL");
398
+ const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL") ?? pickEnv(base, "ASKDB_STUDIO_RAG_EMBEDDER_BASE_URL");
399
+ return {
400
+ ...base,
401
+ ...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
402
+ ...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
403
+ ...(modelOverride ? { ASKDB_STUDIO_RAG_EMBEDDER_MODEL: modelOverride } : {}),
404
+ ...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
405
+ };
406
+ }
407
+ function fallbackStudioRagProvider(kind, base) {
408
+ if (kind === "openai")
409
+ return "openai";
410
+ const raw = (pickEnv(base, "ASKDB_AI_PROVIDER") ?? "").toLowerCase();
411
+ return raw === "azure" || raw === "azure-openai" || raw === "foundry"
412
+ ? "azure"
413
+ : "openai";
414
+ }
415
+ function studioRagAiSdkKeyMissingMessage() {
416
+ return ("Studio RAG AI SDK embeddings require a configured AI provider key. " +
417
+ "Set ASKDB_AI_API_KEY or the provider-native key, or set ASKDB_RAG_EMBEDDER=mock for the local lexical embedder.");
418
+ }
419
+ async function createStudioRagEmbedder(config, usage) {
420
+ if (config.kind === "mock")
421
+ return createStudioMockEmbedder(config.dimensions);
422
+ if (!config.aiConfig) {
423
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
424
+ }
425
+ const model = await createAskDbEmbeddingModel(config.aiConfig, {
426
+ dimensions: config.requestDimensions,
427
+ });
428
+ return createAiSdkEmbedder({
429
+ model,
430
+ maxRetries: 0,
431
+ onUsage: (reported) => {
432
+ usage?.add("embedding", {
433
+ totalTokens: reported.totalTokens ?? reported.tokens ?? reported.promptTokens,
434
+ promptTokens: reported.promptTokens ?? reported.tokens,
435
+ embeddingTokens: reported.tokens ?? reported.totalTokens ?? reported.promptTokens,
436
+ });
437
+ },
438
+ });
439
+ }
440
+ function formatStudioRagEmbeddingError(error, config) {
441
+ if (config.kind === "mock") {
442
+ return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
443
+ }
444
+ const apiError = findApiCallError(error);
445
+ const parts = [
446
+ `Studio RAG embedding request failed for provider ${config.provider}, model ${config.model}.`,
447
+ ];
448
+ if (config.baseUrl)
449
+ parts.push(`Base URL: ${config.baseUrl}.`);
450
+ if (apiError?.statusCode)
451
+ parts.push(`Status: ${apiError.statusCode}.`);
452
+ const responseBody = truncateForMessage(apiError?.responseBody);
453
+ if (responseBody)
454
+ parts.push(`Response: ${responseBody}`);
455
+ if (!responseBody && error instanceof Error)
456
+ parts.push(`Error: ${error.message}`);
457
+ return new StudioHttpError(502, parts.join(" "));
458
+ }
459
+ function findApiCallError(error) {
460
+ const queue = [error];
461
+ for (let i = 0; i < queue.length; i++) {
462
+ const current = queue[i];
463
+ if (!isRecord(current))
464
+ continue;
465
+ if (typeof current.statusCode === "number" ||
466
+ typeof current.responseBody === "string") {
467
+ return {
468
+ statusCode: typeof current.statusCode === "number" ? current.statusCode : undefined,
469
+ responseBody: typeof current.responseBody === "string" ? current.responseBody : undefined,
470
+ };
471
+ }
472
+ if (Array.isArray(current.errors))
473
+ queue.push(...current.errors);
474
+ if ("cause" in current)
475
+ queue.push(current.cause);
476
+ }
477
+ return undefined;
478
+ }
479
+ function truncateForMessage(value) {
480
+ if (!value)
481
+ return undefined;
482
+ const trimmed = value.trim().replace(/\s+/g, " ");
483
+ if (!trimmed)
484
+ return undefined;
485
+ return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
486
+ }
487
+ function clearIncompatibleRagStore(schemaDir, config) {
488
+ const embeddingsJsonPath = join(schemaDir, "schema.embeddings.json");
489
+ const embeddings = readOptionalJson(embeddingsJsonPath);
490
+ if (embeddings?.dimensions === undefined || embeddings.dimensions === config.dimensions)
491
+ return;
492
+ for (const path of [
493
+ join(schemaDir, "schema.embeddings.json"),
494
+ join(schemaDir, "schema.embeddings.bin"),
495
+ join(schemaDir, "schema.lock.json"),
496
+ ]) {
497
+ rmSync(path, { force: true });
498
+ }
499
+ }
500
+ function defaultEmbeddingDimensions(model) {
501
+ if (model === "text-embedding-3-large")
502
+ return 3072;
503
+ return 1536;
504
+ }
505
+ function readPositiveIntegerEnv(value) {
506
+ if (value === undefined || value.trim() === "")
507
+ return undefined;
508
+ const parsed = Number(value);
509
+ if (!Number.isInteger(parsed) || parsed <= 0) {
510
+ throw new StudioHttpError(400, `Invalid Studio RAG embedding dimensions: ${value}`);
511
+ }
512
+ return parsed;
513
+ }
514
+ function serializeRagResult(result) {
515
+ return {
516
+ id: result.id,
517
+ score: Number(result.score.toFixed(6)),
518
+ type: result.payload.type,
519
+ refs: result.payload.refs,
520
+ sensitive: result.payload.sensitive,
521
+ text: result.payload.text,
522
+ };
523
+ }
524
+ function createStudioMockEmbedder(dim) {
525
+ return async (texts) => {
526
+ return texts.map((text) => {
527
+ const vector = new Array(dim).fill(0);
528
+ const tokens = text.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
529
+ for (const token of tokens) {
530
+ vector[stableTokenHash(token) % dim] += 1;
531
+ }
532
+ const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
533
+ return vector.map((value) => value / norm);
534
+ });
535
+ };
536
+ }
537
+ function stableTokenHash(token) {
538
+ let hash = 2166136261;
539
+ for (let i = 0; i < token.length; i++) {
540
+ hash ^= token.charCodeAt(i);
541
+ hash = Math.imul(hash, 16777619);
542
+ }
543
+ return hash >>> 0;
544
+ }
545
+ function readOptionalJson(path) {
546
+ if (!existsSync(path))
547
+ return undefined;
548
+ try {
549
+ return JSON.parse(readFileSync(path, "utf8"));
550
+ }
551
+ catch {
552
+ return undefined;
553
+ }
554
+ }
555
+ function parseTableDraftBody(body) {
556
+ if (!isRecord(body) || !isRecord(body.draft)) {
557
+ throw new StudioHttpError(400, "`draft` is required.");
558
+ }
559
+ const draft = body.draft;
560
+ if (typeof draft.description !== "string") {
561
+ throw new StudioHttpError(400, "`draft.description` is required.");
562
+ }
563
+ if (!isRecord(draft.columns)) {
564
+ throw new StudioHttpError(400, "`draft.columns` is required.");
565
+ }
566
+ return draft;
567
+ }
568
+ function parseSuggestSource(body) {
569
+ if (!isRecord(body) || !isRecord(body.source)) {
570
+ throw new StudioHttpError(400, "`source` is required.");
571
+ }
572
+ const source = body.source;
573
+ if (source.scope === "table") {
574
+ if (typeof source.tableId !== "string" || typeof source.field !== "string") {
575
+ throw new StudioHttpError(400, "`source.tableId` and `source.field` are required.");
576
+ }
577
+ if (!["description", "aliases", "primaryEntity", "commonQueryLanguage"].includes(source.field)) {
578
+ throw new StudioHttpError(400, "Unsupported table suggestion field.");
579
+ }
580
+ return source;
581
+ }
582
+ if (source.scope === "column") {
583
+ if (typeof source.tableId !== "string" ||
584
+ typeof source.columnId !== "string" ||
585
+ typeof source.field !== "string") {
586
+ throw new StudioHttpError(400, "`source.tableId`, `source.columnId`, and `source.field` are required.");
587
+ }
588
+ if (!["description", "aliases"].includes(source.field)) {
589
+ throw new StudioHttpError(400, "Unsupported column suggestion field.");
590
+ }
591
+ return source;
592
+ }
593
+ throw new StudioHttpError(400, "Unsupported suggestion source.");
594
+ }
595
+ function parseAskBody(body) {
596
+ if (!isRecord(body) || typeof body.question !== "string" || body.question.trim() === "") {
597
+ throw new StudioHttpError(400, "`question` is required.");
598
+ }
599
+ const mode = typeof body.mode === "string" ? body.mode : "full";
600
+ if (mode !== "full" && mode !== "rag") {
601
+ throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
602
+ }
603
+ return {
604
+ question: body.question.trim(),
605
+ useRag: mode === "rag",
606
+ };
607
+ }
608
+ function parseRagQuery(body) {
609
+ if (!isRecord(body) || typeof body.question !== "string" || body.question.trim() === "") {
610
+ throw new StudioHttpError(400, "`question` is required.");
611
+ }
612
+ const k = typeof body.k === "number" && Number.isFinite(body.k) ? Math.trunc(body.k) : 8;
613
+ if (k < 1 || k > 25) {
614
+ throw new StudioHttpError(400, "`k` must be between 1 and 25.");
615
+ }
616
+ const allowed = new Set(["table", "column", "cql", "question", "concept", "relationship"]);
617
+ let types;
618
+ if (Array.isArray(body.types)) {
619
+ types = body.types.filter((type) => typeof type === "string" && allowed.has(type));
620
+ }
621
+ return {
622
+ question: body.question.trim(),
623
+ k,
624
+ ...(types && types.length > 0 ? { types } : {}),
625
+ };
626
+ }
627
+ function createTrackedGenerateText(usage) {
628
+ const generateText = defaultGenerateText;
629
+ return (async (...args) => {
630
+ const result = await generateText(...args);
631
+ usage.add("generation", normalizeGenerationUsage(result.usage));
632
+ return result;
633
+ });
634
+ }
635
+ function createRequestUsageCollector() {
636
+ const requests = [];
637
+ return {
638
+ add(kind, usage) {
639
+ if (!usage)
640
+ return;
641
+ const derivedTotal = usage.totalTokens ??
642
+ sumDefined([usage.promptTokens, usage.completionTokens]) ??
643
+ usage.embeddingTokens;
644
+ const request = {
645
+ kind,
646
+ totalTokens: derivedTotal ?? null,
647
+ promptTokens: usage.promptTokens ?? null,
648
+ completionTokens: usage.completionTokens ?? null,
649
+ embeddingTokens: usage.embeddingTokens ?? null,
650
+ };
651
+ if (request.totalTokens === null &&
652
+ request.promptTokens === null &&
653
+ request.completionTokens === null &&
654
+ request.embeddingTokens === null) {
655
+ return;
656
+ }
657
+ requests.push(request);
658
+ },
659
+ toDto() {
660
+ if (requests.length === 0)
661
+ return null;
662
+ return {
663
+ totalTokens: sumNullable(requests.map((request) => request.totalTokens)),
664
+ promptTokens: sumNullable(requests.map((request) => request.promptTokens)),
665
+ completionTokens: sumNullable(requests.map((request) => request.completionTokens)),
666
+ embeddingTokens: sumNullable(requests.map((request) => request.embeddingTokens)),
667
+ requests,
668
+ };
669
+ },
670
+ };
671
+ }
672
+ function normalizeGenerationUsage(value) {
673
+ if (!isRecord(value))
674
+ return undefined;
675
+ const promptTokens = readFiniteNumber(value.promptTokens);
676
+ const completionTokens = readFiniteNumber(value.completionTokens);
677
+ const totalTokens = readFiniteNumber(value.totalTokens);
678
+ if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
679
+ return undefined;
680
+ }
681
+ return { promptTokens, completionTokens, totalTokens };
682
+ }
683
+ function sumDefined(values) {
684
+ let total = 0;
685
+ let seen = false;
686
+ for (const value of values) {
687
+ if (value === undefined)
688
+ continue;
689
+ total += value;
690
+ seen = true;
691
+ }
692
+ return seen ? total : undefined;
693
+ }
694
+ function sumNullable(values) {
695
+ let total = 0;
696
+ let seen = false;
697
+ for (const value of values) {
698
+ if (value === null)
699
+ continue;
700
+ total += value;
701
+ seen = true;
702
+ }
703
+ return seen ? total : null;
704
+ }
705
+ function readFiniteNumber(value) {
706
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
707
+ }
708
+ async function readJson(req) {
709
+ const chunks = [];
710
+ for await (const chunk of req) {
711
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
712
+ }
713
+ if (chunks.length === 0)
714
+ return {};
715
+ try {
716
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
717
+ }
718
+ catch {
719
+ throw new StudioHttpError(400, "Request body must be valid JSON.");
720
+ }
721
+ }
722
+ function writeJson(res, status, body) {
723
+ writeText(res, status, "application/json; charset=utf-8", JSON.stringify(body));
724
+ }
725
+ function writeText(res, status, contentType, body) {
726
+ res.writeHead(status, {
727
+ "content-type": contentType,
728
+ "cache-control": "no-store",
729
+ });
730
+ res.end(body);
731
+ }
732
+ function serveClientFile(res, relativePath) {
733
+ const filePath = resolve(CLIENT_DIR, relativePath);
734
+ const fileRelativeToClientDir = relative(CLIENT_DIR, filePath);
735
+ if (fileRelativeToClientDir.startsWith("..") || fileRelativeToClientDir === "") {
736
+ return writeJson(res, 404, { error: { message: "Not found" } });
737
+ }
738
+ if (!existsSync(filePath)) {
739
+ return writeJson(res, 500, {
740
+ error: {
741
+ message: "Studio client assets are missing. Run `pnpm --filter @askdb/studio build` before starting Studio.",
742
+ },
743
+ });
744
+ }
745
+ res.writeHead(200, {
746
+ "content-type": contentTypeFor(filePath),
747
+ "cache-control": "no-store",
748
+ });
749
+ res.end(readFileSync(filePath));
750
+ }
751
+ function contentTypeFor(path) {
752
+ switch (extname(path)) {
753
+ case ".html":
754
+ return "text/html; charset=utf-8";
755
+ case ".css":
756
+ return "text/css; charset=utf-8";
757
+ case ".js":
758
+ return "text/javascript; charset=utf-8";
759
+ case ".svg":
760
+ return "image/svg+xml";
761
+ case ".png":
762
+ return "image/png";
763
+ case ".ico":
764
+ return "image/x-icon";
765
+ default:
766
+ return "application/octet-stream";
767
+ }
768
+ }
769
+ function isRecord(value) {
770
+ return typeof value === "object" && value !== null && !Array.isArray(value);
771
+ }
772
+ class StudioHttpError extends Error {
773
+ status;
774
+ constructor(status, message) {
775
+ super(message);
776
+ this.status = status;
777
+ this.name = "StudioHttpError";
778
+ }
779
+ }
780
+ //# sourceMappingURL=server.js.map