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.
@@ -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)