import os import re import warnings from pathlib import Path from typing import Annotated, Any, Literal import yaml from pydantic import BaseModel, Field, 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 _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 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) EvidenceSourceConfig = Annotated[ FilesystemEvidenceSourceConfig | HttpEvidenceSourceConfig, 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 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", {}))