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(
|
function installDisambiguationGate(
|
||||||
pi,
|
pi,
|
||||||
{ workflow, session, ledger, toTextResult },
|
{ workflow, session, ledger, toTextResult },
|
||||||
@@ -73,5 +175,9 @@ function installDisambiguationGate(
|
|||||||
export function createDisambiguationGate(dependencies) {
|
export function createDisambiguationGate(dependencies) {
|
||||||
return {
|
return {
|
||||||
install: (pi) => installDisambiguationGate(pi, dependencies),
|
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;
|
lockActive = true;
|
||||||
},
|
},
|
||||||
phase: (ctx, session) => ({ number: currentPhase(ctx, session) }),
|
phase: (ctx, session) => ({ number: currentPhase(ctx, session) }),
|
||||||
|
describe: (ctx, session) => phaseId(ctx, currentPhase(ctx, session)),
|
||||||
|
decisionTypes: knownDecisionTypes,
|
||||||
|
decisionMinimumPhases: decisionMinPhaseMap,
|
||||||
advance: advancePhaseAndFinalize,
|
advance: advancePhaseAndFinalize,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -638,6 +641,10 @@ export default function (pi) {
|
|||||||
ctx, decisionAddArgs(session, decision), recovery,
|
ctx, decisionAddArgs(session, decision), recovery,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
reviewer: {
|
||||||
|
buildSelect: buildSelectRequest,
|
||||||
|
isReserved,
|
||||||
|
},
|
||||||
toTextResult: textResult,
|
toTextResult: textResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -828,49 +835,26 @@ export default function (pi) {
|
|||||||
intro: Type.Optional(Type.String()),
|
intro: Type.Optional(Type.String()),
|
||||||
advance: Type.Optional(Type.Boolean()),
|
advance: Type.Optional(Type.Boolean()),
|
||||||
}),
|
}),
|
||||||
prepareArguments: prepareReviewerArguments,
|
prepareArguments: disambiguationGate.prepareClarificationArguments,
|
||||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||||
lockActive = true;
|
lockActive = true;
|
||||||
try {
|
try {
|
||||||
const { session, title, options: opts, intro, advance } = params;
|
const prepared = disambiguationGate.prepareClarification(
|
||||||
const typeErr = validateDecisionTypes(ctx, opts, session);
|
ctx, params, `u${Date.now()}`,
|
||||||
if (typeErr) return textResult(typeErr);
|
);
|
||||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
if (prepared.result) return prepared.result;
|
||||||
const recommended = opts.find((o) => o.recommended)?.id ?? null;
|
const response = await emitAndWait(ctx, prepared.widget);
|
||||||
|
const outcome = disambiguationGate.resolveClarification(prepared, response);
|
||||||
const widget = buildSelectRequest({
|
if (outcome.decision) {
|
||||||
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 err = relayIfThtFails(
|
const err = relayIfThtFails(
|
||||||
ctx,
|
ctx,
|
||||||
decisionAddArgs(session, outcome.decision),
|
decisionAddArgs(prepared.session, outcome.decision),
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
if (advance) advanceIfReady(ctx, session);
|
if (prepared.advance) advanceIfReady(ctx, prepared.session);
|
||||||
return textResult(
|
|
||||||
decisionRecordedResultText(outcome.decision, outcome.option),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return textResult(
|
return textResult(outcome.text);
|
||||||
`Scelta del reviewer: ${outcome.option ? outcome.option.label : outcome.choice}`,
|
|
||||||
);
|
|
||||||
} catch (fatal) {
|
} catch (fatal) {
|
||||||
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
||||||
return textResult(`[reviewer_select ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);
|
return textResult(`[reviewer_select ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);
|
||||||
|
|||||||
Reference in New Issue
Block a user