183 lines
6.4 KiB
JavaScript
183 lines
6.4 KiB
JavaScript
// enrich.js -- PURE merge/enrichment for the v2 gate payloads (WS2).
|
|
//
|
|
// Catalog lookups are INJECTED as `getColumns` params (for testability): a function
|
|
// (tableName) => { description, columns: [{ name, description, ... }] } | null.
|
|
// A catalog miss (null) yields empty descriptions, never an error (the hard-fail
|
|
// stays only in reviewer_schema_linking).
|
|
//
|
|
// No dependency on tht-gate.js: importable and testable in isolation.
|
|
|
|
"use strict";
|
|
|
|
// Memoize a raw catalog lookup so repeated table hits do a single I/O call. The
|
|
// gate wraps its `tht schema columns <t> --json` (try/catch -> null on miss) and
|
|
// passes it here; enrich itself stays pure.
|
|
function memoizeGetColumns(rawGetColumns) {
|
|
const cache = new Map();
|
|
return function getColumns(tableName) {
|
|
if (cache.has(tableName)) return cache.get(tableName);
|
|
const v = rawGetColumns(tableName);
|
|
cache.set(tableName, v);
|
|
return v;
|
|
};
|
|
}
|
|
|
|
function columnDescription(cat, colName) {
|
|
if (!cat || !Array.isArray(cat.columns)) return "";
|
|
const col = cat.columns.find((c) => c.name === colName);
|
|
return (col && col.description) || "";
|
|
}
|
|
|
|
// Contract A. Fills index (1-based), tables[].description and filters[].description
|
|
// via getColumns. For filters, `column: "tab.col"` resolves to the column's catalog
|
|
// description. Returns a NEW object; model-owned fields pass through unchanged.
|
|
function enrichCtePlanV2(data, getColumns) {
|
|
const ctes = (data.ctes || []).map((cte, i) => {
|
|
const out = { ...cte, index: i + 1 };
|
|
if (Array.isArray(cte.tables)) {
|
|
out.tables = cte.tables.map((t) => {
|
|
const cat = getColumns(t.name);
|
|
return { ...t, description: (cat && cat.description) || "" };
|
|
});
|
|
}
|
|
if (Array.isArray(cte.filters)) {
|
|
out.filters = cte.filters.map((f) => {
|
|
const out2 = { ...f };
|
|
if (typeof f.column === "string" && f.column.includes(".")) {
|
|
const idx = f.column.indexOf(".");
|
|
const tableName = f.column.slice(0, idx);
|
|
const colName = f.column.slice(idx + 1);
|
|
out2.description = columnDescription(getColumns(tableName), colName);
|
|
} else {
|
|
out2.description = "";
|
|
}
|
|
return out2;
|
|
});
|
|
}
|
|
return out;
|
|
});
|
|
return { ...data, ctes };
|
|
}
|
|
|
|
// Contract B. Fuses the model's thin data with `tht cte info` output.
|
|
// name/index/total/sql <- cteInfo
|
|
// purpose/rationale/depends_on <- cteInfo.doc (fallback: thinData)
|
|
// status/execution_ms/row_sample/warnings/sql_hash/preview <- cteInfo.last_test
|
|
// note <- thinData
|
|
// preview = { columns: last_test.columns, rows: last_test.preview_rows }.
|
|
function buildCteResultV2(thinData, cteInfo) {
|
|
const thin = thinData || {};
|
|
const doc = cteInfo.doc || {};
|
|
const lt = cteInfo.last_test || {};
|
|
const pick = (docVal, thinVal) => (docVal !== undefined ? docVal : thinVal);
|
|
return {
|
|
schema_version: 2,
|
|
name: cteInfo.name,
|
|
index: cteInfo.index,
|
|
total: cteInfo.total,
|
|
purpose: pick(doc.purpose, thin.purpose),
|
|
rationale: pick(doc.rationale, thin.rationale),
|
|
depends_on: pick(doc.depends_on, thin.depends_on),
|
|
sql: cteInfo.sql,
|
|
status: lt.status,
|
|
execution_ms: lt.execution_ms,
|
|
row_sample: lt.row_sample,
|
|
warnings: lt.warnings || [],
|
|
sql_hash: lt.sql_hash,
|
|
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
|
|
preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
|
|
note: thin.note,
|
|
};
|
|
}
|
|
|
|
// Contract C. Fills `phase` from phaseMeta and the `description` fields in
|
|
// tables[]/tables[].columns[]/sections[].items[] via getColumns. phaseMeta is
|
|
// { id, num, name }. Returns a NEW object.
|
|
function enrichPhaseSummaryV2(data, phaseMeta, getColumns) {
|
|
const out = { ...data, phase: phaseMeta };
|
|
if (Array.isArray(data.tables)) {
|
|
out.tables = data.tables.map((t) => {
|
|
const cat = getColumns(t.name);
|
|
const enriched = { ...t, description: (cat && cat.description) || "" };
|
|
if (Array.isArray(t.columns)) {
|
|
enriched.columns = t.columns.map((c) => ({
|
|
...c,
|
|
description: columnDescription(cat, c.name),
|
|
}));
|
|
}
|
|
return enriched;
|
|
});
|
|
}
|
|
if (Array.isArray(data.sections)) {
|
|
out.sections = data.sections.map((s) => {
|
|
if (!Array.isArray(s.items)) return { ...s };
|
|
return {
|
|
...s,
|
|
items: s.items.map((it) => {
|
|
// An item's description resolves against its declared table+column.
|
|
let desc = "";
|
|
if (typeof it.table === "string" && typeof it.column === "string") {
|
|
desc = columnDescription(getColumns(it.table), it.column);
|
|
} else if (typeof it.table === "string") {
|
|
const cat = getColumns(it.table);
|
|
desc = (cat && cat.description) || "";
|
|
}
|
|
return { ...it, description: desc };
|
|
}),
|
|
};
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Fills columns[].description of a built cte_result payload by matching each column
|
|
// name against the catalog of the plan's tables (`planTables`: string[]). First
|
|
// table whose catalog carries the column wins; unresolved -> empty description.
|
|
// Mutates a copy of payload.columns; returns the payload.
|
|
function enrichCteResultColumns(payload, planTables, getColumns) {
|
|
if (!Array.isArray(payload.columns)) return payload;
|
|
const tables = Array.isArray(planTables) ? planTables : [];
|
|
const columns = payload.columns.map((col) => {
|
|
let desc = "";
|
|
for (const t of tables) {
|
|
const d = columnDescription(getColumns(t), col.name);
|
|
if (d) {
|
|
desc = d;
|
|
break;
|
|
}
|
|
}
|
|
return { ...col, description: desc };
|
|
});
|
|
return { ...payload, columns };
|
|
}
|
|
|
|
// Contract C bis. Appends a deterministic "decisions of this phase" section built
|
|
// from the session ledger, so the model's recap can stay thin (Discipline 6 exactness
|
|
// comes from persisted state, not model prose). `decisions` is session-show's ledger
|
|
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
|
|
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
|
|
function appendLedgerSection(data, decisions, emits) {
|
|
const emitSet = new Set(emits || []);
|
|
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
|
|
if (rows.length === 0) return data;
|
|
const section = {
|
|
title: "Decisioni registrate in questa fase (dal ledger)",
|
|
items: rows.map((d) => ({
|
|
label: `${d.type}: ${d.subject}`,
|
|
value: d.detail || "",
|
|
kind: "decision",
|
|
rationale: d.rationale || "",
|
|
})),
|
|
};
|
|
return { ...data, sections: [...(data.sections || []), section] };
|
|
}
|
|
|
|
module.exports = {
|
|
memoizeGetColumns,
|
|
enrichCtePlanV2,
|
|
buildCteResultV2,
|
|
enrichCteResultColumns,
|
|
enrichPhaseSummaryV2,
|
|
appendLedgerSection,
|
|
};
|