refactor(memory): extract F8 promotion gate (#22)
This commit is contained in:
@@ -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.",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user