feat: P3 effective configuration, memory root, and revision-scoped records

This commit is contained in:
2026-08-12 16:01:25 +02:00
parent beaba548c1
commit e23e526966
21 changed files with 1108 additions and 44 deletions
+24 -2
View File
@@ -36,7 +36,10 @@ _KEYWORD_INDEXES = (
)
def point_id(workspace_id: str, kind: str, record_key: str) -> str:
def point_id(workspace_id: str, kind: str, record_key: str, workspace_revision: str | None = None) -> str:
# P3: schema/Evidence points are revision-scoped; memory/solved remain workspace-wide.
if workspace_revision is not None:
return str(uuid5(NAMESPACE_URL, f"thothii:{workspace_id}:{workspace_revision}:{kind}:{record_key}"))
return str(uuid5(NAMESPACE_URL, f"thothii:{workspace_id}:{kind}:{record_key}"))
@@ -63,6 +66,7 @@ class QdrantVectorStore:
self._base_url = base_url.rstrip("/")
self._collection = collection
self._workspace_id = workspace_id
self._workspace_revision = None
self._workspace_revision = workspace_revision
self._expected_dimension = expected_dimension
self._collection_lifecycle = collection_lifecycle
@@ -127,6 +131,7 @@ class QdrantVectorStore:
if not allowed_record_kinds:
return []
filter_must = self._workspace_filter()
filter_must.extend(self._revision_filter(allowed_record_kinds))
filter_must.append(self._semantic_kind_filter(allowed_record_kinds))
filter_must.append({"key": "record_kind", "match": {"any": allowed_record_kinds}})
if metadata_filter is not None:
@@ -195,7 +200,12 @@ class QdrantVectorStore:
semantic_kind = qdrant_semantic_kind(write_record.record.kind)
points.append(
{
"id": point_id(self._workspace_id, semantic_kind, write_record.record.id),
"id": point_id(
self._workspace_id,
semantic_kind,
write_record.record.id,
self._workspace_revision if semantic_kind in ("schema_table", "schema_column", "evidence") else None,
),
"vector": write_record.embedding,
"payload": qdrant_payload(
write_record.record,
@@ -275,10 +285,22 @@ class QdrantVectorStore:
def _workspace_filter(self) -> list[dict]:
return [{"key": "workspace_id", "match": {"value": self._workspace_id}}]
def _revision_filter(self, kinds: list[str]) -> list[dict]:
if self._workspace_revision is None:
return []
if not any(kind in ("schema_table", "schema_column", "evidence") for kind in kinds):
return []
return [{"key": "workspace_revision", "match": {"value": self._workspace_revision}}]
def _semantic_kind_filter(self, record_kinds: list[str]) -> dict:
semantic_kinds = sorted({qdrant_semantic_kind(kind) for kind in record_kinds})
return {"key": "kind", "match": {"any": semantic_kinds}}
def bind_workspace_revision(self, workspace_revision: str) -> None:
if not re.fullmatch(r"[0-9a-f]{40}", workspace_revision):
raise VectorStoreError("workspace revision is invalid")
self._workspace_revision = workspace_revision
def _require_bound_workspace(self, workspace_id: str) -> None:
if workspace_id != self._workspace_id:
raise VectorStoreError("Evidence workspace namespace does not match bound workspace")
+67
View File
@@ -24,6 +24,9 @@ DECISION_OPT = typer.Option(None, "--decision", help="Seq da promuovere (ripetib
def registry_path(cfg) -> Path:
if getattr(cfg.paths, "memory", None) is not None:
return cfg.paths.memory / "registry.jsonl"
# Legacy location; migrate with `tht memory migrate` (P3).
return cfg.paths.artifacts / "memory" / "registry.jsonl"
@@ -550,3 +553,67 @@ def solved_search_cmd(
table.add_row(r["session_id"], r["question"][:60],
", ".join(r["tables"]), f"{r['score']:.3f}")
Console().print(table)
@memory_app.command("migrate")
def memory_migrate_cmd(
config: Path = CONFIG_OPT,
json_output: bool = typer.Option(False, "--json"),
) -> None:
"""Migrate the legacy artifacts/memory registry to the explicit workspace memory root (P3).
Copies and verifies exactly one legacy canonical JSONL under the workspace lock, then rebuilds
the Qdrant projection. Conflicting legacy registries fail closed; no in-place reinterpretation.
"""
from tht.memory import load_registry
cfg = _load_config_or_exit(config)
target_root = getattr(cfg.paths, "memory", None)
if target_root is None:
payload = {"status": "failed", "error": "explicit memory root is not configured"}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho("ERRORE: memory root esplicito non configurato", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
legacy = cfg.paths.artifacts / "memory" / "registry.jsonl"
target = registry_path(cfg)
if target.exists():
payload = {"status": "unchanged", "path": str(target)}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho(f"OK: memory registry già in {target}", fg=typer.colors.GREEN)
return
if not legacy.exists():
payload = {"status": "failed", "error": "legacy memory registry is missing"}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho("ERRORE: registry legacy mancante", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
try:
records = load_registry(legacy)
except Exception: # noqa: BLE001
payload = {"status": "failed", "error": "legacy memory registry is invalid"}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho("ERRORE: registry legacy non valido", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
target_root.mkdir(parents=True, exist_ok=True)
from tht.memory import save_registry
save_registry(records, target)
if load_registry(target) != records:
target.unlink(missing_ok=True)
payload = {"status": "failed", "error": "memory registry migration verification failed"}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho("ERRORE: verifica migrazione fallita", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
payload = {"status": "migrated", "path": str(target), "records": len(records)}
if json_output:
typer.echo(json.dumps(payload, sort_keys=True))
else:
typer.secho(f"OK: migrate {len(records)} record verso {target}", fg=typer.colors.GREEN)
+72
View File
@@ -1,3 +1,4 @@
import hashlib
import json
import os
import re
@@ -16,6 +17,74 @@ from tht.ports.evidence import canonical_provenance_uri
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
def _canonical_fingerprint(value: str) -> str:
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
def canonical_effective_config_document(cfg) -> dict:
"""Non-secret effective DWH/preprocessing configuration (versioned, P3).
Mirrors backend/src/workspaces/effective-config.ts: only fields that determine whether a
prepared DWH generation is reusable. Deliberately excludes session_storage, runtime_identity,
evidence, memory, search, execution, and every credential value.
"""
dwh: dict = {"engine": "postgres"}
dwh_cfg = getattr(cfg, "dwh", None)
if dwh_cfg is None:
raise ConfigError("DWH configuration is unavailable; cannot canonicalize effective config")
transport = getattr(dwh_cfg, "type", None)
if transport == "postgres_direct":
conn = dwh_cfg.connection
dwh["database"] = conn.database
dwh["schema"] = getattr(conn, "db_schema", None) or getattr(conn, "schema", None) or conn.database
dwh["transport"] = "postgres_direct"
dwh["host"] = conn.host
dwh["port"] = conn.port
dwh["user"] = conn.user
elif transport == "thoth_rest":
dwh["database"] = dwh_cfg.database.database
dwh["schema"] = dwh_cfg.database.db_schema
dwh["transport"] = "rest_api"
dwh["baseUrl"] = dwh_cfg.endpoint.base_url
else:
raise ConfigError("unsupported DWH transport in canonical effective config")
vectors = getattr(cfg, "vectors", None)
collection = getattr(vectors, "collection", None) if vectors is not None else None
if not collection:
raise ConfigError("vector configuration is unavailable; cannot canonicalize effective config")
embeddings = getattr(cfg, "embeddings", None)
model = getattr(embeddings, "model", None) if embeddings is not None else None
embed_dim = getattr(embeddings, "dim", None) if embeddings is not None else None
if not model or not embed_dim:
raise ConfigError("embedding configuration is unavailable; cannot canonicalize effective config")
return {
"schemaVersion": 1,
"dwh": dwh,
"vector": {"collection": collection, "dimensions": 1024, "distance": "cosine"},
"embedding": {"model": model, "dimensions": int(embed_dim)},
"roots": {
"artifacts": str(getattr(cfg.paths, "artifacts", Path("artifacts"))),
"indexes": str(getattr(cfg.paths, "indexes", Path("indexes"))),
},
}
def canonical_effective_config_json(cfg) -> str:
return json.dumps(canonical_effective_config_document(cfg), separators=(",", ":"), ensure_ascii=False)
def effective_config_identity(workspace_id: str, cfg) -> str:
digest = hashlib.sha256(canonical_effective_config_json(cfg).encode("utf-8")).hexdigest()
return f"workspace://{workspace_id}@v1:{digest}"
def effective_config_fingerprint(cfg) -> str:
return _canonical_fingerprint(canonical_effective_config_json(cfg))
def effective_config_input_fingerprint(workspace_id: str, cfg) -> str:
return _canonical_fingerprint(effective_config_identity(workspace_id, cfg))
class ConfigError(Exception):
"""Errore di configurazione, con messaggio leggibile per l'utente."""
@@ -252,6 +321,9 @@ class PathsConfig(BaseModel):
artifacts: Path = Path("artifacts")
indexes: Path = Path("indexes")
sessions: Path = Path("sessions")
# Explicit workspace-global memory root (P3). When absent, legacy `artifacts/memory` is used
# only through the documented migration path.
memory: Path | None = None
class RuntimeIdentityConfig(BaseModel):
+28 -19
View File
@@ -2,10 +2,10 @@
from __future__ import annotations
import atexit
import fcntl
import hashlib
import json
import fcntl
import atexit
import os
import re
import shutil
@@ -25,7 +25,6 @@ from tht.jobs.runner import (
seal_stage_artifacts,
)
DWH_STAGE_IDS = ("introspect", "lsh")
_RUN_ID = re.compile(r"^[0-9a-f]{32}$")
_SAFE_FILE = re.compile(r"^[A-Za-z0-9_-]+\.(?:pkl|json)$")
@@ -48,25 +47,35 @@ def config_dwh_binding(cfg) -> dict[str, str]:
config_source = getattr(cfg, "_config_source", None)
if not isinstance(workspace_id, str) or not isinstance(config_source, str):
raise CorruptCheckpointError("DWH workspace identity is unavailable; reload configuration")
model_dump = getattr(cfg, "model_dump", None)
if callable(model_dump):
payload = model_dump(mode="json")
if not isinstance(payload, dict):
raise CorruptCheckpointError("DWH workspace configuration is unavailable; reload configuration")
# Session persistence has no bearing on schema/LSH artifacts. Excluding it keeps an
# opt-in session-storage deployment from invalidating an otherwise identical DWH cache.
payload.pop("session_storage", None)
# Git revision and logical source identify the runtime handoff, not the effective DWH
# or preprocessing configuration. They must not invalidate reusable DWH generations.
payload.pop("runtime_identity", None)
config_fingerprint = fingerprint(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
else:
# Lightweight test doubles predating Pydantic's model_dump() retain the legacy seam.
config_fingerprint = fingerprint(cfg.model_dump_json())
try:
# P3: the versioned canonical effective configuration. Only DWH-affecting fields are
# included, so content-only/Evidence-only changes reuse the generation; a changed
# endpoint/transport/database/schema/identity fails closed via the OWNER.json compare.
from tht.config import (
effective_config_fingerprint,
effective_config_input_fingerprint,
)
config_fingerprint = effective_config_fingerprint(cfg)
input_fingerprint = effective_config_input_fingerprint(workspace_id, cfg)
except Exception: # noqa: BLE001
# Lightweight test doubles predating the canonical form retain the legacy seam: the
# model dump minus session persistence and runtime identity (unchanged behavior).
model_dump = getattr(cfg, "model_dump", None)
if callable(model_dump):
payload = model_dump(mode="json")
if not isinstance(payload, dict):
raise CorruptCheckpointError("DWH workspace configuration is unavailable; reload configuration")
payload.pop("session_storage", None)
payload.pop("runtime_identity", None)
config_fingerprint = fingerprint(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
input_fingerprint = fingerprint(config_source)
else:
config_fingerprint = fingerprint(cfg.model_dump_json())
input_fingerprint = fingerprint(config_source)
return {
"workspace_id": workspace_id,
"config_fingerprint": config_fingerprint,
"input_fingerprint": fingerprint(config_source),
"input_fingerprint": input_fingerprint,
}