fix(harness): recover schema table name typos
This commit is contained in:
@@ -4,8 +4,9 @@
|
|||||||
//
|
//
|
||||||
// HARNESS ADAPTATIONS (no existing test invokes a tool's execute() end-to-end):
|
// 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
|
// 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
|
// CommonJS `require(esm)` the named binding is snapshotted at load time. A single
|
||||||
// `child_process.execFileSync` BEFORE requiring tht-gate.js is observed by tht().
|
// delegating stub is installed ONCE before requiring tht-gate.js; each test swaps the
|
||||||
|
// inner handler via `_handler` so every stub is visible to the frozen ESM binding.
|
||||||
// 2. tht()'s loadEnvFromDotenv calls `require("node:fs")`; that `require` is undefined
|
// 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
|
// 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
|
// extension as CommonJS, where require is native). We install a globalThis.require
|
||||||
@@ -33,12 +34,20 @@ const CATALOG = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Adaptation #1: the ESM import of execFileSync is frozen at require(GATE) time.
|
||||||
|
// A single delegating function is installed before the first load; each test swaps
|
||||||
|
// the inner _handler and clears it in finally — no save/restore of cp.execFileSync.
|
||||||
|
let _handler = null;
|
||||||
|
const _origExecFileSync = cp.execFileSync;
|
||||||
|
cp.execFileSync = function delegatingStub(file, args, opts) {
|
||||||
|
if (_handler) return _handler(file, args, opts);
|
||||||
|
return _origExecFileSync.call(cp, file, args, opts);
|
||||||
|
};
|
||||||
|
|
||||||
test("reviewer_schema_linking records table/column decisions and syncs schema_linking", async () => {
|
test("reviewer_schema_linking records table/column decisions and syncs schema_linking", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const inputs = [];
|
const inputs = [];
|
||||||
// Adaptation #1: stub the shell BEFORE requiring tht-gate.js.
|
_handler = (file, args, opts) => {
|
||||||
const origExecFileSync = cp.execFileSync;
|
|
||||||
cp.execFileSync = (file, args, opts) => {
|
|
||||||
calls.push(args.join(" "));
|
calls.push(args.join(" "));
|
||||||
inputs.push(opts?.input);
|
inputs.push(opts?.input);
|
||||||
if (args[0] === "phase" && args[1] === "meta")
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
@@ -119,13 +128,79 @@ test("reviewer_schema_linking records table/column decisions and syncs schema_li
|
|||||||
|
|
||||||
assert.match(result.content[0].text, /Schema linking registrato/);
|
assert.match(result.content[0].text, /Schema linking registrato/);
|
||||||
} finally {
|
} finally {
|
||||||
cp.execFileSync = origExecFileSync;
|
_handler = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviewer_schema_linking auto-corrects typos in table names via fuzzy matching", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const inputs = [];
|
||||||
|
_handler = (file, args, opts) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
inputs.push(opts?.input);
|
||||||
|
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";
|
||||||
|
// The typo name fails:
|
||||||
|
if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patientt") {
|
||||||
|
const err = new Error("Command failed");
|
||||||
|
err.stderr = "ERRORE: tabella 'dim_patientt' non trovata nel catalogo";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// The correct name works:
|
||||||
|
if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patient")
|
||||||
|
return JSON.stringify(CATALOG);
|
||||||
|
// catalogTableNames calls schema render:
|
||||||
|
if (args[0] === "schema" && args[1] === "render")
|
||||||
|
return "CREATE TABLE dim_patient (\n cod_paz bigint\n);\n";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gate = require(GATE);
|
||||||
|
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||||
|
const { pi, ctx, tools } = createFakePi();
|
||||||
|
ctx.cwd = "/nonexistent-thothii-test-cwd-fuzzy"; // distinct key → fresh cache
|
||||||
|
gate.default(pi);
|
||||||
|
|
||||||
|
let capturedDescriptor = null;
|
||||||
|
ctx.ui.input = async (title) => {
|
||||||
|
capturedDescriptor = JSON.parse(title);
|
||||||
|
return JSON.stringify({
|
||||||
|
id: capturedDescriptor.id,
|
||||||
|
kind: "schema-linking",
|
||||||
|
tables: [{ id: "dim_patient", enacted: true, columns: ["cod_paz"] }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = tools.get("reviewer_schema_linking");
|
||||||
|
const result = await tool.def.execute(
|
||||||
|
"call-1",
|
||||||
|
{
|
||||||
|
session: "s1",
|
||||||
|
title: "Schema linking",
|
||||||
|
tables: [{ id: "t-pat", name: "dim_patientt", kind: "promote", suggested_columns: ["cod_paz"] }],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The gate should have auto-corrected "dim_patientt" → "dim_patient".
|
||||||
|
assert.equal(capturedDescriptor.tables[0].name, "dim_patient");
|
||||||
|
assert.match(result.content[0].text, /Schema linking registrato/);
|
||||||
|
// The persisted decisions use the corrected name.
|
||||||
|
const batchIdx = calls.findIndex((c) => c === "decision add-batch --session s1 --doc -");
|
||||||
|
assert.ok(batchIdx !== -1, "expected atomic decision batch");
|
||||||
|
const batch = JSON.parse(inputs[batchIdx]);
|
||||||
|
assert.equal(batch[0].subject, "dim_patient");
|
||||||
|
} finally {
|
||||||
|
_handler = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reviewer_schema_linking returns a textResult (not a throw) when a proposed table is not in the catalog", async () => {
|
test("reviewer_schema_linking returns a textResult (not a throw) when a proposed table is not in the catalog", async () => {
|
||||||
const origExecFileSync = cp.execFileSync;
|
_handler = (file, args) => {
|
||||||
cp.execFileSync = (file, args) => {
|
|
||||||
if (args[0] === "phase" && args[1] === "meta")
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
|
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
|
||||||
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
|
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
|
||||||
@@ -167,6 +242,6 @@ test("reviewer_schema_linking returns a textResult (not a throw) when a proposed
|
|||||||
|
|
||||||
assert.match(result.content[0].text, /badtable/);
|
assert.match(result.content[0].text, /badtable/);
|
||||||
} finally {
|
} finally {
|
||||||
cp.execFileSync = origExecFileSync;
|
_handler = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -367,6 +367,50 @@ function currentPhase(ctx, session) {
|
|||||||
return m ? parseInt(m[1], 10) : 1;
|
return m ? parseInt(m[1], 10) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- fuzzy table-name matching (prevents multi-turn debug loops on typos) ---
|
||||||
|
|
||||||
|
function levenshtein(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
const la = a.length, lb = b.length;
|
||||||
|
if (!la) return lb;
|
||||||
|
if (!lb) return la;
|
||||||
|
let prev = Array.from({ length: lb + 1 }, (_, i) => i);
|
||||||
|
for (let i = 1; i <= la; i++) {
|
||||||
|
const cur = [i];
|
||||||
|
for (let j = 1; j <= lb; j++) {
|
||||||
|
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return prev[lb];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _catalogNamesCache = new Map();
|
||||||
|
function catalogTableNames(ctx) {
|
||||||
|
const key = ctx.cwd ?? "";
|
||||||
|
if (_catalogNamesCache.has(key)) return _catalogNamesCache.get(key);
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
const raw = tht(ctx, ["schema", "render", "--format", "mschema-text"]);
|
||||||
|
names = [...raw.matchAll(/^CREATE TABLE (\S+)/gm)].map((m) => m[1]);
|
||||||
|
} catch {
|
||||||
|
names = [];
|
||||||
|
}
|
||||||
|
_catalogNamesCache.set(key, names);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestTableName(ctx, name, maxDist = 3) {
|
||||||
|
const names = catalogTableNames(ctx);
|
||||||
|
let best = null, bestDist = maxDist + 1;
|
||||||
|
for (const n of names) {
|
||||||
|
if (Math.abs(n.length - name.length) > maxDist) continue;
|
||||||
|
const d = levenshtein(n, name);
|
||||||
|
if (d < bestDist) { bestDist = d; best = n; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
// tht phase advance --auto: exit 6 = not ready / needs human (silent no-op), others propagated.
|
// tht phase advance --auto: exit 6 = not ready / needs human (silent no-op), others propagated.
|
||||||
// Used for the fire-and-forget auto-advance of the auto phases (F2 memory, F6 cte) after a
|
// Used for the fire-and-forget auto-advance of the auto phases (F2 memory, F6 cte) after a
|
||||||
// reviewer_decide: it only advances when the phase is auto-eligible (zero substantive
|
// reviewer_decide: it only advances when the phase is auto-eligible (zero substantive
|
||||||
@@ -978,11 +1022,24 @@ export default function (pi) {
|
|||||||
try {
|
try {
|
||||||
cat = JSON.parse(tht(ctx, ["schema", "columns", t.name, "--json"]));
|
cat = JSON.parse(tht(ctx, ["schema", "columns", t.name, "--json"]));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Fuzzy recovery: the model often misspells Italian table names
|
||||||
|
// (e.g. "abellazione" vs "ablazione"). Auto-correct if a close
|
||||||
|
// catalog match exists, preventing a multi-turn debug spiral.
|
||||||
|
const fix = closestTableName(ctx, t.name);
|
||||||
|
if (fix) {
|
||||||
|
try {
|
||||||
|
cat = JSON.parse(tht(ctx, ["schema", "columns", fix, "--json"]));
|
||||||
|
t.id = fix;
|
||||||
|
t.name = fix;
|
||||||
|
} catch { /* fall through to error */ }
|
||||||
|
}
|
||||||
|
if (!cat) {
|
||||||
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
||||||
return textResult(
|
return textResult(
|
||||||
`Tabella '${t.name}' non caricabile dal catalogo (${msg}). Proponi solo tabelle presenti nel catalogo (usa 'tht schema render' / 'tht search' per verificarne i nomi).`,
|
`Tabella '${t.name}' non caricabile dal catalogo (${msg}). Proponi solo tabelle presenti nel catalogo (usa 'tht schema render' / 'tht search' per verificarne i nomi).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(cat.columns)) {
|
if (!Array.isArray(cat.columns)) {
|
||||||
return textResult(`Catalogo per '${t.name}' non contiene colonne valide. Verifica con 'tht schema columns ${t.name} --json'.`);
|
return textResult(`Catalogo per '${t.name}' non contiene colonne valide. Verifica con 'tht schema columns ${t.name} --json'.`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user