cassis-cli 3.4.0__tar.gz → 3.5.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.4.0
3
+ Version: 3.5.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
@@ -348,3 +348,47 @@ requires an account.
348
348
 
349
349
  Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
350
350
 
351
+
352
+ ## Read conversations and their analyses
353
+
354
+ These commands read saved conversations and reports. They never start an analysis,
355
+ retry a failed run, execute SQL or make a model call. Use the same API key and
356
+ project selection as other commands (`CASSIS_API_KEY`, `--project`, or the
357
+ checkout's `cassis/project.yml`). Requires a server with the conversation API.
358
+
359
+ ```bash
360
+ cassis chats list --limit 20
361
+ cassis chats list --review needs-attention --feedback down --since 2026-11-01
362
+ cassis chats list --search revenue --offset 20
363
+ cassis chats show <chat-id>
364
+ cassis chats show <chat-id> --analysis-only
365
+ cassis chats show <chat-id> --trace
366
+ cassis chats show <chat-id> --trace-entry trace:<message-id>:<index>
367
+ cassis chats show <chat-id> --json
368
+ ```
369
+
370
+ `list` sorts by latest message, returns up to 20 chats by default (maximum 100
371
+ per page), and reports the total and next offset. Filters combine with AND.
372
+ `--feedback` accepts `up`, `down`, `any`, or `none`; a conversation with both
373
+ positive and negative feedback matches both `up` and `down`. `--review
374
+ needs-attention` selects completed latest reports containing a potential problem,
375
+ not improvement suggestions alone. `--since` includes conversations created or
376
+ with a message posted at or after the given ISO date or timestamp; dates and
377
+ naive timestamps use UTC.
378
+
379
+ `show` prints messages, generated SQL, feedback, then the saved analysis with its
380
+ citations and next steps. Traces are compact by default; `--trace` adds previews
381
+ limited to 500 characters per entry, and `--trace-entry` opens one entry in full.
382
+ The entry identifiers match the analysis's trace references. `--json` returns the
383
+ full messages and traces without those display limits; `--analysis-only --json`
384
+ returns the analysis view. If a retry failed, the previous completed report is
385
+ shown with the failed status. Missing analyses are reported explicitly.
386
+
387
+ Members can read their own chats; administrators can read chats in accessible
388
+ projects. API keys scoped to a project cannot reach another one. `--app-url`
389
+ sets the base URL for browser links when the webapp and API use different hosts
390
+ or ports. JSON list responses include `url` on each item and `next_offset`.
391
+ Exit codes: 0 on a successful read (even with no report or an empty list), 1 for
392
+ an inaccessible/missing chat or trace entry, 2 for invalid arguments, 3 for API
393
+ or authentication errors.
394
+
@@ -325,3 +325,47 @@ requires an account.
325
325
  ## License
326
326
 
327
327
  Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
328
+
329
+
330
+ ## Read conversations and their analyses
331
+
332
+ These commands read saved conversations and reports. They never start an analysis,
333
+ retry a failed run, execute SQL or make a model call. Use the same API key and
334
+ project selection as other commands (`CASSIS_API_KEY`, `--project`, or the
335
+ checkout's `cassis/project.yml`). Requires a server with the conversation API.
336
+
337
+ ```bash
338
+ cassis chats list --limit 20
339
+ cassis chats list --review needs-attention --feedback down --since 2026-11-01
340
+ cassis chats list --search revenue --offset 20
341
+ cassis chats show <chat-id>
342
+ cassis chats show <chat-id> --analysis-only
343
+ cassis chats show <chat-id> --trace
344
+ cassis chats show <chat-id> --trace-entry trace:<message-id>:<index>
345
+ cassis chats show <chat-id> --json
346
+ ```
347
+
348
+ `list` sorts by latest message, returns up to 20 chats by default (maximum 100
349
+ per page), and reports the total and next offset. Filters combine with AND.
350
+ `--feedback` accepts `up`, `down`, `any`, or `none`; a conversation with both
351
+ positive and negative feedback matches both `up` and `down`. `--review
352
+ needs-attention` selects completed latest reports containing a potential problem,
353
+ not improvement suggestions alone. `--since` includes conversations created or
354
+ with a message posted at or after the given ISO date or timestamp; dates and
355
+ naive timestamps use UTC.
356
+
357
+ `show` prints messages, generated SQL, feedback, then the saved analysis with its
358
+ citations and next steps. Traces are compact by default; `--trace` adds previews
359
+ limited to 500 characters per entry, and `--trace-entry` opens one entry in full.
360
+ The entry identifiers match the analysis's trace references. `--json` returns the
361
+ full messages and traces without those display limits; `--analysis-only --json`
362
+ returns the analysis view. If a retry failed, the previous completed report is
363
+ shown with the failed status. Missing analyses are reported explicitly.
364
+
365
+ Members can read their own chats; administrators can read chats in accessible
366
+ projects. API keys scoped to a project cannot reach another one. `--app-url`
367
+ sets the base URL for browser links when the webapp and API use different hosts
368
+ or ports. JSON list responses include `url` on each item and `next_offset`.
369
+ Exit codes: 0 on a successful read (even with no report or an empty list), 1 for
370
+ an inaccessible/missing chat or trace entry, 2 for invalid arguments, 3 for API
371
+ or authentication errors.
@@ -1328,3 +1328,56 @@ def get_dbt_observation(
1328
1328
  except httpx.HTTPError as exc:
1329
1329
  raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
1330
1330
  return _dbt_observation_response(response, url)
1331
+
1332
+
1333
+ class ChatNotFoundError(ApiError):
1334
+ """The conversation does not exist or is not readable by this key."""
1335
+
1336
+
1337
+ def get_chats(
1338
+ *,
1339
+ api_url: str,
1340
+ api_key: str,
1341
+ project_id: str,
1342
+ chat_id: Optional[str] = None,
1343
+ params: Optional[dict[str, Any]] = None,
1344
+ transport: Optional[httpx.BaseTransport] = None,
1345
+ ) -> dict[str, Any]:
1346
+ """Read a conversation page or a conversation with its saved analysis."""
1347
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/chats"
1348
+ if chat_id:
1349
+ url += f"/{chat_id}"
1350
+ try:
1351
+ with _client(transport=transport) as client:
1352
+ response = client.get(url, params=params, headers={"Authorization": f"Bearer {api_key}"})
1353
+ except httpx.HTTPError as exc:
1354
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
1355
+ if response.status_code == 401:
1356
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
1357
+ if response.status_code == 404 and chat_id and _detail_or_text(response) == "Chat not found":
1358
+ raise ChatNotFoundError("Conversation not found or not accessible to this API key.")
1359
+ if response.status_code in (403, 404):
1360
+ raise ApiError(
1361
+ f"Conversation access denied (HTTP {response.status_code}). Check --project and the API key’s read access and project scope."
1362
+ )
1363
+ if response.status_code >= 400:
1364
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
1365
+ data = _parse_json_response(response, url)
1366
+ valid = isinstance(data, dict)
1367
+ if valid and chat_id:
1368
+ valid = (
1369
+ isinstance(data.get("chat"), dict)
1370
+ and "id" in data["chat"]
1371
+ and isinstance(data.get("messages"), list)
1372
+ and isinstance(data.get("analysis"), dict)
1373
+ )
1374
+ elif valid:
1375
+ valid = (
1376
+ isinstance(data.get("items"), list)
1377
+ and isinstance(data.get("total"), int)
1378
+ and isinstance(data.get("offset"), int)
1379
+ and all(isinstance(row, dict) and "id" in row for row in data["items"])
1380
+ )
1381
+ if not valid:
1382
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
1383
+ return data
@@ -0,0 +1,204 @@
1
+ """Read conversations and saved analyses without running model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+ from urllib.parse import urlencode
10
+ from uuid import UUID
11
+
12
+ import typer
13
+ from cassis_cli.api import DEFAULT_API_URL, ApiError, ChatNotFoundError, get_chats
14
+ from cassis_cli.common import api_failure, one_line, require_api_key, resolve_project_id
15
+
16
+ app = typer.Typer(no_args_is_help=True, help="Read conversations and their saved analyses.")
17
+ _PATH = typer.Option(Path("."), "--path", help="Checkout root containing the context directory.")
18
+ _PROJECT = typer.Option(None, "--project", envvar="CASSIS_PROJECT_ID", help="Project ID; defaults to project.yml.")
19
+ _KEY = typer.Option(None, "--api-key", envvar="CASSIS_API_KEY", help="Cassis API key.")
20
+ _URL = typer.Option(DEFAULT_API_URL, "--api-url", envvar="CASSIS_API_URL", help="Cassis API base URL.")
21
+ _BASE = typer.Option("cassis", "--base-path", envvar="CASSIS_BASE_PATH", help="Context directory within the checkout.")
22
+ _APP_URL = typer.Option(None, "--app-url", help="Webapp base URL for conversation links; defaults to --api-url.")
23
+
24
+
25
+ def _url(base: str, project: str, chat: str) -> str:
26
+ return base.rstrip("/") + "/chat?" + urlencode({"projectId": project, "chatId": chat})
27
+
28
+
29
+ def _status(summary: Optional[dict[str, Any]]) -> str:
30
+ if not summary:
31
+ return "Not analyzed"
32
+ if summary.get("status") in ("running", "scheduled", "deferred"):
33
+ return "In progress"
34
+ if summary.get("status") == "completed" and summary.get("problems", 0):
35
+ return "Needs attention"
36
+ if summary.get("status") != "completed" or summary.get("conversation_changed"):
37
+ return "Not analyzed"
38
+ return "Analyzed"
39
+
40
+
41
+ def _read(**kwargs: Any) -> dict[str, Any]:
42
+ try:
43
+ return get_chats(**kwargs)
44
+ except ChatNotFoundError as exc:
45
+ typer.echo(str(exc), err=True)
46
+ raise typer.Exit(1) from exc
47
+ except ApiError as exc:
48
+ raise api_failure(exc) from exc
49
+
50
+
51
+ @app.command("list")
52
+ def list_chats(
53
+ path: Path = _PATH,
54
+ project_id: Optional[str] = _PROJECT,
55
+ api_key: Optional[str] = _KEY,
56
+ api_url: str = _URL,
57
+ base_path: str = _BASE,
58
+ app_url: Optional[str] = _APP_URL,
59
+ limit: int = typer.Option(20, min=1, max=100),
60
+ offset: int = typer.Option(0, min=0),
61
+ since: Optional[str] = typer.Option(None, help="Last-message cutoff: ISO date or timestamp; dates use UTC."),
62
+ feedback: Optional[str] = typer.Option(None, help="Filter: up, down, any or none."),
63
+ review: Optional[str] = typer.Option(None, help="Filter: needs-attention (detected problems only)."),
64
+ query: Optional[str] = typer.Option(None, "--search", help="Search title, author name or email."),
65
+ json_output: bool = typer.Option(False, "--json", help="Print the page as JSON, including links and pagination."),
66
+ ) -> None:
67
+ """List readable chats by latest message. Reads saved results; never starts an analysis."""
68
+ if feedback not in (None, "up", "down", "any", "none"):
69
+ raise typer.BadParameter("Choose up, down, any or none.", param_hint="--feedback")
70
+ if review not in (None, "needs-attention"):
71
+ raise typer.BadParameter("Choose needs-attention.", param_hint="--review")
72
+ if since:
73
+ try:
74
+ dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
75
+ since = (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)).isoformat()
76
+ except ValueError as exc:
77
+ raise typer.BadParameter("Use an ISO date or timestamp.", param_hint="--since") from exc
78
+ key = require_api_key(api_key)
79
+ project = resolve_project_id(project_id, path / base_path, quiet=json_output)
80
+ assert project is not None
81
+ params = {
82
+ k: v
83
+ for k, v in dict(limit=limit, offset=offset, since=since, feedback=feedback, review=review, q=query).items()
84
+ if v is not None
85
+ }
86
+ data = _read(api_url=api_url, api_key=key, project_id=project, params=params)
87
+ for row in data["items"]:
88
+ row["url"] = _url(app_url or api_url, project, row["id"])
89
+ data["next_offset"] = (
90
+ data["offset"] + len(data["items"])
91
+ if data["items"] and data["offset"] + len(data["items"]) < data["total"]
92
+ else None
93
+ )
94
+ if json_output:
95
+ typer.echo(json.dumps(data, indent=2, ensure_ascii=False))
96
+ return
97
+ if not data["items"]:
98
+ typer.echo("No conversations match these filters.")
99
+ for row in data["items"]:
100
+ feedback_text = f"up {row.get('feedback_up', 0)} / down {row.get('feedback_down', 0)}"
101
+ typer.echo(
102
+ f"{row['id']} {_status(row.get('analysis_summary'))} {feedback_text} {row.get('last_message_at') or row.get('created_at')} {one_line(row.get('title') or 'Untitled')}"
103
+ )
104
+ typer.echo(f" {row['url']}")
105
+ typer.echo(f"{len(data['items'])} of {data['total']} conversations (offset {data['offset']}).")
106
+ if data["next_offset"] is not None:
107
+ typer.echo(f"Next page: repeat with --offset {data['next_offset']}")
108
+
109
+
110
+ def _analysis(view: dict[str, Any]) -> None:
111
+ typer.echo("\nAnalysis")
112
+ latest = view.get("analysis")
113
+ if latest:
114
+ typer.echo(f"Status: {latest['status']} | Launched: {latest.get('created_at')}")
115
+ if latest.get("error"):
116
+ typer.echo(f"Error: {latest['error']}")
117
+ shown = latest if latest and latest.get("status") == "completed" else view.get("previous_analysis")
118
+ if not shown or not shown.get("result"):
119
+ typer.echo(
120
+ "No completed analysis available." if view.get("enabled") else "Analysis is not enabled for this project."
121
+ )
122
+ return
123
+ if shown is not latest:
124
+ typer.echo(f"Previous completed analysis ({shown['id']}):")
125
+ if shown.get("conversation_changed"):
126
+ typer.echo("New messages are not covered by this analysis.")
127
+ if shown.get("context_changed"):
128
+ typer.echo("Context has changed since this analysis.")
129
+ report = shown["result"]
130
+ typer.echo(report["summary"])
131
+ for index, obs in enumerate(report["observations"], 1):
132
+ typer.echo(f"\n{index}. {obs['title']} [{obs['kind']} / {obs['confidence']}]")
133
+ typer.echo(obs["explanation"])
134
+ typer.echo(f"Next step ({obs['destination']}): {obs['next_step']}")
135
+ for citation in obs["evidence"]:
136
+ typer.echo(f" [{citation['document_id']}] {citation['quote']}")
137
+ for issue in view.get("issues", []):
138
+ typer.echo(f"Linked issue: {issue['id']} — {issue['title']} ({issue['status']})")
139
+
140
+
141
+ @app.command("show")
142
+ def show_chat(
143
+ chat_id: UUID,
144
+ path: Path = _PATH,
145
+ project_id: Optional[str] = _PROJECT,
146
+ api_key: Optional[str] = _KEY,
147
+ api_url: str = _URL,
148
+ base_path: str = _BASE,
149
+ app_url: Optional[str] = _APP_URL,
150
+ trace: bool = typer.Option(False, "--trace", help="Show bounded previews of execution trace entries."),
151
+ trace_entry: Optional[str] = typer.Option(
152
+ None, "--trace-entry", help="Show one full entry: trace:<message-id>:<index>."
153
+ ),
154
+ analysis_only: bool = typer.Option(False, "--analysis-only", help="Show only the saved analysis."),
155
+ json_output: bool = typer.Option(False, "--json", help="Print the full conversation, traces and analysis as JSON."),
156
+ ) -> None:
157
+ """Read a conversation followed by its saved analysis. No model calls or automatic retries."""
158
+ if analysis_only and (trace or trace_entry):
159
+ raise typer.BadParameter("--analysis-only cannot be combined with trace options.")
160
+ key = require_api_key(api_key)
161
+ project = resolve_project_id(project_id, path / base_path, quiet=json_output)
162
+ assert project is not None
163
+ data = _read(api_url=api_url, api_key=key, project_id=project, chat_id=str(chat_id))
164
+ data["url"] = _url(app_url or api_url, project, str(chat_id))
165
+ if trace_entry:
166
+ for message in data["messages"]:
167
+ for i, entry in enumerate(message.get("process_log") or []):
168
+ if trace_entry == f"trace:{message['id']}:{i}":
169
+ typer.echo(json.dumps(entry, indent=2, ensure_ascii=False))
170
+ return
171
+ typer.echo("Trace entry not found in this conversation.", err=True)
172
+ raise typer.Exit(1)
173
+ if json_output:
174
+ typer.echo(json.dumps(data["analysis"] if analysis_only else data, indent=2, ensure_ascii=False))
175
+ return
176
+ chat = data["chat"]
177
+ typer.echo(f"{chat.get('title') or 'Untitled'} [{chat['id']}]")
178
+ typer.echo(f"Created: {chat.get('created_at')} | Updated: {chat.get('updated_at')}")
179
+ typer.echo(data["url"])
180
+ if not analysis_only:
181
+ for message in data["messages"]:
182
+ typer.echo(f"\n{message['role']} — {message.get('created_at')} [message:{message['id']}]")
183
+ if message.get("content"):
184
+ typer.echo(message["content"])
185
+ if message.get("generated_sql"):
186
+ typer.echo(f"SQL:\n{message['generated_sql']}")
187
+ if message.get("rating"):
188
+ # A feedback message: its content, printed above, is the justification.
189
+ typer.echo(f"Feedback: {message['rating']}")
190
+ if message.get("error"):
191
+ typer.echo(f"Error: {message['error']}")
192
+ entries = message.get("process_log") or []
193
+ if entries and not trace:
194
+ typer.echo(f"Execution: {len(entries)} trace entries (use --trace to inspect).")
195
+ if trace:
196
+ for i, entry in enumerate(entries):
197
+ ref = f"trace:{message['id']}:{i}"
198
+ typer.echo(f" [{ref}] {entry.get('name') or entry.get('type')}")
199
+ detail = json.dumps(entry, ensure_ascii=False)
200
+ typer.echo(
201
+ f" {detail[:500]}"
202
+ + (f"… (use --trace-entry {ref} for full details)" if len(detail) > 500 else "")
203
+ )
204
+ _analysis(data["analysis"])
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
  from cassis_cli import __version__
7
+ from cassis_cli.chats import app as chats_app
7
8
  from cassis_cli.dbt import app as dbt_app
8
9
  from cassis_cli.eval import app as eval_app
9
10
  from cassis_cli.issues import app as issues_app
@@ -29,6 +30,7 @@ app.add_typer(schema_app, name="schema")
29
30
  app.add_typer(dbt_app, name="dbt")
30
31
  app.add_typer(projects_app, name="projects")
31
32
  app.add_typer(issues_app, name="issues")
33
+ app.add_typer(chats_app, name="chats")
32
34
  app.command()(status)
33
35
  app.command()(verify)
34
36
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "3.4.0"
3
+ version = "3.5.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
File without changes