feat: implement memory and evidence administration with guided repairs
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:
Codex
2026-09-10 10:31:34 +02:00
parent 8fe526dd6e
commit 82e2c91f42
168 changed files with 11914 additions and 1772 deletions
+13
View File
@@ -66,9 +66,22 @@
"config check",
"db fetch-ca",
"doctor",
"memory admin",
"memory create",
"memory delete",
"memory index",
"memory list",
"memory pending",
"memory propose",
"memory repair-target",
"memory repair-prepare",
"memory repair-show",
"memory repair-apply",
"memory repairs",
"memory summary",
"memory review-apply",
"memory rules",
"memory retry",
"memory show",
"memory update"
],
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -1,5 +1,6 @@
import json
import uuid
from contextlib import nullcontext
from datetime import UTC, datetime
from types import SimpleNamespace
@@ -7,7 +8,9 @@ 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.memory import MemoryRecord, recall_memories
from tht.memory.models import Card
from tht.memory.service import MemoryService
from tht.phase import current_phase
from tht.session.filesystem_repository import FilesystemSessionRepository
from tht.session.models import PrincipalContext, SessionManifest
@@ -42,7 +45,7 @@ class Searcher:
self.hits = hits
self.calls = []
def search(self, embedding, *, top_n, kinds):
def search(self, embedding, *, top_n, kinds, **kwargs):
self.calls.append((embedding, top_n, kinds))
return self.hits
@@ -145,11 +148,21 @@ def test_recall_cli_reconstructs_applied_and_rejected_memory_from_persisted_f2_s
records = [_memory("mem-0001"), _memory("mem-0003")]
searcher = Searcher([
SimpleNamespace(ref="mem-0003", similarity=0.9),
SimpleNamespace(ref="mem-0001", similarity=0.8),
SimpleNamespace(ref="mem-0003", similarity=0.9,
metadata={"memory_revision": "r", "memory_format": 2}),
SimpleNamespace(ref="mem-0001", similarity=0.8,
metadata={"memory_revision": "r", "memory_format": 2}),
])
embedder = Embedder()
save_registry(records, tmp_path / "artifacts" / "memory" / "registry.jsonl")
cards = {r.id: Card(id=r.id, family="domain_clarification", subject=r.subject,
detail=r.detail, scope="psd-clinical", workspace_id="psd-clinical", origin="workflow",
created_at=r.ts, updated_at=r.ts, revision="r", indexed=True) for r in records}
archive = SimpleNamespace(list=lambda query: {}, get=lambda identity: cards[identity],
close=lambda: None)
archive.operation = lambda: nullcontext(archive)
service = MemoryService(archive, PrincipalContext(issuer="local", subject="reviewer"),
store_factory=lambda: None, embedder_factory=Embedder)
monkeypatch.setattr("tht.cli.memory_cmd.memory_service", lambda cfg: service)
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: searcher)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: embedder)
@@ -165,4 +178,4 @@ def test_recall_cli_reconstructs_applied_and_rejected_memory_from_persisted_f2_s
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"])]
assert searcher.calls == [([0.1, 0.2], 20, ["memory"])]
+121
View File
@@ -0,0 +1,121 @@
"""Deterministic retrieval policy tests, separate from actual embedding/Qdrant recovery."""
from contextlib import nullcontext
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from tht.memory.models import Card, MemoryNotFound
from tht.memory.retrieval import RecallScope, expand_and_rank
def card(identity, **values):
return Card(id=identity, workspace_id="sales", family="domain_clarification",
subject=identity, scope="Sales", origin="manual", revision="r", indexed=True,
created_at=datetime.now(UTC), updated_at=datetime.now(UTC), **values)
class Archive:
def __init__(self, *cards):
self.cards = {c.id: c for c in cards}
self.reads = []
def get(self, identity):
self.reads.append(identity)
if identity not in self.cards:
raise MemoryNotFound(identity)
return self.cards[identity]
def operation(self):
return nullcontext(self)
def hit(identity, **metadata):
return SimpleNamespace(ref=identity, metadata={"memory_revision": "r", "memory_format": 2,
**metadata})
def link(identity):
return {"target_id": identity, "meaning": "Requires the grain clarification"}
def rank(repo, hits, **kwargs):
return expand_and_rank(repo, hits, scope=kwargs.pop("scope", RecallScope()),
family="domain_clarification", excluded=kwargs.pop("excluded", set()), top=100, **kwargs)
def test_link_only_candidates_cycles_duplicates_depth_and_current_content():
repo = Archive(card("a", links=[link("b")]),
card("b", detail="Current correction", links=[link("a"), link("c")]),
card("c", links=[link("d")]), card("d"))
results = rank(repo, [hit("a"), hit("a")])
assert [r.card.id for r in results] == ["a", "b", "c"]
assert results[1].card.detail == "Current correction"
assert results[2].path == ("a", "b", "c")
assert len(repo.reads) == len(set(repo.reads)) == 3
assert rank(repo, [hit("a")]) == results # duplicate seeds do not boost a score
def test_direct_and_graph_candidates_are_reranked_together_without_cycle_boost():
repo = Archive(card("a", links=[link("c")]), card("b"), card("c", links=[link("a")]))
results = rank(repo, [hit("a"), hit("b"), hit("c")])
assert [r.card.id for r in results] == ["a", "c", "b"]
assert len(results) == 3
@pytest.mark.parametrize("invalid", ["missing", "pending", "excluded", "wrong_scope", "family"])
def test_ineligible_link_targets_cannot_be_returned_or_used_as_bridges(invalid):
target = card("b", links=[link("c")])
if invalid == "pending":
target.indexed = False
if invalid == "wrong_scope":
target.scope = "Purchases"
if invalid == "family":
target.family = "sql_rule"
repo = Archive(card("a", links=[link("b")]), card("c"),
*([] if invalid == "missing" else [target]))
results = rank(repo, [hit("a")], scope=RecallScope(scope="Sales"),
excluded={"b"} if invalid == "excluded" else set())
assert [r.card.id for r in results] == ["a"]
@pytest.mark.parametrize("metadata", [{"memory_revision": "old"}, {"memory_format": 1}])
def test_stale_seeds_do_not_expand(metadata):
repo = Archive(card("a", links=[link("b")]), card("b"))
assert rank(repo, [hit("a", **metadata)]) == []
def test_physical_scope_matches_one_dependency_and_includes_workspace_and_parent_rules():
scope = RecallScope(database="sales", schema_name="public", table="orders", column="id")
assert scope.matches(card("global"))
assert scope.matches(card("database", dependencies=[{"database": "sales"}]))
assert scope.matches(card("table", dependencies=[{"database": "sales",
"schema_name": "public", "table": "orders"}]))
assert not scope.matches(card("split", dependencies=[
{"database": "sales", "schema_name": "public", "table": "orders", "column": "amount"},
{"database": "purchases", "schema_name": "public", "table": "orders", "column": "id"},
]))
assert not scope.matches(card("schema", dependencies=[
{"database": "sales", "schema_name": "audit", "table": "orders", "column": "id"},
]))
def test_scope_and_concepts_are_explicit_and_combined():
scope = RecallScope(scope="Sales", concepts=["orders", "grain"])
assert scope.matches(card("a", concepts=["orders", "grain"]))
assert not scope.matches(card("b", concepts=["orders"]))
with pytest.raises(ValueError):
RecallScope(column="id")
def test_fanout_and_total_visits_are_bounded():
cards = [card(f"c{i:04}", links=[link(f"c{j:04}") for j in range(i+1, i+101)])
for i in range(500)]
repo = Archive(*cards)
results = rank(repo, [hit("c0000")])
assert "c0100" not in {r.card.id for r in results}
assert len(repo.reads) <= 200
repo.reads.clear()
rank(repo, [hit(c.id) for c in cards[:100]])
assert len(repo.reads) <= 200
@@ -9,7 +9,7 @@ from tht.session.filesystem_repository import FilesystemSessionRepository
from tht.session.models import PrincipalContext, SessionManifest
def test_finalize_commits_session_before_best_effort_post_commit_read_failure(
def test_finalize_commits_session_without_implicitly_reading_or_saving_memory(
tmp_path, monkeypatch, capsys
):
repository = FilesystemSessionRepository(
@@ -83,5 +83,5 @@ def test_finalize_commits_session_before_best_effort_post_commit_read_failure(
captured = capsys.readouterr()
assert repository.get(session_id).manifest.status == "finalized"
assert "finalized snapshot unavailable" in captured.err
assert "finalized snapshot unavailable" not in captured.err
assert f"OK: sessione {session_id} finalizzata" in captured.out
@@ -6,7 +6,6 @@ import typer
from tht.cli import db_cmd, memory_cmd
from tht.cli.lsh_cmd import _extract_lsh_values
from tht.memory import MemoryRecord
from tht.mschema.models import Annotations, ColumnPhysical, PhysicalSchema, TablePhysical
from tht.ports.dwh import DistinctValues, DwhHealth
@@ -57,58 +56,25 @@ def test_lsh_extraction_honors_configured_limit(limit, truncated):
assert [report.indexed for report in reports] == ([limit] if truncated else [])
def test_memory_command_writes_through_factory_vector_store(monkeypatch):
store = SimpleNamespace(existing_hashes=lambda *args: {}, upsert=lambda table, rows: 1)
captured = []
original_upsert = store.upsert
store.upsert = lambda table, rows: captured.extend(rows) or original_upsert(table, rows)
# Legacy server deployments wrote directly through the factory and intentionally
# did not configure the workstation-only REST writer key.
cfg = SimpleNamespace(profile="server", embeddings=object(), vector_write_rest=None)
manifest = SimpleNamespace(id="s1")
snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={})
record = MemoryRecord(
id="m1", ts=datetime(2026, 1, 1, tzinfo=UTC), session_id="s1",
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_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(memory_cmd, "registry_path", lambda cfg: None)
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder",
lambda cfg: SimpleNamespace(embed_documents=lambda texts: [[0.1]]))
monkeypatch.setattr("tht.memory.promote_snapshot", lambda *args, **kwargs: None)
monkeypatch.setattr("tht.memory.load_registry", lambda path: [record])
memory_cmd.save_one_cmd(session="s1", decision=7, json_out=True)
from tht.ports.vector import VectorWriteRecord
assert len(captured) == 1 and isinstance(captured[0], VectorWriteRecord)
def test_solved_index_writes_through_writer_only_factory_store(monkeypatch):
writer_only_store = SimpleNamespace(
capabilities=SimpleNamespace(search=False, upsert=True),
existing_hashes=lambda *args: {},
upsert=lambda table, rows: 1,
)
cfg = SimpleNamespace(embeddings=object(), vector_write_rest=object())
manifest = SimpleNamespace(id="s1")
snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={})
def test_save_one_passes_the_selected_source_to_authoritative_service(monkeypatch, capsys):
snapshot = object()
calls = []
service = SimpleNamespace(close=lambda: None, promote=lambda source, seqs:
calls.append((source, seqs)) or [{"indexed": False, "saved": True}])
monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda path: object())
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(
"tht.adapters.factory.build_vector_store",
lambda cfg, require_write: calls.append(require_write) or writer_only_store,
)
monkeypatch.setattr("tht.cli.sql_cmd.promoted_tables_for", lambda *args: [])
monkeypatch.setattr(
"tht.memory.index_solved_question",
lambda loaded, tables, *, store, embedder: int(
loaded is snapshot and tables == [] and store is writer_only_store
),
)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: object())
monkeypatch.setattr(memory_cmd, "memory_service", lambda cfg: service)
memory_cmd.save_one_cmd(session="s1", decision=7, json_out=True)
assert calls == [(snapshot, [7])]
assert '"indexed": false' in capsys.readouterr().out
assert memory_cmd.index_solved_session(cfg, "s1") == 1
assert calls == [True]
def test_solved_recovery_uses_authority_without_recreating_historical_content(monkeypatch):
snapshot = object()
calls = []
service = SimpleNamespace(close=lambda: None, retry_solved=lambda source:
calls.append(source) or {"indexed": True, "action": "upsert"})
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(memory_cmd, "memory_service", lambda cfg: service)
assert memory_cmd.index_solved_session(object(), "s1") == 1
assert calls == [snapshot]
+1 -1
View File
@@ -35,7 +35,7 @@ def test_typer_tree_matches_the_approved_command_surface():
expected = set(approved["maintained"]) | set(approved["enhanced"])
assert len(approved["maintained"]) == 61
assert len(approved["enhanced"]) == 8
assert len(approved["enhanced"]) == 21
assert len(approved["erased"]) == 14
assert not (expected & set(approved["erased"]))
assert _leaf_paths(get_command(app)) == expected
@@ -0,0 +1,155 @@
import json
from types import SimpleNamespace
import pytest
from test_evidence_local_archive import write_unit
from test_preprocess_cli import _runtime_config
from typer.testing import CliRunner
from tht.cli import app
from tht.evidence.administration import ConsolidationError, browse, consolidate_from_config
from tht.evidence.local_archive import LocalEvidenceArchive
def test_browse_filters_working_files_without_index_or_dwh_and_reports_invalid_files(tmp_path):
path = write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Alice", activate=lambda _: None)
path.write_text(path.read_text().replace("Use order ID and year.", "Use all three parts of the key."))
invalid = path.parent / "broken.md"
invalid.write_text("not an Evidence document")
result = browse(tmp_path, {"kind": "domain", "q": "three"})
assert result["total"] == 1
assert result["items"][0]["status"] == "modified"
assert result["items"][0]["payload"]["rule"] == "Use all three parts of the key."
assert result["errors"][0]["file"] == "curated/domain/broken.md"
path.unlink()
assert browse(tmp_path, {"status": "removed"})["total"] == 1
def test_public_consolidation_retries_saved_content_and_does_not_use_git(monkeypatch, tmp_path):
import tht.cli.preprocess_cmd as command
import tht.config as config_module
path = write_unit(tmp_path)
cfg = SimpleNamespace(evidence=SimpleNamespace(local_archive_root=tmp_path))
monkeypatch.setattr(config_module, "load_config", lambda _: cfg)
calls = []
def stage(_, *, local_snapshot):
calls.append(local_snapshot)
if len(calls) == 1:
raise RuntimeError("private endpoint failure")
return SimpleNamespace(status="succeeded")
monkeypatch.setattr(command, "run_from_config", stage)
monkeypatch.setattr("subprocess.run", lambda *a, **kw: pytest.fail("Consolidation must not run Git"))
with pytest.raises(ConsolidationError, match="Retry consolidation") as failure:
consolidate_from_config(tmp_path / "config.yaml")
assert failure.value.saved
assert path.exists()
assert LocalEvidenceArchive(tmp_path).active_snapshot() is None
assert consolidate_from_config(tmp_path / "config.yaml").status == "succeeded"
assert calls[0] == calls[1]
def test_cli_failure_is_pristine_json_with_a_correctable_error(monkeypatch, tmp_path):
config = _runtime_config(tmp_path)
def fail(_):
raise ConsolidationError("curated/domain/rule.md: Missing Rule section")
monkeypatch.setattr("tht.evidence.administration.consolidate_from_config", fail)
result = CliRunner().invoke(app, ["preprocess", "evidence", "--consolidate", "--json", "-c", str(config)])
assert result.exit_code == 1
assert "Missing Rule section" in json.loads(result.stdout)["error"]
@pytest.mark.parametrize("flags", [["gc"], ["--dry-run"], ["--resume", "a" * 32]])
def test_cli_rejects_conflicting_consolidation_options_before_any_work(monkeypatch, flags):
monkeypatch.setattr("tht.cli.preprocess_cmd.gc_from_config", lambda *a, **kw: pytest.fail("No cleanup"))
result = CliRunner().invoke(app, ["preprocess", "evidence", "--consolidate", "--json", *flags])
assert result.exit_code == 2
assert json.loads(result.stdout)["code"] == "invalid_consolidation"
def test_first_consolidation_reports_legacy_conversion_error(monkeypatch, tmp_path):
from tht.evidence.authoring import EvidencePreparationError
write_unit(tmp_path)
(tmp_path / "evidence/manifest.yaml").write_text("invalid")
monkeypatch.setattr("tht.config.load_config", lambda _: SimpleNamespace(
evidence=SimpleNamespace(local_archive_root=tmp_path)))
def fail(_):
raise EvidencePreparationError("source_invalid", "source/guide.md")
monkeypatch.setattr("tht.evidence.authoring.migrate_workspace_evidence", fail)
with pytest.raises(ConsolidationError, match="source/guide.md"):
consolidate_from_config(tmp_path / "config.yaml")
def test_preprocessing_uses_only_the_active_snapshot_and_clear_preserves_primary_files(monkeypatch, tmp_path):
import yaml
from tht.cli.preprocess_cmd import clear_from_config
from tht.config import load_config
from tht.evidence.sources import build_sources
path = write_unit(tmp_path / "local")
archive = LocalEvidenceArchive(tmp_path / "local")
archive.consolidate(actor="Alice", activate=lambda _: None)
config = _runtime_config(tmp_path)
raw = yaml.safe_load(config.read_text())
raw["evidence"]["local_archive_root"] = str(tmp_path / "local")
config.write_text(yaml.safe_dump(raw))
path.write_text(path.read_text().replace("Use order ID and year.", "Unconsolidated text."))
cfg = load_config(config)
sources = build_sources(cfg.evidence)
assert len(sources) == 1
assert sources[0].root == archive.active_snapshot()
saved = path.read_bytes()
monkeypatch.setenv("THT_PROFILE", "server")
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *a, **kw: SimpleNamespace(clear_reference=lambda: True))
clear_from_config(config)
assert path.read_bytes() == saved
assert archive.active_snapshot().is_dir()
def test_manual_git_sequence_preserves_edits_additions_deletions_and_archive_metadata(tmp_path):
import subprocess
def git(root, *args):
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True)
remote = tmp_path / "remote.git"
repo = tmp_path / "repo"
repo.mkdir()
git(tmp_path, "init", "--bare", str(remote))
git(repo, "init", "--initial-branch=main")
git(repo, "config", "user.name", "Evidence test")
git(repo, "config", "user.email", "evidence@example.invalid")
git(repo, "remote", "add", "origin", str(remote))
root = repo / "workspace"
path = write_unit(root)
original = path.read_text()
from tht.evidence.canonical import parse_curated_markdown
identity = parse_curated_markdown(original).id
removed = path.with_name("removed.md")
removed.write_text(original.replace(identity, "evidence:removed"))
archive = LocalEvidenceArchive(root)
archive.consolidate(actor="Curator", activate=lambda _: None)
git(repo, "add", "-A", "--", "workspace/evidence")
git(repo, "commit", "--only", "-m", "Baseline", "--", "workspace/evidence")
path.write_text(path.read_text().replace("Use order ID and year.", "Use the approved compound key."))
added = path.with_name("added.md")
added.write_text(original.replace(identity, "evidence:added"))
removed.unlink()
archive.consolidate(actor="Curator", activate=lambda _: None)
git(repo, "status", "--short")
git(repo, "diff", "HEAD", "--", "workspace/evidence")
git(repo, "add", "-A", "--", "workspace/evidence")
git(repo, "commit", "--only", "-m", "Curate Evidence", "--", "workspace/evidence")
git(repo, "push", "origin", "main")
clone = tmp_path / "clone"
git(tmp_path, "clone", "--branch", "main", str(remote), str(clone))
copy = clone / "workspace"
assert (copy / path.relative_to(root)).read_bytes() == path.read_bytes()
assert (copy / added.relative_to(root)).exists()
assert not (copy / removed.relative_to(root)).exists()
assert (copy / "evidence/local-manifest.yaml").read_bytes() == (root / "evidence/local-manifest.yaml").read_bytes()
assert LocalEvidenceArchive(copy).active_snapshot().is_dir()
assert {unit["status"] for unit in browse(copy, {})["items"]} == {"active"}
+17 -12
View File
@@ -350,12 +350,12 @@ def test_prepare_changed_source_uses_one_model_call_and_applies_a_valid_batch(tm
assert restructurer.requests[0].previous_units[0].id == "evidence:fascia-pediatrica"
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
curated = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert curated.schema_version == 3
assert curated.schema_version == 4
assert "# Fascia pediatrica\n" in curated_path.read_text(encoding="utf-8")
assert validate_workspace_evidence(tmp_path).publishable is True
def test_migrate_workspace_evidence_rewrites_v1_units_as_v3_without_a_model_call(tmp_path):
def test_migrate_workspace_evidence_rewrites_v1_units_as_v4_without_a_model_call(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
@@ -365,15 +365,16 @@ def test_migrate_workspace_evidence_rewrites_v1_units_as_v3_without_a_model_call
migrated = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert report.migrated == ("evidence:fascia-pediatrica",)
assert report.unchanged == ()
assert migrated.schema_version == 3
assert migrated.schema_version == 4
assert migrated.payload.rule == "La fascia pediatrica comprende i minori."
text = curated_path.read_text(encoding="utf-8")
assert "## Regola\n\n<!-- tht:raw-rule:" in text
assert "## Regola\n\n" in text
assert "<!-- tht:" not in text
assert "La fascia pediatrica comprende i minori." in text
assert report.findings == ()
def test_migrate_workspace_evidence_rewrites_v2_units_as_table_free_v3(tmp_path):
def test_migrate_workspace_evidence_rewrites_v2_units_as_editable_v4(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
evidence = _evidence(source_text).model_copy(update={"schema_version": 2})
_write_workspace(tmp_path, evidence, source_text)
@@ -388,8 +389,8 @@ def test_migrate_workspace_evidence_rewrites_v2_units_as_table_free_v3(tmp_path)
assert first.unchanged == ()
assert second.migrated == ()
assert second.unchanged == ("evidence:fascia-pediatrica",)
assert migrated.schema_version == 3
assert text.startswith("<!-- tht:metadata:")
assert migrated.schema_version == 4
assert text.startswith("---\nschema_version: 4")
assert not any(line.startswith("|") for line in text.splitlines())
@@ -422,20 +423,24 @@ def test_migrate_workspace_evidence_rewrites_legacy_v3_rule_presentation(tmp_pat
migrated_text = curated_path.read_text(encoding="utf-8")
assert report.migrated == ("evidence:fascia-pediatrica",)
assert report.unchanged == ()
assert "## Regola\n\n<!-- tht:raw-rule:" in migrated_text
assert "## Regola\n\n" in migrated_text
assert "<!-- tht:" not in migrated_text
assert load_curated_tree(tmp_path / "evidence" / "curated")[0].payload.rule == (
evidence.payload.rule
)
def test_migrate_workspace_evidence_rejects_dirty_curated_files_in_a_nested_workspace(tmp_path):
def test_migrate_workspace_evidence_preserves_uncommitted_content_without_git_operations(tmp_path):
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
workspace_root = tmp_path / "psd-clinical"
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(workspace_root, _evidence(source_text), source_text)
with pytest.raises(EvidencePreparationError, match="authoring_worktree_dirty"):
migrate_workspace_evidence(workspace_root)
result = migrate_workspace_evidence(workspace_root)
assert result.migrated == ("evidence:fascia-pediatrica",)
assert load_curated_tree(workspace_root / "evidence/curated")[0].payload.rule == _evidence(source_text).payload.rule
assert subprocess.run(["git", "-C", str(tmp_path), "rev-parse", "HEAD"],
capture_output=True, check=False).returncode != 0
def test_prepare_can_issue_independent_source_calls_concurrently(tmp_path):
@@ -601,7 +606,7 @@ def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path):
"supporting_excerpt_missing", "unresolved_review_item",
]
retained = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert retained.schema_version == 3
assert retained.schema_version == 4
assert retained.review_items[0].code == "source_no_longer_supports_unit"
+130
View File
@@ -0,0 +1,130 @@
import pytest
from tht.evidence.canonical import (
CuratedEvidence,
ManualEvidenceProvenance,
dump_curated_markdown,
parse_curated_markdown,
)
def unit(kind="domain", payload=None):
return CuratedEvidence.model_validate(
{
"schema_version": 4,
"id": "evidence:example",
"title": "Example",
"kind": kind,
"purposes": ["sql_generation"],
"language": "en",
"provenance": {"kind": "manual", "declared_by": "curator"},
"payload": payload or {"rule": "Count orders once, using their complete key."},
}
)
@pytest.mark.parametrize(
("kind", "payload"),
[
("domain", {"rule": "A rule.\n\n### Explanation\n\n- First line\n- Second line"}),
(
"glossary",
{
"definition": "A patient.",
"synonyms": ["person", "a\nmultiline synonym"],
"variants": [],
},
),
(
"enum",
{
"column": "sales.orders.status",
"values": {"": "Unknown", "A": "Active\n\nwith details"},
},
),
("example", {"question": "How many?", "interpretation": "Count distinct orders."}),
(
"mapping",
{"concept": "Orders", "tables": ["sales.orders"], "columns": ["sales.orders.id"]},
),
("normalization", {"input": "A", "output": "active", "rule": "Expand the abbreviation."}),
("formula", {"concept": "Total", "columns": ["sales.lines.amount"], "sql": "sum(amount)"}),
(
"reference",
{"url": "https://example.test/rule", "label": "Policy", "description": "Rule source."},
),
],
)
def test_editable_round_trip_preserves_every_typed_payload(kind, payload):
original = unit(kind, payload)
rendered = dump_curated_markdown(original)
assert parse_curated_markdown(rendered) == original
assert "<!-- tht:" not in rendered
assert "payload:" not in rendered
def test_visible_edit_is_the_only_authoritative_text():
original = unit()
edited = dump_curated_markdown(original).replace(
"Count orders once, using their complete key.", "Use order ID and year."
)
parsed = parse_curated_markdown(edited)
assert parsed.payload.rule == "Use order ID and year."
assert parsed.id == original.id
def test_manual_file_needs_no_source_document_hash_or_encoded_metadata():
text = """---
schema_version: 4
id: evidence:manual
kind: domain
language: it
purposes: [sql_generation]
applies_to:
tables: [sales.orders]
---
# Una regola manuale
## Regola
Le righe vengono collegate tramite numero ordine ed esercizio.
"""
parsed = parse_curated_markdown(text)
assert isinstance(parsed.provenance, ManualEvidenceProvenance)
assert parsed.provenance.original is None
assert parsed.payload.rule.startswith("Le righe")
def test_code_fences_cannot_inject_structural_sections():
original = unit(
"normalization",
{
"input": "A",
"output": "active",
"rule": "Example:\n\n```markdown\n## Input\nDo not parse this heading.\n```",
},
)
assert parse_curated_markdown(dump_curated_markdown(original)) == original
@pytest.mark.parametrize(
"mutation",
[
lambda text: text.replace("## Rule", "## Missing"),
lambda text: text + "\n## Rule\n\nAnother rule.\n",
lambda text: text.replace("kind: domain", "kind: domain\npayload: {rule: hidden}"),
lambda text: text.replace("kind: domain", "kind: glossary\nkind: domain"),
lambda text: text.replace("kind: domain", "kind: ["),
lambda text: text.replace("- sql_generation", ""),
lambda text: text.replace("language: en", "language: ''"),
],
)
def test_invalid_edit_is_rejected_with_a_correctable_error(mutation):
with pytest.raises(ValueError):
parse_curated_markdown(mutation(dump_curated_markdown(unit())))
def test_unrepresentable_legacy_content_is_reported_instead_of_silently_trimmed():
with pytest.raises(ValueError, match="losslessly"):
dump_curated_markdown(unit(payload={"rule": " significant indentation"}))
@@ -0,0 +1,249 @@
import os
import shutil
from pathlib import Path
from uuid import uuid4
import pytest
import requests
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs
from tht.adapters.vector.qdrant import QdrantVectorStore
from tht.evidence.adapters import FilesystemEvidenceSource
from tht.evidence.canonical import CuratedEvidence, dump_curated_markdown
from tht.evidence.corpus.chunk import ChunkPolicy
from tht.evidence.corpus.pipeline import CorpusPipeline
from tht.evidence.corpus.store import CorpusStore
from tht.evidence.local_archive import LocalEvidenceArchive
from tht.evidence.search import ActiveEvidenceSearcher, EvidenceSearchContext, search_evidence
from tht.ports.vector import VectorWriteRecord
from tht.vectorstore.records import VectorRecord
pytestmark = pytest.mark.l0
class Embeddings:
def embed_documents(self, texts):
return [[1.0, 0.0, 0.0] for _ in texts]
def embed_query(self, query):
return [1.0, 0.0, 0.0]
def test_editable_archive_reindexes_visible_edits_for_core_and_preserves_other_indexes(tmp_path, monkeypatch):
with DockerContainer("qdrant/qdrant:v1.18.2").with_exposed_ports(6333) as container:
wait_for_logs(container, "Qdrant HTTP listening on 6333")
url = f"http://{container.get_container_host_ip()}:{container.get_exposed_port(6333)}"
workspace = "editable-" + uuid4().hex
collections = {key: workspace + "-" + key for key in ("reference", "memory")}
for collection in collections.values():
requests.put(
f"{url}/collections/{collection}",
json={
"vectors": {"size": 3, "distance": "Cosine"},
"sparse_vectors": {"bm25": {"modifier": "idf"}},
},
timeout=10,
).raise_for_status()
vectors = QdrantVectorStore(
base_url=url, collections=collections, workspace_id=workspace, expected_dimension=3
)
for collection, kind in [("schema_records", "schema_table"), ("memory", "memory")]:
vectors.upsert(
collection,
[
VectorWriteRecord(
record=VectorRecord(
id=kind,
ref=kind,
kind=kind,
title="Preserve",
content="Unrelated content",
),
embedding=[1, 0, 0],
content_hash="sha256:" + "a" * 64,
)
],
)
corpus = CorpusStore(tmp_path / "corpus")
archive = LocalEvidenceArchive(tmp_path / "workspace")
path = archive.evidence / "curated/domain/order-key.md"
path.parent.mkdir(parents=True)
unit = CuratedEvidence.model_validate(
{
"schema_version": 4,
"id": "evidence:order-key",
"title": "Order key",
"kind": "domain",
"purposes": ["sql_generation"],
"language": "en",
"provenance": {"kind": "manual", "declared_by": "Alice"},
"payload": {"rule": "Join orders using the complete business key."},
}
)
path.write_text(dump_curated_markdown(unit))
def activate(snapshot):
result = CorpusPipeline(
store=corpus,
sources=[FilesystemEvidenceSource(snapshot, patterns=("curated/**/*.md",))],
embedder=Embeddings(),
vector_store=vectors,
embedding_model="fixture",
embedding_dimensions=3,
chunk_policy=ChunkPolicy(version="semantic:v1", max_chars=5000),
pipeline_version="editable-v4",
workspace_id=workspace,
sparse_language="english",
).run()
assert result.status == "succeeded", (result, result.review_items)
class Delegate:
def search(self, embedding, top_n=10, kinds=None, **kwargs):
return vectors.search(["evidence"], embedding, limit=top_n, kinds=kinds, **kwargs)
searcher = ActiveEvidenceSearcher(corpus, Delegate(), workspace, "english")
def lookup():
result = search_evidence(
"order key",
"sql_generation",
EvidenceSearchContext(),
searcher=searcher,
embedder=Embeddings(),
)
assert result.status == "available"
return result.results
archive.consolidate(actor="Alice", activate=activate)
assert lookup()[0].evidence_id == unit.id
assert lookup()[0].provenance["kind"] == "manual"
path.write_text(
path.read_text().replace(
"complete business key", "order number, financial year and company"
)
)
assert "financial year" not in " ".join(lookup()[0].excerpts)
archive.consolidate(actor="Bob", activate=activate)
assert "financial year" in " ".join(lookup()[0].excerpts)
assert lookup()[0].provenance["declared_by"] == "Bob"
previous_snapshot = archive.active_snapshot()
valid = path.read_text()
path.write_text(
valid.replace(
"Join orders using the order number, financial year and company.", "x" * 6000
)
)
with pytest.raises(AssertionError, match="blocked"):
archive.consolidate(actor="Bob", activate=activate)
assert archive.active_snapshot() == previous_snapshot
assert "financial year" in " ".join(lookup()[0].excerpts)
path.write_text(valid)
archive.consolidate(actor="Bob", activate=activate)
path.unlink()
archive.consolidate(actor="Bob", activate=activate)
assert lookup() == ()
# Optional real corpus probe, always copied into the test's private archive.
if supplied := os.environ.get("THT_E1_PSD_COPY"):
from tht.evidence.canonical import load_curated_tree
source_root = Path(supplied) / "evidence"
expected = {u.id for u in load_curated_tree(source_root / "curated")}
assert len(expected) == 35
shutil.copytree(
source_root / "curated", archive.evidence / "curated", dirs_exist_ok=True
)
shutil.copytree(source_root / "source", archive.evidence / "source", dirs_exist_ok=True)
archive.consolidate(actor="Validation", activate=activate)
actual = {
d.metadata["curated_evidence"]["id"] for d in corpus.active_manifest().documents
}
assert actual == expected
print("PSD v4: all 35 converted Evidence units indexed with unchanged identities")
# Exercise the installed harness entry point against the same real Qdrant.
import json
import yaml
from typer.testing import CliRunner
from tht.cli import app
from tht.cli.preprocess_cmd import clear_from_config, run_from_config
runtime = tmp_path / "runtime.yaml"
runtime.write_text(yaml.safe_dump({
"runtime_identity": {"workspace_id": workspace, "workspace_revision": "a" * 40},
"dwh": {"type": "postgres_direct", "connection": {"database": "unused", "schema": "public", "user": "unused", "password": "unused"}},
"vectors": {"type": "qdrant", "base_url": "http://qdrant:6333", "collection": workspace},
"embeddings": {"provider": "ollama_internal", "base_url": "http://embedding:11434", "model": "fixture", "dim": 3},
"evidence": {"schema_version": 2, "local_archive_root": str(archive.root), "sources": [{"type": "http", "urls": ["https://must-not-be-fetched.invalid/source.md"]}]},
"vector": {"max_chunk_chars": 5000},
"roots": {"sessions": str(tmp_path / "sessions"), "artifacts": str(tmp_path / "artifacts"), "indexes": str(tmp_path / "indexes")},
}))
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *a, **kw: vectors)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: Embeddings())
monkeypatch.setenv("THT_PRINCIPAL_SUBJECT", "Installed curator")
path.write_text(dump_curated_markdown(unit))
result = CliRunner().invoke(app, ["preprocess", "evidence", "--consolidate", "--json", "-c", str(runtime)])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout)["status"] == "succeeded"
assert any(hit.provenance.get("declared_by") == "Installed curator" for hit in lookup())
for collection, kind in [("schema_records", "schema_table"), ("memory", "memory")]:
assert vectors.existing_hashes(collection, [kind])
primary = path.read_bytes()
monkeypatch.setenv("THT_PROFILE", "server")
clear_from_config(runtime)
assert path.read_bytes() == primary
assert archive.active_snapshot().is_dir()
# Full preprocessing must recreate Reference after Clear; Evidence alone
# cannot claim the missing Schema derivatives are ready.
requests.put(f"{url}/collections/{collections['reference']}", json={
"vectors": {"size": 3, "distance": "Cosine"},
"sparse_vectors": {"bm25": {"modifier": "idf"}},
}, timeout=10).raise_for_status()
assert run_from_config(runtime).status == "succeeded"
assert any(hit.evidence_id == unit.id for hit in lookup())
# E3 traverses the public source commands and the same real index activation.
from test_evidence_imports import Refiner, Remote
from tht.evidence.imports import reviews
vectors.upsert("schema_records", [VectorWriteRecord(record=VectorRecord(
id="schema_table", ref="schema_table", kind="schema_table", title="Preserve",
content="Unrelated schema"), embedding=[1, 0, 0], content_hash="sha256:" + "a" * 64)])
remote, refiner = Remote(), Refiner()
monkeypatch.setattr("tht.evidence.imports.acquisition_sources", lambda cfg: [remote])
monkeypatch.setattr("tht.evidence.authoring.PiEvidenceRestructurer", lambda *a, **kw: refiner)
def source_command(*args):
outcome = CliRunner().invoke(app, ["evidence", "sources", *args, "--json", "-c", str(runtime)])
assert outcome.exit_code == 0, outcome.output
return json.loads(outcome.stdout)
source_command("refresh")
row = reviews(archive)[0]
imported_id = row["proposed"][0]["id"]
assert not any(hit.evidence_id == imported_id for hit in lookup())
source_command("decide", "--source-id", row["id"], "--revision", row["revision"], "--decision", "replace")
assert any(hit.evidence_id == imported_id for hit in lookup())
imported = archive.evidence / f"curated/domain/{imported_id[9:]}.md"
imported.write_text(imported.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nKeep the curator's company key."))
result = CliRunner().invoke(app, ["preprocess", "evidence", "--consolidate", "--json", "-c", str(runtime)])
assert result.exit_code == 0, result.output
remote.text = "The refreshed source has another key."
source_command("refresh")
assert any("company key" in " ".join(hit.excerpts) for hit in lookup() if hit.evidence_id == imported_id)
row = reviews(archive)[0]
source_command("decide", "--source-id", row["id"], "--revision", row["revision"], "--decision", "replace")
assert any("another key" in " ".join(hit.excerpts) for hit in lookup() if hit.evidence_id == imported_id)
assert not any("company key" in " ".join(hit.excerpts) for hit in lookup() if hit.evidence_id == imported_id)
imported.unlink()
result = CliRunner().invoke(app, ["preprocess", "evidence", "--consolidate", "--json", "-c", str(runtime)])
assert result.exit_code == 0, result.output
remote.text = "Do not regenerate the retired source rule."
source_command("refresh")
row = reviews(archive)[0]
assert not row["proposed"]
source_command("decide", "--source-id", row["id"], "--revision", row["revision"], "--decision", "replace")
assert not any(hit.evidence_id == imported_id for hit in lookup())
for collection, kind in [("schema_records", "schema_table"), ("memory", "memory")]:
assert vectors.existing_hashes(collection, [kind])
assert vectors.existing_hashes("memory", ["memory"])
@@ -97,6 +97,7 @@ def test_source_factory_preserves_legacy_first_order_and_filesystem_configuratio
(legacy_root / "evidence").mkdir(parents=True)
configured_root.mkdir()
cfg = SimpleNamespace(evidence=SimpleNamespace(
local_archive_root=None,
source_root=legacy_root,
evidence_dir="evidence",
sources=[SimpleNamespace(
@@ -124,6 +125,7 @@ def test_legacy_source_discovers_only_curated_evidence_units(tmp_path):
(legacy_root / "evidence" / "curated" / "domain").mkdir(parents=True)
(legacy_root / "evidence" / "curated" / "domain" / "patient.md").write_text("curated")
cfg = SimpleNamespace(evidence=SimpleNamespace(
local_archive_root=None,
source_root=legacy_root,
evidence_dir="evidence",
sources=[],
+239
View File
@@ -0,0 +1,239 @@
import json
import pytest
from test_evidence_local_archive import read_active, write_unit
from tht.evidence.adapters import FilesystemEvidenceSource
from tht.evidence.authoring import RestructureCandidate
from tht.evidence.canonical import EvidenceProvenance, ManualEvidenceProvenance
from tht.evidence.contracts import AcquiredDocument, SourceObject
from tht.evidence.imports import decide, refresh, reviews
from tht.evidence.local_archive import ArchiveConflict, LocalEvidenceArchive, _digest
class Refiner:
def __init__(self):
self.calls = []
def restructure(self, request):
self.calls.append(request)
return (RestructureCandidate(schema_version=1,
existing_id=request.previous_units[0].id if request.previous_units else None,
title="Order key", kind="domain", language="en", purposes=("sql_generation",),
payload={"rule": request.normalized_text.strip()}, supporting_excerpts=(request.normalized_text.strip(),)),)
class Remote:
def __init__(self, uri="https://docs.example.test/rule.md"):
self.uri = uri
self.text = "Use order ID and year."
self.calls = 0
self.fail = False
self.absent = False
def discover(self):
self.calls += 1
if self.fail:
raise RuntimeError("private access credential must not leak")
if not self.absent:
yield SourceObject(source_id="test:rule", uri=self.uri,
fingerprint="sha256:" + _digest(self.text.encode()))
def acquire(self, item):
self.calls += 1
return AcquiredDocument(source=item, content=self.text.encode(), media_type="text/markdown")
def empty_archive(tmp_path):
(tmp_path / "evidence/curated").mkdir(parents=True)
archive = LocalEvidenceArchive(tmp_path)
archive.initialize()
return archive
def choose(archive, choice="replace", activate=lambda _: None):
row = next(r for r in reviews(archive) if r["status"] in {"review", "applying"})
return decide(archive, source_id=row["id"], revision=row["revision"], decision=choice,
actor="Curator", activate=activate)
def test_local_source_refresh_preserves_manual_correction_and_explicit_choices(tmp_path):
path = write_unit(tmp_path, source=True)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Curator", activate=lambda _: None)
source = FilesystemEvidenceSource(archive.evidence, patterns=("source/*.md",))
refiner = Refiner()
assert refresh(archive, [source], refiner)["counts"]["unchanged"] == 1
assert not refiner.calls
path.write_text(path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nUse company as well."))
archive.consolidate(actor="Curator", activate=lambda _: None)
original = archive.active_snapshot()
(archive.evidence / "source/orders.md").write_text("The new source says use ID only.")
refresh(archive, [source], refiner)
assert archive.active_snapshot() == original
assert reviews(archive)[0]["current"][0]["payload"]["rule"] == "Use company as well."
choose(archive, "keep")
unit = read_active(archive)
assert unit.payload.rule == "Use company as well."
assert isinstance(unit.provenance, ManualEvidenceProvenance)
assert unit.provenance.original.supporting_excerpts == ("Use order ID and year.",)
assert refresh(archive, [source], refiner)["counts"]["unchanged"] == 1
(archive.evidence / "source/orders.md").write_text("Now use ID, year and company.")
refresh(archive, [source], refiner)
choose(archive)
unit = read_active(archive)
assert unit.payload.rule == "Now use ID, year and company."
assert isinstance(unit.provenance, EvidenceProvenance)
assert (archive.active_snapshot() / unit.provenance.source_file).read_text().strip() == unit.payload.rule
assert (original / "curated/domain/order-key.md").exists()
@pytest.mark.parametrize("uri", ["https://docs.example.test/rule.md", "s3://documents/rule.md"])
def test_read_only_remote_import_preserves_bytes_and_never_refreshes_implicitly(tmp_path, uri):
archive = empty_archive(tmp_path)
remote, refiner = Remote(uri), Refiner()
refresh(archive, [remote], refiner)
assert archive.active_snapshot() is None
choose(archive)
assert read_active(archive).payload.rule == remote.text
assert remote.calls == 2
archive.consolidate(actor="Curator", activate=lambda _: None)
assert remote.calls == 2
assert refresh(archive, [remote], refiner)["counts"]["unchanged"] == 1
assert len(refiner.calls) == 1
saved = next((archive.metadata / "acquisitions").rglob("*.json"))
assert json.loads(saved.read_text())["source"]["uri"] == uri
assert json.loads(saved.read_text())["raw_base64"]
def test_access_failure_and_missing_source_do_not_remove_or_replace_curated_content(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
choose(archive)
before = (archive.metadata / "sources.json").read_bytes()
active = archive.active_snapshot()
remote.fail = True
with pytest.raises(RuntimeError):
refresh(archive, [remote], refiner)
assert (archive.metadata / "sources.json").read_bytes() == before
remote.fail, remote.absent = False, True
refresh(archive, [remote], refiner)
assert reviews(archive)[0]["availability"] == "missing"
assert archive.active_snapshot() == active
def test_refresh_does_not_resurrect_deleted_units_with_new_model_ids(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
choose(archive)
(archive.evidence / "curated/domain/order-key.md").unlink()
archive.consolidate(actor="Curator", activate=lambda _: None)
remote.text = "Changed source could regenerate the deleted rule."
refresh(archive, [remote], refiner)
row = reviews(archive)[0]
assert row["suppressed"] and row["proposed"] == []
choose(archive)
assert archive._files(archive.active_snapshot()) == {}
def test_failed_activation_retries_saved_source_decision_without_reacquisition(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
def fail(_):
raise RuntimeError("offline index")
with pytest.raises(RuntimeError, match="offline index"):
choose(archive, activate=fail)
assert reviews(archive)[0]["status"] == "applying"
assert archive.active_snapshot() is None
choose(archive)
assert read_active(archive).payload.rule == remote.text
assert remote.calls == 2
def test_source_retirement_decision_also_suppresses_future_regeneration(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
choose(archive)
remote.text = "The source no longer supports the old rule."
class EmptyRefiner:
def restructure(self, request):
return ()
refresh(archive, [remote], EmptyRefiner())
assert reviews(archive)[0]["removed_ids"] == ["evidence:order-key"]
choose(archive)
remote.text = "A newly worded source could restore the same rule."
refresh(archive, [remote], refiner)
assert reviews(archive)[0]["proposed"] == []
def test_retry_never_overwrites_an_intervening_external_edit(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
def fail(_):
raise RuntimeError("offline index")
with pytest.raises(RuntimeError):
choose(archive, activate=fail)
path = archive.evidence / "curated/domain/order-key.md"
edited = path.read_text().replace("Use order ID and year.", "External correction.")
path.write_text(edited)
with pytest.raises(ArchiveConflict, match="changed during"):
choose(archive)
assert path.read_text() == edited
assert archive.active_snapshot() is None
def test_external_edit_invalidates_comparison_and_same_source_can_be_reviewed_again(tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
choose(archive)
remote.text = "New source."
refresh(archive, [remote], refiner)
old_revision = reviews(archive)[0]["revision"]
path = archive.evidence / "curated/domain/order-key.md"
path.write_text(path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nManual correction."))
with pytest.raises(ArchiveConflict, match="changed since"):
choose(archive)
refresh(archive, [remote], refiner)
assert reviews(archive)[0]["revision"] != old_revision
choose(archive, "keep")
assert read_active(archive).payload.rule == "Manual correction."
def test_decision_recovers_interruption_between_journal_and_archive_write(monkeypatch, tmp_path):
archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner()
refresh(archive, [remote], refiner)
write = archive._write_state
monkeypatch.setattr(archive, "_write_state", lambda _: (_ for _ in ()).throw(OSError("interrupted")))
with pytest.raises(OSError):
choose(archive)
monkeypatch.setattr(archive, "_write_state", write)
choose(archive)
assert read_active(archive).payload.rule == remote.text
def test_local_incoming_draft_needs_no_database_or_source_server(tmp_path):
archive = empty_archive(tmp_path)
(archive.evidence / "incoming").mkdir()
(archive.evidence / "incoming/draft.md").write_text("A new domain rule.")
refresh(archive, [FilesystemEvidenceSource(archive.evidence, patterns=("incoming/*.md",))], Refiner())
choose(archive)
assert read_active(archive).payload.rule == "A new domain rule."
def test_source_cli_access_error_is_sanitized_json(monkeypatch, tmp_path):
from typer.testing import CliRunner
from tht.cli import app
def fail(*a, **kw):
raise RuntimeError("private credential")
monkeypatch.setattr("tht.evidence.administration.source_action", fail)
result = CliRunner().invoke(app, ["evidence", "sources", "refresh", "--json", "-c", str(tmp_path / "cfg")])
assert result.exit_code == 1
assert json.loads(result.stdout)["status"] == "failed"
assert "private credential" not in result.stdout
def test_installed_refiner_uses_deployment_resources_outside_the_python_wheel(monkeypatch, tmp_path):
from tht.evidence.authoring import authoring_skill_path
monkeypatch.setenv("THT_HARNESS_DIR", str(tmp_path / "deployment"))
assert authoring_skill_path() == tmp_path / "deployment/.pi/skills/tht-evidence-authoring/SKILL.md"
@@ -0,0 +1,235 @@
import pytest
import yaml
from tht.evidence.authoring import (
EvidencePreparationError,
normalize_source_text,
prepare_workspace_evidence,
)
from tht.evidence.canonical import (
CuratedEvidence,
ManualEvidenceProvenance,
dump_curated_markdown,
parse_curated_markdown,
)
from tht.evidence.local_archive import ArchiveConflict, LocalEvidenceArchive, _digest
def write_unit(root, *, rule="Use order ID and year.", source=False):
provenance = {"kind": "manual", "declared_by": "local curator"}
if source:
path = root / "evidence/source/orders.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(rule)
provenance = {
"source_file": "source/orders.md",
"source_sha256": "sha256:" + _digest(normalize_source_text(rule).encode()),
"supporting_excerpts": [rule],
}
unit = CuratedEvidence.model_validate(
{
"schema_version": 4,
"id": "evidence:order-key",
"title": "Order key",
"kind": "domain",
"language": "en",
"purposes": ["sql_generation"],
"payload": {"rule": rule},
"provenance": provenance,
}
)
path = root / "evidence/curated/domain/order-key.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(dump_curated_markdown(unit))
return path
def read_active(archive):
return parse_curated_markdown(
(archive.active_snapshot() / "curated/domain/order-key.md").read_text()
)
def test_manual_creation_and_visible_edit_reach_only_the_consolidated_snapshot(tmp_path):
path = write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
first = archive.consolidate(actor="Alice", activate=lambda _: None)
assert first["status"] == "active"
assert read_active(archive).provenance.declared_by == "Alice"
path.write_text(path.read_text().replace("Use order ID and year.", "Use ID, year and company."))
assert read_active(archive).payload.rule == "Use order ID and year."
candidate = archive.consolidate(actor="Bob")
assert candidate["status"] == "pending_activation"
assert read_active(archive).payload.rule == "Use order ID and year."
archive.consolidate(actor="Bob", activate=lambda _: None)
assert read_active(archive).payload.rule == "Use ID, year and company."
assert read_active(archive).provenance.declared_by == "Bob"
def test_document_correction_becomes_manual_and_retains_original_source_separately(tmp_path):
path = write_unit(tmp_path, source=True)
archive = LocalEvidenceArchive(tmp_path)
archive.initialize()
original = parse_curated_markdown(path.read_text()).provenance
path.write_text(
path.read_text().replace(
"## Rule\n\nUse order ID and year.",
"## Rule\n\nThe approved key also includes company.",
)
)
archive.consolidate(actor="Curator", activate=lambda _: None)
current = read_active(archive)
assert isinstance(current.provenance, ManualEvidenceProvenance)
assert current.provenance.original == original
assert current.provenance.declared_by == "Curator"
assert (archive.active_snapshot() / "source/orders.md").read_text() == "Use order ID and year."
def test_failed_activation_and_restart_retry_reuse_the_candidate(tmp_path):
path = write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Alice", activate=lambda _: None)
previous = archive.active_snapshot()
path.write_text(
path.read_text().replace("Use order ID and year.", "Use the reviewed composite key.")
)
calls = []
def fail(candidate):
calls.append(candidate)
raise RuntimeError("Index unavailable")
with pytest.raises(RuntimeError, match="Index unavailable"):
archive.consolidate(actor="Alice", activate=fail)
recovered = LocalEvidenceArchive(tmp_path)
assert recovered.active_snapshot() == previous
result = recovered.consolidate(actor="Alice", activate=calls.append)
assert calls[0] == calls[1]
assert result["status"] == "active"
def test_deleted_source_unit_stays_deleted_after_restart_and_refinement_attempt(tmp_path):
path = write_unit(tmp_path, source=True)
archive = LocalEvidenceArchive(tmp_path)
archive.initialize()
archive.consolidate(actor="Alice", activate=lambda _: None)
path.unlink()
archive.consolidate(actor="Alice", activate=lambda _: None)
state = yaml.safe_load((tmp_path / "evidence/.local/state.yaml").read_text())
assert state["deleted_ids"] == ["evidence:order-key"]
assert state["suppressed_sources"] == ["source/orders.md"]
class MustNotCall:
def restructure(self, request):
raise AssertionError("Deleted knowledge must not be regenerated")
with pytest.raises(EvidencePreparationError, match="explicit_source_refresh"):
prepare_workspace_evidence(tmp_path, restructurer=MustNotCall(), git_status=lambda _: ())
assert not path.exists()
assert not list(LocalEvidenceArchive(tmp_path).active_snapshot().rglob("*.md"))
def test_invalid_edit_does_not_change_files_metadata_or_active_snapshot(tmp_path):
path = write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Alice", activate=lambda _: None)
old = archive.active_snapshot()
state = (tmp_path / "evidence/.local/state.yaml").read_bytes()
path.write_text(path.read_text().replace("## Rule", "## Wrong heading"))
invalid = path.read_bytes()
with pytest.raises(ValueError, match="curated/domain/order-key.md.*section"):
archive.consolidate(actor="Alice", activate=lambda _: pytest.fail("Must not activate"))
assert path.read_bytes() == invalid
assert (tmp_path / "evidence/.local/state.yaml").read_bytes() == state
assert archive.active_snapshot() == old
def test_workflow_correction_checks_revision_and_never_overwrites_a_manual_edit(tmp_path):
path = write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Alice", activate=lambda _: None)
before = archive.get("evidence:order-key")
proposed = before["unit"].model_copy(update={"title": "Workflow correction"})
path.write_text(path.read_text().replace("Use order ID and year.", "Manual edit in progress."))
with pytest.raises(ArchiveConflict):
archive.save(proposed, expected_revision=before["revision"], actor="Reviewer")
assert "Manual edit in progress." in path.read_text()
latest = archive.get("evidence:order-key")
archive.save(
latest["unit"].model_copy(update={"title": "Approved title"}),
expected_revision=latest["revision"],
actor="Reviewer",
)
assert archive.get("evidence:order-key")["unit"].title == "Approved title"
def test_source_refresh_does_not_replace_manual_content_or_historical_citation(tmp_path):
path = write_unit(tmp_path, source=True)
archive = LocalEvidenceArchive(tmp_path)
archive.initialize()
path.write_text(
path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nManual rule.")
)
archive.consolidate(actor="Alice", activate=lambda _: None)
(tmp_path / "evidence/source/orders.md").write_text("Contradictory newer source.")
archive.consolidate(actor="Alice", activate=lambda _: None)
assert read_active(archive).payload.rule == "Manual rule."
assert (archive.active_snapshot() / "source/orders.md").read_text() == "Use order ID and year."
def test_symlinks_cannot_escape_the_archive(tmp_path):
path = write_unit(tmp_path)
path.unlink()
outside = tmp_path / "outside.md"
outside.write_text("Private unrelated file")
path.symlink_to(outside)
with pytest.raises(ValueError, match="symlinks"):
LocalEvidenceArchive(tmp_path).consolidate(actor="Alice")
@pytest.mark.parametrize("edit_after_interruption", [False, True])
def test_interrupted_normalization_recovers_without_losing_new_edits(
tmp_path, monkeypatch, edit_after_interruption
):
import tht.evidence.local_archive as module
path = write_unit(tmp_path, source=True)
archive = LocalEvidenceArchive(tmp_path)
archive.initialize()
archive.consolidate(actor="Alice", activate=lambda _: None)
old_active = archive.active_snapshot()
path.write_text(
path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nReviewed rule.")
)
atomic = module._atomic
def interrupted(target, data):
if target == path:
raise OSError("Interrupted file write")
atomic(target, data)
monkeypatch.setattr(module, "_atomic", interrupted)
with pytest.raises(OSError, match="Interrupted"):
archive.consolidate(actor="Bob", activate=lambda _: pytest.fail("Must not activate"))
assert archive.active_snapshot() == old_active
if edit_after_interruption:
path.write_text(path.read_text().replace("Reviewed rule.", "Further manual correction."))
monkeypatch.setattr(module, "_atomic", atomic)
recovered = LocalEvidenceArchive(tmp_path)
recovered.consolidate(actor="Bob", activate=lambda _: None)
assert read_active(recovered).payload.rule == (
"Further manual correction." if edit_after_interruption else "Reviewed rule."
)
assert read_active(recovered).provenance.declared_by == "Bob"
assert read_active(recovered).provenance.original.source_file == "source/orders.md"
def test_missing_archive_directory_is_not_interpreted_as_deletion(tmp_path):
write_unit(tmp_path)
archive = LocalEvidenceArchive(tmp_path)
archive.consolidate(actor="Alice", activate=lambda _: None)
active = archive.active_snapshot()
(archive.evidence / "curated").rename(archive.evidence / "unmounted")
with pytest.raises(ValueError, match="absence is not deletion"):
archive.consolidate(actor="Alice", activate=lambda _: pytest.fail("Must not activate"))
assert archive.active_snapshot() == active
+1 -1
View File
@@ -9,7 +9,7 @@ from tht.pi_skill_projection import (
render_projection,
)
BASELINE_SHA256 = "62bfa0dbc1179b43b2d80dc48155a6119421488a1ffc160e9c664f1fe280ce52"
BASELINE_SHA256 = "314d5e62eecda59dc16f00946d1f814829a326e7b117e102fd03c61f1f00a1a1"
def test_modular_pi_skill_renders_the_byte_identical_approved_projection():
+1
View File
@@ -371,6 +371,7 @@ def test_candidate_evaluation_is_not_required_outside_v2_filesystem_corpora(
cfg = SimpleNamespace(
evidence=SimpleNamespace(
schema_version=schema_version,
local_archive_root=None,
source_root=None,
sources=[SimpleNamespace(type=source_type)],
),
+18 -49
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
@@ -10,7 +9,6 @@ import pytest
from typer.testing import CliRunner
from tht.cli import app
from tht.memory import MemoryRecord, save_registry
from tht.ports.vector import VectorStoreError
@@ -102,22 +100,6 @@ def _write_catalog_snapshot(tmp_path: Path) -> None:
}))
def _memory_record() -> MemoryRecord:
return MemoryRecord(
id="mem-0001",
ts=datetime(2026, 1, 1, tzinfo=UTC),
session_id="s1",
decision_seq=7,
type="concept_clarified",
subject="paziente attivo",
detail="flag_attivo = TRUE",
rationale="r",
question_context="dammi i pazienti attivi",
tables=[],
concepts=["paziente attivo"],
)
def test_vector_index_schema_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
cfg = _qdrant_runtime_config(tmp_path)
_write_catalog_snapshot(tmp_path)
@@ -196,42 +178,29 @@ def test_vector_index_schema_json_failure_is_pristine(tmp_path, monkeypatch):
}
def test_memory_promote_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
def test_memory_promote_adapts_qdrant_runtime_to_authoritative_service(tmp_path, monkeypatch):
cfg = _qdrant_runtime_config(tmp_path)
store = _FakeVectorStore()
promoted = [_memory_record()]
snapshot = SimpleNamespace(manifest=SimpleNamespace(id="s1"))
snapshot = object()
calls = []
service = SimpleNamespace(close=lambda: None, promote=lambda source, seqs:
calls.append((source, seqs)) or [{"indexed": True, "card": {"id": "mem-test"}}])
monkeypatch.setattr("tht.cli.memory_cmd.load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr("tht.memory.promote_snapshot", lambda *args, **kwargs: promoted)
monkeypatch.setattr("tht.memory.load_registry", lambda path: promoted)
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
res = CliRunner().invoke(
app,
["memory", "promote", "--session", "s1", "--decision", "7", "--json", "-c", str(cfg)],
)
assert res.exit_code == 0, res.output
assert json.loads(res.stdout)["indexed"] is True
assert store.upserts
monkeypatch.setattr("tht.cli.memory_cmd.memory_service", lambda cfg: service)
result = CliRunner().invoke(app, [
"memory", "promote", "--session", "s1", "--decision", "7", "--json", "-c", str(cfg),
])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout)["indexed"] is True
assert calls == [(snapshot, [7])]
def test_memory_index_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
def test_memory_index_rebuilds_only_through_authoritative_service(tmp_path, monkeypatch):
cfg = _qdrant_runtime_config(tmp_path)
store = _FakeVectorStore()
records = [_memory_record()]
save_registry(records, tmp_path / "artifacts" / "memory" / "registry.jsonl")
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
res = CliRunner().invoke(app, ["memory", "index", "-c", str(cfg)])
assert res.exit_code == 0, res.output
assert "OK:" in res.output
assert store.upserts
service = SimpleNamespace(close=lambda: None, rebuild=lambda: [{"indexed": True}])
monkeypatch.setattr("tht.cli.memory_cmd.memory_service", lambda cfg: service)
result = CliRunner().invoke(app, ["memory", "index", "--json", "-c", str(cfg)])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == [{"indexed": True}]
def test_vector_help_exposes_only_the_supported_qdrant_command():
+40 -100
View File
@@ -259,95 +259,11 @@ def test_clear_drops_reference_without_deleting_memory_collection():
assert not any(method == "DELETE" and url.endswith("/collections/demo-memory") for method, url in calls)
def test_clear_can_create_memory_collection_before_payload_indexes_become_visible():
def test_clear_does_not_read_or_import_legacy_memory():
calls = []
memory_created = False
def request(method, url, *, json=None, timeout=None):
nonlocal memory_created
calls.append((method, url, json))
if method == "GET" and url.endswith("/collections/demo"):
return FakeResponse(200, {"result": {}})
if method == "GET" and url.endswith("/collections/demo-memory"):
if not memory_created:
return FakeResponse(404, {"status": "error"})
return FakeResponse(200, {
"result": {
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
# Qdrant exposes newly-created payload indexes asynchronously.
"payload_schema": {},
}
})
if method == "PUT" and url.endswith("/collections/demo-memory"):
memory_created = True
return FakeResponse(200, {"status": "ok"})
if method == "PUT" and url.endswith("/collections/demo-memory/index"):
return FakeResponse(200, {"status": "ok"})
if method == "POST" and url.endswith("/collections/demo/points/scroll"):
return FakeResponse(200, {
"result": {"points": [], "next_page_offset": None}
})
if method == "DELETE" and url.endswith("/collections/demo"):
return FakeResponse(200, {"status": "ok"})
if method == "GET" and url.endswith("/collections/demo-reference"):
return FakeResponse(404, {"status": "error"})
raise AssertionError((method, url, json))
store = QdrantVectorStore(
base_url="http://qdrant:6333",
collections={"reference": "demo-reference", "memory": "demo-memory"},
workspace_id="demo",
expected_dimension=1024,
collection_lifecycle="require_existing",
request=request,
)
assert store.clear_reference() is True
assert memory_created is True
assert any(method == "DELETE" and url.endswith("/collections/demo") for method, url, _ in calls)
def test_clear_migrates_legacy_memory_before_retiring_shared_collection():
calls = []
legacy_point = {
"id": "legacy-memory-id",
"vector": [0.25] * 1024,
"payload": {
"workspace_id": "demo",
"kind": "memory",
"record_kind": "memory",
"record_key": "memory:legacy",
"content_hash": "sha256:" + "a" * 64,
},
}
ready_memory = {
"result": {
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
"payload_schema": {
field: {"data_type": "keyword"}
for field in (
"content_hash", "document_id", "kind", "record_key", "record_kind",
"vector_generation", "workspace_id", "workspace_revision",
)
},
}
}
def request(method, url, *, json=None, timeout=None):
calls.append((method, url, json))
if method == "GET" and url.endswith("/collections/demo"):
return FakeResponse(200, {"result": {}})
if method == "GET" and url.endswith("/collections/demo-memory"):
return FakeResponse(200, ready_memory)
if method == "POST" and url.endswith("/collections/demo/points/scroll"):
assert json["with_vector"] is True
return FakeResponse(200, {
"result": {"points": [legacy_point], "next_page_offset": None}
})
if method == "PUT" and "/collections/demo-memory/points?wait=true" in url:
return FakeResponse(200, {"status": "ok"})
if method == "DELETE" and url.endswith("/collections/demo"):
return FakeResponse(200, {"status": "ok"})
calls.append((method, url))
if method == "GET" and url.endswith("/collections/demo-reference"):
return FakeResponse(200, {"result": {}})
if method == "DELETE" and url.endswith("/collections/demo-reference"):
@@ -357,23 +273,13 @@ def test_clear_migrates_legacy_memory_before_retiring_shared_collection():
store = QdrantVectorStore(
base_url="http://qdrant:6333",
collections={"reference": "demo-reference", "memory": "demo-memory"},
workspace_id="demo",
expected_dimension=1024,
request=request,
workspace_id="demo", expected_dimension=1024, request=request,
)
assert store.clear_reference() is True
migrated = next(
payload for method, url, payload in calls
if method == "PUT" and "/collections/demo-memory/points?wait=true" in url
)
assert migrated == {"points": [legacy_point]}
deleted = [url for method, url, _payload in calls if method == "DELETE"]
assert deleted == [
"http://qdrant:6333/collections/demo",
"http://qdrant:6333/collections/demo-reference",
assert calls == [
("GET", "http://qdrant:6333/collections/demo-reference"),
("DELETE", "http://qdrant:6333/collections/demo-reference"),
]
assert not any(url.endswith("/collections/demo-memory") for url in deleted)
def _ready_collection_with_bm25(fake: FakeQdrantHttp) -> None:
@@ -718,6 +624,40 @@ def test_search_filters_by_workspace_and_allowed_record_kinds():
}
@pytest.mark.parametrize("kind,family", [("memory", "domain_clarification"),
("solved_question", "solved_question")])
def test_memory_hybrid_applies_identical_scope_to_both_prefetch_branches(kind, family):
from tht.memory.retrieval import RecallScope
fake = FakeQdrantHttp()
_ready_collection_with_bm25(fake)
store = _store(fake)
scope = RecallScope(database="dwh", schema_name="sales", table="orders", column="id",
scope="Sales", concepts=["grain"])
store.search(["memory"], [0.2] * 1024, limit=5, kinds=[kind],
query_text="Order grain", query_language="english",
metadata_filter=scope.vector_filter(family))
query = next(call[2] for call in reversed(fake.calls) if call[1].endswith("/points/query"))
dense, lexical = query["prefetch"]
assert dense["filter"] == lexical["filter"]
must = dense["filter"]["must"]
assert {"key": "workspace_id", "match": {"value": "demo"}} in must
assert {"key": "memory_family", "match": {"value": family}} in must
assert {"key": "memory_format", "match": {"value": 2}} in must
assert {"key": "memory_scope", "match": {"value": "Sales"}} in must
assert {"key": "memory_concepts", "match": {"value": "grain"}} in must
nested = must[-1]["should"][1]["nested"]
assert nested["key"] == "memory_dependencies"
assert nested["filter"]["must"] == [
{"key": "database", "match": {"value": "dwh"}},
{"key": "schema_name", "match": {"any": ["", "sales"]}},
{"key": "table", "match": {"any": ["", "orders"]}},
{"key": "column", "match": {"any": ["", "id"]}},
]
assert lexical["using"] == "bm25"
assert query["query"] == {"rrf": {}}
def test_evidence_search_refuses_dense_only_fallback():
store = _store(FakeQdrantHttp())
+68 -119
View File
@@ -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
@@ -34,6 +34,9 @@ def test_built_wheel_omits_vector_sql_migrations_and_discovers_cli(tmp_path):
assert not any(name.startswith("tht/migrations/vector/") for name in names)
assert "tht/migrations/sessions/001_schema.sql" in names
assert "tht/migrations/sessions/002_security.sql" in names
assert "tht/migrations/memory/001_memory.sql" in names
assert "tht/migrations/memory/002_hybrid_projection.sql" in names
assert "tht/migrations/memory/003_review_receipts.sql" in names
subprocess.run(
[sys.executable, "-m", "pip", "install", "--no-deps", "--target", str(target), wheel],
@@ -111,6 +111,7 @@ def test_workflow_definition_has_the_approved_semantic_contract():
"name": "datamart",
"advance": "reviewer_decide",
"prerequisites": [
{"decision_exists": "memory_summary_reviewed"},
{
"any": [
{"decision_exists": "datamart_requested"},
@@ -124,6 +125,7 @@ def test_workflow_definition_has_the_approved_semantic_contract():
"datamart_declined",
"memory_promoted",
"memory_promotion_declined",
"memory_summary_reviewed",
],
},
]