fix: harden reviewer workflow and memory handling

This commit is contained in:
2026-07-23 12:34:23 +02:00
parent 00761ae2ca
commit 694b7dd21f
23 changed files with 728 additions and 75 deletions
@@ -0,0 +1,104 @@
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");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true,
get: () => dispatcher,
set: (fn) => { activeStub = fn; },
});
async function loadGate() {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
await pi.emit("session_start", {});
return { ctx, tools };
}
function phase8Stub(calls) {
return (_file, args) => {
calls.push(args);
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({
max_phase: 8,
phases: [{
num: 8,
id: "F8",
name: "datamart",
emits: ["datamart_requested", "datamart_declined"],
}],
});
}
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n";
return "";
};
}
test("reviewer_datamart skips the question and records decline on workstation", async () => {
const previousProfile = process.env.THT_PROFILE;
const originalExec = cp.execFileSync;
const calls = [];
process.env.THT_PROFILE = "workstation";
cp.execFileSync = phase8Stub(calls);
try {
const { ctx, tools } = await loadGate();
const tool = tools.get("reviewer_datamart");
assert.ok(tool, "reviewer_datamart must be registered");
const result = await tool.def.execute("call-1", { session: "s1" }, null, null, ctx);
assert.equal(ctx.uiCalls.length, 0, "workstation must not show a pointless question");
assert.ok(calls.some((args) =>
args.join(" ").includes("decision add --session s1 --type datamart_declined --subject phase:8")
));
assert.match(result.content[0].text, /workstation.*saltato automaticamente/i);
} finally {
cp.execFileSync = originalExec;
if (previousProfile === undefined) delete process.env.THT_PROFILE;
else process.env.THT_PROFILE = previousProfile;
}
});
test("reviewer_datamart shows both yes and no choices on server", async () => {
const previousProfile = process.env.THT_PROFILE;
const originalExec = cp.execFileSync;
const calls = [];
process.env.THT_PROFILE = "server";
cp.execFileSync = phase8Stub(calls);
try {
const { ctx, tools } = await loadGate();
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
assert.deepEqual(
descriptor.options.slice(0, 2).map((option) => option.label),
["Sì, genera il datamart", "No, salta il datamart"],
);
return JSON.stringify({ id: descriptor.id, choices: ["generate"] });
};
const result = await tools.get("reviewer_datamart").def.execute(
"call-1", { session: "s1" }, null, null, ctx,
);
assert.ok(calls.some((args) =>
args.join(" ").includes("decision add --session s1 --type datamart_requested --subject phase:8")
));
assert.match(result.content[0].text, /datamart_requested/);
} finally {
cp.execFileSync = originalExec;
if (previousProfile === undefined) delete process.env.THT_PROFILE;
else process.env.THT_PROFILE = previousProfile;
}
});
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert");
const {
dedupePromotionCandidates,
promotionOptions,
promotionContent,
splitPromotionChoices,
@@ -20,26 +21,47 @@ const CANDIDATES = [
];
test("promotionOptions maps candidates to seq-keyed options", () => {
assert.deepEqual(promotionOptions(CANDIDATES), [
{ id: "seq-3", label: "table_promoted: fact_seeablazione" },
{ id: "seq-5", label: "concept_clarified: paziente attivo" },
assert.deepEqual(promotionOptions(dedupePromotionCandidates(CANDIDATES)), [
{
id: "seq-5",
label: "concept_clarified: paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "",
meta: { question_context: "quante ablazioni nel 2023" },
},
]);
});
test("promotionContent lists every candidate with detail and context", () => {
const c = promotionContent(CANDIDATES);
assert.ok(c.includes("fact_seeablazione"));
test("F8 proposes semantically identical memory content only once", () => {
const duplicate = { ...CANDIDATES[0], decision_seq: 9 };
assert.deepEqual(
dedupePromotionCandidates([CANDIDATES[0], duplicate, CANDIDATES[1]])
.map((candidate) => candidate.decision_seq),
[5],
);
});
test("F8 never proposes promoted tables as memory", () => {
const candidates = dedupePromotionCandidates(CANDIDATES);
const options = promotionOptions(candidates);
const c = promotionContent(candidates);
assert.deepEqual(candidates.map((candidate) => candidate.type), ["concept_clarified"]);
assert.deepEqual(options.map((option) => option.id), ["seq-5"]);
assert.ok(!c.includes("fact_seeablazione"));
assert.ok(c.includes("flag_attivo = TRUE"));
assert.ok(c.includes("quante ablazioni nel 2023"));
});
test("splitPromotionChoices partitions by selection", () => {
const { promote, decline } = splitPromotionChoices(CANDIDATES, ["seq-5"]);
const candidates = dedupePromotionCandidates(CANDIDATES);
const { promote, decline } = splitPromotionChoices(candidates, ["seq-5"]);
assert.deepEqual(promote.map((c) => c.decision_seq), [5]);
assert.deepEqual(decline.map((c) => c.decision_seq), [3]);
assert.deepEqual(decline.map((c) => c.decision_seq), []);
});
test("empty or missing choices declines everything", () => {
assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2);
assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2);
const candidates = dedupePromotionCandidates(CANDIDATES);
assert.equal(splitPromotionChoices(candidates, []).decline.length, 1);
assert.equal(splitPromotionChoices(candidates, undefined).decline.length, 1);
});
@@ -1,6 +1,20 @@
const test = require("node:test");
const assert = require("node:assert");
const { memorySelectionWidgetProps } = require("../../tht-gate.js");
const path = require("node:path");
const {
memorySelectionWidgetProps,
normalizeMemoryOptions,
} = require("../../tht-gate.js");
test("reviewer_decide accepts the exact memory content in option.description", () => {
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, tools } = createFakePi();
require(path.join(__dirname, "..", "..", "tht-gate.js")).default(pi);
const optionProperties = tools.get("reviewer_decide")
.def.parameters.properties.options.items.properties;
assert.ok(optionProperties.description);
});
test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () => {
assert.deepEqual(
@@ -16,3 +30,65 @@ test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () =
},
);
});
test("F2 preserves the exact memory content and proposes each memory id once", () => {
const options = normalizeMemoryOptions([
{
id: "first",
label: "Regola paziente attivo",
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
decision: {
type: "concept_clarified",
subject: "paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "Riusa mem-0042 per la stessa definizione",
},
recommended: true,
},
{
id: "duplicate",
label: "La stessa regola",
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
decision: {
type: "concept_clarified",
subject: "paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "Memory mem-0042",
},
},
]);
assert.equal(options.length, 1);
assert.equal(
options[0].detail,
"Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
);
assert.equal(options[0].recommended, true);
});
test("F2 accepts only concept_clarified memory options", () => {
const options = normalizeMemoryOptions([
{
id: "mem-0042",
label: "Concetto paziente attivo",
description: "Definizione riusabile",
decision: {
type: "concept_clarified",
subject: "paziente attivo",
detail: "flag_attivo = TRUE",
},
},
{
id: "mem-0043",
label: "Tabella promossa",
description: "fact_pazienti",
decision: {
type: "table_promoted",
subject: "fact_pazienti",
detail: "tabella principale",
},
},
]);
assert.deepEqual(options.map((option) => option.id), ["mem-0042"]);
});
@@ -132,6 +132,64 @@ test("reviewer_schema_linking records table/column decisions and syncs schema_li
}
});
test("reviewer_schema_linking preselects promoted tables but not excluded tables", async () => {
_handler = (_file, args) => {
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")
return JSON.stringify({ ...CATALOG, table: args[2] });
return "";
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-table-defaults";
gate.default(pi);
let capturedDescriptor;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({
id: capturedDescriptor.id,
kind: "schema-linking",
tables: capturedDescriptor.tables.map((table) => ({
id: table.id,
enacted: table.recommended,
columns: [],
})),
});
};
await tools.get("reviewer_schema_linking").def.execute(
"call-defaults",
{
session: "s1",
title: "Schema linking",
tables: [
{ id: "keep", name: "fact_keep", kind: "promote" },
{ id: "drop", name: "fact_drop", kind: "exclude", recommended: true },
],
},
null,
null,
ctx,
);
assert.deepEqual(
capturedDescriptor.tables.map(({ id, recommended }) => ({ id, recommended })),
[
{ id: "keep", recommended: true },
{ id: "drop", recommended: false },
],
);
} finally {
_handler = null;
}
});
test("reviewer_schema_linking auto-corrects typos in table names via fuzzy matching", async () => {
const calls = [];
const inputs = [];