352 lines
12 KiB
Python
352 lines
12 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, 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 _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()}
|
|
if "password_file" in resolved:
|
|
if "password" in resolved:
|
|
raise ConfigError("password and password_file are mutually exclusive")
|
|
path = Path(resolved.pop("password_file"))
|
|
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["password"] = 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 EvidenceSourcesConfig(BaseModel):
|
|
source_root: Path
|
|
# 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"
|
|
|
|
|
|
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
|
|
|
|
|
|
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):
|
|
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 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}."
|
|
)
|
|
data_root = 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,
|
|
)
|
|
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", {}))
|