refactor(memory): extract F8 promotion gate (#22)

This commit is contained in:
2026-08-24 00:51:53 +02:00
parent d15bb59c3d
commit beac2e80f4
3 changed files with 379 additions and 217 deletions
@@ -1,67 +1,156 @@
const test = require("node:test");
const assert = require("node:assert");
const {
dedupePromotionCandidates,
promotionOptions,
promotionContent,
splitPromotionChoices,
} = require("../../tht-gate.js");
const { installMemoryGate } = require("../memory/index.js");
const { createFakePi } = require("./fake_pi_runtime.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"] },
];
const TABLE_CANDIDATE = {
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: [],
};
test("promotionOptions maps candidates to seq-keyed options", () => {
assert.deepEqual(promotionOptions(dedupePromotionCandidates(CANDIDATES)), [
const MEMORY_CANDIDATE = {
decision_seq: 5,
type: "concept_clarified",
subject: "paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "scelta dal reviewer",
question_context: "quante ablazioni nel 2023",
tables: [],
concepts: ["paziente attivo"],
};
test("the public Memory facade owns F8 policy and mutation ordering", async () => {
const { pi, tools, ctx } = createFakePi();
const calls = [];
let descriptor;
const duplicateMemory = { ...MEMORY_CANDIDATE, decision_seq: 6 };
const declinedMemory = {
...MEMORY_CANDIDATE,
decision_seq: 7,
subject: "ricovero indice",
detail: "first_event",
};
ctx.ui.input = async (title) => {
descriptor = JSON.parse(title);
calls.push(["review", descriptor.options.map((option) => option.id)]);
return JSON.stringify({ id: descriptor.id, choices: ["seq-5"] });
};
installMemoryGate(pi, {
workflow: {
activate: () => calls.push(["activate"]),
phase: () => ({ number: 8, id: "F8" }),
close: (_ctx, _session, phaseNumber, summary) => {
calls.push(["close", phaseNumber, summary]);
return null;
},
},
memory: {
execute: (_ctx, args) => {
calls.push(["memory-execute", args]);
return JSON.stringify([
TABLE_CANDIDATE,
MEMORY_CANDIDATE,
duplicateMemory,
declinedMemory,
]);
},
mutate: (_ctx, args, recovery) => {
calls.push(["memory-mutate", args, recovery]);
return null;
},
},
ledger: {
record: (_ctx, session, decision, recovery) => {
calls.push(["ledger", session, decision, recovery]);
return null;
},
},
waitForReviewer: async (runtimeContext, widget) => {
const response = await runtimeContext.ui.input(JSON.stringify(widget), "");
return JSON.parse(response);
},
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
});
const result = await tools.get("reviewer_memory_promote").def.execute(
"promote-via-facade",
{ session: "s1" },
null,
null,
ctx,
);
assert.deepEqual(descriptor.options, [
{
id: "seq-5",
label: "concept_clarified: paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "",
rationale: "scelta dal reviewer",
meta: { question_context: "quante ablazioni nel 2023" },
selected: true,
},
{
id: "seq-7",
label: "concept_clarified: ricovero indice",
detail: "first_event",
rationale: "scelta dal reviewer",
meta: { question_context: "quante ablazioni nel 2023" },
selected: true,
},
]);
});
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 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), []);
});
test("empty or missing choices declines everything", () => {
const candidates = dedupePromotionCandidates(CANDIDATES);
assert.equal(splitPromotionChoices(candidates, []).decline.length, 1);
assert.equal(splitPromotionChoices(candidates, undefined).decline.length, 1);
assert.deepEqual(descriptor.selected, ["seq-5", "seq-7"]);
assert.doesNotMatch(descriptor.content, /fact_seeablazione/);
assert.match(descriptor.content, /flag_attivo = TRUE/);
assert.deepEqual(calls.slice(0, 7), [
["activate"],
[
"memory-execute",
["promote", "--session", "s1", "--preview", "--json"],
],
["review", ["seq-5", "seq-7"]],
[
"memory-mutate",
["save-one", "--session", "s1", "--decision", "5", "--json"],
"Recupero manuale (umano): tht memory save-one --session s1 " +
"--decision 5. Finora salvate: 0.",
],
[
"ledger",
"s1",
{
type: "memory_promoted",
subject: "paziente attivo",
detail: "seq:5",
rationale: "scelta dal reviewer",
},
"Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: " +
"recupero manuale (umano) con tht decision add --session s1 " +
"--type memory_promoted --subject \"paziente attivo\" --detail seq:5.",
],
[
"ledger",
"s1",
{
type: "memory_promotion_declined",
subject: "ricovero indice",
detail: "seq:7",
},
"",
],
[
"close",
8,
"Promozione registrata: 1 memorie salvate nel vectordb, 1 candidati scartati.",
],
]);
assert.match(result.content[0].text, /1 memorie salvate.*1 candidati scartati/);
});
+210
View File
@@ -0,0 +1,210 @@
import { Type } from "typebox";
import { buildMultiselectRequest } from "../builders.js";
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
}
// The candidates come from `tht memory promote --preview --json` (deterministic,
// reviewer-approved decisions only); the model never authors them.
function dedupePromotionCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
if (candidate.type !== "concept_clarified") return false;
const key = [
candidate.type,
candidate.subject,
candidate.detail,
candidate.rationale,
candidate.question_context,
].map(normalizedText).join("\u0000");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function promotionOptions(candidates) {
return candidates.map((candidate) => ({
id: `seq-${candidate.decision_seq}`,
label: `${candidate.type}: ${candidate.subject}`,
detail: candidate.detail || "",
rationale: candidate.rationale || "",
meta: { question_context: candidate.question_context || "" },
}));
}
function promotionContent(candidates) {
return candidates
.map(
(candidate) =>
`- **${candidate.type}: ${candidate.subject}** (decisione #${candidate.decision_seq})\n` +
` ${candidate.detail || ""}\n` +
` Motivo: ${candidate.rationale || "—"}\n` +
` Domanda di contesto: ${candidate.question_context || "—"}`,
)
.join("\n");
}
function splitPromotionChoices(candidates, choices) {
const chosen = new Set(choices ?? []);
const promote = [];
const decline = [];
for (const candidate of candidates) {
(chosen.has(`seq-${candidate.decision_seq}`) ? promote : decline).push(candidate);
}
return { promote, decline };
}
/**
* Install the public F8 Memory tool against the narrow capability supplied by the
* workflow composition root. Memory owns candidate policy, presentation and mutation
* order; the capability keeps shared phase, ledger and persistence mechanisms in core.
*/
export function installMemoryGate(
pi,
{ workflow, memory, ledger, waitForReviewer, toTextResult },
) {
// Compatibility note: these labels move verbatim from the composition root.
// Ticket #22 requires byte-for-byte observable parity; translating existing chrome
// is a separate product behavior change rather than part of this extraction.
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: solo concept_clarified, " +
"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. Registrata la " +
"promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " +
"presentare un reviewer_confirm dopo.",
parameters: Type.Object({
session: Type.String(),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
workflow.activate();
try {
const { session } = params;
const phase = workflow.phase(ctx, session);
let candidates;
try {
candidates = JSON.parse(memory.execute(
ctx,
["promote", "--session", session, "--preview", "--json"],
));
} catch (error) {
const message = (error.stderr || error.message || String(error)).toString().trim();
return toTextResult(`Preview di promozione non disponibile: ${message}`);
}
if (!Array.isArray(candidates) || candidates.length === 0) {
await ctx.ui.notify(
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
"info",
);
const closed = workflow.close(
ctx, session, phase.number, "Nessun candidato di promozione.",
);
if (closed) return closed;
return toTextResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
);
}
candidates = dedupePromotionCandidates(candidates);
if (candidates.length === 0) {
await ctx.ui.notify(
"Nessun concetto chiarito da salvare come memory per questa sessione.",
"info",
);
const closed = workflow.close(
ctx, session, phase.number, "Nessun candidato di promozione.",
);
if (closed) return closed;
return toTextResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
);
}
const options = promotionOptions(candidates);
const widget = buildMultiselectRequest({
id: `u${Date.now()}`,
phase: phase.id,
title: "Quali concetti chiariti salvare nella memoria riutilizzabile?",
allowEmpty: true,
options,
selected: options.map((option) => option.id),
content: promotionContent(candidates),
});
const response = await waitForReviewer(ctx, widget);
if (response.control === "freetext") {
return toTextResult(
`Altro (reviewer): ${response.text}. Valuta e ripresenta il gate.`,
);
}
if (response.control === "back") {
return toTextResult("Il reviewer vuole tornare indietro.");
}
if (response.control === "exit") {
return toTextResult("Il reviewer vuole uscire.");
}
const { promote, decline } = splitPromotionChoices(candidates, response.choices);
let saved = 0;
for (const candidate of promote) {
const saveError = memory.mutate(
ctx,
[
"save-one", "--session", session,
"--decision", String(candidate.decision_seq), "--json",
],
`Recupero manuale (umano): tht memory save-one --session ${session} ` +
`--decision ${candidate.decision_seq}. Finora salvate: ${saved}.`,
);
if (saveError) return saveError;
const ledgerError = ledger.record(
ctx,
session,
{
type: "memory_promoted",
subject: candidate.subject,
detail: `seq:${candidate.decision_seq}`,
rationale: candidate.rationale || candidate.detail || "",
},
"Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: " +
`recupero manuale (umano) con tht decision add --session ${session} ` +
`--type memory_promoted --subject "${candidate.subject}" ` +
`--detail seq:${candidate.decision_seq}.`,
);
if (ledgerError) return ledgerError;
saved++;
}
for (const candidate of decline) {
const ledgerError = ledger.record(ctx, session, {
type: "memory_promotion_declined",
subject: candidate.subject,
detail: `seq:${candidate.decision_seq}`,
}, "");
if (ledgerError) return ledgerError;
}
const summary =
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
`${decline.length} candidati scartati.`;
const closed = workflow.close(ctx, session, phase.number, summary);
if (closed) return closed;
return toTextResult(summary);
} catch (fatal) {
const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return toTextResult(
`[reviewer_memory_promote ERRORE INTERNO] ${message}. ` +
"Riprova o usa un approccio diverso.",
);
}
},
});
}
+26 -163
View File
@@ -18,10 +18,8 @@
// auto-advance not ready -> silent no-op)
// - textResult / tht() / relayIfThtFails / advanceIfReady helpers
//
// TESTING: the pure builders are L1-tested (./gate/__tests__/). This file is the
// GLUE -- it depends on the Pi runtime (pi.on, pi.registerTool, ctx.sendRaw) and is
// verified end-to-end at L2 (Task D4). It is intentionally NOT unit-tested here; a
// fake-Pi runtime mock (cross-cutting follow-up) would let it run in CI.
// TESTING: pure builders and domain modules are L1-tested under ./gate/__tests__/.
// The composition root is exercised through the fake Pi runtime and live at L2.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
@@ -46,6 +44,7 @@ import {
enrichPhaseSummaryV2,
appendLedgerSection,
} from "./gate/enrich.js";
import { installMemoryGate } from "./gate/memory/index.js";
import { isReserved } from "./reserved-labels.mjs";
// Load the workflow contract once when Pi loads the extension. Asking the model to
@@ -625,58 +624,6 @@ export function normalizeMemoryOptions(options) {
return out;
}
// --- 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 dedupePromotionCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
if (candidate.type !== "concept_clarified") return false;
const key = [
candidate.type,
candidate.subject,
candidate.detail,
candidate.rationale,
candidate.question_context,
].map(normalizedText).join("\u0000");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function promotionOptions(candidates) {
return candidates.map((c) => ({
id: `seq-${c.decision_seq}`,
label: `${c.type}: ${c.subject}`,
detail: c.detail || "",
rationale: c.rationale || "",
meta: { question_context: c.question_context || "" },
}));
}
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), "");
@@ -1687,114 +1634,30 @@ 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: solo concept_clarified, " +
"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. Registrata la " +
"promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " +
"presentare un reviewer_confirm dopo.",
parameters: Type.Object({
session: Type.String(),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
try {
const { session } = params;
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
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",
);
const closed = closeAfterPromotion(
ctx, session, curNum, "Nessun candidato di promozione.",
);
if (closed) return closed;
return textResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
);
}
candidates = dedupePromotionCandidates(candidates);
if (candidates.length === 0) {
await ctx.ui.notify(
"Nessun concetto chiarito da salvare come memory per questa sessione.",
"info",
);
const closed = closeAfterPromotion(
ctx, session, curNum, "Nessun candidato di promozione.",
);
if (closed) return closed;
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 concetti chiariti 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"],
`Recupero manuale (umano): tht memory save-one --session ${session} --decision ${c.decision_seq}. Finora salvate: ${saved}.`,
);
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 || "",
}), `Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: recupero manuale (umano) con tht decision add --session ${session} --type memory_promoted --subject "${c.subject}" --detail seq:${c.decision_seq}.`);
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;
}
const summary =
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
`${decline.length} candidati scartati.`;
const closed = closeAfterPromotion(ctx, session, curNum, summary);
if (closed) return closed;
return textResult(summary);
} catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return textResult(`[reviewer_memory_promote ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);
}
installMemoryGate(pi, {
workflow: {
activate: () => {
lockActive = true;
},
phase: (ctx, session) => {
const number = currentPhase(ctx, session);
return { number, id: phaseId(ctx, number) };
},
close: closeAfterPromotion,
},
memory: {
execute: (ctx, args) => tht(ctx, ["memory", ...args]),
mutate: (ctx, args, recovery) => relayIfThtFails(
ctx, ["memory", ...args], recovery,
),
},
ledger: {
record: (ctx, session, decision, recovery) => relayIfThtFails(
ctx, decisionAddArgs(session, decision), recovery,
),
},
waitForReviewer: emitAndWait,
toTextResult: textResult,
});
pi.registerTool({