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
+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)",