Files
ThothII/harness/tht/config.py
T

425 lines
16 KiB
Python

import os
import re
import warnings
from pathlib import Path
from typing import Annotated, Any, Literal
import yaml
from pydantic import BaseModel, Field, PrivateAttr, SecretStr, model_validator, ValidationError
from tht.config_compat import translate_legacy_config
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
class ConfigError(Exception):
"""Errore di configurazione, con messaggio leggibile per l'utente."""
def local_tht_home() -> Path:
"""Return the private local ThothII home, honoring the explicit override."""
return Path(os.environ.get("THT_HOME", "~/.thothii")).expanduser()
def _expand_env(value: Any) -> Any:
if isinstance(value, str):
def repl(m: re.Match) -> str:
var = m.group(1)
if var not in os.environ:
raise ConfigError(
f"Variabile d'ambiente non definita: {var} "
f"(definiscila nel file .env o nell'ambiente)"
)
return os.environ[var]
return _ENV_RE.sub(repl, value)
if isinstance(value, dict):
return {k: _expand_env(v) for k, v in value.items()}
if isinstance(value, list):
return [_expand_env(v) for v in value]
return value
def _resolve_secret_files(value: Any) -> Any:
if isinstance(value, dict):
resolved = {key: _resolve_secret_files(item) for key, item in value.items()}
for secret_name in ("password", "access_key", "secret_key", "session_token"):
file_name = f"{secret_name}_file"
if file_name not in resolved:
continue
if secret_name in resolved:
raise ConfigError(f"{secret_name} and {file_name} are mutually exclusive")
path = Path(resolved.pop(file_name))
try:
secret = path.read_text()
except (OSError, UnicodeError) as exc:
raise ConfigError(f"Cannot read secret file: {path}") from exc
if not secret or any(char.isspace() for char in secret) or "\x00" in secret:
raise ConfigError(f"Invalid secret file: {path}")
resolved[secret_name] = secret
return resolved
if isinstance(value, list):
return [_resolve_secret_files(item) for item in value]
return value
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
database: str
db_schema: str = Field(alias="schema")
user: str
password: str
# transport: `direct` (Postgres via SQLAlchemy) o `rest` (Supabase/PostgREST).
# In `rest` deve esistere la sezione `rest` (validato a livello di Config).
transport: Literal["direct", "rest"] = "direct"
model_config = {"populate_by_name": True}
class RestConfig(BaseModel):
"""Accesso al DWH via Supabase/PostgREST. base_url es. https://host/dwh/ ."""
base_url: str
api_key: str
timeout: int = 30
connect_timeout: int = 5
ssl_ca: str | None = None # path al certificato CA (per server con CA interna)
class DatabaseIdentityConfig(BaseModel):
database: str
db_schema: str = Field(alias="schema")
model_config = {"populate_by_name": True}
class PostgresDwhConfig(BaseModel):
type: Literal["postgres_direct"]
connection: DatabaseConfig
class ThothRestDwhConfig(BaseModel):
type: Literal["thoth_rest"]
database: DatabaseIdentityConfig
endpoint: RestConfig
DwhResourceConfig = Annotated[
PostgresDwhConfig | ThothRestDwhConfig,
Field(discriminator="type"),
]
class PgvectorDirectConfig(BaseModel):
type: Literal["pgvector_direct"]
reader: DatabaseConfig | None = None
writer: DatabaseConfig | None = None
# Deprecated compatibility: a single direct connection historically meant read-only.
connection: DatabaseConfig | None = None
@model_validator(mode="after")
def validate_connections(self):
if self.reader is None and self.writer is None and self.connection is None:
raise ValueError("pgvector_direct requires a reader or writer connection")
return self
class ThothVectorHttpConfig(BaseModel):
type: Literal["thoth_vector_http"]
reader: RestConfig | None = None
writer: RestConfig | None = None
# Transitional direct loading path used by the server profile.
direct: DatabaseConfig | None = None
VectorResourceConfig = Annotated[
PgvectorDirectConfig | ThothVectorHttpConfig,
Field(discriminator="type"),
]
class PathsConfig(BaseModel):
artifacts: Path = Path("artifacts")
indexes: Path = Path("indexes")
sessions: Path = Path("sessions")
class WorkspaceRoots(PathsConfig):
pass
class ExamplesConfig(BaseModel):
max_per_column: int = 10
class LshSkipConfig(BaseModel):
# DEPRECATO: l'euristica di lunghezza (skip_column vendored) non è più usata. La
# selezione delle colonne da indicizzare segue il principio di column eligibility
# (sezione `eligibility`). Mantenuto solo per compatibilità con tht.yaml esistenti.
max_total_chars: int = 50000
max_avg_length: int = 20
class LshConfig(BaseModel):
signature_size: int = 64
n_gram: int = 3
threshold: float = 0.5
max_values_per_column: int = 1000
skip: LshSkipConfig = LshSkipConfig()
class EligibilityConfig(BaseModel):
# Soglie del principio di column eligibility (testo ampio ignorato ovunque).
max_declared_len: int = 128 # char/varchar dichiarati <= soglia: eligible senza campionare
max_avg_length: int = 40 # fallback data-driven: lunghezza media valori campionati
max_sampled_len: int = 200 # fallback data-driven: lunghezza massima valore campionato
# Colonne di servizio sempre ignorate per nome (match case-insensitive), a prescindere
# dal tipo: metadati ETL/audit non analitici (es. timestamp di ultimo aggiornamento).
ignore_columns: list[str] = ["etl_last_update"]
class FilesystemEvidenceSourceConfig(BaseModel):
type: Literal["filesystem"]
root: Path
patterns: list[str] = ["**/*.md"]
max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
class HttpEvidenceSourceConfig(BaseModel):
type: Literal["http"]
# Manifest URLs may contain signed query parameters. Treat the complete transport URL as
# secret-bearing configuration; adapters derive a query-free provenance URI from it.
urls: list[SecretStr] = Field(min_length=1)
connect_timeout: float = Field(default=5, gt=0)
read_timeout: float = Field(default=30, gt=0)
max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
max_redirects: int = Field(default=5, ge=0)
allow_private_hosts: bool = False
max_cache_bytes: int = Field(default=64 * 1024 * 1024, gt=0)
class S3EvidenceSourceConfig(BaseModel):
type: Literal["s3"]
bucket: str = Field(min_length=1)
prefix: str = ""
endpoint_url: str | None = None
region: str | None = None
access_key: SecretStr | None = None
secret_key: SecretStr | None = None
session_token: SecretStr | None = None
trusted_endpoint: bool = False
allow_private_endpoint: bool = False
allow_insecure_endpoint: bool = False
max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
max_objects: int = Field(default=10_000, gt=0)
max_pages: int = Field(default=100, gt=0)
page_size: int = Field(default=1000, gt=0, le=1000)
EvidenceSourceConfig = Annotated[
FilesystemEvidenceSourceConfig | HttpEvidenceSourceConfig | S3EvidenceSourceConfig,
Field(discriminator="type"),
]
class EvidenceSourcesConfig(BaseModel):
# Legacy curated-tree configuration remains accepted during migration.
source_root: Path | None = None
# cartella curata a mano nell'ETL (relativa a source_root): unica fonte delle
# evidence. Niente piu' estrazione automatica dalle schede tabella: i documenti
# qui dentro sono gia' evidence pronte (frontmatter + corpo), scelte e arricchite
# dall'autore ETL e organizzate in sottocartelle per dominio.
evidence_dir: str = "evidence"
sources: list[EvidenceSourceConfig] = []
@model_validator(mode="after")
def require_a_source(self):
if self.source_root is None and not self.sources:
raise ValueError("evidence requires source_root or sources")
return self
class EmbeddingsConfig(BaseModel):
base_url: str
model: str = "nomic-embed-text-v2-moe"
dim: int = 768
batch_size: int = 32
timeout: int = 30
connect_timeout: int = 5
bin: str = "ollama"
start_cmd: list[str] | None = None
class VectorConfig(BaseModel):
max_chunk_chars: int = 4000
# ACTIVE plus the two most recent rollback generations by default.
retain_published_generations: int = Field(default=3, ge=1)
class SearchConfig(BaseModel):
rrf_k: int = 60
top_schema_tables: int = 12 # default `--top` per `tht search --kind schema` (n. tabelle)
schema_chunk_pool: int = 150 # chunk tabella/colonna fusi prima dell'aggregazione a tabella
class ExecutionConfig(BaseModel):
allow: list[str] = ["cte_test", "explain", "preview", "aggregate", "export"]
max_preview_rows: int = 10
statement_timeout_ms: int = 30000
warn_execution_ms: int = 5000
warn_plan_rows: int = 1_000_000
max_aggregate_cells: int = 20
max_export_rows: int = 100000
forbidden_functions: list[str] = [
"setval",
"nextval",
"pg_advisory_lock",
"pg_advisory_xact_lock",
"dblink",
"dblink_exec",
"pg_terminate_backend",
"pg_cancel_backend",
"lo_import",
"lo_export",
"pg_reload_conf",
]
class Config(BaseModel):
_workspace_id: str = PrivateAttr(default="default")
_config_source: str = PrivateAttr(default="direct")
dwh: DwhResourceConfig
vectors: VectorResourceConfig | None = None
roots: WorkspaceRoots = WorkspaceRoots()
# Compatibility views retained until all call sites consume typed resources.
database: DatabaseConfig
# Profilo dell'installazione, letto da THT_PROFILE (.env), non dallo yaml versionato.
# server: ricostruisce i derivati (artefatti, LSH, vettori schema nel vectordb).
# workstation: postazione locale che legge il vectordb via REST; gli upsert remoti
# richiedono vector_write_rest, mentre init/clear/rebuild restano solo-server.
profile: Literal["server", "workstation"] = "server"
# Language in which table/column descriptions and evidence are written. The skill
# instructions stay in English; only content/output follow this language. Default
# 'en' so Thoth is not bound to any customer's language.
language: str = "en"
paths: PathsConfig = PathsConfig()
examples: ExamplesConfig = ExamplesConfig()
lsh: LshConfig = LshConfig()
eligibility: EligibilityConfig = EligibilityConfig()
evidence: EvidenceSourcesConfig | None = None
embeddings: EmbeddingsConfig | None = None
vector_db: DatabaseConfig | None = None
vector: VectorConfig = VectorConfig()
search: SearchConfig = SearchConfig()
execution: ExecutionConfig = ExecutionConfig()
rest: RestConfig | None = None
# Endpoint REST dedicato per la similarity search del pgvector (rpc search_similar).
# Se presente, `tht search` legge via REST; altrimenti legge in diretto (dev/test).
vector_rest: RestConfig | None = None
# Endpoint REST dedicato alle scritture controllate del pgvector. E' opzionale e usa
# una API key separata dalla lettura; espone solo upsert/hash via RPC allowlist.
vector_write_rest: RestConfig | None = None
@model_validator(mode="before")
@classmethod
def accept_legacy_constructor_fields(cls, value: Any) -> Any:
if not isinstance(value, dict) or "dwh" in value:
return value
translated, _ = translate_legacy_config(value)
_populate_legacy_views(translated)
return translated
def workspace_id_from_path(path: Path) -> str:
"""Return the stable workspace identity, resolving deployment aliases first."""
return path.resolve().stem.lower().replace(".", "-").replace("_", "-")
def load_config(path: Path) -> Config:
if not path.exists():
raise ConfigError(f"File di configurazione non trovato: {path}")
raw = yaml.safe_load(path.read_text())
if not isinstance(raw, dict):
raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}")
expanded = _resolve_secret_files(_expand_env(raw))
translated, used_legacy = translate_legacy_config(expanded)
_populate_legacy_views(translated)
try:
cfg = Config.model_validate(translated)
except ValidationError as e:
raise ConfigError(f"Configurazione non valida in {path}:\n{e}") from e
env_profile = os.environ.get("THT_PROFILE")
if env_profile is not None:
if env_profile not in ("server", "workstation"):
raise ConfigError(
f"THT_PROFILE non valido: {env_profile!r} (atteso 'server' o 'workstation')."
)
cfg = cfg.model_copy(update={"profile": env_profile})
if cfg.database.transport == "rest" and cfg.rest is None:
raise ConfigError(
f"transport: rest richiede la sezione `rest` (base_url, api_key) in {path}."
)
# THT_HOME is the local-user storage root. Retain THT_DATA_ROOT as the
# portable-deployment compatibility name until all callers use repositories.
data_root = os.environ.get("THT_HOME") or os.environ.get("THT_DATA_ROOT")
if data_root:
# Import locally: paths owns resolution, while ConfigError remains the public
# configuration exception callers already handle.
from tht.paths import resolve_workspace_paths
resolved = resolve_workspace_paths(path, cfg, Path(data_root))
cfg = cfg.model_copy(
update={
"paths": PathsConfig(
sessions=resolved.sessions,
artifacts=resolved.artifacts,
indexes=resolved.indexes,
)
}
)
elif not used_legacy:
# Modern `roots` replace `paths`; without a mounted data root retain the old
# working-directory-relative behavior used by local development.
cfg = cfg.model_copy(update={"paths": PathsConfig(**cfg.roots.model_dump())})
if used_legacy:
warnings.warn(
"DEPRECATION: legacy workspace resource keys are deprecated; "
"use dwh, vectors, and roots.",
FutureWarning,
stacklevel=2,
)
cfg._workspace_id = workspace_id_from_path(path)
cfg._config_source = path.resolve().as_posix()
return cfg
def _populate_legacy_views(raw: dict[str, Any]) -> None:
"""Populate old Config attributes for command compatibility during migration."""
dwh = raw.get("dwh")
if "database" not in raw and isinstance(dwh, dict):
if dwh.get("type") == "postgres_direct":
raw["database"] = {**dwh["connection"], "transport": "direct"}
elif dwh.get("type") == "thoth_rest":
raw["database"] = {
**dwh["database"],
"user": "rest",
"password": "",
"transport": "rest",
}
raw["rest"] = dwh["endpoint"]
vectors = raw.get("vectors")
if isinstance(vectors, dict):
if vectors.get("type") == "pgvector_direct":
raw.setdefault(
"vector_db",
vectors.get("writer") or vectors.get("reader") or vectors.get("connection"),
)
elif vectors.get("type") == "thoth_vector_http":
raw.setdefault("vector_rest", vectors.get("reader"))
raw.setdefault("vector_write_rest", vectors.get("writer"))
raw.setdefault("vector_db", vectors.get("direct"))
raw.setdefault("paths", raw.get("roots", {}))