cassis-cli 0.4.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.4.0 → cassis_cli-1.0.0}/PKG-INFO +18 -6
- {cassis_cli-0.4.0 → cassis_cli-1.0.0}/README.md +17 -5
- {cassis_cli-0.4.0 → cassis_cli-1.0.0}/cassis_cli/__init__.py +1 -1
- {cassis_cli-0.4.0 → cassis_cli-1.0.0}/cassis_cli/api.py +178 -17
- {cassis_cli-0.4.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-0.4.0 → cassis_cli-1.0.0}/cassis_cli/ontology.py +201 -9
- cassis_cli-1.0.0/cassis_cli/ontology_design_guide.md +405 -0
- {cassis_cli-0.4.0 → cassis_cli-1.0.0}/pyproject.toml +4 -1
- {cassis_cli-0.4.0 → cassis_cli-1.0.0}/cassis_cli/common.py +0 -0
- {cassis_cli-0.4.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
|
|
@@ -26,7 +26,11 @@ Run Cassis actions from your CI pipelines:
|
|
|
26
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.
|
|
27
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.
|
|
28
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).
|
|
29
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.
|
|
30
34
|
|
|
31
35
|
## Install
|
|
32
36
|
|
|
@@ -77,6 +81,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
77
81
|
|
|
78
82
|
# Start the run and return immediately (poll in the webapp):
|
|
79
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 ..."
|
|
80
92
|
```
|
|
81
93
|
|
|
82
94
|
Configuration (flags take precedence over env vars):
|
|
@@ -86,7 +98,7 @@ Configuration (flags take precedence over env vars):
|
|
|
86
98
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
87
99
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
88
100
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
89
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
101
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
90
102
|
|
|
91
103
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
92
104
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -114,12 +126,12 @@ cassis ontology fmt --check
|
|
|
114
126
|
|
|
115
127
|
| Code | Meaning |
|
|
116
128
|
| ---- | ------------------------------------------------------------------------------ |
|
|
117
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
118
|
-
| 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) |
|
|
119
131
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
120
|
-
| 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 |
|
|
121
133
|
|
|
122
|
-
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
|
|
123
135
|
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
124
136
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
125
137
|
double-check `--base-path` if you hit it.
|
|
@@ -6,7 +6,11 @@ Run Cassis actions from your CI pipelines:
|
|
|
6
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.
|
|
7
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.
|
|
8
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).
|
|
9
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.
|
|
10
14
|
|
|
11
15
|
## Install
|
|
12
16
|
|
|
@@ -57,6 +61,14 @@ cassis eval run --project ... --branch feature-x
|
|
|
57
61
|
|
|
58
62
|
# Start the run and return immediately (poll in the webapp):
|
|
59
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 ..."
|
|
60
72
|
```
|
|
61
73
|
|
|
62
74
|
Configuration (flags take precedence over env vars):
|
|
@@ -66,7 +78,7 @@ Configuration (flags take precedence over env vars):
|
|
|
66
78
|
| `--api-key` | `CASSIS_API_KEY` | — (required) |
|
|
67
79
|
| `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
|
|
68
80
|
| `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
|
|
69
|
-
| `--project` (pull, upload, eval run) | `CASSIS_PROJECT_ID` | — (required) |
|
|
81
|
+
| `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
|
|
70
82
|
|
|
71
83
|
`cassis eval run` also accepts `--label` (run label in the Evals page; defaults
|
|
72
84
|
to the branch name from the CI environment or the local git checkout; rejected
|
|
@@ -94,12 +106,12 @@ cassis ontology fmt --check
|
|
|
94
106
|
|
|
95
107
|
| Code | Meaning |
|
|
96
108
|
| ---- | ------------------------------------------------------------------------------ |
|
|
97
|
-
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) |
|
|
98
|
-
| 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) |
|
|
99
111
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
100
|
-
| 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 |
|
|
101
113
|
|
|
102
|
-
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
|
|
103
115
|
2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
|
|
104
116
|
files). Beyond that the CLI fails fast with exit 2 before uploading anything;
|
|
105
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
|
|
@@ -296,7 +401,7 @@ def post_ontology_fmt(
|
|
|
296
401
|
"""POST the ontology tree to /api/ci/ontology-fmt and return the response body."""
|
|
297
402
|
url = api_url.rstrip("/") + "/api/ci/ontology-fmt"
|
|
298
403
|
try:
|
|
299
|
-
with
|
|
404
|
+
with _client(transport=transport) as client:
|
|
300
405
|
response = client.post(
|
|
301
406
|
url,
|
|
302
407
|
json={"files": files},
|
|
@@ -320,3 +425,59 @@ def post_ontology_fmt(
|
|
|
320
425
|
):
|
|
321
426
|
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
322
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
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import List, Optional
|
|
8
8
|
from uuid import UUID
|
|
9
9
|
|
|
10
10
|
import typer
|
|
@@ -12,11 +12,13 @@ from cassis_cli.api import (
|
|
|
12
12
|
DEFAULT_API_URL,
|
|
13
13
|
ApiError,
|
|
14
14
|
AuthError,
|
|
15
|
+
OntologyTestValidationError,
|
|
15
16
|
UploadValidationError,
|
|
16
17
|
get_ontology_export,
|
|
17
18
|
post_ontology_check,
|
|
18
19
|
post_ontology_fmt,
|
|
19
20
|
post_ontology_import,
|
|
21
|
+
post_ontology_test,
|
|
20
22
|
)
|
|
21
23
|
from cassis_cli.common import (
|
|
22
24
|
DEFAULT_BASE_PATH,
|
|
@@ -28,10 +30,25 @@ from cassis_cli.common import (
|
|
|
28
30
|
from cassis_cli.common import collect_files as _collect_files
|
|
29
31
|
from cassis_cli.common import collect_tree as _collect_tree
|
|
30
32
|
from cassis_cli.common import require_api_key as _require_api_key
|
|
33
|
+
from cassis_cli.guide import DOCTRINE_VERSION, GUIDE_FILENAME, guide_status, refresh_guide
|
|
31
34
|
|
|
32
35
|
app = typer.Typer(no_args_is_help=True, help="Ontology commands.")
|
|
33
36
|
|
|
34
37
|
|
|
38
|
+
def _warn_newer_guide(base_path: str) -> None:
|
|
39
|
+
"""Tell the user their checkout's AGENTS.md outruns this CLI's doctrine.
|
|
40
|
+
|
|
41
|
+
A newer-stamped guide (written by a newer CLI or by the Cassis server) is
|
|
42
|
+
never overwritten — the fix is upgrading the CLI, so say so and move on.
|
|
43
|
+
"""
|
|
44
|
+
typer.secho(
|
|
45
|
+
f"notice: {base_path}/{GUIDE_FILENAME} carries a newer Cassis doctrine than this CLI "
|
|
46
|
+
f"(v{DOCTRINE_VERSION}) — leaving it in place; run `pip install -U cassis-cli` to update.",
|
|
47
|
+
fg=typer.colors.YELLOW,
|
|
48
|
+
err=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
35
52
|
@app.command()
|
|
36
53
|
def check(
|
|
37
54
|
path: Path = typer.Argument(
|
|
@@ -183,12 +200,26 @@ def pull(
|
|
|
183
200
|
raise typer.Exit(EXIT_USAGE) from exc
|
|
184
201
|
deleted.append(rel)
|
|
185
202
|
|
|
203
|
+
# Managed modeling guide: refresh AGENTS.md so a repo-aware agent loads
|
|
204
|
+
# current Cassis doctrine. Not part of the ontology tree (YAML-only), so it
|
|
205
|
+
# was neither pulled above nor pruned.
|
|
206
|
+
try:
|
|
207
|
+
guide_state = refresh_guide(ontology_dir)
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
typer.secho(f"Cannot write {ontology_dir / GUIDE_FILENAME}: {exc}", fg=typer.colors.RED, err=True)
|
|
210
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
211
|
+
guide_written = guide_state in ("stale", "missing")
|
|
212
|
+
if guide_state == "newer":
|
|
213
|
+
_warn_newer_guide(base_path)
|
|
214
|
+
|
|
186
215
|
if json_output:
|
|
187
|
-
typer.echo(json.dumps({"written": written, "deleted": deleted}, indent=2))
|
|
216
|
+
typer.echo(json.dumps({"written": written, "deleted": deleted, "guide_written": guide_written}, indent=2))
|
|
188
217
|
else:
|
|
189
218
|
summary = f"✓ Pulled {len(written)} files into {ontology_dir}"
|
|
190
219
|
if deleted:
|
|
191
220
|
summary += f" ({len(deleted)} stale files deleted)"
|
|
221
|
+
if guide_written:
|
|
222
|
+
summary += f"; wrote {base_path}/{GUIDE_FILENAME}"
|
|
192
223
|
typer.secho(f"{summary}.", fg=typer.colors.GREEN)
|
|
193
224
|
raise typer.Exit(EXIT_OK)
|
|
194
225
|
|
|
@@ -346,7 +377,16 @@ def fmt(
|
|
|
346
377
|
|
|
347
378
|
changed = result["changed_paths"]
|
|
348
379
|
removed = result["removed_paths"]
|
|
349
|
-
|
|
380
|
+
# The managed AGENTS.md guide is canonicalized alongside the YAML tree
|
|
381
|
+
# (it isn't in the tree, so the server round-trip above never sees it).
|
|
382
|
+
# A guide stamped with a NEWER doctrine than this CLI carries is left
|
|
383
|
+
# alone and does not fail --check: the repo is fine, the CLI is old.
|
|
384
|
+
guide_state = guide_status(ontology_dir)
|
|
385
|
+
guide_stale = guide_state in ("stale", "missing")
|
|
386
|
+
if guide_state == "newer":
|
|
387
|
+
_warn_newer_guide(base_path)
|
|
388
|
+
|
|
389
|
+
if not changed and not removed and not guide_stale:
|
|
350
390
|
typer.secho(f"✓ {len(files)} file(s) already canonical.", fg=typer.colors.GREEN)
|
|
351
391
|
raise typer.Exit(EXIT_OK)
|
|
352
392
|
|
|
@@ -355,6 +395,8 @@ def fmt(
|
|
|
355
395
|
typer.echo(f"would rewrite {base_path}/{p}")
|
|
356
396
|
for p in removed:
|
|
357
397
|
typer.echo(f"would remove {base_path}/{p}")
|
|
398
|
+
if guide_stale:
|
|
399
|
+
typer.echo(f"would rewrite {base_path}/{GUIDE_FILENAME}")
|
|
358
400
|
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
359
401
|
|
|
360
402
|
for p in changed:
|
|
@@ -365,10 +407,160 @@ def fmt(
|
|
|
365
407
|
for p in removed:
|
|
366
408
|
(ontology_dir / p).unlink(missing_ok=True)
|
|
367
409
|
typer.echo(f"removed {base_path}/{p}")
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
410
|
+
if guide_stale:
|
|
411
|
+
try:
|
|
412
|
+
refresh_guide(ontology_dir)
|
|
413
|
+
except OSError as exc:
|
|
414
|
+
typer.secho(f"Cannot write {ontology_dir / GUIDE_FILENAME}: {exc}", fg=typer.colors.RED, err=True)
|
|
415
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
416
|
+
typer.echo(f"rewrote {base_path}/{GUIDE_FILENAME}")
|
|
417
|
+
|
|
418
|
+
if changed or removed:
|
|
419
|
+
typer.secho(
|
|
420
|
+
f"Formatted {len(changed)} file(s)"
|
|
421
|
+
+ (f", removed {len(removed)}" if removed else "")
|
|
422
|
+
+ ". Review the diff: fields Cassis does not recognize are dropped.",
|
|
423
|
+
fg=typer.colors.YELLOW,
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
# Only the guide was refreshed — the "rewrote ..." line above already said so.
|
|
427
|
+
typer.secho(f"✓ {len(files)} file(s) already canonical.", fg=typer.colors.GREEN)
|
|
374
428
|
raise typer.Exit(EXIT_OK)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command()
|
|
432
|
+
def test(
|
|
433
|
+
path: Path = typer.Argument(
|
|
434
|
+
Path("."),
|
|
435
|
+
help="Repository checkout root (the directory containing the ontology export path).",
|
|
436
|
+
),
|
|
437
|
+
question: List[str] = typer.Option(
|
|
438
|
+
...,
|
|
439
|
+
"--question",
|
|
440
|
+
"-q",
|
|
441
|
+
help="Natural-language question to probe (repeat for several).",
|
|
442
|
+
),
|
|
443
|
+
project_id: str = typer.Option(
|
|
444
|
+
...,
|
|
445
|
+
"--project",
|
|
446
|
+
envvar="CASSIS_PROJECT_ID",
|
|
447
|
+
help="Target Cassis project ID (UUID, shown in the project's URL).",
|
|
448
|
+
),
|
|
449
|
+
api_key: Optional[str] = typer.Option(
|
|
450
|
+
None,
|
|
451
|
+
"--api-key",
|
|
452
|
+
envvar="CASSIS_API_KEY",
|
|
453
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
454
|
+
),
|
|
455
|
+
api_url: str = typer.Option(
|
|
456
|
+
DEFAULT_API_URL,
|
|
457
|
+
"--api-url",
|
|
458
|
+
envvar="CASSIS_API_URL",
|
|
459
|
+
help="Cassis API base URL.",
|
|
460
|
+
),
|
|
461
|
+
base_path: str = typer.Option(
|
|
462
|
+
DEFAULT_BASE_PATH,
|
|
463
|
+
"--base-path",
|
|
464
|
+
envvar="CASSIS_BASE_PATH",
|
|
465
|
+
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
466
|
+
),
|
|
467
|
+
json_output: bool = typer.Option(False, "--json", help="Print the raw JSON outcomes."),
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Run questions through the text-to-SQL agent using your local ontology files.
|
|
470
|
+
|
|
471
|
+
The behavioral probe: checks that a change actually WORKS — e.g. that a
|
|
472
|
+
new column gets picked —
|
|
473
|
+
where `cassis eval run` only checks for regressions on existing gold
|
|
474
|
+
cases. Each question is one full agent run (expect ~30-90s each); nothing
|
|
475
|
+
is persisted server-side. The outcome is informational, not a gate: read
|
|
476
|
+
the SQL and answer, don't wire the exit code into CI verdicts. Exits 0
|
|
477
|
+
when every probe completed (whatever its outcome), 1 when the tree is
|
|
478
|
+
invalid or a probe failed, 2 on usage errors, 3 on transport errors.
|
|
479
|
+
"""
|
|
480
|
+
api_key = _require_api_key(api_key)
|
|
481
|
+
try:
|
|
482
|
+
UUID(project_id)
|
|
483
|
+
except ValueError:
|
|
484
|
+
typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
|
|
485
|
+
raise typer.Exit(EXIT_USAGE)
|
|
486
|
+
files, base_path = _collect_tree(path, base_path)
|
|
487
|
+
|
|
488
|
+
outcomes: "list[dict]" = []
|
|
489
|
+
failed = False
|
|
490
|
+
for q in question:
|
|
491
|
+
try:
|
|
492
|
+
outcome = post_ontology_test(
|
|
493
|
+
api_url=api_url, api_key=api_key, project_id=project_id, files=files, question=q
|
|
494
|
+
)
|
|
495
|
+
except OntologyTestValidationError as exc:
|
|
496
|
+
_print_test_validation_failure(exc.detail, base_path)
|
|
497
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
498
|
+
except AuthError as exc:
|
|
499
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
500
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
501
|
+
except ApiError as exc:
|
|
502
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
503
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
504
|
+
outcomes.append(outcome)
|
|
505
|
+
if not json_output:
|
|
506
|
+
_print_test_outcome(q, outcome)
|
|
507
|
+
if outcome.get("status") != "completed":
|
|
508
|
+
failed = True
|
|
509
|
+
|
|
510
|
+
if json_output:
|
|
511
|
+
# Always a list, regardless of how many questions ran — scripts
|
|
512
|
+
# shouldn't have to branch on the shape.
|
|
513
|
+
typer.echo(json.dumps(outcomes, indent=2))
|
|
514
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED if failed else EXIT_OK)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _print_test_validation_failure(detail: object, base_path: str) -> None:
|
|
518
|
+
"""Print a structured 400 from the test endpoint (invalid tree findings, or a plain message)."""
|
|
519
|
+
if isinstance(detail, dict) and isinstance(detail.get("findings"), list):
|
|
520
|
+
typer.secho("Ontology validation failed:", fg=typer.colors.RED, bold=True, err=True)
|
|
521
|
+
message = detail.get("message")
|
|
522
|
+
if message:
|
|
523
|
+
typer.echo(message, err=True)
|
|
524
|
+
for finding in detail["findings"]:
|
|
525
|
+
location = f"{base_path}/{finding.get('path')}: " if finding.get("path") else ""
|
|
526
|
+
typer.echo(f" {location}{finding.get('message', '')} ({finding.get('stage', '?')})", err=True)
|
|
527
|
+
else:
|
|
528
|
+
typer.secho(str(detail), fg=typer.colors.RED, err=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
_TEST_RESULT_ROWS_SHOWN = 10
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _print_test_outcome(question: str, outcome: "dict") -> None:
|
|
535
|
+
typer.secho(f"▶ {question}", bold=True)
|
|
536
|
+
if outcome.get("status") != "completed":
|
|
537
|
+
typer.secho(f" probe failed: {outcome.get('error', 'unknown error')}", fg=typer.colors.RED)
|
|
538
|
+
if outcome.get("generated_sql"):
|
|
539
|
+
typer.echo(f" SQL before failure:\n{_indent(outcome['generated_sql'])}")
|
|
540
|
+
return
|
|
541
|
+
run_status = outcome.get("run_status", "?")
|
|
542
|
+
color = typer.colors.GREEN if run_status == "success" else typer.colors.YELLOW
|
|
543
|
+
duration = f" ({outcome['duration_seconds']:.0f}s)" if outcome.get("duration_seconds") is not None else ""
|
|
544
|
+
typer.secho(f" {run_status}{duration}", fg=color)
|
|
545
|
+
if outcome.get("generated_sql"):
|
|
546
|
+
typer.echo(_indent(outcome["generated_sql"]))
|
|
547
|
+
if outcome.get("answer"):
|
|
548
|
+
typer.echo(f" Answer: {outcome['answer']}")
|
|
549
|
+
results = outcome.get("results")
|
|
550
|
+
# None means the SQL was never executed (schema-only source); an empty
|
|
551
|
+
# list means the query ran and returned nothing — show the distinction.
|
|
552
|
+
if results is not None:
|
|
553
|
+
total = outcome.get("total_rows", len(results))
|
|
554
|
+
typer.echo(f" Results ({total} row{'s' if total != 1 else ''}):")
|
|
555
|
+
for row in results[:_TEST_RESULT_ROWS_SHOWN]:
|
|
556
|
+
typer.echo(f" {row}")
|
|
557
|
+
if len(results) > _TEST_RESULT_ROWS_SHOWN or outcome.get("truncated"):
|
|
558
|
+
typer.echo(" ...")
|
|
559
|
+
for concept in outcome.get("missing_concepts") or []:
|
|
560
|
+
typer.secho(f" missing concept: {concept}", fg=typer.colors.YELLOW)
|
|
561
|
+
for warning in outcome.get("warnings") or []:
|
|
562
|
+
typer.secho(f" warning: {warning}", fg=typer.colors.YELLOW)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _indent(text: str) -> str:
|
|
566
|
+
return "\n".join(f" {line}" for line in text.splitlines())
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# Cassis Ontology Design Guide
|
|
2
|
+
|
|
3
|
+
This is the canonical guide to modeling a Cassis ontology well. It is written for
|
|
4
|
+
whoever edits the ontology — a person or an AI agent — working in the project's
|
|
5
|
+
git repository. The rules here are not style preference: they follow from **how
|
|
6
|
+
the text-to-SQL agent actually reads the ontology**, so breaking them produces
|
|
7
|
+
wrong SQL, not just messy files.
|
|
8
|
+
|
|
9
|
+
Read this before proposing an ontology change.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. What a Cassis ontology is
|
|
14
|
+
|
|
15
|
+
The ontology is the curated business context the agent uses to translate a
|
|
16
|
+
natural-language question into SQL. It has five kinds of object plus one
|
|
17
|
+
project-level field, edited as YAML files under the repository's ontology
|
|
18
|
+
directory (`cassis/` by default):
|
|
19
|
+
|
|
20
|
+
| Layer | What it is | Key fields |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| **Root context** (`_project.yml → context_md`) | One free-text block, always in the agent's prompt | markdown |
|
|
23
|
+
| **Domains** | A navigable tree grouping the business by subject area | `path`, `display_name`, `description`, `context_md` |
|
|
24
|
+
| **Tables** | A physical warehouse table (introspected) or a virtual one (SQL-defined) placed in a domain | `name` (`schema.TABLE`), `description`, `synonyms`, `grain`, columns |
|
|
25
|
+
| **Columns** | Enrichment on a table's columns | `description`, `unit`, `synonyms` |
|
|
26
|
+
| **Joins** | A known way two tables relate | `from_schema`/`from_table`, `to_schema`/`to_table`, `column_pairs`, `condition_sql`, `cardinality`, `description` |
|
|
27
|
+
| **Metrics** | A reusable business measure | `name`, `display_name`, `expression`, `table_schema`/`table_name`, `filters`, `unit`, `synonyms`, `notes`, `precomputed_in` |
|
|
28
|
+
|
|
29
|
+
On disk, the export layout is fixed — create each object in its canonical home
|
|
30
|
+
(`cassis ontology fmt` normalizes drift, but start in the right place):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
cassis/ (the git-sync base path)
|
|
34
|
+
_project.yml root context (context_md), project display name
|
|
35
|
+
domains/<path>/_domain.yml one per domain, nested by path
|
|
36
|
+
tables/<schema>/<table>.yml one per table, columns inline
|
|
37
|
+
metrics/<name>.yml one per metric
|
|
38
|
+
joins.yml ALL joins, one list in one file
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Joins and metrics are never embedded inside a table's file, and a table's file
|
|
42
|
+
never carries keys the schema doesn't define — unknown keys are dropped at sync
|
|
43
|
+
time (run `cassis ontology fmt` to see what would be lost).
|
|
44
|
+
|
|
45
|
+
The **published** ontology is an immutable numbered snapshot — what production
|
|
46
|
+
answers from. The **unpublished** ontology is the editable state (the published
|
|
47
|
+
base plus unpublished changes). In a git-synced project the repository *is* the
|
|
48
|
+
edit surface: you edit the YAML, open a pull request, and merging syncs and
|
|
49
|
+
publishes it.
|
|
50
|
+
|
|
51
|
+
Physical tables and their columns come from schema introspection — you never
|
|
52
|
+
invent them; you *enrich* them (place them in a domain, describe them, set units
|
|
53
|
+
and synonyms). The one exception is a virtual table, which you define with a
|
|
54
|
+
`sql` body (see §5).
|
|
55
|
+
|
|
56
|
+
The one governing principle everything below serves:
|
|
57
|
+
|
|
58
|
+
> **Each fact lives in exactly one place, at the most specific layer that fully
|
|
59
|
+
> owns it. Never duplicate across layers. Never hand-write what the agent already
|
|
60
|
+
> reads automatically.**
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 2. How the agent reads your ontology
|
|
65
|
+
|
|
66
|
+
The agent has no other ground truth. It does not see the warehouse, the dbt
|
|
67
|
+
project, or the raw schema — only the strings in the ontology. If a fact isn't
|
|
68
|
+
there, it doesn't exist for the agent; if a string contradicts the data, the
|
|
69
|
+
agent confidently writes wrong SQL. Three mechanics drive every rule that follows.
|
|
70
|
+
|
|
71
|
+
**Retrieval is structured and automatic.** The agent navigates the domain tree
|
|
72
|
+
with a `get(path)` call and inspects a table with `get_table("schema.TABLE")`.
|
|
73
|
+
These assemble structured fields for free:
|
|
74
|
+
|
|
75
|
+
- `get(path)` returns the domain's `context_md` **plus** each direct sub-domain
|
|
76
|
+
(`path`, `display_name`, `description`), each table (`name`, `description`,
|
|
77
|
+
`synonyms`, `grain`), and each metric (full definition).
|
|
78
|
+
- `get_table("schema.TABLE")` returns the table's columns, `grain`, and its
|
|
79
|
+
**joins** (each with its condition, cardinality, and description).
|
|
80
|
+
- The **root** domain — its `root_context`, direct children, root tables, root
|
|
81
|
+
metrics — is injected into the prompt at the start of every conversation.
|
|
82
|
+
|
|
83
|
+
Because those fields are surfaced automatically, **restating them in prose is
|
|
84
|
+
pure redundancy** — and redundant copies drift, which is how the ontology starts
|
|
85
|
+
lying to the agent. `context_md` and `description` are for the connective tissue
|
|
86
|
+
the structured fields can't carry, nothing the fields already carry.
|
|
87
|
+
|
|
88
|
+
**Minimum tokens to answer.** Every byte the agent reads costs context, and a
|
|
89
|
+
real ontology does not fit in the prompt at once — it is retrieved piecemeal.
|
|
90
|
+
Keep free-text fields concise; don't state what the object's own name already
|
|
91
|
+
makes obvious; watch your largest tables, since column count drives rendered
|
|
92
|
+
cost.
|
|
93
|
+
|
|
94
|
+
**Vocabulary in prose fields is the only vocabulary the agent has.** In prose
|
|
95
|
+
fields (descriptions, `context_md`, root context) refer to tables by their
|
|
96
|
+
`schema.TABLE` ontology name, never by some other alias the agent can't map back.
|
|
97
|
+
In SQL-bearing fields (a metric's `expression`/`filters`, a virtual table's
|
|
98
|
+
`sql`, a join's `condition_sql`) use real physical identifiers — those are SQL
|
|
99
|
+
and must match the warehouse (see §7).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 3. The placement contract
|
|
104
|
+
|
|
105
|
+
Put each fact at the lowest layer that fully owns it:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
column synonym / column description single-column facts: meaning, encoding, unit, date format, nulls
|
|
109
|
+
→ table description single-table facts: grain, scope, mandatory filters, "use this not that"
|
|
110
|
+
→ domain context_md cross-table routing, disambiguation, vocabulary spanning tables
|
|
111
|
+
→ root context truly global rules only: corporate identity, entity hierarchy, global conventions
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Plus two non-prose homes that pull facts *out* of prose entirely:
|
|
115
|
+
|
|
116
|
+
- A **recurring calculation that encodes a business rule** → a **metric**, not a
|
|
117
|
+
sentence.
|
|
118
|
+
- **How two tables relate** → a **join**, not a "joins to X on Y" note.
|
|
119
|
+
|
|
120
|
+
A fact written one layer too high is redundant (the tool already surfaces the
|
|
121
|
+
lower layer) or stranded where the agent won't look. A fact written one layer too
|
|
122
|
+
low can't express the cross-object relationship it's really about. The test for
|
|
123
|
+
`context_md`: imagine pushing the fact one domain level deeper — if that would
|
|
124
|
+
force you to copy it across sibling sub-domains, it belongs at this shared level;
|
|
125
|
+
otherwise push it down.
|
|
126
|
+
|
|
127
|
+
**Never restate the structured fields:**
|
|
128
|
+
|
|
129
|
+
- Direct sub-domains are surfaced with their descriptions → no "Sub-domains: …"
|
|
130
|
+
lists. If a child reads too thin to stand alone, fix its `description`; don't
|
|
131
|
+
re-list it in the parent.
|
|
132
|
+
- Tables are surfaced with description, synonyms, grain → no `## Tables` block.
|
|
133
|
+
Per-table facts go on that table's `description`; keep only genuinely
|
|
134
|
+
cross-table routing in `context_md`.
|
|
135
|
+
- Metrics are surfaced with their full definition → don't re-paste a formula in
|
|
136
|
+
prose. Point to a metric by name **only** when it lives in a *different* domain
|
|
137
|
+
than the prose discussing it; a domain's own metrics are surfaced alongside its
|
|
138
|
+
`context_md` already.
|
|
139
|
+
- Joins are surfaced with their condition and cardinality → don't enumerate join
|
|
140
|
+
keys in prose. The only join content that belongs in prose is a join+filter
|
|
141
|
+
*recipe* no single structured join can express.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Domains and `context_md`
|
|
146
|
+
|
|
147
|
+
Build a domain hierarchy that mirrors **how the business thinks about its data**,
|
|
148
|
+
not the raw schema layout. Nest as deep as the structure genuinely warrants
|
|
149
|
+
(there is no level cap), but don't add a level that carries no distinct context of
|
|
150
|
+
its own — an empty navigational domain is just noise. Every table and every
|
|
151
|
+
metric names a `domain_path` that must resolve to a domain you've declared (or the
|
|
152
|
+
root, `""`).
|
|
153
|
+
|
|
154
|
+
A useful convention inside `context_md`: a `## Terms` section for the domain's
|
|
155
|
+
vocabulary and disambiguation, and a `## Notes` section for routing hints and
|
|
156
|
+
scope caveats. Omit either if you have nothing for it.
|
|
157
|
+
|
|
158
|
+
`context_md` carries only the connective tissue the structured fields can't:
|
|
159
|
+
|
|
160
|
+
- **Cross-table routing and disambiguation** — which table answers which
|
|
161
|
+
question; "two different 'active users'" and which to use when.
|
|
162
|
+
- **Business rules, scope and temporal caveats, aggregation traps** that span
|
|
163
|
+
more than one object.
|
|
164
|
+
- **Vocabulary / glossary** that maps to no single table (acronyms, internal
|
|
165
|
+
project names, business terms).
|
|
166
|
+
- A short **orientation paragraph** for a navigational domain.
|
|
167
|
+
- Markdown **links** to related domains, using the resolvable domain path
|
|
168
|
+
(`[berries](play/features/berries)`), not a bare filename.
|
|
169
|
+
|
|
170
|
+
**Root context** — the `context_md` in `_project.yml`, injected into every
|
|
171
|
+
conversation — **holds only what applies across almost every query:** corporate
|
|
172
|
+
identity, the core entity hierarchy (how the central models relate — e.g. "users
|
|
173
|
+
belong to companies via enrollments; primary enrollments are employees, partner
|
|
174
|
+
and child are dependents"), global country/timestamp/currency conventions,
|
|
175
|
+
default population exclusions, and cross-cutting vocabulary that maps to no single
|
|
176
|
+
domain. Everything domain-specific moves down.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 5. Tables
|
|
181
|
+
|
|
182
|
+
A table's `description` answers *"what is this, and what must the SQL agent know
|
|
183
|
+
to use it correctly?"* — business meaning **plus** the correctness facts (scope,
|
|
184
|
+
mandatory filters, gotchas). Always **state the grain explicitly** ("one row per
|
|
185
|
+
order"; "one row per (user_id, day)") so the agent aggregates correctly, and set
|
|
186
|
+
the `grain` field to the identifying column(s). Don't enumerate the columns — the
|
|
187
|
+
column list is surfaced automatically.
|
|
188
|
+
|
|
189
|
+
Use `synonyms` for alternate names a user might say ("purchases", "transactions"),
|
|
190
|
+
never an "also known as" sentence in the description.
|
|
191
|
+
|
|
192
|
+
When a table's rows have non-obvious semantics, put the rule in the description:
|
|
193
|
+
a table that keeps one row per entity chosen by a priority ordering, a
|
|
194
|
+
`GROUPING SETS` table whose discriminator column must be filtered to avoid
|
|
195
|
+
double-counting, a JSON/array column and how to query it. Don't silently omit a
|
|
196
|
+
messy column — surface it with guidance.
|
|
197
|
+
|
|
198
|
+
**Virtual tables are a last resort — almost never the right answer.** A virtual
|
|
199
|
+
table is a `sql`-defined `SELECT` placed in the ontology. Do **not** reach for one
|
|
200
|
+
to hold a single field, a metric, or a simple rollup: use a **metric** for a
|
|
201
|
+
recurring calculation, a **column** description to clarify a field, a **join** to
|
|
202
|
+
relate two tables, or **enrich an existing physical table**. A virtual table is
|
|
203
|
+
justified only when a genuinely complex object cannot be represented by any
|
|
204
|
+
physical table — e.g. extracting structured rows out of a nested JSON/blob
|
|
205
|
+
column, or reshaping an entity split across an unusable physical layout. When you
|
|
206
|
+
do create one, prefer selecting from existing physical tables (don't duplicate a
|
|
207
|
+
fact table), list its columns so they can be enriched, and quote/case identifiers
|
|
208
|
+
in the `sql` per §7.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 6. Columns
|
|
213
|
+
|
|
214
|
+
Column enrichment is `description`, `unit`, and `synonyms` — you cannot add or
|
|
215
|
+
rename columns (they come from introspection). The `description` is the SQL
|
|
216
|
+
agent's only per-column guidance, so it carries both the semantic meaning and the
|
|
217
|
+
SQL-correctness facts: possible values, encoding, units, date format, null
|
|
218
|
+
behavior, any mandatory filter.
|
|
219
|
+
|
|
220
|
+
- **Prioritize the trap columns.** Disambiguate similarly-named columns
|
|
221
|
+
(`created_at` vs `updated_at`, `amount` vs `net_amount`), numeric-encoded
|
|
222
|
+
columns, numbers-that-are-timestamps, dates-stored-as-text. Skip the
|
|
223
|
+
self-explanatory ones — a description that just restates the column name is
|
|
224
|
+
wasted tokens.
|
|
225
|
+
- **Set `unit`** whenever a number is a currency, percentage, or duration —
|
|
226
|
+
aggregation correctness depends on it.
|
|
227
|
+
- **Use `synonyms`** for 1–3 short, plain alternate terms a user might use.
|
|
228
|
+
- **Keep the join key queryable.** A foreign-key column stays a real, described
|
|
229
|
+
column even when a join covers the relationship — hiding it leaves the agent
|
|
230
|
+
unable to filter or group on it without traversing the join. Test: imagine a
|
|
231
|
+
question that filters or groups by this column *directly*. If you can, describe
|
|
232
|
+
it; if the column only ever appears via the join, it needs less attention.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 7. Joins
|
|
237
|
+
|
|
238
|
+
All joins live in the single `joins.yml` file, one list entry per join. An entry
|
|
239
|
+
names its two endpoints with schema and table split into separate keys, carries
|
|
240
|
+
the joined column pairs, and the `condition_sql` — the ON-clause boolean
|
|
241
|
+
expression, with every column table-qualified and quoted in the warehouse
|
|
242
|
+
dialect. Add a business `description` and set `cardinality` (`one_to_one` /
|
|
243
|
+
`one_to_many` / `many_to_one` / `many_to_many`, read from the `from` side to the
|
|
244
|
+
`to` side):
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
- from_schema: public
|
|
248
|
+
from_table: orders
|
|
249
|
+
to_schema: public
|
|
250
|
+
to_table: customers
|
|
251
|
+
column_pairs:
|
|
252
|
+
- from_column: customer_id
|
|
253
|
+
to_column: id
|
|
254
|
+
condition_sql: '"public"."orders"."customer_id" = "public"."customers"."id"'
|
|
255
|
+
cardinality: many_to_one
|
|
256
|
+
description: Each order is placed by exactly one customer.
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Fill in both `column_pairs` and `condition_sql` — the agent is shown both.
|
|
260
|
+
|
|
261
|
+
- Cover every foreign key and every obvious `*_id` reference between tables. FK
|
|
262
|
+
joins are pre-seeded from introspection — confirm them and add a description;
|
|
263
|
+
then add the ones the heuristic missed (columns whose names diverge across the
|
|
264
|
+
two tables, or relationships that go through a bridge table).
|
|
265
|
+
- **Join forms:** a simple join is a single-column equality
|
|
266
|
+
(`a.x = b.y`); a **composite** join ANDs the equalities
|
|
267
|
+
(`a.x = b.x AND a.y = b.y`); a **filtered** join ANDs a filter
|
|
268
|
+
(`a.x = b.y AND b.type = 'order'`); a **functional** join uses a function
|
|
269
|
+
(`DATE_TRUNC('day', b.ts) = a.day`).
|
|
270
|
+
- **Avoid join explosion — the single biggest failure mode.** A column shared by
|
|
271
|
+
many tables (`country_id`) must **not** get a join between every pair; it
|
|
272
|
+
degrades agent quality badly. Pick one hub table for a shared key and link the
|
|
273
|
+
peers to it (hub-and-spoke). Coincidental column-name matches — two unrelated
|
|
274
|
+
tables that both happen to have a `contract_id` — get **no** join.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 8. Metrics
|
|
279
|
+
|
|
280
|
+
Define a metric when a calculation **recurs and encodes a business rule** — e.g.
|
|
281
|
+
revenue net of refunds, active-user counts with a specific definition. Don't turn
|
|
282
|
+
every column into a metric.
|
|
283
|
+
|
|
284
|
+
- `expression` is the **aggregation expression only** (`SUM(net_amount)`,
|
|
285
|
+
`COUNT(DISTINCT customer_id)`), never a full `SELECT`.
|
|
286
|
+
- `table_schema` + `table_name` name the base table it aggregates over (omit
|
|
287
|
+
only for a genuinely cross-table metric).
|
|
288
|
+
- `filters` is any mandatory `WHERE` condition always applied
|
|
289
|
+
(`status <> 'cancelled'`).
|
|
290
|
+
- `unit` for non-obvious result quantities; `synonyms` for alternate names;
|
|
291
|
+
`precomputed_in` the `schema.TABLE` of a rollup that already materializes it, if
|
|
292
|
+
one exists.
|
|
293
|
+
|
|
294
|
+
One file per metric under `metrics/<name>.yml`:
|
|
295
|
+
|
|
296
|
+
```yaml
|
|
297
|
+
name: net_revenue
|
|
298
|
+
display_name: Net Revenue
|
|
299
|
+
domain_path: sales
|
|
300
|
+
table_schema: public
|
|
301
|
+
table_name: orders
|
|
302
|
+
expression: SUM(amount_cents - refunded_cents)
|
|
303
|
+
filters: status NOT IN ('cancelled', 'refunded')
|
|
304
|
+
unit: euro cents
|
|
305
|
+
synonyms:
|
|
306
|
+
- net sales
|
|
307
|
+
- revenue net of refunds
|
|
308
|
+
description: Gross order amount minus refunds, excluding cancelled and refunded orders.
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
A metric definition beats a prose "revenue = net revenue excluding tax" note —
|
|
312
|
+
the metric is surfaced structurally and can be reused; the prose drifts.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## 9. Identifiers and case — where SQL silently breaks
|
|
317
|
+
|
|
318
|
+
A stored `name` on a table or column is a **physical identifier**, not a display
|
|
319
|
+
string. The SQL agent quotes every identifier to preserve case
|
|
320
|
+
(`"schema"."table"."column"`), and quoted identifiers are **case-sensitive** in
|
|
321
|
+
Snowflake and Postgres. So the ontology's stored identifier case **must equal the
|
|
322
|
+
warehouse's stored case**, or generated SQL fails with errors like
|
|
323
|
+
`Schema "core" does not exist`.
|
|
324
|
+
|
|
325
|
+
- Seed the physical layer from schema introspection, which returns the real case
|
|
326
|
+
(Snowflake unquoted DDL is stored UPPERCASE; Postgres lowercase). Never
|
|
327
|
+
hand-lowercase identifiers from a spreadsheet and import them.
|
|
328
|
+
- This applies to **identifiers only**: `schema_name`/`table_name`, column `name`,
|
|
329
|
+
`grain`, join endpoints (`from_schema`/`from_table`/…), `column_pairs` and
|
|
330
|
+
`condition_sql`, and metric `table_schema`/`table_name`/`expression`/`filters`. Logical fields — domain paths, display names, descriptions,
|
|
331
|
+
**synonyms**, `context_md` — stay natural language; the matching layer there is
|
|
332
|
+
case-insensitive and should read naturally.
|
|
333
|
+
- **Schema-qualify everything.** Table references in SQL and prose carry the
|
|
334
|
+
`schema.TABLE` form (`dbt_core.fct_orders`), so a scope rule about `raw.users`
|
|
335
|
+
never accidentally catches `core.users`, and a prefix that doesn't match what
|
|
336
|
+
the warehouse actually has doesn't silently resolve to nothing.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 10. Examples and enums — handle like loaded weapons
|
|
341
|
+
|
|
342
|
+
An inaccurate example primes the agent **more strongly** than a correct prose
|
|
343
|
+
description, so a wrong example is worse than none.
|
|
344
|
+
|
|
345
|
+
- **Use real stored enum values verbatim.** Never hand-wave a list with "etc." —
|
|
346
|
+
the agent reads `etc.` as license to invent values. If you don't have the exact
|
|
347
|
+
values, omit the list rather than approximate it. When two columns supposedly
|
|
348
|
+
share an enum, verify they're identical before encoding both.
|
|
349
|
+
- **Be wary of analogies to external products** — they mislead the moment the
|
|
350
|
+
real mechanic diverges.
|
|
351
|
+
- **Don't describe things that don't exist.** Every table, column, and value you
|
|
352
|
+
reference must be real.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 11. Cross-cutting disciplines
|
|
357
|
+
|
|
358
|
+
- **One home, then de-duplicate.** When you learn a fact, put it in exactly one
|
|
359
|
+
place — the most specific layer that owns it — and, if the same fact already
|
|
360
|
+
appears elsewhere, remove the copy or leave a one-line pointer. When a
|
|
361
|
+
stakeholder clarifies something, scan every layer for places that need the
|
|
362
|
+
update and land it in only one.
|
|
363
|
+
- **Work back to the source, not a derived copy.** Model from the warehouse
|
|
364
|
+
schema and the authoritative source SQL, not from a previously-written
|
|
365
|
+
description. Each derivation loses information; chaining them compounds the
|
|
366
|
+
loss. Where sources disagree, pick one authoritative source and document why it
|
|
367
|
+
wins.
|
|
368
|
+
- **Scope every edit to what was asked.** Don't bundle unrequested companion
|
|
369
|
+
changes (extra metrics, synonyms, reorganizations) into a change. When you spot
|
|
370
|
+
an adjacent improvement, note it and let a human opt in. The one exception: a
|
|
371
|
+
metric you place in a *different* domain than the prose that discusses it takes
|
|
372
|
+
a one-line pointer in that prose, as part of the same change.
|
|
373
|
+
- **Ground writes in the real schema.** Before referencing a physical table or
|
|
374
|
+
column, confirm it exists. Don't invent tables or columns; the physical schema
|
|
375
|
+
is fixed.
|
|
376
|
+
- **Ask for the business meaning; don't guess.** A term like "active user" or
|
|
377
|
+
"last month" has a project-specific definition (time window, criteria, filters).
|
|
378
|
+
Encode the business's actual definition, not a textbook default.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## 12. Working in a git-synced repo
|
|
383
|
+
|
|
384
|
+
The repository is the source of truth. Edit the YAML, then verify before opening
|
|
385
|
+
a pull request — the CLI runs the same checks the platform does, from your
|
|
386
|
+
checkout:
|
|
387
|
+
|
|
388
|
+
- `cassis ontology check` — validate the files (YAML parse, round-trip, semantic
|
|
389
|
+
checks); the same gate that runs on the pull request.
|
|
390
|
+
- `cassis ontology fmt` — rewrite the files in canonical form, so hand or agent
|
|
391
|
+
edits round-trip cleanly and any dropped/unknown fields become visible in the
|
|
392
|
+
diff before you commit.
|
|
393
|
+
- `cassis ontology test -q "…"` — run a real question through the text-to-SQL
|
|
394
|
+
agent against your local files, to confirm a change actually *works* (e.g. a new
|
|
395
|
+
column gets picked), not just that it validates.
|
|
396
|
+
- `cassis eval run` — score the project's eval suite against your local files to
|
|
397
|
+
check the change for regressions before merging.
|
|
398
|
+
|
|
399
|
+
Merging the pull request syncs and publishes the ontology; nothing reaches
|
|
400
|
+
production answers until then.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
*This guide is the single canonical home for Cassis ontology modeling doctrine.
|
|
405
|
+
When a new pattern generalizes beyond one project, it belongs here.*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cassis-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Proprietary" }
|
|
@@ -19,6 +19,9 @@ cassis = "cassis_cli.main:app"
|
|
|
19
19
|
|
|
20
20
|
[tool.poetry]
|
|
21
21
|
packages = [{ include = "cassis_cli" }]
|
|
22
|
+
# Ship the bundled modeling guide (non-.py data) in the wheel/sdist — the CLI
|
|
23
|
+
# reads it at runtime to write <base_path>/AGENTS.md.
|
|
24
|
+
include = [{ path = "cassis_cli/ontology_design_guide.md", format = ["sdist", "wheel"] }]
|
|
22
25
|
|
|
23
26
|
[tool.poetry.group.dev.dependencies]
|
|
24
27
|
pytest = "^9.0.0"
|
|
File without changes
|
|
File without changes
|