sqbyl-runtime 0.1.0__tar.gz

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 (38) hide show
  1. sqbyl_runtime-0.1.0/.gitignore +44 -0
  2. sqbyl_runtime-0.1.0/PKG-INFO +57 -0
  3. sqbyl_runtime-0.1.0/README.md +17 -0
  4. sqbyl_runtime-0.1.0/pyproject.toml +56 -0
  5. sqbyl_runtime-0.1.0/src/sqbyl_runtime/__init__.py +40 -0
  6. sqbyl_runtime-0.1.0/src/sqbyl_runtime/context.py +319 -0
  7. sqbyl_runtime-0.1.0/src/sqbyl_runtime/cost.py +265 -0
  8. sqbyl_runtime-0.1.0/src/sqbyl_runtime/db/__init__.py +49 -0
  9. sqbyl_runtime-0.1.0/src/sqbyl_runtime/db/connection.py +172 -0
  10. sqbyl_runtime-0.1.0/src/sqbyl_runtime/db/dialects.py +329 -0
  11. sqbyl_runtime-0.1.0/src/sqbyl_runtime/db/errors.py +35 -0
  12. sqbyl_runtime-0.1.0/src/sqbyl_runtime/db/guard.py +99 -0
  13. sqbyl_runtime-0.1.0/src/sqbyl_runtime/export.py +230 -0
  14. sqbyl_runtime-0.1.0/src/sqbyl_runtime/fingerprint.py +154 -0
  15. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/__init__.py +43 -0
  16. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/_errors.py +30 -0
  17. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/anthropic_client.py +141 -0
  18. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/base.py +177 -0
  19. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/factory.py +40 -0
  20. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/mock.py +74 -0
  21. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/openai_client.py +204 -0
  22. sqbyl_runtime-0.1.0/src/sqbyl_runtime/llm/replay.py +73 -0
  23. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/__init__.py +50 -0
  24. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/assets.py +36 -0
  25. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/base.py +23 -0
  26. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/judges.py +19 -0
  27. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/release.py +109 -0
  28. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/selection.py +26 -0
  29. sqbyl_runtime-0.1.0/src/sqbyl_runtime/models/semantics.py +100 -0
  30. sqbyl_runtime-0.1.0/src/sqbyl_runtime/pipeline.py +288 -0
  31. sqbyl_runtime-0.1.0/src/sqbyl_runtime/py.typed +0 -0
  32. sqbyl_runtime-0.1.0/src/sqbyl_runtime/runtime.py +202 -0
  33. sqbyl_runtime-0.1.0/src/sqbyl_runtime/schema.py +41 -0
  34. sqbyl_runtime-0.1.0/src/sqbyl_runtime/selection.py +348 -0
  35. sqbyl_runtime-0.1.0/src/sqbyl_runtime/state/__init__.py +26 -0
  36. sqbyl_runtime-0.1.0/src/sqbyl_runtime/state/layout.py +46 -0
  37. sqbyl_runtime-0.1.0/src/sqbyl_runtime/state/traces.py +127 -0
  38. sqbyl_runtime-0.1.0/src/sqbyl_runtime/state/usage.py +164 -0
@@ -0,0 +1,44 @@
1
+ # Claude Code / local agent workspace (designer-dev only, not part of the project)
2
+ .claude/
3
+
4
+ # sqbyl local state: runs, traces, usage, caches (per design spec §4)
5
+ .sqbyl/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ *.egg-info/
12
+ .eggs/
13
+ build/
14
+ dist/
15
+ *.egg
16
+
17
+ # uv / virtualenvs
18
+ .venv/
19
+ venv/
20
+ env/
21
+ .python-version
22
+
23
+ # Tooling caches
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ .mypy_cache/
27
+ .pyright/
28
+ .coverage
29
+ htmlcov/
30
+ .tox/
31
+
32
+ # Environment / secrets (never commit ANTHROPIC_API_KEY or DATABASE_URL)
33
+ .env
34
+ .env.*
35
+ !.env.example
36
+
37
+ # OS / editor
38
+ .DS_Store
39
+ .idea/
40
+ .vscode/
41
+ *.swp
42
+
43
+ # Private planning doc — kept local, not published
44
+ sqbyl-enhancements.md
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqbyl-runtime
3
+ Version: 0.1.0
4
+ Summary: The minimal, dependency-light sqbyl runtime: load a release + ask() + structured logging.
5
+ Project-URL: Homepage, https://github.com/jackwerner/sqbyl
6
+ Project-URL: Repository, https://github.com/jackwerner/sqbyl
7
+ Project-URL: Issues, https://github.com/jackwerner/sqbyl/issues
8
+ Author: Jack Werner
9
+ License-Expression: MIT
10
+ Keywords: agent,anthropic,claude,llm,openai,sql,text-to-sql
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: duckdb-engine>=0.13
21
+ Requires-Dist: duckdb>=1.0
22
+ Requires-Dist: pydantic<3,>=2.7
23
+ Requires-Dist: sqlalchemy>=2.0
24
+ Requires-Dist: sqlglot>=25
25
+ Provides-Extra: anthropic
26
+ Requires-Dist: anthropic>=0.116.0; extra == 'anthropic'
27
+ Provides-Extra: bigquery
28
+ Requires-Dist: sqlalchemy-bigquery>=1.9; extra == 'bigquery'
29
+ Provides-Extra: langchain
30
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
31
+ Provides-Extra: mysql
32
+ Requires-Dist: pymysql>=1.1; extra == 'mysql'
33
+ Provides-Extra: openai
34
+ Requires-Dist: openai>=1.40; extra == 'openai'
35
+ Provides-Extra: postgres
36
+ Requires-Dist: psycopg[binary]>=3.3.4; extra == 'postgres'
37
+ Provides-Extra: snowflake
38
+ Requires-Dist: snowflake-sqlalchemy>=1.5; extra == 'snowflake'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # sqbyl-runtime
42
+
43
+ The minimal, dependency-light runtime for [sqbyl](https://github.com/jackwerner/sqbyl).
44
+
45
+ Embed a released sqbyl agent into your production app with three lines:
46
+
47
+ ```python
48
+ from sqbyl_runtime import load
49
+
50
+ agent = load("revenue-analytics.v1.json", db=env.DATABASE_URL, model="claude-opus-4-8")
51
+ agent.ask("net revenue last month") # → {plan, sql, rows, used_assets, usage, latency}
52
+ ```
53
+
54
+ This package contains **only** release `load()` + `ask()` + structured logging.
55
+ None of the dev toolkit (eval, synth, Coach, judges, review console) ships here —
56
+ that all lives in the full `sqbyl` package. The one-way dependency arrow
57
+ (`sqbyl` → `sqbyl-runtime`, never the reverse) is enforced in CI.
@@ -0,0 +1,17 @@
1
+ # sqbyl-runtime
2
+
3
+ The minimal, dependency-light runtime for [sqbyl](https://github.com/jackwerner/sqbyl).
4
+
5
+ Embed a released sqbyl agent into your production app with three lines:
6
+
7
+ ```python
8
+ from sqbyl_runtime import load
9
+
10
+ agent = load("revenue-analytics.v1.json", db=env.DATABASE_URL, model="claude-opus-4-8")
11
+ agent.ask("net revenue last month") # → {plan, sql, rows, used_assets, usage, latency}
12
+ ```
13
+
14
+ This package contains **only** release `load()` + `ask()` + structured logging.
15
+ None of the dev toolkit (eval, synth, Coach, judges, review console) ships here —
16
+ that all lives in the full `sqbyl` package. The one-way dependency arrow
17
+ (`sqbyl` → `sqbyl-runtime`, never the reverse) is enforced in CI.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "sqbyl-runtime"
3
+ version = "0.1.0"
4
+ description = "The minimal, dependency-light sqbyl runtime: load a release + ask() + structured logging."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Jack Werner" }]
9
+ keywords = ["text-to-sql", "sql", "llm", "claude", "anthropic", "openai", "agent"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Database",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.7,<3",
22
+ "sqlalchemy>=2.0",
23
+ "duckdb>=1.0",
24
+ "duckdb-engine>=0.13",
25
+ "sqlglot>=25",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ # Provider SDKs are optional so the runtime is provider-neutral: pick one and install its
30
+ # extra, e.g. `pip install sqbyl-runtime[openai]`. A bare install talks to no provider until
31
+ # you choose. The client for each raises a matching install hint if its SDK is missing.
32
+ anthropic = ["anthropic>=0.116.0"]
33
+ openai = ["openai>=1.40"]
34
+ # Dialect drivers are optional too, so the minimal runtime stays light. DuckDB is the default
35
+ # (a hard dep above); SQLite needs no driver (Python stdlib). Install the extra for the
36
+ # dialect you target, e.g. `pip install sqbyl-runtime[postgres]`. The dialect seam raises
37
+ # a matching install hint when a driver is missing (db/dialects.py).
38
+ postgres = ["psycopg[binary]>=3.3.4"]
39
+ mysql = ["pymysql>=1.1"]
40
+ snowflake = ["snowflake-sqlalchemy>=1.5"]
41
+ bigquery = ["sqlalchemy-bigquery>=1.9"]
42
+ # Export a release as a LangChain tool (sqbyl_runtime.langchain_tool). Optional — the MCP
43
+ # and plain-callable export shapes need no extra dependency.
44
+ langchain = ["langchain-core>=0.3"]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/jackwerner/sqbyl"
48
+ Repository = "https://github.com/jackwerner/sqbyl"
49
+ Issues = "https://github.com/jackwerner/sqbyl/issues"
50
+
51
+ [build-system]
52
+ requires = ["hatchling"]
53
+ build-backend = "hatchling.build"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/sqbyl_runtime"]
@@ -0,0 +1,40 @@
1
+ """sqbyl-runtime — the minimal, shippable sqbyl runtime.
2
+
3
+ Contains only what a production app needs to embed a released agent: release
4
+ ``load()``, ``ask()``, the ``LLMClient`` seam, and structured logging. None of
5
+ the dev toolkit (eval, synth, Coach, judges, console) lives here or is importable
6
+ from here — that one-way dependency arrow is enforced by import-linter in CI.
7
+
8
+ from sqbyl_runtime import load
9
+ agent = load("revenue-analytics.v3.json", db=DATABASE_URL, model="claude-opus-4-8")
10
+ agent.ask("How many orders shipped last month?") # → AgentResult
11
+ """
12
+
13
+ from sqbyl_runtime.export import (
14
+ McpServer,
15
+ answer_dict,
16
+ as_callable,
17
+ langchain_tool,
18
+ serve_mcp_stdio,
19
+ )
20
+ from sqbyl_runtime.runtime import (
21
+ Agent,
22
+ ModelMismatchWarning,
23
+ SchemaMismatchWarning,
24
+ load,
25
+ )
26
+
27
+ __version__ = "0.0.0"
28
+
29
+ __all__ = [
30
+ "Agent",
31
+ "McpServer",
32
+ "ModelMismatchWarning",
33
+ "SchemaMismatchWarning",
34
+ "answer_dict",
35
+ "as_callable",
36
+ "langchain_tool",
37
+ "load",
38
+ "serve_mcp_stdio",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,319 @@
1
+ """The context compiler (spec §5 steps 1-2, plan 2.1).
2
+
3
+ Turn a project's knowledge (semantics + examples + trusted assets + instructions)
4
+ plus a question into the prompt the agent runtime sends to Claude. Two halves:
5
+
6
+ * a **stable system block** — instructions, annotated schema/semantics, trusted
7
+ assets, and few-shot examples. It does not vary with the question, so it is the
8
+ prompt-cache unit (``cache_system=True`` downstream).
9
+ * a **question turn** — the one varying part, sent as the user message.
10
+
11
+ This lives in ``sqbyl-runtime`` because ``ask()`` compiles context at inference
12
+ time (the same compiler runs in dev and in a shipped release). The small-project
13
+ path is "include everything"; large-schema shortlisting (Phase 9) runs as a
14
+ separate :mod:`~sqbyl_runtime.selection` step *before* this renderer and hands the
15
+ narrowed table set in — so the renderer itself stays a pure function of its inputs
16
+ (deterministic, snapshot-testable): no clocks, no IDs, no LLM calls, stable ordering.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ from sqbyl_runtime.llm.base import LLMClient, Usage
26
+ from sqbyl_runtime.models import (
27
+ Column,
28
+ Dialect,
29
+ Example,
30
+ Profile,
31
+ ReleaseArtifact,
32
+ ScalarBound,
33
+ SelectionConfig,
34
+ TableSemantics,
35
+ TrustedAsset,
36
+ )
37
+ from sqbyl_runtime.selection import LLMCallHook, ValueMatch, select_context
38
+
39
+ # Past this many tables "include everything" stops being viable and Phase 9's
40
+ # LLM/lexical shortlisting is needed; until then we still include all but flag it.
41
+ _INCLUDE_ALL_TABLE_LIMIT = 30
42
+
43
+
44
+ class CompiledContext(BaseModel):
45
+ """The compiled prompt plus what went into it (for citation + caching).
46
+
47
+ ``usage`` carries any tokens the *selection* step spent (LLM shortlisting on a
48
+ large schema); the pipeline folds it into the run's total so it's metered and
49
+ budgeted like generation (invariant 5). It is ``Usage()`` for the include-all /
50
+ lexical paths, which spend nothing.
51
+ """
52
+
53
+ system: str
54
+ user: str
55
+ selected_tables: list[str] = Field(default_factory=list)
56
+ offered_assets: list[str] = Field(default_factory=list)
57
+ notes: list[str] = Field(default_factory=list)
58
+ usage: Usage = Field(default_factory=Usage)
59
+ # The selection strategy that actually ran (rewritten to ``include_all`` on a fallback)
60
+ # and whether a narrowing strategy degraded to include-all — carried out so the pipeline
61
+ # can put the drop rationale on the run's trace/result (invariant 7, transparency).
62
+ selection_strategy: str = "include_all"
63
+ selection_fell_back: bool = False
64
+
65
+
66
+ class ProjectKnowledge(BaseModel):
67
+ """Everything the agent reasons over, decoupled from where it came from.
68
+
69
+ Both a dev project (loaded from files) and a shipped ``ReleaseArtifact`` produce
70
+ one of these, so the runtime pipeline is identical in dev and in production.
71
+ """
72
+
73
+ dialect: Dialect
74
+ semantics: list[TableSemantics] = Field(default_factory=list)
75
+ instructions: str = ""
76
+ examples: list[Example] = Field(default_factory=list)
77
+ trusted_assets: list[TrustedAsset] = Field(default_factory=list)
78
+ selection: SelectionConfig = Field(default_factory=SelectionConfig)
79
+
80
+ @classmethod
81
+ def from_release(cls, release: ReleaseArtifact) -> ProjectKnowledge:
82
+ return cls(
83
+ dialect=release.dialect,
84
+ semantics=release.semantics,
85
+ instructions=release.instructions,
86
+ examples=release.examples,
87
+ trusted_assets=release.trusted_assets,
88
+ selection=release.selection,
89
+ )
90
+
91
+ def compile(
92
+ self,
93
+ question: str,
94
+ *,
95
+ llm: LLMClient | None = None,
96
+ model: str | None = None,
97
+ on_llm_call: LLMCallHook | None = None,
98
+ ) -> CompiledContext:
99
+ """Select the relevant subset (LLM/lexical for large schemas) then render it.
100
+
101
+ ``llm`` and ``model`` power the ``llm``/``llm_lexical`` selection strategies;
102
+ without them (or on the include-all/lexical strategies) selection spends nothing
103
+ and this is a pure function of the project's files. ``on_llm_call`` lets a caller
104
+ (the pipeline) trace the shortlisting call as its own GenAI span.
105
+ """
106
+ return compile_context(
107
+ question,
108
+ dialect=self.dialect,
109
+ semantics=self.semantics,
110
+ instructions=self.instructions,
111
+ examples=self.examples,
112
+ trusted_assets=self.trusted_assets,
113
+ selection=self.selection,
114
+ llm=llm,
115
+ model=model,
116
+ on_llm_call=on_llm_call,
117
+ )
118
+
119
+
120
+ def compile_context(
121
+ question: str,
122
+ *,
123
+ dialect: Dialect,
124
+ semantics: Sequence[TableSemantics],
125
+ instructions: str = "",
126
+ examples: Sequence[Example] = (),
127
+ trusted_assets: Sequence[TrustedAsset] = (),
128
+ selection: SelectionConfig | None = None,
129
+ llm: LLMClient | None = None,
130
+ model: str | None = None,
131
+ on_llm_call: LLMCallHook | None = None,
132
+ ) -> CompiledContext:
133
+ """Compile the question + project knowledge into a system/user prompt pair.
134
+
135
+ Runs the :mod:`~sqbyl_runtime.selection` step first (include-all for small
136
+ projects; lexical/LLM shortlisting past ``max_tables``), then renders only the
137
+ surviving tables and the examples that reference them, plus any value-match hints.
138
+ """
139
+ selection = selection or SelectionConfig()
140
+ picked = select_context(
141
+ question,
142
+ semantics=semantics,
143
+ config=selection,
144
+ llm=llm,
145
+ model=model,
146
+ on_llm_call=on_llm_call,
147
+ )
148
+
149
+ chosen_names = set(picked.tables)
150
+ selected = [t for t in semantics if t.table in chosen_names]
151
+ notes = list(picked.notes)
152
+ if len(semantics) > _INCLUDE_ALL_TABLE_LIMIT and selection.strategy == "include_all":
153
+ notes.append(
154
+ f"{len(semantics)} tables exceeds the include-everything limit "
155
+ f"({_INCLUDE_ALL_TABLE_LIMIT}); set selection.strategy to lexical/llm to narrow"
156
+ )
157
+
158
+ # When selection narrowed the schema, keep only examples that reference a surviving
159
+ # table (an example about a dropped table is noise for this question). Include-all
160
+ # keeps every example.
161
+ shown_examples = _relevant_examples(examples, selected, narrowed=len(selected) < len(semantics))
162
+
163
+ system = _render_system(
164
+ dialect=dialect,
165
+ instructions=instructions,
166
+ semantics=selected,
167
+ trusted_assets=trusted_assets,
168
+ examples=shown_examples,
169
+ value_matches=picked.value_matches,
170
+ )
171
+ return CompiledContext(
172
+ system=system,
173
+ user=_render_question(question),
174
+ selected_tables=[t.table for t in selected],
175
+ offered_assets=[a.name for a in trusted_assets],
176
+ notes=notes,
177
+ usage=picked.usage,
178
+ selection_strategy=picked.strategy,
179
+ selection_fell_back=picked.fell_back,
180
+ )
181
+
182
+
183
+ def _relevant_examples(
184
+ examples: Sequence[Example], selected: Sequence[TableSemantics], *, narrowed: bool
185
+ ) -> list[Example]:
186
+ """Keep examples whose SQL names a selected table; on include-all keep them all.
187
+
188
+ Best-effort substring match on table names — an example that mentions none of the
189
+ surviving tables is dropped only when we actually narrowed, so a project that never
190
+ narrows behaves exactly as before.
191
+ """
192
+ if not narrowed:
193
+ return list(examples)
194
+ names = [t.table for t in selected]
195
+ kept = [ex for ex in examples if any(_names_table(ex.sql, name) for name in names)]
196
+ # Never strip every example to zero on a narrow — keep the originals if none match,
197
+ # since a few-shot example is cheap and dropping all of them helps nothing.
198
+ return kept or list(examples)
199
+
200
+
201
+ def _names_table(sql: str, table: str) -> bool:
202
+ """Whether ``sql`` references ``table`` as a whole word (case-insensitive)."""
203
+ import re
204
+
205
+ return re.search(rf"\b{re.escape(table)}\b", sql, re.IGNORECASE) is not None
206
+
207
+
208
+ # --- rendering (deterministic) --------------------------------------------------
209
+
210
+
211
+ def _render_system(
212
+ *,
213
+ dialect: Dialect,
214
+ instructions: str,
215
+ semantics: Sequence[TableSemantics],
216
+ trusted_assets: Sequence[TrustedAsset],
217
+ examples: Sequence[Example],
218
+ value_matches: Sequence[ValueMatch] = (),
219
+ ) -> str:
220
+ blocks: list[str] = [
221
+ f"You are a careful {dialect.value} SQL analyst. Answer questions by writing a "
222
+ "single read-only SELECT, grounded in the semantic layer below. Prefer measures, "
223
+ "filters, and trusted assets over ad-hoc arithmetic.",
224
+ ]
225
+ if instructions.strip():
226
+ # Rendered verbatim: instructions.md is author-owned markdown (it brings its
227
+ # own headings), so we don't wrap it in another.
228
+ blocks.append(instructions.strip())
229
+ blocks.append(_render_tables(semantics))
230
+ if value_matches:
231
+ blocks.append(_render_value_matches(value_matches))
232
+ if trusted_assets:
233
+ blocks.append(_render_trusted_assets(trusted_assets))
234
+ if examples:
235
+ blocks.append(_render_examples(examples))
236
+ return "\n\n".join(blocks).strip() + "\n"
237
+
238
+
239
+ def _render_value_matches(value_matches: Sequence[ValueMatch]) -> str:
240
+ """Grounding hints mapping question literals to canonical declared values (§5.1)."""
241
+ lines = ["# Value hints (question terms mapped to declared column values)"]
242
+ for match in value_matches:
243
+ lines.append(f'- "{match.term}" → {match.table}.{match.column} = {match.value!r}')
244
+ return "\n".join(lines)
245
+
246
+
247
+ def _render_tables(semantics: Sequence[TableSemantics]) -> str:
248
+ lines = ["# Schema"]
249
+ for table in semantics:
250
+ header = f"## {table.table}"
251
+ if table.description:
252
+ header += f" — {table.description}"
253
+ lines.append(header)
254
+ if table.synonyms:
255
+ lines.append(f"synonyms: {', '.join(table.synonyms)}")
256
+ lines.append("columns:")
257
+ for col in table.columns:
258
+ lines.append(_render_column(col))
259
+ for join in table.joins:
260
+ conf = "" if join.confidence is None else f" [confidence {join.confidence:.2f}]"
261
+ lines.append(f"join: {join.type} -> {join.to} ON {join.on}{conf}")
262
+ for measure in table.measures:
263
+ desc = f" — {measure.description}" if measure.description else ""
264
+ lines.append(f"measure {measure.name}: {measure.sql}{desc}")
265
+ for filt in table.filters:
266
+ desc = f" — {filt.description}" if filt.description else ""
267
+ lines.append(f"filter {filt.name}: {filt.sql}{desc}")
268
+ return "\n".join(lines)
269
+
270
+
271
+ def _render_column(col: Column) -> str:
272
+ parts = [f"- {col.name} ({col.type})"]
273
+ if col.description:
274
+ parts.append(f" — {col.description}")
275
+ if col.synonyms:
276
+ parts.append(f" [synonyms: {', '.join(col.synonyms)}]")
277
+ hint = _profile_hint(col.profile, col.sample_values)
278
+ if hint:
279
+ parts.append(f" {hint}")
280
+ return "".join(parts)
281
+
282
+
283
+ def _profile_hint(profile: Profile | bool | None, sample_values: list[ScalarBound] | None) -> str:
284
+ """A compact grounding hint a human would eyeball: range or representative values."""
285
+ if sample_values:
286
+ shown = ", ".join(str(v) for v in sample_values)
287
+ return f"[values: {shown}]"
288
+ if isinstance(profile, Profile) and profile.min is not None and profile.max is not None:
289
+ return f"[range: {profile.min}..{profile.max}]"
290
+ return ""
291
+
292
+
293
+ def _render_trusted_assets(assets: Sequence[TrustedAsset]) -> str:
294
+ lines = ["# Trusted assets (prefer these over ad-hoc SQL; cite the one you use)"]
295
+ for asset in assets:
296
+ params = ", ".join(f"{p.name} {p.type}" for p in asset.params)
297
+ header = f"## {asset.name}({params})"
298
+ if asset.description:
299
+ header += f" — {asset.description}"
300
+ lines.append(header)
301
+ lines.append(asset.sql.strip())
302
+ return "\n".join(lines)
303
+
304
+
305
+ def _render_examples(examples: Sequence[Example]) -> str:
306
+ lines = ["# Examples"]
307
+ for ex in examples:
308
+ lines.append(f"Q: {ex.question}")
309
+ lines.append("SQL:")
310
+ lines.append(ex.sql.strip())
311
+ return "\n".join(lines)
312
+
313
+
314
+ def _render_question(question: str) -> str:
315
+ return (
316
+ f"Question: {question.strip()}\n\n"
317
+ "Write a single read-only SELECT that answers it. Think briefly about which "
318
+ "tables, measures, and trusted assets apply, then give the SQL."
319
+ )