refactor(memory): extract F2 recall path (#23)

This commit is contained in:
2026-08-24 01:08:15 +02:00
parent beac2e80f4
commit 93fe0d733b
8 changed files with 585 additions and 236 deletions
@@ -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/);
});
+164 -4
View File
@@ -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),
};
}
+32 -111
View File
@@ -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",
+168
View File
@@ -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"])]
+10 -23
View File
@@ -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))
+42
View File
@@ -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],