refactor(disambiguation): own F1 clarification policy (#27)

This commit is contained in:
2026-08-24 01:55:42 +02:00
parent 2ed55ef131
commit 1e459b073e
3 changed files with 327 additions and 34 deletions
@@ -0,0 +1,203 @@
const test = require("node:test");
const assert = require("node:assert");
const { createDisambiguationGate } = require("../disambiguation/index.js");
function setupClarification({
phase = 1,
knownTypes = new Set(["concept_clarified", "ambiguity_open", "value_grounded"]),
minimumPhases = {
concept_clarified: 1,
ambiguity_open: 1,
value_grounded: 3,
},
} = {}) {
const calls = [];
const gate = createDisambiguationGate({
workflow: {
phase: (_ctx, session) => {
calls.push(["phase", session]);
return { number: phase };
},
describe: (_ctx, session) => {
calls.push(["describe", session]);
return "F1";
},
decisionTypes: () => {
calls.push(["decisionTypes"]);
return knownTypes;
},
decisionMinimumPhases: () => {
calls.push(["decisionMinimumPhases"]);
return minimumPhases;
},
advance: () => ({ advanced: false }),
},
session: { mutate: () => null },
ledger: {
record: () => null,
},
reviewer: {
buildSelect: (input) => {
calls.push(["build", input]);
return { type: "ui_request", widget: "select", ...input };
},
isReserved: (label) => ["Altro", "Other"].includes(label),
},
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
});
return { gate, calls };
}
const OPTIONS = [
{
id: "procedure",
label: "Procedura clinica",
recommended: true,
decision: {
type: "concept_clarified",
subject: "ablazione",
detail: "procedura clinica",
rationale: "scelta dal reviewer",
},
},
{ id: "ask", label: "Serve un altro chiarimento" },
{ id: "other", label: "Altro" },
];
test("Disambiguation normalizes stringified F1 options", () => {
const { gate } = setupClarification();
assert.deepEqual(
gate.prepareClarificationArguments({ session: "s1", options: JSON.stringify(OPTIONS) }),
{ session: "s1", options: OPTIONS },
);
assert.deepEqual(
gate.prepareClarificationArguments({ session: "s1", options: "not-json" }),
{ session: "s1", options: "not-json" },
);
});
test("Disambiguation validates and presents F1 clarification options", () => {
const { gate, calls } = setupClarification();
const prepared = gate.prepareClarification({}, {
session: "s1",
title: "Che cosa significa ablazione?",
intro: "Una sola interpretazione può essere corretta.",
options: OPTIONS,
advance: true,
}, "u1");
assert.deepEqual(calls.map(([kind]) => kind), [
"decisionTypes", "phase", "decisionMinimumPhases", "describe", "build",
]);
assert.deepEqual(prepared, {
session: "s1",
options: OPTIONS,
advance: true,
widget: {
type: "ui_request",
widget: "select",
id: "u1",
phase: "F1",
title: "Che cosa significa ablazione?",
intro: "Una sola interpretazione può essere corretta.",
recommended: "procedure",
options: [
{ id: "procedure", label: "Procedura clinica" },
{ id: "ask", label: "Serve un altro chiarimento" },
],
},
});
});
test("Disambiguation rejects an unknown F1 decision type before presentation", () => {
const { gate, calls } = setupClarification();
const invalidOptions = [{
id: "invalid",
label: "Invalida",
decision: { type: "not_a_decision", subject: "x" },
}];
const prepared = gate.prepareClarification({}, {
session: "s1",
title: "Domanda",
options: invalidOptions,
}, "u1");
assert.deepEqual(calls.map(([kind]) => kind), ["decisionTypes"]);
assert.equal(
prepared.result.content[0].text,
"Tipo di decisione 'not_a_decision' non valido. Tipi ammessi: " +
"concept_clarified, ambiguity_open, value_grounded. Correggi e riprova.",
);
});
test("Disambiguation rejects a decision that belongs to a later phase", () => {
const { gate, calls } = setupClarification();
const futureOptions = [{
id: "grounded",
label: "Valore verificato",
decision: { type: "value_grounded", subject: "stato" },
}];
const prepared = gate.prepareClarification({}, {
session: "s1",
title: "Domanda",
options: futureOptions,
}, "u1");
assert.deepEqual(calls.map(([kind]) => kind), [
"decisionTypes", "phase", "decisionMinimumPhases",
]);
assert.equal(
prepared.result.content[0].text,
"Tipo 'value_grounded' ammesso dalla Fase 3, sessione alla Fase 1. " +
"Chiudi prima la fase corrente.",
);
});
test("Disambiguation translates F1 choices into domain decisions or clarification requests", () => {
const { gate } = setupClarification();
const prepared = { options: OPTIONS };
const decision = gate.resolveClarification(prepared, { choices: ["procedure"] });
assert.deepEqual(decision.decision, OPTIONS[0].decision);
assert.match(decision.text, /Decisione registrata \(concept_clarified\): Procedura clinica\./);
assert.match(decision.text, /tht session show/);
assert.deepEqual(
gate.resolveClarification(prepared, { choices: ["ask"] }),
{ text: "Scelta del reviewer: Serve un altro chiarimento" },
);
assert.deepEqual(
gate.resolveClarification(prepared, { choices: ["missing"] }),
{ text: "Scelta del reviewer: missing" },
);
});
test("Disambiguation never translates F1 control responses into decisions", () => {
const { gate } = setupClarification();
const prepared = { options: OPTIONS };
assert.deepEqual(
gate.resolveClarification(prepared, { control: "freetext", text: "un altro significato" }),
{ text: "Altro (reviewer): un altro significato" },
);
assert.deepEqual(
gate.resolveClarification(prepared, { control: "back" }),
{ text: "Il reviewer vuole tornare indietro." },
);
assert.deepEqual(
gate.resolveClarification(prepared, { control: "exit" }),
{ text: "Il reviewer vuole uscire." },
);
});
@@ -14,6 +14,108 @@ function normalizedAssumptions(assumptions) {
}
function prepareClarificationArguments(input) {
if (!input || typeof input !== "object") return input;
const args = { ...input };
if (typeof args.options === "string") {
try {
const parsed = JSON.parse(args.options);
if (Array.isArray(parsed)) args.options = parsed;
} catch {
// Preserve non-JSON input for the TypeBox/tool validation path.
}
}
return args;
}
function decisionRecordedText(decision, option) {
return (
`Decisione registrata (${decision.type}): ${option.label}. ` +
"Ora rileggi lo stato persistito con `tht session show` e invoca immediatamente " +
"il prossimo tool reviewer_ richiesto dal workflow. " +
"Non scrivere analisi o spiegazioni visibili."
);
}
function validateClarificationOptions(ctx, options, session, workflow) {
const known = workflow.decisionTypes(ctx);
if (!known) return null;
for (const option of options) {
if (option.decision && !known.has(option.decision.type)) {
return (
`Tipo di decisione '${option.decision.type}' non valido. Tipi ammessi: ` +
`${[...known].join(", ")}. Correggi e riprova.`
);
}
}
const current = workflow.phase(ctx, session).number;
const minimumPhases = workflow.decisionMinimumPhases(ctx);
for (const option of options) {
const type = option.decision?.type;
if (type && minimumPhases[type] && minimumPhases[type] > current) {
return (
`Tipo '${type}' ammesso dalla Fase ${minimumPhases[type]}, ` +
`sessione alla Fase ${current}. Chiudi prima la fase corrente.`
);
}
}
return null;
}
function prepareClarification(
ctx,
params,
id,
{ workflow, reviewer, toTextResult },
) {
const { session, title, options, intro, advance } = params;
const validationError = validateClarificationOptions(ctx, options, session, workflow);
if (validationError) return { result: toTextResult(validationError) };
const phase = workflow.describe(ctx, session);
const recommended = options.find((option) => option.recommended)?.id ?? null;
return {
session,
options,
advance,
widget: reviewer.buildSelect({
id,
phase,
title,
intro: intro ?? null,
recommended,
options: options
.filter((option) => !reviewer.isReserved(option.label))
.map((option) => ({ id: option.id, label: option.label })),
}),
};
}
function resolveClarification(prepared, response) {
if (response?.control === "freetext") {
return { text: `Altro (reviewer): ${response.text}` };
}
if (response?.control === "back") {
return { text: "Il reviewer vuole tornare indietro." };
}
if (response?.control === "exit") {
return { text: "Il reviewer vuole uscire." };
}
const choice = Array.isArray(response?.choices) ? response.choices[0] : response?.choice;
const option = (prepared.options || []).find((candidate) => candidate.id === choice) || null;
if (option?.decision) {
return {
decision: option.decision,
text: decisionRecordedText(option.decision, option),
};
}
return { text: `Scelta del reviewer: ${option ? option.label : choice}` };
}
function installDisambiguationGate(
pi,
{ workflow, session, ledger, toTextResult },
@@ -73,5 +175,9 @@ function installDisambiguationGate(
export function createDisambiguationGate(dependencies) {
return {
install: (pi) => installDisambiguationGate(pi, dependencies),
prepareClarificationArguments,
prepareClarification: (ctx, params, id) =>
prepareClarification(ctx, params, id, dependencies),
resolveClarification,
};
}
+18 -34
View File
@@ -626,6 +626,9 @@ export default function (pi) {
lockActive = true;
},
phase: (ctx, session) => ({ number: currentPhase(ctx, session) }),
describe: (ctx, session) => phaseId(ctx, currentPhase(ctx, session)),
decisionTypes: knownDecisionTypes,
decisionMinimumPhases: decisionMinPhaseMap,
advance: advancePhaseAndFinalize,
},
session: {
@@ -638,6 +641,10 @@ export default function (pi) {
ctx, decisionAddArgs(session, decision), recovery,
),
},
reviewer: {
buildSelect: buildSelectRequest,
isReserved,
},
toTextResult: textResult,
});
@@ -828,49 +835,26 @@ export default function (pi) {
intro: Type.Optional(Type.String()),
advance: Type.Optional(Type.Boolean()),
}),
prepareArguments: prepareReviewerArguments,
prepareArguments: disambiguationGate.prepareClarificationArguments,
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
try {
const { session, title, options: opts, intro, advance } = params;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session));
const recommended = opts.find((o) => o.recommended)?.id ?? null;
const widget = buildSelectRequest({
id: `u${Date.now()}`,
phase,
title,
intro: intro ?? null,
recommended,
options: opts
.filter((o) => !isReserved(o.label))
.map((o) => ({ id: o.id, label: o.label })),
});
const resp = await emitAndWait(ctx, widget);
const outcome = resolveSelectOutcome(opts, 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") {
const prepared = disambiguationGate.prepareClarification(
ctx, params, `u${Date.now()}`,
);
if (prepared.result) return prepared.result;
const response = await emitAndWait(ctx, prepared.widget);
const outcome = disambiguationGate.resolveClarification(prepared, response);
if (outcome.decision) {
const err = relayIfThtFails(
ctx,
decisionAddArgs(session, outcome.decision),
decisionAddArgs(prepared.session, outcome.decision),
"",
);
if (err) return err;
if (advance) advanceIfReady(ctx, session);
return textResult(
decisionRecordedResultText(outcome.decision, outcome.option),
);
if (prepared.advance) advanceIfReady(ctx, prepared.session);
}
return textResult(
`Scelta del reviewer: ${outcome.option ? outcome.option.label : outcome.choice}`,
);
return textResult(outcome.text);
} catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return textResult(`[reviewer_select ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);