applied-cli 0.6.11__tar.gz → 0.7.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 (76) hide show
  1. {applied_cli-0.6.11 → applied_cli-0.7.0}/PKG-INFO +1 -1
  2. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/__init__.py +1 -1
  3. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/client.py +20 -1
  4. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/tools.py +3 -0
  5. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/catalog.py +2 -0
  6. applied_cli-0.7.0/applied_cli/v2/dashboards.py +330 -0
  7. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/evals.py +53 -12
  8. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/PKG-INFO +1 -1
  9. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/SOURCES.txt +2 -0
  10. {applied_cli-0.6.11 → applied_cli-0.7.0}/pyproject.toml +1 -1
  11. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_client.py +29 -0
  12. applied_cli-0.7.0/tests/test_v2_dashboards.py +142 -0
  13. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_evals.py +24 -0
  14. {applied_cli-0.6.11 → applied_cli-0.7.0}/README.md +0 -0
  15. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/agent_scoped_flows.py +0 -0
  16. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/auth.py +0 -0
  17. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/cli.py +0 -0
  18. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/conversation_lookup.py +0 -0
  19. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/conversations.py +0 -0
  20. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/credentials.py +0 -0
  21. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/flow_helpers.py +0 -0
  22. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/formatters.py +0 -0
  23. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/mcp.py +0 -0
  24. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/recovery.py +0 -0
  25. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/toolkit.py +0 -0
  26. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/__init__.py +0 -0
  27. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/agents.py +0 -0
  28. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/articles.py +0 -0
  29. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/connectors.py +0 -0
  30. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/content.py +0 -0
  31. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/conversations.py +0 -0
  32. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/domains.py +0 -0
  33. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/flows.py +0 -0
  34. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/knowledge.py +0 -0
  35. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/manifest.py +0 -0
  36. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/products.py +0 -0
  37. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/scenarios.py +0 -0
  38. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/taxonomy.py +0 -0
  39. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli/v2/tickets.py +0 -0
  40. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/dependency_links.txt +0 -0
  41. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/entry_points.txt +0 -0
  42. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/requires.txt +0 -0
  43. {applied_cli-0.6.11 → applied_cli-0.7.0}/applied_cli.egg-info/top_level.txt +0 -0
  44. {applied_cli-0.6.11 → applied_cli-0.7.0}/setup.cfg +0 -0
  45. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_agent_scoped_flows.py +0 -0
  46. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_audit_tools.py +0 -0
  47. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_auth_context.py +0 -0
  48. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_benchmark_clone.py +0 -0
  49. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_benchmark_delete_guardrail.py +0 -0
  50. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_benchmark_list_with_results.py +0 -0
  51. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_benchmark_results.py +0 -0
  52. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_benchmark_scenario_tools.py +0 -0
  53. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_cli.py +0 -0
  54. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_cli_v2.py +0 -0
  55. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_client_v2.py +0 -0
  56. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_conversation_tools.py +0 -0
  57. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_flow_tools.py +0 -0
  58. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_knowledge_content_tools.py +0 -0
  59. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_recovery.py +0 -0
  60. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_scenario_bulk_cancel.py +0 -0
  61. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_scenario_bulk_run_contact.py +0 -0
  62. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_scenario_bulk_run_wait.py +0 -0
  63. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_scenario_create_bulk.py +0 -0
  64. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_toolkit_contract.py +0 -0
  65. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_agents.py +0 -0
  66. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_articles.py +0 -0
  67. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_catalog_and_mcp.py +0 -0
  68. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_connectors.py +0 -0
  69. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_content.py +0 -0
  70. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_conversations.py +0 -0
  71. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_flows.py +0 -0
  72. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_knowledge.py +0 -0
  73. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_products.py +0 -0
  74. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_scenarios.py +0 -0
  75. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_taxonomy.py +0 -0
  76. {applied_cli-0.6.11 → applied_cli-0.7.0}/tests/test_v2_tickets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.11
3
+ Version: 0.7.0
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -6,7 +6,7 @@ from applied_cli.client import AppliedClient
6
6
  from applied_cli.formatters import to_csv, to_json
7
7
  from applied_cli.toolkit import ToolResult, ToolSpec
8
8
 
9
- __version__ = "0.6.11"
9
+ __version__ = "0.7.0"
10
10
 
11
11
  __all__ = [
12
12
  "AppliedClient",
@@ -210,6 +210,21 @@ def _filter_has_explicit_lookup(field: str) -> bool:
210
210
  return field.endswith(_EXPLICIT_FILTER_SUFFIXES)
211
211
 
212
212
 
213
+ # Filters the server declares ONLY in scalar form (method filters, e.g. the
214
+ # evals review-state filters on ConversationFilterset). Rewriting these to
215
+ # `field__in` silently drops them — django-filter ignores unknown params —
216
+ # so they must pass through verbatim.
217
+ _SCALAR_ONLY_FILTER_FIELDS = frozenset(
218
+ {
219
+ "verdict",
220
+ "reviewed",
221
+ "needs_attention",
222
+ "exclude_audited",
223
+ "only_unaudited",
224
+ }
225
+ )
226
+
227
+
213
228
  def _serialize_filter_value(value: Any) -> str:
214
229
  if isinstance(value, (dict, list)):
215
230
  return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
@@ -260,7 +275,11 @@ def _apply_query_filter(params: dict[str, Any], field: str, cond: Any) -> None:
260
275
  )
261
276
  return
262
277
 
263
- key = field if explicit_lookup else f"{field}__in"
278
+ key = (
279
+ field
280
+ if explicit_lookup or field in _SCALAR_ONLY_FILTER_FIELDS
281
+ else f"{field}__in"
282
+ )
264
283
  params[key] = _serialize_filter_value(cond)
265
284
 
266
285
 
@@ -1186,6 +1186,9 @@ async def conversation_query(
1186
1186
  {"sublabel_id": "<intent-uuid>"} — filter by intent
1187
1187
  {"flags__in": "<flag-uuid>"} — has specific flag
1188
1188
  {"resolution": {"nin": ["escalated"]}} — exclude escalated
1189
+ {"verdict": "ai_flagged"} — AI Reviewer flagged (also: pass, fail,
1190
+ disagree, agree, none)
1191
+ {"reviewed": False} — no human review verdict yet
1189
1192
  search: Full-text search across conversation content
1190
1193
  fields: Fields to return, e.g. ["id", "title", "contact.email", "score", "comment"]
1191
1194
  When provided, fields override view-specific projections.
@@ -17,6 +17,7 @@ from applied_cli.v2.articles import article_specs
17
17
  from applied_cli.v2.connectors import connector_specs
18
18
  from applied_cli.v2.content import content_specs
19
19
  from applied_cli.v2.conversations import conversation_specs
20
+ from applied_cli.v2.dashboards import dashboard_specs
20
21
  from applied_cli.v2.domains import LEGACY_TOOL_RENAMES
21
22
  from applied_cli.v2.evals import evals_specs
22
23
  from applied_cli.v2.flows import flow_specs
@@ -228,6 +229,7 @@ def get_tool_catalog(include_debug: bool = False) -> ToolCatalog:
228
229
  *connector_specs(),
229
230
  *content_specs(),
230
231
  *conversation_specs(),
232
+ *dashboard_specs(),
231
233
  *evals_specs(),
232
234
  *flow_specs(),
233
235
  *knowledge_specs(),
@@ -0,0 +1,330 @@
1
+ """Native structured v2 tools for analytics dashboards and save-metric config.
2
+
3
+ These tools let an agent stand up a shop's Save Agent analytics end to end:
4
+ list the built-in dashboard templates, instantiate one (e.g. ``save_agent``),
5
+ and read/update the save-metric vocabulary (cancellation reasons and save
6
+ tactics) that the post-conversation metric extractor classifies against.
7
+
8
+ All endpoints are shop-scoped and merchant-authenticated. Save-metric
9
+ extraction itself is enrolled per shop via the ``save_metric_extraction``
10
+ feature flag (platform admin) — ``save_metrics_config_get`` reports whether
11
+ the current shop is enrolled.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ from applied_cli.client import AppliedAPIError, AppliedClient
21
+ from applied_cli.toolkit import ToolError, ToolResult, ToolSpec
22
+
23
+
24
+ class StrictInput(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+
28
+ # ── inputs ──────────────────────────────────────────────────────────────────
29
+
30
+
31
+ class DashboardsListInput(StrictInput):
32
+ pass
33
+
34
+
35
+ class DashboardTemplatesListInput(StrictInput):
36
+ pass
37
+
38
+
39
+ class DashboardsCreateInput(StrictInput):
40
+ template_key: str | None = Field(
41
+ default=None,
42
+ description=(
43
+ "Key of a built-in dashboard template to instantiate (see "
44
+ "dashboards_templates_list; e.g. 'save_agent'). Omit to create an "
45
+ "empty dashboard."
46
+ ),
47
+ )
48
+ name: str | None = Field(
49
+ default=None,
50
+ description="Dashboard name. Defaults to the template's name.",
51
+ )
52
+ description: str | None = Field(
53
+ default=None,
54
+ description="Dashboard description. Defaults to the template's.",
55
+ )
56
+ is_public: bool = Field(
57
+ default=True,
58
+ description=(
59
+ "Whether the dashboard is visible to the whole workspace (true) "
60
+ "or private to the creator (false)."
61
+ ),
62
+ )
63
+
64
+
65
+ class SaveMetricsConfigGetInput(StrictInput):
66
+ pass
67
+
68
+
69
+ class SaveMetricsConfigUpdateInput(StrictInput):
70
+ cancellation_reasons: list[str] | None = Field(
71
+ default=None,
72
+ description=(
73
+ "Canonical cancellation_reason values the extractor classifies "
74
+ "against. Replaces the current list; pass [] to clear the override "
75
+ "and fall back to values observed in the shop's metric events. "
76
+ "Omit to leave unchanged."
77
+ ),
78
+ )
79
+ save_tactics: list[str] | None = Field(
80
+ default=None,
81
+ description=(
82
+ "Canonical save_tactic values the extractor classifies against. "
83
+ "Replaces the current list; pass [] to clear the override and fall "
84
+ "back to observed values. Omit to leave unchanged."
85
+ ),
86
+ )
87
+
88
+
89
+ # ── helpers ─────────────────────────────────────────────────────────────────
90
+
91
+
92
+ def _api_error_result(exc: AppliedAPIError) -> ToolResult[Any]:
93
+ return ToolResult(
94
+ ok=False,
95
+ error=ToolError(
96
+ code=f"api.{exc.status_code}",
97
+ message=str(exc),
98
+ status_code=exc.status_code,
99
+ retryable=exc.status_code in {408, 429, 500, 502, 503, 504},
100
+ recovery_hint=exc.suggestion,
101
+ ),
102
+ )
103
+
104
+
105
+ def _rows(data: Any) -> list[dict[str, Any]]:
106
+ if isinstance(data, list):
107
+ return [row for row in data if isinstance(row, dict)]
108
+ if isinstance(data, dict):
109
+ for key in ("results", "items", "data"):
110
+ value = data.get(key)
111
+ if isinstance(value, list):
112
+ return [row for row in value if isinstance(row, dict)]
113
+ return []
114
+
115
+
116
+ def _project_dashboard(item: dict[str, Any]) -> dict[str, Any]:
117
+ revision = item.get("latest_revision") or item.get("revision") or {}
118
+ if not isinstance(revision, dict):
119
+ revision = {}
120
+ return {
121
+ "id": item.get("id"),
122
+ "name": item.get("name") or revision.get("name"),
123
+ "description": item.get("description") or revision.get("description"),
124
+ "is_public": item.get("is_public"),
125
+ "created_at": item.get("created_at"),
126
+ }
127
+
128
+
129
+ # ── handlers ────────────────────────────────────────────────────────────────
130
+
131
+
132
+ async def dashboards_list_handler(
133
+ client: AppliedClient,
134
+ params: DashboardsListInput,
135
+ ) -> ToolResult[Any]:
136
+ try:
137
+ data = await client.request("GET", "/v1/dashboards/")
138
+ except AppliedAPIError as exc:
139
+ return _api_error_result(exc)
140
+
141
+ items = [_project_dashboard(row) for row in _rows(data)]
142
+ suffix = "" if len(items) == 1 else "s"
143
+ return ToolResult(
144
+ data={"count": len(items), "dashboards": items},
145
+ summary=f"Found {len(items)} dashboard{suffix}.",
146
+ next_actions=[
147
+ "Use dashboards_create with a template_key to add a templated dashboard.",
148
+ ],
149
+ )
150
+
151
+
152
+ async def dashboards_templates_list_handler(
153
+ client: AppliedClient,
154
+ params: DashboardTemplatesListInput,
155
+ ) -> ToolResult[Any]:
156
+ try:
157
+ data = await client.request("GET", "/v1/dashboards/templates/")
158
+ except AppliedAPIError as exc:
159
+ return _api_error_result(exc)
160
+
161
+ templates = data.get("templates", []) if isinstance(data, dict) else []
162
+ suffix = "" if len(templates) == 1 else "s"
163
+ return ToolResult(
164
+ data={"count": len(templates), "templates": templates},
165
+ summary=f"Found {len(templates)} dashboard template{suffix}.",
166
+ next_actions=[
167
+ "Use dashboards_create with template_key to instantiate a template "
168
+ "(e.g. 'save_agent' for the Save Agent dashboard).",
169
+ ],
170
+ )
171
+
172
+
173
+ async def dashboards_create_handler(
174
+ client: AppliedClient,
175
+ params: DashboardsCreateInput,
176
+ ) -> ToolResult[Any]:
177
+ body: dict[str, Any] = {"is_public": params.is_public}
178
+ if params.template_key:
179
+ body["template_key"] = params.template_key
180
+ if params.name:
181
+ body["name"] = params.name
182
+ if params.description:
183
+ body["description"] = params.description
184
+
185
+ try:
186
+ data = await client.request("POST", "/v1/dashboards/", body=body)
187
+ except AppliedAPIError as exc:
188
+ return _api_error_result(exc)
189
+
190
+ dashboard = _project_dashboard(data if isinstance(data, dict) else {})
191
+ label = params.template_key or "empty"
192
+ return ToolResult(
193
+ data={"dashboard": dashboard, "raw": data},
194
+ summary=f"Created dashboard {dashboard.get('id')} from template '{label}'.",
195
+ next_actions=[
196
+ "Use save_metrics_config_get to check the save-metric vocabulary "
197
+ "and enrollment if this is a Save Agent dashboard.",
198
+ ],
199
+ )
200
+
201
+
202
+ async def save_metrics_config_get_handler(
203
+ client: AppliedClient,
204
+ params: SaveMetricsConfigGetInput,
205
+ ) -> ToolResult[Any]:
206
+ try:
207
+ data = await client.request("GET", "/v1/save-metrics/config/")
208
+ except AppliedAPIError as exc:
209
+ return _api_error_result(exc)
210
+
211
+ enrolled = bool(data.get("enrolled")) if isinstance(data, dict) else False
212
+ return ToolResult(
213
+ data=data,
214
+ summary=(
215
+ "Save-metric extraction is "
216
+ + ("enrolled" if enrolled else "NOT enrolled")
217
+ + " for this shop."
218
+ ),
219
+ next_actions=[
220
+ "Use save_metrics_config_update to set cancellation_reasons or save_tactics.",
221
+ "Enrollment needs the save_metric_extraction feature flag (platform "
222
+ "admin) plus a dashboard charting the save metrics.",
223
+ ],
224
+ )
225
+
226
+
227
+ async def save_metrics_config_update_handler(
228
+ client: AppliedClient,
229
+ params: SaveMetricsConfigUpdateInput,
230
+ ) -> ToolResult[Any]:
231
+ body: dict[str, Any] = {}
232
+ if params.cancellation_reasons is not None:
233
+ body["cancellation_reasons"] = params.cancellation_reasons
234
+ if params.save_tactics is not None:
235
+ body["save_tactics"] = params.save_tactics
236
+ if not body:
237
+ return ToolResult(
238
+ ok=False,
239
+ error=ToolError(
240
+ code="input.invalid",
241
+ message="Provide cancellation_reasons and/or save_tactics.",
242
+ retryable=False,
243
+ recovery_hint=(
244
+ "Pass the full replacement list for at least one of "
245
+ "cancellation_reasons or save_tactics."
246
+ ),
247
+ ),
248
+ )
249
+
250
+ try:
251
+ data = await client.request("PUT", "/v1/save-metrics/config/", body=body)
252
+ except AppliedAPIError as exc:
253
+ return _api_error_result(exc)
254
+
255
+ return ToolResult(
256
+ data=data,
257
+ summary=f"Updated save-metric vocabulary ({', '.join(sorted(body))}).",
258
+ next_actions=[
259
+ "Use save_metrics_config_get to verify the effective taxonomy.",
260
+ ],
261
+ )
262
+
263
+
264
+ def dashboard_specs() -> list[ToolSpec]:
265
+ return [
266
+ ToolSpec(
267
+ name="dashboards_list",
268
+ namespace="dashboards",
269
+ description="List the shop's analytics dashboards.",
270
+ input_model=DashboardsListInput,
271
+ output_model=None,
272
+ handler=dashboards_list_handler,
273
+ read_write_mode="read",
274
+ tags=["native"],
275
+ ),
276
+ ToolSpec(
277
+ name="dashboards_templates_list",
278
+ namespace="dashboards",
279
+ description=(
280
+ "List built-in dashboard templates (e.g. save_agent) usable "
281
+ "with dashboards_create."
282
+ ),
283
+ input_model=DashboardTemplatesListInput,
284
+ output_model=None,
285
+ handler=dashboards_templates_list_handler,
286
+ read_write_mode="read",
287
+ tags=["native"],
288
+ ),
289
+ ToolSpec(
290
+ name="dashboards_create",
291
+ namespace="dashboards",
292
+ description=(
293
+ "Create an analytics dashboard, optionally from a built-in "
294
+ "template such as 'save_agent'."
295
+ ),
296
+ input_model=DashboardsCreateInput,
297
+ output_model=None,
298
+ handler=dashboards_create_handler,
299
+ read_write_mode="write",
300
+ tags=["native"],
301
+ ),
302
+ ToolSpec(
303
+ name="save_metrics_config_get",
304
+ namespace="save_metrics",
305
+ description=(
306
+ "Read the shop's save-metric vocabulary (cancellation reasons "
307
+ "and save tactics), the observed values, and whether the shop "
308
+ "is enrolled in post-conversation save-metric extraction."
309
+ ),
310
+ input_model=SaveMetricsConfigGetInput,
311
+ output_model=None,
312
+ handler=save_metrics_config_get_handler,
313
+ read_write_mode="read",
314
+ tags=["native"],
315
+ ),
316
+ ToolSpec(
317
+ name="save_metrics_config_update",
318
+ namespace="save_metrics",
319
+ description=(
320
+ "Update the shop's canonical save tactics and/or cancellation "
321
+ "reasons used by save-metric extraction and the Save Agent "
322
+ "dashboard group-bys."
323
+ ),
324
+ input_model=SaveMetricsConfigUpdateInput,
325
+ output_model=None,
326
+ handler=save_metrics_config_update_handler,
327
+ read_write_mode="write",
328
+ tags=["native"],
329
+ ),
330
+ ]
@@ -7,9 +7,10 @@ step. These tools expose that loop to an agent — grade conversations, record
7
7
  verdicts, teach the Reviewer, and promote a bad conversation into the simulation
8
8
  suite as a proposed test (with an auto-generated test customer).
9
9
 
10
- All endpoints are shop-scoped and merchant-authenticated. Discover conversations
11
- to review with `conversations_search` / `conversations_find`, then feed their ids
12
- here.
10
+ All endpoints are shop-scoped and merchant-authenticated. Enumerate the review
11
+ queue directly with `review_status(segment="inbox")` (or "disagree"), or discover
12
+ conversations with `conversations_search` / `conversations_find` and feed their
13
+ ids here.
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -43,8 +44,24 @@ class ReviewRunInput(StrictInput):
43
44
 
44
45
 
45
46
  class ReviewStatusInput(StrictInput):
46
- conversation_ids: list[str] = Field(
47
- description="Conversation ids to fetch verdict pairs + narration for.",
47
+ conversation_ids: list[str] | None = Field(
48
+ default=None,
49
+ description=(
50
+ "Conversation ids to fetch verdict pairs + narration for. Omit and "
51
+ "pass segment to enumerate a queue segment instead."
52
+ ),
53
+ )
54
+ segment: Literal["inbox", "disagree"] | None = Field(
55
+ default=None,
56
+ description=(
57
+ "Enumerate a queue segment server-side, newest judgment first: "
58
+ "'inbox' (AI-flagged, no human verdict yet) or 'disagree' (human "
59
+ "and AI verdicts differ). Ignored when conversation_ids is set."
60
+ ),
61
+ )
62
+ limit: int | None = Field(
63
+ default=None,
64
+ description="Max segment rows (server default 50, max 200).",
48
65
  )
49
66
 
50
67
 
@@ -179,20 +196,42 @@ async def review_run_handler(
179
196
  async def review_status_handler(
180
197
  client: AppliedClient, params: ReviewStatusInput
181
198
  ) -> ToolResult[Any]:
182
- ids = params.conversation_ids[:200]
183
- try:
184
- data = await client.request(
185
- "GET", "/v1/evals/status/", params={"ids": ",".join(ids)}
199
+ ids = (params.conversation_ids or [])[:200]
200
+ if not ids and not params.segment:
201
+ return ToolResult(
202
+ ok=False,
203
+ error=ToolError(
204
+ code="input.invalid",
205
+ message=(
206
+ "Provide conversation_ids, or segment='inbox'/'disagree' "
207
+ "to enumerate a queue segment."
208
+ ),
209
+ retryable=False,
210
+ ),
186
211
  )
212
+ query: dict[str, Any] = {}
213
+ if ids:
214
+ query["ids"] = ",".join(ids)
215
+ else:
216
+ query["segment"] = params.segment
217
+ if params.limit is not None:
218
+ query["limit"] = params.limit
219
+ try:
220
+ data = await client.request("GET", "/v1/evals/status/", params=query)
187
221
  except AppliedAPIError as exc:
188
222
  return _api_error_result(exc)
189
223
  rows = data.get("rows", []) if isinstance(data, dict) else []
224
+ summary = (
225
+ f"{len(rows)} row(s) in the '{params.segment}' segment."
226
+ if not ids
227
+ else f"Verdict pairs for {len(rows)} of {len(ids)} conversation(s)."
228
+ )
190
229
  return ToolResult(
191
230
  data=data,
192
- summary=f"Verdict pairs for {len(rows)} of {len(ids)} conversation(s).",
231
+ summary=summary,
193
232
  next_actions=[
194
233
  "Use review_get for the full review pair + notes on one conversation.",
195
- "Use review_run with the unreviewed ids to grade them.",
234
+ "Use review_set_verdict to confirm or overrule a verdict.",
196
235
  ],
197
236
  )
198
237
 
@@ -424,7 +463,9 @@ def evals_specs() -> list[ToolSpec]:
424
463
  namespace="review",
425
464
  description=(
426
465
  "Fetch the verdict pair (AI + human) and the Reviewer's one-line "
427
- "reason for a set of conversations, plus shop-wide review counts."
466
+ "reason for a set of conversations, plus shop-wide review counts. "
467
+ "Pass segment='inbox' (AI-flagged, unreviewed) or 'disagree' "
468
+ "instead of ids to enumerate the review queue directly."
428
469
  ),
429
470
  input_model=ReviewStatusInput,
430
471
  output_model=None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.11
3
+ Version: 0.7.0
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -27,6 +27,7 @@ applied_cli/v2/catalog.py
27
27
  applied_cli/v2/connectors.py
28
28
  applied_cli/v2/content.py
29
29
  applied_cli/v2/conversations.py
30
+ applied_cli/v2/dashboards.py
30
31
  applied_cli/v2/domains.py
31
32
  applied_cli/v2/evals.py
32
33
  applied_cli/v2/flows.py
@@ -63,6 +64,7 @@ tests/test_v2_catalog_and_mcp.py
63
64
  tests/test_v2_connectors.py
64
65
  tests/test_v2_content.py
65
66
  tests/test_v2_conversations.py
67
+ tests/test_v2_dashboards.py
66
68
  tests/test_v2_evals.py
67
69
  tests/test_v2_flows.py
68
70
  tests/test_v2_knowledge.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.6.11"
3
+ version = "0.7.0"
4
4
  description = "CLI and shared client library for Applied Labs AI support agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -136,6 +136,35 @@ async def test_query_conversations_serializes_metric_event_filter_payload(monkey
136
136
  )
137
137
 
138
138
 
139
+ @pytest.mark.asyncio
140
+ async def test_query_conversations_passes_scalar_only_filters_verbatim(monkeypatch):
141
+ """Method filters like the evals review-state filters exist server-side only
142
+ in scalar form — rewriting them to `field__in` silently drops them."""
143
+ client = AppliedClient(token="test-token")
144
+ seen = {}
145
+
146
+ async def fake_request(method, path, params=None, body=None, shop_id=None):
147
+ seen["params"] = params
148
+ return []
149
+
150
+ monkeypatch.setattr(client, "_request", fake_request)
151
+
152
+ await client.query_conversations(
153
+ filters={
154
+ "verdict": "ai_flagged",
155
+ "reviewed": False,
156
+ "resolution": "escalated",
157
+ }
158
+ )
159
+
160
+ assert seen["params"]["verdict"] == "ai_flagged"
161
+ assert seen["params"]["reviewed"] == "false"
162
+ assert "verdict__in" not in seen["params"]
163
+ assert "reviewed__in" not in seen["params"]
164
+ # Ordinary scalars keep the historical `__in` rewrite.
165
+ assert seen["params"]["resolution__in"] == "escalated"
166
+
167
+
139
168
  @pytest.mark.asyncio
140
169
  async def test_analytics_report_posts_payload(monkeypatch):
141
170
  client = AppliedClient(token="test-token")
@@ -0,0 +1,142 @@
1
+ import pytest
2
+
3
+ from applied_cli.v2.catalog import get_tool_catalog
4
+
5
+
6
+ class FakeDashboardsClient:
7
+ def __init__(self):
8
+ self.access_mode = "full_access"
9
+ self.requests = []
10
+ self.responses = {}
11
+
12
+ async def request(self, method, path, params=None, body=None, shop_id=None):
13
+ self.requests.append(
14
+ {"method": method, "path": path, "params": params, "body": body}
15
+ )
16
+ return self.responses.get((method, path), {})
17
+
18
+
19
+ def test_catalog_exposes_dashboard_and_save_metrics_tools():
20
+ catalog = get_tool_catalog()
21
+ expected = {
22
+ "dashboards_list": "read",
23
+ "dashboards_templates_list": "read",
24
+ "dashboards_create": "write",
25
+ "save_metrics_config_get": "read",
26
+ "save_metrics_config_update": "write",
27
+ }
28
+ for name, mode in expected.items():
29
+ spec = catalog[name]
30
+ assert spec.handler.__module__ == "applied_cli.v2.dashboards"
31
+ assert spec.read_write_mode == mode
32
+ assert "native" in spec.tags
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_dashboards_templates_list_returns_templates():
37
+ client = FakeDashboardsClient()
38
+ client.responses[("GET", "/v1/dashboards/templates/")] = {
39
+ "templates": [
40
+ {
41
+ "key": "save_agent",
42
+ "name": "Save Agent Dashboard",
43
+ "description": "Track save performance.",
44
+ "chart_count": 10,
45
+ }
46
+ ]
47
+ }
48
+ spec = get_tool_catalog()["dashboards_templates_list"]
49
+
50
+ result = await spec.run(client, {})
51
+
52
+ assert result.ok is True
53
+ assert result.data["count"] == 1
54
+ assert result.data["templates"][0]["key"] == "save_agent"
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_dashboards_create_posts_template_key():
59
+ client = FakeDashboardsClient()
60
+ client.responses[("POST", "/v1/dashboards/")] = {
61
+ "id": "dash-1",
62
+ "name": "Save Agent Dashboard",
63
+ "is_public": True,
64
+ }
65
+ spec = get_tool_catalog()["dashboards_create"]
66
+
67
+ result = await spec.run(client, {"template_key": "save_agent"})
68
+
69
+ assert result.ok is True
70
+ assert result.data["dashboard"]["id"] == "dash-1"
71
+ assert client.requests[-1]["body"] == {
72
+ "is_public": True,
73
+ "template_key": "save_agent",
74
+ }
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_dashboards_list_projects_rows():
79
+ client = FakeDashboardsClient()
80
+ client.responses[("GET", "/v1/dashboards/")] = {
81
+ "results": [
82
+ {"id": "dash-1", "name": "Save Agent Dashboard", "is_public": True},
83
+ {"id": "dash-2", "name": "Support Trends", "is_public": False},
84
+ ]
85
+ }
86
+ spec = get_tool_catalog()["dashboards_list"]
87
+
88
+ result = await spec.run(client, {})
89
+
90
+ assert result.ok is True
91
+ assert result.data["count"] == 2
92
+ assert result.data["dashboards"][0]["id"] == "dash-1"
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_save_metrics_config_get_reports_enrollment():
97
+ client = FakeDashboardsClient()
98
+ client.responses[("GET", "/v1/save-metrics/config/")] = {
99
+ "configured": {"save_tactics": ["Offer a discount"]},
100
+ "observed": {"cancellation_reasons": ["Price"], "save_tactics": []},
101
+ "effective": {
102
+ "cancellation_reasons": ["Price"],
103
+ "save_tactics": ["Offer a discount"],
104
+ },
105
+ "enrolled": True,
106
+ "flag_name": "save_metric_extraction",
107
+ }
108
+ spec = get_tool_catalog()["save_metrics_config_get"]
109
+
110
+ result = await spec.run(client, {})
111
+
112
+ assert result.ok is True
113
+ assert "enrolled" in result.summary
114
+ assert result.data["effective"]["save_tactics"] == ["Offer a discount"]
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_save_metrics_config_update_puts_only_provided_lists():
119
+ client = FakeDashboardsClient()
120
+ client.responses[("PUT", "/v1/save-metrics/config/")] = {
121
+ "configured": {"save_tactics": ["Recipe Swap"]},
122
+ "effective": {"cancellation_reasons": [], "save_tactics": ["Recipe Swap"]},
123
+ "enrolled": False,
124
+ }
125
+ spec = get_tool_catalog()["save_metrics_config_update"]
126
+
127
+ result = await spec.run(client, {"save_tactics": ["Recipe Swap"]})
128
+
129
+ assert result.ok is True
130
+ assert client.requests[-1]["body"] == {"save_tactics": ["Recipe Swap"]}
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_save_metrics_config_update_requires_a_field():
135
+ client = FakeDashboardsClient()
136
+ spec = get_tool_catalog()["save_metrics_config_update"]
137
+
138
+ result = await spec.run(client, {})
139
+
140
+ assert result.ok is False
141
+ assert result.error.code == "input.invalid"
142
+ assert client.requests == []
@@ -76,6 +76,30 @@ async def test_review_status_joins_ids_into_query():
76
76
  assert client.calls[0]["params"] == {"ids": "a,b,c"}
77
77
 
78
78
 
79
+ @pytest.mark.asyncio
80
+ async def test_review_status_enumerates_segment_without_ids():
81
+ client = FakeEvalsClient({"rows": [{"conversation_id": "a"}], "counts": {}})
82
+ result = await _run("review_status", client, segment="inbox", limit=25)
83
+ assert result.ok
84
+ assert client.calls[0]["params"] == {"segment": "inbox", "limit": 25}
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_review_status_ids_win_over_segment():
89
+ client = FakeEvalsClient({"rows": [], "counts": {}})
90
+ await _run("review_status", client, conversation_ids=["a"], segment="inbox")
91
+ assert client.calls[0]["params"] == {"ids": "a"}
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_review_status_requires_ids_or_segment():
96
+ client = FakeEvalsClient()
97
+ result = await _run("review_status", client)
98
+ assert not result.ok
99
+ assert result.error.code == "input.invalid"
100
+ assert client.calls == []
101
+
102
+
79
103
  @pytest.mark.asyncio
80
104
  async def test_review_set_verdict_omits_unset_optionals():
81
105
  client = FakeEvalsClient({"verdict": "fail"})
File without changes
File without changes