feat(gate): reviewer_schema_linking tool with per-column curation + deterministic sync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:25:00 +02:00
committed by Marco Pancotti
co-authored by Claude Opus 4.8
parent 559f52e26e
commit ad788278dd
3 changed files with 235 additions and 0 deletions
@@ -0,0 +1,122 @@
// L1.5 roundtrip for the reviewer_schema_linking tool: drives the registered tool's
// execute() end-to-end against a fake Pi runtime, stubbing the `tht` shell (execFileSync)
// and ctx.ui.input, then asserts the recorded decision-add / sync-schema-linking argv.
//
// HARNESS ADAPTATIONS (no existing test invokes a tool's execute() end-to-end):
// 1. tht-gate.js uses ESM `import { execFileSync } from "node:child_process"`. Under
// CommonJS `require(esm)` the named binding tracks the module namespace, so patching
// `child_process.execFileSync` BEFORE requiring tht-gate.js is observed by tht().
// 2. tht()'s loadEnvFromDotenv calls `require("node:fs")`; that `require` is undefined
// in the ESM module scope when loaded via require(esm) (in production Pi loads the
// extension as CommonJS, where require is native). We install a globalThis.require
// shim so loadEnvFromDotenv works. cwd points at a missing dir so the .env read
// fails gracefully inside its own try/catch.
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
// Adaptation #2: shim require() for the ESM-loaded-via-require module scope.
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
const CATALOG = {
table: "dim_patient",
description: "Anagrafica",
columns: [
{ name: "cod_paz", description: "Codice", type: "bigint", pk: true },
{ name: "nome", description: "Nome", type: "text", pk: false },
],
};
test("reviewer_schema_linking records table/column decisions and syncs schema_linking", async () => {
const calls = [];
// Adaptation #1: stub the shell BEFORE requiring tht-gate.js.
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patient")
return JSON.stringify(CATALOG);
return ""; // decision add, session sync-schema-linking, ...
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd"; // .env read fails gracefully
gate.default(pi);
// Reflect the descriptor id back and return the reviewer response so emitAndWait
// accepts it (its resp.id === descriptor.id invariant). The descriptor id is a
// runtime-generated `u${Date.now()}` — capture it, do not hardcode.
let capturedDescriptor = null;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({
id: capturedDescriptor.id,
kind: "schema-linking",
tables: [{ id: "t-pat", enacted: true, columns: ["cod_paz"] }],
});
};
const tool = tools.get("reviewer_schema_linking");
assert.ok(tool, "reviewer_schema_linking must be registered");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
title: "Schema linking",
tables: [
{
id: "t-pat",
name: "dim_patient",
kind: "promote",
suggested_columns: ["cod_paz", "nome"],
},
],
},
null,
null,
ctx,
);
// The emitted descriptor carried the enriched catalog columns.
assert.equal(capturedDescriptor.widget, "schema-linking");
assert.equal(capturedDescriptor.tables[0].columns.length, 2);
// cod_paz was selected -> promoted; nome was suggested but deselected -> excluded.
assert.ok(
calls.some((c) => /decision add .*--type table_promoted --subject dim_patient\b/.test(c)),
`expected table_promoted dim_patient in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /decision add .*--type column_promoted --subject dim_patient\.cod_paz\b/.test(c)),
`expected column_promoted dim_patient.cod_paz in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /decision add .*--type column_excluded --subject dim_patient\.nome\b/.test(c)),
`expected column_excluded dim_patient.nome in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /^session sync-schema-linking s1$/.test(c)),
`expected session sync-schema-linking s1 in: ${JSON.stringify(calls)}`,
);
// sync runs AFTER the decisions are recorded.
const syncIdx = calls.findIndex((c) => c.startsWith("session sync-schema-linking"));
const lastDecisionIdx = calls.map((c) => c.startsWith("decision add")).lastIndexOf(true);
assert.ok(syncIdx > lastDecisionIdx, "sync must run after all decision adds");
assert.match(result.content[0].text, /Schema linking registrato/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
@@ -40,6 +40,12 @@ test("prepareReviewerArguments leaves an object artifact and parses stringified
assert.equal(out.options[0].id, "a");
});
test("prepareReviewerArguments parses stringified tables array", () => {
const out = prepareReviewerArguments({ tables: JSON.stringify([{ id: "t1", name: "x" }]) });
assert.ok(Array.isArray(out.tables));
assert.equal(out.tables[0].id, "t1");
});
test("GATE_CODE_FILES blocks writes to gate extensions, not session artifacts", () => {
assert.ok(GATE_CODE_FILES.test("harness/.pi/extensions/tht-gate.js"));
assert.ok(GATE_CODE_FILES.test(".pi/extensions/reserved-labels.mjs"));
+107
View File
@@ -29,6 +29,7 @@ import {
buildSelectRequest,
buildMultiselectRequest,
buildArtifactGate,
buildSchemaLinkingRequest,
} from "./gate/builders.js";
import { isReserved } from "./reserved-labels.mjs";
@@ -67,6 +68,14 @@ export function prepareReviewerArguments(input) {
/* not JSON */
}
}
if (typeof args.tables === "string") {
try {
const parsed = JSON.parse(args.tables);
if (Array.isArray(parsed)) args.tables = parsed;
} catch {
/* not JSON */
}
}
// reviewer_confirm's `artifact` is a Type.Object; some models send it as a
// stringified JSON object, which fails validation BEFORE execute() and makes the
// model loop. Coerce it back to an object so the gate proceeds.
@@ -610,6 +619,104 @@ export default function (pi) {
},
});
pi.registerTool({
name: "reviewer_schema_linking",
label: "Schema linking: tabelle/colonne/esclusioni (reviewer)",
description:
"F4: presenta al reviewer le tabelle da promuovere/escludere con la loro descrizione " +
"e, per ogni tabella, le colonne dal catalogo (le suggerite pre-selezionate). Il reviewer " +
"cura le colonne di ogni tabella promossa. PERSISTE table_promoted/table_excluded e " +
"column_promoted/column_excluded, poi riproietta schema_linking.json in modo deterministico " +
"(tht session sync-schema-linking). `tables[]`: {id, name, kind: 'promote'|'exclude', " +
"rationale?, suggested_columns?: string[]}. Le colonne complete arrivano dal catalogo, non dal modello.",
parameters: Type.Object({
session: Type.String(),
title: Type.String(),
tables: Type.Array(
Type.Object({
id: Type.String(),
name: Type.String(),
kind: Type.Union([Type.Literal("promote"), Type.Literal("exclude")]),
rationale: Type.Optional(Type.String()),
suggested_columns: Type.Optional(Type.Array(Type.String())),
recommended: Type.Optional(Type.Boolean()),
}),
),
advance: Type.Optional(Type.Boolean()),
}),
prepareArguments: prepareReviewerArguments,
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, title, tables, advance } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
// Enrich each table with its full catalog columns (deterministic source).
const enriched = tables.map((t) => {
const cat = JSON.parse(tht(ctx, ["schema", "columns", t.name, "--json"]));
const suggested = new Set(t.suggested_columns ?? []);
return {
id: t.id,
name: t.name,
kind: t.kind,
recommended: t.recommended ?? true,
description: cat.description ?? "",
rationale: t.rationale ?? "",
columns: cat.columns.map((c) => ({ ...c, suggested: suggested.has(c.name) })),
};
});
const widget = buildSchemaLinkingRequest({
id: `u${Date.now()}`,
phase,
title,
tables: enriched,
});
const resp = await emitAndWait(ctx, widget);
if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro.");
if (resp.control === "exit") return textResult("Il reviewer vuole uscire.");
if (resp.control === "freetext")
return textResult(`Altro (reviewer): ${resp.text}. Riformula tenendone conto.`);
const byId = new Map(enriched.map((t) => [t.id, t]));
let n = 0;
for (const rt of resp.tables ?? []) {
const t = byId.get(rt.id);
if (!t || !rt.enacted) continue;
if (t.kind === "promote") {
const e1 = relayIfThtFails(ctx, decisionAddArgs(session, {
type: "table_promoted", subject: t.name, detail: t.description, rationale: t.rationale,
}), "");
if (e1) return e1;
n++;
const sel = new Set(rt.columns ?? []);
for (const c of t.columns) {
const type = sel.has(c.name)
? "column_promoted"
: (c.suggested ? "column_excluded" : null);
if (!type) continue;
const e2 = relayIfThtFails(ctx, decisionAddArgs(session, {
type, subject: `${t.name}.${c.name}`, detail: c.description ?? "",
}), "");
if (e2) return e2;
}
} else {
const e3 = relayIfThtFails(ctx, decisionAddArgs(session, {
type: "table_excluded", subject: t.name, detail: t.description, rationale: t.rationale,
}), "");
if (e3) return e3;
n++;
}
}
// Deterministic projection of the ledger into schema_linking.json.
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
if (eSync) return eSync;
if (advance) advanceIfReady(ctx, session);
return textResult(`Schema linking registrato dal reviewer (${n} tabelle + colonne curate).`);
},
});
pi.registerTool({
name: "reviewer_confirm",
label: "Gate di avanzamento (reviewer)",