ontora-cli 0.1.0__tar.gz → 0.3.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.
Files changed (41) hide show
  1. ontora_cli-0.3.0/PKG-INFO +26 -0
  2. ontora_cli-0.3.0/ontora_cli/__init__.py +3 -0
  3. ontora_cli-0.3.0/ontora_cli/client.py +136 -0
  4. ontora_cli-0.3.0/ontora_cli/commands/analysis.py +490 -0
  5. ontora_cli-0.3.0/ontora_cli/commands/auth.py +67 -0
  6. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/campaigns.py +88 -13
  7. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/contacts.py +5 -1
  8. ontora_cli-0.3.0/ontora_cli/commands/corpus.py +1501 -0
  9. ontora_cli-0.3.0/ontora_cli/commands/people.py +50 -0
  10. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/query.py +18 -5
  11. ontora_cli-0.3.0/ontora_cli/commands/transcripts.py +105 -0
  12. ontora_cli-0.3.0/ontora_cli/formatters.py +166 -0
  13. ontora_cli-0.3.0/ontora_cli/main.py +119 -0
  14. ontora_cli-0.3.0/ontora_cli/schemas.py +77 -0
  15. ontora_cli-0.3.0/ontora_cli.egg-info/PKG-INFO +26 -0
  16. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/SOURCES.txt +9 -2
  17. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/pyproject.toml +3 -3
  18. ontora_cli-0.3.0/tests/test_analysis.py +474 -0
  19. ontora_cli-0.3.0/tests/test_auth.py +102 -0
  20. ontora_cli-0.3.0/tests/test_client.py +112 -0
  21. ontora_cli-0.3.0/tests/test_corpus.py +937 -0
  22. ontora_cli-0.3.0/tests/test_transcripts.py +78 -0
  23. ontora_cli-0.1.0/PKG-INFO +0 -118
  24. ontora_cli-0.1.0/README.md +0 -93
  25. ontora_cli-0.1.0/ontora_cli/__init__.py +0 -3
  26. ontora_cli-0.1.0/ontora_cli/client.py +0 -72
  27. ontora_cli-0.1.0/ontora_cli/commands/auth.py +0 -40
  28. ontora_cli-0.1.0/ontora_cli/commands/transcripts.py +0 -65
  29. ontora_cli-0.1.0/ontora_cli/formatters.py +0 -84
  30. ontora_cli-0.1.0/ontora_cli/main.py +0 -70
  31. ontora_cli-0.1.0/ontora_cli/schemas.py +0 -46
  32. ontora_cli-0.1.0/ontora_cli.egg-info/PKG-INFO +0 -118
  33. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/__init__.py +0 -0
  34. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/reports.py +0 -0
  35. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/webhooks.py +0 -0
  36. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/config.py +0 -0
  37. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/dependency_links.txt +0 -0
  38. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/entry_points.txt +0 -0
  39. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/requires.txt +0 -0
  40. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/top_level.txt +0 -0
  41. {ontora_cli-0.1.0 → ontora_cli-0.3.0}/setup.cfg +0 -0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: ontora-cli
3
+ Version: 0.3.0
4
+ Summary: Ontora CLI — manage campaigns, run privacy-safe analysis, and export verified confidential corpora.
5
+ Author: Ontora
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Documentation, https://docs.ontora.com/cli/install
8
+ Project-URL: API, https://docs.ontora.com/api-reference/introduction
9
+ Project-URL: Homepage, https://docs.ontora.com
10
+ Keywords: ontora,interviews,research,synthesis,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: typer>=0.12.0
21
+ Requires-Dist: httpx>=0.27.0
22
+ Requires-Dist: rich>=13.7.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: pydantic>=2.6.0
25
+
26
+ Ontora CLI for managing interview campaigns, transcripts, reports, queries, people, contacts, webhooks, privacy-safe analysis, and campaign-bound verified confidential-corpus exports.
@@ -0,0 +1,3 @@
1
+ """Ontora CLI."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,136 @@
1
+ """Thin httpx wrapper around the Ontora REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, Self
8
+
9
+ import httpx
10
+
11
+ from ontora_cli import __version__
12
+ from ontora_cli.config import CliConfig
13
+
14
+
15
+ class ApiError(Exception):
16
+ def __init__(self, status: int, detail: Any):
17
+ self.status = status
18
+ self.detail = detail
19
+ super().__init__(f"HTTP {status}: {detail}")
20
+
21
+
22
+ class OntoraClient:
23
+ def __init__(self, cfg: CliConfig, api_key: str):
24
+ self.cfg = cfg
25
+ self._client = httpx.Client(
26
+ base_url=cfg.base_url,
27
+ headers={
28
+ "X-API-Key": api_key,
29
+ "User-Agent": f"ontora-cli/{__version__}",
30
+ },
31
+ timeout=60.0,
32
+ # Workspace keys use the REST API's X-API-Key contract. Never
33
+ # auto-follow redirects: custom auth headers can otherwise be
34
+ # forwarded to a different origin by the HTTP client.
35
+ follow_redirects=False,
36
+ )
37
+
38
+ def close(self) -> None:
39
+ self._client.close()
40
+
41
+ def __enter__(self) -> Self:
42
+ return self
43
+
44
+ def __exit__(self, *_: object) -> None:
45
+ self.close()
46
+
47
+ def _raise(self, r: httpx.Response) -> None:
48
+ if r.is_success:
49
+ return
50
+ try:
51
+ detail = r.json()
52
+ except json.JSONDecodeError:
53
+ detail = r.text
54
+ raise ApiError(r.status_code, detail)
55
+
56
+ # ------------------------------------------------------------------ HTTP
57
+ def get(self, path: str, params: dict | None = None) -> Any:
58
+ r = self._client.get(path, params=params)
59
+ self._raise(r)
60
+ return r.json() if r.content else None
61
+
62
+ def post(
63
+ self, path: str, json_body: dict | None = None, params: dict | None = None
64
+ ) -> Any:
65
+ r = self._client.post(path, json=json_body, params=params)
66
+ self._raise(r)
67
+ return r.json() if r.content else None
68
+
69
+ def patch(self, path: str, json_body: dict) -> Any:
70
+ r = self._client.patch(path, json=json_body)
71
+ self._raise(r)
72
+ return r.json() if r.content else None
73
+
74
+ def delete(self, path: str) -> None:
75
+ r = self._client.delete(path)
76
+ self._raise(r)
77
+
78
+ def get_raw(
79
+ self,
80
+ path: str,
81
+ params: dict | None = None,
82
+ *,
83
+ timeout: float | None = None,
84
+ max_bytes: int | None = None,
85
+ ) -> httpx.Response:
86
+ """Return a downloaded response, optionally enforcing a streaming size cap."""
87
+
88
+ request_options = {"timeout": timeout} if timeout is not None else {}
89
+ deadline = time.monotonic() + timeout if timeout is not None else None
90
+ if max_bytes is None:
91
+ r = self._client.get(path, params=params, **request_options)
92
+ self._raise(r)
93
+ return r
94
+ if max_bytes <= 0:
95
+ raise ValueError("max_bytes must be greater than zero")
96
+
97
+ with self._client.stream(
98
+ "GET", path, params=params, **request_options
99
+ ) as streamed:
100
+ if deadline is not None and time.monotonic() > deadline:
101
+ raise RuntimeError(
102
+ f"download exceeded the {timeout:g}-second total deadline"
103
+ )
104
+ if not streamed.is_success:
105
+ streamed.read()
106
+ self._raise(streamed)
107
+
108
+ content_length = streamed.headers.get("Content-Length")
109
+ if content_length is not None:
110
+ try:
111
+ declared_size = int(content_length)
112
+ except ValueError:
113
+ declared_size = None
114
+ if declared_size is not None and declared_size > max_bytes:
115
+ raise RuntimeError(
116
+ f"download exceeds the {max_bytes // (1024 * 1024)} MiB safety limit"
117
+ )
118
+
119
+ content = bytearray()
120
+ for chunk in streamed.iter_bytes():
121
+ if deadline is not None and time.monotonic() > deadline:
122
+ raise RuntimeError(
123
+ f"download exceeded the {timeout:g}-second total deadline"
124
+ )
125
+ if len(content) + len(chunk) > max_bytes:
126
+ raise RuntimeError(
127
+ f"download exceeds the {max_bytes // (1024 * 1024)} MiB safety limit"
128
+ )
129
+ content.extend(chunk)
130
+
131
+ return httpx.Response(
132
+ status_code=streamed.status_code,
133
+ headers=streamed.headers,
134
+ content=bytes(content),
135
+ request=streamed.request,
136
+ )
@@ -0,0 +1,490 @@
1
+ """Privacy-safe full-corpus analysis commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import typer
13
+
14
+ from ontora_cli.client import OntoraClient
15
+ from ontora_cli.config import load_config, require_api_key
16
+
17
+ app = typer.Typer(help="Run privacy-safe analyses over every answer in a campaign.")
18
+
19
+ _TERMINAL_STATUSES = frozenset({"ready", "partial", "failed", "cancelled"})
20
+ _SUCCESS_STATUS = "ready"
21
+
22
+
23
+ class InvalidApiResponse(RuntimeError):
24
+ """Raised when the API violates the CLI's documented response contract."""
25
+
26
+
27
+ class ResultFormat(str, Enum):
28
+ markdown = "md"
29
+ json = "json"
30
+
31
+
32
+ def _status(run: dict[str, Any]) -> str:
33
+ return str(run.get("status") or "unknown").strip().lower()
34
+
35
+
36
+ def _stale_reason(run: dict[str, Any]) -> str | None:
37
+ value = run.get("stale_reason")
38
+ return str(value).strip() if value else None
39
+
40
+
41
+ def _coverage(run: dict[str, Any]) -> dict[str, Any]:
42
+ value = run.get("coverage")
43
+ return value if isinstance(value, dict) else {}
44
+
45
+
46
+ def _added_since_snapshot(run: dict[str, Any]) -> int:
47
+ try:
48
+ return max(0, int(_coverage(run).get("added_since_snapshot") or 0))
49
+ except (TypeError, ValueError):
50
+ return 0
51
+
52
+
53
+ def _is_terminal(run: dict[str, Any]) -> bool:
54
+ return _status(run) in _TERMINAL_STATUSES or _stale_reason(run) is not None
55
+
56
+
57
+ def _run_problem(run: dict[str, Any]) -> str | None:
58
+ stale_reason = _stale_reason(run)
59
+ if stale_reason:
60
+ return f"stale analysis ({stale_reason}); start a new analysis run"
61
+
62
+ added = _added_since_snapshot(run)
63
+ if added:
64
+ current = _coverage(run).get("current_completed")
65
+ current_detail = (
66
+ f" ({current} answers are now eligible)" if current is not None else ""
67
+ )
68
+ return (
69
+ f"analysis snapshot excludes {added} newly completed answer(s){current_detail}; "
70
+ "start a new analysis run"
71
+ )
72
+
73
+ status = _status(run)
74
+ if status == _SUCCESS_STATUS:
75
+ return None
76
+ if status == "partial":
77
+ prefix = "analysis completed only partially"
78
+ elif status == "failed":
79
+ prefix = "analysis failed"
80
+ elif status == "cancelled":
81
+ prefix = "analysis was cancelled"
82
+ else:
83
+ return None
84
+
85
+ code = str(run.get("error_code") or "").strip()
86
+ message = str(run.get("error_message") or "").strip()
87
+ details = ": ".join(value for value in (code, message) if value)
88
+ return f"{prefix}: {details}" if details else prefix
89
+
90
+
91
+ def _progress(run: dict[str, Any]) -> tuple[Any, Any]:
92
+ coverage = _coverage(run)
93
+ progress = run.get("progress") if isinstance(run.get("progress"), dict) else {}
94
+ processed = coverage.get("processed", progress.get("processed"))
95
+ total = coverage.get("total", progress.get("total"))
96
+ return processed, total
97
+
98
+
99
+ def _state_signature(run: dict[str, Any]) -> tuple[Any, ...]:
100
+ processed, total = _progress(run)
101
+ return (
102
+ _status(run),
103
+ run.get("phase"),
104
+ processed,
105
+ total,
106
+ _stale_reason(run),
107
+ _added_since_snapshot(run),
108
+ _coverage(run).get("current_completed"),
109
+ run.get("error_code"),
110
+ run.get("error_message"),
111
+ )
112
+
113
+
114
+ def _state_line(run: dict[str, Any]) -> str:
115
+ status = _status(run)
116
+ phase = str(run.get("phase") or status)
117
+ processed, total = _progress(run)
118
+ progress = ""
119
+ if processed is not None or total is not None:
120
+ progress = f" progress={processed if processed is not None else '?'}"
121
+ if total is not None:
122
+ progress += f"/{total}"
123
+ added = _added_since_snapshot(run)
124
+ if added:
125
+ current = _coverage(run).get("current_completed")
126
+ progress += f" snapshot_outdated=+{added}"
127
+ if current is not None:
128
+ progress += f" current={current}"
129
+ return f"status={status} phase={phase}{progress}"
130
+
131
+
132
+ def _print_run(run: dict[str, Any], *, session_id: str, run_id: str) -> None:
133
+ typer.echo(f"session_id: {session_id}")
134
+ typer.echo(f"run_id: {run_id}")
135
+ typer.echo(_state_line(run))
136
+ problem = _run_problem(run)
137
+ if problem:
138
+ typer.secho(f"Problem: {problem}", fg=typer.colors.RED, err=True)
139
+
140
+
141
+ def _wait_for_terminal(
142
+ client: OntoraClient,
143
+ *,
144
+ session_id: str,
145
+ run_id: str,
146
+ initial: dict[str, Any],
147
+ poll_interval: float,
148
+ show_progress: bool,
149
+ ) -> dict[str, Any]:
150
+ run = initial
151
+ last_signature = _state_signature(run)
152
+ while not _is_terminal(run):
153
+ time.sleep(poll_interval)
154
+ run = client.get(
155
+ f"/v1/insights-chat/sessions/{session_id}/analysis-runs/{run_id}"
156
+ )
157
+ if not isinstance(run, dict):
158
+ raise InvalidApiResponse(
159
+ "The analysis status endpoint returned an invalid response."
160
+ )
161
+ signature = _state_signature(run)
162
+ if show_progress and signature != last_signature:
163
+ typer.echo(_state_line(run))
164
+ last_signature = signature
165
+ return run
166
+
167
+
168
+ def _citation_markers(item: dict[str, Any]) -> str:
169
+ evidence = item.get("evidence")
170
+ if not isinstance(evidence, list):
171
+ return ""
172
+ keys = []
173
+ for value in evidence[:2]:
174
+ if not isinstance(value, dict):
175
+ continue
176
+ key = value.get("citation_key")
177
+ if key:
178
+ keys.append(f"[{key}]")
179
+ return f" {' '.join(keys)}" if keys else ""
180
+
181
+
182
+ def _result_markdown(result: Any, coverage: dict[str, Any]) -> str:
183
+ if isinstance(result, str) and result.strip():
184
+ return result.strip()
185
+ if not isinstance(result, dict):
186
+ return json.dumps(result, indent=2, ensure_ascii=False, default=str)
187
+
188
+ for key in ("markdown", "content", "answer", "text"):
189
+ direct = result.get(key)
190
+ if isinstance(direct, str) and direct.strip():
191
+ return direct.strip()
192
+
193
+ summary = result.get("management_summary")
194
+ summary = summary if isinstance(summary, dict) else {}
195
+ lines = [f"## {summary.get('headline') or 'Management Summary'}"]
196
+ if summary.get("summary"):
197
+ lines.extend(("", str(summary["summary"])))
198
+
199
+ key_points = summary.get("key_points")
200
+ if isinstance(key_points, list):
201
+ for point in key_points:
202
+ if isinstance(point, dict):
203
+ text = point.get("text") or point.get("summary") or point.get("title")
204
+ if text:
205
+ lines.append(f"- {text}{_citation_markers(point)}")
206
+ elif point:
207
+ lines.append(f"- {point}")
208
+
209
+ priorities = result.get("priorities")
210
+ if isinstance(priorities, list) and priorities:
211
+ lines.extend(("", "## Priorities"))
212
+ for index, priority in enumerate(priorities, start=1):
213
+ if not isinstance(priority, dict):
214
+ lines.append(f"{index}. {priority}")
215
+ continue
216
+ rank = priority.get("rank") or index
217
+ title = priority.get("title") or "Priority"
218
+ rationale = priority.get("rationale")
219
+ suffix = f": {rationale}" if rationale else ""
220
+ lines.append(f"{rank}. **{title}**{suffix}{_citation_markers(priority)}")
221
+ next_steps = priority.get("next_steps")
222
+ if isinstance(next_steps, list):
223
+ lines.extend(f" - {step}" for step in next_steps if step)
224
+
225
+ themes = result.get("themes")
226
+ if isinstance(themes, list) and themes:
227
+ lines.extend(("", "## Themes"))
228
+ for theme in themes:
229
+ if not isinstance(theme, dict):
230
+ lines.append(f"- {theme}")
231
+ continue
232
+ title = theme.get("title") or "Theme"
233
+ summary_text = theme.get("summary")
234
+ suffix = f": {summary_text}" if summary_text else ""
235
+ lines.append(f"- **{title}**{suffix}{_citation_markers(theme)}")
236
+
237
+ divergences = result.get("divergences")
238
+ if isinstance(divergences, list) and divergences:
239
+ lines.extend(("", "## Divergences"))
240
+ for divergence in divergences:
241
+ if not isinstance(divergence, dict):
242
+ lines.append(f"- {divergence}")
243
+ continue
244
+ topic = (
245
+ divergence.get("topic")
246
+ or divergence.get("title")
247
+ or "Different perspectives"
248
+ )
249
+ description = divergence.get("description") or divergence.get("summary")
250
+ suffix = f": {description}" if description else ""
251
+ lines.append(f"- **{topic}**{suffix}{_citation_markers(divergence)}")
252
+
253
+ caveats = result.get("caveats")
254
+ if isinstance(caveats, list) and caveats:
255
+ lines.extend(("", "## Caveats"))
256
+ lines.extend(f"- {caveat}" for caveat in caveats if caveat)
257
+
258
+ processed = coverage.get("processed", 0)
259
+ total = coverage.get("total", 0)
260
+ citable = coverage.get("citable", 0)
261
+ aggregate_processed = coverage.get("aggregate_only_processed", 0)
262
+ aggregate_total = coverage.get("aggregate_only", 0)
263
+ aggregate_contributing = coverage.get("aggregate_only_contributing", 0)
264
+ added = coverage.get("added_since_snapshot", 0)
265
+ current_completed = coverage.get("current_completed", total)
266
+ lines.extend(
267
+ (
268
+ "",
269
+ "## Coverage",
270
+ (
271
+ f"{processed}/{total} answers processed; {citable} citable sources; "
272
+ f"{aggregate_processed}/{aggregate_total} protected sources processed; "
273
+ f"{aggregate_contributing} protected sources contributed to publishable shared patterns."
274
+ ),
275
+ (
276
+ f"Snapshot is outdated: {added} newly completed answer(s) are not included "
277
+ f"({current_completed} currently eligible). Start a new analysis run."
278
+ if added
279
+ else f"Snapshot includes all {current_completed} currently eligible answers."
280
+ ),
281
+ )
282
+ )
283
+ return "\n".join(lines).strip()
284
+
285
+
286
+ @app.command("start")
287
+ def start_cmd(
288
+ interview_id: Annotated[str, typer.Argument(help="Campaign/interview ID.")],
289
+ objective: Annotated[
290
+ str,
291
+ typer.Argument(help="Question or objective for the complete-corpus analysis."),
292
+ ],
293
+ model: Annotated[
294
+ str | None, typer.Option("--model", help="Analysis model ID.")
295
+ ] = None,
296
+ request_id: Annotated[
297
+ uuid.UUID | None,
298
+ typer.Option(
299
+ "--request-id", help="Caller-supplied UUID for the client message."
300
+ ),
301
+ ] = None,
302
+ wait: Annotated[
303
+ bool,
304
+ typer.Option("--wait", help="Poll until the run reaches a terminal state."),
305
+ ] = False,
306
+ poll_interval: Annotated[
307
+ float,
308
+ typer.Option(
309
+ "--poll-interval",
310
+ min=0.1,
311
+ help="Seconds between status requests with --wait.",
312
+ ),
313
+ ] = 2.0,
314
+ json_output: Annotated[
315
+ bool, typer.Option("--json", help="Print machine-readable JSON.")
316
+ ] = False,
317
+ ) -> None:
318
+ """Start an exhaustive, privacy-safe analysis over every campaign answer."""
319
+ objective = objective.strip()
320
+ if not objective:
321
+ raise typer.BadParameter("must not be empty", param_hint="OBJECTIVE")
322
+
323
+ cfg = load_config()
324
+ key = require_api_key(cfg)
325
+ message_id = request_id or uuid.uuid4()
326
+
327
+ with OntoraClient(cfg, key) as client:
328
+ chat_session = client.post(
329
+ f"/v1/interviews/{interview_id}/chat/sessions",
330
+ json_body={"title": None},
331
+ )
332
+ if not isinstance(chat_session, dict) or not chat_session.get("id"):
333
+ raise InvalidApiResponse(
334
+ "The chat session endpoint returned an invalid response."
335
+ )
336
+ session_id = str(chat_session["id"])
337
+
338
+ user_message = client.post(
339
+ f"/v1/insights-chat/sessions/{session_id}/messages",
340
+ json_body={
341
+ "role": "user",
342
+ "content": objective,
343
+ "client_message_id": str(message_id),
344
+ },
345
+ )
346
+ if not isinstance(user_message, dict) or not user_message.get("message_id"):
347
+ raise InvalidApiResponse(
348
+ "The chat message endpoint returned an invalid response."
349
+ )
350
+
351
+ body: dict[str, Any] = {
352
+ "objective": objective,
353
+ "user_message_id": str(user_message["message_id"]),
354
+ }
355
+ if model:
356
+ body["model"] = model
357
+ run = client.post(
358
+ f"/v1/insights-chat/sessions/{session_id}/analysis-runs",
359
+ json_body=body,
360
+ )
361
+ if not isinstance(run, dict) or not run.get("id"):
362
+ raise InvalidApiResponse(
363
+ "The analysis start endpoint returned an invalid response."
364
+ )
365
+ run_id = str(run["id"])
366
+
367
+ if not json_output:
368
+ _print_run(run, session_id=session_id, run_id=run_id)
369
+ if wait:
370
+ run = _wait_for_terminal(
371
+ client,
372
+ session_id=session_id,
373
+ run_id=run_id,
374
+ initial=run,
375
+ poll_interval=poll_interval,
376
+ show_progress=not json_output,
377
+ )
378
+
379
+ if json_output:
380
+ typer.echo(
381
+ json.dumps(
382
+ {
383
+ "session_id": session_id,
384
+ "run_id": run_id,
385
+ "request_id": str(message_id),
386
+ "run": run,
387
+ },
388
+ indent=2,
389
+ ensure_ascii=False,
390
+ default=str,
391
+ )
392
+ )
393
+
394
+ if wait:
395
+ problem = _run_problem(run)
396
+ if problem:
397
+ if not json_output:
398
+ typer.secho(f"Problem: {problem}", fg=typer.colors.RED, err=True)
399
+ raise typer.Exit(1)
400
+ if not json_output:
401
+ typer.secho("Analysis completed.", fg=typer.colors.GREEN)
402
+
403
+
404
+ @app.command("status")
405
+ def status_cmd(
406
+ session_id: Annotated[
407
+ str, typer.Argument(help="Chat session ID returned by analysis start.")
408
+ ],
409
+ run_id: Annotated[
410
+ str, typer.Argument(help="Analysis run ID returned by analysis start.")
411
+ ],
412
+ json_output: Annotated[
413
+ bool, typer.Option("--json", help="Print the complete run as JSON.")
414
+ ] = False,
415
+ ) -> None:
416
+ """Show progress, terminal failures, and stale-run details."""
417
+ cfg = load_config()
418
+ key = require_api_key(cfg)
419
+ with OntoraClient(cfg, key) as client:
420
+ run = client.get(
421
+ f"/v1/insights-chat/sessions/{session_id}/analysis-runs/{run_id}"
422
+ )
423
+ if not isinstance(run, dict):
424
+ raise InvalidApiResponse(
425
+ "The analysis status endpoint returned an invalid response."
426
+ )
427
+ if json_output:
428
+ typer.echo(json.dumps(run, indent=2, ensure_ascii=False, default=str))
429
+ else:
430
+ _print_run(run, session_id=session_id, run_id=run_id)
431
+ if _added_since_snapshot(run):
432
+ raise typer.Exit(1)
433
+
434
+
435
+ @app.command("result")
436
+ def result_cmd(
437
+ session_id: Annotated[
438
+ str, typer.Argument(help="Chat session ID returned by analysis start.")
439
+ ],
440
+ run_id: Annotated[
441
+ str, typer.Argument(help="Analysis run ID returned by analysis start.")
442
+ ],
443
+ format: Annotated[
444
+ ResultFormat, typer.Option("--format", "-f")
445
+ ] = ResultFormat.markdown,
446
+ output: Annotated[
447
+ Path | None,
448
+ typer.Option("--output", "-o", help="Write to a file instead of stdout."),
449
+ ] = None,
450
+ ) -> None:
451
+ """Read the server-sanitized analysis result without fetching raw transcripts."""
452
+ cfg = load_config()
453
+ key = require_api_key(cfg)
454
+ with OntoraClient(cfg, key) as client:
455
+ run = client.get(
456
+ f"/v1/insights-chat/sessions/{session_id}/analysis-runs/{run_id}"
457
+ )
458
+ if not isinstance(run, dict):
459
+ raise InvalidApiResponse(
460
+ "The analysis result endpoint returned an invalid response."
461
+ )
462
+
463
+ stale_reason = _stale_reason(run)
464
+ added_since_snapshot = _added_since_snapshot(run)
465
+ result = run.get("result")
466
+ if stale_reason or added_since_snapshot or result is None:
467
+ problem = _run_problem(run)
468
+ if problem is None:
469
+ problem = f"no result is available while status is {_status(run)}"
470
+ typer.secho(f"Error: {problem}", fg=typer.colors.RED, err=True)
471
+ raise typer.Exit(1)
472
+
473
+ if format is ResultFormat.json:
474
+ text = json.dumps(result, indent=2, ensure_ascii=False, default=str)
475
+ else:
476
+ coverage = run.get("coverage") if isinstance(run.get("coverage"), dict) else {}
477
+ text = _result_markdown(result, coverage)
478
+
479
+ if output:
480
+ output.write_text(f"{text}\n", encoding="utf-8")
481
+ typer.echo(f"Wrote {output}")
482
+ else:
483
+ typer.echo(text)
484
+
485
+ if _status(run) == "partial":
486
+ typer.secho(
487
+ "Warning: this is a partial analysis result.",
488
+ fg=typer.colors.YELLOW,
489
+ err=True,
490
+ )