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