cassis-cli 0.3.0__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/PKG-INFO +33 -6
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/README.md +32 -5
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/cassis_cli/__init__.py +1 -1
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/cassis_cli/api.py +213 -16
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/cassis_cli/eval.py +76 -0
- cassis_cli-1.0.0/cassis_cli/guide.py +97 -0
- cassis_cli-1.0.0/cassis_cli/ontology.py +566 -0
- cassis_cli-1.0.0/cassis_cli/ontology_design_guide.md +405 -0
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/pyproject.toml +4 -1
- cassis_cli-0.3.0/cassis_cli/ontology.py +0 -283
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/cassis_cli/common.py +0 -0
- {cassis_cli-0.3.0 → cassis_cli-1.0.0}/cassis_cli/main.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Keywords: cassis,ontology,ci,text-to-sql
|
|
@@ -23,9 +23,14 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
Run Cassis actions from your CI pipelines:
|
|
24
24
|
|
|
25
25
|
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub.
|
|
26
|
+
- `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
|
|
26
27
|
- `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
|
|
27
28
|
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
29
|
+
- `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (the CLI reads only `*.yml`/`*.yaml`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
|
|
30
|
+
- The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
|
|
28
31
|
- `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
|
|
32
|
+
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
33
|
+
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
29
34
|
|
|
30
35
|
## Install
|
|
31
36
|
|
|
@@ -76,6 +81,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
76
81
|
|
|
77
82
|
# Start the run and return immediately (poll in the webapp):
|
|
78
83
|
cassis eval run --project ... --no-wait
|
|
84
|
+
|
|
85
|
+
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
86
|
+
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
87
|
+
cassis ontology test --project ... -q "How much was refunded last month?" -q "Net revenue in Q1?"
|
|
88
|
+
|
|
89
|
+
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
90
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
91
|
+
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
79
92
|
```
|
|
80
93
|
|
|
81
94
|
Configuration (flags take precedence over env vars):
|
|
@@ -85,7 +98,7 @@ Configuration (flags take precedence over env vars):
|
|
|
85
98
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
86
99
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
87
100
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
88
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
101
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
89
102
|
|
|
90
103
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
91
104
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -95,16 +108,30 @@ and Ctrl-C cancels the run (exit 130). It prints a deep link to the run's page
|
|
|
95
108
|
in the Evals UI; `--app-url` / `CASSIS_APP_URL` overrides the link's base URL
|
|
96
109
|
when the webapp is not served from the API host (defaults to `--api-url`).
|
|
97
110
|
|
|
111
|
+
### Formatting
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Rewrite the ontology files in canonical form (in place)
|
|
115
|
+
cassis ontology fmt
|
|
116
|
+
|
|
117
|
+
# CI mode: fail (exit 1) if any file is not canonical, write nothing
|
|
118
|
+
cassis ontology fmt --check
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`fmt` uses the exact serializer the validation round-trip compares against, so a formatted tree cannot fail that stage. Formatting does not run import validation — `check` remains the pass/fail gate for semantic problems (dangling references, incomplete metrics).
|
|
122
|
+
|
|
123
|
+
**Review the diff before committing**: canonical form keeps exactly the fields Cassis understands. Unknown fields (typos) are dropped — the rewrite makes them visible in `git diff` instead of losing them silently at sync time. Files with duplicate YAML keys are rejected (fix them by hand: the formatter can't know which value you meant).
|
|
124
|
+
|
|
98
125
|
### Exit codes
|
|
99
126
|
|
|
100
127
|
| Code | Meaning |
|
|
101
128
|
| ---- | ------------------------------------------------------------------------------ |
|
|
102
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
103
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
129
|
+
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
130
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
|
|
104
131
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
105
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
132
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
106
133
|
|
|
107
|
-
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
134
|
+
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
108
135
|
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
109
136
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
110
137
|
double-check `--base-path` if you hit it.
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
Run Cassis actions from your CI pipelines:
|
|
4
4
|
|
|
5
5
|
- `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub.
|
|
6
|
+
- `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
|
|
6
7
|
- `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
|
|
7
8
|
- `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
|
|
9
|
+
- `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (the CLI reads only `*.yml`/`*.yaml`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
|
|
10
|
+
- The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
|
|
8
11
|
- `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
|
|
12
|
+
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
13
|
+
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
9
14
|
|
|
10
15
|
## Install
|
|
11
16
|
|
|
@@ -56,6 +61,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
56
61
|
|
|
57
62
|
# Start the run and return immediately (poll in the webapp):
|
|
58
63
|
cassis eval run --project ... --no-wait
|
|
64
|
+
|
|
65
|
+
# Probe questions through the text-to-SQL agent using the local ontology files
|
|
66
|
+
# (one full agent run per question, expect ~30-90s each; repeat -q for several):
|
|
67
|
+
cassis ontology test --project ... -q "How much was refunded last month?" -q "Net revenue in Q1?"
|
|
68
|
+
|
|
69
|
+
# Add a gold case to the eval suite (rejected if the exact question already exists):
|
|
70
|
+
cassis eval add-case --project ... -q "How much was refunded last month?" \
|
|
71
|
+
--gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
|
|
59
72
|
```
|
|
60
73
|
|
|
61
74
|
Configuration (flags take precedence over env vars):
|
|
@@ -65,7 +78,7 @@ Configuration (flags take precedence over env vars):
|
|
|
65
78
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
66
79
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
67
80
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
68
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
81
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
69
82
|
|
|
70
83
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
71
84
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -75,16 +88,30 @@ and Ctrl-C cancels the run (exit 130). It prints a deep link to the run's page
|
|
|
75
88
|
in the Evals UI; `--app-url` / `CASSIS_APP_URL` overrides the link's base URL
|
|
76
89
|
when the webapp is not served from the API host (defaults to `--api-url`).
|
|
77
90
|
|
|
91
|
+
### Formatting
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Rewrite the ontology files in canonical form (in place)
|
|
95
|
+
cassis ontology fmt
|
|
96
|
+
|
|
97
|
+
# CI mode: fail (exit 1) if any file is not canonical, write nothing
|
|
98
|
+
cassis ontology fmt --check
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`fmt` uses the exact serializer the validation round-trip compares against, so a formatted tree cannot fail that stage. Formatting does not run import validation — `check` remains the pass/fail gate for semantic problems (dangling references, incomplete metrics).
|
|
102
|
+
|
|
103
|
+
**Review the diff before committing**: canonical form keeps exactly the fields Cassis understands. Unknown fields (typos) are dropped — the rewrite makes them visible in `git diff` instead of losing them silently at sync time. Files with duplicate YAML keys are rejected (fix them by hand: the formatter can't know which value you meant).
|
|
104
|
+
|
|
78
105
|
### Exit codes
|
|
79
106
|
|
|
80
107
|
| Code | Meaning |
|
|
81
108
|
| ---- | ------------------------------------------------------------------------------ |
|
|
82
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
83
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run) |
|
|
109
|
+
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
110
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
|
|
84
111
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
85
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, or `--timeout` reached |
|
|
112
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
86
113
|
|
|
87
|
-
Commands that send the local tree (`check`, `upload`, `eval run`) accept up to
|
|
114
|
+
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
88
115
|
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
89
116
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
90
117
|
double-check `--base-path` if you hit it.
|
|
@@ -2,13 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from typing import Any, Optional
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
9
|
+
from cassis_cli import __version__
|
|
8
10
|
|
|
9
11
|
DEFAULT_API_URL = "https://app.getcassis.com"
|
|
10
12
|
TIMEOUT_SECONDS = 60.0
|
|
11
13
|
|
|
14
|
+
USER_AGENT = f"cassis-cli/{__version__}"
|
|
15
|
+
|
|
16
|
+
# Response header the CI endpoints set to the newest cassis-cli on PyPI.
|
|
17
|
+
LATEST_VERSION_HEADER = "x-cassis-cli-latest"
|
|
18
|
+
|
|
19
|
+
_upgrade_notice_shown = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _version_tuple(version: str) -> Optional[tuple[int, ...]]:
|
|
23
|
+
try:
|
|
24
|
+
return tuple(int(part) for part in version.strip().split("."))
|
|
25
|
+
except ValueError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _maybe_print_upgrade_notice(response: httpx.Response) -> None:
|
|
30
|
+
"""Print a one-time stderr notice when the server advertises a newer CLI.
|
|
31
|
+
|
|
32
|
+
Purely informational — never changes behavior or exit codes. Staying
|
|
33
|
+
current matters beyond bugfixes: the ontology modeling guide written to
|
|
34
|
+
``cassis/AGENTS.md`` ships inside this package, so an old CLI keeps old
|
|
35
|
+
doctrine in the repo.
|
|
36
|
+
"""
|
|
37
|
+
global _upgrade_notice_shown
|
|
38
|
+
if _upgrade_notice_shown:
|
|
39
|
+
return
|
|
40
|
+
latest = response.headers.get(LATEST_VERSION_HEADER)
|
|
41
|
+
if not latest:
|
|
42
|
+
return
|
|
43
|
+
mine, theirs = _version_tuple(__version__), _version_tuple(latest)
|
|
44
|
+
if mine is None or theirs is None:
|
|
45
|
+
return
|
|
46
|
+
# Zero-pad to equal length so "0.6" == "0.6.0" (same rule as the webapp's
|
|
47
|
+
# Agent setup page — the comparison logic exists on both surfaces).
|
|
48
|
+
width = max(len(mine), len(theirs))
|
|
49
|
+
if theirs + (0,) * (width - len(theirs)) <= mine + (0,) * (width - len(mine)):
|
|
50
|
+
return
|
|
51
|
+
_upgrade_notice_shown = True
|
|
52
|
+
print(
|
|
53
|
+
f"notice: cassis-cli {latest} is available (you have {__version__}) — "
|
|
54
|
+
"run `pip install -U cassis-cli`, then `cassis ontology fmt` to refresh cassis/AGENTS.md.",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _client(*, timeout: float = TIMEOUT_SECONDS, transport: Optional[httpx.BaseTransport] = None) -> httpx.Client:
|
|
60
|
+
"""Build the HTTP client every API call goes through.
|
|
61
|
+
|
|
62
|
+
Identifies the CLI to the server (User-Agent) and watches responses for
|
|
63
|
+
the newer-version advertisement.
|
|
64
|
+
"""
|
|
65
|
+
return httpx.Client(
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
transport=transport,
|
|
68
|
+
headers={"User-Agent": USER_AGENT},
|
|
69
|
+
event_hooks={"response": [_maybe_print_upgrade_notice]},
|
|
70
|
+
)
|
|
71
|
+
|
|
12
72
|
|
|
13
73
|
class ApiError(Exception):
|
|
14
74
|
"""Transport or HTTP-level failure talking to the Cassis API."""
|
|
@@ -52,6 +112,14 @@ def _project_scope_error(response: httpx.Response) -> ApiError:
|
|
|
52
112
|
)
|
|
53
113
|
|
|
54
114
|
|
|
115
|
+
def _detail_or_text(response: httpx.Response) -> Any:
|
|
116
|
+
"""Return the error response's ``detail`` (str or structured), falling back to its body."""
|
|
117
|
+
try:
|
|
118
|
+
return response.json().get("detail") or response.text[:500]
|
|
119
|
+
except ValueError:
|
|
120
|
+
return response.text[:500]
|
|
121
|
+
|
|
122
|
+
|
|
55
123
|
def _parse_json_response(response: httpx.Response, url: str) -> Any:
|
|
56
124
|
try:
|
|
57
125
|
return response.json()
|
|
@@ -71,7 +139,7 @@ def post_ontology_check(
|
|
|
71
139
|
"""POST the ontology tree to /api/ci/ontology-check and return the response body."""
|
|
72
140
|
url = api_url.rstrip("/") + "/api/ci/ontology-check"
|
|
73
141
|
try:
|
|
74
|
-
with
|
|
142
|
+
with _client(transport=transport) as client:
|
|
75
143
|
response = client.post(
|
|
76
144
|
url,
|
|
77
145
|
json={"files": files},
|
|
@@ -110,7 +178,7 @@ def post_ontology_import(
|
|
|
110
178
|
if label is not None:
|
|
111
179
|
body["label"] = label
|
|
112
180
|
try:
|
|
113
|
-
with
|
|
181
|
+
with _client(transport=transport) as client:
|
|
114
182
|
response = client.post(
|
|
115
183
|
url,
|
|
116
184
|
json=body,
|
|
@@ -122,11 +190,7 @@ def post_ontology_import(
|
|
|
122
190
|
if response.status_code == 401:
|
|
123
191
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
124
192
|
if response.status_code == 400:
|
|
125
|
-
|
|
126
|
-
detail = response.json().get("detail") or response.text[:500]
|
|
127
|
-
except ValueError:
|
|
128
|
-
detail = response.text[:500]
|
|
129
|
-
raise UploadValidationError(str(detail))
|
|
193
|
+
raise UploadValidationError(str(_detail_or_text(response)))
|
|
130
194
|
if response.status_code in (403, 404):
|
|
131
195
|
raise _project_scope_error(response)
|
|
132
196
|
if response.status_code >= 400:
|
|
@@ -149,7 +213,7 @@ def get_ontology_export(
|
|
|
149
213
|
"""GET /api/ci/projects/{project_id}/ontology/export and return the files tree."""
|
|
150
214
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/export"
|
|
151
215
|
try:
|
|
152
|
-
with
|
|
216
|
+
with _client(transport=transport) as client:
|
|
153
217
|
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
154
218
|
except httpx.HTTPError as exc:
|
|
155
219
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -186,7 +250,7 @@ def post_eval_run_start(
|
|
|
186
250
|
if label is not None:
|
|
187
251
|
body["label"] = label
|
|
188
252
|
try:
|
|
189
|
-
with
|
|
253
|
+
with _client(transport=transport) as client:
|
|
190
254
|
response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
|
|
191
255
|
except httpx.HTTPError as exc:
|
|
192
256
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -194,11 +258,7 @@ def post_eval_run_start(
|
|
|
194
258
|
if response.status_code == 401:
|
|
195
259
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
196
260
|
if response.status_code == 400:
|
|
197
|
-
|
|
198
|
-
detail = response.json().get("detail") or response.text[:500]
|
|
199
|
-
except ValueError:
|
|
200
|
-
detail = response.text[:500]
|
|
201
|
-
raise EvalStartValidationError(detail)
|
|
261
|
+
raise EvalStartValidationError(_detail_or_text(response))
|
|
202
262
|
if response.status_code == 409:
|
|
203
263
|
raise EvalRunActiveError(
|
|
204
264
|
"An eval run is already active for this project — wait for it to finish or cancel it "
|
|
@@ -214,10 +274,55 @@ def post_eval_run_start(
|
|
|
214
274
|
return result
|
|
215
275
|
|
|
216
276
|
|
|
277
|
+
class EvalCaseExistsError(ApiError):
|
|
278
|
+
"""The project already has an eval case with this exact question."""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class EvalCaseGoldSqlError(ApiError):
|
|
282
|
+
"""The API rejected the gold SQL (400): it does not run against the project's data source."""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def post_eval_case_create(
|
|
286
|
+
*,
|
|
287
|
+
api_url: str,
|
|
288
|
+
api_key: str,
|
|
289
|
+
project_id: str,
|
|
290
|
+
question: str,
|
|
291
|
+
gold_sql: str,
|
|
292
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""POST to /api/ci/projects/{project_id}/eval/cases and return the created case."""
|
|
295
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases"
|
|
296
|
+
try:
|
|
297
|
+
with _client(transport=transport) as client:
|
|
298
|
+
response = client.post(
|
|
299
|
+
url,
|
|
300
|
+
json={"question": question, "gold_sql": gold_sql},
|
|
301
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
302
|
+
)
|
|
303
|
+
except httpx.HTTPError as exc:
|
|
304
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
305
|
+
|
|
306
|
+
if response.status_code == 401:
|
|
307
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
308
|
+
if response.status_code == 409:
|
|
309
|
+
raise EvalCaseExistsError(str(_detail_or_text(response)))
|
|
310
|
+
if response.status_code == 400:
|
|
311
|
+
raise EvalCaseGoldSqlError(str(_detail_or_text(response)))
|
|
312
|
+
if response.status_code in (403, 404):
|
|
313
|
+
raise _project_scope_error(response)
|
|
314
|
+
if response.status_code >= 400:
|
|
315
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
316
|
+
result = _parse_json_response(response, url)
|
|
317
|
+
if not isinstance(result, dict) or not all(key in result for key in ("id", "question", "gold_sql")):
|
|
318
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
217
322
|
def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
|
|
218
323
|
"""GET an eval-run URL with the shared error mapping."""
|
|
219
324
|
try:
|
|
220
|
-
with
|
|
325
|
+
with _client(transport=transport) as client:
|
|
221
326
|
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
222
327
|
except httpx.HTTPError as exc:
|
|
223
328
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -274,7 +379,7 @@ def post_eval_run_cancel(
|
|
|
274
379
|
"""POST /api/ci/projects/{project_id}/eval/runs/{run_id}/cancel."""
|
|
275
380
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}/cancel"
|
|
276
381
|
try:
|
|
277
|
-
with
|
|
382
|
+
with _client(transport=transport) as client:
|
|
278
383
|
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
279
384
|
except httpx.HTTPError as exc:
|
|
280
385
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -284,3 +389,95 @@ def post_eval_run_cancel(
|
|
|
284
389
|
raise _project_scope_error(response)
|
|
285
390
|
if response.status_code >= 400:
|
|
286
391
|
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def post_ontology_fmt(
|
|
395
|
+
*,
|
|
396
|
+
api_url: str,
|
|
397
|
+
api_key: str,
|
|
398
|
+
files: dict[str, str],
|
|
399
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""POST the ontology tree to /api/ci/ontology-fmt and return the response body."""
|
|
402
|
+
url = api_url.rstrip("/") + "/api/ci/ontology-fmt"
|
|
403
|
+
try:
|
|
404
|
+
with _client(transport=transport) as client:
|
|
405
|
+
response = client.post(
|
|
406
|
+
url,
|
|
407
|
+
json={"files": files},
|
|
408
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
409
|
+
)
|
|
410
|
+
except httpx.HTTPError as exc:
|
|
411
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
412
|
+
|
|
413
|
+
if response.status_code == 401:
|
|
414
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
415
|
+
if response.status_code >= 400:
|
|
416
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
417
|
+
result = _parse_json_response(response, url)
|
|
418
|
+
if (
|
|
419
|
+
not isinstance(result, dict)
|
|
420
|
+
or "ok" not in result
|
|
421
|
+
or not isinstance(result.get("findings"), list)
|
|
422
|
+
or not isinstance(result.get("changed_paths"), list)
|
|
423
|
+
or not isinstance(result.get("removed_paths"), list)
|
|
424
|
+
or (result["ok"] and not isinstance(result.get("files"), dict))
|
|
425
|
+
):
|
|
426
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# The server-side probe budget is 300s; leave headroom for transport.
|
|
431
|
+
ONTOLOGY_TEST_TIMEOUT_SECONDS = 330.0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class OntologyTestValidationError(ApiError):
|
|
435
|
+
"""The API rejected the ontology tree as invalid (400).
|
|
436
|
+
|
|
437
|
+
``detail`` keeps the structured payload (``{"message", "findings"}``) for
|
|
438
|
+
display.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def __init__(self, detail: object) -> None:
|
|
442
|
+
super().__init__(str(detail))
|
|
443
|
+
self.detail = detail
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def post_ontology_test(
|
|
447
|
+
*,
|
|
448
|
+
api_url: str,
|
|
449
|
+
api_key: str,
|
|
450
|
+
project_id: str,
|
|
451
|
+
files: dict[str, str],
|
|
452
|
+
question: str,
|
|
453
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
454
|
+
) -> dict[str, Any]:
|
|
455
|
+
"""POST to /api/ci/projects/{project_id}/ontology/test and return the probe outcome.
|
|
456
|
+
|
|
457
|
+
Blocks for the duration of the agent run (up to ~5 minutes server-side).
|
|
458
|
+
"""
|
|
459
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/test"
|
|
460
|
+
try:
|
|
461
|
+
with _client(timeout=ONTOLOGY_TEST_TIMEOUT_SECONDS, transport=transport) as client:
|
|
462
|
+
response = client.post(
|
|
463
|
+
url,
|
|
464
|
+
json={"files": files, "question": question},
|
|
465
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
466
|
+
)
|
|
467
|
+
except httpx.HTTPError as exc:
|
|
468
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
469
|
+
|
|
470
|
+
if response.status_code == 401:
|
|
471
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
472
|
+
if response.status_code == 400:
|
|
473
|
+
raise OntologyTestValidationError(_detail_or_text(response))
|
|
474
|
+
if response.status_code == 402:
|
|
475
|
+
raise ApiError("Your organization has run out of credits. Contact your administrator to top up.")
|
|
476
|
+
if response.status_code in (403, 404):
|
|
477
|
+
raise _project_scope_error(response)
|
|
478
|
+
if response.status_code >= 400:
|
|
479
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
480
|
+
result = _parse_json_response(response, url)
|
|
481
|
+
if not isinstance(result, dict) or "status" not in result:
|
|
482
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
483
|
+
return result
|
|
@@ -15,10 +15,13 @@ from cassis_cli.api import (
|
|
|
15
15
|
DEFAULT_API_URL,
|
|
16
16
|
ApiError,
|
|
17
17
|
AuthError,
|
|
18
|
+
EvalCaseExistsError,
|
|
19
|
+
EvalCaseGoldSqlError,
|
|
18
20
|
EvalRunActiveError,
|
|
19
21
|
EvalStartValidationError,
|
|
20
22
|
get_eval_run,
|
|
21
23
|
get_eval_run_results,
|
|
24
|
+
post_eval_case_create,
|
|
22
25
|
post_eval_run_cancel,
|
|
23
26
|
post_eval_run_start,
|
|
24
27
|
)
|
|
@@ -139,6 +142,79 @@ def _print_summary(run: dict[str, Any]) -> None:
|
|
|
139
142
|
typer.echo(", ".join(parts))
|
|
140
143
|
|
|
141
144
|
|
|
145
|
+
@app.command(name="add-case")
|
|
146
|
+
def add_case(
|
|
147
|
+
project_id: str = typer.Option(
|
|
148
|
+
...,
|
|
149
|
+
"--project",
|
|
150
|
+
envvar="CASSIS_PROJECT_ID",
|
|
151
|
+
help="Target Cassis project ID (UUID, shown in the project's URL).",
|
|
152
|
+
),
|
|
153
|
+
question: str = typer.Option(
|
|
154
|
+
...,
|
|
155
|
+
"-q",
|
|
156
|
+
"--question",
|
|
157
|
+
help="The natural-language question the case guards.",
|
|
158
|
+
),
|
|
159
|
+
gold_sql: str = typer.Option(
|
|
160
|
+
...,
|
|
161
|
+
"--gold-sql",
|
|
162
|
+
help="The correct SQL for the question; executed at run time to produce the expected output.",
|
|
163
|
+
),
|
|
164
|
+
api_key: Optional[str] = typer.Option(
|
|
165
|
+
None,
|
|
166
|
+
"--api-key",
|
|
167
|
+
envvar="CASSIS_API_KEY",
|
|
168
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
169
|
+
),
|
|
170
|
+
api_url: str = typer.Option(
|
|
171
|
+
DEFAULT_API_URL,
|
|
172
|
+
"--api-url",
|
|
173
|
+
envvar="CASSIS_API_URL",
|
|
174
|
+
help="Cassis API base URL.",
|
|
175
|
+
),
|
|
176
|
+
json_output: bool = typer.Option(False, "--json", help="Print the created case as raw JSON."),
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Add a gold test case to the project's eval suite.
|
|
179
|
+
|
|
180
|
+
Closes the loop after fixing an ontology issue: the question users were
|
|
181
|
+
failing on becomes a gold case, so `cassis eval run` guards it from
|
|
182
|
+
regressing. On an executable data source the gold SQL is run before the
|
|
183
|
+
case is stored, so a case that cannot execute never enters the suite.
|
|
184
|
+
Exits 0 on creation, 1 on a duplicate question or gold SQL that does not
|
|
185
|
+
run, 2 on usage errors, 3 on transport/API errors.
|
|
186
|
+
"""
|
|
187
|
+
api_key = require_api_key(api_key)
|
|
188
|
+
try:
|
|
189
|
+
UUID(project_id)
|
|
190
|
+
except ValueError:
|
|
191
|
+
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
192
|
+
raise typer.Exit(EXIT_USAGE)
|
|
193
|
+
if not question.strip() or not gold_sql.strip():
|
|
194
|
+
typer.secho("--question and --gold-sql must not be empty.", fg=typer.colors.RED, err=True)
|
|
195
|
+
raise typer.Exit(EXIT_USAGE)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
case = post_eval_case_create(
|
|
199
|
+
api_url=api_url, api_key=api_key, project_id=project_id, question=question, gold_sql=gold_sql
|
|
200
|
+
)
|
|
201
|
+
except (EvalCaseExistsError, EvalCaseGoldSqlError) as exc:
|
|
202
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
203
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
204
|
+
except AuthError as exc:
|
|
205
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
206
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
207
|
+
except ApiError as exc:
|
|
208
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
209
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
210
|
+
|
|
211
|
+
if json_output:
|
|
212
|
+
typer.echo(json.dumps(case, indent=2))
|
|
213
|
+
else:
|
|
214
|
+
typer.secho(f"✓ Added eval case {case['id']}: {case['question']}", fg=typer.colors.GREEN)
|
|
215
|
+
raise typer.Exit(EXIT_OK)
|
|
216
|
+
|
|
217
|
+
|
|
142
218
|
@app.command()
|
|
143
219
|
def run(
|
|
144
220
|
path: Path = typer.Argument(
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The ontology modeling guide the CLI writes into a git-synced checkout.
|
|
2
|
+
|
|
3
|
+
``ontology_design_guide.md`` in this package is a byte-for-byte copy of the
|
|
4
|
+
canonical ``docs/ontology-design-guide.md`` (a repo-root test enforces they stay
|
|
5
|
+
identical — the backend image doesn't ship ``docs/``, so the CLI carries its own
|
|
6
|
+
copy). ``pull`` writes it into the checkout as ``<base_path>/AGENTS.md`` and
|
|
7
|
+
``fmt`` keeps it canonical, so a repo-aware agent loads current Cassis modeling
|
|
8
|
+
doctrine by convention. The file is managed: a banner marks it generated and the
|
|
9
|
+
CLI overwrites local edits, exactly as ``fmt`` rewrites drifted ontology YAML.
|
|
10
|
+
|
|
11
|
+
Two writers manage the file — this CLI and the Cassis server's git export — and
|
|
12
|
+
they may run different doctrine versions (the guide ships inside each). The
|
|
13
|
+
banner stamps its ``DOCTRINE_VERSION`` so an older CLI never *downgrades* a
|
|
14
|
+
guide a newer writer produced: ``refresh_guide`` leaves a newer-stamped file in
|
|
15
|
+
place and the CLI tells the user to upgrade instead.
|
|
16
|
+
|
|
17
|
+
``AGENTS.md`` sits inside the ontology base path but is never part of the
|
|
18
|
+
ontology tree — ``collect_files`` globs only ``*.yml`` / ``*.yaml``, so check,
|
|
19
|
+
fmt-of-YAML, upload, and pull-prune all ignore it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from importlib import resources
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
GUIDE_FILENAME = "AGENTS.md"
|
|
30
|
+
|
|
31
|
+
# Monotonic version of the doctrine text below. Bump it whenever
|
|
32
|
+
# ontology_design_guide.md changes (a backend test enforces the pairing) — it
|
|
33
|
+
# is what lets an older writer recognize a newer guide and leave it alone.
|
|
34
|
+
DOCTRINE_VERSION = 1
|
|
35
|
+
|
|
36
|
+
# Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
|
|
37
|
+
# the server-side git export writes the same file, and differing banners would
|
|
38
|
+
# make the two writers churn the file against each other.
|
|
39
|
+
_BANNER = (
|
|
40
|
+
"<!--\n"
|
|
41
|
+
f"Generated by Cassis — the ontology modeling guide (doctrine v{DOCTRINE_VERSION}).\n"
|
|
42
|
+
"Do NOT edit: Cassis and cassis-cli (`ontology pull` / `ontology fmt`) overwrite this file.\n"
|
|
43
|
+
"-->\n\n"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_DOCTRINE_VERSION_RE = re.compile(r"doctrine v(\d+)\b")
|
|
47
|
+
|
|
48
|
+
GuideStatus = Literal["current", "stale", "newer", "missing"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical_guide() -> str:
|
|
52
|
+
"""Return the managed ``AGENTS.md`` content: banner + the packaged guide."""
|
|
53
|
+
body = resources.files("cassis_cli").joinpath("ontology_design_guide.md").read_text(encoding="utf-8")
|
|
54
|
+
return _BANNER + body
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def guide_status(ontology_dir: Path) -> GuideStatus:
|
|
58
|
+
"""Classify ``<ontology_dir>/AGENTS.md`` against the guide this CLI carries.
|
|
59
|
+
|
|
60
|
+
- ``current``: byte-identical to what this CLI would write.
|
|
61
|
+
- ``newer``: stamped with a higher doctrine version (written by a newer
|
|
62
|
+
CLI or by the Cassis server) — must not be overwritten by this CLI.
|
|
63
|
+
- ``stale``: anything else that exists (older doctrine, local edits, or an
|
|
64
|
+
unversioned banner).
|
|
65
|
+
- ``missing``: absent or unreadable.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
existing = (ontology_dir / GUIDE_FILENAME).read_text(encoding="utf-8")
|
|
69
|
+
except OSError:
|
|
70
|
+
return "missing"
|
|
71
|
+
if existing == canonical_guide():
|
|
72
|
+
return "current"
|
|
73
|
+
# Only a leading banner comment carries the stamp — the guide body may
|
|
74
|
+
# legitimately mention "doctrine vN" as prose, and a hand-written file has
|
|
75
|
+
# no banner at all. Both must read as "stale", never as "newer" (which is
|
|
76
|
+
# never repaired and nags the user to upgrade a CLI that is already current).
|
|
77
|
+
if existing.lstrip().startswith("<!--") and "-->" in existing:
|
|
78
|
+
match = _DOCTRINE_VERSION_RE.search(existing.split("-->", 1)[0])
|
|
79
|
+
if match is not None and int(match.group(1)) > DOCTRINE_VERSION:
|
|
80
|
+
return "newer"
|
|
81
|
+
return "stale"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def refresh_guide(ontology_dir: Path) -> GuideStatus:
|
|
85
|
+
"""Bring ``<ontology_dir>/AGENTS.md`` up to this CLI's doctrine, never down.
|
|
86
|
+
|
|
87
|
+
Writes the managed guide when the file is ``stale`` or ``missing``; leaves
|
|
88
|
+
``current`` and ``newer`` files untouched. Returns the status found before
|
|
89
|
+
writing, so the caller can report what happened (and nudge an upgrade on
|
|
90
|
+
``newer``). Raises OSError on a local write failure (the caller maps it to
|
|
91
|
+
a usage-level exit).
|
|
92
|
+
"""
|
|
93
|
+
status = guide_status(ontology_dir)
|
|
94
|
+
if status in ("stale", "missing"):
|
|
95
|
+
ontology_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
(ontology_dir / GUIDE_FILENAME).write_text(canonical_guide(), encoding="utf-8")
|
|
97
|
+
return status
|