@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,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dql propose` engine — turn dbt evidence into a ranked queue of DRAFT blocks.
|
|
3
|
+
*
|
|
4
|
+
* Product principle: **AI drafts, humans certify.** A new user should not author
|
|
5
|
+
* DQL from a blank file. This engine scans dbt artifacts and proposes a draft
|
|
6
|
+
* governance layer: one `block` per high-value model, born `status: draft`, run
|
|
7
|
+
* through the Certifier (results stored so a reviewer sees "what's missing to
|
|
8
|
+
* certify"), and demand-ranked by downstream value.
|
|
9
|
+
*
|
|
10
|
+
* Hard rules enforced here:
|
|
11
|
+
* - Nothing is ever emitted with `status: certified`. Promotion is a separate
|
|
12
|
+
* human action (`dql certify --from-draft`).
|
|
13
|
+
* - Re-running is idempotent: an existing draft / certified / review block for
|
|
14
|
+
* the same model is never overwritten or duplicated.
|
|
15
|
+
* - Inference is conservative — grain/pattern/outputs are only set when the
|
|
16
|
+
* evidence is obvious, and invariants are limited to provably-safe checks.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { Certifier } from '@duckcodeailabs/dql-governance';
|
|
21
|
+
import { loadDbtArtifacts, } from './dbt-artifacts.js';
|
|
22
|
+
import { upsertProposedDraft, blockSlug, } from './write-draft.js';
|
|
23
|
+
import { classifyModel, } from './classify.js';
|
|
24
|
+
import { resolveProposeConfig, } from './config.js';
|
|
25
|
+
import { buildBusinessQuery, buildMetricWrapperBlocks, deriveBlockFilters } from './generate-sql.js';
|
|
26
|
+
const TIME_COLUMN_RE = /(^|_)(date|day|week|month|quarter|year|period|ts|time|timestamp|datetime|created_at|updated_at)($|_)/i;
|
|
27
|
+
const ID_COLUMN_RE = /(_id|_key|_pk|_sk)$/i;
|
|
28
|
+
const MEASURE_COLUMN_RE = /(amount|amt|total|count|qty|quantity|revenue|cost|price|sum|avg|score|value|balance|rate|pct|percent)/i;
|
|
29
|
+
/** Choose the effective columns for a model: catalog (typed) ∪ manifest YAML. */
|
|
30
|
+
function effectiveColumns(model, artifacts) {
|
|
31
|
+
const catalog = artifacts.catalogColumns.get(model.uniqueId) ?? [];
|
|
32
|
+
if (catalog.length === 0)
|
|
33
|
+
return model.columns;
|
|
34
|
+
// Merge descriptions from the YAML columns onto the typed catalog columns.
|
|
35
|
+
const yamlByName = new Map(model.columns.map((c) => [c.name.toLowerCase(), c]));
|
|
36
|
+
return catalog.map((c) => ({
|
|
37
|
+
name: c.name,
|
|
38
|
+
type: c.type ?? yamlByName.get(c.name.toLowerCase())?.type,
|
|
39
|
+
description: c.description ?? yamlByName.get(c.name.toLowerCase())?.description,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Infer the row grain conservatively. We only commit to a grain when there is a
|
|
44
|
+
* single obvious identifier column or an explicit primary-key declaration; we
|
|
45
|
+
* never guess composite grains.
|
|
46
|
+
*/
|
|
47
|
+
function inferGrain(model, columns) {
|
|
48
|
+
// 1. Explicit primary key in meta/config (dbt convention varies; check both).
|
|
49
|
+
const metaPk = model.meta.primary_key ?? model.meta.grain ?? model.config.meta?.primary_key;
|
|
50
|
+
if (typeof metaPk === 'string' && metaPk.trim())
|
|
51
|
+
return metaPk.trim();
|
|
52
|
+
if (Array.isArray(metaPk) && metaPk.length === 1 && typeof metaPk[0] === 'string')
|
|
53
|
+
return metaPk[0];
|
|
54
|
+
// 2. A single id/key column → that is the grain.
|
|
55
|
+
const idColumns = columns.filter((c) => ID_COLUMN_RE.test(c.name));
|
|
56
|
+
if (idColumns.length === 1)
|
|
57
|
+
return idColumns[0].name;
|
|
58
|
+
// 3. A column matching `<entity>_id` where entity == singularized model name.
|
|
59
|
+
const entity = singularize(stripModelPrefix(model.name));
|
|
60
|
+
const named = columns.find((c) => c.name.toLowerCase() === `${entity}_id` || c.name.toLowerCase() === `${entity}_key`);
|
|
61
|
+
if (named)
|
|
62
|
+
return named.name;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Conservative pattern inference from columns + folder + semantic linkage. */
|
|
66
|
+
function inferPattern(model, columns, grain, hasSemanticMetric) {
|
|
67
|
+
if (hasSemanticMetric)
|
|
68
|
+
return 'metric_wrapper';
|
|
69
|
+
const hasTime = columns.some((c) => TIME_COLUMN_RE.test(c.name));
|
|
70
|
+
const hasMeasure = columns.some((c) => MEASURE_COLUMN_RE.test(c.name));
|
|
71
|
+
const idCount = columns.filter((c) => ID_COLUMN_RE.test(c.name)).length;
|
|
72
|
+
// dim_* with a single grain and no measures → entity_profile.
|
|
73
|
+
if (grain && !hasMeasure && /^(dim_|d_)/i.test(model.name))
|
|
74
|
+
return 'entity_profile';
|
|
75
|
+
if (grain && !hasMeasure && idCount === 1)
|
|
76
|
+
return 'entity_profile';
|
|
77
|
+
// fct_*/agg with a grain + measures → entity_rollup.
|
|
78
|
+
if (grain && hasMeasure && /^(fct_|fact_|f_|agg_)/i.test(model.name))
|
|
79
|
+
return 'entity_rollup';
|
|
80
|
+
// Time + measure, no stable single grain → trend.
|
|
81
|
+
if (hasTime && hasMeasure && !grain)
|
|
82
|
+
return 'trend';
|
|
83
|
+
return 'custom';
|
|
84
|
+
}
|
|
85
|
+
function inferEntities(model, grain) {
|
|
86
|
+
if (!grain)
|
|
87
|
+
return [];
|
|
88
|
+
// Derive the business entity from the grain column or model name.
|
|
89
|
+
const base = grain.replace(ID_COLUMN_RE, '');
|
|
90
|
+
const entity = base || singularize(stripModelPrefix(model.name));
|
|
91
|
+
return entity ? [entity] : [];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build provably-safe, *column-checkable* invariants only. The runtime invariant
|
|
95
|
+
* evaluator only sees a query's result columns, so a `row_count >= 0` invariant is
|
|
96
|
+
* "uncheckable" there (it surfaces as a warning) even though the generated test
|
|
97
|
+
* assertion covers it. We therefore emit only column predicates the evaluator can
|
|
98
|
+
* actually check — when an obvious non-negative measure exists, a `>= 0` guard the
|
|
99
|
+
* reviewer can keep or drop. (Row-count coverage lives in the block's tests.)
|
|
100
|
+
*/
|
|
101
|
+
function inferInvariants(columns) {
|
|
102
|
+
const invariants = [];
|
|
103
|
+
const countLike = columns.find((c) => /(^|_)(count|qty|quantity|num_|n_)/i.test(c.name));
|
|
104
|
+
if (countLike)
|
|
105
|
+
invariants.push(`${countLike.name} >= 0`);
|
|
106
|
+
return invariants;
|
|
107
|
+
}
|
|
108
|
+
/** Build llmContext + examples from the model description (conservative). */
|
|
109
|
+
function buildLlmContext(model, columns, grain) {
|
|
110
|
+
const desc = model.description?.trim();
|
|
111
|
+
const grainSentence = grain ? ` One row per ${grain}.` : '';
|
|
112
|
+
const llmContext = desc
|
|
113
|
+
? `${desc}${grainSentence}`.trim()
|
|
114
|
+
: undefined;
|
|
115
|
+
// Prefer concrete business questions (better agent grounding + a real eval set)
|
|
116
|
+
// over a generic "what does this contain?". Derived only from observable evidence
|
|
117
|
+
// — the grain entity and an obvious measure column — so we never invent semantics.
|
|
118
|
+
const examples = [];
|
|
119
|
+
const entity = singularize(((grain ? grain.replace(/_id$/i, '') : '') || stripModelPrefix(model.name))).replace(/_/g, ' ').trim();
|
|
120
|
+
const measureColumn = columns.find((c) => /(amount|spend|revenue|sales|price|value|cost|total)/i.test(c.name)) ??
|
|
121
|
+
columns.find((c) => /(^|_)(count|qty|quantity|num_|n_)/i.test(c.name));
|
|
122
|
+
const measurePhrase = measureColumn ? measureColumn.name.replace(/_/g, ' ') : undefined;
|
|
123
|
+
if (grain && entity) {
|
|
124
|
+
examples.push({ question: `How many ${pluralize(entity)} are there?` });
|
|
125
|
+
if (measurePhrase) {
|
|
126
|
+
examples.push({ question: `What is the total ${measurePhrase} per ${entity}?` });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (measurePhrase) {
|
|
130
|
+
examples.push({ question: `What is the total ${measurePhrase}?` });
|
|
131
|
+
}
|
|
132
|
+
else if (desc) {
|
|
133
|
+
examples.push({ question: `What does the ${stripModelPrefix(model.name).replace(/_/g, ' ')} data cover?` });
|
|
134
|
+
}
|
|
135
|
+
return { llmContext, examples };
|
|
136
|
+
}
|
|
137
|
+
function inferTags(model) {
|
|
138
|
+
const tags = new Set(['proposed', 'from-dbt']);
|
|
139
|
+
for (const tag of model.tags)
|
|
140
|
+
tags.add(tag);
|
|
141
|
+
if (model.folder)
|
|
142
|
+
tags.add(model.folder);
|
|
143
|
+
return [...tags];
|
|
144
|
+
}
|
|
145
|
+
function buildInference(model, artifacts, hasSemanticMetric) {
|
|
146
|
+
const columns = effectiveColumns(model, artifacts);
|
|
147
|
+
const grain = inferGrain(model, columns);
|
|
148
|
+
const pattern = inferPattern(model, columns, grain, hasSemanticMetric);
|
|
149
|
+
const { llmContext, examples } = buildLlmContext(model, columns, grain);
|
|
150
|
+
return {
|
|
151
|
+
pattern,
|
|
152
|
+
grain,
|
|
153
|
+
declaredOutputs: columns.map((c) => c.name),
|
|
154
|
+
entities: inferEntities(model, grain),
|
|
155
|
+
llmContext,
|
|
156
|
+
invariants: inferInvariants(columns),
|
|
157
|
+
examples,
|
|
158
|
+
tags: inferTags(model),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Score a model by DEMAND only: downstream fan-out (primary), exposure linkage,
|
|
163
|
+
* and run frequency. Layer exclusion is now the classifier's job (plumbing is
|
|
164
|
+
* dropped, not penalized), so the score is a non-negative demand signal used to
|
|
165
|
+
* order + cap the selection — folder/name no longer *penalize* the score, which
|
|
166
|
+
* would otherwise push a legitimately-business model below `minScore`. A small
|
|
167
|
+
* positive nudge for mart-style models keeps the most answer-shaped models on
|
|
168
|
+
* top within a domain.
|
|
169
|
+
*/
|
|
170
|
+
function rankModel(model, fanOut, exposureLinked, runCount) {
|
|
171
|
+
let score = fanOut * 10;
|
|
172
|
+
if (exposureLinked)
|
|
173
|
+
score += 50;
|
|
174
|
+
score += Math.min(runCount, 20) * 2;
|
|
175
|
+
// Marts/answer-surface models surface first within their domain (nudge only).
|
|
176
|
+
if (model.folder === 'marts' || /^(fct_|dim_|mart_|rpt_)/i.test(model.name))
|
|
177
|
+
score += 15;
|
|
178
|
+
return { fanOut, exposureLinked, runCount, score };
|
|
179
|
+
}
|
|
180
|
+
/** Map our inference into a BlockRecord for the Certifier (status always draft). */
|
|
181
|
+
function toBlockRecord(slug, domain, owner, model, inference, gitPath) {
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const sourceSystems = [model.schema, model.database].filter((v) => Boolean(v));
|
|
184
|
+
return {
|
|
185
|
+
id: slug,
|
|
186
|
+
name: slug,
|
|
187
|
+
domain,
|
|
188
|
+
type: 'custom',
|
|
189
|
+
version: '0.1.0',
|
|
190
|
+
status: 'draft',
|
|
191
|
+
gitRepo: '',
|
|
192
|
+
gitPath,
|
|
193
|
+
gitCommitSha: '',
|
|
194
|
+
description: model.description?.trim() || `Draft governance block proposed from dbt model ${model.name}.`,
|
|
195
|
+
owner,
|
|
196
|
+
tags: inference.tags,
|
|
197
|
+
dependencies: model.dependsOn,
|
|
198
|
+
usedInCount: 0,
|
|
199
|
+
createdAt: now,
|
|
200
|
+
updatedAt: now,
|
|
201
|
+
llmContext: inference.llmContext,
|
|
202
|
+
examples: inference.examples.length > 0 ? inference.examples : undefined,
|
|
203
|
+
invariants: inference.invariants,
|
|
204
|
+
pattern: inference.pattern,
|
|
205
|
+
grain: inference.grain,
|
|
206
|
+
entities: inference.entities.length > 0 ? inference.entities : undefined,
|
|
207
|
+
declaredOutputs: inference.declaredOutputs.length > 0 ? inference.declaredOutputs : undefined,
|
|
208
|
+
sourceSystems: sourceSystems.length > 0 ? sourceSystems : undefined,
|
|
209
|
+
// Two tests proposed below match the invariants we emit.
|
|
210
|
+
testAssertions: inference.invariants.map((inv) => `assert ${inv}`),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Detect blocks that already exist for a model so re-runs never duplicate or
|
|
215
|
+
* overwrite. We check both draft-queue locations and the canonical domain
|
|
216
|
+
* folder for a `<slug>.dql` file.
|
|
217
|
+
*/
|
|
218
|
+
function deriveExistingSlugs(projectRoot, slugs) {
|
|
219
|
+
const existing = new Set();
|
|
220
|
+
for (const slug of slugs) {
|
|
221
|
+
const candidates = [
|
|
222
|
+
join(projectRoot, 'blocks', '_drafts', `${slug}.dql`),
|
|
223
|
+
join(projectRoot, 'blocks', `${slug}.dql`),
|
|
224
|
+
];
|
|
225
|
+
// Domain-first locations are checked lazily below; we scan the common ones.
|
|
226
|
+
if (candidates.some((p) => existsSync(p)))
|
|
227
|
+
existing.add(slug);
|
|
228
|
+
}
|
|
229
|
+
return existing;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* PASS 1 (cheap, ALL models, O(N), NO Certifier, NO column inference): load
|
|
233
|
+
* artifacts, build the fan-out / exposure / semantic signal maps, then classify
|
|
234
|
+
* + assign domain + score every model. Deterministic and reproducible.
|
|
235
|
+
*/
|
|
236
|
+
function scanModels(dbtManifestPath, config) {
|
|
237
|
+
const artifacts = loadDbtArtifacts(dbtManifestPath);
|
|
238
|
+
// Downstream fan-out: count edges pointing *into* each model.
|
|
239
|
+
const fanOut = new Map();
|
|
240
|
+
for (const model of artifacts.models) {
|
|
241
|
+
for (const dep of model.dependsOn) {
|
|
242
|
+
fanOut.set(dep, (fanOut.get(dep) ?? 0) + 1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Exposure linkage.
|
|
246
|
+
const exposureLinked = new Set();
|
|
247
|
+
for (const exposure of artifacts.exposures) {
|
|
248
|
+
for (const dep of exposure.dependsOn)
|
|
249
|
+
exposureLinked.add(dep);
|
|
250
|
+
}
|
|
251
|
+
// Models bound to a semantic metric → business + eligible for metric_wrapper.
|
|
252
|
+
const metricModels = new Set();
|
|
253
|
+
for (const metric of artifacts.semanticMetrics) {
|
|
254
|
+
if (metric.model)
|
|
255
|
+
metricModels.add(metric.model.toLowerCase());
|
|
256
|
+
}
|
|
257
|
+
const classifierCtx = { exposureLinked, metricModels, config };
|
|
258
|
+
let businessModels = 0;
|
|
259
|
+
let plumbingExcluded = 0;
|
|
260
|
+
let nicheExcluded = 0;
|
|
261
|
+
const scored = [];
|
|
262
|
+
for (const model of artifacts.models) {
|
|
263
|
+
const { classification, domain, owner, evidence } = classifyModel(model, classifierCtx);
|
|
264
|
+
if (classification === 'business')
|
|
265
|
+
businessModels++;
|
|
266
|
+
else if (classification === 'plumbing')
|
|
267
|
+
plumbingExcluded++;
|
|
268
|
+
else
|
|
269
|
+
nicheExcluded++;
|
|
270
|
+
const ranking = rankModel(model, fanOut.get(model.uniqueId) ?? 0, exposureLinked.has(model.uniqueId), artifacts.runCounts.get(model.uniqueId) ?? 0);
|
|
271
|
+
scored.push({
|
|
272
|
+
model,
|
|
273
|
+
slug: blockSlug(model.name),
|
|
274
|
+
domain,
|
|
275
|
+
owner,
|
|
276
|
+
classification,
|
|
277
|
+
evidence: enrichEvidence(model, ranking, evidence),
|
|
278
|
+
ranking,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
artifacts,
|
|
283
|
+
config,
|
|
284
|
+
metricModels,
|
|
285
|
+
scored,
|
|
286
|
+
totals: {
|
|
287
|
+
modelsScanned: artifacts.models.length,
|
|
288
|
+
businessModels,
|
|
289
|
+
plumbingExcluded,
|
|
290
|
+
nicheExcluded,
|
|
291
|
+
metricsFound: artifacts.semanticMetrics.length,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** Add demand signals to the evidence chain for the plan view. */
|
|
296
|
+
function enrichEvidence(model, ranking, evidence) {
|
|
297
|
+
const out = [...evidence];
|
|
298
|
+
if (ranking.fanOut > 0)
|
|
299
|
+
out.push(`feeds ${ranking.fanOut} downstream model${ranking.fanOut === 1 ? '' : 's'}`);
|
|
300
|
+
if (ranking.runCount > 0)
|
|
301
|
+
out.push(`${ranking.runCount} recorded run${ranking.runCount === 1 ? '' : 's'}`);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Select the bounded seed deterministically: business-classified, score ≥
|
|
306
|
+
* minScore, then the top `maxPerDomain` per domain. `niche`/`plumbing` are never
|
|
307
|
+
* selected. When `onlySlugs` is provided, restrict to that approved scope (still
|
|
308
|
+
* business-only — plumbing is never generated even if explicitly requested).
|
|
309
|
+
* Returns selected candidates in stable rank order (score desc, fan-out desc,
|
|
310
|
+
* slug asc) across all domains.
|
|
311
|
+
*/
|
|
312
|
+
function selectBounded(scan, onlySlugs) {
|
|
313
|
+
const approved = onlySlugs ? new Set(onlySlugs) : undefined;
|
|
314
|
+
const eligible = scan.scored.filter((c) => c.classification === 'business' &&
|
|
315
|
+
c.ranking.score >= scan.config.minScore &&
|
|
316
|
+
(!approved || approved.has(c.slug)));
|
|
317
|
+
// Deterministic order within each domain bucket.
|
|
318
|
+
const ordered = [...eligible].sort(compareCandidates);
|
|
319
|
+
const perDomain = new Map();
|
|
320
|
+
const selected = [];
|
|
321
|
+
for (const candidate of ordered) {
|
|
322
|
+
const count = perDomain.get(candidate.domain) ?? 0;
|
|
323
|
+
if (count >= scan.config.maxPerDomain)
|
|
324
|
+
continue;
|
|
325
|
+
perDomain.set(candidate.domain, count + 1);
|
|
326
|
+
selected.push(candidate);
|
|
327
|
+
}
|
|
328
|
+
return selected;
|
|
329
|
+
}
|
|
330
|
+
function compareCandidates(a, b) {
|
|
331
|
+
if (b.ranking.score !== a.ranking.score)
|
|
332
|
+
return b.ranking.score - a.ranking.score;
|
|
333
|
+
if (b.ranking.fanOut !== a.ranking.fanOut)
|
|
334
|
+
return b.ranking.fanOut - a.ranking.fanOut;
|
|
335
|
+
return a.slug.localeCompare(b.slug);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Run the proposal engine end to end: load artifacts, classify (cheap pass),
|
|
339
|
+
* select a bounded business-only seed, then — for the selection ONLY — infer
|
|
340
|
+
* grain/pattern/outputs/invariants and run the Certifier, write drafts, and
|
|
341
|
+
* store verdicts. Returns a deterministic, ranked summary.
|
|
342
|
+
*
|
|
343
|
+
* Plumbing/niche models are EXCLUDED from generation (never written), and the
|
|
344
|
+
* expensive Pass-2 work (inference + Certifier) runs only on the bounded
|
|
345
|
+
* selection — at 10k models this is hundreds of inferences, not thousands.
|
|
346
|
+
*/
|
|
347
|
+
export function propose(options) {
|
|
348
|
+
const { projectRoot, dbtManifestPath } = options;
|
|
349
|
+
const owner = options.owner ?? '';
|
|
350
|
+
const config = resolveProposeConfig(options.config);
|
|
351
|
+
// ── Pass 1: cheap classify + score over ALL models ───────────────────────
|
|
352
|
+
const scan = scanModels(dbtManifestPath, config);
|
|
353
|
+
// Bounded, business-only selection (honors onlySlugs / approved scope).
|
|
354
|
+
const selection = selectBounded(scan, options.onlySlugs);
|
|
355
|
+
// ── Pass 2: expensive inference + Certifier on the SELECTION only ─────────
|
|
356
|
+
const certifier = new Certifier();
|
|
357
|
+
const proposals = [];
|
|
358
|
+
for (const candidate of selection) {
|
|
359
|
+
const { model, slug, domain, owner: metaOwner, classification, evidence } = candidate;
|
|
360
|
+
const inference = buildInference(model, scan.artifacts, scan.metricModels.has(model.name.toLowerCase()));
|
|
361
|
+
// Build the would-be draft path (matches the draft-writer's resolution).
|
|
362
|
+
const draftRelPath = resolveDraftRelPath(projectRoot, domain, slug);
|
|
363
|
+
const record = toBlockRecord(slug, domain, owner || metaOwner || '', model, inference, draftRelPath);
|
|
364
|
+
const verdict = certifier.evaluate(record);
|
|
365
|
+
proposals.push({
|
|
366
|
+
model: model.name,
|
|
367
|
+
slug,
|
|
368
|
+
domain,
|
|
369
|
+
classification,
|
|
370
|
+
evidence,
|
|
371
|
+
owner: metaOwner,
|
|
372
|
+
inference,
|
|
373
|
+
ranking: candidate.ranking,
|
|
374
|
+
certification: {
|
|
375
|
+
certified: false,
|
|
376
|
+
errors: verdict.errors,
|
|
377
|
+
warnings: verdict.warnings,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// Rank: score desc, then fan-out desc, then name asc for determinism.
|
|
382
|
+
proposals.sort((a, b) => {
|
|
383
|
+
if (b.ranking.score !== a.ranking.score)
|
|
384
|
+
return b.ranking.score - a.ranking.score;
|
|
385
|
+
if (b.ranking.fanOut !== a.ranking.fanOut)
|
|
386
|
+
return b.ranking.fanOut - a.ranking.fanOut;
|
|
387
|
+
return a.slug.localeCompare(b.slug);
|
|
388
|
+
});
|
|
389
|
+
const allSlugs = proposals.map((p) => p.slug);
|
|
390
|
+
const existingSlugs = options.existingBlockSlugs ?? deriveExistingSlugs(projectRoot, allSlugs);
|
|
391
|
+
let draftsWritten = 0;
|
|
392
|
+
let draftsSkipped = 0;
|
|
393
|
+
const limit = options.limit ?? Number.POSITIVE_INFINITY;
|
|
394
|
+
for (const proposal of proposals) {
|
|
395
|
+
if (existingSlugs.has(proposal.slug)) {
|
|
396
|
+
proposal.skipped = 'A block for this model already exists; not overwriting.';
|
|
397
|
+
draftsSkipped++;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (draftsWritten >= limit) {
|
|
401
|
+
proposal.skipped = 'Above --limit; re-run to draft lower-ranked models.';
|
|
402
|
+
draftsSkipped++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (options.dryRun) {
|
|
406
|
+
draftsWritten++; // counted as "would write" for the summary
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const model = scan.artifacts.models.find((m) => m.name === proposal.model);
|
|
410
|
+
// Optional AI enrichment overrides only human-facing content; structure
|
|
411
|
+
// (sql/grain/outputs/invariants) stays deterministic. Falls back to dbt content.
|
|
412
|
+
const enriched = options.enrichedBySlug?.get(proposal.slug);
|
|
413
|
+
const enrichedExamples = enriched?.examples?.length
|
|
414
|
+
? enriched.examples.map((question) => ({ question }))
|
|
415
|
+
: undefined;
|
|
416
|
+
// Metric-backed model → emit ONE metric-bound (semantic) block per governed
|
|
417
|
+
// metric it backs, so proposed blocks actually reference the semantic layer
|
|
418
|
+
// (type=semantic + metric=<name> + a pre-compiled, runnable query). Falls back
|
|
419
|
+
// to the custom block below when a model backs no usable measures.
|
|
420
|
+
const metricBlocks = proposal.inference.pattern === 'metric_wrapper'
|
|
421
|
+
? buildMetricWrapperBlocks(model, scan.artifacts)
|
|
422
|
+
: [];
|
|
423
|
+
if (metricBlocks.length > 0) {
|
|
424
|
+
let firstPath;
|
|
425
|
+
let wroteAny = false;
|
|
426
|
+
for (const mb of metricBlocks) {
|
|
427
|
+
const slug = blockSlug(mb.metricName);
|
|
428
|
+
if (existingSlugs.has(slug)) {
|
|
429
|
+
draftsSkipped++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const metricRecord = {
|
|
433
|
+
slug,
|
|
434
|
+
domain: proposal.domain,
|
|
435
|
+
owner: owner || proposal.owner || '',
|
|
436
|
+
description: mb.description?.trim() || `Governed "${mb.metricName}" metric from dbt model ${proposal.model}.`,
|
|
437
|
+
sql: mb.sql,
|
|
438
|
+
blockType: 'semantic',
|
|
439
|
+
metricRef: mb.metricName,
|
|
440
|
+
dimensions: mb.dimensions,
|
|
441
|
+
pattern: 'metric_wrapper',
|
|
442
|
+
grain: mb.dimensions.length > 0 ? mb.dimensions.join(' + ') : proposal.inference.grain,
|
|
443
|
+
entities: proposal.inference.entities,
|
|
444
|
+
declaredOutputs: [...mb.dimensions, mb.alias],
|
|
445
|
+
// App-ready: the metric's dimensions become dashboard filters.
|
|
446
|
+
...deriveBlockFilters(mb.dimensions),
|
|
447
|
+
llmContext: `Wraps the governed semantic metric "${mb.metricName}". Use this for "${mb.metricName.replace(/_/g, ' ')}" questions.`,
|
|
448
|
+
invariants: [],
|
|
449
|
+
examples: [{ question: `What is ${mb.metricName.replace(/_/g, ' ')}?` }],
|
|
450
|
+
tags: ['proposed', 'from-dbt', 'metric'],
|
|
451
|
+
reviewCadence: 'quarterly',
|
|
452
|
+
sourceModel: proposal.model,
|
|
453
|
+
sourceSystems: [model.schema, model.database].filter((v) => Boolean(v)),
|
|
454
|
+
certification: { certified: false, errors: [], warnings: [] },
|
|
455
|
+
};
|
|
456
|
+
const written = upsertProposedDraft(projectRoot, metricRecord);
|
|
457
|
+
existingSlugs.add(slug);
|
|
458
|
+
if (written.created) {
|
|
459
|
+
draftsWritten++;
|
|
460
|
+
wroteAny = true;
|
|
461
|
+
firstPath ??= written.path;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
proposal.path = firstPath;
|
|
465
|
+
if (!wroteAny) {
|
|
466
|
+
proposal.skipped = 'Metric blocks for this model already exist; not overwriting.';
|
|
467
|
+
draftsSkipped++;
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const draftRecord = {
|
|
472
|
+
slug: proposal.slug,
|
|
473
|
+
domain: proposal.domain,
|
|
474
|
+
owner: owner || proposal.owner || '',
|
|
475
|
+
// A human-authored dbt description is authoritative; AI only fills the gap
|
|
476
|
+
// when dbt has none. (llmContext/examples are templated deterministically, so
|
|
477
|
+
// AI enrichment improves on them when available.)
|
|
478
|
+
description: model.description?.trim() ||
|
|
479
|
+
enriched?.description?.trim() ||
|
|
480
|
+
`Draft governance block proposed from dbt model ${proposal.model}.`,
|
|
481
|
+
sql: buildBusinessQuery(model, proposal.inference, scan.artifacts),
|
|
482
|
+
pattern: proposal.inference.pattern,
|
|
483
|
+
grain: proposal.inference.grain,
|
|
484
|
+
entities: proposal.inference.entities,
|
|
485
|
+
declaredOutputs: proposal.inference.declaredOutputs,
|
|
486
|
+
// App-ready: a flat/entity block can be filtered by any of its output columns.
|
|
487
|
+
...deriveBlockFilters(proposal.inference.declaredOutputs),
|
|
488
|
+
llmContext: enriched?.llmContext ?? proposal.inference.llmContext,
|
|
489
|
+
invariants: proposal.inference.invariants,
|
|
490
|
+
examples: enrichedExamples ?? proposal.inference.examples,
|
|
491
|
+
tags: proposal.inference.tags,
|
|
492
|
+
reviewCadence: 'quarterly',
|
|
493
|
+
sourceModel: proposal.model,
|
|
494
|
+
sourceSystems: [model.schema, model.database].filter((v) => Boolean(v)),
|
|
495
|
+
certification: proposal.certification,
|
|
496
|
+
};
|
|
497
|
+
const written = upsertProposedDraft(projectRoot, draftRecord);
|
|
498
|
+
proposal.path = written.path;
|
|
499
|
+
if (written.created)
|
|
500
|
+
draftsWritten++;
|
|
501
|
+
else {
|
|
502
|
+
proposal.skipped = 'A block for this model already exists; not overwriting.';
|
|
503
|
+
draftsSkipped++;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
projectName: scan.artifacts.projectName,
|
|
508
|
+
modelsScanned: scan.totals.modelsScanned,
|
|
509
|
+
businessModels: scan.totals.businessModels,
|
|
510
|
+
plumbingExcluded: scan.totals.plumbingExcluded,
|
|
511
|
+
metricsFound: scan.totals.metricsFound,
|
|
512
|
+
proposalsRanked: proposals.length,
|
|
513
|
+
draftsWritten,
|
|
514
|
+
draftsSkipped,
|
|
515
|
+
config,
|
|
516
|
+
proposals,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Build a deterministic PLAN of what `propose` WOULD generate — and writes
|
|
521
|
+
* NOTHING. Reuses the cheap Pass-1 scan + bounded selection so the plan is an
|
|
522
|
+
* exact preview of the generated scope. Grain/pattern hints are cheap and
|
|
523
|
+
* optional (folder/column heuristics, no Certifier). Same input → same plan.
|
|
524
|
+
*/
|
|
525
|
+
export function proposePlan(projectRoot, dbtManifestPath, options = {}) {
|
|
526
|
+
void projectRoot; // reserved for future per-project overrides; plan writes nothing.
|
|
527
|
+
const config = resolveProposeConfig(options.config);
|
|
528
|
+
const scan = scanModels(dbtManifestPath, config);
|
|
529
|
+
const selection = selectBounded(scan);
|
|
530
|
+
// Group selection into domains (stable order: by domain name asc).
|
|
531
|
+
const byDomain = new Map();
|
|
532
|
+
for (const candidate of selection) {
|
|
533
|
+
const list = byDomain.get(candidate.domain) ?? [];
|
|
534
|
+
list.push(candidate);
|
|
535
|
+
byDomain.set(candidate.domain, list);
|
|
536
|
+
}
|
|
537
|
+
const domains = [...byDomain.entries()]
|
|
538
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
539
|
+
.map(([name, candidates]) => {
|
|
540
|
+
const ordered = [...candidates].sort(compareCandidates);
|
|
541
|
+
const owner = ordered.find((c) => c.owner)?.owner;
|
|
542
|
+
return {
|
|
543
|
+
name,
|
|
544
|
+
owner,
|
|
545
|
+
modelCount: ordered.length,
|
|
546
|
+
candidates: ordered.map((c) => toPlanCandidate(c, scan, options.skipHints)),
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
return {
|
|
550
|
+
totals: {
|
|
551
|
+
modelsScanned: scan.totals.modelsScanned,
|
|
552
|
+
businessModels: scan.totals.businessModels,
|
|
553
|
+
plumbingExcluded: scan.totals.plumbingExcluded,
|
|
554
|
+
metricsFound: scan.totals.metricsFound,
|
|
555
|
+
},
|
|
556
|
+
willGenerate: selection.length,
|
|
557
|
+
// What we scanned but won't seed: everything that isn't in the bounded
|
|
558
|
+
// selection (plumbing + niche + business beyond the per-domain cap).
|
|
559
|
+
willSkip: scan.totals.modelsScanned - selection.length,
|
|
560
|
+
domains,
|
|
561
|
+
config,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** Cheap plan candidate. Grain/pattern are optional, derived without Certifier. */
|
|
565
|
+
function toPlanCandidate(candidate, scan, skipHints) {
|
|
566
|
+
const base = {
|
|
567
|
+
model: candidate.model.name,
|
|
568
|
+
slug: candidate.slug,
|
|
569
|
+
score: candidate.ranking.score,
|
|
570
|
+
classification: candidate.classification,
|
|
571
|
+
owner: candidate.owner,
|
|
572
|
+
evidence: candidate.evidence,
|
|
573
|
+
};
|
|
574
|
+
if (skipHints)
|
|
575
|
+
return base;
|
|
576
|
+
// Cheap hints: grain from columns + pattern from semantic/folder heuristics.
|
|
577
|
+
const columns = effectiveColumns(candidate.model, scan.artifacts);
|
|
578
|
+
const grain = inferGrain(candidate.model, columns);
|
|
579
|
+
const hasMetric = scan.metricModels.has(candidate.model.name.toLowerCase());
|
|
580
|
+
const pattern = inferPattern(candidate.model, columns, grain, hasMetric);
|
|
581
|
+
return { ...base, grain, pattern };
|
|
582
|
+
}
|
|
583
|
+
/** Mirror of the draft-writer's path resolution (for the certifier gitPath). */
|
|
584
|
+
function resolveDraftRelPath(projectRoot, domain, slug) {
|
|
585
|
+
if (domain && existsSync(join(projectRoot, 'domains', domain))) {
|
|
586
|
+
return `domains/${domain}/blocks/_drafts/${slug}.dql`;
|
|
587
|
+
}
|
|
588
|
+
return `blocks/_drafts/${slug}.dql`;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Build the FILLED preview for a single candidate slug: real aggregation/
|
|
592
|
+
* projection SQL (via `buildBusinessQuery`, NOT select-*), declared outputs,
|
|
593
|
+
* description, llmContext, examples, and the Certifier verdict
|
|
594
|
+
* ({ blocking, warnings, ready }). Writes NOTHING.
|
|
595
|
+
*
|
|
596
|
+
* This is the lazy/expensive path: `proposePlan()` stays cheap for all
|
|
597
|
+
* candidates; this runs inference + SQL generation + the Certifier for ONE slug
|
|
598
|
+
* only. Returns `undefined` when the slug is not part of the bounded,
|
|
599
|
+
* business-only selection (so the caller can 404).
|
|
600
|
+
*/
|
|
601
|
+
export function buildProposePreview(projectRoot, dbtManifestPath, slug, options = {}) {
|
|
602
|
+
const config = resolveProposeConfig(options.config);
|
|
603
|
+
const scan = scanModels(dbtManifestPath, config);
|
|
604
|
+
const selection = selectBounded(scan);
|
|
605
|
+
const candidate = selection.find((c) => c.slug === slug);
|
|
606
|
+
if (!candidate)
|
|
607
|
+
return undefined;
|
|
608
|
+
const { model, domain, owner: metaOwner, classification, evidence, ranking } = candidate;
|
|
609
|
+
const hasMetric = scan.metricModels.has(model.name.toLowerCase());
|
|
610
|
+
const inference = buildInference(model, scan.artifacts, hasMetric);
|
|
611
|
+
// Real SQL (aggregation for metric-backed models, narrowed projection otherwise).
|
|
612
|
+
const sqlPreview = buildBusinessQuery(model, inference, scan.artifacts);
|
|
613
|
+
// Certifier verdict for the would-be draft (owner stamped so "Missing owner"
|
|
614
|
+
// is not a phantom strike when an owner is resolvable).
|
|
615
|
+
const draftRelPath = resolveDraftRelPath(projectRoot, domain, slug);
|
|
616
|
+
const owner = options.owner || metaOwner || '';
|
|
617
|
+
const record = toBlockRecord(slug, domain, owner, model, inference, draftRelPath);
|
|
618
|
+
const verdict = new Certifier().evaluate(record);
|
|
619
|
+
// Content: AI enrichment overrides best-effort; else deterministic dbt-derived.
|
|
620
|
+
const description = model.description?.trim() ||
|
|
621
|
+
options.enriched?.description?.trim() ||
|
|
622
|
+
`Draft governance block proposed from dbt model ${model.name}.`;
|
|
623
|
+
const llmContext = options.enriched?.llmContext ?? inference.llmContext;
|
|
624
|
+
const examples = options.enriched?.examples?.length
|
|
625
|
+
? options.enriched.examples
|
|
626
|
+
: inference.examples.map((ex) => ex.question);
|
|
627
|
+
return {
|
|
628
|
+
model: model.name,
|
|
629
|
+
slug,
|
|
630
|
+
score: ranking.score,
|
|
631
|
+
classification,
|
|
632
|
+
owner: metaOwner,
|
|
633
|
+
evidence,
|
|
634
|
+
grain: inference.grain,
|
|
635
|
+
pattern: inference.pattern,
|
|
636
|
+
sqlPreview,
|
|
637
|
+
description,
|
|
638
|
+
llmContext,
|
|
639
|
+
examples,
|
|
640
|
+
outputs: inference.declaredOutputs,
|
|
641
|
+
certifierVerdict: {
|
|
642
|
+
blocking: verdict.errors.map((e) => e.message),
|
|
643
|
+
warnings: verdict.warnings.map((w) => w.message),
|
|
644
|
+
ready: verdict.errors.length === 0,
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
// ---- small word helpers (intentionally minimal — no NLP dependency) ----
|
|
649
|
+
function stripModelPrefix(name) {
|
|
650
|
+
return name.replace(/^(stg_|int_|fct_|fact_|f_|dim_|d_|agg_|mart_|rpt_)/i, '');
|
|
651
|
+
}
|
|
652
|
+
function singularize(word) {
|
|
653
|
+
if (/ies$/i.test(word))
|
|
654
|
+
return word.replace(/ies$/i, 'y');
|
|
655
|
+
if (/ses$/i.test(word))
|
|
656
|
+
return word.replace(/es$/i, '');
|
|
657
|
+
if (/s$/i.test(word) && !/ss$/i.test(word))
|
|
658
|
+
return word.replace(/s$/i, '');
|
|
659
|
+
return word;
|
|
660
|
+
}
|
|
661
|
+
function pluralize(word) {
|
|
662
|
+
if (/[^aeiou]y$/i.test(word))
|
|
663
|
+
return word.replace(/y$/i, 'ies');
|
|
664
|
+
if (/(s|x|z|ch|sh)$/i.test(word))
|
|
665
|
+
return `${word}es`;
|
|
666
|
+
return `${word}s`;
|
|
667
|
+
}
|
|
668
|
+
//# sourceMappingURL=propose.js.map
|