refactor(memory): extract F2 recall path (#23)
This commit is contained in:
@@ -1,16 +0,0 @@
|
|||||||
const test = require("node:test");
|
|
||||||
const assert = require("node:assert");
|
|
||||||
const { shouldSkipEmptyDecide } = require("../../tht-gate.js");
|
|
||||||
|
|
||||||
test("skips the widget only when empty AND allow_empty AND advance", () => {
|
|
||||||
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: true, advance: true }), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not skip when there are merito options", () => {
|
|
||||||
assert.equal(shouldSkipEmptyDecide({ meritCount: 2, allowEmpty: true, advance: true }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not skip when allow_empty is false or advance is false", () => {
|
|
||||||
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: false, advance: true }), false);
|
|
||||||
assert.equal(shouldSkipEmptyDecide({ meritCount: 0, allowEmpty: true, advance: false }), false);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { installMemoryGate } = require("../memory/index.js");
|
const { createMemoryGate } = require("../memory/index.js");
|
||||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ test("the public Memory facade owns F8 policy and mutation ordering", async () =
|
|||||||
return JSON.stringify({ id: descriptor.id, choices: ["seq-5"] });
|
return JSON.stringify({ id: descriptor.id, choices: ["seq-5"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
installMemoryGate(pi, {
|
const memoryGate = createMemoryGate({
|
||||||
workflow: {
|
workflow: {
|
||||||
activate: () => calls.push(["activate"]),
|
activate: () => calls.push(["activate"]),
|
||||||
phase: () => ({ number: 8, id: "F8" }),
|
phase: () => ({ number: 8, id: "F8" }),
|
||||||
@@ -80,6 +80,7 @@ test("the public Memory facade owns F8 policy and mutation ordering", async () =
|
|||||||
},
|
},
|
||||||
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
|
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
|
||||||
});
|
});
|
||||||
|
memoryGate.install(pi);
|
||||||
|
|
||||||
const result = await tools.get("reviewer_memory_promote").def.execute(
|
const result = await tools.get("reviewer_memory_promote").def.execute(
|
||||||
"promote-via-facade",
|
"promote-via-facade",
|
||||||
|
|||||||
@@ -1,38 +1,10 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const path = require("node:path");
|
const { createMemoryGate } = require("../memory/index.js");
|
||||||
const {
|
|
||||||
memorySelectionWidgetProps,
|
|
||||||
normalizeMemoryOptions,
|
|
||||||
} = require("../../tht-gate.js");
|
|
||||||
|
|
||||||
test("reviewer_decide accepts the exact memory content in option.description", () => {
|
|
||||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||||
const { pi, tools } = createFakePi();
|
|
||||||
require(path.join(__dirname, "..", "..", "tht-gate.js")).default(pi);
|
|
||||||
|
|
||||||
const optionProperties = tools.get("reviewer_decide")
|
|
||||||
.def.parameters.properties.options.items.properties;
|
|
||||||
assert.ok(optionProperties.description);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () => {
|
const MEMORY_OPTIONS = [
|
||||||
assert.deepEqual(
|
|
||||||
memorySelectionWidgetProps([
|
|
||||||
{ id: "recommended", label: "Memory A", recommended: true },
|
|
||||||
{ id: "optional", label: "Memory B" },
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
title: "Seleziona le memory da applicare alla domanda",
|
|
||||||
selected: ["recommended"],
|
|
||||||
selectionLabel: "memory da applicare",
|
|
||||||
confirmLabel: "Applica le memory selezionate",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("F2 preserves the exact memory content and proposes each memory id once", () => {
|
|
||||||
const options = normalizeMemoryOptions([
|
|
||||||
{
|
{
|
||||||
id: "first",
|
id: "first",
|
||||||
label: "Regola paziente attivo",
|
label: "Regola paziente attivo",
|
||||||
@@ -56,28 +28,6 @@ test("F2 preserves the exact memory content and proposes each memory id once", (
|
|||||||
rationale: "Memory mem-0042",
|
rationale: "Memory mem-0042",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(options.length, 1);
|
|
||||||
assert.equal(
|
|
||||||
options[0].detail,
|
|
||||||
"Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
|
||||||
);
|
|
||||||
assert.equal(options[0].recommended, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("F2 accepts only concept_clarified memory options", () => {
|
|
||||||
const options = normalizeMemoryOptions([
|
|
||||||
{
|
|
||||||
id: "mem-0042",
|
|
||||||
label: "Concetto paziente attivo",
|
|
||||||
description: "Definizione riusabile",
|
|
||||||
decision: {
|
|
||||||
type: "concept_clarified",
|
|
||||||
subject: "paziente attivo",
|
|
||||||
detail: "flag_attivo = TRUE",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mem-0043",
|
id: "mem-0043",
|
||||||
label: "Tabella promossa",
|
label: "Tabella promossa",
|
||||||
@@ -88,7 +38,143 @@ test("F2 accepts only concept_clarified memory options", () => {
|
|||||||
detail: "tabella principale",
|
detail: "tabella principale",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
assert.deepEqual(options.map((option) => option.id), ["mem-0042"]);
|
|
||||||
|
function setupRecall(choices) {
|
||||||
|
const { pi, tools, ctx } = createFakePi();
|
||||||
|
const calls = [];
|
||||||
|
let descriptor;
|
||||||
|
ctx.ui.input = async (title) => {
|
||||||
|
descriptor = JSON.parse(title);
|
||||||
|
calls.push(["review", descriptor.options.map((option) => option.id)]);
|
||||||
|
return JSON.stringify({ id: descriptor.id, choices });
|
||||||
|
};
|
||||||
|
const memoryGate = createMemoryGate({
|
||||||
|
workflow: {
|
||||||
|
activate: () => calls.push(["activate"]),
|
||||||
|
phase: () => ({ number: 8, id: "F8" }),
|
||||||
|
advance: () => {
|
||||||
|
calls.push(["advance"]);
|
||||||
|
return { advanced: choices.length === 0 };
|
||||||
|
},
|
||||||
|
close: () => null,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
execute: () => "[]",
|
||||||
|
mutate: () => null,
|
||||||
|
},
|
||||||
|
ledger: {
|
||||||
|
validate: () => null,
|
||||||
|
record: (_ctx, session, decision, recovery) => {
|
||||||
|
calls.push(["ledger", session, decision, recovery]);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
waitForReviewer: async (runtimeContext, widget) => {
|
||||||
|
const response = await runtimeContext.ui.input(JSON.stringify(widget), "");
|
||||||
|
return JSON.parse(response);
|
||||||
|
},
|
||||||
|
toTextResult: (text) => ({ content: [{ type: "text", text }] }),
|
||||||
|
});
|
||||||
|
memoryGate.install(pi);
|
||||||
|
return { ctx, tools, calls, memoryGate, descriptor: () => descriptor };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test("the Memory facade normalizes search hits and applies only the selected F2 memory", async () => {
|
||||||
|
const { ctx, calls, memoryGate, descriptor } = setupRecall(["first"]);
|
||||||
|
|
||||||
|
const result = await memoryGate.reviewRecall(ctx, {
|
||||||
|
session: "s1",
|
||||||
|
title: "Memorie candidate",
|
||||||
|
options: MEMORY_OPTIONS,
|
||||||
|
allow_empty: true,
|
||||||
|
advance: true,
|
||||||
|
}, "F2");
|
||||||
|
|
||||||
|
assert.deepEqual(descriptor().options, [
|
||||||
|
{
|
||||||
|
id: "first",
|
||||||
|
label: "Regola paziente attivo",
|
||||||
|
detail: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
||||||
|
rationale: "Riusa mem-0042 per la stessa definizione",
|
||||||
|
meta: { memory_id: "mem-0042" },
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
["review", ["first"]],
|
||||||
|
["ledger", "s1", MEMORY_OPTIONS[0].decision, ""],
|
||||||
|
["advance"],
|
||||||
|
]);
|
||||||
|
assert.match(result.content[0].text, /1 decisioni.*La fase resta aperta/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the Memory facade accepts a deselected F2 result without a rejection", async () => {
|
||||||
|
const { ctx, calls, memoryGate } = setupRecall([]);
|
||||||
|
|
||||||
|
const result = await memoryGate.reviewRecall(ctx, {
|
||||||
|
session: "s1",
|
||||||
|
title: "Memorie candidate",
|
||||||
|
options: [MEMORY_OPTIONS[0]],
|
||||||
|
allow_empty: true,
|
||||||
|
advance: true,
|
||||||
|
}, "F2");
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [["review", ["first"]], ["advance"]]);
|
||||||
|
assert.match(result.content[0].text, /Nessuna decisione registrata.*Fase avanzata/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the Memory facade skips an absent F2 result and advances without a widget", async () => {
|
||||||
|
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
|
||||||
|
|
||||||
|
const result = await memoryGate.reviewRecall(ctx, {
|
||||||
|
session: "s1",
|
||||||
|
title: "Memorie candidate",
|
||||||
|
options: [],
|
||||||
|
allow_empty: true,
|
||||||
|
advance: true,
|
||||||
|
}, "F2");
|
||||||
|
|
||||||
|
assert.equal(descriptor(), undefined);
|
||||||
|
assert.deepEqual(calls, [["advance"]]);
|
||||||
|
assert.deepEqual(ctx.notifications.map(({ level }) => level), ["info"]);
|
||||||
|
assert.match(result.content[0].text, /Fase memoria vuota/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the Memory facade does not skip an absent F2 result when empty is forbidden", async () => {
|
||||||
|
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
|
||||||
|
|
||||||
|
const result = await memoryGate.reviewRecall(ctx, {
|
||||||
|
session: "s1",
|
||||||
|
title: "Memorie candidate",
|
||||||
|
options: [],
|
||||||
|
allow_empty: false,
|
||||||
|
advance: true,
|
||||||
|
}, "F2");
|
||||||
|
|
||||||
|
assert.equal(descriptor(), undefined);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.match(result.content[0].text, /ERRORE INTERNO/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the Memory facade keeps an absent F2 result open when advance is disabled", async () => {
|
||||||
|
const { ctx, calls, memoryGate, descriptor } = setupRecall([]);
|
||||||
|
|
||||||
|
const result = await memoryGate.reviewRecall(ctx, {
|
||||||
|
session: "s1",
|
||||||
|
title: "Memorie candidate",
|
||||||
|
options: [],
|
||||||
|
allow_empty: true,
|
||||||
|
advance: false,
|
||||||
|
}, "F2");
|
||||||
|
|
||||||
|
assert.deepEqual(descriptor().options, []);
|
||||||
|
assert.deepEqual(calls, [["review", []]]);
|
||||||
|
assert.match(result.content[0].text, /Nessuna decisione registrata/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,168 @@
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
import { buildMultiselectRequest } from "../builders.js";
|
import { buildMultiselectRequest } from "../builders.js";
|
||||||
|
import { isReserved } from "../../reserved-labels.mjs";
|
||||||
|
|
||||||
|
// Compatibility note: reviewer labels move verbatim from the composition root.
|
||||||
|
// Tickets #22 and #23 require observable parity; translating existing chrome is a
|
||||||
|
// separate product behavior change rather than part of these extractions.
|
||||||
|
|
||||||
function normalizedText(value) {
|
function normalizedText(value) {
|
||||||
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MEMORY_ID_RE = /\bmem-\d{4,}\b/i;
|
||||||
|
|
||||||
|
|
||||||
|
function memoryOptionKey(option) {
|
||||||
|
const searchable = [
|
||||||
|
option.id,
|
||||||
|
option.label,
|
||||||
|
option.description,
|
||||||
|
option.decision?.rationale,
|
||||||
|
].join(" ");
|
||||||
|
const memoryId = searchable.match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
||||||
|
if (memoryId) return `id:${memoryId}`;
|
||||||
|
return [
|
||||||
|
"content",
|
||||||
|
normalizedText(option.decision?.type),
|
||||||
|
normalizedText(option.decision?.subject),
|
||||||
|
normalizedText(option.decision?.detail),
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeMemoryOptions(options) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const option of options) {
|
||||||
|
if (option.decision?.type !== "concept_clarified") continue;
|
||||||
|
const key = memoryOptionKey(option);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const memoryId = [
|
||||||
|
option.id,
|
||||||
|
option.label,
|
||||||
|
option.description,
|
||||||
|
option.decision?.rationale,
|
||||||
|
].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
||||||
|
const fallbackDetail = [
|
||||||
|
option.decision?.type && option.decision?.subject
|
||||||
|
? `Decisione ${option.decision.type}: ${option.decision.subject}`
|
||||||
|
: "",
|
||||||
|
option.decision?.detail ?? "",
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
out.push({
|
||||||
|
...option,
|
||||||
|
detail: option.description?.trim() || fallbackDetail,
|
||||||
|
rationale: option.decision?.rationale ?? "",
|
||||||
|
...(memoryId ? { meta: { memory_id: memoryId } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function memorySelectionWidgetProps(options) {
|
||||||
|
return {
|
||||||
|
title: "Seleziona le memory da applicare alla domanda",
|
||||||
|
selected: options.filter((option) => option.recommended).map((option) => option.id),
|
||||||
|
selectionLabel: "memory da applicare",
|
||||||
|
confirmLabel: "Applica le memory selezionate",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
|
||||||
|
return meritCount === 0 && !!allowEmpty && !!advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function reviewRecall(ctx, params, phase, dependencies) {
|
||||||
|
const { workflow, ledger, waitForReviewer, toTextResult } = dependencies;
|
||||||
|
try {
|
||||||
|
const { session, advance } = params;
|
||||||
|
const options = normalizeMemoryOptions(params.options);
|
||||||
|
const typeError = ledger.validate(ctx, options, session);
|
||||||
|
if (typeError) return toTextResult(typeError);
|
||||||
|
const meritOptions = options
|
||||||
|
.filter((option) => !isReserved(option.label))
|
||||||
|
.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
label: option.label,
|
||||||
|
...(option.detail ? { detail: option.detail } : {}),
|
||||||
|
...(option.rationale ? { rationale: option.rationale } : {}),
|
||||||
|
...(option.meta ? { meta: option.meta } : {}),
|
||||||
|
}));
|
||||||
|
if (shouldSkipEmptyRecall({
|
||||||
|
meritCount: meritOptions.length,
|
||||||
|
allowEmpty: params.allow_empty ?? false,
|
||||||
|
advance,
|
||||||
|
})) {
|
||||||
|
await ctx.ui.notify(
|
||||||
|
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
workflow.advance(ctx, session);
|
||||||
|
return toTextResult(
|
||||||
|
"Fase memoria vuota: nessuna decisione da registrare, " +
|
||||||
|
"avanzamento automatico alla fase successiva.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const widget = buildMultiselectRequest({
|
||||||
|
id: `u${Date.now()}`,
|
||||||
|
phase,
|
||||||
|
allowEmpty: params.allow_empty ?? false,
|
||||||
|
options: meritOptions,
|
||||||
|
...memorySelectionWidgetProps(options),
|
||||||
|
});
|
||||||
|
const response = await waitForReviewer(ctx, widget);
|
||||||
|
if (response.control === "freetext") {
|
||||||
|
return toTextResult(
|
||||||
|
`Altro (reviewer): ${response.text}. Riformula la proposta tenendo conto.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.control === "back") {
|
||||||
|
return toTextResult("Il reviewer vuole tornare indietro.");
|
||||||
|
}
|
||||||
|
if (response.control === "exit") {
|
||||||
|
return toTextResult("Il reviewer vuole uscire.");
|
||||||
|
}
|
||||||
|
const chosen = options.filter((option) =>
|
||||||
|
(response.choices ?? []).includes(option.id));
|
||||||
|
const recorded = [];
|
||||||
|
for (const choice of chosen) {
|
||||||
|
const error = ledger.record(ctx, session, choice.decision, "");
|
||||||
|
if (error) return error;
|
||||||
|
recorded.push(choice.decision);
|
||||||
|
}
|
||||||
|
if (!recorded.length) {
|
||||||
|
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
|
||||||
|
const message =
|
||||||
|
"Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
|
||||||
|
return toTextResult(
|
||||||
|
result.advanced ? message + " Fase avanzata automaticamente." : message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
|
||||||
|
const parts = [
|
||||||
|
`Registrate ${recorded.length} decisioni: ` +
|
||||||
|
recorded.map((decision) => decision.type).join(", ") + ".",
|
||||||
|
];
|
||||||
|
parts.push(result.advanced
|
||||||
|
? "Fase avanzata automaticamente."
|
||||||
|
: 'La fase resta aperta: chiudila con reviewer_confirm kind:"phase" quando pronta.');
|
||||||
|
return toTextResult(parts.join(" "));
|
||||||
|
} catch (fatal) {
|
||||||
|
const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
||||||
|
return toTextResult(
|
||||||
|
`[reviewer_decide ERRORE INTERNO] ${message}. Riprova o usa un approccio diverso.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// The candidates come from `tht memory promote --preview --json` (deterministic,
|
// The candidates come from `tht memory promote --preview --json` (deterministic,
|
||||||
// reviewer-approved decisions only); the model never authors them.
|
// reviewer-approved decisions only); the model never authors them.
|
||||||
function dedupePromotionCandidates(candidates) {
|
function dedupePromotionCandidates(candidates) {
|
||||||
@@ -68,13 +223,10 @@ function splitPromotionChoices(candidates, choices) {
|
|||||||
* workflow composition root. Memory owns candidate policy, presentation and mutation
|
* workflow composition root. Memory owns candidate policy, presentation and mutation
|
||||||
* order; the capability keeps shared phase, ledger and persistence mechanisms in core.
|
* order; the capability keeps shared phase, ledger and persistence mechanisms in core.
|
||||||
*/
|
*/
|
||||||
export function installMemoryGate(
|
function installMemoryGate(
|
||||||
pi,
|
pi,
|
||||||
{ workflow, memory, ledger, waitForReviewer, toTextResult },
|
{ workflow, memory, ledger, waitForReviewer, toTextResult },
|
||||||
) {
|
) {
|
||||||
// Compatibility note: these labels move verbatim from the composition root.
|
|
||||||
// Ticket #22 requires byte-for-byte observable parity; translating existing chrome
|
|
||||||
// is a separate product behavior change rather than part of this extraction.
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "reviewer_memory_promote",
|
name: "reviewer_memory_promote",
|
||||||
label: "Promozione memorie riusabili (reviewer)",
|
label: "Promozione memorie riusabili (reviewer)",
|
||||||
@@ -208,3 +360,11 @@ export function installMemoryGate(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createMemoryGate(dependencies) {
|
||||||
|
return {
|
||||||
|
install: (pi) => installMemoryGate(pi, dependencies),
|
||||||
|
reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
enrichPhaseSummaryV2,
|
enrichPhaseSummaryV2,
|
||||||
appendLedgerSection,
|
appendLedgerSection,
|
||||||
} from "./gate/enrich.js";
|
} from "./gate/enrich.js";
|
||||||
import { installMemoryGate } from "./gate/memory/index.js";
|
import { createMemoryGate } from "./gate/memory/index.js";
|
||||||
import { isReserved } from "./reserved-labels.mjs";
|
import { isReserved } from "./reserved-labels.mjs";
|
||||||
|
|
||||||
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
||||||
@@ -554,76 +554,6 @@ export function isJoinReviewApproval(resp, optionIds) {
|
|||||||
[...chosen].every((id) => expected.has(id));
|
[...chosen].every((id) => expected.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when a reviewer_decide has no merito options but is allowed to close empty
|
|
||||||
// and advance (the empty memory phase F2). The gate then shows an info notice and
|
|
||||||
// auto-advances instead of presenting an empty checklist.
|
|
||||||
export function shouldSkipEmptyDecide({ meritCount, allowEmpty, advance }) {
|
|
||||||
return meritCount === 0 && !!allowEmpty && !!advance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2 uses a single positive checkbox meaning: apply this memory now.
|
|
||||||
// Candidates not checked by the reviewer remain undecided and may be shown again.
|
|
||||||
export function memorySelectionWidgetProps(options) {
|
|
||||||
return {
|
|
||||||
title: "Seleziona le memory da applicare alla domanda",
|
|
||||||
selected: options.filter((option) => option.recommended).map((option) => option.id),
|
|
||||||
selectionLabel: "memory da applicare",
|
|
||||||
confirmLabel: "Applica le memory selezionate",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEMORY_ID_RE = /\bmem-\d{4,}\b/i;
|
|
||||||
|
|
||||||
function normalizedText(value) {
|
|
||||||
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function memoryOptionKey(option) {
|
|
||||||
const searchable = [
|
|
||||||
option.id,
|
|
||||||
option.label,
|
|
||||||
option.description,
|
|
||||||
option.decision?.rationale,
|
|
||||||
].join(" ");
|
|
||||||
const memoryId = searchable.match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
|
||||||
if (memoryId) return `id:${memoryId}`;
|
|
||||||
return [
|
|
||||||
"content",
|
|
||||||
normalizedText(option.decision?.type),
|
|
||||||
normalizedText(option.decision?.subject),
|
|
||||||
normalizedText(option.decision?.detail),
|
|
||||||
].join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
// F2 is model-orchestrated, so normalize at the trust boundary: retain the exact
|
|
||||||
// retrieved memory text for the reviewer and collapse repeated representations of
|
|
||||||
// the same memory id/content before rendering or persisting a choice.
|
|
||||||
export function normalizeMemoryOptions(options) {
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const option of options) {
|
|
||||||
if (option.decision?.type !== "concept_clarified") continue;
|
|
||||||
const key = memoryOptionKey(option);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
const memoryId = [option.id, option.label, option.description, option.decision?.rationale]
|
|
||||||
.join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
|
||||||
const fallbackDetail = [
|
|
||||||
option.decision?.type && option.decision?.subject
|
|
||||||
? `Decisione ${option.decision.type}: ${option.decision.subject}`
|
|
||||||
: "",
|
|
||||||
option.decision?.detail ?? "",
|
|
||||||
].filter(Boolean).join("\n");
|
|
||||||
out.push({
|
|
||||||
...option,
|
|
||||||
detail: option.description?.trim() || fallbackDetail,
|
|
||||||
rationale: option.decision?.rationale ?? "",
|
|
||||||
...(memoryId ? { meta: { memory_id: memoryId } } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function emitAndWait(ctx, descriptor) {
|
export async function emitAndWait(ctx, descriptor) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||||
@@ -662,6 +592,33 @@ export default function (pi) {
|
|||||||
let lastSteered = false;
|
let lastSteered = false;
|
||||||
let pendingKickoff = null;
|
let pendingKickoff = null;
|
||||||
let activeSessionId = null;
|
let activeSessionId = null;
|
||||||
|
const memoryGate = createMemoryGate({
|
||||||
|
workflow: {
|
||||||
|
activate: () => {
|
||||||
|
lockActive = true;
|
||||||
|
},
|
||||||
|
phase: (ctx, session) => {
|
||||||
|
const number = currentPhase(ctx, session);
|
||||||
|
return { number, id: phaseId(ctx, number) };
|
||||||
|
},
|
||||||
|
advance: advanceIfReady,
|
||||||
|
close: (...args) => closeAfterPromotion(...args),
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
execute: (ctx, args) => tht(ctx, ["memory", ...args]),
|
||||||
|
mutate: (ctx, args, recovery) => relayIfThtFails(
|
||||||
|
ctx, ["memory", ...args], recovery,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ledger: {
|
||||||
|
validate: validateDecisionTypes,
|
||||||
|
record: (ctx, session, decision, recovery) => relayIfThtFails(
|
||||||
|
ctx, decisionAddArgs(session, decision), recovery,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
waitForReviewer: emitAndWait,
|
||||||
|
toTextResult: textResult,
|
||||||
|
});
|
||||||
|
|
||||||
// 1) ANTI-BYPASS tool_call hook (spec D4, verbatim). Blocks direct phase/decision
|
// 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.
|
// calls and writes to protected files so the reviewer cannot bypass the gate.
|
||||||
@@ -1014,9 +971,8 @@ export default function (pi) {
|
|||||||
try {
|
try {
|
||||||
const { session, title, advance } = params;
|
const { session, title, advance } = params;
|
||||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||||
const opts = phase === "F2"
|
if (phase === "F2") return await memoryGate.reviewRecall(ctx, params, phase);
|
||||||
? normalizeMemoryOptions(params.options)
|
const opts = params.options;
|
||||||
: params.options;
|
|
||||||
const typeErr = validateDecisionTypes(ctx, opts, session);
|
const typeErr = validateDecisionTypes(ctx, opts, session);
|
||||||
if (typeErr) return textResult(typeErr);
|
if (typeErr) return textResult(typeErr);
|
||||||
const toAdd = [];
|
const toAdd = [];
|
||||||
@@ -1032,16 +988,6 @@ export default function (pi) {
|
|||||||
const joinOnly = meritOptions.length > 0 && opts
|
const joinOnly = meritOptions.length > 0 && opts
|
||||||
.filter((o) => !isReserved(o.label))
|
.filter((o) => !isReserved(o.label))
|
||||||
.every((o) => o.decision.type === "join_modified");
|
.every((o) => o.decision.type === "join_modified");
|
||||||
if (shouldSkipEmptyDecide({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance })) {
|
|
||||||
await ctx.ui.notify(
|
|
||||||
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
advanceIfReady(ctx, session);
|
|
||||||
return textResult(
|
|
||||||
"Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const widget = joinOnly
|
const widget = joinOnly
|
||||||
? buildJoinReviewRequest({
|
? buildJoinReviewRequest({
|
||||||
id: `u${Date.now()}`,
|
id: `u${Date.now()}`,
|
||||||
@@ -1059,10 +1005,9 @@ export default function (pi) {
|
|||||||
: buildMultiselectRequest({
|
: buildMultiselectRequest({
|
||||||
id: `u${Date.now()}`,
|
id: `u${Date.now()}`,
|
||||||
phase,
|
phase,
|
||||||
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
|
title,
|
||||||
allowEmpty: params.allow_empty ?? false,
|
allowEmpty: params.allow_empty ?? false,
|
||||||
options: meritOptions,
|
options: meritOptions,
|
||||||
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
|
|
||||||
});
|
});
|
||||||
let resp;
|
let resp;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -1634,31 +1579,7 @@ export default function (pi) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
installMemoryGate(pi, {
|
memoryGate.install(pi);
|
||||||
workflow: {
|
|
||||||
activate: () => {
|
|
||||||
lockActive = true;
|
|
||||||
},
|
|
||||||
phase: (ctx, session) => {
|
|
||||||
const number = currentPhase(ctx, session);
|
|
||||||
return { number, id: phaseId(ctx, number) };
|
|
||||||
},
|
|
||||||
close: closeAfterPromotion,
|
|
||||||
},
|
|
||||||
memory: {
|
|
||||||
execute: (ctx, args) => tht(ctx, ["memory", ...args]),
|
|
||||||
mutate: (ctx, args, recovery) => relayIfThtFails(
|
|
||||||
ctx, ["memory", ...args], recovery,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
ledger: {
|
|
||||||
record: (ctx, session, decision, recovery) => relayIfThtFails(
|
|
||||||
ctx, decisionAddArgs(session, decision), recovery,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
waitForReviewer: emitAndWait,
|
|
||||||
toTextResult: textResult,
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "rewrite_question",
|
name: "rewrite_question",
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from tht.cli import app
|
||||||
|
from tht.decisions import DecisionInput
|
||||||
|
from tht.memory import MemoryRecord, recall_memories, save_registry
|
||||||
|
from tht.phase import current_phase
|
||||||
|
from tht.session.filesystem_repository import FilesystemSessionRepository
|
||||||
|
from tht.session.models import PrincipalContext, SessionManifest
|
||||||
|
|
||||||
|
|
||||||
|
def _memory(id_: str, type_: str = "concept_clarified") -> MemoryRecord:
|
||||||
|
return MemoryRecord(
|
||||||
|
id=id_,
|
||||||
|
ts=datetime(2026, 8, 24, tzinfo=UTC),
|
||||||
|
session_id="source-session",
|
||||||
|
decision_seq=int(id_.split("-")[1]),
|
||||||
|
type=type_,
|
||||||
|
subject=f"subject {id_}",
|
||||||
|
detail=f"detail {id_}",
|
||||||
|
rationale=f"rationale {id_}",
|
||||||
|
question_context="source question",
|
||||||
|
concepts=[f"concept {id_}"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Embedder:
|
||||||
|
def __init__(self):
|
||||||
|
self.questions = []
|
||||||
|
|
||||||
|
def embed_query(self, question):
|
||||||
|
self.questions.append(question)
|
||||||
|
return [0.1, 0.2]
|
||||||
|
|
||||||
|
|
||||||
|
class Searcher:
|
||||||
|
def __init__(self, hits):
|
||||||
|
self.hits = hits
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def search(self, embedding, *, top_n, kinds):
|
||||||
|
self.calls.append((embedding, top_n, kinds))
|
||||||
|
return self.hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_preserves_rank_and_public_payload_for_reusable_memories():
|
||||||
|
records = [_memory("mem-0001"), _memory("mem-0002", "table_promoted"), _memory("mem-0003")]
|
||||||
|
searcher = Searcher([
|
||||||
|
SimpleNamespace(ref="mem-0003", similarity=0.93456),
|
||||||
|
SimpleNamespace(ref="mem-0002", similarity=0.92345),
|
||||||
|
SimpleNamespace(ref="orphan", similarity=0.91234),
|
||||||
|
SimpleNamespace(ref="mem-0001", similarity=0.87654),
|
||||||
|
])
|
||||||
|
embedder = Embedder()
|
||||||
|
|
||||||
|
results = recall_memories(
|
||||||
|
"active patients",
|
||||||
|
records=records,
|
||||||
|
decisions=[],
|
||||||
|
searcher=searcher,
|
||||||
|
embedder=embedder,
|
||||||
|
top=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [result["id"] for result in results] == ["mem-0003", "mem-0001"]
|
||||||
|
assert results[0] == {
|
||||||
|
"id": "mem-0003",
|
||||||
|
"type": "concept_clarified",
|
||||||
|
"subject": "subject mem-0003",
|
||||||
|
"detail": "detail mem-0003",
|
||||||
|
"rationale": "rationale mem-0003",
|
||||||
|
"question_context": "source question",
|
||||||
|
"tables": [],
|
||||||
|
"concepts": ["concept mem-0003"],
|
||||||
|
"session_id": "source-session",
|
||||||
|
"score": 0.9346,
|
||||||
|
}
|
||||||
|
assert embedder.questions == ["active patients"]
|
||||||
|
assert searcher.calls == [([0.1, 0.2], 5, ["memory"])]
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_config(tmp_path):
|
||||||
|
config = tmp_path / "workspace.yaml"
|
||||||
|
config.write_text(
|
||||||
|
f"""
|
||||||
|
runtime_identity:
|
||||||
|
workspace_id: psd-clinical
|
||||||
|
workspace_revision: {'a' * 40}
|
||||||
|
dwh:
|
||||||
|
type: postgres_direct
|
||||||
|
connection: {{database: analytics, schema: mart, user: reader, password: secret}}
|
||||||
|
vectors:
|
||||||
|
type: qdrant
|
||||||
|
base_url: http://qdrant:6333
|
||||||
|
collection: psd-clinical
|
||||||
|
roots:
|
||||||
|
sessions: {tmp_path / 'sessions'}
|
||||||
|
artifacts: {tmp_path / 'artifacts'}
|
||||||
|
indexes: {tmp_path / 'indexes'}
|
||||||
|
embeddings:
|
||||||
|
provider: ollama_internal
|
||||||
|
base_url: http://embedding:11434
|
||||||
|
model: qwen3-embedding:0.6b
|
||||||
|
dim: 1024
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_cli_reconstructs_applied_and_rejected_memory_from_persisted_f2_session(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("THT_HOME", str(tmp_path / "home"))
|
||||||
|
repository = FilesystemSessionRepository(
|
||||||
|
tmp_path / "home",
|
||||||
|
"psd-clinical",
|
||||||
|
PrincipalContext(issuer="local", subject="reviewer"),
|
||||||
|
root=tmp_path / "sessions",
|
||||||
|
)
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
repository.create(SessionManifest(
|
||||||
|
id=session_id,
|
||||||
|
created_at=datetime(2026, 8, 24, tzinfo=UTC),
|
||||||
|
question="active patients",
|
||||||
|
database="analytics",
|
||||||
|
schema="mart",
|
||||||
|
))
|
||||||
|
repository.append_decisions(session_id, [
|
||||||
|
DecisionInput(type="phase_approved", subject="phase:1"),
|
||||||
|
DecisionInput(
|
||||||
|
type="concept_clarified",
|
||||||
|
subject="active patient",
|
||||||
|
rationale="Applied from mem-0003",
|
||||||
|
),
|
||||||
|
DecisionInput(
|
||||||
|
type="memory_rejected",
|
||||||
|
subject="mem-0001",
|
||||||
|
rationale="Not relevant to the resumed question",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
records = [_memory("mem-0001"), _memory("mem-0003")]
|
||||||
|
searcher = Searcher([
|
||||||
|
SimpleNamespace(ref="mem-0003", similarity=0.9),
|
||||||
|
SimpleNamespace(ref="mem-0001", similarity=0.8),
|
||||||
|
])
|
||||||
|
embedder = Embedder()
|
||||||
|
save_registry(records, tmp_path / "artifacts" / "memory" / "registry.jsonl")
|
||||||
|
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: searcher)
|
||||||
|
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: embedder)
|
||||||
|
|
||||||
|
response = CliRunner().invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"memory", "search", "active patients", "--session", session_id,
|
||||||
|
"--json", "-c", str(_workspace_config(tmp_path)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.exit_code == 0, response.output
|
||||||
|
assert json.loads(response.stdout) == []
|
||||||
|
assert current_phase(repository.get(session_id)) == 2
|
||||||
|
assert embedder.questions == ["active patients"]
|
||||||
|
assert searcher.calls == [([0.1, 0.2], 5, ["memory"])]
|
||||||
@@ -361,34 +361,21 @@ def search_cmd(
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg
|
from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg
|
||||||
from tht.memory import REUSABLE_TYPES, decided_memory_ids, load_registry
|
from tht.memory import load_registry, recall_memories
|
||||||
|
|
||||||
cfg = _load_config_or_exit(config)
|
cfg = _load_config_or_exit(config)
|
||||||
require_vector_cfg(cfg)
|
require_vector_cfg(cfg)
|
||||||
excluded: set[str] = set()
|
decisions = load_snapshot_or_exit(cfg, session).decisions if session is not None else []
|
||||||
if session is not None:
|
|
||||||
excluded = decided_memory_ids(load_snapshot_or_exit(cfg, session).decisions)
|
|
||||||
searcher = open_searcher(cfg)
|
searcher = open_searcher(cfg)
|
||||||
embedder = make_embedder(cfg.embeddings)
|
embedder = make_embedder(cfg.embeddings)
|
||||||
hits = searcher.search(embedder.embed_query(question), top_n=top, kinds=["memory"])
|
results = recall_memories(
|
||||||
by_id = {r.id: r for r in load_registry(registry_path(cfg))}
|
question,
|
||||||
|
records=load_registry(registry_path(cfg)),
|
||||||
results = []
|
decisions=decisions,
|
||||||
for h in hits:
|
searcher=searcher,
|
||||||
rec = by_id.get(h.ref)
|
embedder=embedder,
|
||||||
if rec is None:
|
top=top,
|
||||||
continue # indice piu' avanti del registro: ignora
|
)
|
||||||
if rec.type not in REUSABLE_TYPES:
|
|
||||||
continue # record legacy non-concept: non e' una memory riusabile
|
|
||||||
if rec.id in excluded:
|
|
||||||
continue # gia' decisa in questa sessione: non riproporla
|
|
||||||
results.append({
|
|
||||||
"id": rec.id, "type": rec.type, "subject": rec.subject,
|
|
||||||
"detail": rec.detail, "rationale": rec.rationale,
|
|
||||||
"question_context": rec.question_context,
|
|
||||||
"tables": rec.tables, "concepts": rec.concepts,
|
|
||||||
"session_id": rec.session_id, "score": round(h.similarity, 4),
|
|
||||||
})
|
|
||||||
|
|
||||||
if json_out:
|
if json_out:
|
||||||
typer.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
typer.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
||||||
|
|||||||
@@ -137,6 +137,48 @@ REUSABLE_TYPES = frozenset({"concept_clarified"})
|
|||||||
MAX_PROMOTION_CANDIDATES = 5
|
MAX_PROMOTION_CANDIDATES = 5
|
||||||
|
|
||||||
|
|
||||||
|
def recall_memories(
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
records: list[MemoryRecord],
|
||||||
|
decisions: list[DecisionRecord],
|
||||||
|
searcher,
|
||||||
|
embedder,
|
||||||
|
top: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return reviewer-visible F2 candidates in semantic-search rank order.
|
||||||
|
|
||||||
|
Memory owns filtering against the canonical registry, reusable decision types,
|
||||||
|
and decisions already persisted in the current (including resumed) session.
|
||||||
|
The CLI remains a thin adapter that supplies the vector ports and renders output.
|
||||||
|
"""
|
||||||
|
excluded = decided_memory_ids(decisions)
|
||||||
|
hits = searcher.search(
|
||||||
|
embedder.embed_query(question),
|
||||||
|
top_n=top,
|
||||||
|
kinds=["memory"],
|
||||||
|
)
|
||||||
|
by_id = {record.id: record for record in records}
|
||||||
|
results = []
|
||||||
|
for hit in hits:
|
||||||
|
record = by_id.get(hit.ref)
|
||||||
|
if record is None or record.type not in REUSABLE_TYPES or record.id in excluded:
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"id": record.id,
|
||||||
|
"type": record.type,
|
||||||
|
"subject": record.subject,
|
||||||
|
"detail": record.detail,
|
||||||
|
"rationale": record.rationale,
|
||||||
|
"question_context": record.question_context,
|
||||||
|
"tables": record.tables,
|
||||||
|
"concepts": record.concepts,
|
||||||
|
"session_id": record.session_id,
|
||||||
|
"score": round(hit.similarity, 4),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _compute_promotions(
|
def _compute_promotions(
|
||||||
session_dir: Path, manifest: SessionManifest, *,
|
session_dir: Path, manifest: SessionManifest, *,
|
||||||
seqs: list[int] | None, existing: list[MemoryRecord],
|
seqs: list[int] | None, existing: list[MemoryRecord],
|
||||||
|
|||||||
Reference in New Issue
Block a user