diff --git a/harness/.pi/extensions/gate/__tests__/gate_disambiguation_clarification.test.js b/harness/.pi/extensions/gate/__tests__/gate_disambiguation_clarification.test.js new file mode 100644 index 00000000..c2fe0740 --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/gate_disambiguation_clarification.test.js @@ -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." }, + ); +}); diff --git a/harness/.pi/extensions/gate/disambiguation/index.js b/harness/.pi/extensions/gate/disambiguation/index.js index f201f523..bed430b5 100644 --- a/harness/.pi/extensions/gate/disambiguation/index.js +++ b/harness/.pi/extensions/gate/disambiguation/index.js @@ -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, }; } diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 7ae09ec8..b3ec142e 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -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.`);