cassis-cli 1.6.0__tar.gz → 1.7.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-1.6.0 → cassis_cli-1.7.0}/PKG-INFO +8 -3
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/README.md +7 -2
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/api.py +113 -4
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/common.py +3 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/eval.py +15 -6
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/guide.py +1 -1
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/issues.py +185 -2
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/ontology_design_guide.md +9 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/schema.py +3 -1
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/pyproject.toml +1 -1
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/LICENSE +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/NOTICE +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/__init__.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/main.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/ontology.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/projects.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/source_changes.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/status.py +0 -0
- {cassis_cli-1.6.0 → cassis_cli-1.7.0}/cassis_cli/verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.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
|
|
@@ -132,6 +132,11 @@ cassis schema push schema.sql
|
|
|
132
132
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
133
133
|
cassis projects list
|
|
134
134
|
|
|
135
|
+
# Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
|
|
136
|
+
# webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
|
|
137
|
+
# and Ctrl-C cancels the run server-side and exits 130):
|
|
138
|
+
cassis issues analyze
|
|
139
|
+
|
|
135
140
|
# Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
|
|
136
141
|
cassis issues list --status open
|
|
137
142
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
@@ -190,9 +195,9 @@ cassis ontology fmt --check
|
|
|
190
195
|
| Code | Meaning |
|
|
191
196
|
| ---- | ------------------------------------------------------------------------------ |
|
|
192
197
|
| 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) |
|
|
193
|
-
| 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; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
198
|
+
| 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 push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
194
199
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
195
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
|
|
200
|
+
| 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 |
|
|
196
201
|
|
|
197
202
|
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
198
203
|
20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
|
|
@@ -110,6 +110,11 @@ cassis schema push schema.sql
|
|
|
110
110
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
111
111
|
cassis projects list
|
|
112
112
|
|
|
113
|
+
# Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
|
|
114
|
+
# webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
|
|
115
|
+
# and Ctrl-C cancels the run server-side and exits 130):
|
|
116
|
+
cassis issues analyze
|
|
117
|
+
|
|
113
118
|
# Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
|
|
114
119
|
cassis issues list --status open
|
|
115
120
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
@@ -168,9 +173,9 @@ cassis ontology fmt --check
|
|
|
168
173
|
| Code | Meaning |
|
|
169
174
|
| ---- | ------------------------------------------------------------------------------ |
|
|
170
175
|
| 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) |
|
|
171
|
-
| 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; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
176
|
+
| 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 push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
172
177
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
173
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
|
|
178
|
+
| 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 |
|
|
174
179
|
|
|
175
180
|
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
176
181
|
20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
|
|
@@ -635,7 +635,11 @@ def post_eval_run_cancel(
|
|
|
635
635
|
|
|
636
636
|
|
|
637
637
|
class IssueNotFoundError(ApiError):
|
|
638
|
-
"""The project has no issue
|
|
638
|
+
"""The project has no issue, occurrence or issue-analysis run with this id.
|
|
639
|
+
|
|
640
|
+
Raised off the server's exact 404 detail, so it separates "this id doesn't
|
|
641
|
+
exist" from "this project isn't in scope for your key", which 404s too.
|
|
642
|
+
"""
|
|
639
643
|
|
|
640
644
|
|
|
641
645
|
def _get_issue_json(
|
|
@@ -645,8 +649,14 @@ def _get_issue_json(
|
|
|
645
649
|
*,
|
|
646
650
|
params: Optional[dict[str, str]] = None,
|
|
647
651
|
not_found_message: Optional[str] = None,
|
|
652
|
+
not_found_detail: str = "Issue not found",
|
|
648
653
|
) -> Any:
|
|
649
|
-
"""GET an issues URL with the shared error mapping.
|
|
654
|
+
"""GET an issues URL with the shared error mapping.
|
|
655
|
+
|
|
656
|
+
`not_found_detail` is the server's exact 404 detail for "this id does not
|
|
657
|
+
exist in the project" — `"Issue not found"` for issues and occurrences,
|
|
658
|
+
`"Issue-analysis run not found"` for analysis runs.
|
|
659
|
+
"""
|
|
650
660
|
try:
|
|
651
661
|
with _client(transport=transport) as client:
|
|
652
662
|
response = client.get(url, params=params, headers={"Authorization": f"Bearer {api_key}"})
|
|
@@ -657,8 +667,8 @@ def _get_issue_json(
|
|
|
657
667
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
658
668
|
# Exact-match wire contract with the issue endpoints' 404 detail (see the
|
|
659
669
|
# issue routes in backend/app/endpoints/ci.py): it distinguishes a missing
|
|
660
|
-
# issue or
|
|
661
|
-
if response.status_code == 404 and not_found_message and _detail_or_text(response) ==
|
|
670
|
+
# issue, occurrence or run (exit 1) from a project-scope 404 (exit 3).
|
|
671
|
+
if response.status_code == 404 and not_found_message and _detail_or_text(response) == not_found_detail:
|
|
662
672
|
raise IssueNotFoundError(not_found_message)
|
|
663
673
|
if response.status_code in (403, 404):
|
|
664
674
|
raise _project_scope_error(response)
|
|
@@ -925,3 +935,102 @@ def post_ontology_test(
|
|
|
925
935
|
if not isinstance(result, dict) or "status" not in result:
|
|
926
936
|
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
927
937
|
return result
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
class IssueAnalysisActiveError(ApiError):
|
|
941
|
+
"""An issue-analysis run is already in flight for the project (HTTP 409)."""
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
class NothingToAnalyzeError(ApiError):
|
|
945
|
+
"""Every conversation on the project has already been analyzed (HTTP 422)."""
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
_ANALYSIS_RUN_KEYS = ("id", "status", "total_chats", "chats_analyzed", "occurrences_created", "issues_touched")
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _check_analysis_run_shape(result: Any, url: str) -> dict[str, Any]:
|
|
952
|
+
if not isinstance(result, dict) or not all(key in result for key in _ANALYSIS_RUN_KEYS):
|
|
953
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
954
|
+
return result
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def post_issue_analysis_start(
|
|
958
|
+
*,
|
|
959
|
+
api_url: str,
|
|
960
|
+
api_key: str,
|
|
961
|
+
project_id: str,
|
|
962
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
"""POST /api/ci/projects/{project_id}/issue-analysis/runs and return the run record."""
|
|
965
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs"
|
|
966
|
+
try:
|
|
967
|
+
with _client(transport=transport) as client:
|
|
968
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
969
|
+
except httpx.HTTPError as exc:
|
|
970
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
971
|
+
|
|
972
|
+
if response.status_code == 401:
|
|
973
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
974
|
+
if response.status_code == 409:
|
|
975
|
+
raise IssueAnalysisActiveError(
|
|
976
|
+
"An issue analysis is already running for this project — wait for it to finish, "
|
|
977
|
+
"or cancel it from the webapp's Issues page."
|
|
978
|
+
)
|
|
979
|
+
if response.status_code == 422 and "analyz" in response.text.lower():
|
|
980
|
+
# The route takes no body, so its 422 is the server's "nothing to
|
|
981
|
+
# analyze" answer; any other 422 falls through to the generic error.
|
|
982
|
+
raise NothingToAnalyzeError("Nothing to analyze: every conversation on this project has already been analyzed.")
|
|
983
|
+
if response.status_code in (403, 404):
|
|
984
|
+
raise _project_scope_error(response)
|
|
985
|
+
if response.status_code >= 400:
|
|
986
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
987
|
+
return _check_analysis_run_shape(_parse_json_response(response, url), url)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def get_issue_analysis_run(
|
|
991
|
+
*,
|
|
992
|
+
api_url: str,
|
|
993
|
+
api_key: str,
|
|
994
|
+
project_id: str,
|
|
995
|
+
run_id: str,
|
|
996
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
997
|
+
) -> dict[str, Any]:
|
|
998
|
+
"""GET /api/ci/projects/{project_id}/issue-analysis/runs/{run_id} and return the run record."""
|
|
999
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}"
|
|
1000
|
+
return _check_analysis_run_shape(
|
|
1001
|
+
_get_issue_json(
|
|
1002
|
+
url,
|
|
1003
|
+
api_key,
|
|
1004
|
+
transport,
|
|
1005
|
+
not_found_message=f"No issue-analysis run {run_id} in this project.",
|
|
1006
|
+
not_found_detail="Issue-analysis run not found",
|
|
1007
|
+
),
|
|
1008
|
+
url,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def post_issue_analysis_cancel(
|
|
1013
|
+
*,
|
|
1014
|
+
api_url: str,
|
|
1015
|
+
api_key: str,
|
|
1016
|
+
project_id: str,
|
|
1017
|
+
run_id: str,
|
|
1018
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
"""POST /api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel."""
|
|
1021
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel"
|
|
1022
|
+
try:
|
|
1023
|
+
with _client(transport=transport) as client:
|
|
1024
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
1025
|
+
except httpx.HTTPError as exc:
|
|
1026
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
1027
|
+
if response.status_code == 401:
|
|
1028
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
1029
|
+
# Same exact-match contract as `get_issue_analysis_run`: a missing run is
|
|
1030
|
+
# not a project-scope problem.
|
|
1031
|
+
if response.status_code == 404 and _detail_or_text(response) == "Issue-analysis run not found":
|
|
1032
|
+
raise IssueNotFoundError(f"No issue-analysis run {run_id} in this project.")
|
|
1033
|
+
if response.status_code in (403, 404):
|
|
1034
|
+
raise _project_scope_error(response)
|
|
1035
|
+
if response.status_code >= 400:
|
|
1036
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
@@ -20,6 +20,9 @@ EXIT_OK = 0
|
|
|
20
20
|
EXIT_VALIDATION_FAILED = 1
|
|
21
21
|
EXIT_USAGE = 2
|
|
22
22
|
EXIT_TRANSPORT = 3
|
|
23
|
+
# Conventional "terminated by SIGINT" code: the user pressed Ctrl-C while a
|
|
24
|
+
# command was waiting on a server-side run (which the command cancels first).
|
|
25
|
+
EXIT_INTERRUPTED = 130
|
|
23
26
|
|
|
24
27
|
# Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
|
|
25
28
|
# trees fail fast with a clear message before any upload. Sized for ~10,000
|
|
@@ -30,6 +30,7 @@ from cassis_cli.api import (
|
|
|
30
30
|
)
|
|
31
31
|
from cassis_cli.common import (
|
|
32
32
|
DEFAULT_BASE_PATH,
|
|
33
|
+
EXIT_INTERRUPTED,
|
|
33
34
|
EXIT_OK,
|
|
34
35
|
EXIT_TRANSPORT,
|
|
35
36
|
EXIT_USAGE,
|
|
@@ -41,7 +42,6 @@ from cassis_cli.common import (
|
|
|
41
42
|
|
|
42
43
|
app = typer.Typer(no_args_is_help=True, help="Eval commands.")
|
|
43
44
|
|
|
44
|
-
EXIT_INTERRUPTED = 130
|
|
45
45
|
|
|
46
46
|
# Result statuses that count as "passed"; everything else is a failure or error.
|
|
47
47
|
_PASSED = "passed"
|
|
@@ -134,6 +134,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
|
|
|
134
134
|
|
|
135
135
|
def _print_summary(run: dict[str, Any]) -> None:
|
|
136
136
|
summary = run.get("summary") or {}
|
|
137
|
+
if summary.get("error"):
|
|
138
|
+
# A failed run carries its reason here (e.g. the worker was lost).
|
|
139
|
+
typer.secho(f"Run error: {str(summary['error'])[:300]}", fg=typer.colors.RED)
|
|
137
140
|
total = summary.get("total", run.get("total_cases"))
|
|
138
141
|
passed = summary.get("passed", 0)
|
|
139
142
|
accuracy = summary.get("accuracy")
|
|
@@ -498,7 +501,8 @@ def run(
|
|
|
498
501
|
typer.echo(f"Follow it at: {run_url}")
|
|
499
502
|
raise typer.Exit(EXIT_OK)
|
|
500
503
|
|
|
501
|
-
|
|
504
|
+
# To stderr under --json so `cassis eval run --json | jq` gets only the record.
|
|
505
|
+
typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.", err=json_output)
|
|
502
506
|
|
|
503
507
|
try:
|
|
504
508
|
final_run, results = _wait_for_run(
|
|
@@ -509,13 +513,15 @@ def run(
|
|
|
509
513
|
total=total,
|
|
510
514
|
poll_interval=poll_interval,
|
|
511
515
|
timeout=timeout,
|
|
516
|
+
json_output=json_output,
|
|
512
517
|
)
|
|
513
518
|
except KeyboardInterrupt:
|
|
514
|
-
|
|
515
|
-
typer.echo("
|
|
519
|
+
# To stderr under --json, for the same reason as `issues analyze`.
|
|
520
|
+
typer.echo("", err=json_output)
|
|
521
|
+
typer.echo("Interrupted — cancelling the run...", err=json_output)
|
|
516
522
|
try:
|
|
517
523
|
post_eval_run_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
518
|
-
typer.echo("Run cancelled.")
|
|
524
|
+
typer.echo("Run cancelled.", err=json_output)
|
|
519
525
|
except ApiError as exc:
|
|
520
526
|
typer.secho(f"Could not cancel the run: {exc}", fg=typer.colors.RED, err=True)
|
|
521
527
|
raise typer.Exit(EXIT_INTERRUPTED)
|
|
@@ -550,9 +556,12 @@ def _wait_for_run(
|
|
|
550
556
|
total: int,
|
|
551
557
|
poll_interval: float,
|
|
552
558
|
timeout: float,
|
|
559
|
+
json_output: bool = False,
|
|
553
560
|
) -> "tuple[dict[str, Any], list[dict[str, Any]]]":
|
|
554
561
|
"""Poll the run until terminal. Returns (run, results).
|
|
555
562
|
|
|
563
|
+
Progress lines go to stderr under --json so stdout stays the JSON record.
|
|
564
|
+
|
|
556
565
|
Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
|
|
557
566
|
revoked key can't succeed), or after several consecutive poll failures.
|
|
558
567
|
The server-side run keeps going in all three cases.
|
|
@@ -587,7 +596,7 @@ def _wait_for_run(
|
|
|
587
596
|
passed = sum(1 for r in results if r.get("status") == _PASSED)
|
|
588
597
|
line = f"{done}/{total} cases done — {passed} ✓ {done - passed} ✗"
|
|
589
598
|
if line != last_line:
|
|
590
|
-
typer.echo(line)
|
|
599
|
+
typer.echo(line, err=json_output)
|
|
591
600
|
last_line = line
|
|
592
601
|
|
|
593
602
|
if run_record.get("status") in _TERMINAL_RUN_STATUSES:
|
|
@@ -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 =
|
|
34
|
+
DOCTRINE_VERSION = 6
|
|
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
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
Issues are what the product found wrong while answering questions (an ontology
|
|
4
4
|
gap, missing data). Listing, reading the evidence behind an occurrence, and
|
|
5
5
|
resolving or dismissing them from a checkout keeps the fix loop next to the
|
|
6
|
-
ontology files instead of in the webapp
|
|
6
|
+
ontology files instead of in the webapp; `analyze` refreshes them from the
|
|
7
|
+
conversations that arrived since the last pass, without waiting for the nightly one.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
import json
|
|
13
|
+
import time
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any, Optional
|
|
14
16
|
|
|
@@ -18,14 +20,20 @@ from cassis_cli.api import (
|
|
|
18
20
|
ApiError,
|
|
19
21
|
AuthError,
|
|
20
22
|
IssueNotFoundError,
|
|
23
|
+
NothingToAnalyzeError,
|
|
21
24
|
get_issue,
|
|
25
|
+
get_issue_analysis_run,
|
|
22
26
|
get_issue_evidence,
|
|
23
27
|
get_issues,
|
|
28
|
+
post_issue_analysis_cancel,
|
|
29
|
+
post_issue_analysis_start,
|
|
24
30
|
post_issue_status,
|
|
25
31
|
)
|
|
26
32
|
from cassis_cli.common import (
|
|
27
33
|
DEFAULT_BASE_PATH,
|
|
34
|
+
EXIT_INTERRUPTED,
|
|
28
35
|
EXIT_OK,
|
|
36
|
+
EXIT_TRANSPORT,
|
|
29
37
|
EXIT_USAGE,
|
|
30
38
|
EXIT_VALIDATION_FAILED,
|
|
31
39
|
api_failure,
|
|
@@ -288,7 +296,7 @@ def resolve(
|
|
|
288
296
|
api_url: str = _API_URL_OPTION,
|
|
289
297
|
base_path: str = _BASE_PATH_OPTION,
|
|
290
298
|
) -> None:
|
|
291
|
-
"""Mark an issue resolved — the ontology change that fixes it
|
|
299
|
+
"""Mark an issue resolved — the ontology change that fixes it is published.
|
|
292
300
|
|
|
293
301
|
Exits 0 on success, 1 when the issue does not exist in the project, 2 on
|
|
294
302
|
usage errors, 3 on transport/API errors.
|
|
@@ -352,3 +360,178 @@ def reopen(
|
|
|
352
360
|
api_url=api_url,
|
|
353
361
|
base_path=base_path,
|
|
354
362
|
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
_TERMINAL_ANALYSIS_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
|
366
|
+
|
|
367
|
+
# Consecutive poll failures tolerated before giving up — same tolerance as
|
|
368
|
+
# `eval run`: a transient blip must not abandon a multi-minute run, a
|
|
369
|
+
# permanently broken poll must not spin until --timeout.
|
|
370
|
+
_MAX_CONSECUTIVE_POLL_FAILURES = 5
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.command()
|
|
374
|
+
def analyze(
|
|
375
|
+
path: Path = _PATH_OPTION,
|
|
376
|
+
project_id: Optional[str] = _PROJECT_OPTION,
|
|
377
|
+
api_key: Optional[str] = _API_KEY_OPTION,
|
|
378
|
+
api_url: str = _API_URL_OPTION,
|
|
379
|
+
base_path: str = _BASE_PATH_OPTION,
|
|
380
|
+
wait: bool = typer.Option(
|
|
381
|
+
True,
|
|
382
|
+
"--wait/--no-wait",
|
|
383
|
+
help="Wait for the analysis to finish and print its summary (default), or just print the run id.",
|
|
384
|
+
),
|
|
385
|
+
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between status polls."),
|
|
386
|
+
timeout: float = typer.Option(1800.0, "--timeout", help="Give up waiting after this many seconds."),
|
|
387
|
+
json_output: bool = typer.Option(False, "--json", help="Print the final run record as raw JSON."),
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Analyze the conversations nobody has analyzed yet, turning what went wrong into issues.
|
|
390
|
+
|
|
391
|
+
The same pass as the webapp's "Analyze conversations" button — it runs on
|
|
392
|
+
Cassis's workers, so a Ctrl-C or a lost connection never kills it — started
|
|
393
|
+
on demand instead of waiting for the nightly one. When every conversation is
|
|
394
|
+
already analyzed the command is a no-op and exits 0, so a job re-running it
|
|
395
|
+
on a quiet project stays green. Exits 0 when the run completes, 1 when it
|
|
396
|
+
fails or is cancelled, 2 on usage errors, 3 on transport errors, when a run
|
|
397
|
+
is already in flight, or on --timeout (the run keeps going server-side).
|
|
398
|
+
Ctrl-C cancels the run and exits 130.
|
|
399
|
+
"""
|
|
400
|
+
api_key = require_api_key(api_key)
|
|
401
|
+
project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
run = post_issue_analysis_start(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
405
|
+
except NothingToAnalyzeError as exc:
|
|
406
|
+
if json_output:
|
|
407
|
+
typer.echo(json.dumps({"run": None, "message": str(exc)}, indent=2))
|
|
408
|
+
else:
|
|
409
|
+
typer.echo(str(exc))
|
|
410
|
+
raise typer.Exit(EXIT_OK) from exc
|
|
411
|
+
except (AuthError, ApiError) as exc: # covers IssueAnalysisActiveError too
|
|
412
|
+
raise api_failure(exc) from exc
|
|
413
|
+
|
|
414
|
+
run_id = str(run["id"])
|
|
415
|
+
total = int(run.get("total_chats") or 0)
|
|
416
|
+
|
|
417
|
+
if not wait:
|
|
418
|
+
if json_output:
|
|
419
|
+
typer.echo(json.dumps({"run": run}, indent=2))
|
|
420
|
+
else:
|
|
421
|
+
typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
|
|
422
|
+
typer.echo("It keeps going server-side; the results land on the webapp's Issues page.")
|
|
423
|
+
raise typer.Exit(EXIT_OK)
|
|
424
|
+
|
|
425
|
+
if not json_output:
|
|
426
|
+
typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
final_run = _wait_for_analysis(
|
|
430
|
+
api_url=api_url,
|
|
431
|
+
api_key=api_key,
|
|
432
|
+
project_id=project_id,
|
|
433
|
+
run_id=run_id,
|
|
434
|
+
poll_interval=poll_interval,
|
|
435
|
+
timeout=timeout,
|
|
436
|
+
quiet=json_output,
|
|
437
|
+
)
|
|
438
|
+
except KeyboardInterrupt:
|
|
439
|
+
# To stderr under --json: stdout is the machine's, and these lines would
|
|
440
|
+
# land in front of the JSON document a caller is parsing.
|
|
441
|
+
typer.echo("", err=json_output)
|
|
442
|
+
typer.echo("Interrupted — cancelling the analysis...", err=json_output)
|
|
443
|
+
try:
|
|
444
|
+
post_issue_analysis_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
445
|
+
typer.echo("Analysis cancelled. Conversations analyzed so far keep their occurrences.", err=json_output)
|
|
446
|
+
except ApiError as exc:
|
|
447
|
+
typer.secho(f"Could not cancel the analysis: {exc}", fg=typer.colors.RED, err=True)
|
|
448
|
+
raise typer.Exit(EXIT_INTERRUPTED)
|
|
449
|
+
|
|
450
|
+
if json_output:
|
|
451
|
+
typer.echo(json.dumps({"run": final_run}, indent=2))
|
|
452
|
+
else:
|
|
453
|
+
_print_analysis_outcome(final_run)
|
|
454
|
+
|
|
455
|
+
if final_run.get("status") == "completed":
|
|
456
|
+
raise typer.Exit(EXIT_OK)
|
|
457
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _wait_for_analysis(
|
|
461
|
+
*,
|
|
462
|
+
api_url: str,
|
|
463
|
+
api_key: str,
|
|
464
|
+
project_id: str,
|
|
465
|
+
run_id: str,
|
|
466
|
+
poll_interval: float,
|
|
467
|
+
timeout: float,
|
|
468
|
+
quiet: bool,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
"""Poll the run until terminal and return its final record.
|
|
471
|
+
|
|
472
|
+
Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
|
|
473
|
+
revoked key can't succeed), or after several consecutive poll failures.
|
|
474
|
+
The server-side run keeps going in all three cases.
|
|
475
|
+
"""
|
|
476
|
+
deadline = time.monotonic() + timeout
|
|
477
|
+
last_line = ""
|
|
478
|
+
failures = 0
|
|
479
|
+
while time.monotonic() < deadline:
|
|
480
|
+
try:
|
|
481
|
+
run = get_issue_analysis_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
482
|
+
except AuthError as exc:
|
|
483
|
+
typer.secho(f"{exc} The analysis keeps going server-side.", fg=typer.colors.RED, err=True)
|
|
484
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
485
|
+
except IssueNotFoundError as exc:
|
|
486
|
+
# The run is gone (purged): nothing to keep polling for.
|
|
487
|
+
raise _not_found_failure(exc) from exc
|
|
488
|
+
except ApiError as exc:
|
|
489
|
+
failures += 1
|
|
490
|
+
if failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
|
|
491
|
+
typer.secho(
|
|
492
|
+
f"Polling failed {failures} times in a row ({exc}). "
|
|
493
|
+
"Giving up — the analysis keeps going server-side; see the webapp's Issues page.",
|
|
494
|
+
fg=typer.colors.RED,
|
|
495
|
+
err=True,
|
|
496
|
+
)
|
|
497
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
498
|
+
typer.secho(f"(poll failed, retrying: {exc})", fg=typer.colors.YELLOW, err=True)
|
|
499
|
+
time.sleep(poll_interval)
|
|
500
|
+
continue
|
|
501
|
+
failures = 0
|
|
502
|
+
|
|
503
|
+
line = (
|
|
504
|
+
f"{run.get('chats_analyzed', 0)}/{run.get('total_chats', 0)} conversations analyzed — "
|
|
505
|
+
f"{run.get('occurrences_created', 0)} occurrence(s) found"
|
|
506
|
+
)
|
|
507
|
+
if not quiet and line != last_line:
|
|
508
|
+
typer.echo(line)
|
|
509
|
+
last_line = line
|
|
510
|
+
|
|
511
|
+
if run.get("status") in _TERMINAL_ANALYSIS_STATUSES:
|
|
512
|
+
return run
|
|
513
|
+
time.sleep(poll_interval)
|
|
514
|
+
|
|
515
|
+
typer.secho(
|
|
516
|
+
f"Timed out after {timeout:.0f}s waiting for analysis run {run_id}. "
|
|
517
|
+
"The analysis keeps going server-side — see the webapp's Issues page.",
|
|
518
|
+
fg=typer.colors.YELLOW,
|
|
519
|
+
err=True,
|
|
520
|
+
)
|
|
521
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _print_analysis_outcome(run: dict[str, Any]) -> None:
|
|
525
|
+
status = run.get("status")
|
|
526
|
+
if status == "completed":
|
|
527
|
+
typer.echo("")
|
|
528
|
+
typer.echo(
|
|
529
|
+
f"Analysis complete: {run.get('chats_analyzed', 0)} conversation(s) analyzed, "
|
|
530
|
+
f"{run.get('occurrences_created', 0)} occurrence(s) found, "
|
|
531
|
+
f"{run.get('issues_touched', 0)} issue(s) created or updated."
|
|
532
|
+
)
|
|
533
|
+
typer.echo("Review them with `cassis issues list`.")
|
|
534
|
+
elif status == "cancelled":
|
|
535
|
+
typer.secho("Analysis cancelled before it finished.", fg=typer.colors.YELLOW, err=True)
|
|
536
|
+
else:
|
|
537
|
+
typer.secho(f"Analysis failed: {run.get('error') or 'unknown error'}", fg=typer.colors.RED, err=True)
|
|
@@ -440,6 +440,15 @@ checkout:
|
|
|
440
440
|
Merging the pull request syncs and publishes the ontology; nothing reaches
|
|
441
441
|
production answers until then.
|
|
442
442
|
|
|
443
|
+
When the change came from a Cassis issue, merging is still not the last step.
|
|
444
|
+
Confirm the published version contains it (`cassis status`, or
|
|
445
|
+
`get_project_status` over MCP), then propose resolving that issue —
|
|
446
|
+
`cassis issues resolve <id>`, or `update_issue_status`. Propose it to whoever
|
|
447
|
+
owns the project: resolving needs the editor or admin role, and it records an
|
|
448
|
+
outcome without changing any ontology. Never resolve before publication, and
|
|
449
|
+
never silently — an issue nobody resolves stays in the triage queue and reads
|
|
450
|
+
as a gap that was never fixed.
|
|
451
|
+
|
|
443
452
|
---
|
|
444
453
|
|
|
445
454
|
*This guide is the single canonical home for Cassis ontology modeling doctrine.
|
|
@@ -231,7 +231,8 @@ def push(
|
|
|
231
231
|
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
232
232
|
|
|
233
233
|
run_id = run["run_id"]
|
|
234
|
-
|
|
234
|
+
# To stderr under --json so `cassis schema push --json | jq` gets only the record.
|
|
235
|
+
typer.echo(f"Detection run started: {run_id}", err=json_output)
|
|
235
236
|
|
|
236
237
|
run = _wait_for_detection_run(
|
|
237
238
|
api_url=api_url,
|
|
@@ -262,6 +263,7 @@ def push(
|
|
|
262
263
|
typer.secho(
|
|
263
264
|
f"✓ Detection completed: {total} change(s) to review." if total else "✓ Detection completed: no changes.",
|
|
264
265
|
fg=typer.colors.GREEN,
|
|
266
|
+
err=json_output,
|
|
265
267
|
)
|
|
266
268
|
raise typer.Exit(EXIT_OK)
|
|
267
269
|
if run_status == "failed":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|