refactor(memory): extract F2 recall path (#23)

This commit is contained in:
2026-08-24 01:08:15 +02:00
parent beac2e80f4
commit 93fe0d733b
8 changed files with 585 additions and 236 deletions
@@ -1,16 +0,0 @@
const test = require("node:test");
const assert = require("node:assert");
const { shouldSkipEmptyDecide } = require("../../tht-gate.js");
test("skips the widget only when empty AND allow_empty AND advance", () => {
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: true, advance: true }), true);
});
test("does not skip when there are merito options", () => {
assert.equal(shouldSkipEmptyDecide({ meritCount: 2, allowEmpty: true, advance: true }), false);
});
test("does not skip when allow_empty is false or advance is false", () => {
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: false, advance: true }), false);
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: true, advance: false }), false);
});
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert");
const { installMemoryGate } = require("../memory/index.js");
const { createMemoryGate } = require("../memory/index.js");
const { createFakePi } = require("./fake_pi_runtime.js");
@@ -44,7 +44,7 @@ test("the public Memory facade owns F8 policy and mutation ordering", async () =
return JSON.stringify({ id: descriptor.id, choices: ["seq-5"] });
};
installMemoryGate(pi, {
const memoryGate = createMemoryGate({
workflow: {
activate: () => calls.push(["activate"]),
phase: () => ({ number: 8, id: "F8" }),
@@ -80,6 +80,7 @@ test("the public Memory facade owns F8 policy and mutation ordering", async () =
},
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
});
memoryGate.install(pi);
const result = await tools.get("reviewer_memory_promote").def.execute(
"promote-via-facade",
@@ -1,94 +1,180 @@
const test = require("node:test");
const assert = require("node:assert");
const path = require("node:path");
const {
memorySelectionWidgetProps,
normalizeMemoryOptions,
} = require("../../tht-gate.js");
const { createMemoryGate } = require("../memory/index.js");
const { createFakePi } = require("./fake_pi_runtime.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(
memorySelectionWidgetProps([
{ id: "recommended", label: "Memory A", recommended: true },
{ id: "optional", label: "Memory B" },
]),
{
title: "Seleziona le memory da applicare alla domanda",
selected: ["recommended"],
selectionLabel: "memory da applicare",
confirmLabel: "Applica le memory selezionate",
const MEMORY_OPTIONS = [
{
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",
},
},
{
id: "mem-0043",
label: "Tabella promossa",
description: "fact_pazienti",
decision: {
type: "table_promoted",
subject: "fact_pazienti",
detail: "tabella principale",
},
},
];
test("F2 preserves the exact memory content and proposes each memory id once", () => {
const options = normalizeMemoryOptions([
function setupRecall(choices) {
const { pi, tools, ctx } = createFakePi();
const calls = [];
let descriptor;
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 });
};
const memoryGate = createMemoryGate({
workflow: {
activate: () => calls.push(["activate"]),
phase: () => ({ number: 8, id: "F8" }),
advance: () => {
calls.push(["advance"]);
return { advanced: choices.length === 0 };
},
close: () => null,
},
memory: {
execute: () => "[]",
mutate: () => null,
},
ledger: {
validate: () => null,
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 }] }),
});
memoryGate.install(pi);
return { ctx, tools, calls, memoryGate, descriptor: () => descriptor };
}
test("the Memory facade normalizes search hits and applies only the selected F2 memory", async () => {
const { ctx, calls, memoryGate, descriptor } = setupRecall(["first"]);
const result = await memoryGate.reviewRecall(ctx, {
session: "s1",
title: "Memorie candidate",
options: MEMORY_OPTIONS,
allow_empty: true,
advance: true,
}, "F2");
assert.deepEqual(descriptor().options, [
{
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",
},
detail: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
rationale: "Riusa mem-0042 per la stessa definizione",
meta: { memory_id: "mem-0042" },
selected: true,
},
]);
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(calls, [
["review", ["first"]],
["ledger", "s1", MEMORY_OPTIONS[0].decision, ""],
["advance"],
]);
assert.match(result.content[0].text, /1 decisioni.*La fase resta aperta/s);
});
assert.deepEqual(options.map((option) => option.id), ["mem-0042"]);
test("the Memory facade accepts a deselected F2 result without a rejection", async () => {
const { ctx, calls, memoryGate } = setupRecall([]);
const result = await memoryGate.reviewRecall(ctx, {
session: "s1",
title: "Memorie candidate",
options: [MEMORY_OPTIONS[0]],
allow_empty: true,
advance: true,
}, "F2");
assert.deepEqual(calls, [["review", ["first"]], ["advance"]]);
assert.match(result.content[0].text, /Nessuna decisione registrata.*Fase avanzata/s);
});
test("the Memory facade skips an absent F2 result and advances without a widget", async () => {
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
const result = await memoryGate.reviewRecall(ctx, {
session: "s1",
title: "Memorie candidate",
options: [],
allow_empty: true,
advance: true,
}, "F2");
assert.equal(descriptor(), undefined);
assert.deepEqual(calls, [["advance"]]);
assert.deepEqual(ctx.notifications.map(({ level }) => level), ["info"]);
assert.match(result.content[0].text, /Fase memoria vuota/);
});
test("the Memory facade does not skip an absent F2 result when empty is forbidden", async () => {
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
const result = await memoryGate.reviewRecall(ctx, {
session: "s1",
title: "Memorie candidate",
options: [],
allow_empty: false,
advance: true,
}, "F2");
assert.equal(descriptor(), undefined);
assert.deepEqual(calls, []);
assert.match(result.content[0].text, /ERRORE INTERNO/);
});
test("the Memory facade keeps an absent F2 result open when advance is disabled", async () => {
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
const result = await memoryGate.reviewRecall(ctx, {
session: "s1",
title: "Memorie candidate",
options: [],
allow_empty: true,
advance: false,
}, "F2");
assert.deepEqual(descriptor().options, []);
assert.deepEqual(calls, [["review", []]]);
assert.match(result.content[0].text, /Nessuna decisione registrata/);
});
+164 -4
View File
@@ -1,13 +1,168 @@
import { Type } from "typebox";
import { buildMultiselectRequest } from "../builders.js";
import { isReserved } from "../../reserved-labels.mjs";
// Compatibility note: reviewer labels move verbatim from the composition root.
// Tickets #22 and #23 require observable parity; translating existing chrome is a
// separate product behavior change rather than part of these extractions.
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
}
const MEMORY_ID_RE = /\bmem-\d{4,}\b/i;
function memoryOptionKey(option) {
const searchable = [
option.id,
option.label,
option.description,
option.decision?.rationale,
].join(" ");
const memoryId = searchable.match(MEMORY_ID_RE)?.[0]?.toLowerCase();
if (memoryId) return `id:${memoryId}`;
return [
"content",
normalizedText(option.decision?.type),
normalizedText(option.decision?.subject),
normalizedText(option.decision?.detail),
].join(":");
}
function normalizeMemoryOptions(options) {
const seen = new Set();
const out = [];
for (const option of options) {
if (option.decision?.type !== "concept_clarified") continue;
const key = memoryOptionKey(option);
if (seen.has(key)) continue;
seen.add(key);
const memoryId = [
option.id,
option.label,
option.description,
option.decision?.rationale,
].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
const fallbackDetail = [
option.decision?.type && option.decision?.subject
? `Decisione ${option.decision.type}: ${option.decision.subject}`
: "",
option.decision?.detail ?? "",
].filter(Boolean).join("\n");
out.push({
...option,
detail: option.description?.trim() || fallbackDetail,
rationale: option.decision?.rationale ?? "",
...(memoryId ? { meta: { memory_id: memoryId } } : {}),
});
}
return out;
}
function memorySelectionWidgetProps(options) {
return {
title: "Seleziona le memory da applicare alla domanda",
selected: options.filter((option) => option.recommended).map((option) => option.id),
selectionLabel: "memory da applicare",
confirmLabel: "Applica le memory selezionate",
};
}
function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
return meritCount === 0 && !!allowEmpty && !!advance;
}
async function reviewRecall(ctx, params, phase, dependencies) {
const { workflow, ledger, waitForReviewer, toTextResult } = dependencies;
try {
const { session, advance } = params;
const options = normalizeMemoryOptions(params.options);
const typeError = ledger.validate(ctx, options, session);
if (typeError) return toTextResult(typeError);
const meritOptions = options
.filter((option) => !isReserved(option.label))
.map((option) => ({
id: option.id,
label: option.label,
...(option.detail ? { detail: option.detail } : {}),
...(option.rationale ? { rationale: option.rationale } : {}),
...(option.meta ? { meta: option.meta } : {}),
}));
if (shouldSkipEmptyRecall({
meritCount: meritOptions.length,
allowEmpty: params.allow_empty ?? false,
advance,
})) {
await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
"info",
);
workflow.advance(ctx, session);
return toTextResult(
"Fase memoria vuota: nessuna decisione da registrare, " +
"avanzamento automatico alla fase successiva.",
);
}
const widget = buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...memorySelectionWidgetProps(options),
});
const response = await waitForReviewer(ctx, widget);
if (response.control === "freetext") {
return toTextResult(
`Altro (reviewer): ${response.text}. Riformula la proposta tenendo conto.`,
);
}
if (response.control === "back") {
return toTextResult("Il reviewer vuole tornare indietro.");
}
if (response.control === "exit") {
return toTextResult("Il reviewer vuole uscire.");
}
const chosen = options.filter((option) =>
(response.choices ?? []).includes(option.id));
const recorded = [];
for (const choice of chosen) {
const error = ledger.record(ctx, session, choice.decision, "");
if (error) return error;
recorded.push(choice.decision);
}
if (!recorded.length) {
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
const message =
"Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
return toTextResult(
result.advanced ? message + " Fase avanzata automaticamente." : message,
);
}
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
const parts = [
`Registrate ${recorded.length} decisioni: ` +
recorded.map((decision) => decision.type).join(", ") + ".",
];
parts.push(result.advanced
? "Fase avanzata automaticamente."
: 'La fase resta aperta: chiudila con reviewer_confirm kind:"phase" quando pronta.');
return toTextResult(parts.join(" "));
} catch (fatal) {
const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return toTextResult(
`[reviewer_decide ERRORE INTERNO] ${message}. Riprova o usa un approccio diverso.`,
);
}
}
// The candidates come from `tht memory promote --preview --json` (deterministic,
// reviewer-approved decisions only); the model never authors them.
function dedupePromotionCandidates(candidates) {
@@ -68,13 +223,10 @@ function splitPromotionChoices(candidates, choices) {
* 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(
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)",
@@ -208,3 +360,11 @@ export function installMemoryGate(
},
});
}
export function createMemoryGate(dependencies) {
return {
install: (pi) => installMemoryGate(pi, dependencies),
reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies),
};
}