feat: P3 effective configuration, memory root, and revision-scoped records
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user