fix: harden reviewer workflow and memory handling

This commit is contained in:
2026-07-23 12:34:23 +02:00
parent 00761ae2ca
commit 694b7dd21f
23 changed files with 728 additions and 75 deletions
+185 -8
View File
@@ -573,13 +573,85 @@ export function memorySelectionWidgetProps(options) {
};
}
const MEMORY_ID_RE = /\bmem-\d{4,}\b/i;
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
}
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(":");
}
// F2 is model-orchestrated, so normalize at the trust boundary: retain the exact
// retrieved memory text for the reviewer and collapse repeated representations of
// the same memory id/content before rendering or persisting a choice.
export 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;
}
// --- 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 || "" },
}));
}
@@ -795,7 +867,7 @@ export default function (pi) {
}
});
// --- the four reviewer tools (widget-descriptor emit + await) ---------------
// --- reviewer tools (widget-descriptor emit + await) -------------------------
pi.registerTool({
name: "reviewer_select",
@@ -881,6 +953,87 @@ export default function (pi) {
},
});
pi.registerTool({
name: "reviewer_datamart",
label: "Scelta datamart (reviewer)",
description:
"F8: gestisce deterministicamente la scelta di generazione del datamart. " +
"Con THT_PROFILE=workstation non mostra alcun widget e registra automaticamente " +
"datamart_declined; con profile=server mostra sempre entrambe le opzioni Si/No e " +
"registra la decisione scelta. Dopo questo tool prosegui con reviewer_memory_promote.",
parameters: Type.Object({
session: Type.String(),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
try {
const { session } = params;
const curNum = currentPhase(ctx, session);
if (curNum !== 8) {
return textResult(
`La scelta datamart e' disponibile solo in Fase 8 (fase corrente: ${curNum}).`,
);
}
const subject = "phase:8";
if (process.env.THT_PROFILE === "workstation") {
const decision = { type: "datamart_declined", subject };
const err = relayIfThtFails(ctx, decisionAddArgs(session, decision), "");
if (err) return err;
return textResult(
"Profilo workstation: datamart saltato automaticamente " +
"e decisione datamart_declined registrata. Invoca ora reviewer_memory_promote.",
);
}
const options = [
{
id: "generate",
label: "Sì, genera il datamart",
decision: { type: "datamart_requested", subject },
},
{
id: "skip",
label: "No, salta il datamart",
decision: { type: "datamart_declined", subject },
recommended: true,
},
];
const widget = buildSelectRequest({
id: `u${Date.now()}`,
phase: phaseId(ctx, curNum),
title: "Vuoi generare un datamart?",
intro: null,
recommended: "skip",
options: options.map((option) => ({ id: option.id, label: option.label })),
});
const resp = await emitAndWait(ctx, widget);
const outcome = resolveSelectOutcome(options, resp);
if (outcome.kind === "freetext")
return textResult(`Altro (reviewer): ${outcome.text}`);
if (outcome.kind === "back")
return textResult("Il reviewer vuole tornare indietro.");
if (outcome.kind === "exit")
return textResult("Il reviewer vuole uscire.");
if (outcome.kind !== "decision")
return textResult("Scelta datamart non valida: ripresenta reviewer_datamart.");
const err = relayIfThtFails(
ctx,
decisionAddArgs(session, outcome.decision),
"",
);
if (err) return err;
return textResult(
decisionRecordedResultText(outcome.decision, outcome.option),
);
} catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return textResult(
`[reviewer_datamart ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`,
);
}
},
});
pi.registerTool({
name: "reviewer_decide",
label: "Decisione di merito (reviewer)",
@@ -895,6 +1048,7 @@ export default function (pi) {
Type.Object({
id: Type.String(),
label: Type.String(),
description: Type.Optional(Type.String()),
decision: Type.Object({
type: Type.String(),
subject: Type.String(),
@@ -911,14 +1065,23 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
try {
const { session, title, options: opts, advance } = params;
const { session, title, advance } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
const opts = phase === "F2"
? normalizeMemoryOptions(params.options)
: params.options;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session));
const toAdd = [];
const meritOptions = opts
.filter((o) => !isReserved(o.label))
.map((o) => ({ id: o.id, label: o.label }));
.map((o) => ({
id: o.id,
label: o.label,
...(o.detail ? { detail: o.detail } : {}),
...(o.rationale ? { rationale: o.rationale } : {}),
...(o.meta ? { meta: o.meta } : {}),
}));
const joinOnly = meritOptions.length > 0 && opts
.filter((o) => !isReserved(o.label))
.every((o) => o.decision.type === "join_modified");
@@ -1084,7 +1247,7 @@ export default function (pi) {
id: t.id,
name: t.name,
kind: t.kind,
recommended: t.recommended ?? true,
recommended: t.kind === "promote" ? (t.recommended ?? true) : false,
description: cat.description ?? "",
rationale: t.rationale ?? "",
columns: cat.columns.map((c) => ({ ...c, suggested: suggested.has(c.name) })),
@@ -1529,8 +1692,8 @@ export default function (pi) {
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: tipi riusabili concept_clarified/" +
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
"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 " +
@@ -1567,11 +1730,25 @@ export default function (pi) {
"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 decisioni salvare nella memoria riutilizzabile?",
title: "Quali concetti chiariti salvare nella memoria riutilizzabile?",
allowEmpty: true,
options,
selected: options.map((o) => o.id),