dbt-cortex-agent 0.0.3__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.
- dbt_cortex_agent/__init__.py +5 -0
- dbt_cortex_agent/artifacts.py +26 -0
- dbt_cortex_agent/cli.py +90 -0
- dbt_cortex_agent/commands/__init__.py +1 -0
- dbt_cortex_agent/commands/agent.py +115 -0
- dbt_cortex_agent/commands/bootstrap.py +92 -0
- dbt_cortex_agent/commands/common.py +62 -0
- dbt_cortex_agent/commands/eval.py +143 -0
- dbt_cortex_agent/commands/manifest.py +48 -0
- dbt_cortex_agent/commands/skill.py +129 -0
- dbt_cortex_agent/config.py +88 -0
- dbt_cortex_agent/dbt_runner.py +93 -0
- dbt_cortex_agent/doctor.py +204 -0
- dbt_cortex_agent/eval/__init__.py +0 -0
- dbt_cortex_agent/eval/baseline.py +44 -0
- dbt_cortex_agent/eval/compare.py +68 -0
- dbt_cortex_agent/eval/dataset.py +128 -0
- dbt_cortex_agent/eval/gate.py +31 -0
- dbt_cortex_agent/eval/lifecycle.py +567 -0
- dbt_cortex_agent/eval/results.py +195 -0
- dbt_cortex_agent/execution_context.py +136 -0
- dbt_cortex_agent/identifiers.py +46 -0
- dbt_cortex_agent/init.py +322 -0
- dbt_cortex_agent/invoke.py +140 -0
- dbt_cortex_agent/manifest.py +285 -0
- dbt_cortex_agent/skills.py +181 -0
- dbt_cortex_agent/snow.py +11 -0
- dbt_cortex_agent/starters/orders/models/agents/orders_assistant/agent.yml +7 -0
- dbt_cortex_agent/starters/orders/models/agents/orders_assistant/evals/core.yml +54 -0
- dbt_cortex_agent/starters/orders/models/agents/orders_assistant/evals/orders_assistant_core.sql +50 -0
- dbt_cortex_agent/starters/orders/models/agents/orders_assistant/orders_assistant.sql +54 -0
- dbt_cortex_agent/starters/orders/models/semantic/sem_orders.sql +40 -0
- dbt_cortex_agent/starters/orders/seeds/orders.csv +4 -0
- dbt_cortex_agent/starters/orders/seeds/orders.yml +19 -0
- dbt_cortex_agent-0.0.3.dist-info/METADATA +196 -0
- dbt_cortex_agent-0.0.3.dist-info/RECORD +40 -0
- dbt_cortex_agent-0.0.3.dist-info/WHEEL +5 -0
- dbt_cortex_agent-0.0.3.dist-info/entry_points.txt +2 -0
- dbt_cortex_agent-0.0.3.dist-info/licenses/LICENSE +201 -0
- dbt_cortex_agent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from ..config import Config
|
|
13
|
+
from ..dbt_runner import CommandRunner, run_dbt_operation, run_dbt_parse
|
|
14
|
+
from ..identifiers import fqn, identifier
|
|
15
|
+
from ..manifest import assert_resource_databases_allowed
|
|
16
|
+
from ..skills import assert_apply_safety
|
|
17
|
+
from .dataset import annotate_rows, validate_table
|
|
18
|
+
from .results import build_candidate, write_candidate, write_diagnostic
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TERMINAL_SUCCESS = {"COMPLETED", "SUCCEEDED", "DONE"}
|
|
22
|
+
TERMINAL_FAILURE = {"FAILED", "ERROR", "CANCELLED", "INVOCATION_FAILED", "INVOCATION_ERROR"}
|
|
23
|
+
PARTIAL_COMPLETION = {"INVOCATION_PARTIALLY_COMPLETED"}
|
|
24
|
+
RETRYABLE_DETAILS = (
|
|
25
|
+
"invocation failed",
|
|
26
|
+
"service is currently unavailable",
|
|
27
|
+
"internal error",
|
|
28
|
+
"timed out",
|
|
29
|
+
"timeout",
|
|
30
|
+
"rate limit",
|
|
31
|
+
)
|
|
32
|
+
_PATH_COMPONENT = re.compile(r"^[A-Za-z0-9_$.-]+$")
|
|
33
|
+
PLAN_SCHEMA_VERSION = 2
|
|
34
|
+
_PLAN_PREFIX = "CORTEX_EVAL_PLAN_JSON="
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class EvalPlan:
|
|
39
|
+
schema_version: int
|
|
40
|
+
agent_name: str
|
|
41
|
+
suite_name: str
|
|
42
|
+
eval_model: str
|
|
43
|
+
table_fqn: str
|
|
44
|
+
agent_fqn: str
|
|
45
|
+
stage_fqn: str
|
|
46
|
+
result_database: str
|
|
47
|
+
result_schema: str
|
|
48
|
+
target_name: str
|
|
49
|
+
target_role: str
|
|
50
|
+
target_database: str
|
|
51
|
+
target_schema: str
|
|
52
|
+
target_warehouse: str | None
|
|
53
|
+
native_eval_config: dict[str, Any]
|
|
54
|
+
dataset_name_token: str
|
|
55
|
+
config_filename_template: str
|
|
56
|
+
metric_names: list[str]
|
|
57
|
+
thresholds: dict[str, float]
|
|
58
|
+
regression_tolerances: dict[str, float]
|
|
59
|
+
ordered_ground_truth_refs: list[str]
|
|
60
|
+
suite_signature: str
|
|
61
|
+
plan_identity: dict[str, Any]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class PollResult:
|
|
66
|
+
status: str
|
|
67
|
+
details: str
|
|
68
|
+
request_ids: tuple[str, ...] = ()
|
|
69
|
+
inference_ids: tuple[str, ...] = ()
|
|
70
|
+
error_code: str | None = None
|
|
71
|
+
observed_status: str | None = None
|
|
72
|
+
expected_records: int | None = None
|
|
73
|
+
actual_records: int | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def succeeded(self) -> bool:
|
|
77
|
+
return self.status in TERMINAL_SUCCESS
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _numeric_policy(raw: Any, label: str) -> dict[str, float]:
|
|
81
|
+
if not isinstance(raw, dict):
|
|
82
|
+
raise ValueError(f"Evaluation plan {label} must be an object")
|
|
83
|
+
try:
|
|
84
|
+
values = {str(name): float(value) for name, value in raw.items()}
|
|
85
|
+
except (TypeError, ValueError) as exc:
|
|
86
|
+
raise ValueError(f"Evaluation plan {label} values must be numeric") from exc
|
|
87
|
+
if any(value < 0 for value in values.values()):
|
|
88
|
+
raise ValueError(f"Evaluation plan {label} values must be non-negative")
|
|
89
|
+
return values
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _plan_from_payload(payload: dict[str, Any], agent_name: str, suite_name: str) -> EvalPlan:
|
|
93
|
+
if payload.get("schema_version") != PLAN_SCHEMA_VERSION:
|
|
94
|
+
raise ValueError(f"Evaluation plan schema_version must be {PLAN_SCHEMA_VERSION}")
|
|
95
|
+
identity = payload.get("identity")
|
|
96
|
+
if not isinstance(identity, dict):
|
|
97
|
+
raise ValueError("Evaluation plan identity must be an object")
|
|
98
|
+
required_identity = {
|
|
99
|
+
"agent_name", "suite_name", "eval_model", "agent_fqn",
|
|
100
|
+
"dataset_fqn", "stage_fqn", "target_name", "target_role", "target_database",
|
|
101
|
+
"target_schema", "result_database", "result_schema",
|
|
102
|
+
}
|
|
103
|
+
missing_identity = sorted(required_identity - identity.keys())
|
|
104
|
+
if missing_identity:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Evaluation plan identity is missing required fields: {', '.join(missing_identity)}"
|
|
107
|
+
)
|
|
108
|
+
if identity.get("agent_name") != agent_name or identity.get("suite_name") != suite_name:
|
|
109
|
+
raise ValueError("Evaluation plan identity does not match the selected Agent and suite")
|
|
110
|
+
if "projection" in identity or "projection" in payload:
|
|
111
|
+
raise ValueError("Evaluation plan must not contain projection identity")
|
|
112
|
+
signature_material = payload.get("signature_material")
|
|
113
|
+
if not isinstance(signature_material, str):
|
|
114
|
+
raise ValueError("Evaluation plan signature_material must be a string")
|
|
115
|
+
expected_signature = hashlib.md5(signature_material.encode("utf-8")).hexdigest()
|
|
116
|
+
if payload.get("suite_signature") != expected_signature:
|
|
117
|
+
raise ValueError("Evaluation plan suite_signature does not match signature_material")
|
|
118
|
+
try:
|
|
119
|
+
signed = json.loads(signature_material)
|
|
120
|
+
except json.JSONDecodeError as exc:
|
|
121
|
+
raise ValueError("Evaluation plan signature_material is not valid JSON") from exc
|
|
122
|
+
signed_fields = {
|
|
123
|
+
"plan_schema_version": payload["schema_version"],
|
|
124
|
+
"identity": identity,
|
|
125
|
+
"native_eval_config": payload.get("native_eval_config"),
|
|
126
|
+
"metric_names": payload.get("metric_names"),
|
|
127
|
+
"thresholds": payload.get("thresholds"),
|
|
128
|
+
"regression_tolerances": payload.get("regression_tolerances"),
|
|
129
|
+
"ordered_ground_truth_refs": payload.get("ordered_ground_truth_refs"),
|
|
130
|
+
}
|
|
131
|
+
if signed != signed_fields:
|
|
132
|
+
raise ValueError("Evaluation plan signed fields do not match signature_material")
|
|
133
|
+
config_template = payload.get("native_eval_config")
|
|
134
|
+
refs = payload.get("ordered_ground_truth_refs")
|
|
135
|
+
metric_names = payload.get("metric_names")
|
|
136
|
+
if not isinstance(config_template, dict) or not isinstance(refs, list) or not isinstance(metric_names, list):
|
|
137
|
+
raise ValueError("Evaluation plan config, metric_names, and ordered refs have invalid types")
|
|
138
|
+
config_agent = (
|
|
139
|
+
config_template.get("evaluation", {}).get("agent_params", {}).get("agent_name")
|
|
140
|
+
)
|
|
141
|
+
if config_agent != identity.get("agent_fqn"):
|
|
142
|
+
raise ValueError("Evaluation plan native config Agent must match signed agent_fqn")
|
|
143
|
+
refs = [str(value) for value in refs]
|
|
144
|
+
if not refs or any(not value for value in refs) or len(refs) != len(set(refs)):
|
|
145
|
+
raise ValueError("Evaluation plan ordered_ground_truth_refs must be non-empty and unique")
|
|
146
|
+
metric_names = [str(value) for value in metric_names]
|
|
147
|
+
if not metric_names or len(metric_names) != len(set(metric_names)):
|
|
148
|
+
raise ValueError("Evaluation plan metric_names must be non-empty and unique")
|
|
149
|
+
token = payload.get("dataset_name_token")
|
|
150
|
+
filename_template = payload.get("config_filename_template")
|
|
151
|
+
if not isinstance(token, str) or not token or not isinstance(filename_template, str):
|
|
152
|
+
raise ValueError("Evaluation plan dataset token and config filename template are required")
|
|
153
|
+
if not identity.get("target_role"):
|
|
154
|
+
raise ValueError("Evaluation plan target_role is required")
|
|
155
|
+
return EvalPlan(
|
|
156
|
+
schema_version=PLAN_SCHEMA_VERSION,
|
|
157
|
+
agent_name=agent_name,
|
|
158
|
+
suite_name=suite_name,
|
|
159
|
+
eval_model=str(identity["eval_model"]),
|
|
160
|
+
table_fqn=fqn(str(identity["dataset_fqn"]), "eval table"),
|
|
161
|
+
agent_fqn=fqn(str(identity["agent_fqn"]), "Agent object"),
|
|
162
|
+
stage_fqn=fqn(str(identity["stage_fqn"]), "evaluation stage"),
|
|
163
|
+
result_database=identifier(str(identity["result_database"]), "result database"),
|
|
164
|
+
result_schema=identifier(str(identity["result_schema"]), "result schema"),
|
|
165
|
+
target_name=str(identity["target_name"]),
|
|
166
|
+
target_role=identifier(str(identity["target_role"]), "target role"),
|
|
167
|
+
target_database=identifier(str(identity["target_database"]), "target database"),
|
|
168
|
+
target_schema=identifier(str(identity["target_schema"]), "target schema"),
|
|
169
|
+
target_warehouse=(str(identity["target_warehouse"]) if identity.get("target_warehouse") else None),
|
|
170
|
+
native_eval_config=config_template,
|
|
171
|
+
dataset_name_token=token,
|
|
172
|
+
config_filename_template=filename_template,
|
|
173
|
+
metric_names=metric_names,
|
|
174
|
+
thresholds=_numeric_policy(payload.get("thresholds"), "thresholds"),
|
|
175
|
+
regression_tolerances=_numeric_policy(payload.get("regression_tolerances"), "regression_tolerances"),
|
|
176
|
+
ordered_ground_truth_refs=refs,
|
|
177
|
+
suite_signature=expected_signature,
|
|
178
|
+
plan_identity=dict(identity),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_plan(stdout: str, agent_name: str, suite_name: str) -> EvalPlan:
|
|
183
|
+
payloads = [line.split(_PLAN_PREFIX, 1)[1].strip() for line in stdout.splitlines() if _PLAN_PREFIX in line]
|
|
184
|
+
if len(payloads) != 1:
|
|
185
|
+
raise ValueError(f"Expected exactly one dbt evaluation plan payload, found {len(payloads)}")
|
|
186
|
+
try:
|
|
187
|
+
payload = json.loads(payloads[0])
|
|
188
|
+
except json.JSONDecodeError as exc:
|
|
189
|
+
raise ValueError("dbt evaluation plan payload is not valid JSON") from exc
|
|
190
|
+
if not isinstance(payload, dict):
|
|
191
|
+
raise ValueError("dbt evaluation plan payload must be a JSON object")
|
|
192
|
+
return _plan_from_payload(payload, agent_name, suite_name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_plan(
|
|
196
|
+
config: Config,
|
|
197
|
+
*,
|
|
198
|
+
agent_name: str,
|
|
199
|
+
suite_name: str,
|
|
200
|
+
parse: bool = True,
|
|
201
|
+
runner: CommandRunner | None = None,
|
|
202
|
+
plan_payload: dict[str, Any] | None = None,
|
|
203
|
+
) -> EvalPlan:
|
|
204
|
+
if plan_payload is not None:
|
|
205
|
+
return _plan_from_payload(plan_payload, agent_name, suite_name)
|
|
206
|
+
command_runner = runner or CommandRunner()
|
|
207
|
+
if parse:
|
|
208
|
+
parsed = run_dbt_parse(
|
|
209
|
+
config.dbt_executable,
|
|
210
|
+
config.project_dir,
|
|
211
|
+
config.target,
|
|
212
|
+
command_runner,
|
|
213
|
+
config.dbt_env,
|
|
214
|
+
)
|
|
215
|
+
if parsed.returncode != 0:
|
|
216
|
+
raise RuntimeError(parsed.stderr.strip() or parsed.stdout.strip() or "dbt parse failed")
|
|
217
|
+
rendered = run_dbt_operation(
|
|
218
|
+
config.dbt_executable,
|
|
219
|
+
config.project_dir,
|
|
220
|
+
config.target,
|
|
221
|
+
"dbt_cortex_agent.cortex_eval__execution_plan",
|
|
222
|
+
{"agent_name": agent_name, "suite_name": suite_name},
|
|
223
|
+
command_runner,
|
|
224
|
+
config.dbt_env,
|
|
225
|
+
)
|
|
226
|
+
if rendered.returncode != 0:
|
|
227
|
+
raise RuntimeError(rendered.stderr.strip() or rendered.stdout.strip() or "dbt evaluation plan render failed")
|
|
228
|
+
return _extract_plan(rendered.stdout, agent_name, suite_name)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def render_eval_config(plan: EvalPlan, dataset_name: str) -> str:
|
|
232
|
+
rendered = json.dumps(plan.native_eval_config, separators=(",", ":"))
|
|
233
|
+
if rendered.count(plan.dataset_name_token) != 2:
|
|
234
|
+
raise ValueError("dbt evaluation plan must contain exactly two dataset-name tokens")
|
|
235
|
+
return rendered.replace(plan.dataset_name_token, dataset_name)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def flatten_status_details(raw: Any) -> str:
|
|
239
|
+
if raw in (None, ""):
|
|
240
|
+
return ""
|
|
241
|
+
if isinstance(raw, (list, tuple)):
|
|
242
|
+
return "; ".join(str(item) for item in raw if item)
|
|
243
|
+
text = str(raw).strip()
|
|
244
|
+
if text.startswith("[") and text.endswith("]"):
|
|
245
|
+
try:
|
|
246
|
+
parsed = json.loads(text)
|
|
247
|
+
if isinstance(parsed, list):
|
|
248
|
+
return "; ".join(str(item) for item in parsed if item)
|
|
249
|
+
except json.JSONDecodeError:
|
|
250
|
+
pass
|
|
251
|
+
return text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def is_retryable(details: Any) -> bool:
|
|
255
|
+
value = flatten_status_details(details).lower()
|
|
256
|
+
return bool(value) and any(pattern in value for pattern in RETRYABLE_DETAILS)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_retryable_result(status: PollResult) -> bool:
|
|
260
|
+
return (
|
|
261
|
+
status.status in PARTIAL_COMPLETION
|
|
262
|
+
or status.observed_status in PARTIAL_COMPLETION
|
|
263
|
+
or status.status == "INCOMPLETE_RESULTS"
|
|
264
|
+
or is_retryable(status.details)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def poll(
|
|
269
|
+
cursor,
|
|
270
|
+
run_name: str,
|
|
271
|
+
config_path: str,
|
|
272
|
+
*,
|
|
273
|
+
attempts: int,
|
|
274
|
+
interval: float,
|
|
275
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
276
|
+
) -> PollResult:
|
|
277
|
+
if attempts < 1:
|
|
278
|
+
raise ValueError("Poll attempts must be at least 1")
|
|
279
|
+
last_result: PollResult | None = None
|
|
280
|
+
for attempt in range(attempts):
|
|
281
|
+
cursor.execute(
|
|
282
|
+
"CALL EXECUTE_AI_EVALUATION('STATUS', OBJECT_CONSTRUCT('run_name', %s), %s)",
|
|
283
|
+
(run_name, config_path),
|
|
284
|
+
)
|
|
285
|
+
rows = cursor.fetchall()
|
|
286
|
+
if rows:
|
|
287
|
+
columns = [str(item[0]).upper() for item in cursor.description]
|
|
288
|
+
row = rows[0]
|
|
289
|
+
status = str(row[columns.index("STATUS")] if "STATUS" in columns else row[3]).upper()
|
|
290
|
+
details = flatten_status_details(
|
|
291
|
+
row[columns.index("STATUS_DETAILS")] if "STATUS_DETAILS" in columns else row[4] if len(row) > 4 else ""
|
|
292
|
+
)
|
|
293
|
+
request_ids = _status_identifiers(row, columns, "REQUEST_ID")
|
|
294
|
+
inference_ids = _status_identifiers(row, columns, "INFERENCE_ID")
|
|
295
|
+
error_code = _status_value(row, columns, "ERROR_CODE")
|
|
296
|
+
last_result = PollResult(
|
|
297
|
+
status,
|
|
298
|
+
details,
|
|
299
|
+
request_ids,
|
|
300
|
+
inference_ids,
|
|
301
|
+
error_code,
|
|
302
|
+
observed_status=status,
|
|
303
|
+
)
|
|
304
|
+
if status in TERMINAL_SUCCESS | TERMINAL_FAILURE:
|
|
305
|
+
return last_result
|
|
306
|
+
if attempt + 1 < attempts:
|
|
307
|
+
sleep(interval)
|
|
308
|
+
if last_result is None:
|
|
309
|
+
return PollResult("TIMEOUT", "poll attempts exhausted")
|
|
310
|
+
return replace(
|
|
311
|
+
last_result,
|
|
312
|
+
status="TIMEOUT",
|
|
313
|
+
details="poll attempts exhausted",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _status_value(row: tuple[Any, ...], columns: list[str], name: str) -> str | None:
|
|
318
|
+
if name not in columns:
|
|
319
|
+
return None
|
|
320
|
+
value = row[columns.index(name)]
|
|
321
|
+
if value in (None, ""):
|
|
322
|
+
return None
|
|
323
|
+
return str(value)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _status_identifiers(
|
|
327
|
+
row: tuple[Any, ...], columns: list[str], name: str
|
|
328
|
+
) -> tuple[str, ...]:
|
|
329
|
+
value = _status_value(row, columns, name)
|
|
330
|
+
return (value,) if value else ()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _diagnostic_category(status: PollResult) -> str:
|
|
334
|
+
if status.observed_status in PARTIAL_COMPLETION or status.status == "INCOMPLETE_RESULTS":
|
|
335
|
+
return "transient_platform_failure"
|
|
336
|
+
if status.status == "TIMEOUT":
|
|
337
|
+
return "timeout"
|
|
338
|
+
if is_retryable(status.details):
|
|
339
|
+
return "transient_platform_failure"
|
|
340
|
+
return "terminal_evaluation_failure"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _failure_diagnostic(plan: EvalPlan, run_name: str, status: PollResult) -> dict[str, Any]:
|
|
344
|
+
diagnostic = {
|
|
345
|
+
"schema_version": 1,
|
|
346
|
+
"artifact_type": "evaluation_diagnostic",
|
|
347
|
+
"agent": plan.agent_name,
|
|
348
|
+
"agent_fqn": plan.agent_fqn,
|
|
349
|
+
"plan_identity": plan.plan_identity,
|
|
350
|
+
"suite": plan.suite_name,
|
|
351
|
+
"run_name": run_name,
|
|
352
|
+
"status": status.status,
|
|
353
|
+
"request_ids": list(status.request_ids),
|
|
354
|
+
"inference_ids": list(status.inference_ids),
|
|
355
|
+
"error": {
|
|
356
|
+
"category": _diagnostic_category(status),
|
|
357
|
+
"code": status.error_code,
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
if status.status == "TIMEOUT" and status.observed_status:
|
|
361
|
+
diagnostic["observed_status"] = status.observed_status
|
|
362
|
+
if status.expected_records is not None:
|
|
363
|
+
diagnostic["expected_records"] = status.expected_records
|
|
364
|
+
if status.actual_records is not None:
|
|
365
|
+
diagnostic["actual_records"] = status.actual_records
|
|
366
|
+
return diagnostic
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _upload_config(cursor, plan: EvalPlan, filename: str, content: str) -> str:
|
|
370
|
+
if not _PATH_COMPONENT.fullmatch(filename):
|
|
371
|
+
raise ValueError(f"Invalid evaluation config filename: {filename!r}")
|
|
372
|
+
cursor.execute(f"CREATE STAGE IF NOT EXISTS {plan.stage_fqn}")
|
|
373
|
+
cursor.execute(
|
|
374
|
+
f"COPY INTO @{plan.stage_fqn}/{filename} FROM (SELECT %s) "
|
|
375
|
+
"FILE_FORMAT=(TYPE=CSV FIELD_DELIMITER=NONE RECORD_DELIMITER=NONE COMPRESSION=NONE) "
|
|
376
|
+
"SINGLE=TRUE OVERWRITE=TRUE",
|
|
377
|
+
(content,),
|
|
378
|
+
)
|
|
379
|
+
return f"@{plan.stage_fqn}/{filename}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _results_complete(rows: list[dict[str, Any]], plan: EvalPlan) -> bool:
|
|
383
|
+
expected_inputs = len(plan.ordered_ground_truth_refs)
|
|
384
|
+
expected_metrics = {name.lower() for name in plan.metric_names}
|
|
385
|
+
metrics_by_input: dict[str, set[str]] = {}
|
|
386
|
+
for row in rows:
|
|
387
|
+
input_query = row.get("input")
|
|
388
|
+
metric_name = row.get("metric_name")
|
|
389
|
+
if not input_query or not metric_name:
|
|
390
|
+
continue
|
|
391
|
+
metrics_by_input.setdefault(str(input_query), set()).add(str(metric_name).lower())
|
|
392
|
+
return (
|
|
393
|
+
len(metrics_by_input) == expected_inputs
|
|
394
|
+
and all(metrics == expected_metrics for metrics in metrics_by_input.values())
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _fetch_rows(cursor, plan: EvalPlan, run_name: str, retries: int, sleep: Callable[[float], None]) -> list[dict[str, Any]]:
|
|
399
|
+
rows: list[dict[str, Any]] = []
|
|
400
|
+
database, schema, agent = plan.agent_fqn.split(".")
|
|
401
|
+
for attempt in range(retries + 1):
|
|
402
|
+
cursor.execute(
|
|
403
|
+
"SELECT * FROM TABLE(SNOWFLAKE.LOCAL.GET_AI_EVALUATION_DATA(%s,%s,%s,'CORTEX AGENT',%s))",
|
|
404
|
+
(database, schema, agent, run_name),
|
|
405
|
+
)
|
|
406
|
+
columns = [str(item[0]).lower() for item in cursor.description]
|
|
407
|
+
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
408
|
+
if _results_complete(rows, plan):
|
|
409
|
+
return rows
|
|
410
|
+
if attempt < retries:
|
|
411
|
+
sleep(1)
|
|
412
|
+
return rows
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _result_record_count(rows: list[dict[str, Any]]) -> int:
|
|
416
|
+
return len({str(row.get("input")) for row in rows if row.get("input")})
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _agent_provenance(cursor, plan: EvalPlan) -> dict[str, Any]:
|
|
420
|
+
cursor.execute(f"DESCRIBE AGENT {plan.agent_fqn}")
|
|
421
|
+
columns = [str(item[0]).lower() for item in cursor.description]
|
|
422
|
+
row = cursor.fetchone()
|
|
423
|
+
if row is None:
|
|
424
|
+
raise RuntimeError(f"Agent {plan.agent_fqn} does not exist")
|
|
425
|
+
aliases: dict[str, Any] = {}
|
|
426
|
+
if "aliases" in columns and row[columns.index("aliases")]:
|
|
427
|
+
raw = row[columns.index("aliases")]
|
|
428
|
+
aliases = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
429
|
+
default_version = aliases.get("DEFAULT")
|
|
430
|
+
if not default_version:
|
|
431
|
+
raise RuntimeError(f"Agent {plan.agent_fqn} has no resolvable DEFAULT version")
|
|
432
|
+
return {"default_version": str(default_version), "aliases": aliases}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _assert_agent_exists_with_default(cursor, plan: EvalPlan) -> dict[str, Any]:
|
|
436
|
+
try:
|
|
437
|
+
provenance = _agent_provenance(cursor, plan)
|
|
438
|
+
except Exception as exc:
|
|
439
|
+
raise RuntimeError(
|
|
440
|
+
f"Evaluation requires existing Agent {plan.agent_fqn} with a resolvable DEFAULT version"
|
|
441
|
+
) from exc
|
|
442
|
+
return provenance
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _default_connect(connection: str):
|
|
446
|
+
try:
|
|
447
|
+
import snowflake.connector
|
|
448
|
+
except ImportError as exc:
|
|
449
|
+
raise RuntimeError(
|
|
450
|
+
"Evaluation execution requires the 'runtime' extra: pip install 'dbt-cortex-agent[runtime]'"
|
|
451
|
+
) from exc
|
|
452
|
+
return snowflake.connector.connect(connection_name=connection)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def run_evaluation(
|
|
456
|
+
config: Config,
|
|
457
|
+
plan: EvalPlan,
|
|
458
|
+
*,
|
|
459
|
+
apply: bool,
|
|
460
|
+
run_name: str | None = None,
|
|
461
|
+
poll_attempts: int = 60,
|
|
462
|
+
poll_interval: float = 30,
|
|
463
|
+
transient_retries: int = 1,
|
|
464
|
+
allowed_targets: list[str] | None = None,
|
|
465
|
+
allowed_databases: list[str] | None = None,
|
|
466
|
+
connect: Callable[[str], Any] = _default_connect,
|
|
467
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
468
|
+
) -> Path | None:
|
|
469
|
+
if not apply:
|
|
470
|
+
return None
|
|
471
|
+
if not config.connection_explicit or not config.connection or not config.warehouse:
|
|
472
|
+
raise ValueError("Evaluation apply requires explicit --connection and --warehouse")
|
|
473
|
+
assert_apply_safety(config, allowed_targets or [], allowed_databases or [])
|
|
474
|
+
resource_databases = {
|
|
475
|
+
plan.agent_fqn.split(".", 1)[0],
|
|
476
|
+
plan.table_fqn.split(".", 1)[0],
|
|
477
|
+
plan.stage_fqn.split(".", 1)[0],
|
|
478
|
+
plan.result_database,
|
|
479
|
+
plan.target_database,
|
|
480
|
+
}
|
|
481
|
+
assert_resource_databases_allowed(resource_databases, allowed_databases or [])
|
|
482
|
+
if transient_retries < 0:
|
|
483
|
+
raise ValueError("Transient retries must be non-negative")
|
|
484
|
+
if config.target and config.target != plan.target_name:
|
|
485
|
+
raise ValueError(
|
|
486
|
+
f"Configured target {config.target!r} does not match dbt plan target {plan.target_name!r}"
|
|
487
|
+
)
|
|
488
|
+
base_run = run_name or (
|
|
489
|
+
f"{plan.agent_name}_{plan.suite_name}_"
|
|
490
|
+
f"{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
|
|
491
|
+
)
|
|
492
|
+
conn = connect(config.connection)
|
|
493
|
+
cursor = conn.cursor()
|
|
494
|
+
try:
|
|
495
|
+
cursor.execute(f"USE ROLE {plan.target_role}")
|
|
496
|
+
cursor.execute(f"USE WAREHOUSE {identifier(plan.target_warehouse or config.warehouse, 'warehouse')}")
|
|
497
|
+
cursor.execute(f"USE DATABASE {plan.result_database}")
|
|
498
|
+
cursor.execute(f"USE SCHEMA {plan.result_schema}")
|
|
499
|
+
initial_provenance = _assert_agent_exists_with_default(cursor, plan)
|
|
500
|
+
validate_table(cursor, plan.table_fqn, plan.metric_names, plan.ordered_ground_truth_refs)
|
|
501
|
+
final_run = base_run
|
|
502
|
+
status = PollResult("FAILED", "evaluation was not started")
|
|
503
|
+
rows: list[dict[str, Any]] = []
|
|
504
|
+
pre_start: dict[str, Any] | None = None
|
|
505
|
+
for retry in range(transient_retries + 1):
|
|
506
|
+
final_run = base_run if retry == 0 else f"{base_run}_r{retry}"
|
|
507
|
+
dataset_name = f"{final_run}_dataset"
|
|
508
|
+
filename = plan.config_filename_template.replace("__RUN_NAME__", final_run)
|
|
509
|
+
stage_path = _upload_config(cursor, plan, filename, render_eval_config(plan, dataset_name))
|
|
510
|
+
pre_start = _assert_agent_exists_with_default(cursor, plan)
|
|
511
|
+
if pre_start["default_version"] != initial_provenance["default_version"]:
|
|
512
|
+
raise RuntimeError(
|
|
513
|
+
f"Agent {plan.agent_fqn} DEFAULT version changed before evaluation START"
|
|
514
|
+
)
|
|
515
|
+
cursor.execute(
|
|
516
|
+
"CALL EXECUTE_AI_EVALUATION('START', OBJECT_CONSTRUCT('run_name', %s), %s)",
|
|
517
|
+
(final_run, stage_path),
|
|
518
|
+
)
|
|
519
|
+
status = poll(
|
|
520
|
+
cursor, final_run, stage_path, attempts=poll_attempts, interval=poll_interval, sleep=sleep
|
|
521
|
+
)
|
|
522
|
+
expected_records = len(plan.ordered_ground_truth_refs)
|
|
523
|
+
if status.succeeded or status.observed_status in PARTIAL_COMPLETION:
|
|
524
|
+
rows = _fetch_rows(cursor, plan, final_run, retries=2, sleep=sleep)
|
|
525
|
+
actual_records = _result_record_count(rows)
|
|
526
|
+
if status.succeeded and not _results_complete(rows, plan):
|
|
527
|
+
status = replace(
|
|
528
|
+
status,
|
|
529
|
+
status="INCOMPLETE_RESULTS",
|
|
530
|
+
details="evaluation returned incomplete result cardinality",
|
|
531
|
+
expected_records=expected_records,
|
|
532
|
+
actual_records=actual_records,
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
status = replace(
|
|
536
|
+
status,
|
|
537
|
+
expected_records=expected_records,
|
|
538
|
+
actual_records=actual_records,
|
|
539
|
+
)
|
|
540
|
+
if status.succeeded or not _is_retryable_result(status) or retry == transient_retries:
|
|
541
|
+
break
|
|
542
|
+
if not status.succeeded:
|
|
543
|
+
write_diagnostic(_failure_diagnostic(plan, final_run, status), config.artifact_dir)
|
|
544
|
+
raise RuntimeError(f"Evaluation {final_run} ended in {status.status}")
|
|
545
|
+
if not rows or not any(row.get("metric_name") for row in rows):
|
|
546
|
+
raise RuntimeError(f"Evaluation {final_run} returned no scored metric rows")
|
|
547
|
+
annotate_rows(cursor, plan.table_fqn, rows)
|
|
548
|
+
post_completion = _assert_agent_exists_with_default(cursor, plan)
|
|
549
|
+
if pre_start is None:
|
|
550
|
+
raise RuntimeError("Evaluation provenance was not captured before START")
|
|
551
|
+
provenance = {
|
|
552
|
+
"agent_fqn": plan.agent_fqn,
|
|
553
|
+
"plan_identity": plan.plan_identity,
|
|
554
|
+
"evaluated_version": pre_start["default_version"],
|
|
555
|
+
"pre_start": pre_start,
|
|
556
|
+
"post_completion": post_completion,
|
|
557
|
+
"default_version_changed": (
|
|
558
|
+
pre_start["default_version"] != post_completion["default_version"]
|
|
559
|
+
),
|
|
560
|
+
}
|
|
561
|
+
candidate = build_candidate(
|
|
562
|
+
plan=plan, run_name=final_run, rows=rows, provenance=provenance
|
|
563
|
+
)
|
|
564
|
+
return write_candidate(candidate, config.artifact_dir)
|
|
565
|
+
finally:
|
|
566
|
+
cursor.close()
|
|
567
|
+
conn.close()
|