pretensor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,168 @@
1
+ """Index-time embedding computation for SchemaTable nodes.
2
+
3
+ Opt-in via ``PretensorConfig.embeddings.index_tables``. When the toggle is
4
+ off (default) nothing runs — zero reads, zero writes — so the null path
5
+ stays byte-identical to the pre-embedding pipeline output.
6
+
7
+ :func:`compute_table_embeddings` is the single computation entry point.
8
+ Index and reindex call it right after schema rows are written and *before*
9
+ relationship discovery, so the embedding relationship scorer, the
10
+ clustering blend, and the classify step's role vote all consume vectors
11
+ from the same run — no one-cycle staging lag. :class:`EmbeddingIndexStep`
12
+ remains in the pipeline for callers that invoke
13
+ ``run_intelligence_layer`` directly; it delegates to the same function and
14
+ skips when the context marks vectors as already computed this run.
15
+
16
+ Every entry point honors the ``PRETENSOR_EMBEDDINGS_DISABLED`` environment
17
+ variable: when set to a truthy value (``1``, ``true``, ``yes`` —
18
+ case-insensitive), the embedding pass is forced to the null path even when
19
+ the ``[embeddings]`` extra is installed and ``index_tables`` is True. CI
20
+ exercises this to assert that "extra installed but user opted out"
21
+ produces output byte-identical to "extra absent" — the determinism
22
+ contract behind the release gate.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import TYPE_CHECKING
29
+
30
+ from pretensor.intelligence.embeddings import (
31
+ embeddings_disabled_via_env,
32
+ format_entity_text,
33
+ get_default_embedding_client,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from pretensor.config import EmbeddingsConfig
38
+ from pretensor.core.store import KuzuStore
39
+ from pretensor.intelligence.steps import PipelineContext
40
+
41
+ # ``embeddings_disabled_via_env`` is canonically defined in
42
+ # ``embeddings.py``; it is imported above for this module's own use but is
43
+ # deliberately NOT re-exported here, so there is one authoritative import
44
+ # path for every caller.
45
+ __all__ = [
46
+ "EmbeddingIndexStep",
47
+ "compute_table_embeddings",
48
+ ]
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # Canonical context keys live in ``steps.py`` (also home to PipelineStep
54
+ # / PipelineRunner / PipelineContext). Importing them keeps writer and
55
+ # reader on one source of truth — drift between sibling string literals
56
+ # would silently turn this step into a no-op.
57
+ from pretensor.intelligence.steps import ( # noqa: E402 (post-imports to keep the section clear)
58
+ _CTX_DATABASE_KEY,
59
+ _CTX_EMBEDDINGS_CONFIG,
60
+ _CTX_EMBEDDINGS_PRECOMPUTED,
61
+ _CTX_STORE,
62
+ )
63
+
64
+
65
+ def compute_table_embeddings(store: KuzuStore, database_key: str) -> None:
66
+ """Compute one embedding per ``SchemaTable`` and persist it to Kuzu.
67
+
68
+ Callers gate on ``EmbeddingsConfig.index_tables`` and
69
+ :func:`embeddings_disabled_via_env` — this function assumes the user
70
+ opted in. When the ``[embeddings]`` extra is absent it logs once at
71
+ ``WARNING`` and returns without raising. Per-table write failures are
72
+ tolerated: the offending table is left with ``embedding=None`` and the
73
+ rest of the pass continues.
74
+ """
75
+ rows = store.query_all_rows(
76
+ """
77
+ MATCH (t:SchemaTable {database: $db})
78
+ OPTIONAL MATCH (t)-[:HAS_COLUMN]->(c:SchemaColumn)
79
+ RETURN t.node_id, t.schema_name, t.table_name, collect(c.column_name)
80
+ """,
81
+ {"db": database_key},
82
+ )
83
+ if not rows:
84
+ return
85
+
86
+ client = get_default_embedding_client()
87
+
88
+ # Build the per-table input texts and a parallel ``node_id`` list so
89
+ # we can issue ONE batched ``embed()`` call instead of N. ONNX
90
+ # inference is dominated by per-call overhead (model warm-up,
91
+ # tensor allocation), so a single 50-row batch beats 50 single-row
92
+ # calls by a wide margin on every realistic schema size.
93
+ nids: list[str] = []
94
+ texts: list[str] = []
95
+ for row in rows:
96
+ nids.append(str(row[0]))
97
+ qualified_name = f"{row[1]}.{row[2]}"
98
+ column_names = [str(c) for c in (row[3] or []) if c is not None]
99
+ texts.append(format_entity_text(qualified_name, column_names))
100
+
101
+ try:
102
+ vectors = client.embed(texts)
103
+ except ImportError as exc:
104
+ logger.warning(
105
+ "embedding_index: skipping, [embeddings] extra not installed: %s",
106
+ exc,
107
+ )
108
+ return
109
+ except Exception as exc: # noqa: BLE001 — batch failure is tolerated
110
+ logger.warning(
111
+ "embedding_index: batched embed failed (%s); leaving every "
112
+ "table's embedding=None",
113
+ exc,
114
+ )
115
+ return
116
+
117
+ if not vectors:
118
+ return
119
+ if len(vectors) != len(texts):
120
+ logger.warning(
121
+ "embedding_index: client returned %d vectors for %d texts; "
122
+ "skipping write to avoid mis-aligned assignment",
123
+ len(vectors),
124
+ len(texts),
125
+ )
126
+ return
127
+
128
+ for nid, vec in zip(nids, vectors, strict=True):
129
+ try:
130
+ store.set_table_embedding(nid, vec)
131
+ except ValueError as exc:
132
+ logger.warning("embedding_index: invalid vector for %s: %s", nid, exc)
133
+
134
+
135
+ class EmbeddingIndexStep:
136
+ """Pipeline wrapper around :func:`compute_table_embeddings`.
137
+
138
+ Kept for callers that drive ``run_intelligence_layer`` directly (and
139
+ for plugins that anchor on the ``embedding_index`` step name). The
140
+ index/reindex CLI paths compute embeddings *before* relationship
141
+ discovery and mark the context with ``embeddings_precomputed=True``,
142
+ in which case this step is a no-op.
143
+
144
+ Setting ``PRETENSOR_EMBEDDINGS_DISABLED=1`` forces the null path even
145
+ when ``index_tables`` is True; used by CI to enforce null-path parity.
146
+ """
147
+
148
+ name = "embedding_index"
149
+ # No dependencies: the computation reads only SchemaTable + HAS_COLUMN
150
+ # rows, which exist before the intelligence pipeline starts. Running
151
+ # first lets the classify step's role vote consume same-run vectors
152
+ # instead of being one index cycle behind.
153
+ dependencies: list[str] = []
154
+
155
+ async def execute(self, ctx: PipelineContext) -> None:
156
+ emb_cfg: EmbeddingsConfig | None = ctx.get(_CTX_EMBEDDINGS_CONFIG)
157
+ if emb_cfg is None or not emb_cfg.index_tables:
158
+ return
159
+ if embeddings_disabled_via_env():
160
+ logger.info("embedding_index: skipping (PRETENSOR_EMBEDDINGS_DISABLED set)")
161
+ return
162
+ if ctx.get(_CTX_EMBEDDINGS_PRECOMPUTED, False):
163
+ logger.debug("embedding_index: vectors already computed this run")
164
+ return
165
+
166
+ store: KuzuStore = ctx.get(_CTX_STORE)
167
+ database_key: str = ctx.get(_CTX_DATABASE_KEY)
168
+ compute_table_embeddings(store, database_key)
@@ -0,0 +1,6 @@
1
+ """Schema introspection: project config, DSN parsing, and compatibility shims.
2
+
3
+ Schema snapshot DTOs, PostgreSQL introspection, and ``diff_snapshots`` live in
4
+ ``pretensor.connectors``. This package re-exports ``inspector.inspect``,
5
+ ``snapshot`` helpers, and ``models.schema`` for backward compatibility.
6
+ """
@@ -0,0 +1,5 @@
1
+ """Schema inspector — re-exported from ``pretensor.connectors.inspect``."""
2
+
3
+ from pretensor.connectors.inspect import inspect
4
+
5
+ __all__ = ["inspect"]
File without changes
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class PretensorModel(BaseModel):
5
+ model_config = ConfigDict(frozen=True)
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import ConfigDict, Field, model_validator
10
+ from ruamel.yaml import YAML
11
+
12
+ from .base import PretensorModel
13
+
14
+ ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
15
+
16
+
17
+ def _resolve_env_vars(value: str) -> str:
18
+ """Replace ${VAR_NAME} references with their environment variable values."""
19
+
20
+ def _replace(match: re.Match[str]) -> str:
21
+ var_name = match.group(1)
22
+ env_value = os.environ.get(var_name)
23
+ if env_value is None:
24
+ raise ValueError(f"Environment variable '{var_name}' is not set")
25
+ return env_value
26
+
27
+ return ENV_VAR_PATTERN.sub(_replace, value)
28
+
29
+
30
+ class DatabaseType(StrEnum):
31
+ POSTGRES = "postgres"
32
+ BIGQUERY = "bigquery"
33
+ SNOWFLAKE = "snowflake"
34
+ DUCKDB = "duckdb"
35
+ MYSQL = "mysql"
36
+
37
+
38
+ class PrivacyMode(StrEnum):
39
+ SCHEMA_ONLY = "schema_only"
40
+ SYNTHETIC_SAMPLES = "synthetic_samples"
41
+ REAL_SAMPLES = "real_samples"
42
+
43
+
44
+ class SchemaFilter(PretensorModel):
45
+ include: list[str] = Field(default_factory=list)
46
+ exclude: list[str] = Field(default_factory=list)
47
+
48
+
49
+ class ConnectionConfig(PretensorModel):
50
+ # Mutable config (env resolution, optional connector-specific fields).
51
+ model_config = ConfigDict(frozen=False)
52
+
53
+ name: str
54
+ type: DatabaseType
55
+ host: str | None = None
56
+ port: int | str | None = None
57
+ database: str | None = None
58
+ user: str | None = None
59
+ password: str | None = None
60
+ schema_filter: SchemaFilter = Field(default_factory=SchemaFilter)
61
+ metadata_extra: dict[str, Any] = Field(default_factory=dict)
62
+
63
+ @model_validator(mode="after")
64
+ def resolve_env_vars(self) -> ConnectionConfig:
65
+ for field_name in self.__class__.model_fields:
66
+ raw = getattr(self, field_name)
67
+ if isinstance(raw, str) and ENV_VAR_PATTERN.search(raw):
68
+ object.__setattr__(self, field_name, _resolve_env_vars(raw))
69
+ # Also resolve env vars inside metadata_extra string values.
70
+ for key, val in self.metadata_extra.items():
71
+ if isinstance(val, str) and ENV_VAR_PATTERN.search(val):
72
+ self.metadata_extra[key] = _resolve_env_vars(val)
73
+ # Coerce port back to int after env var substitution (${PORT} → "5432").
74
+ raw_port = getattr(self, "port")
75
+ if isinstance(raw_port, str):
76
+ object.__setattr__(self, "port", int(raw_port))
77
+ return self
78
+
79
+
80
+ class LLMProvider(StrEnum):
81
+ """Registered LLM backends (see `LLMConfig.provider`)."""
82
+
83
+ ANTHROPIC = "anthropic"
84
+ OPENROUTER = "openrouter"
85
+
86
+
87
+ class LLMStepKey(StrEnum):
88
+ """Pipeline step identifiers for per-step LLM overrides (`llm_steps`)."""
89
+
90
+ SEMANTIC = "semantic"
91
+ PLANNER = "planner"
92
+ CODEGEN_QUERIES = "codegen_queries"
93
+ CODEGEN_PAGES = "codegen_pages"
94
+ VALIDATION = "validation"
95
+ REPAIR = "repair"
96
+
97
+
98
+ class StepLLMOverride(PretensorModel):
99
+ """Partial LLM settings merged over the global `llm` block for one step.
100
+
101
+ Omitted fields keep the global default. ``http_headers`` replaces the
102
+ global header map when set (non-``None``).
103
+ """
104
+
105
+ provider: LLMProvider | None = None
106
+ model: str | None = None
107
+ temperature: float | None = None
108
+ max_tokens: int | None = None
109
+ base_url: str | None = None
110
+ http_headers: dict[str, str] | None = None
111
+
112
+
113
+ class LLMConfig(PretensorModel):
114
+ provider: LLMProvider = LLMProvider.ANTHROPIC
115
+ model: str = "claude-sonnet-4-20250514"
116
+ temperature: float = 0.0
117
+ max_tokens: int = 4096
118
+ base_url: str | None = None
119
+ http_headers: dict[str, str] = Field(default_factory=dict)
120
+
121
+ def merged_with(self, override: StepLLMOverride | None) -> LLMConfig:
122
+ """Return a copy with non-``None`` fields from ``override`` applied."""
123
+ if override is None:
124
+ return self.model_copy(deep=True)
125
+ data = self.model_dump()
126
+ if override.provider is not None:
127
+ data["provider"] = override.provider
128
+ if override.model is not None:
129
+ data["model"] = override.model
130
+ if override.temperature is not None:
131
+ data["temperature"] = override.temperature
132
+ if override.max_tokens is not None:
133
+ data["max_tokens"] = override.max_tokens
134
+ if override.base_url is not None:
135
+ data["base_url"] = override.base_url
136
+ if override.http_headers is not None:
137
+ data["http_headers"] = dict(override.http_headers)
138
+ return LLMConfig.model_validate(data)
139
+
140
+
141
+ class SyntheticOptions(PretensorModel):
142
+ hash_strings: bool = True
143
+ randomize_numbers: bool = True
144
+ preserve_magnitude: bool = True
145
+
146
+
147
+ class PrivacyConfig(PretensorModel):
148
+ mode: PrivacyMode = PrivacyMode.SCHEMA_ONLY
149
+ synthetic: SyntheticOptions = Field(default_factory=SyntheticOptions)
150
+
151
+
152
+ class MCPConfig(PretensorModel):
153
+ enabled: bool = False
154
+ url: str = "https://evidence.studio/mcp"
155
+
156
+
157
+ class EvidenceConfig(PretensorModel):
158
+ mcp: MCPConfig = Field(default_factory=MCPConfig)
159
+
160
+
161
+ class RepairConfig(PretensorModel):
162
+ max_code_retries: int = 3
163
+ max_plan_retries: int = 2
164
+ max_total_iterations: int = 10
165
+
166
+
167
+ class StagingConfig(PretensorModel):
168
+ annotations_enabled: bool = True
169
+ staging_env_var: str = "PRETENSOR_STAGING"
170
+
171
+
172
+ class ValidationConfig(PretensorModel):
173
+ threshold: int = 7
174
+ top_components_check: int = 2
175
+ repair: RepairConfig = Field(default_factory=RepairConfig)
176
+ staging: StagingConfig = Field(default_factory=StagingConfig)
177
+ # Layer 1 — skip LLM self-review when SQL lint, page markdown, and Evidence
178
+ # build (if run) produced no ERROR issues; still run when any deterministic
179
+ # step reports ERROR.
180
+ l1_skip_llm_self_review_on_clean_deterministic: bool = True
181
+ # Layer 3 — deterministic structure vs plan + optional presentation hints.
182
+ l3_structural_checks_enabled: bool = True
183
+ l3_orphan_chart_severity: Literal["error", "warning"] = "error"
184
+ l3_structural_presentation_warnings: bool = True
185
+
186
+
187
+ class ProjectConfig(PretensorModel):
188
+ project_name: str | None = None
189
+ connections: list[ConnectionConfig] | dict[str, ConnectionConfig] = Field(
190
+ default_factory=list
191
+ )
192
+ llm: LLMConfig = Field(default_factory=LLMConfig)
193
+ llm_steps: dict[LLMStepKey, StepLLMOverride] = Field(default_factory=dict)
194
+ privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
195
+ evidence: EvidenceConfig = Field(default_factory=EvidenceConfig)
196
+ validation: ValidationConfig = Field(default_factory=ValidationConfig)
197
+
198
+ @model_validator(mode="before")
199
+ @classmethod
200
+ def _preprocess_config(cls, data: dict) -> dict:
201
+ """Handle legacy and shorthand config formats."""
202
+ if not isinstance(data, dict):
203
+ return data
204
+
205
+ # Infer project_name if missing
206
+ if not data.get("project_name"):
207
+ data["project_name"] = Path.cwd().name
208
+
209
+ # Handle connections as dict
210
+ connections = data.get("connections")
211
+ if isinstance(connections, dict):
212
+ connection_list = []
213
+ for name, config in connections.items():
214
+ if isinstance(config, dict):
215
+ config["name"] = name
216
+ connection_list.append(config)
217
+ data["connections"] = connection_list
218
+
219
+ return data
220
+
221
+ def resolved_llm_for_step(self, step: LLMStepKey) -> LLMConfig:
222
+ """Global ``llm`` merged with ``llm_steps[step]`` when present."""
223
+ override = self.llm_steps.get(step)
224
+ return self.llm.merged_with(override)
225
+
226
+ @classmethod
227
+ def from_yaml(cls, yaml_str: str) -> ProjectConfig:
228
+ yaml = YAML()
229
+ data = yaml.load(yaml_str)
230
+ return cls.model_validate(data)
231
+
232
+ @classmethod
233
+ def load(cls, path: Path) -> ProjectConfig:
234
+ """Load project config from a YAML file on disk."""
235
+ if not path.exists():
236
+ raise FileNotFoundError(f"Config file not found: {path}")
237
+ return cls.from_yaml(path.read_text())