refactor(memory): extract F2 recall path (#23)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user