@pgsage/core 0.1.0
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 +126 -0
- package/dist/corrector/index.d.ts +72 -0
- package/dist/corrector/index.d.ts.map +1 -0
- package/dist/corrector/index.js +113 -0
- package/dist/corrector/index.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +2 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/pool.d.ts +46 -0
- package/dist/db/pool.d.ts.map +1 -0
- package/dist/db/pool.js +46 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/embeddings/index.d.ts +4 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +2 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/types.d.ts +39 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/embeddings/types.js +16 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/embeddings/voyage.d.ts +70 -0
- package/dist/embeddings/voyage.d.ts.map +1 -0
- package/dist/embeddings/voyage.js +163 -0
- package/dist/embeddings/voyage.js.map +1 -0
- package/dist/estimator/index.d.ts +53 -0
- package/dist/estimator/index.d.ts.map +1 -0
- package/dist/estimator/index.js +57 -0
- package/dist/estimator/index.js.map +1 -0
- package/dist/executor/index.d.ts +56 -0
- package/dist/executor/index.d.ts.map +1 -0
- package/dist/executor/index.js +86 -0
- package/dist/executor/index.js.map +1 -0
- package/dist/explainer/index.d.ts +39 -0
- package/dist/explainer/index.d.ts.map +1 -0
- package/dist/explainer/index.js +79 -0
- package/dist/explainer/index.js.map +1 -0
- package/dist/explainer/prompt.d.ts +37 -0
- package/dist/explainer/prompt.d.ts.map +1 -0
- package/dist/explainer/prompt.js +102 -0
- package/dist/explainer/prompt.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/introspector/index.d.ts +127 -0
- package/dist/introspector/index.d.ts.map +1 -0
- package/dist/introspector/index.js +460 -0
- package/dist/introspector/index.js.map +1 -0
- package/dist/orchestrator/index.d.ts +113 -0
- package/dist/orchestrator/index.d.ts.map +1 -0
- package/dist/orchestrator/index.js +126 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/planner/index.d.ts +83 -0
- package/dist/planner/index.d.ts.map +1 -0
- package/dist/planner/index.js +67 -0
- package/dist/planner/index.js.map +1 -0
- package/dist/planner/prompt.d.ts +37 -0
- package/dist/planner/prompt.d.ts.map +1 -0
- package/dist/planner/prompt.js +436 -0
- package/dist/planner/prompt.js.map +1 -0
- package/dist/retriever/index.d.ts +90 -0
- package/dist/retriever/index.d.ts.map +1 -0
- package/dist/retriever/index.js +164 -0
- package/dist/retriever/index.js.map +1 -0
- package/dist/validator/index.d.ts +26 -0
- package/dist/validator/index.d.ts.map +1 -0
- package/dist/validator/index.js +205 -0
- package/dist/validator/index.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoyageEmbeddingProvider
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Voyage AI REST API (https://api.voyageai.com/v1/embeddings)
|
|
5
|
+
* using the global fetch available in Node 22+. No external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Key behaviours:
|
|
8
|
+
* - Batches large text arrays into configurable chunks to stay within
|
|
9
|
+
* Voyage's per-request token limits.
|
|
10
|
+
* - Retries 429 (rate-limit) and 5xx responses with exponential backoff.
|
|
11
|
+
* - Maps our EmbeddingInputType to Voyage's input_type param, which causes
|
|
12
|
+
* Voyage to prepend retrieval-optimised prompts server-side.
|
|
13
|
+
* - Passes output_dimension to enable Matryoshka truncation server-side.
|
|
14
|
+
*/
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Error type
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export class VoyageApiError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
detail;
|
|
21
|
+
attempt;
|
|
22
|
+
constructor(status, detail, attempt) {
|
|
23
|
+
super(`Voyage API error ${status}: ${detail} (attempt ${attempt})`);
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.detail = detail;
|
|
26
|
+
this.attempt = attempt;
|
|
27
|
+
this.name = "VoyageApiError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Implementation
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const DEFAULT_CONFIG = {
|
|
34
|
+
model: "voyage-4-lite",
|
|
35
|
+
baseUrl: "https://api.voyageai.com/v1",
|
|
36
|
+
dimensions: 1024,
|
|
37
|
+
maxTextsPerBatch: 128,
|
|
38
|
+
truncation: true,
|
|
39
|
+
requestTimeoutMs: 30_000,
|
|
40
|
+
maxRetries: 3,
|
|
41
|
+
};
|
|
42
|
+
export class VoyageEmbeddingProvider {
|
|
43
|
+
apiKey;
|
|
44
|
+
model;
|
|
45
|
+
baseUrl;
|
|
46
|
+
_dimensions;
|
|
47
|
+
maxTextsPerBatch;
|
|
48
|
+
truncation;
|
|
49
|
+
requestTimeoutMs;
|
|
50
|
+
maxRetries;
|
|
51
|
+
constructor(config) {
|
|
52
|
+
if (!config.apiKey)
|
|
53
|
+
throw new Error("VoyageEmbeddingProvider: apiKey is required");
|
|
54
|
+
this.apiKey = config.apiKey;
|
|
55
|
+
this.model = config.model ?? DEFAULT_CONFIG.model;
|
|
56
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_CONFIG.baseUrl;
|
|
57
|
+
this._dimensions = config.dimensions ?? DEFAULT_CONFIG.dimensions;
|
|
58
|
+
this.maxTextsPerBatch = config.maxTextsPerBatch ?? DEFAULT_CONFIG.maxTextsPerBatch;
|
|
59
|
+
this.truncation = config.truncation ?? DEFAULT_CONFIG.truncation;
|
|
60
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_CONFIG.requestTimeoutMs;
|
|
61
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_CONFIG.maxRetries;
|
|
62
|
+
}
|
|
63
|
+
get dimensions() {
|
|
64
|
+
return this._dimensions;
|
|
65
|
+
}
|
|
66
|
+
get modelId() {
|
|
67
|
+
return this.model;
|
|
68
|
+
}
|
|
69
|
+
async embed(texts, inputType) {
|
|
70
|
+
if (texts.length === 0) {
|
|
71
|
+
return { embeddings: [], totalTokens: 0, model: this.model };
|
|
72
|
+
}
|
|
73
|
+
// Split into batches
|
|
74
|
+
const batches = chunk(texts, this.maxTextsPerBatch);
|
|
75
|
+
const allEmbeddings = [];
|
|
76
|
+
let totalTokens = 0;
|
|
77
|
+
let resolvedModel = this.model;
|
|
78
|
+
for (const batch of batches) {
|
|
79
|
+
const result = await this.embedBatch(batch, inputType);
|
|
80
|
+
allEmbeddings.push(...result.embeddings);
|
|
81
|
+
totalTokens += result.totalTokens;
|
|
82
|
+
resolvedModel = result.model;
|
|
83
|
+
}
|
|
84
|
+
return { embeddings: allEmbeddings, totalTokens, model: resolvedModel };
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Private helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
async embedBatch(texts, inputType) {
|
|
90
|
+
const body = {
|
|
91
|
+
input: texts,
|
|
92
|
+
model: this.model,
|
|
93
|
+
input_type: inputType ?? null,
|
|
94
|
+
truncation: this.truncation,
|
|
95
|
+
output_dimension: this._dimensions,
|
|
96
|
+
};
|
|
97
|
+
let lastError;
|
|
98
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify(body),
|
|
107
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
108
|
+
});
|
|
109
|
+
if (response.ok) {
|
|
110
|
+
const json = (await response.json());
|
|
111
|
+
// Sort by index to ensure order matches input (Voyage guarantees
|
|
112
|
+
// this, but we sort defensively).
|
|
113
|
+
const sorted = [...json.data].sort((a, b) => a.index - b.index);
|
|
114
|
+
return {
|
|
115
|
+
embeddings: sorted.map((d) => d.embedding),
|
|
116
|
+
totalTokens: json.usage.total_tokens,
|
|
117
|
+
model: json.model,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Parse the error body
|
|
121
|
+
const errBody = (await response.json().catch(() => ({
|
|
122
|
+
detail: response.statusText,
|
|
123
|
+
})));
|
|
124
|
+
lastError = new VoyageApiError(response.status, errBody.detail, attempt);
|
|
125
|
+
// Only retry on rate-limit or server errors
|
|
126
|
+
if (!isRetryable(response.status))
|
|
127
|
+
throw lastError;
|
|
128
|
+
// Exponential backoff: 1 s, 2 s, 4 s, …
|
|
129
|
+
if (attempt < this.maxRetries) {
|
|
130
|
+
await sleep(Math.pow(2, attempt - 1) * 1_000);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err instanceof VoyageApiError)
|
|
135
|
+
throw err;
|
|
136
|
+
// Network / timeout errors — wrap and rethrow on final attempt
|
|
137
|
+
if (attempt === this.maxRetries) {
|
|
138
|
+
throw new Error(`Voyage API request failed after ${attempt} attempt(s): ${String(err)}`, { cause: err });
|
|
139
|
+
}
|
|
140
|
+
await sleep(Math.pow(2, attempt - 1) * 1_000);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Exhausted retries
|
|
144
|
+
throw lastError ?? new Error("Voyage API: exhausted retries with no error recorded");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Utilities
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
function chunk(arr, size) {
|
|
151
|
+
const result = [];
|
|
152
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
153
|
+
result.push(arr.slice(i, i + size));
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
function sleep(ms) {
|
|
158
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
159
|
+
}
|
|
160
|
+
function isRetryable(status) {
|
|
161
|
+
return status === 429 || (status >= 500 && status < 600);
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=voyage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voyage.js","sourceRoot":"","sources":["../../src/embeddings/voyage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6EH,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E,MAAM,OAAO,cAAe,SAAQ,KAAK;IAErB;IACA;IACA;IAHlB,YACkB,MAAc,EACd,MAAc,EACd,OAAe;QAE/B,KAAK,CAAC,oBAAoB,MAAM,KAAK,MAAM,aAAa,OAAO,GAAG,CAAC,CAAC;QAJpD,WAAM,GAAN,MAAM,CAAQ;QACd,WAAM,GAAN,MAAM,CAAQ;QACd,YAAO,GAAP,OAAO,CAAQ;QAG/B,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,8EAA8E;AAE9E,MAAM,cAAc,GAAG;IACrB,KAAK,EAAE,eAA8B;IACrC,OAAO,EAAE,6BAA6B;IACtC,UAAU,EAAE,IAAI;IAChB,gBAAgB,EAAE,GAAG;IACrB,UAAU,EAAE,IAAI;IAChB,gBAAgB,EAAE,MAAM;IACxB,UAAU,EAAE,CAAC;CACL,CAAC;AAEX,MAAM,OAAO,uBAAuB;IACjB,MAAM,CAAS;IACf,KAAK,CAAS;IACd,OAAO,CAAS;IAChB,WAAW,CAAS;IACpB,gBAAgB,CAAS;IACzB,UAAU,CAAU;IACpB,gBAAgB,CAAS;IACzB,UAAU,CAAS;IAEpC,YAAY,MAAoB;QAC9B,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;QAEnF,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,cAAc,CAAC,KAAK,CAAC;QAClD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,IAAI,cAAc,CAAC,OAAO,CAAC;QACxD,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,UAAU,IAAI,cAAc,CAAC,UAAU,CAAC;QAClE,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAC,gBAAgB,IAAI,cAAc,CAAC,gBAAgB,CAAC;QACnF,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,cAAc,CAAC,UAAU,CAAC;QACjE,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAC,gBAAgB,IAAI,cAAc,CAAC,gBAAgB,CAAC;QACnF,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,cAAc,CAAC,UAAU,CAAC;IACnE,CAAC;IAED,IAAI,UAAU;QACZ,OAAO,IAAI,CAAC,WAAW,CAAC;IAC1B,CAAC;IAED,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,KAAe,EAAE,SAA8B;QACzD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,WAAW,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/D,CAAC;QAED,qBAAqB;QACrB,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC,gBAAgB,CAAC,CAAC;QACpD,MAAM,aAAa,GAAe,EAAE,CAAC;QACrC,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,IAAI,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC;QAE/B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;YACvD,aAAa,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;YACzC,WAAW,IAAI,MAAM,CAAC,WAAW,CAAC;YAClC,aAAa,GAAG,MAAM,CAAC,KAAK,CAAC;QAC/B,CAAC;QAED,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IAC1E,CAAC;IAED,8EAA8E;IAC9E,kBAAkB;IAClB,8EAA8E;IAEtE,KAAK,CAAC,UAAU,CACtB,KAAe,EACf,SAA8B;QAE9B,MAAM,IAAI,GAA4B;YACpC,KAAK,EAAE,KAAK;YACZ,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,UAAU,EAAE,SAAS,IAAI,IAAI;YAC7B,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,gBAAgB,EAAE,IAAI,CAAC,WAAW;SACnC,CAAC;QAEF,IAAI,SAAqC,CAAC;QAE1C,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,CAAC;YAC5D,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,aAAa,EAAE;oBACzD,MAAM,EAAE,MAAM;oBACd,OAAO,EAAE;wBACP,cAAc,EAAE,kBAAkB;wBAClC,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;qBACvC;oBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;oBAC1B,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC;iBACnD,CAAC,CAAC;gBAEH,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC;oBAChB,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA6B,CAAC;oBACjE,iEAAiE;oBACjE,kCAAkC;oBAClC,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;oBAChE,OAAO;wBACL,UAAU,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;wBAC1C,WAAW,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY;wBACpC,KAAK,EAAE,IAAI,CAAC,KAAK;qBAClB,CAAC;gBACJ,CAAC;gBAED,uBAAuB;gBACvB,MAAM,OAAO,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;oBAClD,MAAM,EAAE,QAAQ,CAAC,UAAU;iBAC5B,CAAC,CAAC,CAAwB,CAAC;gBAC5B,SAAS,GAAG,IAAI,cAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAEzE,4CAA4C;gBAC5C,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,MAAM,CAAC;oBAAE,MAAM,SAAS,CAAC;gBAEnD,wCAAwC;gBACxC,IAAI,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;oBAC9B,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC;gBAChD,CAAC;YACH,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,GAAG,YAAY,cAAc;oBAAE,MAAM,GAAG,CAAC;gBAC7C,+DAA+D;gBAC/D,IAAI,OAAO,KAAK,IAAI,CAAC,UAAU,EAAE,CAAC;oBAChC,MAAM,IAAI,KAAK,CACb,mCAAmC,OAAO,gBAAgB,MAAM,CAAC,GAAG,CAAC,EAAE,EACvE,EAAE,KAAK,EAAE,GAAG,EAAE,CACf,CAAC;gBACJ,CAAC;gBACD,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC;YAChD,CAAC;QACH,CAAC;QAED,oBAAoB;QACpB,MAAM,SAAS,IAAI,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;IACvF,CAAC;CACF;AAED,8EAA8E;AAC9E,YAAY;AACZ,8EAA8E;AAE9E,SAAS,KAAK,CAAI,GAAQ,EAAE,IAAY;IACtC,MAAM,MAAM,GAAU,EAAE,CAAC;IACzB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;QAC1C,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,KAAK,CAAC,EAAU;IACvB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,SAAS,WAAW,CAAC,MAAc;IACjC,OAAO,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG,CAAC,CAAC;AAC3D,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimator
|
|
3
|
+
*
|
|
4
|
+
* Wraps `EXPLAIN (FORMAT JSON)` to estimate query cost and row-scan volume
|
|
5
|
+
* before execution, applying configurable rejection thresholds.
|
|
6
|
+
*
|
|
7
|
+
* The estimator is created via a factory function that accepts a pg Pool so
|
|
8
|
+
* that callers control connection lifecycle. The pool should be a read-write
|
|
9
|
+
* pool (EXPLAIN does not require write privileges, but the pgsage_readonly
|
|
10
|
+
* role can run EXPLAIN as well).
|
|
11
|
+
*/
|
|
12
|
+
import type pg from "pg";
|
|
13
|
+
export interface EstimatorConfig {
|
|
14
|
+
/** pg Pool used to run EXPLAIN queries. */
|
|
15
|
+
pool: InstanceType<typeof pg.Pool>;
|
|
16
|
+
/**
|
|
17
|
+
* Maximum allowed planner cost units before the query is flagged.
|
|
18
|
+
* PostgreSQL's cost model is unitless but roughly corresponds to sequential
|
|
19
|
+
* 8 KB page reads. Default: 1_000_000.
|
|
20
|
+
*/
|
|
21
|
+
costThreshold?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Maximum estimated row count before the query is flagged.
|
|
24
|
+
* Default: 500_000.
|
|
25
|
+
*/
|
|
26
|
+
rowThreshold?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface CostEstimate {
|
|
29
|
+
/** Top-level planner "Total Cost" from EXPLAIN (FORMAT JSON). */
|
|
30
|
+
estimatedCost: number;
|
|
31
|
+
/** Top-level "Plan Rows" from EXPLAIN (FORMAT JSON). */
|
|
32
|
+
estimatedRows: number;
|
|
33
|
+
/** True when estimatedCost > costThreshold OR estimatedRows > rowThreshold. */
|
|
34
|
+
exceedsThreshold: boolean;
|
|
35
|
+
/** Human-readable reason when exceedsThreshold is true. */
|
|
36
|
+
thresholdReason?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface Estimator {
|
|
39
|
+
estimate(sql: string): Promise<CostEstimate>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create an Estimator that runs `EXPLAIN (FORMAT JSON)` against the provided
|
|
43
|
+
* pool and compares the result against the configured thresholds.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const estimator = createEstimator({ pool, costThreshold: 100_000 });
|
|
48
|
+
* const result = await estimator.estimate("SELECT * FROM public.estimates");
|
|
49
|
+
* if (result.exceedsThreshold) throw new Error(result.thresholdReason);
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export declare function createEstimator(config: EstimatorConfig): Estimator;
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/estimator/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAMzB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,IAAI,EAAE,YAAY,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;IACnC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,aAAa,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,gBAAgB,EAAE,OAAO,CAAC;IAC1B,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC9C;AAuBD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CAkClE"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimator
|
|
3
|
+
*
|
|
4
|
+
* Wraps `EXPLAIN (FORMAT JSON)` to estimate query cost and row-scan volume
|
|
5
|
+
* before execution, applying configurable rejection thresholds.
|
|
6
|
+
*
|
|
7
|
+
* The estimator is created via a factory function that accepts a pg Pool so
|
|
8
|
+
* that callers control connection lifecycle. The pool should be a read-write
|
|
9
|
+
* pool (EXPLAIN does not require write privileges, but the pgsage_readonly
|
|
10
|
+
* role can run EXPLAIN as well).
|
|
11
|
+
*/
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Factory
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const DEFAULT_COST_THRESHOLD = 1_000_000;
|
|
16
|
+
const DEFAULT_ROW_THRESHOLD = 500_000;
|
|
17
|
+
/**
|
|
18
|
+
* Create an Estimator that runs `EXPLAIN (FORMAT JSON)` against the provided
|
|
19
|
+
* pool and compares the result against the configured thresholds.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const estimator = createEstimator({ pool, costThreshold: 100_000 });
|
|
24
|
+
* const result = await estimator.estimate("SELECT * FROM public.estimates");
|
|
25
|
+
* if (result.exceedsThreshold) throw new Error(result.thresholdReason);
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function createEstimator(config) {
|
|
29
|
+
const costThreshold = config.costThreshold ?? DEFAULT_COST_THRESHOLD;
|
|
30
|
+
const rowThreshold = config.rowThreshold ?? DEFAULT_ROW_THRESHOLD;
|
|
31
|
+
return {
|
|
32
|
+
async estimate(sql) {
|
|
33
|
+
const res = await config.pool.query(`EXPLAIN (FORMAT JSON) ${sql}`);
|
|
34
|
+
const plan = res.rows[0]?.["QUERY PLAN"]?.[0];
|
|
35
|
+
if (!plan) {
|
|
36
|
+
throw new Error("EXPLAIN returned no plan rows — the SQL may be invalid.");
|
|
37
|
+
}
|
|
38
|
+
const estimatedCost = plan.Plan["Total Cost"];
|
|
39
|
+
const estimatedRows = plan.Plan["Plan Rows"];
|
|
40
|
+
const costExceeded = estimatedCost > costThreshold;
|
|
41
|
+
const rowsExceeded = estimatedRows > rowThreshold;
|
|
42
|
+
const exceedsThreshold = costExceeded || rowsExceeded;
|
|
43
|
+
let thresholdReason;
|
|
44
|
+
if (costExceeded && rowsExceeded) {
|
|
45
|
+
thresholdReason = `Query exceeds both cost threshold (${estimatedCost.toFixed(2)} > ${costThreshold}) and row threshold (${estimatedRows} > ${rowThreshold}).`;
|
|
46
|
+
}
|
|
47
|
+
else if (costExceeded) {
|
|
48
|
+
thresholdReason = `Query exceeds cost threshold (${estimatedCost.toFixed(2)} > ${costThreshold}).`;
|
|
49
|
+
}
|
|
50
|
+
else if (rowsExceeded) {
|
|
51
|
+
thresholdReason = `Query exceeds row threshold (${estimatedRows} > ${rowThreshold}).`;
|
|
52
|
+
}
|
|
53
|
+
return { estimatedCost, estimatedRows, exceedsThreshold, thresholdReason };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/estimator/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAqDH,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,MAAM,sBAAsB,GAAG,SAAS,CAAC;AACzC,MAAM,qBAAqB,GAAG,OAAO,CAAC;AAEtC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,MAAuB;IACrD,MAAM,aAAa,GAAG,MAAM,CAAC,aAAa,IAAI,sBAAsB,CAAC;IACrE,MAAM,YAAY,GAAG,MAAM,CAAC,YAAY,IAAI,qBAAqB,CAAC;IAElE,OAAO;QACL,KAAK,CAAC,QAAQ,CAAC,GAAW;YACxB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CACjC,yBAAyB,GAAG,EAAE,CAC/B,CAAC;YAEF,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YAC9C,IAAI,CAAC,IAAI,EAAE,CAAC;gBACV,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;YAC7E,CAAC;YAED,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YAC9C,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAE7C,MAAM,YAAY,GAAG,aAAa,GAAG,aAAa,CAAC;YACnD,MAAM,YAAY,GAAG,aAAa,GAAG,YAAY,CAAC;YAClD,MAAM,gBAAgB,GAAG,YAAY,IAAI,YAAY,CAAC;YAEtD,IAAI,eAAmC,CAAC;YACxC,IAAI,YAAY,IAAI,YAAY,EAAE,CAAC;gBACjC,eAAe,GAAG,sCAAsC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,aAAa,wBAAwB,aAAa,MAAM,YAAY,IAAI,CAAC;YACjK,CAAC;iBAAM,IAAI,YAAY,EAAE,CAAC;gBACxB,eAAe,GAAG,iCAAiC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,aAAa,IAAI,CAAC;YACrG,CAAC;iBAAM,IAAI,YAAY,EAAE,CAAC;gBACxB,eAAe,GAAG,gCAAgC,aAAa,MAAM,YAAY,IAAI,CAAC;YACxF,CAAC;YAED,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;QAC7E,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor
|
|
3
|
+
*
|
|
4
|
+
* Executes validated, cost-checked SQL against a read-only, pooled, timeout-bound
|
|
5
|
+
* Postgres connection. The pool provided should use the `pgsage_readonly` role —
|
|
6
|
+
* that role has no write/DDL grants, acting as a second line of defense behind
|
|
7
|
+
* the AST validator.
|
|
8
|
+
*
|
|
9
|
+
* Automatic row limiting: if the SQL already has a top-level LIMIT clause its
|
|
10
|
+
* value is left untouched; otherwise a LIMIT is appended. This prevents
|
|
11
|
+
* runaway result sets that slip past the cost estimator.
|
|
12
|
+
*/
|
|
13
|
+
import type pg from "pg";
|
|
14
|
+
export interface ExecutorConfig {
|
|
15
|
+
/** pg Pool connected as the read-only role (pgsage_readonly). */
|
|
16
|
+
pool: InstanceType<typeof pg.Pool>;
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of rows returned. Appended as a LIMIT clause when the
|
|
19
|
+
* query does not already include one. Default: 500.
|
|
20
|
+
*/
|
|
21
|
+
rowLimit?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Per-query statement timeout in milliseconds, set with SET LOCAL on the
|
|
24
|
+
* acquired client before executing the query. Default: 30_000 ms.
|
|
25
|
+
*/
|
|
26
|
+
statementTimeout?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ExecutionResult {
|
|
29
|
+
/** The returned rows as plain objects keyed by column name. */
|
|
30
|
+
rows: Record<string, unknown>[];
|
|
31
|
+
/** Actual row count returned (after row-limit enforcement). */
|
|
32
|
+
rowCount: number;
|
|
33
|
+
/** Column names in result order. */
|
|
34
|
+
fields: string[];
|
|
35
|
+
/**
|
|
36
|
+
* True when the result was capped at rowLimit. The caller should surface
|
|
37
|
+
* this to the user so they know the result is incomplete.
|
|
38
|
+
*/
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface Executor {
|
|
42
|
+
execute(sql: string): Promise<ExecutionResult>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create an Executor that runs SQL on the supplied read-only pool with
|
|
46
|
+
* automatic statement timeout and row-limit enforcement.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* const executor = createExecutor({ pool: readonlyPool, rowLimit: 200 });
|
|
51
|
+
* const result = await executor.execute("SELECT * FROM public.estimates");
|
|
52
|
+
* if (result.truncated) console.warn("Result was truncated to 200 rows");
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function createExecutor(config: ExecutorConfig): Executor;
|
|
56
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/executor/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAMzB,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,IAAI,EAAE,YAAY,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CAChD;AAqCD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAgC/D"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor
|
|
3
|
+
*
|
|
4
|
+
* Executes validated, cost-checked SQL against a read-only, pooled, timeout-bound
|
|
5
|
+
* Postgres connection. The pool provided should use the `pgsage_readonly` role —
|
|
6
|
+
* that role has no write/DDL grants, acting as a second line of defense behind
|
|
7
|
+
* the AST validator.
|
|
8
|
+
*
|
|
9
|
+
* Automatic row limiting: if the SQL already has a top-level LIMIT clause its
|
|
10
|
+
* value is left untouched; otherwise a LIMIT is appended. This prevents
|
|
11
|
+
* runaway result sets that slip past the cost estimator.
|
|
12
|
+
*/
|
|
13
|
+
import { parse } from "pgsql-ast-parser";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const DEFAULT_ROW_LIMIT = 500;
|
|
18
|
+
const DEFAULT_STATEMENT_TIMEOUT = 30_000;
|
|
19
|
+
/**
|
|
20
|
+
* Returns true when the top-level SELECT/WITH already has a LIMIT clause so
|
|
21
|
+
* we don't stack an additional one. Uses the AST parser — if parsing fails
|
|
22
|
+
* (shouldn't happen after validation) we conservatively assume no LIMIT.
|
|
23
|
+
*/
|
|
24
|
+
function hasTopLevelLimit(sql) {
|
|
25
|
+
try {
|
|
26
|
+
const stmts = parse(sql);
|
|
27
|
+
const stmt = stmts[0];
|
|
28
|
+
if (!stmt)
|
|
29
|
+
return false;
|
|
30
|
+
// select, with, and union shapes all expose a `limit` property at the top
|
|
31
|
+
// level when they carry one.
|
|
32
|
+
if ("limit" in stmt && stmt.limit != null)
|
|
33
|
+
return true;
|
|
34
|
+
// `with` statements delegate to their `in` clause
|
|
35
|
+
if (stmt.type === "with" && "limit" in stmt.in && stmt.in.limit != null)
|
|
36
|
+
return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Factory
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Create an Executor that runs SQL on the supplied read-only pool with
|
|
48
|
+
* automatic statement timeout and row-limit enforcement.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const executor = createExecutor({ pool: readonlyPool, rowLimit: 200 });
|
|
53
|
+
* const result = await executor.execute("SELECT * FROM public.estimates");
|
|
54
|
+
* if (result.truncated) console.warn("Result was truncated to 200 rows");
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function createExecutor(config) {
|
|
58
|
+
const rowLimit = config.rowLimit ?? DEFAULT_ROW_LIMIT;
|
|
59
|
+
const statementTimeout = config.statementTimeout ?? DEFAULT_STATEMENT_TIMEOUT;
|
|
60
|
+
return {
|
|
61
|
+
async execute(sql) {
|
|
62
|
+
const client = await config.pool.connect();
|
|
63
|
+
try {
|
|
64
|
+
// Apply a per-query timeout as an additional guard on top of the pool's
|
|
65
|
+
// global statement_timeout option.
|
|
66
|
+
await client.query(`SET LOCAL statement_timeout = ${statementTimeout}`);
|
|
67
|
+
// Append LIMIT only when the query doesn't already carry one.
|
|
68
|
+
const limitedSql = hasTopLevelLimit(sql) ? sql : `${sql} LIMIT ${rowLimit}`;
|
|
69
|
+
const res = await client.query(limitedSql);
|
|
70
|
+
const rows = res.rows;
|
|
71
|
+
const fields = res.fields.map((f) => f.name);
|
|
72
|
+
const truncated = !hasTopLevelLimit(sql) && rows.length >= rowLimit;
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
rowCount: rows.length,
|
|
76
|
+
fields,
|
|
77
|
+
truncated,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
client.release();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/executor/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAwCzC,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAC9B,MAAM,yBAAyB,GAAG,MAAM,CAAC;AAEzC;;;;GAIG;AACH,SAAS,gBAAgB,CAAC,GAAW;IACnC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;QACzB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACtB,IAAI,CAAC,IAAI;YAAE,OAAO,KAAK,CAAC;QAExB,0EAA0E;QAC1E,6BAA6B;QAC7B,IAAI,OAAO,IAAI,IAAI,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC;QAEvD,kDAAkD;QAClD,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,OAAO,IAAI,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC,KAAK,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC;QAErF,OAAO,KAAK,CAAC;IACf,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,MAAsB;IACnD,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,iBAAiB,CAAC;IACtD,MAAM,gBAAgB,GAAG,MAAM,CAAC,gBAAgB,IAAI,yBAAyB,CAAC;IAE9E,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,GAAW;YACvB,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YAC3C,IAAI,CAAC;gBACH,wEAAwE;gBACxE,mCAAmC;gBACnC,MAAM,MAAM,CAAC,KAAK,CAAC,iCAAiC,gBAAgB,EAAE,CAAC,CAAC;gBAExE,8DAA8D;gBAC9D,MAAM,UAAU,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,UAAU,QAAQ,EAAE,CAAC;gBAE5E,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;gBAE3C,MAAM,IAAI,GAAI,GAAG,CAAC,IAAkC,CAAC;gBACrD,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;gBAC7C,MAAM,SAAS,GAAG,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,QAAQ,CAAC;gBAEpE,OAAO;oBACL,IAAI;oBACJ,QAAQ,EAAE,IAAI,CAAC,MAAM;oBACrB,MAAM;oBACN,SAAS;iBACV,CAAC;YACJ,CAAC;oBAAS,CAAC;gBACT,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explainer
|
|
3
|
+
*
|
|
4
|
+
* Post-execution stage that generates a concise natural-language answer
|
|
5
|
+
* from the SQL query results. Uses a lightweight LLM call (same Mastra
|
|
6
|
+
* Agent pattern as the planner) to summarize the data into 1–3 sentences.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const explainer = createExplainer();
|
|
10
|
+
* const answer = await explainer.explain({ question, sql, ... });
|
|
11
|
+
* // answer — "Providence County has a median household income of $78,787 (±$1,722)."
|
|
12
|
+
*/
|
|
13
|
+
import { type ExplainerInput } from "./prompt.js";
|
|
14
|
+
export interface ExplainerConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Mastra model router string for the explainer LLM call.
|
|
17
|
+
* @default "anthropic/claude-sonnet-4-6"
|
|
18
|
+
*/
|
|
19
|
+
model?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Explainer {
|
|
22
|
+
/**
|
|
23
|
+
* Generate a natural-language answer from the query results.
|
|
24
|
+
*
|
|
25
|
+
* @param input The question, SQL, result rows, and metadata.
|
|
26
|
+
* @returns A concise 1–3 sentence answer string.
|
|
27
|
+
*/
|
|
28
|
+
explain(input: ExplainerInput): Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a reusable Explainer that wraps a Mastra Agent.
|
|
32
|
+
* The Agent is instantiated once and reused across all explain() calls.
|
|
33
|
+
*
|
|
34
|
+
* Requires the ANTHROPIC_API_KEY environment variable to be set.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createExplainer(config?: ExplainerConfig): Explainer;
|
|
37
|
+
export { EXPLAINER_SYSTEM_PROMPT, buildExplainerMessage, } from "./prompt.js";
|
|
38
|
+
export type { ExplainerInput } from "./prompt.js";
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/explainer/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAMrB,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IACxB;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AA2BD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,SAAS,CAsCvE;AAMD,OAAO,EACL,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explainer
|
|
3
|
+
*
|
|
4
|
+
* Post-execution stage that generates a concise natural-language answer
|
|
5
|
+
* from the SQL query results. Uses a lightweight LLM call (same Mastra
|
|
6
|
+
* Agent pattern as the planner) to summarize the data into 1–3 sentences.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const explainer = createExplainer();
|
|
10
|
+
* const answer = await explainer.explain({ question, sql, ... });
|
|
11
|
+
* // answer — "Providence County has a median household income of $78,787 (±$1,722)."
|
|
12
|
+
*/
|
|
13
|
+
import { Agent } from "@mastra/core/agent";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { EXPLAINER_SYSTEM_PROMPT, buildExplainerMessage, } from "./prompt.js";
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Zod schema for structured output
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const explainerResultSchema = z.object({
|
|
20
|
+
answer: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe("A concise 1–3 sentence natural-language answer to the user's question"),
|
|
23
|
+
});
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Fallback answer (used when rows are empty or the LLM call fails)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function buildFallbackAnswer(input) {
|
|
28
|
+
if (input.rowCount === 0) {
|
|
29
|
+
return `No matching data was found for "${input.question}".`;
|
|
30
|
+
}
|
|
31
|
+
return `Found ${String(input.rowCount)} result${input.rowCount === 1 ? "" : "s"}.`;
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Factory
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Creates a reusable Explainer that wraps a Mastra Agent.
|
|
38
|
+
* The Agent is instantiated once and reused across all explain() calls.
|
|
39
|
+
*
|
|
40
|
+
* Requires the ANTHROPIC_API_KEY environment variable to be set.
|
|
41
|
+
*/
|
|
42
|
+
export function createExplainer(config = {}) {
|
|
43
|
+
const modelId = config.model ?? "anthropic/claude-sonnet-4-6";
|
|
44
|
+
const agent = new Agent({
|
|
45
|
+
id: "pgsage-explainer",
|
|
46
|
+
name: "pgsage-explainer",
|
|
47
|
+
instructions: EXPLAINER_SYSTEM_PROMPT,
|
|
48
|
+
model: modelId,
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
async explain(input) {
|
|
52
|
+
// Short-circuit: no rows → use fallback without an LLM call
|
|
53
|
+
if (input.rowCount === 0) {
|
|
54
|
+
return buildFallbackAnswer(input);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const userMessage = buildExplainerMessage(input);
|
|
58
|
+
const result = await agent.generate(userMessage, {
|
|
59
|
+
structuredOutput: { schema: explainerResultSchema },
|
|
60
|
+
});
|
|
61
|
+
const answer = result.object.answer;
|
|
62
|
+
// Guard against empty or whitespace-only answers
|
|
63
|
+
if (!answer || answer.trim().length === 0) {
|
|
64
|
+
return buildFallbackAnswer(input);
|
|
65
|
+
}
|
|
66
|
+
return answer;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// LLM failure should not break the pipeline — degrade gracefully
|
|
70
|
+
return buildFallbackAnswer(input);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Convenience re-exports
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export { EXPLAINER_SYSTEM_PROMPT, buildExplainerMessage, } from "./prompt.js";
|
|
79
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/explainer/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,GAEtB,MAAM,aAAa,CAAC;AAwBrB,8EAA8E;AAC9E,mCAAmC;AACnC,8EAA8E;AAE9E,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IACrC,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,QAAQ,CAAC,uEAAuE,CAAC;CACrF,CAAC,CAAC;AAEH,8EAA8E;AAC9E,mEAAmE;AACnE,8EAA8E;AAE9E,SAAS,mBAAmB,CAAC,KAAqB;IAChD,IAAI,KAAK,CAAC,QAAQ,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,mCAAmC,KAAK,CAAC,QAAQ,IAAI,CAAC;IAC/D,CAAC;IACD,OAAO,SAAS,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,KAAK,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC;AACrF,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,SAA0B,EAAE;IAC1D,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,IAAI,6BAA6B,CAAC;IAE9D,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC;QACtB,EAAE,EAAE,kBAAkB;QACtB,IAAI,EAAE,kBAAkB;QACxB,YAAY,EAAE,uBAAuB;QACrC,KAAK,EAAE,OAAO;KACf,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,KAAqB;YACjC,4DAA4D;YAC5D,IAAI,KAAK,CAAC,QAAQ,KAAK,CAAC,EAAE,CAAC;gBACzB,OAAO,mBAAmB,CAAC,KAAK,CAAC,CAAC;YACpC,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,WAAW,GAAG,qBAAqB,CAAC,KAAK,CAAC,CAAC;gBAEjD,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,WAAW,EAAE;oBAC/C,gBAAgB,EAAE,EAAE,MAAM,EAAE,qBAAqB,EAAE;iBACpD,CAAC,CAAC;gBAEH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;gBAEpC,iDAAiD;gBACjD,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBAC1C,OAAO,mBAAmB,CAAC,KAAK,CAAC,CAAC;gBACpC,CAAC;gBAED,OAAO,MAAM,CAAC;YAChB,CAAC;YAAC,MAAM,CAAC;gBACP,iEAAiE;gBACjE,OAAO,mBAAmB,CAAC,KAAK,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,yBAAyB;AACzB,8EAA8E;AAE9E,OAAO,EACL,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explainer prompt utilities
|
|
3
|
+
*
|
|
4
|
+
* Contains:
|
|
5
|
+
* - EXPLAINER_SYSTEM_PROMPT: instructs the model to produce a concise
|
|
6
|
+
* natural-language answer from query results
|
|
7
|
+
* - buildExplainerMessage(): formats the question, plan metadata, and
|
|
8
|
+
* result rows into a compact user message for the explainer LLM call
|
|
9
|
+
*/
|
|
10
|
+
import type { DisplayHint } from "../planner/index.js";
|
|
11
|
+
export declare const EXPLAINER_SYSTEM_PROMPT = "You are a data analyst summarizing SQL query results in plain English.\n\n## Your task\nGiven the user's original question, the SQL query results (column names + rows),\nand some metadata about the query, produce a concise natural-language answer.\n\n## Rules\n- Answer the question DIRECTLY. Start with the key finding, not \"Based on the data\u2026\" or \"The query returned\u2026\".\n- Reference specific values from the data. Format numbers with proper units:\n - Currency: $78,787 (with commas, dollar sign)\n - Percentages: 34.2%\n - Counts: 1,234,567 (with commas)\n - Margins of error: \u00B1$1,722 or \u00B12.3%\n- Keep it to 1\u20133 sentences. Be direct and informative.\n- Do NOT repeat the question back.\n- Do NOT list assumptions \u2014 those are displayed separately in the UI.\n- Do NOT say \"the data shows\" or \"according to the results\" \u2014 just state the facts.\n- If the displayHint is \"big\" (single value), state the value and its context clearly.\n- If the displayHint is \"bar\" (ranked list), mention the top entry and give a sense of the range.\n- If the displayHint is \"statrow\" (multiple metrics), briefly cover each metric.\n- If the displayHint is \"grouped\" (comparison), highlight the key comparison.\n- If the displayHint is \"table\" (general), summarize the key pattern or finding.\n- If there are zero rows, say that no matching data was found and briefly suggest why.\n\n## Output format\nRespond with a JSON object:\n{\n \"answer\": \"<your 1\u20133 sentence answer>\"\n}\nDo not include any text outside the JSON object.";
|
|
12
|
+
export interface ExplainerInput {
|
|
13
|
+
/** The user's original natural-language question. */
|
|
14
|
+
question: string;
|
|
15
|
+
/** The SQL query that was executed. */
|
|
16
|
+
sql: string;
|
|
17
|
+
/** Assumptions the planner made (so the explainer avoids repeating them). */
|
|
18
|
+
assumptions: string[];
|
|
19
|
+
/** The display hint chosen by the planner. */
|
|
20
|
+
displayHint: DisplayHint;
|
|
21
|
+
/** Column names in result order. */
|
|
22
|
+
fields: string[];
|
|
23
|
+
/** Result rows (capped to keep token usage low). */
|
|
24
|
+
rows: Record<string, unknown>[];
|
|
25
|
+
/** Total row count (may be larger than rows.length if truncated). */
|
|
26
|
+
rowCount: number;
|
|
27
|
+
/** Whether the result was truncated. */
|
|
28
|
+
truncated: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Builds the user message for the explainer LLM call.
|
|
32
|
+
*
|
|
33
|
+
* Includes the question, display hint, field names, and a compact
|
|
34
|
+
* representation of the first N rows.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildExplainerMessage(input: ExplainerInput): string;
|
|
37
|
+
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/explainer/prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAMvD,eAAO,MAAM,uBAAuB,4iDA8Ba,CAAC;AAMlD,MAAM,WAAW,cAAc;IAC7B,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,6EAA6E;IAC7E,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,8CAA8C;IAC9C,WAAW,EAAE,WAAW,CAAC;IACzB,oCAAoC;IACpC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,wCAAwC;IACxC,SAAS,EAAE,OAAO,CAAC;CACpB;AAQD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAsDnE"}
|