refactor(disambiguation): own F1 clarification policy (#27)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
Reference in New Issue
Block a user