diff --git a/harness/.pi/extensions/gate/__tests__/gate_decide_empty.test.js b/harness/.pi/extensions/gate/__tests__/gate_decide_empty.test.js deleted file mode 100644 index 036d6985..00000000 --- a/harness/.pi/extensions/gate/__tests__/gate_decide_empty.test.js +++ /dev/null @@ -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); -}); diff --git a/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js index 08f5c2e4..100114d4 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js @@ -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", diff --git a/harness/.pi/extensions/gate/__tests__/gate_memory_selection.test.js b/harness/.pi/extensions/gate/__tests__/gate_memory_selection.test.js index ce92041f..fe654550 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_memory_selection.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_memory_selection.test.js @@ -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/); }); diff --git a/harness/.pi/extensions/gate/memory/index.js b/harness/.pi/extensions/gate/memory/index.js index a5d9dfb1..e7da0fc9 100644 --- a/harness/.pi/extensions/gate/memory/index.js +++ b/harness/.pi/extensions/gate/memory/index.js @@ -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), + }; +} diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 25c5f9c5..c6c06b57 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -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", diff --git a/harness/tests/memory/test_recall.py b/harness/tests/memory/test_recall.py new file mode 100644 index 00000000..bed1cc0d --- /dev/null +++ b/harness/tests/memory/test_recall.py @@ -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"])] diff --git a/harness/tht/cli/memory_cmd.py b/harness/tht/cli/memory_cmd.py index 90c92765..c9ca446c 100644 --- a/harness/tht/cli/memory_cmd.py +++ b/harness/tht/cli/memory_cmd.py @@ -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)) diff --git a/harness/tht/memory.py b/harness/tht/memory.py index 33f47be8..2d720b0a 100644 --- a/harness/tht/memory.py +++ b/harness/tht/memory.py @@ -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],