259 lines
8.1 KiB
Python
259 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
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.ports.vector import VectorStoreError
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ignore_operator_profile(monkeypatch):
|
|
"""Exercise the self-contained server runtime fixture, not the developer's harness/.env."""
|
|
monkeypatch.delenv("THT_PROFILE", raising=False)
|
|
|
|
|
|
class _FakeEmbedder:
|
|
def embed_documents(self, documents):
|
|
return [[0.1] * 4 for _ in documents]
|
|
|
|
|
|
class _FakeVectorStore:
|
|
def __init__(self):
|
|
self.upserts = []
|
|
|
|
def existing_hashes(self, collection, kinds):
|
|
return {}
|
|
|
|
def delete_kinds(self, collection, kinds):
|
|
return 0
|
|
|
|
def upsert(self, collection, records):
|
|
self.upserts.append((collection, records))
|
|
return len(records)
|
|
|
|
def _sha_file(path: Path) -> str:
|
|
return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def _qdrant_runtime_config(tmp_path: Path) -> Path:
|
|
cfg = tmp_path / "workspace.yaml"
|
|
cfg.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'}
|
|
catalog_metadata_snapshot: {tmp_path / 'catalog-metadata.json'}
|
|
embeddings:
|
|
provider: ollama_internal
|
|
base_url: http://embedding:11434
|
|
model: qwen3-embedding:0.6b
|
|
dim: 1024
|
|
"""
|
|
)
|
|
return cfg
|
|
|
|
|
|
def _write_catalog_snapshot(tmp_path: Path) -> None:
|
|
(tmp_path / "catalog-metadata.json").write_text(json.dumps({
|
|
"schemaVersion": 1,
|
|
"workspaceId": "psd-clinical",
|
|
"databaseId": "db-1",
|
|
"databaseName": "analytics",
|
|
"schemaName": "mart",
|
|
"metadataContentRevision": 1,
|
|
"tables": [{
|
|
"id": "table-1",
|
|
"name": "fact_patient",
|
|
"description": "Patients",
|
|
"descriptionSource": "curated",
|
|
"columns": [{
|
|
"id": "column-1",
|
|
"name": "id",
|
|
"ordinalPosition": 1,
|
|
"dataType": "bigint",
|
|
"isNullable": False,
|
|
"defaultExpression": None,
|
|
"primaryKeyPosition": 1,
|
|
"sensitive": False,
|
|
"description": None,
|
|
"descriptionSource": None,
|
|
}],
|
|
}],
|
|
"relationships": [],
|
|
}))
|
|
|
|
|
|
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)
|
|
store = _FakeVectorStore()
|
|
|
|
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, ["vector", "index-schema", "-c", str(cfg)])
|
|
|
|
assert res.exit_code == 0, res.output
|
|
assert store.upserts
|
|
|
|
|
|
def test_vector_index_schema_json_is_pristine_and_reports_artifacts(tmp_path, monkeypatch):
|
|
cfg = _qdrant_runtime_config(tmp_path)
|
|
_write_catalog_snapshot(tmp_path)
|
|
store = _FakeVectorStore()
|
|
|
|
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
|
|
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
|
|
|
|
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
|
|
|
assert response.exit_code == 0, response.output
|
|
assert response.stderr == ""
|
|
assert json.loads(response.stdout) == {
|
|
"artifactIdentities": [
|
|
{
|
|
"digest": _sha_file(tmp_path / "catalog-metadata.json"),
|
|
"kind": "catalog_metadata_snapshot",
|
|
},
|
|
],
|
|
"code": "ok",
|
|
"collection": "psd-clinical",
|
|
"counts": {
|
|
"added": 2,
|
|
"columns": 1,
|
|
"deleted": 0,
|
|
"records": 2,
|
|
"relationships": 0,
|
|
"tables": 1,
|
|
"unchanged": 0,
|
|
"updated": 0,
|
|
},
|
|
"operation": "index_schema",
|
|
"schemaVersion": 1,
|
|
"status": "succeeded",
|
|
"workspaceId": "psd-clinical",
|
|
"workspaceRevision": "a" * 40,
|
|
}
|
|
|
|
|
|
def test_vector_index_schema_json_failure_is_pristine(tmp_path, monkeypatch):
|
|
cfg = _qdrant_runtime_config(tmp_path)
|
|
_write_catalog_snapshot(tmp_path)
|
|
|
|
def boom(cfg, require_write):
|
|
raise VectorStoreError("semantic_index_incompatible")
|
|
|
|
monkeypatch.setattr("tht.adapters.factory.build_vector_store", boom)
|
|
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
|
|
|
|
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
|
|
|
assert response.exit_code == 1
|
|
assert response.stderr == ""
|
|
assert json.loads(response.stdout) == {
|
|
"code": "semantic_index_incompatible",
|
|
"error": "semantic index incompatible",
|
|
"operation": "index_schema",
|
|
"schemaVersion": 1,
|
|
"status": "failed",
|
|
"workspaceId": "psd-clinical",
|
|
"workspaceRevision": "a" * 40,
|
|
}
|
|
|
|
|
|
def test_memory_promote_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
|
|
cfg = _qdrant_runtime_config(tmp_path)
|
|
store = _FakeVectorStore()
|
|
promoted = [_memory_record()]
|
|
snapshot = SimpleNamespace(manifest=SimpleNamespace(id="s1"))
|
|
|
|
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
|
|
|
|
|
|
def test_memory_index_accepts_qdrant_only_runtime_config(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
|
|
|
|
|
|
def test_vector_help_exposes_only_the_supported_qdrant_command():
|
|
res = CliRunner().invoke(app, ["vector", "--help"])
|
|
|
|
assert res.exit_code == 0, res.output
|
|
assert "migrate" not in res.output
|
|
assert "init" not in res.output
|
|
assert "index-schema" in res.output
|
|
|
|
|
|
def test_vector_migrate_command_is_absent():
|
|
res = CliRunner().invoke(app, ["vector", "migrate", "--help"])
|
|
|
|
assert res.exit_code != 0
|
|
assert "No such command 'migrate'" in res.output
|
|
|
|
|
|
def test_memory_solved_index_help_uses_semantic_store_wording():
|
|
res = CliRunner().invoke(app, ["memory", "solved-index", "--help"])
|
|
|
|
assert res.exit_code == 0, res.output
|
|
assert "semantic" in res.output.lower() or "qdrant" in res.output.lower()
|
|
assert "vectordb" not in res.output.lower()
|