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 assert = require("node:assert");
|
||||
const { installMemoryGate } = require("../memory/index.js");
|
||||
const { createMemoryGate } = require("../memory/index.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"] });
|
||||
};
|
||||
|
||||
installMemoryGate(pi, {
|
||||
const memoryGate = createMemoryGate({
|
||||
workflow: {
|
||||
activate: () => calls.push(["activate"]),
|
||||
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 }] }),
|
||||
});
|
||||
memoryGate.install(pi);
|
||||
|
||||
const result = await tools.get("reviewer_memory_promote").def.execute(
|
||||
"promote-via-facade",
|
||||
|
||||
@@ -1,94 +1,180 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
memorySelectionWidgetProps,
|
||||
normalizeMemoryOptions,
|
||||
} = require("../../tht-gate.js");
|
||||
const { createMemoryGate } = require("../memory/index.js");
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
|
||||
test("reviewer_decide accepts the exact memory content in option.description", () => {
|
||||
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", () => {
|
||||
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",
|
||||
const MEMORY_OPTIONS = [
|
||||
{
|
||||
id: "first",
|
||||
label: "Regola paziente attivo",
|
||||
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
||||
decision: {
|
||||
type: "concept_clarified",
|
||||
subject: "paziente attivo",
|
||||
detail: "flag_attivo = TRUE",
|
||||
rationale: "Riusa mem-0042 per la stessa definizione",
|
||||
},
|
||||
);
|
||||
});
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "duplicate",
|
||||
label: "La stessa regola",
|
||||
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
||||
decision: {
|
||||
type: "concept_clarified",
|
||||
subject: "paziente attivo",
|
||||
detail: "flag_attivo = TRUE",
|
||||
rationale: "Memory mem-0042",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mem-0043",
|
||||
label: "Tabella promossa",
|
||||
description: "fact_pazienti",
|
||||
decision: {
|
||||
type: "table_promoted",
|
||||
subject: "fact_pazienti",
|
||||
detail: "tabella principale",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test("F2 preserves the exact memory content and proposes each memory id once", () => {
|
||||
const options = normalizeMemoryOptions([
|
||||
|
||||
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",
|
||||
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
||||
decision: {
|
||||
type: "concept_clarified",
|
||||
subject: "paziente attivo",
|
||||
detail: "flag_attivo = TRUE",
|
||||
rationale: "Riusa mem-0042 per la stessa definizione",
|
||||
},
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "duplicate",
|
||||
label: "La stessa regola",
|
||||
description: "Decisione concept_clarified: paziente attivo\nflag_attivo = TRUE",
|
||||
decision: {
|
||||
type: "concept_clarified",
|
||||
subject: "paziente attivo",
|
||||
detail: "flag_attivo = TRUE",
|
||||
rationale: "Memory mem-0042",
|
||||
},
|
||||
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.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",
|
||||
label: "Tabella promossa",
|
||||
description: "fact_pazienti",
|
||||
decision: {
|
||||
type: "table_promoted",
|
||||
subject: "fact_pazienti",
|
||||
detail: "tabella principale",
|
||||
},
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
assert.deepEqual(options.map((option) => option.id), ["mem-0042"]);
|
||||
|
||||
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 { 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) {
|
||||
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,
|
||||
// reviewer-approved decisions only); the model never authors them.
|
||||
function dedupePromotionCandidates(candidates) {
|
||||
@@ -68,13 +223,10 @@ function splitPromotionChoices(candidates, choices) {
|
||||
* workflow composition root. Memory owns candidate policy, presentation and mutation
|
||||
* order; the capability keeps shared phase, ledger and persistence mechanisms in core.
|
||||
*/
|
||||
export function installMemoryGate(
|
||||
function installMemoryGate(
|
||||
pi,
|
||||
{ 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({
|
||||
name: "reviewer_memory_promote",
|
||||
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,
|
||||
appendLedgerSection,
|
||||
} from "./gate/enrich.js";
|
||||
import { installMemoryGate } from "./gate/memory/index.js";
|
||||
import { createMemoryGate } from "./gate/memory/index.js";
|
||||
import { isReserved } from "./reserved-labels.mjs";
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (;;) {
|
||||
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
|
||||
@@ -662,6 +592,33 @@ export default function (pi) {
|
||||
let lastSteered = false;
|
||||
let pendingKickoff = 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
|
||||
// calls and writes to protected files so the reviewer cannot bypass the gate.
|
||||
@@ -1014,9 +971,8 @@ export default function (pi) {
|
||||
try {
|
||||
const { session, title, advance } = params;
|
||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||
const opts = phase === "F2"
|
||||
? normalizeMemoryOptions(params.options)
|
||||
: params.options;
|
||||
if (phase === "F2") return await memoryGate.reviewRecall(ctx, params, phase);
|
||||
const opts = params.options;
|
||||
const typeErr = validateDecisionTypes(ctx, opts, session);
|
||||
if (typeErr) return textResult(typeErr);
|
||||
const toAdd = [];
|
||||
@@ -1032,16 +988,6 @@ export default function (pi) {
|
||||
const joinOnly = meritOptions.length > 0 && opts
|
||||
.filter((o) => !isReserved(o.label))
|
||||
.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
|
||||
? buildJoinReviewRequest({
|
||||
id: `u${Date.now()}`,
|
||||
@@ -1059,10 +1005,9 @@ export default function (pi) {
|
||||
: buildMultiselectRequest({
|
||||
id: `u${Date.now()}`,
|
||||
phase,
|
||||
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
|
||||
title,
|
||||
allowEmpty: params.allow_empty ?? false,
|
||||
options: meritOptions,
|
||||
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
|
||||
});
|
||||
let resp;
|
||||
for (;;) {
|
||||
@@ -1634,31 +1579,7 @@ export default function (pi) {
|
||||
);
|
||||
}
|
||||
|
||||
installMemoryGate(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,
|
||||
});
|
||||
memoryGate.install(pi);
|
||||
|
||||
pi.registerTool({
|
||||
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 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)
|
||||
require_vector_cfg(cfg)
|
||||
excluded: set[str] = set()
|
||||
if session is not None:
|
||||
excluded = decided_memory_ids(load_snapshot_or_exit(cfg, session).decisions)
|
||||
decisions = load_snapshot_or_exit(cfg, session).decisions if session is not None else []
|
||||
searcher = open_searcher(cfg)
|
||||
embedder = make_embedder(cfg.embeddings)
|
||||
hits = searcher.search(embedder.embed_query(question), top_n=top, kinds=["memory"])
|
||||
by_id = {r.id: r for r in load_registry(registry_path(cfg))}
|
||||
|
||||
results = []
|
||||
for h in hits:
|
||||
rec = by_id.get(h.ref)
|
||||
if rec is None:
|
||||
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),
|
||||
})
|
||||
results = recall_memories(
|
||||
question,
|
||||
records=load_registry(registry_path(cfg)),
|
||||
decisions=decisions,
|
||||
searcher=searcher,
|
||||
embedder=embedder,
|
||||
top=top,
|
||||
)
|
||||
|
||||
if json_out:
|
||||
typer.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -137,6 +137,48 @@ REUSABLE_TYPES = frozenset({"concept_clarified"})
|
||||
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(
|
||||
session_dir: Path, manifest: SessionManifest, *,
|
||||
seqs: list[int] | None, existing: list[MemoryRecord],
|
||||
|
||||
Reference in New Issue
Block a user