cassis-cli 3.3.0__tar.gz → 3.4.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: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Validate, test and evaluate your Cassis context from your terminal, then publish it
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -33,7 +33,8 @@ Validate, test and evaluate your context from your terminal, then publish it. Th
33
33
  - 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).
34
34
  - `cassis eval run` runs the project's eval suite against your local context 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.
35
35
  - `cassis context test` runs individual questions through the text-to-SQL agent using your local context 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.
36
- - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite: after fixing a context issue, add the question users were failing on so `eval run` guards it from regressing.
36
+ - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite: after fixing a context issue, add the question users were failing on so `eval run` guards it from regressing. Cassis then runs the new case three times in the background, and `eval list-cases` flags it if the attempts fail or disagree.
37
+ - `cassis eval run --repeat N` runs every case N times (2 to 5) on the same context and model and compares the attempts per case: which cases flipped, and whether a flip came from the agent writing different SQL or the judge ruling differently on the same SQL. `cassis eval compare <run-id>` prints that comparison again. A repeat run measures noise: it opens no issue, is not the run shown before publishing, and exits 1 only when a case failed every attempt.
37
38
  - `cassis eval list-cases`, `cassis eval history` and `cassis eval delete-case` maintain the suite: list the current cases with their ids (flagging those whose gold SQL reads tables the context lacks), see how one case has behaved across runs (pass rate, whether repeated runs under one configuration disagree, divergences and timings), and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the context has since changed).
38
39
  - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the context changes Cassis will make (every change on a table placed in the context, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting context files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local context to the app (`--yes` in CI). The file speaks only for the schemas it contains: pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so the schema snapshot for a change still in a PR can be planned against safely; `--write-checkout` writes the context files it would produce into the checkout, to commit alongside the schema change.
39
40
  - `cassis projects list` lists the projects your API key can reach: id (what `--project` and `CASSIS_PROJECT_ID` take), name, published context version, and data-source dialect. A pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
@@ -141,6 +142,10 @@ cassis eval list-cases --project ...
141
142
  cassis eval history 019f0000-0000-7000-8000-0000000000ca --project ...
142
143
  cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
143
144
 
145
+ # Run the suite 3 times to see which cases flip, and why; print it again later:
146
+ cassis eval run --repeat 3
147
+ cassis eval compare 019f0000-0000-7000-8000-00000000aaaa --project ...
148
+
144
149
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
145
150
  cassis schema pull
146
151
 
@@ -203,7 +208,7 @@ Configuration (flags take precedence over env vars):
203
208
  | `--api-key` | `CASSIS_API_KEY` | none (required) |
204
209
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
205
210
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis`, must match the project's git-sync "Path" setting |
206
- | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval history, eval delete-case, test, issues, dbt push, dbt check) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
211
+ | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval history, eval compare, eval delete-case, test, issues, dbt push, dbt check) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
207
212
 
208
213
  `cassis eval run` also accepts `--case <id>` (repeatable; run only the named
209
214
  cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
@@ -11,7 +11,8 @@ Validate, test and evaluate your context from your terminal, then publish it. Th
11
11
  - 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).
12
12
  - `cassis eval run` runs the project's eval suite against your local context 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.
13
13
  - `cassis context test` runs individual questions through the text-to-SQL agent using your local context 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.
14
- - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite: after fixing a context issue, add the question users were failing on so `eval run` guards it from regressing.
14
+ - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite: after fixing a context issue, add the question users were failing on so `eval run` guards it from regressing. Cassis then runs the new case three times in the background, and `eval list-cases` flags it if the attempts fail or disagree.
15
+ - `cassis eval run --repeat N` runs every case N times (2 to 5) on the same context and model and compares the attempts per case: which cases flipped, and whether a flip came from the agent writing different SQL or the judge ruling differently on the same SQL. `cassis eval compare <run-id>` prints that comparison again. A repeat run measures noise: it opens no issue, is not the run shown before publishing, and exits 1 only when a case failed every attempt.
15
16
  - `cassis eval list-cases`, `cassis eval history` and `cassis eval delete-case` maintain the suite: list the current cases with their ids (flagging those whose gold SQL reads tables the context lacks), see how one case has behaved across runs (pass rate, whether repeated runs under one configuration disagree, divergences and timings), and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the context has since changed).
16
17
  - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the context changes Cassis will make (every change on a table placed in the context, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting context files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local context to the app (`--yes` in CI). The file speaks only for the schemas it contains: pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so the schema snapshot for a change still in a PR can be planned against safely; `--write-checkout` writes the context files it would produce into the checkout, to commit alongside the schema change.
17
18
  - `cassis projects list` lists the projects your API key can reach: id (what `--project` and `CASSIS_PROJECT_ID` take), name, published context version, and data-source dialect. A pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
@@ -119,6 +120,10 @@ cassis eval list-cases --project ...
119
120
  cassis eval history 019f0000-0000-7000-8000-0000000000ca --project ...
120
121
  cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
121
122
 
123
+ # Run the suite 3 times to see which cases flip, and why; print it again later:
124
+ cassis eval run --repeat 3
125
+ cassis eval compare 019f0000-0000-7000-8000-00000000aaaa --project ...
126
+
122
127
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
123
128
  cassis schema pull
124
129
 
@@ -181,7 +186,7 @@ Configuration (flags take precedence over env vars):
181
186
  | `--api-key` | `CASSIS_API_KEY` | none (required) |
182
187
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
183
188
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis`, must match the project's git-sync "Path" setting |
184
- | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval history, eval delete-case, test, issues, dbt push, dbt check) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
189
+ | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval history, eval compare, eval delete-case, test, issues, dbt push, dbt check) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
185
190
 
186
191
  `cassis eval run` also accepts `--case <id>` (repeatable; run only the named
187
192
  cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
@@ -594,6 +594,7 @@ def post_eval_run_start(
594
594
  branch: Optional[str] = None,
595
595
  label: Optional[str] = None,
596
596
  case_ids: Optional[list[str]] = None,
597
+ repeat_count: Optional[int] = None,
597
598
  transport: Optional[httpx.BaseTransport] = None,
598
599
  ) -> dict[str, Any]:
599
600
  """POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
@@ -607,6 +608,8 @@ def post_eval_run_start(
607
608
  body["label"] = label
608
609
  if case_ids is not None:
609
610
  body["test_case_ids"] = case_ids
611
+ if repeat_count is not None:
612
+ body["repeat_count"] = repeat_count
610
613
  try:
611
614
  # May carry the whole ontology tree in `files` — same budget as the
612
615
  # other whole-tree endpoints.
@@ -833,6 +836,41 @@ def get_eval_run_results(
833
836
  return result
834
837
 
835
838
 
839
+ class EvalCompareUnsupportedError(ApiError):
840
+ """The server predates run comparisons."""
841
+
842
+
843
+ def get_eval_run_comparison(
844
+ *,
845
+ api_url: str,
846
+ api_key: str,
847
+ project_id: str,
848
+ run_id: str,
849
+ transport: Optional[httpx.BaseTransport] = None,
850
+ ) -> dict[str, Any]:
851
+ """GET /api/ci/projects/{project_id}/eval/runs/{run_id}/comparison: the run's attempts compared per case."""
852
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/runs/{run_id}/comparison"
853
+ try:
854
+ with _client(transport=transport) as client:
855
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
856
+ except httpx.HTTPError as exc:
857
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
858
+ if response.status_code == 401:
859
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
860
+ # A server without the route answers the framework's own "Not Found",
861
+ # distinct from the "Eval run not found" a real lookup returns.
862
+ if response.status_code == 404 and _detail_or_text(response) == "Not Found":
863
+ raise EvalCompareUnsupportedError("This Cassis server does not compare runs yet; it needs a newer server.")
864
+ if response.status_code in (403, 404):
865
+ raise _project_scope_error(response)
866
+ if response.status_code >= 400:
867
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
868
+ result = _parse_json_response(response, url)
869
+ if not isinstance(result, dict) or not all(key in result for key in ("run_id", "summary", "cases")):
870
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
871
+ return result
872
+
873
+
836
874
  def post_eval_run_cancel(
837
875
  *,
838
876
  api_url: str,
@@ -111,7 +111,9 @@ _BASE_PATH_OPTION = typer.Option(
111
111
  DEFAULT_BASE_PATH, "--base-path", envvar="CASSIS_BASE_PATH", help="Context directory holding project.yml."
112
112
  )
113
113
  _POLL_INTERVAL_OPTION = typer.Option(2.0, "--poll-interval", help="Seconds between polls.")
114
- _TIMEOUT_OPTION = typer.Option(600.0, "--timeout", help="Give up waiting after this many seconds.")
114
+ _TIMEOUT_OPTION = typer.Option(
115
+ 600.0, "--timeout", help="Give up waiting after this many seconds (exit 3; the upload keeps processing)."
116
+ )
115
117
  _JSON_OPTION = typer.Option(False, "--json", help="Print the upload record as raw JSON (the summary goes to stderr).")
116
118
 
117
119
 
@@ -139,7 +141,9 @@ def push(
139
141
  baseline, and files each part of the context the change puts in doubt as
140
142
  an issue (impact "at risk"). Exits 0 when accepted or superseded by a
141
143
  newer upload, 1 when rejected (invalid files, or no relation matches the
142
- data source), 2 on usage errors, 3 on transport errors.
144
+ data source), 2 on usage errors, 3 on transport errors or a timeout (the
145
+ upload keeps processing server-side), or when Cassis fails on its side
146
+ while processing it.
143
147
  """
144
148
  record = _upload_and_wait(
145
149
  "push",
@@ -189,7 +193,8 @@ def check(
189
193
  nothing is filed and the baseline doesn't move. Exits 0 with or without
190
194
  findings, 1 when the upload is rejected (invalid files, or no baseline
191
195
  yet: run `cassis dbt push` on the production branch first), 2 on usage
192
- errors, 3 on transport errors.
196
+ errors, 3 on transport errors or a timeout (the upload keeps processing
197
+ server-side), or when Cassis fails on its side while processing it.
193
198
  """
194
199
  record = _upload_and_wait(
195
200
  "check",
@@ -287,7 +292,7 @@ def _upload_and_wait(
287
292
 
288
293
  def on_timeout(last: "dict[str, Any]") -> None:
289
294
  typer.secho(
290
- f"Timed out after {timeout:.0f}s: upload {observation_id} is still {last.get('status', '?')}. "
295
+ f"Timed out after {timeout:g}s: upload {observation_id} is still {last.get('status', '?')}. "
291
296
  "It keeps processing server-side.",
292
297
  fg=typer.colors.YELLOW,
293
298
  err=True,
@@ -25,6 +25,7 @@ from cassis_cli.api import (
25
25
  get_eval_case_history,
26
26
  get_eval_cases,
27
27
  get_eval_run,
28
+ get_eval_run_comparison,
28
29
  get_eval_run_results,
29
30
  post_eval_case_create,
30
31
  post_eval_run_cancel,
@@ -272,6 +273,22 @@ def _footprint_note(case: dict[str, Any]) -> Optional[str]:
272
273
  return "; ".join(reasons)
273
274
 
274
275
 
276
+ def _entry_check_note(case: dict[str, Any]) -> Optional[str]:
277
+ """One line on a case whose creation check flags it; None when stable or never checked."""
278
+ check = case.get("entry_check")
279
+ if not isinstance(check, dict):
280
+ return None
281
+ state = check.get("state")
282
+ score = f"{check.get('passed')}/{check.get('total')}" if check.get("total") else ""
283
+ if state == "flaky":
284
+ return f"flaky at creation ({score} attempts passed): its verdict is noise until the question or gold SQL is tightened"
285
+ if state == "failing":
286
+ return f"failing at creation ({score} attempts passed): check the gold SQL, or the context gap it exposes"
287
+ if state == "running":
288
+ return "entry check running (3 attempts on the current context)"
289
+ return None
290
+
291
+
275
292
  @app.command(name="add-case")
276
293
  def add_case(
277
294
  path: Path = typer.Argument(
@@ -328,6 +345,8 @@ def add_case(
328
345
  case is stored, so a case that cannot execute never enters the suite.
329
346
  The SQL comes from --gold-sql (inline) or --gold-sql-file (a file path;
330
347
  prefer it for multi-line SQL, which shell quoting mangles inline).
348
+ Cassis then runs the new case three times in the background, and
349
+ `cassis eval list-cases` flags it if the attempts fail or disagree.
331
350
  Exits 0 on creation, 1 on a duplicate question or gold SQL that does not
332
351
  run, 2 on usage errors, 3 on transport/API errors.
333
352
  """
@@ -379,6 +398,12 @@ def add_case(
379
398
  fg=typer.colors.YELLOW,
380
399
  err=True,
381
400
  )
401
+ check = case.get("entry_check")
402
+ if isinstance(check, dict) and check.get("state") == "running":
403
+ typer.echo(
404
+ " Checking it: 3 attempts run on the current context in the background; "
405
+ "`cassis eval list-cases` flags it if they fail or disagree."
406
+ )
382
407
  raise typer.Exit(EXIT_OK)
383
408
 
384
409
 
@@ -427,7 +452,9 @@ def list_cases(
427
452
  what `cassis eval delete-case` and `cassis eval history` take. A case
428
453
  whose gold SQL does not fully resolve against the context (a table the
429
454
  context lacks, an ambiguous name, SQL that does not parse) is flagged
430
- under its line, since such a case cannot be trusted to score a change.
455
+ under its line, since such a case cannot be trusted to score a change,
456
+ and so is a case whose three-attempt check after creation failed or
457
+ disagreed.
431
458
  Exits 0 on success, 2 on usage errors (including a --branch the project
432
459
  does not have), 3 on transport/API errors.
433
460
  """
@@ -459,6 +486,13 @@ def list_cases(
459
486
  if note:
460
487
  flagged += 1
461
488
  typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
489
+ check_note = _entry_check_note(case)
490
+ if check_note:
491
+ running = (case.get("entry_check") or {}).get("state") == "running"
492
+ typer.secho(
493
+ f" {'…' if running else '⚠'} {check_note}",
494
+ fg=None if running else typer.colors.YELLOW,
495
+ )
462
496
  if flagged:
463
497
  typer.secho(
464
498
  f"{flagged} case(s) have a gold SQL that does not fully resolve against the context; "
@@ -469,6 +503,55 @@ def list_cases(
469
503
  raise typer.Exit(EXIT_OK)
470
504
 
471
505
 
506
+ # How a flip reads to the person fixing it: which side of the evaluation moved.
507
+ _CAUSE_HINTS = {
508
+ "judge": "the judge ruled differently on the same SQL: check the gold SQL and the question's wording",
509
+ "generation": "the agent wrote different SQL from the same context: the context leaves room for both",
510
+ "both": "the agent wrote different SQL and the judge also wavered",
511
+ "unattributed": "the evidence decides neither side (a re-judge failed, or the same SQL returned different rows)",
512
+ }
513
+
514
+
515
+ def _print_comparison(comparison: dict[str, Any]) -> None:
516
+ """A repeat run's attempts per case: the same comparison the webapp's run page shows."""
517
+ summary = comparison.get("summary") or {}
518
+ repeat = comparison.get("repeat_count") or 1
519
+ cases = comparison.get("cases") or []
520
+ typer.secho(f"Repeat comparison: {repeat} attempts per case, {summary.get('cases', len(cases))} cases", bold=True)
521
+ causes = summary.get("causes") or {}
522
+ flipped = f"{summary.get('flipped', 0)} flipped"
523
+ if causes:
524
+ flipped += " (" + ", ".join(f"{k} {n}" for k, n in causes.items()) + ")"
525
+ typer.echo(
526
+ f" {summary.get('all_passed', 0)} passed every attempt · "
527
+ f"{summary.get('all_failed', 0)} failed every attempt · {flipped}"
528
+ )
529
+ if repeat < 2:
530
+ typer.echo(" Not a repeat run: start one with `cassis eval run --repeat N` to measure noise.")
531
+ return
532
+ shown = [c for c in cases if c.get("agreement") != "all_passed"]
533
+ if not shown:
534
+ return
535
+ typer.echo("")
536
+ for c in shown:
537
+ agreement = c.get("agreement")
538
+ icon = "~" if agreement == "flipped" else "✗"
539
+ color = typer.colors.YELLOW if agreement == "flipped" else typer.colors.RED
540
+ distinct = int(c.get("distinct_sql") or 0)
541
+ sqls = f"{distinct} SQL" if distinct == 1 else f"{distinct} SQLs"
542
+ cause = str(c.get("cause") or "")
543
+ question = str(c.get("question") or "").replace("\n", " ")
544
+ if len(question) > 60:
545
+ question = question[:57] + "..."
546
+ typer.secho(f" {icon} {c.get('passed', 0)}/{c.get('total', 0)} {cause:<13} {sqls:<8} {question}", fg=color)
547
+ typer.echo(f" {c.get('test_case_id')}")
548
+ seen = [k for k in _CAUSE_HINTS if k in causes]
549
+ if seen:
550
+ typer.echo("")
551
+ for k in seen:
552
+ typer.echo(f" {k}: {_CAUSE_HINTS[k]}")
553
+
554
+
472
555
  def _format_when(iso: str) -> str:
473
556
  """`2026-10-23 18:14` from an ISO timestamp; the raw text when it does not parse."""
474
557
  try:
@@ -633,6 +716,76 @@ def history(
633
716
  raise typer.Exit(EXIT_OK)
634
717
 
635
718
 
719
+ @app.command()
720
+ def compare(
721
+ run_id: str = typer.Argument(
722
+ ...,
723
+ help="Id of the eval run (printed by `cassis eval run`).",
724
+ ),
725
+ path: Path = typer.Option(
726
+ Path("."),
727
+ "--path",
728
+ help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
729
+ ),
730
+ project_id: Optional[str] = typer.Option(
731
+ None,
732
+ "--project",
733
+ envvar="CASSIS_PROJECT_ID",
734
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
735
+ ),
736
+ api_key: Optional[str] = typer.Option(
737
+ None,
738
+ "--api-key",
739
+ envvar="CASSIS_API_KEY",
740
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
741
+ ),
742
+ api_url: str = typer.Option(
743
+ DEFAULT_API_URL,
744
+ "--api-url",
745
+ envvar="CASSIS_API_URL",
746
+ help="Cassis API base URL.",
747
+ ),
748
+ base_path: str = typer.Option(
749
+ DEFAULT_BASE_PATH,
750
+ "--base-path",
751
+ envvar="CASSIS_BASE_PATH",
752
+ help="Repository directory the context is exported under (holds project.yml for the --project default).",
753
+ ),
754
+ json_output: bool = typer.Option(False, "--json", help="Print the comparison as raw JSON."),
755
+ ) -> None:
756
+ """Compare the attempts of a repeat run, case by case.
757
+
758
+ For a run started with `cassis eval run --repeat N`: how many cases passed
759
+ every attempt, failed every attempt or flipped, and for each flipping case
760
+ how many distinct SQLs the agent wrote and what moved between attempts:
761
+ `generation` (the agent wrote different SQL), `judge` (the judge ruled
762
+ differently on the same SQL), `both`, or `unattributed`. The same
763
+ comparison the webapp's run page shows. Exits 0 on success, 2 on usage
764
+ errors, 3 on transport/API errors (including a server too old to compare).
765
+ """
766
+ api_key = require_api_key(api_key)
767
+ project_id = resolve_project_id(project_id, path / Path(base_path))
768
+ try:
769
+ UUID(run_id)
770
+ except ValueError:
771
+ typer.secho(f"RUN_ID must be a UUID, got {run_id!r}.", fg=typer.colors.RED, err=True)
772
+ raise typer.Exit(EXIT_USAGE)
773
+ try:
774
+ comparison = get_eval_run_comparison(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
775
+ except ApiError as exc: # covers AuthError and EvalCompareUnsupportedError
776
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
777
+ raise typer.Exit(EXIT_TRANSPORT) from exc
778
+
779
+ if json_output:
780
+ typer.echo(json.dumps(comparison, indent=2))
781
+ raise typer.Exit(EXIT_OK)
782
+ if comparison.get("status") == "running":
783
+ typer.echo("The run is still in progress; its attempts are compared once it completes.")
784
+ raise typer.Exit(EXIT_OK)
785
+ _print_comparison(comparison)
786
+ raise typer.Exit(EXIT_OK)
787
+
788
+
636
789
  @app.command(name="delete-case")
637
790
  def delete_case(
638
791
  case_id: str = typer.Argument(
@@ -745,6 +898,13 @@ def run(
745
898
  "--label",
746
899
  help="Run label shown in the webapp's Evals page (default: the local git branch name).",
747
900
  ),
901
+ repeat: Optional[int] = typer.Option(
902
+ None,
903
+ "--repeat",
904
+ min=2,
905
+ max=5,
906
+ help="Run every case N times (2 to 5) as one diagnostic run, then compare the attempts per case.",
907
+ ),
748
908
  wait: bool = typer.Option(
749
909
  True,
750
910
  "--wait/--no-wait",
@@ -767,9 +927,14 @@ def run(
767
927
  an existing Cassis branch instead (no files are sent). With --case, only the
768
928
  named case(s) run — e.g. proving one fresh `add-case` in seconds instead of
769
929
  rerunning the whole suite; passed cases then print their SQL and evidence
770
- too, so you can check the case passes for the right reason. Exits 0 when
771
- the run completes with every case passed, 1 on any failed case / failed
772
- run / invalid tree, 2 on usage errors (including a --branch the project
930
+ too, so you can check the case passes for the right reason. With --repeat
931
+ N, every case runs N times under the same context and model, and the
932
+ command prints how the attempts compare per case (`cassis eval compare`);
933
+ a repeat run is diagnostic, so it files no issue and is not the run the
934
+ webapp shows before publishing. Exits 0 when
935
+ the run completes with every case passed (with --repeat: no case failed
936
+ every attempt, since a flip is the noise being measured), 1 on any failed
937
+ case / failed run / invalid tree, 2 on usage errors (including a --branch the project
773
938
  does not have), 3 on transport errors or --timeout.
774
939
 
775
940
  Failed cases (and, with --case, passed ones) print the generated SQL as
@@ -808,6 +973,7 @@ def run(
808
973
  branch=branch,
809
974
  label=label,
810
975
  case_ids=case,
976
+ repeat_count=repeat,
811
977
  )
812
978
  except EvalStartValidationError as exc:
813
979
  _print_validation_failure(exc.detail, base_path)
@@ -831,6 +997,15 @@ def run(
831
997
  total = run_record.get("total_cases", 0)
832
998
  ontology_label = run_record.get("ontology_label") or "unpublished context"
833
999
  run_url = _run_page_url(app_url or api_url, project_id, run_id)
1000
+ repeat_count = run_record.get("repeat_count")
1001
+ if repeat is not None and repeat > 1 and not repeat_count:
1002
+ # An older server drops the field: the run is an ordinary one, so say so
1003
+ # rather than print a comparison it never measured.
1004
+ typer.secho(
1005
+ "This Cassis server ignored --repeat: the run executes each case once, as an ordinary run.",
1006
+ fg=typer.colors.YELLOW,
1007
+ err=True,
1008
+ )
834
1009
 
835
1010
  if not wait:
836
1011
  if json_output:
@@ -841,7 +1016,10 @@ def run(
841
1016
  raise typer.Exit(EXIT_OK)
842
1017
 
843
1018
  # To stderr under --json so `cassis eval run --json | jq` gets only the record.
844
- typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.", err=json_output)
1019
+ started = f"Eval run {run_id} started: {total} cases against {ontology_label!r}"
1020
+ if repeat_count:
1021
+ started += f", {repeat_count} attempts each"
1022
+ typer.echo(f"{started}.", err=json_output)
845
1023
 
846
1024
  try:
847
1025
  final_run, results = _wait_for_run(
@@ -849,7 +1027,7 @@ def run(
849
1027
  api_key=api_key,
850
1028
  project_id=project_id,
851
1029
  run_id=run_id,
852
- total=total,
1030
+ total=total * (repeat_count or 1),
853
1031
  poll_interval=poll_interval,
854
1032
  timeout=timeout,
855
1033
  json_output=json_output,
@@ -865,21 +1043,44 @@ def run(
865
1043
  typer.secho(f"Could not cancel the run: {exc}", fg=typer.colors.RED, err=True)
866
1044
  raise typer.Exit(EXIT_INTERRUPTED)
867
1045
 
1046
+ comparison: Optional[dict[str, Any]] = None
1047
+ if repeat_count and final_run.get("status") == "completed":
1048
+ try:
1049
+ comparison = get_eval_run_comparison(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
1050
+ except ApiError as exc:
1051
+ typer.secho(f"Could not compare the attempts: {exc}", fg=typer.colors.YELLOW, err=True)
1052
+
868
1053
  if json_output:
869
- typer.echo(json.dumps({"run": final_run, "results": results, "run_url": run_url}, indent=2))
1054
+ record: dict[str, Any] = {"run": final_run, "results": results, "run_url": run_url}
1055
+ if comparison is not None:
1056
+ record["comparison"] = comparison
1057
+ typer.echo(json.dumps(record, indent=2))
870
1058
  else:
871
1059
  typer.echo("")
872
- _print_results_table(results, passed_evidence=bool(case))
1060
+ if comparison is not None:
1061
+ _print_comparison(comparison)
1062
+ else:
1063
+ _print_results_table(results, passed_evidence=bool(case))
873
1064
  typer.echo("")
874
1065
  _print_summary(final_run)
875
1066
  typer.echo(f"View details: {run_url}")
876
1067
 
877
- status = final_run.get("status")
878
- if status == "completed" and all(r.get("status") == _PASSED for r in results):
1068
+ if final_run.get("status") == "completed" and _suite_passed(results, repeated=bool(repeat_count)):
879
1069
  raise typer.Exit(EXIT_OK)
880
1070
  raise typer.Exit(EXIT_VALIDATION_FAILED)
881
1071
 
882
1072
 
1073
+ def _suite_passed(results: "list[dict[str, Any]]", *, repeated: bool) -> bool:
1074
+ """Whether a completed run passes: every case passed, or on a repeat run, no case failed every attempt."""
1075
+ if not repeated:
1076
+ return all(r.get("status") == _PASSED for r in results)
1077
+ passed_once: dict[str, bool] = {}
1078
+ for r in results:
1079
+ key = str(r.get("test_case_id") or r.get("question"))
1080
+ passed_once[key] = passed_once.get(key, False) or r.get("status") == _PASSED
1081
+ return all(passed_once.values())
1082
+
1083
+
883
1084
  def _wait_for_run(
884
1085
  *,
885
1086
  api_url: str,
@@ -928,7 +1129,7 @@ def _wait_for_run(
928
1129
 
929
1130
  def on_timeout(_snapshot: "tuple[dict[str, Any], list[dict[str, Any]]]") -> None:
930
1131
  typer.secho(
931
- f"Timed out after {timeout:.0f}s waiting for run {run_id}. "
1132
+ f"Timed out after {timeout:g}s waiting for run {run_id}. "
932
1133
  "The run keeps going server-side — see the webapp's Evals page.",
933
1134
  fg=typer.colors.YELLOW,
934
1135
  err=True,
@@ -133,8 +133,9 @@ def list_issues(
133
133
 
134
134
  The order: editorial priority (P1, P2, P3, then unranked), then impact,
135
135
  then reach, then how the issue was found (dbt findings first), then
136
- recurrence. Prints each issue's id, impact, occurrence count, reach (the
137
- chat answers that used its context tables in the last 30 days; `-` when
136
+ recurrence. Prints each issue's priority (`-` when unranked), id, impact,
137
+ origin (conversation, eval or dbt), occurrence count, reach (the chat
138
+ answers that used any of its context tables in the last 30 days; `-` when
138
139
  not measured, because its sources name no context table), status, primary
139
140
  context domain (`-` when Cassis could not attach one) and title; the id is what
140
141
  `cassis issues show`, `resolve`, `dismiss` and `reopen` take. Triage one
@@ -176,6 +177,8 @@ def list_issues(
176
177
  reach = issue.get("reach_count")
177
178
  rows.append(
178
179
  [
180
+ # The editorial priority the list is sorted by first; `-` when unranked.
181
+ str(issue.get("priority") or "-"),
179
182
  str(issue.get("id")),
180
183
  str(issue.get("impact")),
181
184
  # Always printed (conversation, eval or dbt), so every line has the same columns.
@@ -621,7 +624,7 @@ def _wait_for_analysis(
621
624
 
622
625
  def on_timeout(_run: "dict[str, Any]") -> None:
623
626
  typer.secho(
624
- f"Timed out after {timeout:.0f}s waiting for analysis run {run_id}. "
627
+ f"Timed out after {timeout:g}s waiting for analysis run {run_id}. "
625
628
  "The analysis keeps going server-side — see the webapp's Issues page.",
626
629
  fg=typer.colors.YELLOW,
627
630
  err=True,
@@ -889,7 +889,7 @@ def _wait_for_plan(
889
889
  else "`cassis status` shows the outcome once it finishes"
890
890
  )
891
891
  typer.secho(
892
- f"Timed out after {timeout:.0f}s: the {what} is still {record.get('status', '?')}. "
892
+ f"Timed out after {timeout:g}s: the {what} is still {record.get('status', '?')}. "
893
893
  f"It keeps running server-side; {resume}",
894
894
  fg=typer.colors.YELLOW,
895
895
  err=True,
@@ -197,7 +197,7 @@ def status(
197
197
 
198
198
  def on_timeout(_snapshot: "Tuple[dict[str, Any], str, bool]") -> None:
199
199
  typer.secho(
200
- f"Timed out after {timeout:.0f}s: the published version still does not match the local HEAD.",
200
+ f"Timed out after {timeout:g}s: the published version still does not match the local HEAD.",
201
201
  fg=typer.colors.YELLOW,
202
202
  err=True,
203
203
  )
@@ -109,6 +109,7 @@ def verify(
109
109
  branch=None,
110
110
  case=None,
111
111
  label=None,
112
+ repeat=None,
112
113
  wait=True,
113
114
  poll_interval=5.0,
114
115
  timeout=1800.0,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "3.3.0"
3
+ version = "3.4.0"
4
4
  description = "Validate, test and evaluate your Cassis context from your terminal, then publish it"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
File without changes
File without changes