@duckcodeailabs/dql-agent 1.6.20 → 1.6.22
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/agent-run-engine.d.ts +287 -0
- package/dist/agent-run-engine.d.ts.map +1 -0
- package/dist/agent-run-engine.js +916 -0
- package/dist/agent-run-engine.js.map +1 -0
- package/dist/agent-run-gates.d.ts +26 -0
- package/dist/agent-run-gates.d.ts.map +1 -0
- package/dist/agent-run-gates.js +170 -0
- package/dist/agent-run-gates.js.map +1 -0
- package/dist/agent-run-planner.d.ts +32 -0
- package/dist/agent-run-planner.d.ts.map +1 -0
- package/dist/agent-run-planner.js +280 -0
- package/dist/agent-run-planner.js.map +1 -0
- package/dist/answer-loop.d.ts +49 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +292 -17
- package/dist/answer-loop.js.map +1 -1
- package/dist/app-builder.d.ts +44 -2
- package/dist/app-builder.d.ts.map +1 -1
- package/dist/app-builder.js +558 -123
- package/dist/app-builder.js.map +1 -1
- package/dist/app-planner.d.ts +78 -0
- package/dist/app-planner.d.ts.map +1 -0
- package/dist/app-planner.js +145 -0
- package/dist/app-planner.js.map +1 -0
- package/dist/embeddings/provider.d.ts +66 -0
- package/dist/embeddings/provider.d.ts.map +1 -0
- package/dist/embeddings/provider.js +105 -0
- package/dist/embeddings/provider.js.map +1 -0
- package/dist/hints/git-store.d.ts +69 -0
- package/dist/hints/git-store.d.ts.map +1 -0
- package/dist/hints/git-store.js +244 -0
- package/dist/hints/git-store.js.map +1 -0
- package/dist/hints/retrieval.d.ts +49 -0
- package/dist/hints/retrieval.d.ts.map +1 -0
- package/dist/hints/retrieval.js +60 -0
- package/dist/hints/retrieval.js.map +1 -0
- package/dist/hints/store.d.ts +39 -0
- package/dist/hints/store.d.ts.map +1 -0
- package/dist/hints/store.js +279 -0
- package/dist/hints/store.js.map +1 -0
- package/dist/hints/types.d.ts +147 -0
- package/dist/hints/types.d.ts.map +1 -0
- package/dist/hints/types.js +92 -0
- package/dist/hints/types.js.map +1 -0
- package/dist/index.d.ts +43 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -3
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +63 -0
- package/dist/intent-controller.d.ts.map +1 -0
- package/dist/intent-controller.js +150 -0
- package/dist/intent-controller.js.map +1 -0
- package/dist/kg/build.d.ts.map +1 -1
- package/dist/kg/build.js +2 -0
- package/dist/kg/build.js.map +1 -1
- package/dist/kg/sqlite-fts.js +4 -0
- package/dist/kg/sqlite-fts.js.map +1 -1
- package/dist/kg/types.d.ts +10 -0
- package/dist/kg/types.d.ts.map +1 -1
- package/dist/metadata/catalog.d.ts +81 -4
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +208 -1
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/drafts.d.ts +22 -0
- package/dist/metadata/drafts.d.ts.map +1 -1
- package/dist/metadata/drafts.js +126 -2
- package/dist/metadata/drafts.js.map +1 -1
- package/dist/metadata/grain-gate.d.ts +61 -0
- package/dist/metadata/grain-gate.d.ts.map +1 -0
- package/dist/metadata/grain-gate.js +265 -0
- package/dist/metadata/grain-gate.js.map +1 -0
- package/dist/metadata/identity.d.ts +47 -0
- package/dist/metadata/identity.d.ts.map +1 -0
- package/dist/metadata/identity.js +147 -0
- package/dist/metadata/identity.js.map +1 -0
- package/dist/metadata/metric-match.d.ts +93 -0
- package/dist/metadata/metric-match.d.ts.map +1 -0
- package/dist/metadata/metric-match.js +233 -0
- package/dist/metadata/metric-match.js.map +1 -0
- package/dist/metadata/sql-grounding.d.ts +149 -0
- package/dist/metadata/sql-grounding.d.ts.map +1 -0
- package/dist/metadata/sql-grounding.js +397 -0
- package/dist/metadata/sql-grounding.js.map +1 -0
- package/dist/metadata/sql-retrieval.d.ts +29 -0
- package/dist/metadata/sql-retrieval.d.ts.map +1 -0
- package/dist/metadata/sql-retrieval.js +79 -0
- package/dist/metadata/sql-retrieval.js.map +1 -0
- package/dist/narrate.d.ts +52 -0
- package/dist/narrate.d.ts.map +1 -0
- package/dist/narrate.js +263 -0
- package/dist/narrate.js.map +1 -0
- package/dist/propose/build-from-prompt.d.ts +160 -0
- package/dist/propose/build-from-prompt.d.ts.map +1 -0
- package/dist/propose/build-from-prompt.js +815 -0
- package/dist/propose/build-from-prompt.js.map +1 -0
- package/dist/propose/classify.d.ts +55 -0
- package/dist/propose/classify.d.ts.map +1 -0
- package/dist/propose/classify.js +167 -0
- package/dist/propose/classify.js.map +1 -0
- package/dist/propose/config.d.ts +39 -0
- package/dist/propose/config.d.ts.map +1 -0
- package/dist/propose/config.js +52 -0
- package/dist/propose/config.js.map +1 -0
- package/dist/propose/dbt-artifacts.d.ts +134 -0
- package/dist/propose/dbt-artifacts.d.ts.map +1 -0
- package/dist/propose/dbt-artifacts.js +320 -0
- package/dist/propose/dbt-artifacts.js.map +1 -0
- package/dist/propose/edit-block.d.ts +74 -0
- package/dist/propose/edit-block.d.ts.map +1 -0
- package/dist/propose/edit-block.js +133 -0
- package/dist/propose/edit-block.js.map +1 -0
- package/dist/propose/enrich.d.ts +48 -0
- package/dist/propose/enrich.d.ts.map +1 -0
- package/dist/propose/enrich.js +125 -0
- package/dist/propose/enrich.js.map +1 -0
- package/dist/propose/generate-sql.d.ts +61 -0
- package/dist/propose/generate-sql.d.ts.map +1 -0
- package/dist/propose/generate-sql.js +180 -0
- package/dist/propose/generate-sql.js.map +1 -0
- package/dist/propose/index.d.ts +24 -0
- package/dist/propose/index.d.ts.map +1 -0
- package/dist/propose/index.js +15 -0
- package/dist/propose/index.js.map +1 -0
- package/dist/propose/propose.d.ts +226 -0
- package/dist/propose/propose.d.ts.map +1 -0
- package/dist/propose/propose.js +668 -0
- package/dist/propose/propose.js.map +1 -0
- package/dist/propose/reflect-block.d.ts +99 -0
- package/dist/propose/reflect-block.d.ts.map +1 -0
- package/dist/propose/reflect-block.js +220 -0
- package/dist/propose/reflect-block.js.map +1 -0
- package/dist/propose/write-draft.d.ts +90 -0
- package/dist/propose/write-draft.d.ts.map +1 -0
- package/dist/propose/write-draft.js +185 -0
- package/dist/propose/write-draft.js.map +1 -0
- package/dist/providers/claude.d.ts +13 -1
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +20 -2
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/gemini.d.ts +13 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +24 -3
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +3 -3
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -3
- package/dist/providers/index.js.map +1 -1
- package/dist/research-loop.d.ts +62 -0
- package/dist/research-loop.d.ts.map +1 -0
- package/dist/research-loop.js +210 -0
- package/dist/research-loop.js.map +1 -0
- package/dist/skills/defaults.d.ts +37 -0
- package/dist/skills/defaults.d.ts.map +1 -0
- package/dist/skills/defaults.js +152 -0
- package/dist/skills/defaults.js.map +1 -0
- package/dist/skills/loader.d.ts +60 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +164 -2
- package/dist/skills/loader.js.map +1 -1
- package/package.json +7 -5
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified AI BUILD engine (spec 14, part B) — ONE engine, two targets.
|
|
3
|
+
*
|
|
4
|
+
* `buildFromPrompt` turns a natural-language prompt into either:
|
|
5
|
+
* - target:'cell' → a SQL snippet for an ad-hoc notebook cell (writes nothing).
|
|
6
|
+
* - target:'block' → a COMPLETE governance DRAFT written to disk via
|
|
7
|
+
* `upsertProposedDraft`, with its Certifier verdict stored.
|
|
8
|
+
*
|
|
9
|
+
* It deliberately does NOT route through the governed Q&A answer-loop
|
|
10
|
+
* (`answer()` / `ask_dql`). Building is a different intent: it must not leak the
|
|
11
|
+
* answer-loop's self-correction reasoning, evidence tiers, or reviewStatus. We
|
|
12
|
+
* reuse the lighter plumbing instead:
|
|
13
|
+
* - `pickProvider` / `provider.generate` for AI content (optional),
|
|
14
|
+
* - `loadDbtArtifacts` for offline schema grounding,
|
|
15
|
+
* - `deriveSemanticDraftName` for a semantic block name,
|
|
16
|
+
* - `resolveLocalOwner` so a draft is never born without an owner,
|
|
17
|
+
* - `upsertProposedDraft` + `Certifier` for the draft + stored verdict.
|
|
18
|
+
*
|
|
19
|
+
* Principles enforced here:
|
|
20
|
+
* - AI drafts, humans certify: a block is ALWAYS `status: "draft"`; this engine
|
|
21
|
+
* never writes `certified`.
|
|
22
|
+
* - Structure deterministic, content AI-optional: with no provider, the cell
|
|
23
|
+
* target returns a deterministic templated SELECT (or a helpful message in
|
|
24
|
+
* `explanation`) and the block target falls back to deterministic content.
|
|
25
|
+
* It never crashes on the offline path.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { Certifier } from '@duckcodeailabs/dql-governance';
|
|
31
|
+
import { reflectAndReviseBlock, } from './reflect-block.js';
|
|
32
|
+
import { deriveSemanticDraftName } from '../metadata/drafts.js';
|
|
33
|
+
import { resolveLocalOwner } from '../metadata/identity.js';
|
|
34
|
+
import { matchSemanticMetric, resolveGovernedMetricSql, parseMetricDefinition, } from '../metadata/metric-match.js';
|
|
35
|
+
import { deriveBlockFilters } from './generate-sql.js';
|
|
36
|
+
import { buildSchemaGrounding, renderGroundingForPrompt, resolveRelationsInSql, validateSqlAgainstGrounding, } from '../metadata/sql-grounding.js';
|
|
37
|
+
import { selectRelevantModels } from '../metadata/sql-retrieval.js';
|
|
38
|
+
import { buildSkillsPrompt, loadSkills, selectRelevantSkills } from '../skills/loader.js';
|
|
39
|
+
import { pickProvider } from '../providers/index.js';
|
|
40
|
+
import { loadDbtArtifacts } from './dbt-artifacts.js';
|
|
41
|
+
import { blockSlug, upsertProposedDraft } from './write-draft.js';
|
|
42
|
+
import { loadBlockForEdit, renderEditedBlock, resolveEditedStatus, writeEditedBlock, } from './edit-block.js';
|
|
43
|
+
const CELL_SYSTEM_PROMPT = 'You are a SQL generator for an analytics notebook. Given a request and the ' +
|
|
44
|
+
'grounded schema, return ONE read-only SQL query (SELECT/WITH only). ' +
|
|
45
|
+
'Use ONLY the relations and columns provided. Reference each table by its ' +
|
|
46
|
+
'fully-qualified relation (database.schema.table) EXACTLY as shown — never a ' +
|
|
47
|
+
'bare model name, never a table or column not in the grounding. Use the ' +
|
|
48
|
+
'listed join keys to connect tables. ' +
|
|
49
|
+
'Respond with ONLY a JSON object — no prose, no markdown fences.';
|
|
50
|
+
const BLOCK_SYSTEM_PROMPT = 'You design a reusable, governed analytics block from a natural-language request. ' +
|
|
51
|
+
'Use ONLY the relations and columns provided; do not invent tables or columns. ' +
|
|
52
|
+
"Reference each table by its {{ ref('<model>') }} form EXACTLY as shown (DQL " +
|
|
53
|
+
'resolves it at execution). Use the listed join keys to connect tables. ' +
|
|
54
|
+
'Respond with ONLY a JSON object — no prose, no markdown fences.';
|
|
55
|
+
/** Best-effort load of dbt artifacts for schema grounding. Never throws. */
|
|
56
|
+
function tryLoadArtifacts(projectRoot, manifestPath) {
|
|
57
|
+
const candidates = [
|
|
58
|
+
manifestPath,
|
|
59
|
+
join(projectRoot, 'target', 'manifest.json'),
|
|
60
|
+
join(projectRoot, '..', 'target', 'manifest.json'),
|
|
61
|
+
join(projectRoot, '..', 'dbt', 'target', 'manifest.json'),
|
|
62
|
+
].filter((p) => Boolean(p));
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
if (!existsSync(candidate))
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
return loadDbtArtifacts(candidate);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Try the next candidate.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Pull the first balanced JSON object out of a model response. */
|
|
76
|
+
function extractJson(text) {
|
|
77
|
+
const start = text.indexOf('{');
|
|
78
|
+
if (start < 0)
|
|
79
|
+
return null;
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let inStr = false;
|
|
82
|
+
let esc = false;
|
|
83
|
+
for (let i = start; i < text.length; i += 1) {
|
|
84
|
+
const ch = text[i];
|
|
85
|
+
if (inStr) {
|
|
86
|
+
if (esc)
|
|
87
|
+
esc = false;
|
|
88
|
+
else if (ch === '\\')
|
|
89
|
+
esc = true;
|
|
90
|
+
else if (ch === '"')
|
|
91
|
+
inStr = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"')
|
|
95
|
+
inStr = true;
|
|
96
|
+
else if (ch === '{')
|
|
97
|
+
depth += 1;
|
|
98
|
+
else if (ch === '}') {
|
|
99
|
+
depth -= 1;
|
|
100
|
+
if (depth === 0) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(text.slice(start, i + 1));
|
|
103
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function asString(value) {
|
|
114
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
115
|
+
}
|
|
116
|
+
function asStringArray(value, max = 12) {
|
|
117
|
+
if (!Array.isArray(value))
|
|
118
|
+
return [];
|
|
119
|
+
return value
|
|
120
|
+
.filter((v) => typeof v === 'string' && v.trim().length > 0)
|
|
121
|
+
.map((v) => v.trim())
|
|
122
|
+
.slice(0, max);
|
|
123
|
+
}
|
|
124
|
+
const requireForBuild = createRequire(import.meta.url);
|
|
125
|
+
/** Load metric KG nodes (read-only, best-effort) so the Build path can reuse the
|
|
126
|
+
* same governed-metric matching the Ask path uses. Returns [] when no KG exists. */
|
|
127
|
+
export function loadSemanticMetrics(projectRoot) {
|
|
128
|
+
const dbPath = join(projectRoot, '.dql', 'cache', 'agent-kg.sqlite');
|
|
129
|
+
if (!existsSync(dbPath))
|
|
130
|
+
return [];
|
|
131
|
+
try {
|
|
132
|
+
const Database = requireForBuild('better-sqlite3');
|
|
133
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
134
|
+
try {
|
|
135
|
+
const rows = db
|
|
136
|
+
.prepare("SELECT node_id, name, domain, description, llm_context, tags_json FROM kg_nodes WHERE kind = 'metric' LIMIT 400")
|
|
137
|
+
.all();
|
|
138
|
+
return rows.map((r) => ({
|
|
139
|
+
nodeId: r.node_id,
|
|
140
|
+
kind: 'metric',
|
|
141
|
+
name: r.name,
|
|
142
|
+
domain: r.domain ?? undefined,
|
|
143
|
+
description: r.description ?? undefined,
|
|
144
|
+
llmContext: r.llm_context ?? undefined,
|
|
145
|
+
tags: safeJsonArray(r.tags_json),
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function safeJsonArray(raw) {
|
|
157
|
+
if (!raw)
|
|
158
|
+
return [];
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Match the request to a governed metric and resolve its executable definition. */
|
|
168
|
+
async function matchGovernedMetric(projectRoot, prompt) {
|
|
169
|
+
const metrics = loadSemanticMetrics(projectRoot);
|
|
170
|
+
if (metrics.length === 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
const match = await matchSemanticMetric(prompt, metrics).catch(() => null);
|
|
173
|
+
if (!match)
|
|
174
|
+
return undefined;
|
|
175
|
+
const resolved = resolveGovernedMetricSql(match.metric, metrics);
|
|
176
|
+
if (!resolved)
|
|
177
|
+
return undefined;
|
|
178
|
+
const def = parseMetricDefinition(resolved.metric);
|
|
179
|
+
if (!def)
|
|
180
|
+
return undefined;
|
|
181
|
+
// Display the metric the USER recognizes; use the resolved measure's executable def.
|
|
182
|
+
return { name: match.metric.name, expr: def.expr, table: def.table, sql: resolved.sql };
|
|
183
|
+
}
|
|
184
|
+
/** Output column names that name a non-negative measure (safe for a `>= 0` guard). */
|
|
185
|
+
const NON_NEGATIVE_MEASURE_RE = /(revenue|sales|income|count|total|amount|sum|qty|quantity|value|price|profit|cost|spend|orders?|tax)\b/i;
|
|
186
|
+
/**
|
|
187
|
+
* Auto-draft a safe sanity test so an AI-built block is born with at least one
|
|
188
|
+
* assertion the reviewer can keep or strengthen — never asked to author one. We
|
|
189
|
+
* only emit a `>= 0` guard on an output that clearly names a non-negative measure,
|
|
190
|
+
* so the assertion always passes (never a surprise certification block). Returns
|
|
191
|
+
* [] when nothing is provably safe, leaving "no tests" as an advisory warning.
|
|
192
|
+
*/
|
|
193
|
+
function safeSanityInvariants(outputs) {
|
|
194
|
+
const measure = outputs.find((name) => NON_NEGATIVE_MEASURE_RE.test(name));
|
|
195
|
+
return measure ? [`${measure} >= 0`] : [];
|
|
196
|
+
}
|
|
197
|
+
/** Resolve the provider for this run, honoring `offline` + explicit overrides. */
|
|
198
|
+
async function resolveProvider(options) {
|
|
199
|
+
if (options.offline)
|
|
200
|
+
return undefined;
|
|
201
|
+
if (options.provider) {
|
|
202
|
+
return (await options.provider.available().catch(() => false)) ? options.provider : undefined;
|
|
203
|
+
}
|
|
204
|
+
const provider = await pickProvider();
|
|
205
|
+
return (await provider.available().catch(() => false)) ? provider : undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Deterministic, offline-safe templated SQL when no provider is available.
|
|
209
|
+
* Grounds the starter query on the REAL qualified relation (e.g.
|
|
210
|
+
* `dev.order_items`), never a bare model name.
|
|
211
|
+
*/
|
|
212
|
+
function fallbackCellSql(grounding, context) {
|
|
213
|
+
if (context?.cellSql?.trim())
|
|
214
|
+
return context.cellSql.trim();
|
|
215
|
+
const first = grounding.tables[0];
|
|
216
|
+
if (first)
|
|
217
|
+
return `SELECT *\nFROM ${first.qualifiedRelation}\nLIMIT 100`;
|
|
218
|
+
return 'SELECT 1 AS placeholder';
|
|
219
|
+
}
|
|
220
|
+
/** Strip markdown fences / `sql` prefixes a provider might add around a query. */
|
|
221
|
+
function cleanSql(raw) {
|
|
222
|
+
let sql = raw.trim();
|
|
223
|
+
sql = sql.replace(/^```(?:sql)?\s*/i, '').replace(/\s*```$/i, '');
|
|
224
|
+
return sql.trim();
|
|
225
|
+
}
|
|
226
|
+
/** A worked example clause appended to grounding so the model has a pattern. */
|
|
227
|
+
function workedExample(grounding, prefer) {
|
|
228
|
+
const first = grounding.tables[0];
|
|
229
|
+
if (!first)
|
|
230
|
+
return '';
|
|
231
|
+
const rel = prefer === 'ref' && first.refForm ? first.refForm : first.qualifiedRelation;
|
|
232
|
+
return `Worked example — count rows in ${first.name}:\nSELECT count(*) AS row_count FROM ${rel}`;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Ground generated SQL: deterministically qualify bare relations, then validate
|
|
236
|
+
* against the grounding. On a validation miss (or execution error when an
|
|
237
|
+
* executor is supplied) re-prompt the model with the specific error, bounded to
|
|
238
|
+
* `maxRepairs` attempts. Returns the best SQL we could ground, plus a flag for
|
|
239
|
+
* whether it ultimately validated. Never throws; the offline path is safe.
|
|
240
|
+
*/
|
|
241
|
+
async function groundAndRepairSql(input) {
|
|
242
|
+
const { grounding, prefer, provider, systemPrompt, userPrompt } = input;
|
|
243
|
+
const maxRepairs = input.maxRepairs ?? 2;
|
|
244
|
+
let current = resolveRelationsInSql(cleanSql(input.initialSql), grounding, { prefer }).sql;
|
|
245
|
+
let lastError;
|
|
246
|
+
for (let attempt = 0; attempt <= maxRepairs; attempt += 1) {
|
|
247
|
+
const validation = validateSqlAgainstGrounding(current, grounding);
|
|
248
|
+
let errorForRepair = validation.ok ? undefined : validation.error;
|
|
249
|
+
// When valid and an executor is available, run a bounded preview; a runtime
|
|
250
|
+
// error is just another repairable miss.
|
|
251
|
+
if (!errorForRepair && input.executeSql) {
|
|
252
|
+
try {
|
|
253
|
+
await input.executeSql(current);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
errorForRepair = err instanceof Error ? err.message : String(err);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!errorForRepair)
|
|
260
|
+
return { sql: current, validated: true };
|
|
261
|
+
lastError = errorForRepair;
|
|
262
|
+
if (attempt === maxRepairs || !provider)
|
|
263
|
+
break;
|
|
264
|
+
try {
|
|
265
|
+
const repaired = await provider.generate([
|
|
266
|
+
{ role: 'system', content: systemPrompt },
|
|
267
|
+
{ role: 'user', content: userPrompt },
|
|
268
|
+
{ role: 'assistant', content: JSON.stringify({ sql: current }) },
|
|
269
|
+
{
|
|
270
|
+
role: 'user',
|
|
271
|
+
content: [
|
|
272
|
+
'That SQL was rejected by grounding validation.',
|
|
273
|
+
`Error: ${errorForRepair}`,
|
|
274
|
+
'Return corrected JSON with a "sql" key that references ONLY the grounded relations and columns shown above.',
|
|
275
|
+
].join('\n'),
|
|
276
|
+
},
|
|
277
|
+
], { maxTokens: 700, temperature: 0.1, signal: AbortSignal.timeout(25_000) });
|
|
278
|
+
const parsed = extractJson(repaired);
|
|
279
|
+
const next = parsed ? asString(parsed.sql) : undefined;
|
|
280
|
+
if (!next)
|
|
281
|
+
break;
|
|
282
|
+
current = resolveRelationsInSql(cleanSql(next), grounding, { prefer }).sql;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
sql: current,
|
|
290
|
+
validated: false,
|
|
291
|
+
message: lastError
|
|
292
|
+
? `The generated SQL referenced relations or columns outside the grounded schema (${lastError}). Returned the best grounded attempt for review.`
|
|
293
|
+
: undefined,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// ─── target: cell ───────────────────────────────────────────────────────────
|
|
297
|
+
async function buildCell(options, grounding, provider, skillsPrompt, appliedSkills, matchedMetric) {
|
|
298
|
+
if (!provider) {
|
|
299
|
+
// No provider — prefer the governed metric definition over a blank starter.
|
|
300
|
+
return {
|
|
301
|
+
target: 'cell',
|
|
302
|
+
sql: matchedMetric?.sql ?? fallbackCellSql(grounding, options.context),
|
|
303
|
+
explanation: matchedMetric
|
|
304
|
+
? `Used the governed metric \`${matchedMetric.name}\` (${matchedMetric.expr}). Set a provider to tailor it further.`
|
|
305
|
+
: 'No AI provider is configured. Returned a starter query — edit it, or set a provider to generate SQL from your prompt.',
|
|
306
|
+
appliedSkills,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const userPrompt = [
|
|
310
|
+
skillsPrompt || null,
|
|
311
|
+
`Request: ${options.prompt}`,
|
|
312
|
+
matchedMetric
|
|
313
|
+
? `GOVERNED METRIC — \`${matchedMetric.name}\` is defined as ${matchedMetric.expr} over ${matchedMetric.table}. Build the SQL USING this governed expression (do not invent a different formula); apply any requested grouping on top of it.`
|
|
314
|
+
: null,
|
|
315
|
+
options.context?.cellSql ? `Current cell SQL:\n${options.context.cellSql}` : null,
|
|
316
|
+
options.context?.selection ? `Selected fragment:\n${options.context.selection}` : null,
|
|
317
|
+
`Grounded schema:\n${renderGroundingForPrompt(grounding, 'cell')}`,
|
|
318
|
+
workedExample(grounding, 'qualified') || null,
|
|
319
|
+
'Produce JSON with keys: "sql" (one read-only SELECT/WITH query) and optional "explanation" (one sentence).',
|
|
320
|
+
]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join('\n\n');
|
|
323
|
+
try {
|
|
324
|
+
const response = await provider.generate([
|
|
325
|
+
{ role: 'system', content: CELL_SYSTEM_PROMPT },
|
|
326
|
+
{ role: 'user', content: userPrompt },
|
|
327
|
+
], { maxTokens: 600, temperature: 0.1, signal: AbortSignal.timeout(25_000) });
|
|
328
|
+
const parsed = extractJson(response);
|
|
329
|
+
const sql = parsed ? asString(parsed.sql) : undefined;
|
|
330
|
+
if (sql) {
|
|
331
|
+
const grounded = await groundAndRepairSql({
|
|
332
|
+
initialSql: sql,
|
|
333
|
+
grounding,
|
|
334
|
+
prefer: 'qualified',
|
|
335
|
+
provider,
|
|
336
|
+
systemPrompt: CELL_SYSTEM_PROMPT,
|
|
337
|
+
userPrompt,
|
|
338
|
+
maxRepairs: 2,
|
|
339
|
+
executeSql: options.executeSql,
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
target: 'cell',
|
|
343
|
+
sql: grounded.sql,
|
|
344
|
+
explanation: grounded.validated
|
|
345
|
+
? (parsed ? asString(parsed.explanation) : undefined)
|
|
346
|
+
: grounded.message,
|
|
347
|
+
appliedSkills,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Fall through to the deterministic fallback below.
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
target: 'cell',
|
|
356
|
+
sql: matchedMetric?.sql ?? fallbackCellSql(grounding, options.context),
|
|
357
|
+
explanation: matchedMetric
|
|
358
|
+
? `Answered from the governed metric \`${matchedMetric.name}\` (${matchedMetric.expr}).`
|
|
359
|
+
: 'Could not generate SQL from the prompt; returned a starter query to refine.',
|
|
360
|
+
appliedSkills,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function generateBlockContent(options, grounding, provider, skillsPrompt, matchedMetric) {
|
|
364
|
+
const userPrompt = [
|
|
365
|
+
skillsPrompt || null,
|
|
366
|
+
`Request: ${options.prompt}`,
|
|
367
|
+
// Authoritative: a governed metric already defines this measure — use ITS
|
|
368
|
+
// expression, do not re-derive a different formula.
|
|
369
|
+
matchedMetric
|
|
370
|
+
? `GOVERNED METRIC — a certified semantic metric already exists for this request: \`${matchedMetric.name}\` is defined as ${matchedMetric.expr} over ${matchedMetric.table}. Build the SQL USING this exact governed expression (do not invent a different formula); apply any requested grouping or grain on top of it.`
|
|
371
|
+
: null,
|
|
372
|
+
options.context?.cellSql ? `Reference SQL:\n${options.context.cellSql}` : null,
|
|
373
|
+
`Grounded schema:\n${renderGroundingForPrompt(grounding, 'block')}`,
|
|
374
|
+
workedExample(grounding, 'ref') || null,
|
|
375
|
+
'Produce JSON with keys:',
|
|
376
|
+
'- "name": a short snake_case block name (entity + key dimension + grain), e.g. "orders_by_region_daily".',
|
|
377
|
+
"- \"sql\": one read-only SELECT/WITH query answering the request, referencing each table by its {{ ref('<model>') }} form and only the grounded columns.",
|
|
378
|
+
'- "description": one or two business-facing sentences.',
|
|
379
|
+
'- "grain": the row grain (e.g. "one row per customer per day"), or "".',
|
|
380
|
+
'- "outputs": array of output column names the query returns.',
|
|
381
|
+
'- "llmContext": one to three sentences telling an AI agent when to use this block.',
|
|
382
|
+
'- "examples": array of up to 3 business questions this block answers.',
|
|
383
|
+
'- "entities": array of business entities (e.g. ["order"]).',
|
|
384
|
+
'- "invariants": array of simple column predicates that should hold (e.g. ["order_count >= 0"]), or [].',
|
|
385
|
+
]
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
.join('\n');
|
|
388
|
+
if (!provider)
|
|
389
|
+
return { content: { outputs: [], examples: [], entities: [], invariants: [] }, userPrompt };
|
|
390
|
+
try {
|
|
391
|
+
const response = await provider.generate([
|
|
392
|
+
{ role: 'system', content: BLOCK_SYSTEM_PROMPT },
|
|
393
|
+
{ role: 'user', content: userPrompt },
|
|
394
|
+
], { maxTokens: 900, temperature: 0.2, signal: AbortSignal.timeout(25_000) });
|
|
395
|
+
const parsed = extractJson(response);
|
|
396
|
+
if (!parsed)
|
|
397
|
+
return { content: { outputs: [], examples: [], entities: [], invariants: [] }, userPrompt };
|
|
398
|
+
return {
|
|
399
|
+
content: {
|
|
400
|
+
name: asString(parsed.name),
|
|
401
|
+
sql: asString(parsed.sql),
|
|
402
|
+
description: asString(parsed.description),
|
|
403
|
+
grain: asString(parsed.grain),
|
|
404
|
+
outputs: asStringArray(parsed.outputs, 30),
|
|
405
|
+
llmContext: asString(parsed.llmContext),
|
|
406
|
+
examples: asStringArray(parsed.examples, 3),
|
|
407
|
+
entities: asStringArray(parsed.entities, 6),
|
|
408
|
+
invariants: asStringArray(parsed.invariants, 6),
|
|
409
|
+
},
|
|
410
|
+
userPrompt,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return { content: { outputs: [], examples: [], entities: [], invariants: [] }, userPrompt };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Scan the project for existing block/draft slugs so the namer can dedupe. */
|
|
418
|
+
function collectExistingSlugs(projectRoot) {
|
|
419
|
+
const slugs = new Set();
|
|
420
|
+
const dirs = [
|
|
421
|
+
join(projectRoot, 'blocks'),
|
|
422
|
+
join(projectRoot, 'blocks', '_drafts'),
|
|
423
|
+
];
|
|
424
|
+
// Domain-scoped block + draft folders.
|
|
425
|
+
const domainsRoot = join(projectRoot, 'domains');
|
|
426
|
+
if (existsSync(domainsRoot)) {
|
|
427
|
+
try {
|
|
428
|
+
for (const domain of readdirSync(domainsRoot)) {
|
|
429
|
+
dirs.push(join(domainsRoot, domain, 'blocks'), join(domainsRoot, domain, 'blocks', '_drafts'));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// Best-effort scan.
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const dir of dirs) {
|
|
437
|
+
if (!existsSync(dir))
|
|
438
|
+
continue;
|
|
439
|
+
try {
|
|
440
|
+
for (const entry of readdirSync(dir)) {
|
|
441
|
+
if (entry.endsWith('.dql'))
|
|
442
|
+
slugs.add(entry.replace(/\.dql$/, ''));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// Skip unreadable dirs.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return slugs;
|
|
450
|
+
}
|
|
451
|
+
function verdictFrom(result) {
|
|
452
|
+
return {
|
|
453
|
+
blocking: result.errors.map((e) => e.message),
|
|
454
|
+
warnings: result.warnings.map((w) => w.message),
|
|
455
|
+
ready: result.errors.length === 0,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
async function buildBlock(options, grounding, provider, skillsPrompt, appliedSkills, matchedMetric) {
|
|
459
|
+
const { projectRoot } = options;
|
|
460
|
+
const owner = resolveLocalOwner(projectRoot, { explicit: options.owner });
|
|
461
|
+
const domain = (options.domain ?? 'misc').trim() || 'misc';
|
|
462
|
+
const { content, userPrompt } = await generateBlockContent(options, grounding, provider, skillsPrompt, matchedMetric);
|
|
463
|
+
// Semantic name: provider suggestion → rule-based → legacy tokenizer (never the raw prompt).
|
|
464
|
+
const existingSlugs = collectExistingSlugs(projectRoot);
|
|
465
|
+
const name = deriveSemanticDraftName({
|
|
466
|
+
question: options.prompt,
|
|
467
|
+
providerName: content.name,
|
|
468
|
+
existingSlugs,
|
|
469
|
+
});
|
|
470
|
+
// SQL: provider SQL → governed metric definition (when a metric matched and the
|
|
471
|
+
// model declined) → reference cell SQL → deterministic templated SELECT. All go
|
|
472
|
+
// through grounding so relations become `{{ ref() }}` forms.
|
|
473
|
+
let sql;
|
|
474
|
+
const initialSql = content.sql || (matchedMetric ? matchedMetric.sql : undefined);
|
|
475
|
+
if (initialSql) {
|
|
476
|
+
const grounded = await groundAndRepairSql({
|
|
477
|
+
initialSql,
|
|
478
|
+
grounding,
|
|
479
|
+
prefer: 'ref',
|
|
480
|
+
provider,
|
|
481
|
+
systemPrompt: BLOCK_SYSTEM_PROMPT,
|
|
482
|
+
userPrompt,
|
|
483
|
+
maxRepairs: 2,
|
|
484
|
+
});
|
|
485
|
+
sql = grounded.sql;
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
sql =
|
|
489
|
+
options.context?.cellSql?.trim() ||
|
|
490
|
+
resolveRelationsInSql(fallbackCellSql(grounding, options.context), grounding, { prefer: 'ref' }).sql;
|
|
491
|
+
}
|
|
492
|
+
const baseDescription = content.description || `Draft block built from prompt: "${options.prompt.slice(0, 160)}".`;
|
|
493
|
+
// When built on a governed metric, make that provenance explicit in the description.
|
|
494
|
+
const description = matchedMetric
|
|
495
|
+
? `${baseDescription} Built on the governed metric \`${matchedMetric.name}\` (${matchedMetric.expr}).`
|
|
496
|
+
: baseDescription;
|
|
497
|
+
const outputs = content.outputs;
|
|
498
|
+
const examples = content.examples;
|
|
499
|
+
const entities = content.entities;
|
|
500
|
+
// Auto-draft a safe sanity test when the model proposed none, so a built block
|
|
501
|
+
// is born with a test assertion (advisory) and the reviewer isn't asked to author
|
|
502
|
+
// one. `>= 0` on a non-negative measure output is the safe, always-passing guard.
|
|
503
|
+
const invariants = content.invariants.length > 0
|
|
504
|
+
? content.invariants
|
|
505
|
+
: safeSanityInvariants(outputs);
|
|
506
|
+
const grain = content.grain;
|
|
507
|
+
// Tag with a sensible default so the has-tags warning is satisfied.
|
|
508
|
+
const tags = ['proposed', 'ai-build'];
|
|
509
|
+
// dbt is the upstream source system for an AI-built, dbt-grounded block — fill it
|
|
510
|
+
// so the lineage warning is pre-satisfied (reviewer can refine).
|
|
511
|
+
const sourceSystems = ['dbt'];
|
|
512
|
+
const draftRelPath = resolveDraftRelPath(projectRoot, domain, name);
|
|
513
|
+
const blockType = matchedMetric ? 'semantic' : 'custom';
|
|
514
|
+
const pattern = matchedMetric ? 'metric_wrapper' : 'custom';
|
|
515
|
+
// Reflect-before-certify (P2): build → run the verifier → auto-revise what is
|
|
516
|
+
// SAFE (the output contract via the execution probe, governance gaps) → report
|
|
517
|
+
// the rest for the human. Owner accountability stays the human gate.
|
|
518
|
+
const reflectable = {
|
|
519
|
+
slug: name,
|
|
520
|
+
domain,
|
|
521
|
+
owner,
|
|
522
|
+
description,
|
|
523
|
+
grain,
|
|
524
|
+
outputs,
|
|
525
|
+
entities,
|
|
526
|
+
invariants,
|
|
527
|
+
llmContext: content.llmContext,
|
|
528
|
+
reviewCadence: 'quarterly',
|
|
529
|
+
tags,
|
|
530
|
+
sourceSystems,
|
|
531
|
+
gitPath: draftRelPath,
|
|
532
|
+
blockType,
|
|
533
|
+
pattern,
|
|
534
|
+
metricRef: matchedMetric?.name,
|
|
535
|
+
};
|
|
536
|
+
let probe;
|
|
537
|
+
if (options.executionProbe) {
|
|
538
|
+
// Best-effort: a probe failure falls back to a static (governance-only) reflection.
|
|
539
|
+
try {
|
|
540
|
+
probe = await options.executionProbe({ sql, invariants });
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
probe = undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const reflection = reflectAndReviseBlock(reflectable, probe);
|
|
547
|
+
const revised = reflection.revised;
|
|
548
|
+
const verdict = verdictFrom({
|
|
549
|
+
errors: reflection.certification.errors,
|
|
550
|
+
warnings: reflection.certification.warnings,
|
|
551
|
+
});
|
|
552
|
+
const draft = {
|
|
553
|
+
slug: name,
|
|
554
|
+
domain,
|
|
555
|
+
owner,
|
|
556
|
+
description: revised.description,
|
|
557
|
+
sql,
|
|
558
|
+
// Metric-bound (semantic) block when a governed metric drove this build, so it
|
|
559
|
+
// references the semantic layer instead of re-deriving the formula in raw SQL.
|
|
560
|
+
blockType: revised.blockType ?? blockType,
|
|
561
|
+
metricRef: revised.metricRef,
|
|
562
|
+
pattern: revised.pattern ?? pattern,
|
|
563
|
+
grain: revised.grain,
|
|
564
|
+
entities: revised.entities,
|
|
565
|
+
// Reflected outputs: reconciled against the SQL's real columns when probed.
|
|
566
|
+
declaredOutputs: revised.outputs,
|
|
567
|
+
// App-ready: the block's output columns become dashboard filters.
|
|
568
|
+
...deriveBlockFilters(revised.outputs),
|
|
569
|
+
llmContext: revised.llmContext,
|
|
570
|
+
invariants: revised.invariants,
|
|
571
|
+
examples: examples.map((question) => ({ question })),
|
|
572
|
+
tags: revised.tags,
|
|
573
|
+
reviewCadence: revised.reviewCadence,
|
|
574
|
+
sourceModel: '(ai-build prompt)',
|
|
575
|
+
sourceSystems: revised.sourceSystems,
|
|
576
|
+
// The reflected verdict — the review header reflects what the agent already fixed.
|
|
577
|
+
certification: {
|
|
578
|
+
certified: false,
|
|
579
|
+
errors: reflection.certification.errors,
|
|
580
|
+
warnings: reflection.certification.warnings,
|
|
581
|
+
},
|
|
582
|
+
reflectionFixes: reflection.fixesApplied.map((f) => `${f.rule}: ${f.action}`),
|
|
583
|
+
};
|
|
584
|
+
const written = upsertProposedDraft(projectRoot, draft);
|
|
585
|
+
return {
|
|
586
|
+
target: 'block',
|
|
587
|
+
path: written.path,
|
|
588
|
+
name,
|
|
589
|
+
sqlPreview: sql,
|
|
590
|
+
description: revised.description,
|
|
591
|
+
grain: revised.grain,
|
|
592
|
+
outputs: revised.outputs,
|
|
593
|
+
examples,
|
|
594
|
+
certifierVerdict: verdict,
|
|
595
|
+
reflection,
|
|
596
|
+
appliedSkills,
|
|
597
|
+
edited: false,
|
|
598
|
+
route: matchedMetric
|
|
599
|
+
? { tier: 'semantic_metric', label: `Based on governed metric ${matchedMetric.name}`, ref: matchedMetric.name }
|
|
600
|
+
: { tier: 'generated_sql', label: `Drafted new block ${name}`, ref: name },
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** Mirror of the draft-writer's path resolution (for the certifier gitPath). */
|
|
604
|
+
function resolveDraftRelPath(projectRoot, domain, slug) {
|
|
605
|
+
const safe = domain
|
|
606
|
+
.trim()
|
|
607
|
+
.toLowerCase()
|
|
608
|
+
.replace(/[^a-z0-9/_-]+/g, '-')
|
|
609
|
+
.replace(/^[-/]+|[-/]+$/g, '');
|
|
610
|
+
if (safe && existsSync(join(projectRoot, 'domains', safe))) {
|
|
611
|
+
return `domains/${safe}/blocks/_drafts/${slug}.dql`;
|
|
612
|
+
}
|
|
613
|
+
return `blocks/_drafts/${slug}.dql`;
|
|
614
|
+
}
|
|
615
|
+
function toBlockRecord(input) {
|
|
616
|
+
const now = new Date();
|
|
617
|
+
return {
|
|
618
|
+
id: input.slug,
|
|
619
|
+
name: input.slug,
|
|
620
|
+
domain: input.domain,
|
|
621
|
+
type: input.blockType ?? 'custom',
|
|
622
|
+
version: '0.1.0',
|
|
623
|
+
status: 'draft',
|
|
624
|
+
gitRepo: '',
|
|
625
|
+
gitPath: input.gitPath,
|
|
626
|
+
gitCommitSha: '',
|
|
627
|
+
description: input.description,
|
|
628
|
+
owner: input.owner,
|
|
629
|
+
tags: input.tags,
|
|
630
|
+
dependencies: [],
|
|
631
|
+
usedInCount: 0,
|
|
632
|
+
createdAt: now,
|
|
633
|
+
updatedAt: now,
|
|
634
|
+
llmContext: input.llmContext,
|
|
635
|
+
invariants: input.invariants,
|
|
636
|
+
pattern: input.pattern ?? 'custom',
|
|
637
|
+
metricRef: input.metricRef,
|
|
638
|
+
grain: input.grain,
|
|
639
|
+
entities: input.entities.length > 0 ? input.entities : undefined,
|
|
640
|
+
declaredOutputs: input.outputs.length > 0 ? input.outputs : undefined,
|
|
641
|
+
testAssertions: input.invariants.map((inv) => `assert ${inv}`),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
// ─── mode: edit (spec 17, part A) ─────────────────────────────────────────────
|
|
645
|
+
const EDIT_BLOCK_SYSTEM_PROMPT = 'You modify an EXISTING governed analytics block. You are given the block\'s ' +
|
|
646
|
+
'current SQL + metadata and a change request (e.g. add a missed table/column, ' +
|
|
647
|
+
'change the grain). Apply ONLY the requested change; preserve everything else. ' +
|
|
648
|
+
'Use ONLY the relations and columns provided in the grounded schema; do not ' +
|
|
649
|
+
"invent tables or columns. Reference each table by its {{ ref('<model>') }} " +
|
|
650
|
+
'form EXACTLY as shown. Respond with ONLY a JSON object — no prose, no fences.';
|
|
651
|
+
/**
|
|
652
|
+
* Edit an existing block in place (spec 17, part A). Loads the block at
|
|
653
|
+
* `blockPath`, asks the model for the UPDATED SQL/metadata applying the user's
|
|
654
|
+
* change, re-grounds + validates + repairs the SQL via spec-15, and writes the
|
|
655
|
+
* result BACK to the SAME path. Never forks a new draft. Returns `previousSql`
|
|
656
|
+
* for a diff and preserves the block's status (certified stays certified).
|
|
657
|
+
*/
|
|
658
|
+
async function editBlock(options, grounding, provider, skillsPrompt, appliedSkills) {
|
|
659
|
+
if (!options.blockPath) {
|
|
660
|
+
throw new Error("Edit mode requires { blockPath } to the block being modified.");
|
|
661
|
+
}
|
|
662
|
+
const existing = loadBlockForEdit(options.projectRoot, options.blockPath);
|
|
663
|
+
const previousSql = existing.sql;
|
|
664
|
+
const userPrompt = [
|
|
665
|
+
skillsPrompt || null,
|
|
666
|
+
`Change request: ${options.prompt}`,
|
|
667
|
+
`Existing block "${existing.name}" (status: ${existing.status ?? 'draft'}, domain: ${existing.domain ?? 'misc'}):`,
|
|
668
|
+
existing.description ? `Description: ${existing.description}` : null,
|
|
669
|
+
existing.grain ? `Grain: ${existing.grain}` : null,
|
|
670
|
+
existing.outputs?.length ? `Outputs: ${existing.outputs.join(', ')}` : null,
|
|
671
|
+
`Current SQL:\n${previousSql || '(empty)'}`,
|
|
672
|
+
`Grounded schema:\n${renderGroundingForPrompt(grounding, 'block')}`,
|
|
673
|
+
workedExample(grounding, 'ref') || null,
|
|
674
|
+
'Produce JSON with keys:',
|
|
675
|
+
'- "sql": the UPDATED read-only SELECT/WITH query with the change applied, ' +
|
|
676
|
+
"referencing each table by its {{ ref('<model>') }} form and only grounded columns.",
|
|
677
|
+
'- "description": the updated one/two-sentence description (or the existing one if unchanged).',
|
|
678
|
+
'- "grain": the row grain, only if the change alters it; else repeat the existing grain or "".',
|
|
679
|
+
'- "outputs": the full array of output column names the updated query returns.',
|
|
680
|
+
]
|
|
681
|
+
.filter(Boolean)
|
|
682
|
+
.join('\n');
|
|
683
|
+
// Generate the updated SQL/metadata (optional; offline keeps the existing SQL).
|
|
684
|
+
let newSql = previousSql;
|
|
685
|
+
let description = existing.description;
|
|
686
|
+
let grain = existing.grain;
|
|
687
|
+
let outputs = existing.outputs ?? [];
|
|
688
|
+
if (provider) {
|
|
689
|
+
try {
|
|
690
|
+
const response = await provider.generate([
|
|
691
|
+
{ role: 'system', content: EDIT_BLOCK_SYSTEM_PROMPT },
|
|
692
|
+
{ role: 'user', content: userPrompt },
|
|
693
|
+
], { maxTokens: 900, temperature: 0.1, signal: AbortSignal.timeout(25_000) });
|
|
694
|
+
const parsed = extractJson(response);
|
|
695
|
+
if (parsed) {
|
|
696
|
+
newSql = asString(parsed.sql) ?? newSql;
|
|
697
|
+
description = asString(parsed.description) ?? description;
|
|
698
|
+
grain = asString(parsed.grain) ?? grain;
|
|
699
|
+
const parsedOutputs = asStringArray(parsed.outputs, 30);
|
|
700
|
+
if (parsedOutputs.length > 0)
|
|
701
|
+
outputs = parsedOutputs;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// Offline-safe: keep the existing SQL/metadata on any provider failure.
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Re-ground + validate + repair the updated SQL (spec 15). Block SQL uses ref().
|
|
709
|
+
const grounded = await groundAndRepairSql({
|
|
710
|
+
initialSql: newSql,
|
|
711
|
+
grounding,
|
|
712
|
+
prefer: 'ref',
|
|
713
|
+
provider,
|
|
714
|
+
systemPrompt: EDIT_BLOCK_SYSTEM_PROMPT,
|
|
715
|
+
userPrompt,
|
|
716
|
+
maxRepairs: 2,
|
|
717
|
+
});
|
|
718
|
+
// Preserve name/owner/domain/grain + status policy; write BACK to same path.
|
|
719
|
+
const status = resolveEditedStatus(existing.status);
|
|
720
|
+
const content = renderEditedBlock({
|
|
721
|
+
name: existing.name,
|
|
722
|
+
blockType: existing.blockType,
|
|
723
|
+
status,
|
|
724
|
+
domain: existing.domain,
|
|
725
|
+
owner: existing.owner,
|
|
726
|
+
description,
|
|
727
|
+
grain,
|
|
728
|
+
pattern: existing.pattern,
|
|
729
|
+
entities: existing.entities,
|
|
730
|
+
outputs,
|
|
731
|
+
tags: existing.tags,
|
|
732
|
+
llmContext: existing.llmContext,
|
|
733
|
+
invariants: existing.invariants,
|
|
734
|
+
sourceSystems: existing.sourceSystems,
|
|
735
|
+
sql: grounded.sql,
|
|
736
|
+
});
|
|
737
|
+
writeEditedBlock(existing.absPath, content);
|
|
738
|
+
// Stored verdict mirrors the edited block (never used to flip status).
|
|
739
|
+
const record = toBlockRecord({
|
|
740
|
+
slug: existing.name,
|
|
741
|
+
domain: existing.domain ?? 'misc',
|
|
742
|
+
owner: existing.owner ?? resolveLocalOwner(options.projectRoot, { explicit: options.owner }),
|
|
743
|
+
description: description ?? `Block "${existing.name}".`,
|
|
744
|
+
grain,
|
|
745
|
+
outputs,
|
|
746
|
+
entities: existing.entities ?? [],
|
|
747
|
+
invariants: existing.invariants ?? [],
|
|
748
|
+
llmContext: existing.llmContext,
|
|
749
|
+
tags: existing.tags ?? ['ai-build'],
|
|
750
|
+
gitPath: existing.requestedPath,
|
|
751
|
+
});
|
|
752
|
+
const evaluation = new Certifier().evaluate(record);
|
|
753
|
+
return {
|
|
754
|
+
target: 'block',
|
|
755
|
+
path: existing.requestedPath,
|
|
756
|
+
name: existing.name,
|
|
757
|
+
sqlPreview: grounded.sql,
|
|
758
|
+
description: description ?? `Block "${existing.name}".`,
|
|
759
|
+
grain,
|
|
760
|
+
outputs,
|
|
761
|
+
examples: [],
|
|
762
|
+
certifierVerdict: verdictFrom(evaluation),
|
|
763
|
+
appliedSkills,
|
|
764
|
+
previousSql,
|
|
765
|
+
edited: true,
|
|
766
|
+
route: {
|
|
767
|
+
tier: 'generated_sql',
|
|
768
|
+
label: `Updated block ${existing.name} in place`,
|
|
769
|
+
ref: existing.name,
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Build from a natural-language prompt. ONE engine, two targets:
|
|
775
|
+
* - 'cell' → returns SQL (writes nothing).
|
|
776
|
+
* - 'block' → writes a complete DRAFT and returns its path + preview fields.
|
|
777
|
+
*
|
|
778
|
+
* `mode: 'edit'` (block target only) updates the existing block at `blockPath`
|
|
779
|
+
* in place instead of forking a new draft (spec 17, part A).
|
|
780
|
+
*/
|
|
781
|
+
export async function buildFromPrompt(options) {
|
|
782
|
+
if (!options.prompt || !options.prompt.trim()) {
|
|
783
|
+
throw new Error('buildFromPrompt requires a non-empty prompt.');
|
|
784
|
+
}
|
|
785
|
+
const artifacts = tryLoadArtifacts(options.projectRoot, options.dbtManifestPath);
|
|
786
|
+
const provider = await resolveProvider(options);
|
|
787
|
+
// Retrieval (spec 15.3): pick the RELEVANT tables for this request, not all of
|
|
788
|
+
// them. Deterministic + offline by default.
|
|
789
|
+
const relevantModels = await selectRelevantModels(artifacts, options.prompt, { topK: 12 });
|
|
790
|
+
// Shared grounding (spec 15.2): qualified relations + {{ ref() }} forms +
|
|
791
|
+
// columns/types + join keys. Used by BOTH targets.
|
|
792
|
+
const grounding = buildSchemaGrounding(artifacts, relevantModels, { limit: 12 });
|
|
793
|
+
// Skills (spec 16): select the SELECTED skills (not all), inject as context,
|
|
794
|
+
// and record which applied. Project (pinned) skills always kept.
|
|
795
|
+
const allSkills = options.skills ?? loadSkills(options.projectRoot).skills;
|
|
796
|
+
const selectedSkills = selectRelevantSkills(allSkills, options.prompt, { userId: options.userId ?? null });
|
|
797
|
+
const skillsPrompt = buildSkillsPrompt(selectedSkills, options.userId ?? null);
|
|
798
|
+
const appliedSkills = selectedSkills.map((s) => ({ id: s.id, description: s.description }));
|
|
799
|
+
// Semantic-metric routing (spec 17, part C) for the Build path: if a governed
|
|
800
|
+
// metric already answers this request, build ON its certified definition instead
|
|
801
|
+
// of letting the model invent a formula. Skipped for edit mode (a different intent).
|
|
802
|
+
const matchedMetric = options.mode === 'edit'
|
|
803
|
+
? undefined
|
|
804
|
+
: await matchGovernedMetric(options.projectRoot, options.prompt);
|
|
805
|
+
if (options.target === 'cell') {
|
|
806
|
+
return buildCell(options, grounding, provider, skillsPrompt, appliedSkills, matchedMetric);
|
|
807
|
+
}
|
|
808
|
+
// target: 'block' — edit an existing block in place, or create a new draft.
|
|
809
|
+
return options.mode === 'edit'
|
|
810
|
+
? editBlock(options, grounding, provider, skillsPrompt, appliedSkills)
|
|
811
|
+
: buildBlock(options, grounding, provider, skillsPrompt, appliedSkills, matchedMetric);
|
|
812
|
+
}
|
|
813
|
+
// Re-export so callers can reuse the slugifier when needed.
|
|
814
|
+
export { blockSlug };
|
|
815
|
+
//# sourceMappingURL=build-from-prompt.js.map
|