feat: implement memory and evidence administration with guided repairs
Publish documentation / publish (push) Successful in 1m27s
Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
This commit is contained in:
@@ -1,141 +1,90 @@
|
||||
"""L1: `tht memory solved-search` — degrado gentile e mapping dei risultati.
|
||||
|
||||
SKILL.md prescrive solved-search in F4/F6/F7 di OGNI sessione: se lo store
|
||||
semantico è irraggiungibile (Qdrant/Ollama non disponibili) il comando non deve morire con un
|
||||
traceback grezzo ma degradare a un avviso di una riga su stderr, con stdout
|
||||
puro (`[]` in modalita' --json) ed exit 0, cosi' il modello prosegue senza
|
||||
exemplar. Il finalize-hook gestisce gia' lo stesso scenario in modo analogo.
|
||||
"""
|
||||
"""CLI adaptation: trusted context, verified recall and explicit availability failures."""
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.cli import app
|
||||
from tht.memory import MemoryRecord, save_registry
|
||||
from tht.memory.models import MemoryUnavailable
|
||||
from tht.ports.vector import VectorReadUnavailable, VectorStoreError
|
||||
from tht.vectorstore.store import VectorHit
|
||||
|
||||
|
||||
def _cfg(tmp_path):
|
||||
cfg = tmp_path / "workspace.yaml"
|
||||
cfg.write_text(
|
||||
"database: {database: d, schema: s, user: u, password: p, transport: direct}\n"
|
||||
f"paths: {{artifacts: {tmp_path/'a'}, indexes: {tmp_path/'i'}, sessions: {tmp_path/'se'}}}\n"
|
||||
"vector_db: {database: v, schema: vectors, user: u, password: p, transport: direct}\n"
|
||||
"embeddings: {base_url: 'http://localhost:11434', model: qwen3-embedding:0.6b, dim: 1024}\n"
|
||||
)
|
||||
return cfg
|
||||
@pytest.fixture
|
||||
def runtime(monkeypatch):
|
||||
service = SimpleNamespace(close=lambda: None, recall=lambda *a, **kw: [])
|
||||
monkeypatch.setattr("tht.cli.memory_cmd._load_config_or_exit", lambda path: object())
|
||||
# The config is supplied by the runner; mapping to the service is tested here.
|
||||
monkeypatch.setattr("tht.cli.memory_cmd._load_config_or_exit",
|
||||
lambda path: SimpleNamespace(embeddings=object(),
|
||||
database=SimpleNamespace(database="sales", db_schema="public")))
|
||||
monkeypatch.setattr("tht.cli.memory_cmd.memory_service", lambda cfg: service)
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: object())
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: object())
|
||||
return service
|
||||
|
||||
|
||||
def test_solved_search_degrades_when_vectordb_unreachable(tmp_path, monkeypatch):
|
||||
@pytest.mark.parametrize("failure", [VectorStoreError, VectorReadUnavailable])
|
||||
def test_solved_search_warns_when_vector_is_unavailable(runtime, monkeypatch, failure):
|
||||
def boom(cfg):
|
||||
raise VectorStoreError("Qdrant non raggiungibile")
|
||||
|
||||
raise failure("private adapter details")
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", boom)
|
||||
res = CliRunner().invoke(
|
||||
app, ["memory", "solved-search", "quante ablazioni", "--json", "-c", str(_cfg(tmp_path))]
|
||||
)
|
||||
assert res.exit_code == 0, res.output
|
||||
assert json.loads(res.stdout) == [] # stdout puro: JSON valido
|
||||
assert "exemplar non disponibili" in res.stderr
|
||||
result = CliRunner().invoke(app, ["memory", "solved-search", "orders", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.stdout) == []
|
||||
assert "exemplar non disponibili" in result.stderr
|
||||
assert "private adapter details" not in result.output
|
||||
|
||||
|
||||
def test_solved_search_degrades_direct_vector_read_error(tmp_path, monkeypatch):
|
||||
def boom(cfg):
|
||||
raise VectorReadUnavailable("Vector read operation unavailable")
|
||||
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", boom)
|
||||
res = CliRunner().invoke(
|
||||
app, ["memory", "solved-search", "quante ablazioni", "--json", "-c", str(_cfg(tmp_path))]
|
||||
)
|
||||
assert res.exit_code == 0, res.output
|
||||
assert json.loads(res.stdout) == []
|
||||
assert "exemplar non disponibili" in res.stderr
|
||||
def test_solved_search_passes_only_verified_service_payload(runtime):
|
||||
def recall(question, **kwargs):
|
||||
assert question == "orders"
|
||||
assert kwargs["solved"] and kwargs["top"] == 3
|
||||
assert kwargs["scope"].database == "sales"
|
||||
assert kwargs["scope"].schema_name == "public"
|
||||
return [{"id": "mem-id", "question": "Orders?", "sql": "select 1", "tables": []}]
|
||||
runtime.recall = recall
|
||||
result = CliRunner().invoke(app, ["memory", "solved-search", "orders", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.stdout)[0]["sql"] == "select 1"
|
||||
|
||||
|
||||
def test_solved_search_degrades_human_mode(tmp_path, monkeypatch):
|
||||
def boom(cfg):
|
||||
raise VectorStoreError("Qdrant non raggiungibile")
|
||||
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", boom)
|
||||
res = CliRunner().invoke(
|
||||
app, ["memory", "solved-search", "quante ablazioni", "-c", str(_cfg(tmp_path))]
|
||||
)
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "exemplar non disponibili" in res.stderr
|
||||
assert "Traceback" not in res.stderr
|
||||
def test_archive_failure_is_not_an_empty_success(runtime):
|
||||
def recall(*args, **kwargs):
|
||||
raise MemoryUnavailable("Memory archive is unavailable")
|
||||
runtime.recall = recall
|
||||
result = CliRunner().invoke(app, ["memory", "solved-search", "orders", "--json"])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stdout)["status"] == 503
|
||||
|
||||
|
||||
def test_solved_search_json_maps_hit_metadata(tmp_path, monkeypatch):
|
||||
hit = VectorHit(
|
||||
id="solved:s1", kind="solved_question", ref="s1", title="quante ablazioni nel 2023",
|
||||
content="quante ablazioni nel 2023",
|
||||
metadata={
|
||||
"session_id": "s1", "question": "quante ablazioni nel 2023",
|
||||
"sql": "SELECT 1", "tables": ["fact_seeablazione"],
|
||||
},
|
||||
similarity=0.91,
|
||||
)
|
||||
|
||||
class FakeSearcher:
|
||||
def search(self, vec, top_n=10, kinds=None):
|
||||
assert kinds == ["solved_question"]
|
||||
return [hit]
|
||||
|
||||
class FakeEmbedder:
|
||||
def embed_query(self, text):
|
||||
return [0.1] * 8
|
||||
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: FakeSearcher())
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda e: FakeEmbedder())
|
||||
res = CliRunner().invoke(
|
||||
app, ["memory", "solved-search", "quante ablazioni", "--json", "-c", str(_cfg(tmp_path))]
|
||||
)
|
||||
assert res.exit_code == 0, res.output
|
||||
data = json.loads(res.stdout)
|
||||
assert data == [{
|
||||
"session_id": "s1", "question": "quante ablazioni nel 2023",
|
||||
"sql": "SELECT 1", "tables": ["fact_seeablazione"], "score": 0.91,
|
||||
}]
|
||||
def test_missing_principal_cannot_bypass_admin(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("THT_PRINCIPAL_ISSUER", raising=False)
|
||||
monkeypatch.delenv("THT_PRINCIPAL_SUBJECT", raising=False)
|
||||
request = tmp_path / "request.json"
|
||||
request.write_text(json.dumps({"action": "list", "runtime": {}}))
|
||||
result = CliRunner().invoke(app, ["memory", "admin", "--workspace", "sales", "-c", str(request)])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stdout)["status"] == 403
|
||||
|
||||
|
||||
def test_memory_search_excludes_legacy_table_records(tmp_path, monkeypatch):
|
||||
records = [
|
||||
MemoryRecord(
|
||||
id="mem-0001", ts=datetime(2026, 1, 1, tzinfo=UTC), session_id="s1",
|
||||
decision_seq=1, type="table_promoted", subject="fact_pazienti",
|
||||
),
|
||||
MemoryRecord(
|
||||
id="mem-0002", ts=datetime(2026, 1, 1, tzinfo=UTC), 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")
|
||||
@pytest.mark.parametrize("command", ["search", "solved-search"])
|
||||
def test_recall_cannot_override_runtime_database_context(runtime, command):
|
||||
result = CliRunner().invoke(app, ["memory", command, "orders", "--json",
|
||||
"--filters", '{"database":"outside"}'])
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stdout)["status"] == 400
|
||||
|
||||
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"]
|
||||
@pytest.mark.parametrize("command", ["search", "solved-search"])
|
||||
def test_recall_passes_explicit_business_and_table_filters(runtime, command):
|
||||
def recall(question, **kwargs):
|
||||
scope = kwargs["scope"]
|
||||
assert (scope.database, scope.schema_name, scope.table, scope.scope) == (
|
||||
"sales", "public", "orders", "Sales")
|
||||
assert scope.concepts == ["grain"]
|
||||
return []
|
||||
runtime.recall = recall
|
||||
result = CliRunner().invoke(app, ["memory", command, "orders", "--json", "--filters",
|
||||
'{"table":"orders","scope":"Sales","concepts":["grain"]}'])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
Reference in New Issue
Block a user