refactor(disambiguation): extract F3 rewrite path (#26)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { createDisambiguationGate } = require("../disambiguation/index.js");
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
|
||||
|
||||
function setupRewrite({ phase = 3, failAt } = {}) {
|
||||
const { pi, ctx, tools } = createFakePi();
|
||||
const calls = [];
|
||||
const error = (message) => ({ content: [{ type: "text", text: message }] });
|
||||
const gate = createDisambiguationGate({
|
||||
workflow: {
|
||||
activate: () => calls.push(["activate"]),
|
||||
phase: () => {
|
||||
calls.push(["phase"]);
|
||||
return { number: phase, id: `F${phase}` };
|
||||
},
|
||||
advance: (_ctx, session, current) => {
|
||||
calls.push(["advance", session, current]);
|
||||
return failAt === "advance" ? { err: error("advance failed") } : { finalized: false };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
mutate: (_ctx, args) => {
|
||||
calls.push(["session", ...args]);
|
||||
return failAt === "session" ? error("question write failed") : null;
|
||||
},
|
||||
},
|
||||
ledger: {
|
||||
record: (_ctx, session, decision) => {
|
||||
calls.push(["ledger", session, decision]);
|
||||
return failAt === "ledger" ? error("ledger write failed") : null;
|
||||
},
|
||||
},
|
||||
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
|
||||
});
|
||||
gate.install(pi);
|
||||
return { ctx, tools, calls };
|
||||
}
|
||||
|
||||
|
||||
for (const resumed of [false, true]) {
|
||||
test(`the Disambiguation facade rewrites and closes F3 in order (${resumed ? "resume" : "new"})`, async () => {
|
||||
const { ctx, tools, calls } = setupRewrite();
|
||||
ctx.resumed = resumed;
|
||||
|
||||
const result = await tools.get("rewrite_question").def.execute(
|
||||
`rewrite-${resumed}`,
|
||||
{
|
||||
session: "s1",
|
||||
question: "pazienti con ablazione",
|
||||
assumptions: resumed ? '["anno 2025"]' : ["anno 2025"],
|
||||
},
|
||||
null,
|
||||
null,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["activate"],
|
||||
["phase"],
|
||||
[
|
||||
"session", "set-question", "s1", "--question", "pazienti con ablazione",
|
||||
"--assumption", "anno 2025",
|
||||
],
|
||||
[
|
||||
"ledger", "s1", {
|
||||
type: "question_rewritten",
|
||||
subject: "domanda",
|
||||
detail: "pazienti con ablazione",
|
||||
},
|
||||
],
|
||||
["advance", "s1", 3],
|
||||
]);
|
||||
assert.match(result.content[0].text, /Fase 3 completata/);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("the Disambiguation facade preserves a non-JSON assumption as one item", async () => {
|
||||
const { ctx, tools, calls } = setupRewrite();
|
||||
|
||||
await tools.get("rewrite_question").def.execute(
|
||||
"rewrite-assumption",
|
||||
{ session: "s1", question: "q", assumptions: "assunzione libera" },
|
||||
null,
|
||||
null,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls[2], [
|
||||
"session", "set-question", "s1", "--question", "q",
|
||||
"--assumption", "assunzione libera",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test("the Disambiguation facade refuses rewrite outside F3 before mutations", async () => {
|
||||
const { ctx, tools, calls } = setupRewrite({ phase: 4 });
|
||||
|
||||
const result = await tools.get("rewrite_question").def.execute(
|
||||
"rewrite-outside",
|
||||
{ session: "s1", question: "q" },
|
||||
null,
|
||||
null,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [["activate"], ["phase"]]);
|
||||
assert.match(result.content[0].text, /solo in Fase 3/);
|
||||
});
|
||||
|
||||
|
||||
for (const [failAt, expectedCalls, errorPattern] of [
|
||||
["session", ["activate", "phase", "session"], /question write failed/],
|
||||
["ledger", ["activate", "phase", "session", "ledger"], /ledger write failed/],
|
||||
["advance", ["activate", "phase", "session", "ledger", "advance"], /advance failed/],
|
||||
]) {
|
||||
test(`the Disambiguation facade stops F3 after a ${failAt} failure`, async () => {
|
||||
const { ctx, tools, calls } = setupRewrite({ failAt });
|
||||
|
||||
const result = await tools.get("rewrite_question").def.execute(
|
||||
`rewrite-${failAt}`,
|
||||
{ session: "s1", question: "q", assumptions: [] },
|
||||
null,
|
||||
null,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls.map(([kind]) => kind), expectedCalls);
|
||||
assert.match(result.content[0].text, errorPattern);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Type } from "typebox";
|
||||
|
||||
|
||||
function normalizedAssumptions(assumptions) {
|
||||
let normalized = assumptions;
|
||||
if (typeof normalized === "string") {
|
||||
try {
|
||||
normalized = JSON.parse(normalized);
|
||||
} catch {
|
||||
normalized = [normalized];
|
||||
}
|
||||
}
|
||||
return Array.isArray(normalized) ? normalized.map(String) : [];
|
||||
}
|
||||
|
||||
|
||||
function installDisambiguationGate(
|
||||
pi,
|
||||
{ workflow, session, ledger, toTextResult },
|
||||
) {
|
||||
// Compatibility: tool schema, labels and messages move verbatim from the
|
||||
// composition root; this extraction changes ownership, not Pi behavior.
|
||||
pi.registerTool({
|
||||
name: "rewrite_question",
|
||||
label: "Riscrittura domanda e chiusura F3 (deterministica)",
|
||||
description:
|
||||
"In Fase 3 scrive deterministicamente question.md, registra question_rewritten e " +
|
||||
"chiude la fase senza chiedere conferma al reviewer. assumptions puo' essere array " +
|
||||
"o stringa JSON.",
|
||||
parameters: Type.Object({
|
||||
session: Type.String(),
|
||||
question: Type.String(),
|
||||
assumptions: Type.Optional(Type.Any()),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
workflow.activate();
|
||||
try {
|
||||
const { session: sessionId, question } = params;
|
||||
const current = workflow.phase(ctx, sessionId);
|
||||
if (current.number !== 3) {
|
||||
return toTextResult("La riscrittura automatica e' disponibile solo in Fase 3.");
|
||||
}
|
||||
// `tht session set-question` takes the session id as a positional argument
|
||||
// (unlike phase/cte/decision, which use --session).
|
||||
const args = ["set-question", sessionId, "--question", question];
|
||||
for (const assumption of normalizedAssumptions(params.assumptions)) {
|
||||
args.push("--assumption", assumption);
|
||||
}
|
||||
const mutationError = session.mutate(ctx, args, "");
|
||||
if (mutationError) return mutationError;
|
||||
const decisionError = ledger.record(ctx, sessionId, {
|
||||
type: "question_rewritten",
|
||||
subject: "domanda",
|
||||
detail: question,
|
||||
}, "");
|
||||
if (decisionError) return decisionError;
|
||||
const advanced = workflow.advance(ctx, sessionId, current.number);
|
||||
if (advanced.err) return advanced.err;
|
||||
return toTextResult(
|
||||
`Domanda riscritta e Fase 3 completata (sessione ${sessionId}).`,
|
||||
);
|
||||
} catch (fatal) {
|
||||
const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
||||
return toTextResult(
|
||||
`[rewrite_question ERRORE INTERNO] ${message}. Riprova o usa un approccio diverso.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function createDisambiguationGate(dependencies) {
|
||||
return {
|
||||
install: (pi) => installDisambiguationGate(pi, dependencies),
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
appendLedgerSection,
|
||||
} from "./gate/enrich.js";
|
||||
import { createMemoryGate } from "./gate/memory/index.js";
|
||||
import { createDisambiguationGate } from "./gate/disambiguation/index.js";
|
||||
import { isReserved } from "./reserved-labels.mjs";
|
||||
|
||||
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
||||
@@ -619,6 +620,26 @@ export default function (pi) {
|
||||
waitForReviewer: emitAndWait,
|
||||
toTextResult: textResult,
|
||||
});
|
||||
const disambiguationGate = createDisambiguationGate({
|
||||
workflow: {
|
||||
activate: () => {
|
||||
lockActive = true;
|
||||
},
|
||||
phase: (ctx, session) => ({ number: currentPhase(ctx, session) }),
|
||||
advance: advancePhaseAndFinalize,
|
||||
},
|
||||
session: {
|
||||
mutate: (ctx, args, recovery) => relayIfThtFails(
|
||||
ctx, ["session", ...args], recovery,
|
||||
),
|
||||
},
|
||||
ledger: {
|
||||
record: (ctx, session, decision, recovery) => relayIfThtFails(
|
||||
ctx, decisionAddArgs(session, decision), recovery,
|
||||
),
|
||||
},
|
||||
toTextResult: textResult,
|
||||
});
|
||||
|
||||
// 1) ANTI-BYPASS tool_call hook (spec D4, verbatim). Blocks direct phase/decision
|
||||
// calls and writes to protected files so the reviewer cannot bypass the gate.
|
||||
@@ -1580,63 +1601,7 @@ export default function (pi) {
|
||||
}
|
||||
|
||||
memoryGate.install(pi);
|
||||
|
||||
pi.registerTool({
|
||||
name: "rewrite_question",
|
||||
label: "Riscrittura domanda e chiusura F3 (deterministica)",
|
||||
description:
|
||||
"In Fase 3 scrive deterministicamente question.md, registra question_rewritten e " +
|
||||
"chiude la fase senza chiedere conferma al reviewer. assumptions puo' essere array " +
|
||||
"o stringa JSON.",
|
||||
parameters: Type.Object({
|
||||
session: Type.String(),
|
||||
question: Type.String(),
|
||||
assumptions: Type.Optional(Type.Any()),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
lockActive = true;
|
||||
try {
|
||||
const { session, question, assumptions } = params;
|
||||
const curNum = currentPhase(ctx, session);
|
||||
if (curNum !== 3) {
|
||||
return textResult("La riscrittura automatica e' disponibile solo in Fase 3.");
|
||||
}
|
||||
let assumps = assumptions;
|
||||
if (typeof assumps === "string") {
|
||||
try {
|
||||
assumps = JSON.parse(assumps);
|
||||
} catch {
|
||||
assumps = [assumps];
|
||||
}
|
||||
}
|
||||
// `tht session set-question` takes the session id as a positional argument
|
||||
// (the `session` command group uses positional ids, unlike phase/cte/decision
|
||||
// which use --session).
|
||||
const args = ["session", "set-question", session, "--question", question];
|
||||
if (Array.isArray(assumps)) {
|
||||
for (const a of assumps) args.push("--assumption", String(a));
|
||||
}
|
||||
const err = relayIfThtFails(ctx, args, "");
|
||||
if (err) return err;
|
||||
const decisionErr = relayIfThtFails(
|
||||
ctx,
|
||||
decisionAddArgs(session, {
|
||||
type: "question_rewritten",
|
||||
subject: "domanda",
|
||||
detail: question,
|
||||
}),
|
||||
"",
|
||||
);
|
||||
if (decisionErr) return decisionErr;
|
||||
const advanced = advancePhaseAndFinalize(ctx, session, curNum);
|
||||
if (advanced.err) return advanced.err;
|
||||
return textResult(`Domanda riscritta e Fase 3 completata (sessione ${session}).`);
|
||||
} catch (fatal) {
|
||||
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
||||
return textResult(`[rewrite_question ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
disambiguationGate.install(pi);
|
||||
|
||||
pi.registerTool({
|
||||
name: "write_schema_linking",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Integration: every `tht ...` command the gate invokes must exist in the Typer CLI.
|
||||
|
||||
Root cause of the Blocco 1 critical bugs: the gate (tht-gate.js) and the Python CLI
|
||||
Root cause of the Blocco 1 critical bugs: the Pi gate and the Python CLI
|
||||
were ported separately and never run together, so the gate called commands/flags that
|
||||
did not exist (`phase advance --if-ready`, `cte plan` without `--name`, `set-question
|
||||
--session` on a positional arg). This test extracts every `["group","sub",...,"--flag"]`
|
||||
array literal from tht-gate.js and asserts, via `tht <group> <sub> --help`, that the
|
||||
subcommand exists (exit 0) and that each long flag used is offered. It makes that whole
|
||||
class of drift impossible to reintroduce silently.
|
||||
array literal from tht-gate.js, plus the subcommand arrays passed through domain-specific
|
||||
CLI capabilities, and asserts via `tht <group> <sub> --help` that the subcommand exists
|
||||
(exit 0) and that each long flag used is offered. It makes that whole class of drift
|
||||
impossible to reintroduce silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,6 +23,7 @@ from tht.cli import app
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_GATE = _ROOT / ".pi" / "extensions" / "tht-gate.js"
|
||||
_DISAMBIGUATION_GATE = _ROOT / ".pi" / "extensions" / "gate" / "disambiguation" / "index.js"
|
||||
_THT = Path(sys.executable).parent / "tht"
|
||||
|
||||
# The command groups the gate drives. Anything else in an array literal is data, not a CLI call.
|
||||
@@ -75,6 +77,15 @@ def _extract_invocations() -> list[tuple[str, str, list[str], str]]:
|
||||
continue
|
||||
flags = [f for f in re.findall(r'"(--[a-z][a-z-]*)"', tail) if f not in _SKIP_FLAGS]
|
||||
out.append((group, sub, flags, m.group(0)))
|
||||
|
||||
# Disambiguation receives a session-scoped capability, so its arrays start at
|
||||
# the subcommand while tht-gate.js supplies the fixed `session` group prefix.
|
||||
domain_src = _DISAMBIGUATION_GATE.read_text()
|
||||
domain_pattern = re.compile(r'\[\s*"([a-z][a-z-]*)"((?:\s*,\s*[^\]\[]+?)?)\]')
|
||||
for m in domain_pattern.finditer(domain_src):
|
||||
sub, tail = m.group(1), m.group(2)
|
||||
flags = [f for f in re.findall(r'"(--[a-z][a-z-]*)"', tail) if f not in _SKIP_FLAGS]
|
||||
out.append(("session", sub, flags, m.group(0)))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user