feat(gate): deterministic v2 review-gate payloads (cte_plan/cte_result/phase)
The tht-gate.js reviewer_confirm now builds structured v2 artifacts before the
widget so the reviewer approves gate-derived data, not raw model text:
- new pure modules gate/artifact-contracts.js (soft validators, {ok,errors},
legacy-passthrough) and gate/enrich.js (index/description enrichment,
buildCteResultV2 fusing thin model data with `tht cte info`, phase enrichment)
- cte_plan v2: validate + enrich + persist via `tht cte plan --name … --doc -`
(names derived from data.ctes[]); legacy `names` param kept as fallback
- cte_result v2: rebuild from `tht cte next`/`tht cte info` (sql + preview from
the persisted test record); null/error last_test -> actionable textResult
- phase v2: soft-validate + fill phase from meta + catalog descriptions
- prepareReviewerArguments coerces artifact.data too (GLM double-stringify);
legacy markdown strings pass through unchanged
- SKILL.md: Phase 6 cte_plan payload A + thin cte_result guidance; Discipline 6
payload C example; Discipline 7 reworded for gate-rebuilt cte_result
Legacy (non-v2) paths unchanged. TypeBox stays Type.Any() for artifact.data;
validation is soft (textResult) so models self-correct instead of looping.
All 102 gate JS tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Pure merge/enrichment functions (contracts.md A/B/C). Catalog lookups are injected
|
||||
// as a `getColumns` function parameter so these are testable with no `tht` shell-out.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const {
|
||||
enrichCtePlanV2,
|
||||
buildCteResultV2,
|
||||
enrichPhaseSummaryV2,
|
||||
} = require("../enrich.js");
|
||||
|
||||
// --- enrichCtePlanV2 --------------------------------------------------------------
|
||||
|
||||
test("enrichCtePlanV2 assigns 1-based index in ctes[] order", () => {
|
||||
const data = {
|
||||
schema_version: 2, question: "q", strategy: "s",
|
||||
ctes: [
|
||||
{ name: "a", tables: [], filters: [] },
|
||||
{ name: "b", tables: [], filters: [] },
|
||||
],
|
||||
};
|
||||
const out = enrichCtePlanV2(data, () => null);
|
||||
assert.equal(out.ctes[0].index, 1);
|
||||
assert.equal(out.ctes[1].index, 2);
|
||||
});
|
||||
|
||||
test("enrichCtePlanV2 fills tables[].description from the catalog", () => {
|
||||
const getColumns = (table) =>
|
||||
table === "dim_patient" ? { description: "Anagrafica pazienti", columns: [{ name: "cod_paz", description: "Codice paziente" }] } : null;
|
||||
const data = {
|
||||
schema_version: 2, question: "q", strategy: "s",
|
||||
ctes: [{ name: "a", tables: [{ name: "dim_patient" }], filters: [] }],
|
||||
};
|
||||
const out = enrichCtePlanV2(data, getColumns);
|
||||
assert.equal(out.ctes[0].tables[0].description, "Anagrafica pazienti");
|
||||
});
|
||||
|
||||
test("enrichCtePlanV2 fills filters[].description by resolving table.column", () => {
|
||||
const getColumns = (table) =>
|
||||
table === "dim_patient"
|
||||
? { description: "Anagrafica", columns: [{ name: "flag_attivo", description: "Flag attivo" }] }
|
||||
: null;
|
||||
const data = {
|
||||
schema_version: 2, question: "q", strategy: "s",
|
||||
ctes: [{ name: "a", tables: [], filters: [{ column: "dim_patient.flag_attivo", op: "IS", value: "TRUE" }] }],
|
||||
};
|
||||
const out = enrichCtePlanV2(data, getColumns);
|
||||
assert.equal(out.ctes[0].filters[0].description, "Flag attivo");
|
||||
});
|
||||
|
||||
test("enrichCtePlanV2: catalog miss -> empty description string, never throws", () => {
|
||||
const out = enrichCtePlanV2(
|
||||
{
|
||||
schema_version: 2, question: "q", strategy: "s",
|
||||
ctes: [{ name: "a", tables: [{ name: "nonexistent_table" }], filters: [{ column: "nonexistent_table.x", op: "IS", value: "1" }] }],
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
assert.equal(out.ctes[0].tables[0].description, "");
|
||||
assert.equal(out.ctes[0].filters[0].description, "");
|
||||
});
|
||||
|
||||
test("enrichCtePlanV2: getColumns throwing is tolerated by the caller contract (function itself does not catch)", () => {
|
||||
// enrich.js documents that getColumns must itself be safe (try/catch -> null on miss);
|
||||
// enrichCtePlanV2 does not need its own try/catch around calls, since getColumns never throws.
|
||||
const getColumns = () => null; // simulates the safe wrapper's miss behavior
|
||||
const out = enrichCtePlanV2(
|
||||
{ schema_version: 2, question: "q", strategy: "s", ctes: [{ name: "a", tables: [{ name: "t" }], filters: [] }] },
|
||||
getColumns,
|
||||
);
|
||||
assert.equal(out.ctes[0].tables[0].description, "");
|
||||
});
|
||||
|
||||
test("enrichCtePlanV2 preserves model-authored fields (purpose/rationale/depends_on/keys/output_columns)", () => {
|
||||
const data = {
|
||||
schema_version: 2, question: "q", strategy: "s",
|
||||
ctes: [{
|
||||
name: "a", purpose: "scopo", rationale: "motivo", depends_on: [],
|
||||
keys: ["cod_paz"], output_columns: ["cod_paz"], tables: [], filters: [],
|
||||
}],
|
||||
};
|
||||
const out = enrichCtePlanV2(data, () => null);
|
||||
assert.equal(out.ctes[0].purpose, "scopo");
|
||||
assert.equal(out.ctes[0].rationale, "motivo");
|
||||
assert.deepEqual(out.ctes[0].keys, ["cod_paz"]);
|
||||
assert.deepEqual(out.ctes[0].output_columns, ["cod_paz"]);
|
||||
});
|
||||
|
||||
// --- buildCteResultV2 --------------------------------------------------------------
|
||||
|
||||
test("buildCteResultV2 merges name/index/total/sql from cteInfo", () => {
|
||||
const thin = { schema_version: 2, note: "nota" };
|
||||
const cteInfo = {
|
||||
name: "base_pazienti", index: 1, total: 3, sql: "WITH base_pazienti AS (SELECT 1)",
|
||||
doc: null, last_test: { status: "ok", execution_ms: 42, row_sample: 5, warnings: [], sql_hash: "h1", columns: ["x"], preview_rows: [[1]] },
|
||||
};
|
||||
const out = buildCteResultV2(thin, cteInfo);
|
||||
assert.equal(out.name, "base_pazienti");
|
||||
assert.equal(out.index, 1);
|
||||
assert.equal(out.total, 3);
|
||||
assert.equal(out.sql, "WITH base_pazienti AS (SELECT 1)");
|
||||
assert.equal(out.note, "nota");
|
||||
});
|
||||
|
||||
test("buildCteResultV2 prefers doc purpose/rationale/depends_on, falls back to thin data", () => {
|
||||
const cteInfoWithDoc = {
|
||||
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)",
|
||||
doc: { purpose: "scopo dal doc", rationale: "motivo dal doc", depends_on: ["x"] },
|
||||
last_test: { status: "ok", execution_ms: 1, row_sample: 0, warnings: [], sql_hash: "h", columns: [], preview_rows: [] },
|
||||
};
|
||||
const out1 = buildCteResultV2({ schema_version: 2, purpose: "scopo thin" }, cteInfoWithDoc);
|
||||
assert.equal(out1.purpose, "scopo dal doc");
|
||||
assert.equal(out1.rationale, "motivo dal doc");
|
||||
assert.deepEqual(out1.depends_on, ["x"]);
|
||||
|
||||
const cteInfoNoDoc = { ...cteInfoWithDoc, doc: null };
|
||||
const out2 = buildCteResultV2({ schema_version: 2, purpose: "scopo thin", rationale: "motivo thin" }, cteInfoNoDoc);
|
||||
assert.equal(out2.purpose, "scopo thin");
|
||||
assert.equal(out2.rationale, "motivo thin");
|
||||
});
|
||||
|
||||
test("buildCteResultV2 builds preview from last_test.columns/preview_rows", () => {
|
||||
const cteInfo = {
|
||||
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
|
||||
last_test: { status: "ok", execution_ms: 10, row_sample: 2, warnings: [], sql_hash: "h1", columns: ["cod_paz", "eta"], preview_rows: [[1, 30], [2, 45]] },
|
||||
};
|
||||
const out = buildCteResultV2({ schema_version: 2 }, cteInfo);
|
||||
assert.deepEqual(out.preview, { columns: ["cod_paz", "eta"], rows: [[1, 30], [2, 45]] });
|
||||
assert.equal(out.status, "ok");
|
||||
assert.equal(out.execution_ms, 10);
|
||||
assert.equal(out.row_sample, 2);
|
||||
assert.equal(out.sql_hash, "h1");
|
||||
assert.deepEqual(out.warnings, []);
|
||||
});
|
||||
|
||||
test("buildCteResultV2: columns[] carries name, with description left as an empty placeholder (filled separately by the gate)", () => {
|
||||
const cteInfo = {
|
||||
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
|
||||
last_test: { status: "ok", execution_ms: 1, row_sample: 1, warnings: [], sql_hash: "h", columns: ["cod_paz"], preview_rows: [[1]] },
|
||||
};
|
||||
const out = buildCteResultV2({ schema_version: 2 }, cteInfo);
|
||||
assert.equal(out.columns.length, 1);
|
||||
assert.equal(out.columns[0].name, "cod_paz");
|
||||
assert.equal(out.columns[0].description, "");
|
||||
});
|
||||
|
||||
// --- enrichPhaseSummaryV2 -----------------------------------------------------------
|
||||
|
||||
test("enrichPhaseSummaryV2 fills phase from phaseMeta", () => {
|
||||
const data = { schema_version: 2, summary: "riepilogo" };
|
||||
const phaseMeta = { id: "F5", num: 5, name: "sintesi" };
|
||||
const out = enrichPhaseSummaryV2(data, phaseMeta, () => null);
|
||||
assert.deepEqual(out.phase, { id: "F5", num: 5, name: "sintesi" });
|
||||
});
|
||||
|
||||
test("enrichPhaseSummaryV2 enriches tables[]/tables[].columns[]/sections[].items[] descriptions", () => {
|
||||
const getColumns = (table) =>
|
||||
table === "dim_patient"
|
||||
? { description: "Anagrafica", columns: [{ name: "cod_paz", description: "Codice paziente" }] }
|
||||
: null;
|
||||
const data = {
|
||||
schema_version: 2, summary: "s",
|
||||
tables: [{ name: "dim_patient", role: "promoted", columns: [{ name: "cod_paz" }] }],
|
||||
sections: [{ title: "Criteri", items: [{ label: "id paziente", table: "dim_patient", column: "cod_paz" }] }],
|
||||
};
|
||||
const out = enrichPhaseSummaryV2(data, { id: "F5", num: 5, name: "sintesi" }, getColumns);
|
||||
assert.equal(out.tables[0].description, "Anagrafica");
|
||||
assert.equal(out.tables[0].columns[0].description, "Codice paziente");
|
||||
assert.equal(out.sections[0].items[0].description, "Codice paziente");
|
||||
});
|
||||
|
||||
test("enrichPhaseSummaryV2: catalog miss -> empty description, never throws", () => {
|
||||
const data = {
|
||||
schema_version: 2, summary: "s",
|
||||
tables: [{ name: "nope", role: "excluded", columns: [{ name: "x" }] }],
|
||||
sections: [{ title: "t", items: [{ label: "l", table: "nope", column: "x" }] }],
|
||||
};
|
||||
const out = enrichPhaseSummaryV2(data, { id: "F5", num: 5, name: "sintesi" }, () => null);
|
||||
assert.equal(out.tables[0].description, "");
|
||||
assert.equal(out.tables[0].columns[0].description, "");
|
||||
assert.equal(out.sections[0].items[0].description, "");
|
||||
});
|
||||
|
||||
test("enrichPhaseSummaryV2 preserves open_questions/checks untouched", () => {
|
||||
const data = {
|
||||
schema_version: 2, summary: "s",
|
||||
checks: [{ label: "x", status: "ok" }],
|
||||
open_questions: ["domanda aperta"],
|
||||
};
|
||||
const out = enrichPhaseSummaryV2(data, { id: "F1", num: 1, name: "chiarimento" }, () => null);
|
||||
assert.deepEqual(out.checks, [{ label: "x", status: "ok" }]);
|
||||
assert.deepEqual(out.open_questions, ["domanda aperta"]);
|
||||
});
|
||||
Reference in New Issue
Block a user