cassis-cli 2.0.0__tar.gz → 2.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Validate, test and evaluate your Cassis ontology from your terminal, then publish it
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -104,6 +104,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
104
104
 
105
105
  # Start the run and return immediately (poll in the webapp):
106
106
  cassis eval run --project ... --no-wait
107
+ ```
108
+
109
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
110
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
111
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
112
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
113
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
114
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
115
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
116
+
117
+ ```bash
107
118
 
108
119
  # Probe questions through the text-to-SQL agent using the local ontology files
109
120
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -197,7 +208,7 @@ cassis ontology fmt --check
197
208
  | ---- | ------------------------------------------------------------------------------ |
198
209
  | 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) |
199
210
  | 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; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
200
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
211
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
201
212
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
202
213
 
203
214
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -82,6 +82,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
82
82
 
83
83
  # Start the run and return immediately (poll in the webapp):
84
84
  cassis eval run --project ... --no-wait
85
+ ```
86
+
87
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
88
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
89
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
90
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
91
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
92
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
93
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
94
+
95
+ ```bash
85
96
 
86
97
  # Probe questions through the text-to-SQL agent using the local ontology files
87
98
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -175,7 +186,7 @@ cassis ontology fmt --check
175
186
  | ---- | ------------------------------------------------------------------------------ |
176
187
  | 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) |
177
188
  | 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; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
178
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
189
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
179
190
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
180
191
 
181
192
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -106,6 +106,21 @@ class EvalRunActiveError(ApiError):
106
106
  """Another eval run is already active for the project (409)."""
107
107
 
108
108
 
109
+ class EvalBranchNotFoundError(ApiError):
110
+ """`--branch` named an ontology branch the project does not have (404)."""
111
+
112
+
113
+ def _is_branch_not_found(detail: object) -> bool:
114
+ """Whether a 404 detail is the start endpoint's missing-branch message.
115
+
116
+ Wire contract with `start_eval_run` in backend `endpoints/ci/eval.py`,
117
+ matched the way `delete_eval_case` matches its own 404 text. A server that
118
+ words it differently falls through to the generic project-scope error, so an
119
+ older server degrades rather than misreporting.
120
+ """
121
+ return isinstance(detail, str) and detail.startswith("Branch ") and detail.endswith(" not found")
122
+
123
+
109
124
  def _project_scope_error(response: httpx.Response) -> ApiError:
110
125
  # Surface the server's own message when it names the missing resource
111
126
  # (e.g. "Branch 'x' not found") — the generic hint covers the rest.
@@ -544,6 +559,11 @@ def post_eval_run_start(
544
559
  "An eval run is already active for this project — wait for it to finish or cancel it "
545
560
  "(in the webapp's Evals page, or with the run id printed when it was started)."
546
561
  )
562
+ if response.status_code == 404 and _is_branch_not_found(_detail_or_text(response)):
563
+ # Distinct from a project-scope 404: the key and project are fine, the
564
+ # branch name is not, and the generic copy sends the user to check
565
+ # permissions instead.
566
+ raise EvalBranchNotFoundError(_detail_or_text(response))
547
567
  if response.status_code in (403, 404):
548
568
  raise _project_scope_error(response)
549
569
  if response.status_code >= 400:
@@ -14,6 +14,7 @@ from cassis_cli.api import (
14
14
  DEFAULT_API_URL,
15
15
  ApiError,
16
16
  AuthError,
17
+ EvalBranchNotFoundError,
17
18
  EvalCaseExistsError,
18
19
  EvalCaseGoldSqlError,
19
20
  EvalCaseNotFoundError,
@@ -56,6 +57,15 @@ _STATUS_COLORS = {
56
57
  "plan_unexecutable": typer.colors.YELLOW,
57
58
  }
58
59
 
60
+ # Caps on the evidence printed under a failed case. Bounded so a suite of
61
+ # failures stays readable in a CI log, and always announced when they bite.
62
+ _MAX_ERROR_CHARS = 200
63
+ _MAX_REASONING_CHARS = 500
64
+ _MAX_SQL_LINES = 20
65
+ # A line cap alone bounds nothing: a gold SQL written as one long line in a YAML
66
+ # case is a single line of any size, printed twice per failing case.
67
+ _MAX_SQL_CHARS = 2000
68
+
59
69
 
60
70
  def _run_page_url(app_url: str, project_id: str, run_id: str) -> str:
61
71
  """Webapp URL of the run's detail view (Evals page, runs tab)."""
@@ -118,6 +128,84 @@ def _print_validation_failure(detail: object, base_path: str) -> None:
118
128
  typer.secho(str(detail), fg=typer.colors.RED, err=True)
119
129
 
120
130
 
131
+ def _shorten(text: str, limit: int) -> str:
132
+ """Shorten to `limit` characters and say so. A silent cut can drop the cause."""
133
+ text = " ".join(text.split())
134
+ if len(text) <= limit:
135
+ return text
136
+ return f"{text[:limit]}... (truncated, {len(text)} chars)"
137
+
138
+
139
+ def _print_sql(label: str, sql: str) -> None:
140
+ """Print SQL under a failed case, bounded on both lines and characters."""
141
+ lines = sql.strip().splitlines()
142
+ typer.echo(f" {label}:")
143
+ budget = _MAX_SQL_CHARS
144
+ shown = 0
145
+ for line in lines[:_MAX_SQL_LINES]:
146
+ if budget <= 0:
147
+ break
148
+ if len(line) > budget:
149
+ typer.echo(f" {line[:budget]}... (truncated, {len(line)} chars)")
150
+ budget = 0
151
+ else:
152
+ typer.echo(f" {line}")
153
+ budget -= len(line)
154
+ shown += 1
155
+ if shown < len(lines):
156
+ typer.echo(f" ... ({len(lines) - shown} more lines)")
157
+
158
+
159
+ def _print_failure_evidence(r: dict[str, Any]) -> None:
160
+ """Print what a failed case needs to be diagnosed here, without row values.
161
+
162
+ Which field carries the reason depends on the status: a missing-concept
163
+ failure explains itself through `missing_concepts`, a judge rejection
164
+ through `judge_reasoning`, a data mismatch through `error` and the row
165
+ counts. Printing only `error` — as this did — showed nothing at all for the
166
+ first two.
167
+
168
+ Expected and actual row values are deliberately not printed. They ride in
169
+ `--json` and the webapp instead: `cassis verify` output is a CI job log, and
170
+ warehouse values in a job log travel further than whoever reads it intends.
171
+
172
+ Every field is read with `.get`, so an older server that returns none of
173
+ them degrades to the error line alone.
174
+ """
175
+ error = r.get("error")
176
+ if error:
177
+ typer.echo(f" {_shorten(str(error), _MAX_ERROR_CHARS)}")
178
+
179
+ names = [str(c["name"]) for c in (r.get("missing_concepts") or []) if isinstance(c, dict) and c.get("name")]
180
+ if names:
181
+ typer.echo(f" Missing concepts: {', '.join(names)}")
182
+
183
+ if r.get("judge_verdict"):
184
+ typer.echo(f" Judge verdict: {r['judge_verdict']}")
185
+ if r.get("judge_reasoning"):
186
+ typer.echo(f" Judge reasoning: {_shorten(str(r['judge_reasoning']), _MAX_REASONING_CHARS)}")
187
+
188
+ expected, actual = r.get("expected_row_count"), r.get("actual_row_count")
189
+ if expected is not None or actual is not None:
190
+ shown_actual = "-" if actual is None else str(actual)
191
+ shown_expected = "-" if expected is None else str(expected)
192
+ typer.echo(f" Rows: {shown_actual} actual vs {shown_expected} expected")
193
+
194
+ # The agent's own words, for the statuses that carry no other explanation
195
+ # (a conversational reply instead of a plan, for one).
196
+ content = r.get("content")
197
+ if content and not error and not names and not r.get("judge_reasoning"):
198
+ typer.echo(f" {_shorten(str(content), _MAX_ERROR_CHARS)}")
199
+
200
+ if r.get("generated_sql"):
201
+ _print_sql("Generated SQL", str(r["generated_sql"]))
202
+ if r.get("gold_sql"):
203
+ label = "Gold SQL"
204
+ if r.get("gold_sql_from_live_case"):
205
+ label = "Gold SQL (the case as it stands now; this result predates snapshotting)"
206
+ _print_sql(label, str(r["gold_sql"]))
207
+
208
+
121
209
  def _print_results_table(results: list[dict[str, Any]]) -> None:
122
210
  for r in sorted(results, key=lambda r: (r.get("status") == _PASSED, r.get("question") or "")):
123
211
  status = r.get("status", "?")
@@ -129,8 +217,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
129
217
  question = question[:67] + "..."
130
218
  line = f" {icon} {status:<17} {duration:>5} {question}"
131
219
  typer.secho(line, fg=color)
132
- if r.get("error"):
133
- typer.echo(f" {str(r['error'])[:200]}")
220
+ if status == _PASSED:
221
+ continue
222
+ _print_failure_evidence(r)
134
223
 
135
224
 
136
225
  def _print_summary(run: dict[str, Any]) -> None:
@@ -448,7 +537,13 @@ def run(
448
537
  named case(s) run — e.g. proving one fresh `add-case` in seconds instead of
449
538
  rerunning the whole suite. Exits 0 when the run completes with every case
450
539
  passed, 1 on any failed case / failed run / invalid tree, 2 on usage
451
- errors, 3 on transport errors or --timeout.
540
+ errors (including a --branch the project does not have), 3 on transport
541
+ errors or --timeout.
542
+
543
+ Failed cases print the generated SQL as the agent wrote it. Row values are
544
+ never printed (they are in --json), but an agent that read your data while
545
+ planning can carry a value it saw into a SQL literal — so treat this output
546
+ as sensitive as the queries themselves.
452
547
  """
453
548
  api_key = require_api_key(api_key)
454
549
  for case_id in case or []:
@@ -485,6 +580,17 @@ def run(
485
580
  except EvalStartValidationError as exc:
486
581
  _print_validation_failure(exc.detail, base_path)
487
582
  raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
583
+ except EvalBranchNotFoundError as exc:
584
+ # Bad input, not a transport or permissions problem — so exit 2, and say
585
+ # what --branch actually takes instead of pointing at the API key.
586
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
587
+ typer.echo(
588
+ "--branch runs against an ontology branch that already exists in Cassis "
589
+ "(create it in the webapp, or push it with `cassis ontology push`). "
590
+ "To label a run after your local checkout's branch, use --label instead.",
591
+ err=True,
592
+ )
593
+ raise typer.Exit(EXIT_USAGE) from exc
488
594
  except ApiError as exc: # covers AuthError and EvalRunActiveError too
489
595
  typer.secho(str(exc), fg=typer.colors.RED, err=True)
490
596
  raise typer.Exit(EXIT_TRANSPORT) from exc
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
31
31
  # Monotonic version of the doctrine text below. Bump it whenever
32
32
  # ontology_design_guide.md changes (a backend test enforces the pairing) — it
33
33
  # is what lets an older writer recognize a newer guide and leave it alone.
34
- DOCTRINE_VERSION = 6
34
+ DOCTRINE_VERSION = 7
35
35
 
36
36
  # Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
37
37
  # the server-side git export writes the same file, and differing banners would
@@ -107,7 +107,7 @@ Put each fact at the lowest layer that fully owns it:
107
107
 
108
108
  ```
109
109
  column synonym / column description single-column facts: meaning, encoding, unit, date format, nulls
110
- → table description single-table facts: grain, scope, mandatory filters, "use this not that"
110
+ → table description single-table facts: scope, mandatory filters, "use this not that"
111
111
  → domain context_md cross-table routing, disambiguation, vocabulary spanning tables
112
112
  → root context truly global rules only: corporate identity, entity hierarchy, global conventions
113
113
  ```
@@ -134,13 +134,18 @@ otherwise push it down.
134
134
  Per-table facts go on that table's `description`; keep only genuinely
135
135
  cross-table routing in `context_md`.
136
136
  - Metrics are surfaced with their full definition → don't re-paste a formula in
137
- prose. Point to a metric by name **only** when it lives in a *different* domain
137
+ prose, and that includes the metric's **own** `description` and `notes` (§8).
138
+ Point to a metric by name **only** when it lives in a *different* domain
138
139
  than the prose discussing it; a domain's own metrics are surfaced alongside its
139
140
  `context_md` already.
140
141
  - Joins are surfaced with their condition and cardinality → don't enumerate join
141
142
  keys in prose. The only join content that belongs in prose is a join+filter
142
143
  *recipe* no single structured join can express.
143
144
 
145
+ **Short or empty beats redundant.** When the structured fields already say
146
+ everything worth saying, leave the prose field short or empty. An empty
147
+ description costs nothing; a redundant one goes stale.
148
+
144
149
  ---
145
150
 
146
151
  ## 4. Domains and `context_md`
@@ -188,9 +193,9 @@ domain. Everything domain-specific moves down.
188
193
 
189
194
  A table's `description` answers *"what is this, and what must the SQL agent know
190
195
  to use it correctly?"* — business meaning **plus** the correctness facts (scope,
191
- mandatory filters, gotchas). Always **state the grain explicitly** ("one row per
192
- order"; "one row per (user_id, day)") so the agent aggregates correctly, and set
193
- the `grain` field to the identifying column(s). Don't enumerate the columns — the
196
+ mandatory filters, gotchas). The grain goes in the `grain` field the identifying
197
+ column(s) not in the description: the agent reads it structurally, and "one row
198
+ per order" in prose is a second copy. Don't enumerate the columns either — the
194
199
  column list is surfaced automatically.
195
200
 
196
201
  Use `synonyms` for alternate names a user might say ("purchases", "transactions"),
@@ -219,10 +224,10 @@ in the `sql` per §7.
219
224
  ## 6. Columns
220
225
 
221
226
  Column enrichment is `description`, `unit`, and `synonyms` — you cannot add or
222
- rename columns (they come from introspection). The `description` is the SQL
223
- agent's only per-column guidance, so it carries both the semantic meaning and the
224
- SQL-correctness facts: possible values, encoding, units, date format, null
225
- behavior, any mandatory filter.
227
+ rename columns (they come from introspection). The agent sees a column's name,
228
+ type, `unit` and `synonyms` structurally; the `description` carries what those
229
+ don't: meaning, possible values, encoding, date format, null behavior, any
230
+ mandatory filter.
226
231
 
227
232
  - **Prioritize the trap columns.** Disambiguate similarly-named columns
228
233
  (`created_at` vs `updated_at`, `amount` vs `net_amount`), numeric-encoded
@@ -232,6 +237,14 @@ behavior, any mandatory filter.
232
237
  - **Set `unit`** whenever a number is a currency, percentage, or duration —
233
238
  aggregation correctness depends on it.
234
239
  - **Use `synonyms`** for 1–3 short, plain alternate terms a user might use.
240
+ - **Facts, not SQL.** Say what the column holds and how it is encoded ("TRUE =
241
+ the account can sign in"), never a fragment to paste ("filter with
242
+ `is_active = TRUE`", "count people with `COUNT(DISTINCT id)`"). Writing the SQL
243
+ is the agent's job.
244
+ - **Relationships live in joins.** A foreign-key column says what it identifies
245
+ ("the owning organization"), not which table it references or on what
246
+ condition; the join carries that, and a description repeating it drifts when
247
+ the join changes.
235
248
  - **Keep the join key queryable.** A foreign-key column stays a real, described
236
249
  column even when a join covers the relationship — hiding it leaves the agent
237
250
  unable to filter or group on it without traversing the join. Test: imagine a
@@ -312,12 +325,33 @@ unit: euro cents
312
325
  synonyms:
313
326
  - net sales
314
327
  - revenue net of refunds
315
- description: Gross order amount minus refunds, excluding cancelled and refunded orders.
328
+ description: Revenue kept after refunds; the top-line sales figure.
316
329
  ```
317
330
 
318
331
  A metric definition beats a prose "revenue = net revenue excluding tax" note —
319
332
  the metric is surfaced structurally and can be reused; the prose drifts.
320
333
 
334
+ **Definition in the fields, meaning in the prose.** `expression`, `filters`,
335
+ `table_schema`/`table_name` and `unit` *are* the definition. Every prose field —
336
+ including the metric's own `description` and `notes` — says what the number means
337
+ and when to use it, never how it is computed: no columns, thresholds, time windows,
338
+ or numerator/denominator. A rule copied into prose is a second definition, and the
339
+ two drift apart (one metric shipped with a 90-day window in `filters` and "30 days"
340
+ in its own description). Business intent is fine ("draft bookings are not
341
+ commitments"); its encoding (`status <> 'DRAFT'`) is not. This holds even when the
342
+ metric's name *is* the term being defined ("active partners"): the reader sees
343
+ `filters` right next to the description. Disambiguate from sibling metrics by the
344
+ question each answers, never by restating their windows or filters. If the fields
345
+ say it all, keep the description to a phrase or leave it empty.
346
+
347
+ ```yaml
348
+ # Bad — restates the definition
349
+ description: Active partners = COUNT(DISTINCT partner_id) with a non-cancelled
350
+ booking in the last 90 days.
351
+ # Good — what the number is for
352
+ description: Partners still trading; the activity count used for outreach targets.
353
+ ```
354
+
321
355
  ---
322
356
 
323
357
  ## 9. Identifiers and case — where SQL silently breaks
@@ -351,7 +385,9 @@ description, so a wrong example is worse than none.
351
385
 
352
386
  - **Use real stored enum values verbatim.** Never hand-wave a list with "etc." —
353
387
  the agent reads `etc.` as license to invent values. If you don't have the exact
354
- values, omit the list rather than approximate it. When two columns supposedly
388
+ values, omit the list rather than approximate it. A `DISTINCT` over the column
389
+ *is* the list of stored values: write it plainly ("Stored values: 'EDITOR'"),
390
+ with no "observed so far" hedge and no counts. When two columns supposedly
355
391
  share an enum, verify they're identical before encoding both.
356
392
  - **Be wary of analogies to external products** — they mislead the moment the
357
393
  real mechanic diverges.
@@ -390,6 +426,14 @@ description, so a wrong example is worse than none.
390
426
  - **Ground writes in the real schema.** Before referencing a physical table or
391
427
  column, confirm it exists. Don't invent tables or columns; the physical schema
392
428
  is fixed.
429
+ - **Never persist a measurement.** Prose holds stable encodings and business
430
+ definitions, not what the data shows today. Row counts, distinct counts, shares,
431
+ distributions, funnel rates and "as of" / "currently" / "observed" statements go
432
+ stale silently, and the answering agent repeats them as fact. Query the data to
433
+ *verify* a stable fact (stored enum values, a date format, null behaviour) and
434
+ write only the fact: "Stored values: 'EDITOR', 'ADMIN'", not "only 'EDITOR'
435
+ observed (6 of 6, 100%)". Complete value lists and constants the business fixes
436
+ (a 14-day trial) are facts, not measurements.
393
437
  - **Ask for the business meaning; don't guess.** A term like "active user" or
394
438
  "last month" has a project-specific definition (time window, criteria, filters).
395
439
  Encode the business's actual definition, not a textbook default.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "Validate, test and evaluate your Cassis ontology from your terminal, then publish it"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
File without changes
File without changes