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.
- ontora_cli-0.3.0/PKG-INFO +26 -0
- ontora_cli-0.3.0/ontora_cli/__init__.py +3 -0
- ontora_cli-0.3.0/ontora_cli/client.py +136 -0
- ontora_cli-0.3.0/ontora_cli/commands/analysis.py +490 -0
- ontora_cli-0.3.0/ontora_cli/commands/auth.py +67 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/campaigns.py +88 -13
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/contacts.py +5 -1
- ontora_cli-0.3.0/ontora_cli/commands/corpus.py +1501 -0
- ontora_cli-0.3.0/ontora_cli/commands/people.py +50 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/query.py +18 -5
- ontora_cli-0.3.0/ontora_cli/commands/transcripts.py +105 -0
- ontora_cli-0.3.0/ontora_cli/formatters.py +166 -0
- ontora_cli-0.3.0/ontora_cli/main.py +119 -0
- ontora_cli-0.3.0/ontora_cli/schemas.py +77 -0
- ontora_cli-0.3.0/ontora_cli.egg-info/PKG-INFO +26 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/SOURCES.txt +9 -2
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/pyproject.toml +3 -3
- ontora_cli-0.3.0/tests/test_analysis.py +474 -0
- ontora_cli-0.3.0/tests/test_auth.py +102 -0
- ontora_cli-0.3.0/tests/test_client.py +112 -0
- ontora_cli-0.3.0/tests/test_corpus.py +937 -0
- ontora_cli-0.3.0/tests/test_transcripts.py +78 -0
- ontora_cli-0.1.0/PKG-INFO +0 -118
- ontora_cli-0.1.0/README.md +0 -93
- ontora_cli-0.1.0/ontora_cli/__init__.py +0 -3
- ontora_cli-0.1.0/ontora_cli/client.py +0 -72
- ontora_cli-0.1.0/ontora_cli/commands/auth.py +0 -40
- ontora_cli-0.1.0/ontora_cli/commands/transcripts.py +0 -65
- ontora_cli-0.1.0/ontora_cli/formatters.py +0 -84
- ontora_cli-0.1.0/ontora_cli/main.py +0 -70
- ontora_cli-0.1.0/ontora_cli/schemas.py +0 -46
- ontora_cli-0.1.0/ontora_cli.egg-info/PKG-INFO +0 -118
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/__init__.py +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/reports.py +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/commands/webhooks.py +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli/config.py +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/dependency_links.txt +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/entry_points.txt +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/requires.txt +0 -0
- {ontora_cli-0.1.0 → ontora_cli-0.3.0}/ontora_cli.egg-info/top_level.txt +0 -0
- {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,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
|
+
)
|