fix: harden reviewer workflow and memory handling

This commit is contained in:
2026-07-23 12:34:23 +02:00
parent 00761ae2ca
commit 694b7dd21f
23 changed files with 728 additions and 75 deletions
+4
View File
@@ -32,6 +32,10 @@ services:
- ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}:/data/workspaces/psd - ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}:/data/workspaces/psd
frontend: frontend:
build:
args:
VITE_BASE: /
VITE_BACKEND_URL: /api
ports: ports:
- "127.0.0.1:8099:8080" - "127.0.0.1:8099:8080"
networks: !override networks: !override
@@ -46,6 +46,56 @@ test("uses memory-specific selection copy without changing selected ids", async
expect(onRespond).toHaveBeenCalledWith({ id: "u-memory", kind: "multiselect", choices: ["recommended"] }); expect(onRespond).toHaveBeenCalledWith({ id: "u-memory", kind: "multiselect", choices: ["recommended"] });
}); });
test("shows the exact content and provenance for every memory option", () => {
render(
<MultiselectWidget
descriptor={{
id: "u-memory-content",
widget: "multiselect",
options: [
{
id: "mem-42",
label: "paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "Confermato dal reviewer",
meta: { question_context: "Conta i pazienti attivi" },
},
],
}}
onRespond={vi.fn()}
/>
);
expect(screen.getByText("flag_attivo = TRUE")).toBeInTheDocument();
expect(screen.getByText(/Confermato dal reviewer/)).toBeInTheDocument();
expect(screen.getByText(/Conta i pazienti attivi/)).toBeInTheDocument();
});
test("renders memory detail as human-friendly Markdown", () => {
render(
<MultiselectWidget
descriptor={{
id: "u-memory-markdown",
widget: "multiselect",
selection_label: "memory da applicare",
options: [
{
id: "mem-0042",
label: "Paziente attivo",
detail: "**Definizione**\n\n- `flag_attivo = TRUE`\n- record non annullato",
},
],
}}
onRespond={vi.fn()}
/>
);
expect(screen.getByText("Definizione").tagName).toBe("STRONG");
expect(screen.getByText("flag_attivo = TRUE").tagName).toBe("CODE");
expect(screen.getByText("record non annullato").closest("li")).not.toBeNull();
expect(screen.queryByText("**Definizione**")).not.toBeInTheDocument();
});
test("select-all checks all options", async () => { test("select-all checks all options", async () => {
const onRespond = vi.fn(); const onRespond = vi.fn();
render( render(
+20 -1
View File
@@ -1,4 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { WidgetProps } from "./types"; import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls"; import { ReservedControls } from "./ReservedControls";
@@ -56,7 +58,24 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
checked={checked.has(o.id)} checked={checked.has(o.id)}
onChange={() => toggle(o.id)} onChange={() => toggle(o.id)}
/> />
<span>{o.label}</span> <span className="min-w-0 flex-1">
<span className="block font-medium">{o.label}</span>
{o.detail && (
<div className="thot-prose mt-1 text-sm text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.detail}</ReactMarkdown>
</div>
)}
{o.rationale && (
<span className="mt-1 block text-xs text-foreground/80">
Motivo: {o.rationale}
</span>
)}
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
<span className="mt-1 block text-xs text-muted-foreground">
Domanda di contesto: {o.meta.question_context}
</span>
)}
</span>
</label> </label>
))} ))}
</div> </div>
@@ -33,6 +33,13 @@ const descriptor: WidgetDescriptor = {
reserved: ["back", "exit", "other"], reserved: ["back", "exit", "other"],
}; };
test("starts include rows checked and exclude rows unchecked even for legacy payloads", () => {
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={vi.fn()} />);
expect(screen.getByRole("checkbox", { name: "dim_patient" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "fact_sostituzione" })).not.toBeChecked();
});
test("Confirm emits enacted tables with suggested columns (catalog order)", async () => { test("Confirm emits enacted tables with suggested columns (catalog order)", async () => {
const onRespond = vi.fn(); const onRespond = vi.fn();
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />); render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
@@ -42,7 +49,7 @@ test("Confirm emits enacted tables with suggested columns (catalog order)", asyn
kind: "schema-linking", kind: "schema-linking",
tables: [ tables: [
{ id: "t-pat", enacted: true, columns: ["cod_paz"] }, { id: "t-pat", enacted: true, columns: ["cod_paz"] },
{ id: "x-sub", enacted: true }, { id: "x-sub", enacted: false },
], ],
}); });
}); });
@@ -60,7 +67,7 @@ test("selecting a column in the modal adds it to the response", async () => {
kind: "schema-linking", kind: "schema-linking",
tables: [ tables: [
{ id: "t-pat", enacted: true, columns: ["cod_paz", "nome"] }, { id: "t-pat", enacted: true, columns: ["cod_paz", "nome"] },
{ id: "x-sub", enacted: true }, { id: "x-sub", enacted: false },
], ],
}); });
}); });
@@ -75,7 +82,7 @@ test("declining a table's enact checkbox drops its columns", async () => {
kind: "schema-linking", kind: "schema-linking",
tables: [ tables: [
{ id: "t-pat", enacted: false }, { id: "t-pat", enacted: false },
{ id: "x-sub", enacted: true }, { id: "x-sub", enacted: false },
], ],
}); });
}); });
@@ -7,7 +7,7 @@ import { ReservedControls } from "./ReservedControls";
export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) { export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) {
const tables: SchemaTable[] = descriptor.tables ?? []; const tables: SchemaTable[] = descriptor.tables ?? [];
const [enacted, setEnacted] = useState<Set<string>>( const [enacted, setEnacted] = useState<Set<string>>(
() => new Set(tables.filter((t) => t.recommended).map((t) => t.id)) () => new Set(tables.filter((t) => t.kind === "promote" && t.recommended).map((t) => t.id))
); );
const [selected, setSelected] = useState<Map<string, Set<string>>>(() => { const [selected, setSelected] = useState<Map<string, Set<string>>>(() => {
const m = new Map<string, Set<string>>(); const m = new Map<string, Set<string>>();
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true,
get: () => dispatcher,
set: (fn) => { activeStub = fn; },
});
async function loadGate() {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
await pi.emit("session_start", {});
return { ctx, tools };
}
function phase8Stub(calls) {
return (_file, args) => {
calls.push(args);
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({
max_phase: 8,
phases: [{
num: 8,
id: "F8",
name: "datamart",
emits: ["datamart_requested", "datamart_declined"],
}],
});
}
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n";
return "";
};
}
test("reviewer_datamart skips the question and records decline on workstation", async () => {
const previousProfile = process.env.THT_PROFILE;
const originalExec = cp.execFileSync;
const calls = [];
process.env.THT_PROFILE = "workstation";
cp.execFileSync = phase8Stub(calls);
try {
const { ctx, tools } = await loadGate();
const tool = tools.get("reviewer_datamart");
assert.ok(tool, "reviewer_datamart must be registered");
const result = await tool.def.execute("call-1", { session: "s1" }, null, null, ctx);
assert.equal(ctx.uiCalls.length, 0, "workstation must not show a pointless question");
assert.ok(calls.some((args) =>
args.join(" ").includes("decision add --session s1 --type datamart_declined --subject phase:8")
));
assert.match(result.content[0].text, /workstation.*saltato automaticamente/i);
} finally {
cp.execFileSync = originalExec;
if (previousProfile === undefined) delete process.env.THT_PROFILE;
else process.env.THT_PROFILE = previousProfile;
}
});
test("reviewer_datamart shows both yes and no choices on server", async () => {
const previousProfile = process.env.THT_PROFILE;
const originalExec = cp.execFileSync;
const calls = [];
process.env.THT_PROFILE = "server";
cp.execFileSync = phase8Stub(calls);
try {
const { ctx, tools } = await loadGate();
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
assert.deepEqual(
descriptor.options.slice(0, 2).map((option) => option.label),
["Sì, genera il datamart", "No, salta il datamart"],
);
return JSON.stringify({ id: descriptor.id, choices: ["generate"] });
};
const result = await tools.get("reviewer_datamart").def.execute(
"call-1", { session: "s1" }, null, null, ctx,
);
assert.ok(calls.some((args) =>
args.join(" ").includes("decision add --session s1 --type datamart_requested --subject phase:8")
));
assert.match(result.content[0].text, /datamart_requested/);
} finally {
cp.execFileSync = originalExec;
if (previousProfile === undefined) delete process.env.THT_PROFILE;
else process.env.THT_PROFILE = previousProfile;
}
});
@@ -1,6 +1,7 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
const { const {
dedupePromotionCandidates,
promotionOptions, promotionOptions,
promotionContent, promotionContent,
splitPromotionChoices, splitPromotionChoices,
@@ -20,26 +21,47 @@ const CANDIDATES = [
]; ];
test("promotionOptions maps candidates to seq-keyed options", () => { test("promotionOptions maps candidates to seq-keyed options", () => {
assert.deepEqual(promotionOptions(CANDIDATES), [ assert.deepEqual(promotionOptions(dedupePromotionCandidates(CANDIDATES)), [
{ id: "seq-3", label: "table_promoted: fact_seeablazione" }, {
{ id: "seq-5", label: "concept_clarified: paziente attivo" }, id: "seq-5",
label: "concept_clarified: paziente attivo",
detail: "flag_attivo = TRUE",
rationale: "",
meta: { question_context: "quante ablazioni nel 2023" },
},
]); ]);
}); });
test("promotionContent lists every candidate with detail and context", () => { test("F8 proposes semantically identical memory content only once", () => {
const c = promotionContent(CANDIDATES); const duplicate = { ...CANDIDATES[0], decision_seq: 9 };
assert.ok(c.includes("fact_seeablazione")); assert.deepEqual(
dedupePromotionCandidates([CANDIDATES[0], duplicate, CANDIDATES[1]])
.map((candidate) => candidate.decision_seq),
[5],
);
});
test("F8 never proposes promoted tables as memory", () => {
const candidates = dedupePromotionCandidates(CANDIDATES);
const options = promotionOptions(candidates);
const c = promotionContent(candidates);
assert.deepEqual(candidates.map((candidate) => candidate.type), ["concept_clarified"]);
assert.deepEqual(options.map((option) => option.id), ["seq-5"]);
assert.ok(!c.includes("fact_seeablazione"));
assert.ok(c.includes("flag_attivo = TRUE")); assert.ok(c.includes("flag_attivo = TRUE"));
assert.ok(c.includes("quante ablazioni nel 2023")); assert.ok(c.includes("quante ablazioni nel 2023"));
}); });
test("splitPromotionChoices partitions by selection", () => { test("splitPromotionChoices partitions by selection", () => {
const { promote, decline } = splitPromotionChoices(CANDIDATES, ["seq-5"]); const candidates = dedupePromotionCandidates(CANDIDATES);
const { promote, decline } = splitPromotionChoices(candidates, ["seq-5"]);
assert.deepEqual(promote.map((c) => c.decision_seq), [5]); assert.deepEqual(promote.map((c) => c.decision_seq), [5]);
assert.deepEqual(decline.map((c) => c.decision_seq), [3]); assert.deepEqual(decline.map((c) => c.decision_seq), []);
}); });
test("empty or missing choices declines everything", () => { test("empty or missing choices declines everything", () => {
assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2); const candidates = dedupePromotionCandidates(CANDIDATES);
assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2); assert.equal(splitPromotionChoices(candidates, []).decline.length, 1);
assert.equal(splitPromotionChoices(candidates, undefined).decline.length, 1);
}); });
@@ -1,6 +1,20 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
const { memorySelectionWidgetProps } = require("../../tht-gate.js"); const path = require("node:path");
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 { 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", () => { test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () => {
assert.deepEqual( assert.deepEqual(
@@ -16,3 +30,65 @@ test("F2 preseleziona solo le memory raccomandate e parla di applicazione", () =
}, },
); );
}); });
test("F2 preserves the exact memory content and proposes each memory id once", () => {
const options = normalizeMemoryOptions([
{
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",
},
},
]);
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(options.map((option) => option.id), ["mem-0042"]);
});
@@ -132,6 +132,64 @@ test("reviewer_schema_linking records table/column decisions and syncs schema_li
} }
}); });
test("reviewer_schema_linking preselects promoted tables but not excluded tables", async () => {
_handler = (_file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
if (args[0] === "schema" && args[1] === "columns")
return JSON.stringify({ ...CATALOG, table: args[2] });
return "";
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-table-defaults";
gate.default(pi);
let capturedDescriptor;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({
id: capturedDescriptor.id,
kind: "schema-linking",
tables: capturedDescriptor.tables.map((table) => ({
id: table.id,
enacted: table.recommended,
columns: [],
})),
});
};
await tools.get("reviewer_schema_linking").def.execute(
"call-defaults",
{
session: "s1",
title: "Schema linking",
tables: [
{ id: "keep", name: "fact_keep", kind: "promote" },
{ id: "drop", name: "fact_drop", kind: "exclude", recommended: true },
],
},
null,
null,
ctx,
);
assert.deepEqual(
capturedDescriptor.tables.map(({ id, recommended }) => ({ id, recommended })),
[
{ id: "keep", recommended: true },
{ id: "drop", recommended: false },
],
);
} finally {
_handler = null;
}
});
test("reviewer_schema_linking auto-corrects typos in table names via fuzzy matching", async () => { test("reviewer_schema_linking auto-corrects typos in table names via fuzzy matching", async () => {
const calls = []; const calls = [];
const inputs = []; const inputs = [];
+185 -8
View File
@@ -573,13 +573,85 @@ export function memorySelectionWidgetProps(options) {
}; };
} }
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;
}
// --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------ // --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------
// 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.
export function dedupePromotionCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
if (candidate.type !== "concept_clarified") return false;
const key = [
candidate.type,
candidate.subject,
candidate.detail,
candidate.rationale,
candidate.question_context,
].map(normalizedText).join("\u0000");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function promotionOptions(candidates) { export function promotionOptions(candidates) {
return candidates.map((c) => ({ return candidates.map((c) => ({
id: `seq-${c.decision_seq}`, id: `seq-${c.decision_seq}`,
label: `${c.type}: ${c.subject}`, label: `${c.type}: ${c.subject}`,
detail: c.detail || "",
rationale: c.rationale || "",
meta: { question_context: c.question_context || "" },
})); }));
} }
@@ -795,7 +867,7 @@ export default function (pi) {
} }
}); });
// --- the four reviewer tools (widget-descriptor emit + await) --------------- // --- reviewer tools (widget-descriptor emit + await) -------------------------
pi.registerTool({ pi.registerTool({
name: "reviewer_select", name: "reviewer_select",
@@ -881,6 +953,87 @@ export default function (pi) {
}, },
}); });
pi.registerTool({
name: "reviewer_datamart",
label: "Scelta datamart (reviewer)",
description:
"F8: gestisce deterministicamente la scelta di generazione del datamart. " +
"Con THT_PROFILE=workstation non mostra alcun widget e registra automaticamente " +
"datamart_declined; con profile=server mostra sempre entrambe le opzioni Si/No e " +
"registra la decisione scelta. Dopo questo tool prosegui con reviewer_memory_promote.",
parameters: Type.Object({
session: Type.String(),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
try {
const { session } = params;
const curNum = currentPhase(ctx, session);
if (curNum !== 8) {
return textResult(
`La scelta datamart e' disponibile solo in Fase 8 (fase corrente: ${curNum}).`,
);
}
const subject = "phase:8";
if (process.env.THT_PROFILE === "workstation") {
const decision = { type: "datamart_declined", subject };
const err = relayIfThtFails(ctx, decisionAddArgs(session, decision), "");
if (err) return err;
return textResult(
"Profilo workstation: datamart saltato automaticamente " +
"e decisione datamart_declined registrata. Invoca ora reviewer_memory_promote.",
);
}
const options = [
{
id: "generate",
label: "Sì, genera il datamart",
decision: { type: "datamart_requested", subject },
},
{
id: "skip",
label: "No, salta il datamart",
decision: { type: "datamart_declined", subject },
recommended: true,
},
];
const widget = buildSelectRequest({
id: `u${Date.now()}`,
phase: phaseId(ctx, curNum),
title: "Vuoi generare un datamart?",
intro: null,
recommended: "skip",
options: options.map((option) => ({ id: option.id, label: option.label })),
});
const resp = await emitAndWait(ctx, widget);
const outcome = resolveSelectOutcome(options, 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")
return textResult("Scelta datamart non valida: ripresenta reviewer_datamart.");
const err = relayIfThtFails(
ctx,
decisionAddArgs(session, outcome.decision),
"",
);
if (err) return err;
return textResult(
decisionRecordedResultText(outcome.decision, outcome.option),
);
} catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
return textResult(
`[reviewer_datamart ERRORE INTERNO] ${msg}. Riprova o usa un approccio diverso.`,
);
}
},
});
pi.registerTool({ pi.registerTool({
name: "reviewer_decide", name: "reviewer_decide",
label: "Decisione di merito (reviewer)", label: "Decisione di merito (reviewer)",
@@ -895,6 +1048,7 @@ export default function (pi) {
Type.Object({ Type.Object({
id: Type.String(), id: Type.String(),
label: Type.String(), label: Type.String(),
description: Type.Optional(Type.String()),
decision: Type.Object({ decision: Type.Object({
type: Type.String(), type: Type.String(),
subject: Type.String(), subject: Type.String(),
@@ -911,14 +1065,23 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
try { try {
const { session, title, options: opts, advance } = params; const { session, title, advance } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
const opts = phase === "F2"
? normalizeMemoryOptions(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 phase = phaseId(ctx, currentPhase(ctx, session));
const toAdd = []; const toAdd = [];
const meritOptions = opts const meritOptions = opts
.filter((o) => !isReserved(o.label)) .filter((o) => !isReserved(o.label))
.map((o) => ({ id: o.id, label: o.label })); .map((o) => ({
id: o.id,
label: o.label,
...(o.detail ? { detail: o.detail } : {}),
...(o.rationale ? { rationale: o.rationale } : {}),
...(o.meta ? { meta: o.meta } : {}),
}));
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");
@@ -1084,7 +1247,7 @@ export default function (pi) {
id: t.id, id: t.id,
name: t.name, name: t.name,
kind: t.kind, kind: t.kind,
recommended: t.recommended ?? true, recommended: t.kind === "promote" ? (t.recommended ?? true) : false,
description: cat.description ?? "", description: cat.description ?? "",
rationale: t.rationale ?? "", rationale: t.rationale ?? "",
columns: cat.columns.map((c) => ({ ...c, suggested: suggested.has(c.name) })), columns: cat.columns.map((c) => ({ ...c, suggested: suggested.has(c.name) })),
@@ -1529,8 +1692,8 @@ export default function (pi) {
label: "Promozione memorie riusabili (reviewer)", label: "Promozione memorie riusabili (reviewer)",
description: description:
"F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " + "F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " +
"calcolati dalla CLI (tht memory promote --preview: tipi riusabili concept_clarified/" + "calcolati dalla CLI (tht memory promote --preview: solo concept_clarified, " +
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " + "max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " + "vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " + "le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
"alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " + "alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " +
@@ -1567,11 +1730,25 @@ export default function (pi) {
"Nessun candidato di promozione: prosegui con la chiusura della sessione.", "Nessun candidato di promozione: prosegui con la chiusura della sessione.",
); );
} }
candidates = dedupePromotionCandidates(candidates);
if (candidates.length === 0) {
await ctx.ui.notify(
"Nessun concetto chiarito da salvare come memory per questa sessione.",
"info",
);
const closed = closeAfterPromotion(
ctx, session, curNum, "Nessun candidato di promozione.",
);
if (closed) return closed;
return textResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
);
}
const options = promotionOptions(candidates); const options = promotionOptions(candidates);
const widget = buildMultiselectRequest({ const widget = buildMultiselectRequest({
id: `u${Date.now()}`, id: `u${Date.now()}`,
phase, phase,
title: "Quali decisioni salvare nella memoria riutilizzabile?", title: "Quali concetti chiariti salvare nella memoria riutilizzabile?",
allowEmpty: true, allowEmpty: true,
options, options,
selected: options.map((o) => o.id), selected: options.map((o) => o.id),
+18 -7
View File
@@ -225,11 +225,15 @@ Prerequisite: Phase 1 closed.
2. The hit comes with full metadata (subject/detail/rationale): read what it says, 2. The hit comes with full metadata (subject/detail/rationale): read what it says,
where it comes from, why it might apply here, the out-of-context risk. where it comes from, why it might apply here, the out-of-context risk.
3. Present candidates in **a single** `reviewer_decide(multi:true, advance:true, 3. Present candidates in **a single** `reviewer_decide(multi:true, advance:true,
allow_empty:true)`. Rules: at most **5** candidates; ONLY the 3 reusable types allow_empty:true)`. Rules: at most **5** candidates; ONLY
(`concept_clarified`, `table_promoted`, `table_excluded`) — query-specific `concept_clarified`. Table choices (`table_promoted`, `table_excluded`) and all
decisions (`question_rewritten`, `sql_approved`, …) are NOT transferable, never other query-specific decisions (`question_rewritten`, `sql_approved`, …) are NOT
propose them. Each option carries `type`/`subject`/`rationale`; cite the source transferable and must never be stored, retrieved, or proposed as memories. Each
memory id (`mem-<id>`) in its rationale when applying it. Every option describes a option carries `type`/`subject`/`rationale`; cite the source
memory id (`mem-<id>`) in its rationale when applying it. Copy the hit's full
`content` verbatim into the option `description`: the reviewer must see the exact
memory text before deciding. Deduplicate hits by memory id before calling the gate.
Every option describes a
candidate memory; never create an opposite "do not use" option. Only candidate memory; never create an opposite "do not use" option. Only
`recommended:true` options start checked. A `recommended:true` options start checked. A
deselected candidate is **not applied now**, not rejected, and may be considered deselected candidate is **not applied now**, not rejected, and may be considered
@@ -411,8 +415,15 @@ Prerequisite: Phase 6 closed.
Prerequisite: Phase 7 closed. Prerequisite: Phase 7 closed.
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no). 1. Call `reviewer_datamart` with the session id. This gate is deployment-aware and is
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell the ONLY allowed way to record the datamart choice:
- `THT_PROFILE=workstation`: it records `datamart_declined` automatically and shows
no question to the reviewer;
- `THT_PROFILE=server` (including the default): it always shows both choices,
"Sì, genera il datamart" and "No, salta il datamart", and records the selected one.
Never replace this gate with a hand-built `reviewer_select`.
2. On a server, if the reviewer chose yes: `tht datamart generate` (stub — raises
NotImplementedError for now). Tell
the reviewer that dbt generation is not implemented yet. the reviewer that dbt generation is not implemented yet.
3. **Memory promotion closes the session.** Call `reviewer_memory_promote` with ONLY 3. **Memory promotion closes the session.** Call `reviewer_memory_promote` with ONLY
the session id: the gate computes the candidates itself (`tht memory promote the session id: the gate computes the candidates itself (`tht memory promote
+5 -6
View File
@@ -9,8 +9,7 @@ already discarded (even after a Phase 2 reopen).
For each memory to present in the checklist, include in the option's `label` and/or For each memory to present in the checklist, include in the option's `label` and/or
`description`: `description`:
- **What it says**: type + subject + detail (e.g. "table_promoted: - **What it says**: the clarified concept, its subject, and its full definition.
fact_seeablazione — main table for ablazioni").
- **Where it comes from**: question_context and origin session_id. - **Where it comes from**: question_context and origin session_id.
- **Why it might apply here**: overlap of concepts/tables with the current question - **Why it might apply here**: overlap of concepts/tables with the current question
(fields tables/concepts), similarity score. (fields tables/concepts), similarity score.
@@ -19,10 +18,10 @@ For each memory to present in the checklist, include in the option's `label` and
Rules: Rules:
- Propose at most **5** candidates. Include ONLY memories of the 3 reusable types: - Propose at most **5** candidates. Include ONLY `concept_clarified` memories.
`concept_clarified`, `table_promoted`, `table_excluded`. Query-specific decisions Table choices (`table_promoted`, `table_excluded`) and all other query-specific
(e.g. `question_rewritten`, `sql_approved`) are NOT to be proposed: they don't decisions are not memories: never store, retrieve, or propose them because they
transfer to other questions. do not transfer to other questions.
- All candidate memories go in **a single** `reviewer_decide(multi:true, - All candidate memories go in **a single** `reviewer_decide(multi:true,
advance:true, allow_empty:true)`: every option describes a candidate memory, never advance:true, allow_empty:true)`: every option describes a candidate memory, never
an opposite action such as "do not use it". Each selected option is applied an opposite action such as "do not use it". Each selected option is applied
@@ -34,9 +34,10 @@ def test_save_one_upserts_to_real_pgvector(l2_env):
record = MemoryRecord( record = MemoryRecord(
id="mem-l2test", ts=datetime.now(), session_id="l2-self-test", id="mem-l2test", ts=datetime.now(), session_id="l2-self-test",
decision_seq=999, type="table_promoted", subject="fct_ricoveri", decision_seq=999, type="concept_clarified", subject="ablazione recente",
detail="ablazione", rationale="L2 self-test (idempotent)", detail="evento di ablazione negli ultimi 15 anni",
question_context="ablazione 2025", tables=["fct_ricoveri"], concepts=[], rationale="L2 self-test (idempotent)",
question_context="ablazione 2025", tables=[], concepts=["ablazione recente"],
) )
from tht.adapters.vector import ThothHttpVectorStore from tht.adapters.vector import ThothHttpVectorStore
@@ -68,9 +68,11 @@ def test_memory_command_writes_through_factory_vector_store(monkeypatch):
cfg = SimpleNamespace(profile="server", embeddings=object(), vector_write_rest=None) cfg = SimpleNamespace(profile="server", embeddings=object(), vector_write_rest=None)
manifest = SimpleNamespace(id="s1") manifest = SimpleNamespace(id="s1")
snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={}) snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={})
record = MemoryRecord(id="m1", ts=datetime(2026, 1, 1), session_id="s1", record = MemoryRecord(
decision_seq=7, type="table_promoted", subject="t", id="m1", ts=datetime(2026, 1, 1), session_id="s1",
question_context="q") decision_seq=7, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", question_context="q",
)
monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda path: cfg) monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda path: cfg)
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot) monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(memory_cmd, "registry_path", lambda cfg: None) monkeypatch.setattr(memory_cmd, "registry_path", lambda cfg: None)
+1 -1
View File
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
("concept_clarified", 1), ("concept_clarified", 1),
("memory_rejected", 2), ("memory_rejected", 2),
("question_rewritten", 3), ("question_rewritten", 3),
("table_promoted", 2), ("table_promoted", 4),
("column_corrected", 4), ("column_corrected", 4),
("evidence_accepted", 4), ("evidence_accepted", 4),
("value_grounded", 4), ("value_grounded", 4),
+9 -9
View File
@@ -13,9 +13,9 @@ from tht.memory import MemoryRecord, memory_vector_records
def _record(**kw) -> MemoryRecord: def _record(**kw) -> MemoryRecord:
base = dict( base = dict(
id="mem-x", ts=datetime(2025, 1, 1), session_id="s", decision_seq=1, id="mem-x", ts=datetime(2025, 1, 1), session_id="s", decision_seq=1,
type="table_promoted", subject="dim_pazienti", detail="promossa", type="concept_clarified", subject="paziente attivo", detail="flag_attivo = TRUE",
rationale="perche' serve", question_context="dammi pazienti", rationale="perche' serve", question_context="dammi pazienti",
tables=["t"], concepts=[], tables=[], concepts=["paziente attivo"],
) )
base.update(kw) base.update(kw)
return MemoryRecord(**base) return MemoryRecord(**base)
@@ -23,17 +23,17 @@ def _record(**kw) -> MemoryRecord:
def test_memory_vector_record_has_subject_detail_rationale_in_metadata(): def test_memory_vector_record_has_subject_detail_rationale_in_metadata():
vr = memory_vector_records([_record()])[0] vr = memory_vector_records([_record()])[0]
assert vr.metadata["subject"] == "dim_pazienti" assert vr.metadata["subject"] == "paziente attivo"
assert vr.metadata["detail"] == "promossa" assert vr.metadata["detail"] == "flag_attivo = TRUE"
assert vr.metadata["rationale"] == "perche' serve" assert vr.metadata["rationale"] == "perche' serve"
def test_memory_vector_record_metadata_keeps_existing_fields(): def test_memory_vector_record_metadata_keeps_existing_fields():
vr = memory_vector_records([_record()])[0] vr = memory_vector_records([_record()])[0]
# i campi che gia' c'erano restano (backward compat) # i campi che gia' c'erano restano (backward compat)
assert vr.metadata["type"] == "table_promoted" assert vr.metadata["type"] == "concept_clarified"
assert vr.metadata["tables"] == ["t"] assert vr.metadata["tables"] == []
assert vr.metadata["concepts"] == [] assert vr.metadata["concepts"] == ["paziente attivo"]
assert vr.metadata["session_id"] == "s" assert vr.metadata["session_id"] == "s"
@@ -62,6 +62,6 @@ def test_save_one_memory_preserves_subject_through_upsert_row():
save_one_memory([_record(decision_seq=1)], decision_seq=1, store=writer, embedder=embedder) save_one_memory([_record(decision_seq=1)], decision_seq=1, store=writer, embedder=embedder)
row = writer.upsert.call_args[0][1][0] row = writer.upsert.call_args[0][1][0]
md = row.record.metadata md = row.record.metadata
assert md["subject"] == "dim_pazienti" assert md["subject"] == "paziente attivo"
assert md["detail"] == "promossa" assert md["detail"] == "flag_attivo = TRUE"
assert md["rationale"] == "perche' serve" assert md["rationale"] == "perche' serve"
+52 -1
View File
@@ -9,7 +9,13 @@ la rende un passo del workflow. Questi test fissano il contratto harness-side:
from datetime import datetime from datetime import datetime
from tht.decisions import DecisionRecord, append_decision from tht.decisions import DecisionRecord, append_decision
from tht.memory import declined_promotion_seqs, reusable_promotions from tht.memory import (
MemoryRecord,
declined_promotion_seqs,
memory_vector_records,
promote,
reusable_promotions,
)
from tht.session.models import SessionManifest from tht.session.models import SessionManifest
from tht.workflow import load_workflow from tht.workflow import load_workflow
@@ -62,3 +68,48 @@ def test_reusable_promotions_exclude_declined(tmp_path):
subject="fact_a", detail="seq:1") # seq 3 subject="fact_a", detail="seq:1") # seq 3
cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl") cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl")
assert [c.decision_seq for c in cand] == [2] assert [c.decision_seq for c in cand] == [2]
def test_reusable_promotions_deduplicate_identical_memory_content(tmp_path):
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", rationale="scelta reviewer")
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", rationale="scelta reviewer")
cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl")
assert [c.decision_seq for c in cand] == [1]
def test_only_concept_clarified_is_proposed_or_promoted(tmp_path):
append_decision(tmp_path, type="table_promoted", subject="fact_a",
detail="tabella principale")
append_decision(tmp_path, type="table_excluded", subject="fact_b",
detail="tabella non pertinente")
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE")
registry = tmp_path / "registry.jsonl"
candidates = reusable_promotions(tmp_path, _manifest(), registry)
promoted = promote(tmp_path, _manifest(), seqs=[1, 2, 3], registry_path=registry)
assert [(c.decision_seq, c.type) for c in candidates] == [(3, "concept_clarified")]
assert [(c.decision_seq, c.type) for c in promoted] == [(3, "concept_clarified")]
def test_legacy_table_records_are_not_published_as_memory_vectors():
records = [
MemoryRecord(
id="mem-0001", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=1, type="table_promoted", subject="fact_a",
),
MemoryRecord(
id="mem-0002", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=2, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE",
),
]
vectors = memory_vector_records(records)
assert [record.ref for record in vectors] == ["mem-0002"]
+3 -2
View File
@@ -16,8 +16,9 @@ from tht.memory import MemoryRecord, memory_vector_record_for_decision, save_one
def _record(seq: int = 7, **kw) -> MemoryRecord: def _record(seq: int = 7, **kw) -> MemoryRecord:
base = dict( base = dict(
id="mem-0007", ts=datetime(2025, 1, 1), session_id="s1", decision_seq=seq, id="mem-0007", ts=datetime(2025, 1, 1), session_id="s1", decision_seq=seq,
type="table_promoted", subject="pazienti", detail="promossa", rationale="r", type="concept_clarified", subject="paziente attivo",
question_context="dammi i pazienti", tables=["pazienti"], concepts=[], detail="flag_attivo = TRUE", rationale="r",
question_context="dammi i pazienti", tables=[], concepts=["paziente attivo"],
) )
base.update(kw) base.update(kw)
return MemoryRecord(**base) return MemoryRecord(**base)
@@ -54,6 +54,10 @@ def test_psd_overlay_uses_generated_workspace_for_default_and_named_commands():
assert core["networks"]["default"]["aliases"] == ["core", "thothii-core"] assert core["networks"]["default"]["aliases"] == ["core", "thothii-core"]
frontend = compose["services"]["frontend"] frontend = compose["services"]["frontend"]
assert frontend["ports"] == ["127.0.0.1:8099:8080"] assert frontend["ports"] == ["127.0.0.1:8099:8080"]
assert frontend["build"]["args"] == {
"VITE_BASE": "/",
"VITE_BACKEND_URL": "/api",
}
assert frontend["networks"] == { assert frontend["networks"] == {
"default": {"aliases": ["frontend", "thothii-frontend"]} "default": {"aliases": ["frontend", "thothii-frontend"]}
} }
+43
View File
@@ -7,10 +7,12 @@ puro (`[]` in modalita' --json) ed exit 0, cosi' il modello prosegue senza
exemplar. Il finalize-hook gestisce gia' lo stesso scenario in modo analogo. exemplar. Il finalize-hook gestisce gia' lo stesso scenario in modo analogo.
""" """
import json import json
from datetime import datetime
from typer.testing import CliRunner from typer.testing import CliRunner
from tht.cli import app from tht.cli import app
from tht.memory import MemoryRecord, save_registry
from tht.ports.vector import VectorReadUnavailable from tht.ports.vector import VectorReadUnavailable
from tht.vectorstore.rest_client import VectorRestError from tht.vectorstore.rest_client import VectorRestError
from tht.vectorstore.store import VectorHit from tht.vectorstore.store import VectorHit
@@ -97,3 +99,44 @@ def test_solved_search_json_maps_hit_metadata(tmp_path, monkeypatch):
"session_id": "s1", "question": "quante ablazioni nel 2023", "session_id": "s1", "question": "quante ablazioni nel 2023",
"sql": "SELECT 1", "tables": ["fact_seeablazione"], "score": 0.91, "sql": "SELECT 1", "tables": ["fact_seeablazione"], "score": 0.91,
}] }]
def test_memory_search_excludes_legacy_table_records(tmp_path, monkeypatch):
records = [
MemoryRecord(
id="mem-0001", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=1, type="table_promoted", subject="fact_pazienti",
),
MemoryRecord(
id="mem-0002", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=2, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE",
),
]
cfg = _cfg(tmp_path)
save_registry(records, tmp_path / "a" / "memory" / "registry.jsonl")
class FakeSearcher:
def search(self, vec, top_n=10, kinds=None):
return [
VectorHit(
id=f"memory:{record.id}", kind="memory", ref=record.id,
title=record.subject, content=record.detail, metadata={},
similarity=0.9,
)
for record in records
]
class FakeEmbedder:
def embed_query(self, text):
return [0.1] * 8
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda workspace: FakeSearcher())
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda embeddings: FakeEmbedder())
res = CliRunner().invoke(
app, ["memory", "search", "pazienti", "--json", "-c", str(cfg)]
)
assert res.exit_code == 0, res.output
assert [record["id"] for record in json.loads(res.stdout)] == ["mem-0002"]
+3 -1
View File
@@ -384,7 +384,7 @@ 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 decided_memory_ids, load_registry from tht.memory import REUSABLE_TYPES, decided_memory_ids, load_registry
cfg = _load_config_or_exit(config) cfg = _load_config_or_exit(config)
require_vector_cfg(cfg) require_vector_cfg(cfg)
@@ -401,6 +401,8 @@ def search_cmd(
rec = by_id.get(h.ref) rec = by_id.get(h.ref)
if rec is None: if rec is None:
continue # indice piu' avanti del registro: ignora 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: if rec.id in excluded:
continue # gia' decisa in questa sessione: non riproporla continue # gia' decisa in questa sessione: non riproporla
results.append({ results.append({
+39 -17
View File
@@ -115,14 +115,6 @@ def delete_record(path: Path, mem_id: str) -> MemoryRecord:
raise MemoryNotFound(mem_id) raise MemoryNotFound(mem_id)
def _default_tables(decision: DecisionRecord) -> list[str]:
if decision.type in ("table_promoted", "table_excluded"):
return [decision.subject]
if decision.type in ("column_corrected", "join_modified") and "." in decision.subject:
return [decision.subject.split(".")[0]]
return []
def _default_concepts(decision: DecisionRecord) -> list[str]: def _default_concepts(decision: DecisionRecord) -> list[str]:
if decision.type == "concept_clarified": if decision.type == "concept_clarified":
return [decision.subject] return [decision.subject]
@@ -139,9 +131,9 @@ def _next_id_num(existing: list[MemoryRecord]) -> int:
return (max(nums) + 1) if nums else 1 return (max(nums) + 1) if nums else 1
# Solo questi tipi di decisione sono concetti riusabili in altre generazioni # Solo i concetti chiariti sono riusabili tra domande. Le scelte sulle tabelle
# (scelta reviewer): il resto e' query-specifico e non va proposto in promozione. # dipendono dallo schema-linking della singola domanda e non sono memory.
REUSABLE_TYPES = frozenset({"concept_clarified", "table_promoted", "table_excluded"}) REUSABLE_TYPES = frozenset({"concept_clarified"})
MAX_PROMOTION_CANDIDATES = 5 MAX_PROMOTION_CANDIDATES = 5
@@ -159,6 +151,8 @@ def _compute_promotions(
context = question_context(decisions, manifest) context = question_context(decisions, manifest)
out: list[MemoryRecord] = [] out: list[MemoryRecord] = []
for d in selected: for d in selected:
if d.type not in REUSABLE_TYPES:
continue
if (manifest.id, d.seq) in already: if (manifest.id, d.seq) in already:
continue continue
out.append( out.append(
@@ -166,7 +160,7 @@ def _compute_promotions(
id=f"mem-{n:04d}", ts=datetime.now(UTC), session_id=manifest.id, id=f"mem-{n:04d}", ts=datetime.now(UTC), session_id=manifest.id,
decision_seq=d.seq, type=d.type, subject=d.subject, detail=d.detail, decision_seq=d.seq, type=d.type, subject=d.subject, detail=d.detail,
rationale=d.rationale, question_context=context, rationale=d.rationale, question_context=context,
tables=_default_tables(d), concepts=_default_concepts(d), tables=[], concepts=_default_concepts(d),
) )
) )
n += 1 n += 1
@@ -206,9 +200,10 @@ def reusable_promotions(
session_dir, manifest, seqs=None, existing=load_registry(registry_path) session_dir, manifest, seqs=None, existing=load_registry(registry_path)
) )
declined = declined_promotion_seqs(effective_decisions(session_dir)) declined = declined_promotion_seqs(effective_decisions(session_dir))
return [ reusable = [
c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined
] ]
return _dedupe_reusable_promotions(reusable)
def reusable_promotions_snapshot(snapshot, registry_path: Path) -> list[MemoryRecord]: def reusable_promotions_snapshot(snapshot, registry_path: Path) -> list[MemoryRecord]:
@@ -216,7 +211,30 @@ def reusable_promotions_snapshot(snapshot, registry_path: Path) -> list[MemoryRe
cand = _compute_promotions(snapshot, snapshot.manifest, seqs=None, existing=load_registry(registry_path)) cand = _compute_promotions(snapshot, snapshot.manifest, seqs=None, existing=load_registry(registry_path))
declined = declined_promotion_seqs(effective_decisions(snapshot)) declined = declined_promotion_seqs(effective_decisions(snapshot))
return [c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined] reusable = [c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined]
return _dedupe_reusable_promotions(reusable)
def _dedupe_reusable_promotions(records: list[MemoryRecord]) -> list[MemoryRecord]:
"""Keep the first proposal for identical reviewer-visible memory content."""
seen: set[tuple[str, str, str, str, str]] = set()
out: list[MemoryRecord] = []
for record in records:
key = tuple(
" ".join(value.split()).casefold()
for value in (
record.type,
record.subject,
record.detail,
record.rationale,
record.question_context,
)
)
if key in seen:
continue
seen.add(key)
out.append(record)
return out
def preview_promotions( def preview_promotions(
@@ -235,6 +253,8 @@ def preview_promotions_snapshot(snapshot, registry_path: Path) -> list[MemoryRec
def memory_vector_records(records: list[MemoryRecord]) -> list[VectorRecord]: def memory_vector_records(records: list[MemoryRecord]) -> list[VectorRecord]:
out: list[VectorRecord] = [] out: list[VectorRecord] = []
for r in records: for r in records:
if r.type not in REUSABLE_TYPES:
continue
lines = [ lines = [
f"Decisione {r.type}: {r.subject}", f"Decisione {r.type}: {r.subject}",
r.detail, r.detail,
@@ -268,10 +288,12 @@ def memory_vector_record_for_decision(
D11 save-one builds only this one record (not the full memory_vector_records D11 save-one builds only this one record (not the full memory_vector_records
list) so the remote upsert is a single row. list) so the remote upsert is a single row.
""" """
match = [r for r in records if r.decision_seq == decision_seq] vectors = memory_vector_records(
if not match: [r for r in records if r.decision_seq == decision_seq]
)
if not vectors:
return None return None
return memory_vector_records(match)[0] return vectors[0]
def save_one_memory( def save_one_memory(
+1 -1
View File
@@ -21,7 +21,7 @@ phases:
advance: auto_if_empty advance: auto_if_empty
prerequisites: [] prerequisites: []
artifacts_out: [] artifacts_out: []
emits: [memory_rejected, table_promoted, table_excluded] emits: [memory_rejected, concept_clarified]
- id: F3 - id: F3
name: riscrittura name: riscrittura
advance: kind:phase advance: kind:phase