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,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from ..config import Config
|
|
6
|
+
from ..identifiers import identifier
|
|
7
|
+
from ..invoke import smoke_skills
|
|
8
|
+
from ..manifest import (
|
|
9
|
+
assert_resource_databases_allowed,
|
|
10
|
+
physical_agent_name,
|
|
11
|
+
select_agents,
|
|
12
|
+
skill_declarations,
|
|
13
|
+
)
|
|
14
|
+
from ..skills import assert_apply_safety, build_upload_plan, upload_skills
|
|
15
|
+
from .common import add_allowlists, emit_json, fresh_manifest, require_explicit_connection
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(subparsers: argparse._SubParsersAction, shared: argparse.ArgumentParser) -> None:
|
|
19
|
+
parser = subparsers.add_parser(
|
|
20
|
+
"skill",
|
|
21
|
+
help="plan, upload, or smoke Agent skills",
|
|
22
|
+
description="Plan skill files locally; upload and live smoke require explicit --apply.",
|
|
23
|
+
epilog=(
|
|
24
|
+
"Examples:\n"
|
|
25
|
+
" dbt-cortex-agent skill plan --agent orders_assistant --json\n"
|
|
26
|
+
" dbt-cortex-agent skill upload --agent orders_assistant --apply --connection dev"
|
|
27
|
+
),
|
|
28
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
29
|
+
)
|
|
30
|
+
commands = parser.add_subparsers(dest="skill_command", required=True)
|
|
31
|
+
for name in ("plan", "upload"):
|
|
32
|
+
help_text = "build a non-mutating upload plan" if name == "plan" else "preview or apply skill upload [MUTATION with --apply]"
|
|
33
|
+
command = commands.add_parser(name, parents=[shared], help=help_text)
|
|
34
|
+
command.add_argument("--agent", action="append", dest="agents", help="logical Agent name; repeatable")
|
|
35
|
+
if name == "upload":
|
|
36
|
+
command.add_argument("--apply", action="store_true", help="[MUTATION] upload files; default is preview")
|
|
37
|
+
add_allowlists(command)
|
|
38
|
+
command.set_defaults(handler=handle)
|
|
39
|
+
smoke = commands.add_parser("smoke", parents=[shared], help="preview or run live skill smoke [RUNTIME with --apply]")
|
|
40
|
+
smoke.add_argument("--agent", action="append", dest="agents", help="logical Agent name; repeatable")
|
|
41
|
+
smoke.add_argument("--agent-object", help="physical Agent override for exactly one selected Agent")
|
|
42
|
+
smoke.add_argument("--endpoint", help="HTTPS Snowflake Agent endpoint override")
|
|
43
|
+
smoke.add_argument("--apply", action="store_true", help="[RUNTIME] invoke Agents; default is preview")
|
|
44
|
+
add_allowlists(smoke)
|
|
45
|
+
smoke.set_defaults(handler=handle)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _plan_payload(plan: list) -> list[dict]:
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
"stage_path": item.stage_path,
|
|
52
|
+
"local_dir": str(item.local_dir),
|
|
53
|
+
"skills": list(item.skill_names),
|
|
54
|
+
"agents": list(item.agent_names),
|
|
55
|
+
}
|
|
56
|
+
for item in plan
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def handle(args: argparse.Namespace, config: Config) -> int:
|
|
61
|
+
manifest = fresh_manifest(config, no_parse=args.no_parse)
|
|
62
|
+
selected = select_agents(manifest, args.agents)
|
|
63
|
+
selected_names = [item["name"] for item in selected]
|
|
64
|
+
if args.skill_command in {"plan", "upload"}:
|
|
65
|
+
plan = build_upload_plan(manifest, config.project_dir, selected_names)
|
|
66
|
+
applied = args.skill_command == "upload" and args.apply
|
|
67
|
+
if applied:
|
|
68
|
+
require_explicit_connection(config, "Skill upload")
|
|
69
|
+
assert_apply_safety(config, args.allow_target, args.allow_database)
|
|
70
|
+
assert_resource_databases_allowed(
|
|
71
|
+
{item.stage_fqn.split(".", 1)[0] for item in plan}, args.allow_database
|
|
72
|
+
)
|
|
73
|
+
upload_skills(plan, config)
|
|
74
|
+
payload = {"command": f"skill {args.skill_command}", "applied": applied, "uploads": _plan_payload(plan)}
|
|
75
|
+
if args.json:
|
|
76
|
+
emit_json(payload)
|
|
77
|
+
else:
|
|
78
|
+
for item in plan:
|
|
79
|
+
print(
|
|
80
|
+
f"{item.stage_path} <- {item.local_dir} "
|
|
81
|
+
f"(skills={','.join(item.skill_names)}; agents={','.join(item.agent_names)})"
|
|
82
|
+
)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
declarations = skill_declarations(manifest, config.project_dir, selected_names)
|
|
86
|
+
if args.agent_object and len(selected) != 1:
|
|
87
|
+
raise ValueError("--agent-object may be used only when exactly one Agent is selected")
|
|
88
|
+
physical_agents = {
|
|
89
|
+
agent["name"]: {
|
|
90
|
+
"database": agent["database"],
|
|
91
|
+
"schema": agent["schema"],
|
|
92
|
+
"object_name": (
|
|
93
|
+
identifier(args.agent_object, "Agent object override")
|
|
94
|
+
if args.agent_object
|
|
95
|
+
else physical_agent_name(agent, config.target)
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
for agent in selected
|
|
99
|
+
}
|
|
100
|
+
planned = [
|
|
101
|
+
{
|
|
102
|
+
"skill": skill.skill_name,
|
|
103
|
+
"agent": ".".join(physical_agents[skill.agent_name].values()),
|
|
104
|
+
}
|
|
105
|
+
for skill in declarations
|
|
106
|
+
]
|
|
107
|
+
verified: list[str] = []
|
|
108
|
+
if args.apply:
|
|
109
|
+
require_explicit_connection(config, "Skill smoke")
|
|
110
|
+
assert_apply_safety(config, args.allow_target, args.allow_database)
|
|
111
|
+
assert_resource_databases_allowed(
|
|
112
|
+
{identity["database"] for identity in physical_agents.values()},
|
|
113
|
+
args.allow_database,
|
|
114
|
+
)
|
|
115
|
+
verified = smoke_skills(
|
|
116
|
+
declarations,
|
|
117
|
+
agent_names=physical_agents,
|
|
118
|
+
connection=str(config.connection),
|
|
119
|
+
endpoint=args.endpoint,
|
|
120
|
+
)
|
|
121
|
+
if args.json:
|
|
122
|
+
emit_json({"command": "skill smoke", "applied": bool(args.apply), "planned": planned, "verified": verified})
|
|
123
|
+
elif args.apply:
|
|
124
|
+
for skill_name in verified:
|
|
125
|
+
print(f"PASS {skill_name}: server_skill selected")
|
|
126
|
+
else:
|
|
127
|
+
for item in planned:
|
|
128
|
+
print(f"[DRY RUN] would smoke {item['skill']} via {item['agent']}")
|
|
129
|
+
return 0
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .execution_context import SnowflakeExecutionContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Config:
|
|
15
|
+
project_dir: Path
|
|
16
|
+
manifest: Path
|
|
17
|
+
target: str | None
|
|
18
|
+
connection: str | None
|
|
19
|
+
connection_explicit: bool
|
|
20
|
+
database: str | None
|
|
21
|
+
database_explicit: bool
|
|
22
|
+
schema: str | None
|
|
23
|
+
warehouse: str | None
|
|
24
|
+
warehouse_explicit: bool
|
|
25
|
+
artifact_dir: Path
|
|
26
|
+
dbt_executable: str
|
|
27
|
+
snow_executable: str
|
|
28
|
+
execution_context: SnowflakeExecutionContext | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def dbt_env(self) -> Mapping[str, str] | None:
|
|
32
|
+
return self.execution_context.dbt_env if self.execution_context else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_yaml_mapping(path: Path, *, strict: bool = True) -> dict[str, Any]:
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
return {}
|
|
38
|
+
value = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
39
|
+
if value is None:
|
|
40
|
+
return {}
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
if strict:
|
|
43
|
+
raise ValueError(f"Expected a YAML mapping in {path}")
|
|
44
|
+
return {}
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _value(explicit: str | None, env: Mapping[str, str], name: str) -> str | None:
|
|
49
|
+
return explicit if explicit is not None else env.get(name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_config(args: object, env: Mapping[str, str] | None = None) -> Config:
|
|
53
|
+
values = os.environ if env is None else env
|
|
54
|
+
project_dir = Path(
|
|
55
|
+
_value(getattr(args, "project_dir", None), values, "DBT_PROJECT_DIR") or "."
|
|
56
|
+
).expanduser().resolve()
|
|
57
|
+
manifest_value = _value(getattr(args, "manifest", None), values, "DBT_MANIFEST")
|
|
58
|
+
artifact_value = _value(
|
|
59
|
+
getattr(args, "artifact_dir", None), values, "DBT_CORTEX_AGENT_ARTIFACT_DIR"
|
|
60
|
+
)
|
|
61
|
+
manifest = Path(manifest_value).expanduser() if manifest_value else Path("target/manifest.json")
|
|
62
|
+
artifact_dir = Path(artifact_value).expanduser() if artifact_value else Path("target/dbt_cortex_agent")
|
|
63
|
+
|
|
64
|
+
return Config(
|
|
65
|
+
project_dir=project_dir,
|
|
66
|
+
manifest=manifest.resolve() if manifest.is_absolute() else (project_dir / manifest).resolve(),
|
|
67
|
+
target=_value(getattr(args, "target", None), values, "DBT_TARGET"),
|
|
68
|
+
connection=_value(getattr(args, "connection", None), values, "SNOWFLAKE_CONNECTION_NAME"),
|
|
69
|
+
connection_explicit=getattr(args, "connection", None) is not None,
|
|
70
|
+
database=_value(getattr(args, "database", None), values, "SNOWFLAKE_DATABASE"),
|
|
71
|
+
database_explicit=getattr(args, "database", None) is not None,
|
|
72
|
+
schema=_value(getattr(args, "schema", None), values, "SNOWFLAKE_SCHEMA"),
|
|
73
|
+
warehouse=_value(getattr(args, "warehouse", None), values, "SNOWFLAKE_WAREHOUSE"),
|
|
74
|
+
warehouse_explicit=getattr(args, "warehouse", None) is not None,
|
|
75
|
+
artifact_dir=(
|
|
76
|
+
artifact_dir.resolve()
|
|
77
|
+
if artifact_dir.is_absolute()
|
|
78
|
+
else (project_dir / artifact_dir).resolve()
|
|
79
|
+
),
|
|
80
|
+
dbt_executable=_value(
|
|
81
|
+
getattr(args, "dbt_executable", None), values, "DBT_EXECUTABLE"
|
|
82
|
+
)
|
|
83
|
+
or "dbt",
|
|
84
|
+
snow_executable=_value(
|
|
85
|
+
getattr(args, "snow_executable", None), values, "SNOW_EXECUTABLE"
|
|
86
|
+
)
|
|
87
|
+
or "snow",
|
|
88
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
RunCallable = Callable[..., subprocess.CompletedProcess[str]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CommandRunner:
|
|
16
|
+
run_callable: RunCallable = subprocess.run
|
|
17
|
+
|
|
18
|
+
def run(
|
|
19
|
+
self,
|
|
20
|
+
command: Sequence[str],
|
|
21
|
+
*,
|
|
22
|
+
cwd: str | Path | None = None,
|
|
23
|
+
env: Mapping[str, str] | None = None,
|
|
24
|
+
) -> subprocess.CompletedProcess[str]:
|
|
25
|
+
kwargs = {"cwd": cwd, "text": True, "capture_output": True, "check": False}
|
|
26
|
+
if env is not None:
|
|
27
|
+
kwargs["env"] = dict(env)
|
|
28
|
+
return self.run_callable(list(command), **kwargs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def executable_version(executable: str, runner: CommandRunner) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return runner.run([executable, "--version"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(
|
|
36
|
+
runner: CommandRunner,
|
|
37
|
+
command: Sequence[str],
|
|
38
|
+
*,
|
|
39
|
+
cwd: str | Path,
|
|
40
|
+
env: Mapping[str, str] | None,
|
|
41
|
+
) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
if env is None:
|
|
43
|
+
return runner.run(command, cwd=cwd)
|
|
44
|
+
return runner.run(command, cwd=cwd, env=env)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_dbt_deps(
|
|
48
|
+
executable: str,
|
|
49
|
+
project_dir: str | Path,
|
|
50
|
+
target: str | None,
|
|
51
|
+
runner: CommandRunner,
|
|
52
|
+
env: Mapping[str, str] | None = None,
|
|
53
|
+
) -> subprocess.CompletedProcess[str]:
|
|
54
|
+
command = [executable, "deps", "--project-dir", str(project_dir)]
|
|
55
|
+
if target:
|
|
56
|
+
command.extend(["--target", target])
|
|
57
|
+
return _run(runner, command, cwd=project_dir, env=env)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_dbt_parse(
|
|
61
|
+
executable: str,
|
|
62
|
+
project_dir: str | Path,
|
|
63
|
+
target: str | None,
|
|
64
|
+
runner: CommandRunner,
|
|
65
|
+
env: Mapping[str, str] | None = None,
|
|
66
|
+
) -> subprocess.CompletedProcess[str]:
|
|
67
|
+
command = [executable, "parse", "--project-dir", str(project_dir)]
|
|
68
|
+
if target:
|
|
69
|
+
command.extend(["--target", target])
|
|
70
|
+
return _run(runner, command, cwd=project_dir, env=env)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_dbt_operation(
|
|
74
|
+
executable: str,
|
|
75
|
+
project_dir: str | Path,
|
|
76
|
+
target: str | None,
|
|
77
|
+
macro: str,
|
|
78
|
+
arguments: dict[str, object],
|
|
79
|
+
runner: CommandRunner,
|
|
80
|
+
env: Mapping[str, str] | None = None,
|
|
81
|
+
) -> subprocess.CompletedProcess[str]:
|
|
82
|
+
command = [
|
|
83
|
+
executable,
|
|
84
|
+
"run-operation",
|
|
85
|
+
macro,
|
|
86
|
+
"--args",
|
|
87
|
+
json.dumps(arguments, separators=(",", ":")),
|
|
88
|
+
"--project-dir",
|
|
89
|
+
str(project_dir),
|
|
90
|
+
]
|
|
91
|
+
if target:
|
|
92
|
+
command.extend(["--target", target])
|
|
93
|
+
return _run(runner, command, cwd=project_dir, env=env)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import Config, load_yaml_mapping
|
|
11
|
+
from .dbt_runner import CommandRunner, executable_version
|
|
12
|
+
from .manifest import cortex_agents, cortex_evals, load_manifest, skill_declarations
|
|
13
|
+
from .snow import connection_test
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_FULL_GIT_SHA = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Diagnostic:
|
|
21
|
+
name: str
|
|
22
|
+
status: str
|
|
23
|
+
detail: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _declared_revisions(project_dir: Path) -> list[str]:
|
|
27
|
+
revisions: list[str] = []
|
|
28
|
+
for filename in ("packages.yml", "package-lock.yml"):
|
|
29
|
+
document = load_yaml_mapping(project_dir / filename, strict=False)
|
|
30
|
+
for item in document.get("packages", []) or []:
|
|
31
|
+
if not isinstance(item, dict):
|
|
32
|
+
continue
|
|
33
|
+
source = str(item.get("git") or item.get("package") or "")
|
|
34
|
+
if "dbt-cortex-agent" in source or "dbt_cortex_agent" in source:
|
|
35
|
+
revision = item.get("revision") or item.get("version")
|
|
36
|
+
if revision:
|
|
37
|
+
revisions.append(str(revision).removeprefix("v"))
|
|
38
|
+
return revisions
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dbt_package_versions(project_dir: Path) -> list[str]:
|
|
42
|
+
versions: list[str] = []
|
|
43
|
+
candidates = (project_dir / "dbt_packages" / "dbt_cortex_agent" / "dbt_project.yml",)
|
|
44
|
+
for candidate in candidates:
|
|
45
|
+
project = load_yaml_mapping(candidate, strict=False)
|
|
46
|
+
if project.get("name") == "dbt_cortex_agent" and project.get("version"):
|
|
47
|
+
versions.append(str(project["version"]).removeprefix("v"))
|
|
48
|
+
return versions
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_doctor(config: Config, runner: CommandRunner | None = None) -> list[Diagnostic]:
|
|
52
|
+
command_runner = runner or CommandRunner()
|
|
53
|
+
diagnostics: list[Diagnostic] = []
|
|
54
|
+
for name, executable in (("dbt executable", config.dbt_executable), ("snow executable", config.snow_executable)):
|
|
55
|
+
try:
|
|
56
|
+
result = executable_version(executable, command_runner)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
diagnostics.append(Diagnostic(name, "FAIL", str(exc)))
|
|
59
|
+
continue
|
|
60
|
+
output = result.stdout.strip() or result.stderr.strip()
|
|
61
|
+
diagnostics.append(
|
|
62
|
+
Diagnostic(name, "PASS" if result.returncode == 0 else "FAIL", output)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
dbt_package_versions = _dbt_package_versions(config.project_dir)
|
|
66
|
+
if dbt_package_versions:
|
|
67
|
+
mismatches = sorted({version for version in dbt_package_versions if version != __version__})
|
|
68
|
+
diagnostics.append(
|
|
69
|
+
Diagnostic(
|
|
70
|
+
"installed package version",
|
|
71
|
+
"FAIL" if mismatches else "PASS",
|
|
72
|
+
f"CLI={__version__}; dbt={', '.join(sorted(set(dbt_package_versions)))}",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
manifest: dict[str, Any] | None = None
|
|
77
|
+
try:
|
|
78
|
+
manifest = load_manifest(config.manifest)
|
|
79
|
+
diagnostics.append(Diagnostic("manifest v12", "PASS", str(config.manifest)))
|
|
80
|
+
except (FileNotFoundError, ValueError, OSError) as exc:
|
|
81
|
+
diagnostics.append(Diagnostic("manifest v12", "FAIL", str(exc)))
|
|
82
|
+
|
|
83
|
+
revisions = _declared_revisions(config.project_dir)
|
|
84
|
+
if revisions:
|
|
85
|
+
mismatches = sorted(
|
|
86
|
+
{
|
|
87
|
+
version
|
|
88
|
+
for version in revisions
|
|
89
|
+
if version != __version__
|
|
90
|
+
and not (_FULL_GIT_SHA.fullmatch(version) and __version__ in dbt_package_versions)
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
diagnostics.append(
|
|
94
|
+
Diagnostic(
|
|
95
|
+
"consumer package version",
|
|
96
|
+
"FAIL" if mismatches else "PASS",
|
|
97
|
+
f"CLI={__version__}; declared={', '.join(sorted(set(revisions)))}",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
diagnostics.append(
|
|
102
|
+
Diagnostic("consumer package version", "WARN", "no package version metadata found")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if manifest:
|
|
106
|
+
agents = cortex_agents(manifest)
|
|
107
|
+
evals = cortex_evals(manifest)
|
|
108
|
+
diagnostics.append(
|
|
109
|
+
Diagnostic(
|
|
110
|
+
"enabled Agents",
|
|
111
|
+
"PASS",
|
|
112
|
+
", ".join(item["physical_fqn"] for item in agents) or "none",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
diagnostics.append(
|
|
116
|
+
Diagnostic("enabled evals", "PASS", ", ".join(item.model_name for item in evals) or "none")
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
skills = skill_declarations(manifest, config.project_dir)
|
|
120
|
+
missing = [str(item.local_dir) for item in skills if not item.local_dir.is_dir()]
|
|
121
|
+
diagnostics.append(
|
|
122
|
+
Diagnostic(
|
|
123
|
+
"configured local skills",
|
|
124
|
+
"FAIL" if missing else "PASS",
|
|
125
|
+
"missing: " + ", ".join(missing) if missing else f"{len(skills)} found",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
diagnostics.append(Diagnostic("configured local skills", "FAIL", str(exc)))
|
|
130
|
+
|
|
131
|
+
project = load_yaml_mapping(config.project_dir / "dbt_project.yml", strict=False)
|
|
132
|
+
variables = project.get("vars") if isinstance(project.get("vars"), dict) else {}
|
|
133
|
+
legacy_target = variables.get("cortex_agent_deploy_target")
|
|
134
|
+
allowed_targets = variables.get("cortex_agent_allowed_targets") or (
|
|
135
|
+
[legacy_target] if legacy_target else []
|
|
136
|
+
)
|
|
137
|
+
allowed_databases = variables.get("cortex_agent_allowed_databases") or []
|
|
138
|
+
resolved_databases = sorted(
|
|
139
|
+
{item["database"] for item in cortex_agents(manifest)} if manifest else set()
|
|
140
|
+
)
|
|
141
|
+
if not allowed_targets or not allowed_databases:
|
|
142
|
+
diagnostics.append(
|
|
143
|
+
Diagnostic(
|
|
144
|
+
"deployment safety",
|
|
145
|
+
"FAIL",
|
|
146
|
+
"cortex_agent_allowed_targets and cortex_agent_allowed_databases must be non-empty; "
|
|
147
|
+
"configure them explicitly or preview init with --target and --allow-database",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
elif not config.target:
|
|
151
|
+
diagnostics.append(
|
|
152
|
+
Diagnostic(
|
|
153
|
+
"deployment safety",
|
|
154
|
+
"WARN",
|
|
155
|
+
f"allowed targets={allowed_targets}; allowed databases={allowed_databases}; "
|
|
156
|
+
"active target unknown, supply --target to check it",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
elif config.target not in allowed_targets:
|
|
160
|
+
diagnostics.append(
|
|
161
|
+
Diagnostic(
|
|
162
|
+
"deployment safety",
|
|
163
|
+
"PASS",
|
|
164
|
+
f"active target {config.target!r} cannot mutate allowlisted targets {allowed_targets}",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
elif missing := sorted(
|
|
168
|
+
set(resolved_databases) - {str(item).upper() for item in allowed_databases}
|
|
169
|
+
):
|
|
170
|
+
diagnostics.append(
|
|
171
|
+
Diagnostic(
|
|
172
|
+
"deployment safety",
|
|
173
|
+
"FAIL",
|
|
174
|
+
f"Agent databases missing from allowlist: {', '.join(missing)}",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
diagnostics.append(
|
|
179
|
+
Diagnostic(
|
|
180
|
+
"deployment safety",
|
|
181
|
+
"PASS",
|
|
182
|
+
f"active target is allowed; Agent databases={resolved_databases}; "
|
|
183
|
+
f"allowed databases={allowed_databases}",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if config.connection_explicit and config.connection:
|
|
188
|
+
try:
|
|
189
|
+
result = connection_test(config.snow_executable, config.connection, command_runner)
|
|
190
|
+
output = result.stdout.strip() or result.stderr.strip()
|
|
191
|
+
diagnostics.append(
|
|
192
|
+
Diagnostic("Snowflake connection", "PASS" if result.returncode == 0 else "FAIL", output)
|
|
193
|
+
)
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
diagnostics.append(Diagnostic("Snowflake connection", "FAIL", str(exc)))
|
|
196
|
+
else:
|
|
197
|
+
diagnostics.append(
|
|
198
|
+
Diagnostic("Snowflake connection", "SKIP", "no explicit --connection supplied")
|
|
199
|
+
)
|
|
200
|
+
return diagnostics
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def diagnostics_json(diagnostics: list[Diagnostic]) -> str:
|
|
204
|
+
return json.dumps([item.__dict__ for item in diagnostics], indent=2)
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..artifacts import ARTIFACT_SCHEMA_VERSION, contained_path
|
|
8
|
+
from .results import _identity_components, validate_result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_baseline(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
12
|
+
validate_result(candidate, "candidate")
|
|
13
|
+
if candidate.get("passed") is not True:
|
|
14
|
+
raise ValueError("Failed evaluation candidates cannot become baselines")
|
|
15
|
+
metadata = dict(candidate.get("run_metadata") or {})
|
|
16
|
+
metadata.pop("git_sha", None)
|
|
17
|
+
keys = (
|
|
18
|
+
"agent", "suite", "eval_model", "run_name", "timestamp", "summary", "thresholds",
|
|
19
|
+
"regression_tolerances", "passed", "total_records", "plan_schema_version",
|
|
20
|
+
"suite_signature", "plan_identity", "agent_fqn", "dataset_fqn", "stage_fqn",
|
|
21
|
+
"metric_names", "ordered_ground_truth_refs", "status",
|
|
22
|
+
)
|
|
23
|
+
baseline = {key: candidate.get(key) for key in keys}
|
|
24
|
+
baseline["schema_version"] = ARTIFACT_SCHEMA_VERSION
|
|
25
|
+
baseline["artifact_type"] = "baseline"
|
|
26
|
+
baseline["run_metadata"] = metadata
|
|
27
|
+
validate_result(baseline, "baseline")
|
|
28
|
+
return baseline
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def accept_baseline(
|
|
32
|
+
candidate: dict[str, Any], baseline_dir: str | Path, *, force: bool = False
|
|
33
|
+
) -> Path:
|
|
34
|
+
baseline = build_baseline(candidate)
|
|
35
|
+
target_name, database, schema, agent_object = _identity_components(baseline)
|
|
36
|
+
target = contained_path(
|
|
37
|
+
baseline_dir, target_name, database, schema, agent_object,
|
|
38
|
+
f"{baseline['suite']}.json",
|
|
39
|
+
)
|
|
40
|
+
if target.exists() and not force:
|
|
41
|
+
raise FileExistsError(f"Baseline already exists: {target}; use --force after recording the ratchet decision")
|
|
42
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
target.write_text(json.dumps(baseline, indent=2, default=str) + "\n", encoding="utf-8")
|
|
44
|
+
return target
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .results import threshold_failures, validate_result
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def metric_averages(result: dict[str, Any]) -> dict[str, float]:
|
|
9
|
+
return {
|
|
10
|
+
name: float(stats["avg"])
|
|
11
|
+
for name, stats in (result.get("summary") or {}).items()
|
|
12
|
+
if isinstance(stats, dict) and stats.get("avg") is not None
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def suite_change(baseline: dict[str, Any], candidate: dict[str, Any]) -> str | None:
|
|
17
|
+
if baseline.get("ordered_ground_truth_refs") != candidate.get("ordered_ground_truth_refs"):
|
|
18
|
+
return "ordered ground-truth refs changed"
|
|
19
|
+
if baseline.get("metric_names") != candidate.get("metric_names"):
|
|
20
|
+
return "metric contract changed"
|
|
21
|
+
if baseline.get("thresholds") != candidate.get("thresholds"):
|
|
22
|
+
return "threshold policy changed"
|
|
23
|
+
if baseline.get("regression_tolerances") != candidate.get("regression_tolerances"):
|
|
24
|
+
return "regression tolerance policy changed"
|
|
25
|
+
if baseline.get("suite_signature") != candidate.get("suite_signature"):
|
|
26
|
+
return "suite signature changed"
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compare_results(
|
|
31
|
+
baseline: dict[str, Any], candidate: dict[str, Any], default_tolerance: float = 0.01
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
validate_result(baseline, "baseline")
|
|
34
|
+
validate_result(candidate, "candidate")
|
|
35
|
+
reason = suite_change(baseline, candidate)
|
|
36
|
+
before, after = metric_averages(baseline), metric_averages(candidate)
|
|
37
|
+
tolerances = {
|
|
38
|
+
name: float(value)
|
|
39
|
+
for name, value in (baseline.get("regression_tolerances") or {}).items()
|
|
40
|
+
}
|
|
41
|
+
gated = set(baseline.get("thresholds") or {}) | set(tolerances)
|
|
42
|
+
rows: list[dict[str, Any]] = []
|
|
43
|
+
regressions: list[str] = []
|
|
44
|
+
for metric in sorted(set(before) | set(after)):
|
|
45
|
+
old, new = before.get(metric), after.get(metric)
|
|
46
|
+
tolerance = tolerances.get(metric, default_tolerance)
|
|
47
|
+
delta = None if old is None or new is None else new - old
|
|
48
|
+
status = "new" if old is None else "missing" if new is None else "flat"
|
|
49
|
+
if old is not None and new is None and metric in gated:
|
|
50
|
+
status = "regressed"
|
|
51
|
+
elif delta is not None and delta < -tolerance:
|
|
52
|
+
status = "regressed" if metric in gated else "advisory"
|
|
53
|
+
elif delta is not None and delta > tolerance:
|
|
54
|
+
status = "improved"
|
|
55
|
+
if status == "regressed":
|
|
56
|
+
regressions.append(metric)
|
|
57
|
+
rows.append({
|
|
58
|
+
"metric": metric, "baseline": old, "candidate": new, "delta": delta,
|
|
59
|
+
"tolerance": tolerance, "status": status,
|
|
60
|
+
})
|
|
61
|
+
failures = threshold_failures(candidate.get("summary") or {}, candidate.get("thresholds") or {})
|
|
62
|
+
return {
|
|
63
|
+
"passed": reason is None and not regressions and not failures,
|
|
64
|
+
"suite_change": reason,
|
|
65
|
+
"regressions": regressions,
|
|
66
|
+
"threshold_failures": failures,
|
|
67
|
+
"metrics": rows,
|
|
68
|
+
}
|