applied-cli 0.1.0__py3-none-any.whl
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.
- applied_cli/__init__.py +2 -0
- applied_cli/auth_store.py +263 -0
- applied_cli/commands/__init__.py +2 -0
- applied_cli/commands/_hints.py +11 -0
- applied_cli/commands/_normalize.py +79 -0
- applied_cli/commands/_parsers.py +58 -0
- applied_cli/commands/_ui.py +33 -0
- applied_cli/commands/agent.py +1231 -0
- applied_cli/commands/auth.py +739 -0
- applied_cli/commands/chat.py +379 -0
- applied_cli/commands/coverage.py +348 -0
- applied_cli/commands/discover.py +1006 -0
- applied_cli/commands/fix.py +1204 -0
- applied_cli/commands/insights.py +614 -0
- applied_cli/commands/intents.py +447 -0
- applied_cli/commands/rate.py +508 -0
- applied_cli/commands/responses.py +604 -0
- applied_cli/commands/shop.py +1757 -0
- applied_cli/commands/simulate.py +330 -0
- applied_cli/commands/spec.py +238 -0
- applied_cli/config.py +50 -0
- applied_cli/error_reporting.py +38 -0
- applied_cli/http.py +1614 -0
- applied_cli/main.py +90 -0
- applied_cli/mcp_server.py +738 -0
- applied_cli/presets/demo.yaml +170 -0
- applied_cli/runtime.py +53 -0
- applied_cli/shop_spec.py +398 -0
- applied_cli/spec_workflow.py +432 -0
- applied_cli-0.1.0.dist-info/METADATA +176 -0
- applied_cli-0.1.0.dist-info/RECORD +34 -0
- applied_cli-0.1.0.dist-info/WHEEL +5 -0
- applied_cli-0.1.0.dist-info/entry_points.txt +3 -0
- applied_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from applied_cli.error_reporting import render_api_error
|
|
7
|
+
from applied_cli.http import APIError, get_conversation, list_conversations, list_property_choices
|
|
8
|
+
from applied_cli.runtime import resolve_runtime
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
help="Manage shop taxonomy: topics and intents for conversation classification."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_CHANNEL_TO_CONVERSATION_TYPE = {
|
|
15
|
+
"chat": "web_chat",
|
|
16
|
+
"email": "email",
|
|
17
|
+
"sms": "sms",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_channels(raw: str) -> list[str]:
|
|
22
|
+
channels = [item.strip().lower() for item in raw.split(",") if item.strip()]
|
|
23
|
+
if not channels:
|
|
24
|
+
raise typer.BadParameter("channels must include at least one value.")
|
|
25
|
+
invalid = [item for item in channels if item not in _CHANNEL_TO_CONVERSATION_TYPE]
|
|
26
|
+
if invalid:
|
|
27
|
+
raise typer.BadParameter(
|
|
28
|
+
f"Unsupported channel(s): {', '.join(invalid)}. Expected: chat,email,sms."
|
|
29
|
+
)
|
|
30
|
+
return channels
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _collect_intents(
|
|
34
|
+
*,
|
|
35
|
+
base_url: str,
|
|
36
|
+
shop_id: str,
|
|
37
|
+
api_token: str,
|
|
38
|
+
include_flags: bool,
|
|
39
|
+
) -> list[dict[str, Any]]:
|
|
40
|
+
choices = list_property_choices(
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
shop_id=shop_id,
|
|
43
|
+
api_token=api_token,
|
|
44
|
+
ordering="name",
|
|
45
|
+
)
|
|
46
|
+
by_id = {str(row.get("id")): row for row in choices if row.get("id")}
|
|
47
|
+
intents: list[dict[str, Any]] = []
|
|
48
|
+
for row in choices:
|
|
49
|
+
parent = row.get("parent_choice")
|
|
50
|
+
if not isinstance(parent, str):
|
|
51
|
+
continue
|
|
52
|
+
is_flag = bool(row.get("is_flag"))
|
|
53
|
+
if is_flag and not include_flags:
|
|
54
|
+
continue
|
|
55
|
+
parent_row = by_id.get(parent, {})
|
|
56
|
+
topic = str(parent_row.get("name") or "(none)")
|
|
57
|
+
intents.append(
|
|
58
|
+
{
|
|
59
|
+
"topic": topic,
|
|
60
|
+
"intent": str(row.get("name") or "(none)"),
|
|
61
|
+
"intent_id": str(row.get("id")),
|
|
62
|
+
"topic_id": parent,
|
|
63
|
+
"is_flag": is_flag,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
if intents:
|
|
67
|
+
intents.sort(key=lambda item: (item["topic"].lower(), item["intent"].lower()))
|
|
68
|
+
return intents
|
|
69
|
+
|
|
70
|
+
# Fallback for shops where taxonomy endpoints are restricted:
|
|
71
|
+
# infer intent ids from audit conversation feed, then resolve names via conversation detail.
|
|
72
|
+
sample_by_intent: dict[str, str] = {}
|
|
73
|
+
offset = 0
|
|
74
|
+
limit = 200
|
|
75
|
+
while True:
|
|
76
|
+
rows = list_conversations(
|
|
77
|
+
base_url=base_url,
|
|
78
|
+
shop_id=shop_id,
|
|
79
|
+
api_token=api_token,
|
|
80
|
+
response_type="audit",
|
|
81
|
+
limit=limit,
|
|
82
|
+
offset=offset,
|
|
83
|
+
ordering="-created_at",
|
|
84
|
+
)
|
|
85
|
+
if not rows:
|
|
86
|
+
break
|
|
87
|
+
for row in rows:
|
|
88
|
+
sublabel_id = row.get("sublabel_id")
|
|
89
|
+
conversation_id = row.get("id")
|
|
90
|
+
if (
|
|
91
|
+
isinstance(sublabel_id, str)
|
|
92
|
+
and sublabel_id
|
|
93
|
+
and isinstance(conversation_id, str)
|
|
94
|
+
and conversation_id
|
|
95
|
+
and sublabel_id not in sample_by_intent
|
|
96
|
+
):
|
|
97
|
+
sample_by_intent[sublabel_id] = conversation_id
|
|
98
|
+
if len(rows) < limit:
|
|
99
|
+
break
|
|
100
|
+
offset += limit
|
|
101
|
+
|
|
102
|
+
for intent_id, conversation_id in sample_by_intent.items():
|
|
103
|
+
detail = get_conversation(
|
|
104
|
+
base_url=base_url,
|
|
105
|
+
shop_id=shop_id,
|
|
106
|
+
api_token=api_token,
|
|
107
|
+
conversation_id=conversation_id,
|
|
108
|
+
)
|
|
109
|
+
label = detail.get("label")
|
|
110
|
+
sublabel = detail.get("sublabel")
|
|
111
|
+
topic_name = (
|
|
112
|
+
str(label.get("name"))
|
|
113
|
+
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
|
114
|
+
else "(none)"
|
|
115
|
+
)
|
|
116
|
+
intent_name = (
|
|
117
|
+
str(sublabel.get("name"))
|
|
118
|
+
if isinstance(sublabel, dict) and isinstance(sublabel.get("name"), str)
|
|
119
|
+
else "(none)"
|
|
120
|
+
)
|
|
121
|
+
topic_id = (
|
|
122
|
+
str(label.get("id"))
|
|
123
|
+
if isinstance(label, dict) and label.get("id")
|
|
124
|
+
else ""
|
|
125
|
+
)
|
|
126
|
+
intents.append(
|
|
127
|
+
{
|
|
128
|
+
"topic": topic_name,
|
|
129
|
+
"intent": intent_name,
|
|
130
|
+
"intent_id": intent_id,
|
|
131
|
+
"topic_id": topic_id,
|
|
132
|
+
"is_flag": False,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
intents.sort(key=lambda item: (item["topic"].lower(), item["intent"].lower()))
|
|
136
|
+
return intents
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_test_counts(
|
|
140
|
+
*,
|
|
141
|
+
base_url: str,
|
|
142
|
+
shop_id: str,
|
|
143
|
+
api_token: str,
|
|
144
|
+
channels: list[str],
|
|
145
|
+
agent_id: Optional[str],
|
|
146
|
+
) -> dict[str, dict[str, int]]:
|
|
147
|
+
out: dict[str, dict[str, int]] = {channel: {} for channel in channels}
|
|
148
|
+
for channel in channels:
|
|
149
|
+
conversation_type = _CHANNEL_TO_CONVERSATION_TYPE[channel]
|
|
150
|
+
offset = 0
|
|
151
|
+
limit = 200
|
|
152
|
+
while True:
|
|
153
|
+
rows = list_conversations(
|
|
154
|
+
base_url=base_url,
|
|
155
|
+
shop_id=shop_id,
|
|
156
|
+
api_token=api_token,
|
|
157
|
+
agent_id=agent_id,
|
|
158
|
+
conversation_type=conversation_type,
|
|
159
|
+
response_type="audit",
|
|
160
|
+
is_test=True,
|
|
161
|
+
limit=limit,
|
|
162
|
+
offset=offset,
|
|
163
|
+
ordering="-created_at",
|
|
164
|
+
)
|
|
165
|
+
if not rows:
|
|
166
|
+
break
|
|
167
|
+
for row in rows:
|
|
168
|
+
sublabel_id = row.get("sublabel_id")
|
|
169
|
+
if isinstance(sublabel_id, str) and sublabel_id:
|
|
170
|
+
out[channel][sublabel_id] = out[channel].get(sublabel_id, 0) + 1
|
|
171
|
+
if len(rows) < limit:
|
|
172
|
+
break
|
|
173
|
+
offset += limit
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command(
|
|
178
|
+
"list",
|
|
179
|
+
help=(
|
|
180
|
+
"List topic/intents available in shop taxonomy. Example: applied-cli taxonomy list "
|
|
181
|
+
"--include-flags"
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
def list_cmd(
|
|
185
|
+
include_flags: bool = typer.Option(
|
|
186
|
+
False, "--include-flags", help="Include flag-like intents (is_flag=true)."
|
|
187
|
+
),
|
|
188
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
189
|
+
base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
|
|
190
|
+
shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
|
|
191
|
+
api_token: Optional[str] = typer.Option(None, help="Applied API token."),
|
|
192
|
+
) -> None:
|
|
193
|
+
try:
|
|
194
|
+
resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
|
|
195
|
+
base_url=base_url,
|
|
196
|
+
shop_id=shop_id,
|
|
197
|
+
api_token=api_token,
|
|
198
|
+
)
|
|
199
|
+
intents = _collect_intents(
|
|
200
|
+
base_url=resolved_base_url,
|
|
201
|
+
shop_id=resolved_shop_id,
|
|
202
|
+
api_token=resolved_token,
|
|
203
|
+
include_flags=include_flags,
|
|
204
|
+
)
|
|
205
|
+
except APIError as exc:
|
|
206
|
+
typer.echo(render_api_error(exc, action="list intents"), err=True)
|
|
207
|
+
raise typer.Exit(code=1) from exc
|
|
208
|
+
|
|
209
|
+
payload = {
|
|
210
|
+
"shop_id": resolved_shop_id,
|
|
211
|
+
"intent_count": len(intents),
|
|
212
|
+
"intents": intents,
|
|
213
|
+
}
|
|
214
|
+
if output_json:
|
|
215
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
216
|
+
return
|
|
217
|
+
if not intents:
|
|
218
|
+
typer.echo("No intents found.")
|
|
219
|
+
return
|
|
220
|
+
for row in intents:
|
|
221
|
+
typer.echo(
|
|
222
|
+
f"topic={row['topic']} | intent={row['intent']} | intent_id={row['intent_id']} | is_flag={row['is_flag']}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _build_intent_coverage_payload(
|
|
227
|
+
*,
|
|
228
|
+
base_url: str,
|
|
229
|
+
shop_id: str,
|
|
230
|
+
api_token: str,
|
|
231
|
+
channels: list[str],
|
|
232
|
+
min_tests_per_channel: int,
|
|
233
|
+
include_flags: bool,
|
|
234
|
+
agent_id: Optional[str],
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
intents = _collect_intents(
|
|
237
|
+
base_url=base_url,
|
|
238
|
+
shop_id=shop_id,
|
|
239
|
+
api_token=api_token,
|
|
240
|
+
include_flags=include_flags,
|
|
241
|
+
)
|
|
242
|
+
counts = _collect_test_counts(
|
|
243
|
+
base_url=base_url,
|
|
244
|
+
shop_id=shop_id,
|
|
245
|
+
api_token=api_token,
|
|
246
|
+
channels=channels,
|
|
247
|
+
agent_id=agent_id,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
rows: list[dict[str, Any]] = []
|
|
251
|
+
for item in intents:
|
|
252
|
+
channel_counts = {channel: counts[channel].get(item["intent_id"], 0) for channel in channels}
|
|
253
|
+
missing_channels = [
|
|
254
|
+
channel
|
|
255
|
+
for channel, value in channel_counts.items()
|
|
256
|
+
if value < min_tests_per_channel
|
|
257
|
+
]
|
|
258
|
+
rows.append(
|
|
259
|
+
{
|
|
260
|
+
**item,
|
|
261
|
+
"test_counts": channel_counts,
|
|
262
|
+
"missing_channels": missing_channels,
|
|
263
|
+
"covered_all_channels": not missing_channels,
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
missing_rows = [row for row in rows if not row["covered_all_channels"]]
|
|
268
|
+
return {
|
|
269
|
+
"shop_id": shop_id,
|
|
270
|
+
"agent_id": agent_id,
|
|
271
|
+
"channels": channels,
|
|
272
|
+
"min_tests_per_channel": min_tests_per_channel,
|
|
273
|
+
"summary": {
|
|
274
|
+
"intent_count": len(rows),
|
|
275
|
+
"fully_covered_count": len(rows) - len(missing_rows),
|
|
276
|
+
"gaps_count": len(missing_rows),
|
|
277
|
+
},
|
|
278
|
+
"rows": rows,
|
|
279
|
+
"gaps": missing_rows,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.command(
|
|
284
|
+
"coverage",
|
|
285
|
+
help=(
|
|
286
|
+
"Show per-intent test coverage matrix by channel. Example: applied-cli taxonomy coverage "
|
|
287
|
+
"--channels chat,email --min-tests-per-channel 1 --only-gaps"
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
def coverage_cmd(
|
|
291
|
+
channels: str = typer.Option(
|
|
292
|
+
"chat,email",
|
|
293
|
+
"--channels",
|
|
294
|
+
help="Comma-separated channels: chat,email,sms",
|
|
295
|
+
),
|
|
296
|
+
min_tests_per_channel: int = typer.Option(
|
|
297
|
+
1,
|
|
298
|
+
"--min-tests-per-channel",
|
|
299
|
+
help="Minimum required test conversations per intent per channel.",
|
|
300
|
+
),
|
|
301
|
+
include_flags: bool = typer.Option(
|
|
302
|
+
False, "--include-flags", help="Include flag-like intents (is_flag=true)."
|
|
303
|
+
),
|
|
304
|
+
only_gaps: bool = typer.Option(
|
|
305
|
+
False, "--only-gaps", help="Show only intents missing required channel coverage."
|
|
306
|
+
),
|
|
307
|
+
agent_id: Optional[str] = typer.Option(
|
|
308
|
+
None,
|
|
309
|
+
"--agent-id",
|
|
310
|
+
"--agent",
|
|
311
|
+
"--id",
|
|
312
|
+
help="Optional agent UUID to scope test conversations.",
|
|
313
|
+
),
|
|
314
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
315
|
+
base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
|
|
316
|
+
shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
|
|
317
|
+
api_token: Optional[str] = typer.Option(None, help="Applied API token."),
|
|
318
|
+
) -> None:
|
|
319
|
+
if min_tests_per_channel < 1:
|
|
320
|
+
raise typer.BadParameter("min-tests-per-channel must be >= 1.")
|
|
321
|
+
selected_channels = _parse_channels(channels)
|
|
322
|
+
try:
|
|
323
|
+
resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
|
|
324
|
+
base_url=base_url,
|
|
325
|
+
shop_id=shop_id,
|
|
326
|
+
api_token=api_token,
|
|
327
|
+
)
|
|
328
|
+
payload = _build_intent_coverage_payload(
|
|
329
|
+
base_url=resolved_base_url,
|
|
330
|
+
shop_id=resolved_shop_id,
|
|
331
|
+
api_token=resolved_token,
|
|
332
|
+
channels=selected_channels,
|
|
333
|
+
min_tests_per_channel=min_tests_per_channel,
|
|
334
|
+
include_flags=include_flags,
|
|
335
|
+
agent_id=agent_id,
|
|
336
|
+
)
|
|
337
|
+
except APIError as exc:
|
|
338
|
+
typer.echo(render_api_error(exc, action="calculate intent coverage"), err=True)
|
|
339
|
+
raise typer.Exit(code=1) from exc
|
|
340
|
+
|
|
341
|
+
rows = payload["gaps"] if only_gaps else payload["rows"]
|
|
342
|
+
if output_json:
|
|
343
|
+
output = dict(payload)
|
|
344
|
+
output["rows"] = rows
|
|
345
|
+
typer.echo(json.dumps(output, indent=2, default=str))
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
summary = payload["summary"]
|
|
349
|
+
typer.echo(
|
|
350
|
+
"summary="
|
|
351
|
+
+ ", ".join(
|
|
352
|
+
[
|
|
353
|
+
f"intents={summary['intent_count']}",
|
|
354
|
+
f"fully_covered={summary['fully_covered_count']}",
|
|
355
|
+
f"gaps={summary['gaps_count']}",
|
|
356
|
+
f"min_tests_per_channel={payload['min_tests_per_channel']}",
|
|
357
|
+
f"channels={','.join(payload['channels'])}",
|
|
358
|
+
]
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
if not rows:
|
|
362
|
+
typer.echo("No results.")
|
|
363
|
+
return
|
|
364
|
+
for row in rows:
|
|
365
|
+
counts_view = ", ".join(
|
|
366
|
+
[f"{channel}:{row['test_counts'].get(channel, 0)}" for channel in payload["channels"]]
|
|
367
|
+
)
|
|
368
|
+
missing_view = ",".join(row["missing_channels"]) if row["missing_channels"] else "none"
|
|
369
|
+
typer.echo(
|
|
370
|
+
f"topic={row['topic']} | intent={row['intent']} | intent_id={row['intent_id']} | tests=({counts_view}) | missing={missing_view}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@app.command(
|
|
375
|
+
"gate",
|
|
376
|
+
help=(
|
|
377
|
+
"Fail with exit code 1 if intent coverage has gaps. Example: applied-cli taxonomy gate "
|
|
378
|
+
"--channels chat,email --min-tests-per-channel 1"
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
def gate_cmd(
|
|
382
|
+
channels: str = typer.Option(
|
|
383
|
+
"chat,email",
|
|
384
|
+
"--channels",
|
|
385
|
+
help="Comma-separated channels: chat,email,sms",
|
|
386
|
+
),
|
|
387
|
+
min_tests_per_channel: int = typer.Option(
|
|
388
|
+
1,
|
|
389
|
+
"--min-tests-per-channel",
|
|
390
|
+
help="Minimum required test conversations per intent per channel.",
|
|
391
|
+
),
|
|
392
|
+
include_flags: bool = typer.Option(
|
|
393
|
+
False, "--include-flags", help="Include flag-like intents (is_flag=true)."
|
|
394
|
+
),
|
|
395
|
+
agent_id: Optional[str] = typer.Option(
|
|
396
|
+
None,
|
|
397
|
+
"--agent-id",
|
|
398
|
+
"--agent",
|
|
399
|
+
"--id",
|
|
400
|
+
help="Optional agent UUID to scope test conversations.",
|
|
401
|
+
),
|
|
402
|
+
base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
|
|
403
|
+
shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
|
|
404
|
+
api_token: Optional[str] = typer.Option(None, help="Applied API token."),
|
|
405
|
+
) -> None:
|
|
406
|
+
if min_tests_per_channel < 1:
|
|
407
|
+
raise typer.BadParameter("min-tests-per-channel must be >= 1.")
|
|
408
|
+
selected_channels = _parse_channels(channels)
|
|
409
|
+
try:
|
|
410
|
+
resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
|
|
411
|
+
base_url=base_url,
|
|
412
|
+
shop_id=shop_id,
|
|
413
|
+
api_token=api_token,
|
|
414
|
+
)
|
|
415
|
+
payload = _build_intent_coverage_payload(
|
|
416
|
+
base_url=resolved_base_url,
|
|
417
|
+
shop_id=resolved_shop_id,
|
|
418
|
+
api_token=resolved_token,
|
|
419
|
+
channels=selected_channels,
|
|
420
|
+
min_tests_per_channel=min_tests_per_channel,
|
|
421
|
+
include_flags=include_flags,
|
|
422
|
+
agent_id=agent_id,
|
|
423
|
+
)
|
|
424
|
+
except APIError as exc:
|
|
425
|
+
typer.echo(render_api_error(exc, action="evaluate intent coverage gate"), err=True)
|
|
426
|
+
raise typer.Exit(code=1) from exc
|
|
427
|
+
|
|
428
|
+
gaps = payload["gaps"]
|
|
429
|
+
if not gaps:
|
|
430
|
+
typer.echo(
|
|
431
|
+
"result=success | gate=pass | "
|
|
432
|
+
f"intents={payload['summary']['intent_count']} | channels={','.join(payload['channels'])}"
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
typer.echo(
|
|
436
|
+
"result=failed | gate=fail | "
|
|
437
|
+
f"gaps={payload['summary']['gaps_count']} | channels={','.join(payload['channels'])}",
|
|
438
|
+
err=True,
|
|
439
|
+
)
|
|
440
|
+
for row in gaps[:20]:
|
|
441
|
+
typer.echo(
|
|
442
|
+
f"- topic={row['topic']} | intent={row['intent']} | missing={','.join(row['missing_channels'])}",
|
|
443
|
+
err=True,
|
|
444
|
+
)
|
|
445
|
+
if len(gaps) > 20:
|
|
446
|
+
typer.echo(f"- ... and {len(gaps) - 20} more gaps", err=True)
|
|
447
|
+
raise typer.Exit(code=1)
|