refactor(memory): extract F8 promotion gate (#22)
This commit is contained in:
@@ -1,67 +1,156 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const {
|
const { installMemoryGate } = require("../memory/index.js");
|
||||||
dedupePromotionCandidates,
|
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||||
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 = [
|
const TABLE_CANDIDATE = {
|
||||||
{ decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione",
|
decision_seq: 3,
|
||||||
detail: "tabella principale ablazioni", rationale: "scelta dal reviewer",
|
type: "table_promoted",
|
||||||
question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] },
|
subject: "fact_seeablazione",
|
||||||
{ decision_seq: 5, type: "concept_clarified", subject: "paziente attivo",
|
detail: "tabella principale ablazioni",
|
||||||
detail: "flag_attivo = TRUE", rationale: "",
|
rationale: "scelta dal reviewer",
|
||||||
question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] },
|
question_context: "quante ablazioni nel 2023",
|
||||||
];
|
tables: ["fact_seeablazione"],
|
||||||
|
concepts: [],
|
||||||
|
};
|
||||||
|
|
||||||
test("promotionOptions maps candidates to seq-keyed options", () => {
|
const MEMORY_CANDIDATE = {
|
||||||
assert.deepEqual(promotionOptions(dedupePromotionCandidates(CANDIDATES)), [
|
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",
|
id: "seq-5",
|
||||||
label: "concept_clarified: paziente attivo",
|
label: "concept_clarified: paziente attivo",
|
||||||
detail: "flag_attivo = TRUE",
|
detail: "flag_attivo = TRUE",
|
||||||
rationale: "",
|
rationale: "scelta dal reviewer",
|
||||||
meta: { question_context: "quante ablazioni nel 2023" },
|
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,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
assert.deepEqual(descriptor.selected, ["seq-5", "seq-7"]);
|
||||||
|
assert.doesNotMatch(descriptor.content, /fact_seeablazione/);
|
||||||
test("F8 proposes semantically identical memory content only once", () => {
|
assert.match(descriptor.content, /flag_attivo = TRUE/);
|
||||||
const duplicate = { ...CANDIDATES[0], decision_seq: 9 };
|
assert.deepEqual(calls.slice(0, 7), [
|
||||||
assert.deepEqual(
|
["activate"],
|
||||||
dedupePromotionCandidates([CANDIDATES[0], duplicate, CANDIDATES[1]])
|
[
|
||||||
.map((candidate) => candidate.decision_seq),
|
"memory-execute",
|
||||||
[5],
|
["promote", "--session", "s1", "--preview", "--json"],
|
||||||
);
|
],
|
||||||
});
|
["review", ["seq-5", "seq-7"]],
|
||||||
|
[
|
||||||
test("F8 never proposes promoted tables as memory", () => {
|
"memory-mutate",
|
||||||
const candidates = dedupePromotionCandidates(CANDIDATES);
|
["save-one", "--session", "s1", "--decision", "5", "--json"],
|
||||||
const options = promotionOptions(candidates);
|
"Recupero manuale (umano): tht memory save-one --session s1 " +
|
||||||
const c = promotionContent(candidates);
|
"--decision 5. Finora salvate: 0.",
|
||||||
|
],
|
||||||
assert.deepEqual(candidates.map((candidate) => candidate.type), ["concept_clarified"]);
|
[
|
||||||
assert.deepEqual(options.map((option) => option.id), ["seq-5"]);
|
"ledger",
|
||||||
assert.ok(!c.includes("fact_seeablazione"));
|
"s1",
|
||||||
assert.ok(c.includes("flag_attivo = TRUE"));
|
{
|
||||||
assert.ok(c.includes("quante ablazioni nel 2023"));
|
type: "memory_promoted",
|
||||||
});
|
subject: "paziente attivo",
|
||||||
|
detail: "seq:5",
|
||||||
test("splitPromotionChoices partitions by selection", () => {
|
rationale: "scelta dal reviewer",
|
||||||
const candidates = dedupePromotionCandidates(CANDIDATES);
|
},
|
||||||
const { promote, decline } = splitPromotionChoices(candidates, ["seq-5"]);
|
"Memoria salvata nel vectordb ma decisione memory_promoted NON registrata: " +
|
||||||
assert.deepEqual(promote.map((c) => c.decision_seq), [5]);
|
"recupero manuale (umano) con tht decision add --session s1 " +
|
||||||
assert.deepEqual(decline.map((c) => c.decision_seq), []);
|
"--type memory_promoted --subject \"paziente attivo\" --detail seq:5.",
|
||||||
});
|
],
|
||||||
|
[
|
||||||
test("empty or missing choices declines everything", () => {
|
"ledger",
|
||||||
const candidates = dedupePromotionCandidates(CANDIDATES);
|
"s1",
|
||||||
assert.equal(splitPromotionChoices(candidates, []).decline.length, 1);
|
{
|
||||||
assert.equal(splitPromotionChoices(candidates, undefined).decline.length, 1);
|
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)
|
// auto-advance not ready -> silent no-op)
|
||||||
// - textResult / tht() / relayIfThtFails / advanceIfReady helpers
|
// - textResult / tht() / relayIfThtFails / advanceIfReady helpers
|
||||||
//
|
//
|
||||||
// TESTING: the pure builders are L1-tested (./gate/__tests__/). This file is the
|
// TESTING: pure builders and domain modules are L1-tested under ./gate/__tests__/.
|
||||||
// GLUE -- it depends on the Pi runtime (pi.on, pi.registerTool, ctx.sendRaw) and is
|
// The composition root is exercised through the fake Pi runtime and live at L2.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
@@ -46,6 +44,7 @@ import {
|
|||||||
enrichPhaseSummaryV2,
|
enrichPhaseSummaryV2,
|
||||||
appendLedgerSection,
|
appendLedgerSection,
|
||||||
} from "./gate/enrich.js";
|
} from "./gate/enrich.js";
|
||||||
|
import { installMemoryGate } from "./gate/memory/index.js";
|
||||||
import { isReserved } from "./reserved-labels.mjs";
|
import { isReserved } from "./reserved-labels.mjs";
|
||||||
|
|
||||||
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
||||||
@@ -625,58 +624,6 @@ export function normalizeMemoryOptions(options) {
|
|||||||
return out;
|
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) {
|
export async function emitAndWait(ctx, descriptor) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||||
@@ -1687,114 +1634,30 @@ export default function (pi) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pi.registerTool({
|
installMemoryGate(pi, {
|
||||||
name: "reviewer_memory_promote",
|
workflow: {
|
||||||
label: "Promozione memorie riusabili (reviewer)",
|
activate: () => {
|
||||||
description:
|
lockActive = true;
|
||||||
"F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " +
|
},
|
||||||
"calcolati dalla CLI (tht memory promote --preview: solo concept_clarified, " +
|
phase: (ctx, session) => {
|
||||||
"max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
|
const number = currentPhase(ctx, session);
|
||||||
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
|
return { number, id: phaseId(ctx, number) };
|
||||||
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
|
},
|
||||||
"alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " +
|
close: closeAfterPromotion,
|
||||||
"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.`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
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({
|
pi.registerTool({
|
||||||
|
|||||||
Reference in New Issue
Block a user