feat(gate): reviewer_memory_promote — deterministic F8 memory-promotion gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const {
|
||||
promotionOptions,
|
||||
promotionContent,
|
||||
splitPromotionChoices,
|
||||
} = require("../../tht-gate.js");
|
||||
|
||||
// F8 memory-promotion gate: the candidates are DETERMINISTIC (computed by
|
||||
// `tht memory promote --preview --json`), the model only names the session.
|
||||
// These tests pin the pure candidate->widget mapping and the choice partition.
|
||||
|
||||
const CANDIDATES = [
|
||||
{ decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione",
|
||||
detail: "tabella principale ablazioni", rationale: "scelta dal reviewer",
|
||||
question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] },
|
||||
{ decision_seq: 5, type: "concept_clarified", subject: "paziente attivo",
|
||||
detail: "flag_attivo = TRUE", rationale: "",
|
||||
question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] },
|
||||
];
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("promotionContent lists every candidate with detail and context", () => {
|
||||
const c = promotionContent(CANDIDATES);
|
||||
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"]);
|
||||
assert.deepEqual(promote.map((c) => c.decision_seq), [5]);
|
||||
assert.deepEqual(decline.map((c) => c.decision_seq), [3]);
|
||||
});
|
||||
|
||||
test("empty or missing choices declines everything", () => {
|
||||
assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2);
|
||||
assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2);
|
||||
});
|
||||
@@ -107,6 +107,8 @@ const FORBIDDEN = [
|
||||
/\btht\s+phase\s+(advance|reopen)\b/,
|
||||
/\btht\s+decision\s+add\b/,
|
||||
/\btht\s+cte\s+plan\b/,
|
||||
// La promozione in memoria passa dal reviewer (reviewer_memory_promote), mai da shell.
|
||||
/\btht\s+memory\s+(promote|save-one)\b/,
|
||||
];
|
||||
const PROTECTED_FILES =
|
||||
/(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/;
|
||||
@@ -341,6 +343,38 @@ export function shouldSkipEmptyDecide({ meritCount, allowEmpty, advance }) {
|
||||
return meritCount === 0 && !!allowEmpty && !!advance;
|
||||
}
|
||||
|
||||
// --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------
|
||||
// The candidates come from `tht memory promote --preview --json` (deterministic,
|
||||
// reviewer-approved decisions only); the model never authors them.
|
||||
export function promotionOptions(candidates) {
|
||||
return candidates.map((c) => ({
|
||||
id: `seq-${c.decision_seq}`,
|
||||
label: `${c.type}: ${c.subject}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function promotionContent(candidates) {
|
||||
return candidates
|
||||
.map(
|
||||
(c) =>
|
||||
`- **${c.type}: ${c.subject}** (decisione #${c.decision_seq})\n` +
|
||||
` ${c.detail || ""}\n` +
|
||||
` Motivo: ${c.rationale || "—"}\n` +
|
||||
` Domanda di contesto: ${c.question_context || "—"}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitPromotionChoices(candidates, choices) {
|
||||
const chosen = new Set(choices ?? []);
|
||||
const promote = [];
|
||||
const decline = [];
|
||||
for (const c of candidates) {
|
||||
(chosen.has(`seq-${c.decision_seq}`) ? promote : decline).push(c);
|
||||
}
|
||||
return { promote, decline };
|
||||
}
|
||||
|
||||
export async function emitAndWait(ctx, descriptor) {
|
||||
for (;;) {
|
||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||
@@ -984,6 +1018,88 @@ export default function (pi) {
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "reviewer_memory_promote",
|
||||
label: "Promozione memorie riusabili (reviewer)",
|
||||
description:
|
||||
"F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " +
|
||||
"calcolati dalla CLI (tht memory promote --preview: tipi riusabili concept_clarified/" +
|
||||
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
|
||||
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
|
||||
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
|
||||
"alla sessione: i candidati sono deterministici, NON li scrivi tu.",
|
||||
parameters: Type.Object({
|
||||
session: Type.String(),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
lockActive = true;
|
||||
const { session } = params;
|
||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||
let candidates;
|
||||
try {
|
||||
candidates = JSON.parse(
|
||||
tht(ctx, ["memory", "promote", "--session", session, "--preview", "--json"]),
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
||||
return textResult(`Preview di promozione non disponibile: ${msg}`);
|
||||
}
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
await ctx.ui.notify(
|
||||
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
|
||||
"info",
|
||||
);
|
||||
return textResult(
|
||||
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
|
||||
);
|
||||
}
|
||||
const options = promotionOptions(candidates);
|
||||
const widget = buildMultiselectRequest({
|
||||
id: `u${Date.now()}`,
|
||||
phase,
|
||||
title: "Quali decisioni salvare nella memoria riutilizzabile?",
|
||||
allowEmpty: true,
|
||||
options,
|
||||
selected: options.map((o) => o.id),
|
||||
content: promotionContent(candidates),
|
||||
});
|
||||
const resp = await emitAndWait(ctx, widget);
|
||||
if (resp.control === "freetext")
|
||||
return textResult(`Altro (reviewer): ${resp.text}. Valuta e ripresenta il gate.`);
|
||||
if (resp.control === "back")
|
||||
return textResult("Il reviewer vuole tornare indietro.");
|
||||
if (resp.control === "exit") return textResult("Il reviewer vuole uscire.");
|
||||
const { promote, decline } = splitPromotionChoices(candidates, resp.choices);
|
||||
let saved = 0;
|
||||
for (const c of promote) {
|
||||
const err = relayIfThtFails(
|
||||
ctx,
|
||||
["memory", "save-one", "--session", session,
|
||||
"--decision", String(c.decision_seq), "--json"],
|
||||
"",
|
||||
);
|
||||
if (err) return err;
|
||||
const e2 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "memory_promoted", subject: c.subject,
|
||||
detail: `seq:${c.decision_seq}`, rationale: c.rationale || c.detail || "",
|
||||
}), "");
|
||||
if (e2) return e2;
|
||||
saved++;
|
||||
}
|
||||
for (const c of decline) {
|
||||
const err = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||||
type: "memory_promotion_declined", subject: c.subject,
|
||||
detail: `seq:${c.decision_seq}`,
|
||||
}), "");
|
||||
if (err) return err;
|
||||
}
|
||||
return textResult(
|
||||
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
|
||||
`${decline.length} candidati scartati.`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "rewrite_question",
|
||||
label: "Riscrittura domanda (deterministica)",
|
||||
|
||||
Reference in New Issue
Block a user