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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""``run_l3_pretensor`` — agent + Pretensor MCP runner.
|
|
2
|
+
|
|
3
|
+
Same gold question set, same SQL grader, same JSON envelope as the
|
|
4
|
+
baseline (``pretensor.benchmark.l3.runner.run_l3_baseline``); the
|
|
5
|
+
difference is what the agent gets fed.
|
|
6
|
+
|
|
7
|
+
The baseline hands the LLM a frozen DDL dump. This runner hands it a
|
|
8
|
+
live MCP session against a ``pretensor serve`` subprocess, lets the LLM
|
|
9
|
+
discover the tool catalogue, and routes its tool calls through the MCP
|
|
10
|
+
client. Per-question records pick up an extra ``tool_calls`` field so
|
|
11
|
+
each agent decision is auditable from the JSON alone.
|
|
12
|
+
|
|
13
|
+
The runner is otherwise sync: the MCP client is the only async surface
|
|
14
|
+
and lives behind a sync wrapper (``StdioMcpClient``), so the call shape
|
|
15
|
+
matches the baseline runner one-to-one.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import importlib.metadata
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Iterator, cast
|
|
30
|
+
|
|
31
|
+
from pretensor.benchmark.fixtures import load_dataset
|
|
32
|
+
from pretensor.benchmark.l3.agent import (
|
|
33
|
+
DEFAULT_MAX_ITERATIONS,
|
|
34
|
+
AgentLlmClient,
|
|
35
|
+
AgentLoopError,
|
|
36
|
+
AgentTool,
|
|
37
|
+
ToolCallTraceEntry,
|
|
38
|
+
ToolInvocationOutcome,
|
|
39
|
+
run_agent_loop,
|
|
40
|
+
)
|
|
41
|
+
from pretensor.benchmark.l3.db import (
|
|
42
|
+
QueryExecutionError,
|
|
43
|
+
execute_query,
|
|
44
|
+
resolve_database_url,
|
|
45
|
+
)
|
|
46
|
+
from pretensor.benchmark.l3.gold import L3GoldEntry, load_l3_gold
|
|
47
|
+
from pretensor.benchmark.l3.llm_client import AnthropicHttpClient, LlmCallError
|
|
48
|
+
from pretensor.benchmark.l3.mcp_client import McpClient, StdioMcpClient
|
|
49
|
+
from pretensor.benchmark.l3.prompt import (
|
|
50
|
+
build_pretensor_system_prompt,
|
|
51
|
+
pretensor_prompt_template_hash,
|
|
52
|
+
prompt_hash,
|
|
53
|
+
strip_sql_fences,
|
|
54
|
+
)
|
|
55
|
+
from pretensor.benchmark.l3.sql_equivalence import gold_is_ordered, rows_equivalent
|
|
56
|
+
from pretensor.benchmark.results import BenchmarkResult, Metric, write_json
|
|
57
|
+
from pretensor.benchmark.runner import Dataset
|
|
58
|
+
|
|
59
|
+
__all__ = ["DEFAULT_TEMPERATURE", "run_l3_pretensor"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
DEFAULT_TEMPERATURE = 0.0
|
|
63
|
+
"""LLM temperature; matches the baseline so the comparison is honest."""
|
|
64
|
+
|
|
65
|
+
_DETERMINISTIC_RAN_AT = "1970-01-01T00:00:00Z"
|
|
66
|
+
"""Pinned timestamp; mirrors the baseline runner's reproducibility convention."""
|
|
67
|
+
|
|
68
|
+
_now = time.perf_counter
|
|
69
|
+
"""Wall-clock source for per-item latency measurement.
|
|
70
|
+
|
|
71
|
+
Module-private so tests can monkeypatch it to a deterministic stub
|
|
72
|
+
(``lambda: 0.0``) — mirrors the pattern in the baseline runner.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_AGENT_ERROR_MAX_CHARS = 500
|
|
76
|
+
"""Truncation limit for per-item ``error`` strings (matches the baseline)."""
|
|
77
|
+
|
|
78
|
+
_SUCCESS_RATE_METRIC = "nl2sql_success_rate_pretensor"
|
|
79
|
+
_LATENCY_METRIC = "mean_latency_ms_pretensor"
|
|
80
|
+
|
|
81
|
+
_SEMANTIC_SEARCH_TOOL = "semantic_search"
|
|
82
|
+
"""The MCP tool that signals the embeddings extra is loaded server-side.
|
|
83
|
+
|
|
84
|
+
Per the L3 contract the runner stays embeddings-agnostic: it inspects
|
|
85
|
+
the catalogue advertised by ``pretensor serve`` and reports whatever
|
|
86
|
+
state it found. If the embeddings extra was installed in the serve
|
|
87
|
+
process, ``semantic_search`` is registered and shows up here.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_l3_pretensor(
|
|
92
|
+
dataset: Dataset,
|
|
93
|
+
out: Path | None,
|
|
94
|
+
graph_dir: Path,
|
|
95
|
+
*,
|
|
96
|
+
model: str,
|
|
97
|
+
seed: int | None,
|
|
98
|
+
llm_client: AgentLlmClient | None = None,
|
|
99
|
+
mcp_client: McpClient | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Run the L3 pretensor (agent + MCP) variant for ``dataset``.
|
|
102
|
+
|
|
103
|
+
``llm_client`` and ``mcp_client`` are Python-API-only seams — the
|
|
104
|
+
CLI never exposes them. Tests inject fakes to drive the runner
|
|
105
|
+
without spawning a subprocess or hitting a real LLM provider.
|
|
106
|
+
Production callers leave both ``None`` and the runner builds an
|
|
107
|
+
:class:`AnthropicHttpClient` against ``ANTHROPIC_API_KEY`` plus a
|
|
108
|
+
:class:`StdioMcpClient` against ``pretensor serve`` over ``graph_dir``.
|
|
109
|
+
|
|
110
|
+
The runner refuses to run when ``graph_dir`` does not contain an
|
|
111
|
+
indexed graph — for L3 parity, the baseline and pretensor variants
|
|
112
|
+
must see the same data, and silently re-indexing would defeat the
|
|
113
|
+
comparison. The user is told to run ``pretensor index`` first.
|
|
114
|
+
"""
|
|
115
|
+
fixture = load_dataset(dataset)
|
|
116
|
+
_questions_path, questions_bytes, questions = load_l3_gold(fixture)
|
|
117
|
+
dsn = resolve_database_url(dataset)
|
|
118
|
+
_require_indexed_graph_dir(graph_dir)
|
|
119
|
+
|
|
120
|
+
resolved_seed = seed if seed is not None else secrets.randbits(63)
|
|
121
|
+
system_prompt = build_pretensor_system_prompt(dataset.value)
|
|
122
|
+
rendered_hash = prompt_hash(system_prompt)
|
|
123
|
+
template_hash = pretensor_prompt_template_hash()
|
|
124
|
+
|
|
125
|
+
client: AgentLlmClient
|
|
126
|
+
if llm_client is not None:
|
|
127
|
+
client = llm_client
|
|
128
|
+
else:
|
|
129
|
+
# Mirror the baseline runner's friendly-exit pattern: convert the
|
|
130
|
+
# missing-key LlmCallError into a LookupError so the CLI's
|
|
131
|
+
# _handle_input_error path renders a one-line message rather than
|
|
132
|
+
# a Python traceback.
|
|
133
|
+
try:
|
|
134
|
+
client = AnthropicHttpClient()
|
|
135
|
+
except LlmCallError as exc:
|
|
136
|
+
raise LookupError(str(exc)) from exc
|
|
137
|
+
|
|
138
|
+
with _resolve_mcp_client(mcp_client, graph_dir=graph_dir) as mcp:
|
|
139
|
+
tools = mcp.list_tools()
|
|
140
|
+
embeddings_enabled = any(t.name == _SEMANTIC_SEARCH_TOOL for t in tools)
|
|
141
|
+
|
|
142
|
+
notes = _build_notes(tools=tools, embeddings_enabled=embeddings_enabled)
|
|
143
|
+
|
|
144
|
+
per_item: list[dict[str, Any]] = []
|
|
145
|
+
latencies_ms: list[int] = []
|
|
146
|
+
successes = 0
|
|
147
|
+
|
|
148
|
+
for entry in questions:
|
|
149
|
+
record = _evaluate_one(
|
|
150
|
+
entry=entry,
|
|
151
|
+
system_prompt=system_prompt,
|
|
152
|
+
client=client,
|
|
153
|
+
tools=tools,
|
|
154
|
+
mcp=mcp,
|
|
155
|
+
model=model,
|
|
156
|
+
temperature=DEFAULT_TEMPERATURE,
|
|
157
|
+
dsn=dsn,
|
|
158
|
+
)
|
|
159
|
+
per_item.append(record)
|
|
160
|
+
latencies_ms.append(int(record["latency_ms"]))
|
|
161
|
+
if record["pretensor_pass"]:
|
|
162
|
+
successes += 1
|
|
163
|
+
|
|
164
|
+
per_item.sort(key=lambda r: cast(str, r["id"]))
|
|
165
|
+
|
|
166
|
+
metrics: dict[str, Metric] = {
|
|
167
|
+
_SUCCESS_RATE_METRIC: Metric(
|
|
168
|
+
value=(successes / len(questions)) if questions else None,
|
|
169
|
+
direction="higher_is_better",
|
|
170
|
+
),
|
|
171
|
+
_LATENCY_METRIC: Metric(
|
|
172
|
+
value=(sum(latencies_ms) / len(latencies_ms)) if latencies_ms else None,
|
|
173
|
+
direction="lower_is_better",
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fixture_sha = "sha256:" + hashlib.sha256(questions_bytes).hexdigest()
|
|
178
|
+
|
|
179
|
+
extra: dict[str, Any] = {
|
|
180
|
+
"runner": "pretensor",
|
|
181
|
+
"provider": _provider_name(client),
|
|
182
|
+
"model": model,
|
|
183
|
+
"temperature": DEFAULT_TEMPERATURE,
|
|
184
|
+
"seed": resolved_seed,
|
|
185
|
+
"prompt_template_hash": template_hash,
|
|
186
|
+
"prompt_hash": rendered_hash,
|
|
187
|
+
"tool_catalogue": [t.name for t in tools],
|
|
188
|
+
"max_iterations": DEFAULT_MAX_ITERATIONS,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
result = BenchmarkResult(
|
|
192
|
+
level="l3",
|
|
193
|
+
dataset=dataset.value,
|
|
194
|
+
pretensor_version=_resolve_version(),
|
|
195
|
+
embeddings_enabled=embeddings_enabled,
|
|
196
|
+
ran_at=_DETERMINISTIC_RAN_AT,
|
|
197
|
+
fixture_sha=fixture_sha,
|
|
198
|
+
metrics=metrics,
|
|
199
|
+
per_item=per_item,
|
|
200
|
+
notes=notes,
|
|
201
|
+
extra=extra,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if out is None:
|
|
205
|
+
sys.stdout.write(json.dumps(result.to_dict(), sort_keys=True, indent=2) + "\n")
|
|
206
|
+
return
|
|
207
|
+
_atomic_write_json(result, out)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _evaluate_one(
|
|
211
|
+
*,
|
|
212
|
+
entry: L3GoldEntry,
|
|
213
|
+
system_prompt: str,
|
|
214
|
+
client: AgentLlmClient,
|
|
215
|
+
tools: list[AgentTool],
|
|
216
|
+
mcp: McpClient,
|
|
217
|
+
model: str,
|
|
218
|
+
temperature: float,
|
|
219
|
+
dsn: str,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Grade one question end-to-end through the agent + MCP loop.
|
|
222
|
+
|
|
223
|
+
Wraps :func:`_populate_record` in a try/finally so ``latency_ms`` is
|
|
224
|
+
always set to the full per-question wall-clock — including SQL
|
|
225
|
+
execution and equivalence checking, not just the LLM round-trips.
|
|
226
|
+
The aggregated LLM-only timing is recorded separately as
|
|
227
|
+
``llm_latency_ms`` for cost / regression diagnostics.
|
|
228
|
+
"""
|
|
229
|
+
record: dict[str, Any] = {
|
|
230
|
+
"id": entry.id,
|
|
231
|
+
"question": entry.question,
|
|
232
|
+
"gold_sql": entry.expected_sql,
|
|
233
|
+
"agent_sql": "",
|
|
234
|
+
"tool_calls": [],
|
|
235
|
+
"execution_success": False,
|
|
236
|
+
"result_equivalence": False,
|
|
237
|
+
"latency_ms": 0,
|
|
238
|
+
"llm_latency_ms": 0,
|
|
239
|
+
"prompt_tokens": None,
|
|
240
|
+
"completion_tokens": None,
|
|
241
|
+
"iterations": 0,
|
|
242
|
+
"row_count_gold": 0,
|
|
243
|
+
"row_count_agent": 0,
|
|
244
|
+
"pretensor_pass": False,
|
|
245
|
+
"error": None,
|
|
246
|
+
}
|
|
247
|
+
t0 = _now()
|
|
248
|
+
try:
|
|
249
|
+
_populate_record(
|
|
250
|
+
record,
|
|
251
|
+
entry=entry,
|
|
252
|
+
system_prompt=system_prompt,
|
|
253
|
+
client=client,
|
|
254
|
+
tools=tools,
|
|
255
|
+
mcp=mcp,
|
|
256
|
+
model=model,
|
|
257
|
+
temperature=temperature,
|
|
258
|
+
dsn=dsn,
|
|
259
|
+
)
|
|
260
|
+
finally:
|
|
261
|
+
record["latency_ms"] = int((_now() - t0) * 1000)
|
|
262
|
+
return record
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _populate_record(
|
|
266
|
+
record: dict[str, Any],
|
|
267
|
+
*,
|
|
268
|
+
entry: L3GoldEntry,
|
|
269
|
+
system_prompt: str,
|
|
270
|
+
client: AgentLlmClient,
|
|
271
|
+
tools: list[AgentTool],
|
|
272
|
+
mcp: McpClient,
|
|
273
|
+
model: str,
|
|
274
|
+
temperature: float,
|
|
275
|
+
dsn: str,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Walk one question through the agent loop → SQL → equivalence pipeline.
|
|
278
|
+
|
|
279
|
+
Mutates ``record`` in place. Errors at any stage short-circuit the
|
|
280
|
+
function and are surfaced in ``record["error"]``; the surrounding
|
|
281
|
+
``_evaluate_one`` always sets ``latency_ms`` regardless of which
|
|
282
|
+
branch we exited through.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def invoke(name: str, arguments: dict[str, Any]) -> ToolInvocationOutcome:
|
|
286
|
+
outcome = mcp.call_tool(name, arguments)
|
|
287
|
+
return ToolInvocationOutcome(content=outcome.content, is_error=outcome.is_error)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
loop_result = run_agent_loop(
|
|
291
|
+
client=client,
|
|
292
|
+
system=system_prompt,
|
|
293
|
+
user=entry.question,
|
|
294
|
+
tools=tools,
|
|
295
|
+
invoke_tool=invoke,
|
|
296
|
+
model=model,
|
|
297
|
+
temperature=temperature,
|
|
298
|
+
)
|
|
299
|
+
except LlmCallError as exc:
|
|
300
|
+
record["error"] = _truncate(f"LLM call failed: {exc}")
|
|
301
|
+
return
|
|
302
|
+
except AgentLoopError as exc:
|
|
303
|
+
record["error"] = _truncate(f"agent loop failed: {exc}")
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
record["llm_latency_ms"] = loop_result.total_llm_latency_ms
|
|
307
|
+
record["prompt_tokens"] = loop_result.total_prompt_tokens
|
|
308
|
+
record["completion_tokens"] = loop_result.total_completion_tokens
|
|
309
|
+
record["iterations"] = loop_result.iterations
|
|
310
|
+
record["tool_calls"] = [_trace_to_dict(t) for t in loop_result.trace]
|
|
311
|
+
|
|
312
|
+
agent_sql = strip_sql_fences(loop_result.text).strip()
|
|
313
|
+
record["agent_sql"] = agent_sql
|
|
314
|
+
if not agent_sql:
|
|
315
|
+
record["error"] = "agent returned an empty SQL string"
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
gold_rows, _ = execute_query(dsn, entry.expected_sql)
|
|
320
|
+
except QueryExecutionError as exc:
|
|
321
|
+
record["error"] = _truncate(f"gold SQL failed to execute: {exc}")
|
|
322
|
+
return
|
|
323
|
+
record["row_count_gold"] = len(gold_rows)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
agent_rows, _ = execute_query(dsn, agent_sql, enforce_select_only=True)
|
|
327
|
+
except QueryExecutionError as exc:
|
|
328
|
+
record["error"] = _truncate(f"agent SQL failed to execute: {exc}")
|
|
329
|
+
return
|
|
330
|
+
record["row_count_agent"] = len(agent_rows)
|
|
331
|
+
record["execution_success"] = True
|
|
332
|
+
|
|
333
|
+
equivalence = rows_equivalent(
|
|
334
|
+
gold_rows, agent_rows, ordered=gold_is_ordered(entry.expected_sql)
|
|
335
|
+
)
|
|
336
|
+
record["result_equivalence"] = equivalence.equivalent
|
|
337
|
+
if not equivalence.equivalent:
|
|
338
|
+
record["error"] = _truncate(f"row equivalence failed: {equivalence.reason}")
|
|
339
|
+
record["pretensor_pass"] = (
|
|
340
|
+
record["execution_success"] and record["result_equivalence"]
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _trace_to_dict(entry: ToolCallTraceEntry) -> dict[str, Any]:
|
|
345
|
+
"""Render one tool-call trace entry for the JSON envelope.
|
|
346
|
+
|
|
347
|
+
``is_error`` is always present (matching every other boolean in
|
|
348
|
+
the per-item record). A consumer reading the envelope can do
|
|
349
|
+
``entry["is_error"]`` without a KeyError on success entries.
|
|
350
|
+
"""
|
|
351
|
+
return {
|
|
352
|
+
"tool": entry.tool,
|
|
353
|
+
"args": entry.args,
|
|
354
|
+
"response_size": entry.response_size,
|
|
355
|
+
"is_error": entry.is_error,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _build_notes(*, tools: list[AgentTool], embeddings_enabled: bool) -> list[str]:
|
|
360
|
+
"""Compose the ``notes[]`` block.
|
|
361
|
+
|
|
362
|
+
Always carries the LLM-determinism caveat so an auditor reads the
|
|
363
|
+
same disclaimer the baseline runner emits. Adds catalogue + embeddings
|
|
364
|
+
notes so a reader can infer the run's tool surface from the envelope
|
|
365
|
+
alone (without re-running ``pretensor serve``).
|
|
366
|
+
"""
|
|
367
|
+
notes = [
|
|
368
|
+
"L3 is LLM-bound; the --seed value governs harness state only "
|
|
369
|
+
"(the Anthropic / OpenAI APIs do not accept a seed parameter). "
|
|
370
|
+
"Same-seed reruns at temperature 0 may still differ by remaining "
|
|
371
|
+
"LLM nondeterminism — including the order or choice of MCP tool "
|
|
372
|
+
"calls inside the agent loop."
|
|
373
|
+
]
|
|
374
|
+
notes.append(
|
|
375
|
+
f"MCP tool catalogue ({len(tools)} tools): "
|
|
376
|
+
+ ", ".join(sorted(t.name for t in tools))
|
|
377
|
+
)
|
|
378
|
+
if not embeddings_enabled:
|
|
379
|
+
notes.append(
|
|
380
|
+
"embeddings extra not detected on the serve subprocess "
|
|
381
|
+
f"(no '{_SEMANTIC_SEARCH_TOOL}' tool in the catalogue); "
|
|
382
|
+
"the agent ran without semantic search."
|
|
383
|
+
)
|
|
384
|
+
return notes
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _require_indexed_graph_dir(graph_dir: Path) -> None:
|
|
388
|
+
"""Refuse to run when ``graph_dir`` clearly has no indexed graph.
|
|
389
|
+
|
|
390
|
+
Looks for a ``graphs/`` subdirectory containing at least one
|
|
391
|
+
``*.kuzu`` file. The check is deliberately shallow — verifying
|
|
392
|
+
that an *index* is fresh would couple this runner to the graph-dir
|
|
393
|
+
layout details, which the indexing subsystem owns. The message
|
|
394
|
+
points the user at the canonical fix (`pretensor index ...`) so an
|
|
395
|
+
operator can self-serve.
|
|
396
|
+
"""
|
|
397
|
+
graphs = graph_dir / "graphs"
|
|
398
|
+
if not graphs.is_dir() or not any(graphs.glob("*.kuzu")):
|
|
399
|
+
raise FileNotFoundError(
|
|
400
|
+
f"L3 pretensor runner requires an indexed graph at {graph_dir}/graphs/. "
|
|
401
|
+
"Run `pretensor index <dsn>` first to populate it."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@contextmanager
|
|
406
|
+
def _resolve_mcp_client(
|
|
407
|
+
injected: McpClient | None, *, graph_dir: Path
|
|
408
|
+
) -> Iterator[McpClient]:
|
|
409
|
+
"""Yield the injected client when given, else stand up a stdio client.
|
|
410
|
+
|
|
411
|
+
The injected branch deliberately does NOT enter a context manager
|
|
412
|
+
on the caller's behalf — tests own the lifecycle of their fakes.
|
|
413
|
+
The stdio branch enters/exits :class:`StdioMcpClient` so the
|
|
414
|
+
subprocess is reaped on every code path through the runner.
|
|
415
|
+
|
|
416
|
+
A :class:`McpClientError` raised by :class:`StdioMcpClient` (broken
|
|
417
|
+
binary, transport handshake failure) propagates unchanged — it is a
|
|
418
|
+
distinct condition from the missing-graph-dir error raised by
|
|
419
|
+
:func:`_require_indexed_graph_dir`. The CLI handles both cleanly
|
|
420
|
+
via its input-error branch; collapsing them here would lose the
|
|
421
|
+
distinction operators need to debug a broken serve install.
|
|
422
|
+
"""
|
|
423
|
+
if injected is not None:
|
|
424
|
+
yield injected
|
|
425
|
+
return
|
|
426
|
+
with StdioMcpClient(graph_dir=graph_dir) as stdio_client_:
|
|
427
|
+
yield stdio_client_
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _truncate(s: str) -> str:
|
|
431
|
+
return s if len(s) <= _AGENT_ERROR_MAX_CHARS else s[:_AGENT_ERROR_MAX_CHARS] + "…"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _atomic_write_json(result: BenchmarkResult, out: Path) -> None:
|
|
435
|
+
"""Write JSON to a temp sibling and atomically rename into place."""
|
|
436
|
+
tmp = out.with_suffix(out.suffix + ".tmp")
|
|
437
|
+
write_json(result, tmp)
|
|
438
|
+
os.replace(tmp, out)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _provider_name(client: AgentLlmClient) -> str:
|
|
442
|
+
"""Best-effort provider tag for the envelope.
|
|
443
|
+
|
|
444
|
+
Mirrors the baseline runner's helper so an auditor can compare the
|
|
445
|
+
two envelopes without reaching for provider-specific docs.
|
|
446
|
+
"""
|
|
447
|
+
if isinstance(client, AnthropicHttpClient):
|
|
448
|
+
return "anthropic"
|
|
449
|
+
return "custom"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _resolve_version() -> str:
|
|
453
|
+
try:
|
|
454
|
+
return importlib.metadata.version("pretensor")
|
|
455
|
+
except importlib.metadata.PackageNotFoundError:
|
|
456
|
+
return "0.0.0+unknown"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""System-prompt template + hashing for the L3 NL-to-SQL agent.
|
|
2
|
+
|
|
3
|
+
The prompt is locked into source so its hash is reproducible across runs:
|
|
4
|
+
two invocations with the same dataset and DDL produce the same
|
|
5
|
+
``prompt_hash``. Any drift — a wording tweak, a new DDL revision,
|
|
6
|
+
a different dataset name — flips the hash and is auditable from the
|
|
7
|
+
JSON envelope.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MAX_DDL_CHARS",
|
|
17
|
+
"build_pretensor_system_prompt",
|
|
18
|
+
"build_system_prompt",
|
|
19
|
+
"pretensor_prompt_template_hash",
|
|
20
|
+
"prompt_hash",
|
|
21
|
+
"prompt_template_hash",
|
|
22
|
+
"strip_sql_fences",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SYSTEM_PROMPT_TEMPLATE = """You are a senior SQL engineer. The schema below describes a {dataset_name} PostgreSQL database. Read the schema carefully, then translate the user's question into a single PostgreSQL SELECT statement that runs against this schema and answers the question.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Return only the SQL query — no markdown, no code fences, no commentary.
|
|
30
|
+
- Emit a single statement; do not include trailing semicolons or explanatory text.
|
|
31
|
+
- Use only objects (tables, columns, schemas) that appear in the schema.
|
|
32
|
+
- Prefer fully-qualified table names (schema.table) when the schema names them that way.
|
|
33
|
+
- Read-only access only: SELECT or WITH ... SELECT statements only. Never DDL or DML.
|
|
34
|
+
|
|
35
|
+
Schema (DDL):
|
|
36
|
+
{ddl_text}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
MAX_DDL_CHARS = 400_000
|
|
40
|
+
"""Soft warning threshold for the DDL passed to the model.
|
|
41
|
+
|
|
42
|
+
Beyond this size the runner emits a ``notes[]`` warning so an auditor can
|
|
43
|
+
tell whether a low success rate might be a context-window artefact rather
|
|
44
|
+
than agent quality. The runner never silently truncates — silent
|
|
45
|
+
truncation would produce a fake baseline.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_system_prompt(dataset_name: str, ddl_text: str) -> str:
|
|
50
|
+
"""Render the locked template with the given dataset name and DDL.
|
|
51
|
+
|
|
52
|
+
The dataset name is interpolated verbatim — give the agent a hint about
|
|
53
|
+
the schema's domain (e.g. ``pagila`` is a video-rental catalog, ``tpch``
|
|
54
|
+
is a decision-support benchmark) at no cost. The DDL is also verbatim;
|
|
55
|
+
comments inside the DDL carry semantic hints we want preserved.
|
|
56
|
+
"""
|
|
57
|
+
return _SYSTEM_PROMPT_TEMPLATE.format(dataset_name=dataset_name, ddl_text=ddl_text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_PRETENSOR_SYSTEM_PROMPT_TEMPLATE = """You are a senior SQL engineer. The {dataset_name} PostgreSQL database is indexed by Pretensor, and you have access to MCP tools that describe the schema, search for relevant tables, and traverse relationships. Use the tools to gather just enough context, then translate the user's question into a single PostgreSQL SELECT statement.
|
|
61
|
+
|
|
62
|
+
Rules:
|
|
63
|
+
- Return only the SQL query — no markdown, no code fences, no commentary.
|
|
64
|
+
- Emit a single statement; do not include trailing semicolons or explanatory text.
|
|
65
|
+
- Use only objects (tables, columns, schemas) that the tools confirm exist.
|
|
66
|
+
- Prefer fully-qualified table names (schema.table) when the schema names them that way.
|
|
67
|
+
- Read-only access only: SELECT or WITH ... SELECT statements only. Never DDL or DML.
|
|
68
|
+
- Stop calling tools and emit the SQL as soon as you have enough context. Do not call tools for general curiosity.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_pretensor_system_prompt(dataset_name: str) -> str:
|
|
73
|
+
"""Render the pretensor-runner system prompt for ``dataset_name``.
|
|
74
|
+
|
|
75
|
+
The pretensor prompt is intentionally close to the baseline's: same
|
|
76
|
+
voice, same SQL-emission rules. The only structural change is that
|
|
77
|
+
schema knowledge is delivered through MCP tools rather than inlined
|
|
78
|
+
DDL — so the template tells the model to gather context via tools
|
|
79
|
+
before answering. Keeping the prompts otherwise identical keeps the
|
|
80
|
+
side-by-side comparison about Pretensor's tooling, not about prompt
|
|
81
|
+
engineering.
|
|
82
|
+
"""
|
|
83
|
+
return _PRETENSOR_SYSTEM_PROMPT_TEMPLATE.format(dataset_name=dataset_name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pretensor_prompt_template_hash() -> str:
|
|
87
|
+
"""SHA-256 of the literal pretensor prompt template (no substitutions).
|
|
88
|
+
|
|
89
|
+
Mirrors :func:`prompt_template_hash` so the JSON envelope can record
|
|
90
|
+
which prompt the run used; flips when the wording is edited in source.
|
|
91
|
+
"""
|
|
92
|
+
return hashlib.sha256(_PRETENSOR_SYSTEM_PROMPT_TEMPLATE.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prompt_template_hash() -> str:
|
|
96
|
+
"""SHA-256 of the literal template (no substitutions).
|
|
97
|
+
|
|
98
|
+
Stable across runs; flips only when the prompt wording changes in
|
|
99
|
+
source. Pair this with ``prompt_hash`` in the JSON envelope so an
|
|
100
|
+
auditor can tell apart "DDL changed but template didn't" from
|
|
101
|
+
"template changed but DDL didn't".
|
|
102
|
+
"""
|
|
103
|
+
return hashlib.sha256(_SYSTEM_PROMPT_TEMPLATE.encode("utf-8")).hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prompt_hash(rendered_prompt: str) -> str:
|
|
107
|
+
"""SHA-256 of the rendered prompt (template + dataset + DDL substituted)."""
|
|
108
|
+
return hashlib.sha256(rendered_prompt.encode("utf-8")).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Match a leading ```sql ... ``` or ``` ... ``` fence. We intentionally
|
|
112
|
+
# only strip the outermost wrapping fence; if the model returns multiple
|
|
113
|
+
# code blocks we want to surface that as an SQL parse failure downstream
|
|
114
|
+
# rather than silently merge them.
|
|
115
|
+
_FENCE_RE = re.compile(
|
|
116
|
+
r"^\s*```(?:sql|postgres|postgresql)?\s*\n(?P<body>.*?)\n```\s*$",
|
|
117
|
+
re.IGNORECASE | re.DOTALL,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def strip_sql_fences(raw: str) -> str:
|
|
122
|
+
"""Best-effort cleanup of model output that ignored the no-fence rule.
|
|
123
|
+
|
|
124
|
+
Some models wrap SQL in ```sql ... ``` despite the explicit
|
|
125
|
+
instruction. This strips one leading fence; everything else is
|
|
126
|
+
returned verbatim so downstream parsing still surfaces real garbage
|
|
127
|
+
as a parse failure.
|
|
128
|
+
"""
|
|
129
|
+
match = _FENCE_RE.match(raw)
|
|
130
|
+
if match is None:
|
|
131
|
+
return raw
|
|
132
|
+
return match.group("body")
|