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.
Files changed (40) hide show
  1. dbt_cortex_agent/__init__.py +5 -0
  2. dbt_cortex_agent/artifacts.py +26 -0
  3. dbt_cortex_agent/cli.py +90 -0
  4. dbt_cortex_agent/commands/__init__.py +1 -0
  5. dbt_cortex_agent/commands/agent.py +115 -0
  6. dbt_cortex_agent/commands/bootstrap.py +92 -0
  7. dbt_cortex_agent/commands/common.py +62 -0
  8. dbt_cortex_agent/commands/eval.py +143 -0
  9. dbt_cortex_agent/commands/manifest.py +48 -0
  10. dbt_cortex_agent/commands/skill.py +129 -0
  11. dbt_cortex_agent/config.py +88 -0
  12. dbt_cortex_agent/dbt_runner.py +93 -0
  13. dbt_cortex_agent/doctor.py +204 -0
  14. dbt_cortex_agent/eval/__init__.py +0 -0
  15. dbt_cortex_agent/eval/baseline.py +44 -0
  16. dbt_cortex_agent/eval/compare.py +68 -0
  17. dbt_cortex_agent/eval/dataset.py +128 -0
  18. dbt_cortex_agent/eval/gate.py +31 -0
  19. dbt_cortex_agent/eval/lifecycle.py +567 -0
  20. dbt_cortex_agent/eval/results.py +195 -0
  21. dbt_cortex_agent/execution_context.py +136 -0
  22. dbt_cortex_agent/identifiers.py +46 -0
  23. dbt_cortex_agent/init.py +322 -0
  24. dbt_cortex_agent/invoke.py +140 -0
  25. dbt_cortex_agent/manifest.py +285 -0
  26. dbt_cortex_agent/skills.py +181 -0
  27. dbt_cortex_agent/snow.py +11 -0
  28. dbt_cortex_agent/starters/orders/models/agents/orders_assistant/agent.yml +7 -0
  29. dbt_cortex_agent/starters/orders/models/agents/orders_assistant/evals/core.yml +54 -0
  30. dbt_cortex_agent/starters/orders/models/agents/orders_assistant/evals/orders_assistant_core.sql +50 -0
  31. dbt_cortex_agent/starters/orders/models/agents/orders_assistant/orders_assistant.sql +54 -0
  32. dbt_cortex_agent/starters/orders/models/semantic/sem_orders.sql +40 -0
  33. dbt_cortex_agent/starters/orders/seeds/orders.csv +4 -0
  34. dbt_cortex_agent/starters/orders/seeds/orders.yml +19 -0
  35. dbt_cortex_agent-0.0.3.dist-info/METADATA +196 -0
  36. dbt_cortex_agent-0.0.3.dist-info/RECORD +40 -0
  37. dbt_cortex_agent-0.0.3.dist-info/WHEEL +5 -0
  38. dbt_cortex_agent-0.0.3.dist-info/entry_points.txt +2 -0
  39. dbt_cortex_agent-0.0.3.dist-info/licenses/LICENSE +201 -0
  40. 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
+ }