applied-cli 0.5.70__tar.gz → 0.5.71__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 (27) hide show
  1. {applied_cli-0.5.70 → applied_cli-0.5.71}/PKG-INFO +1 -1
  2. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/__init__.py +1 -1
  3. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/cli.py +2 -2
  4. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/client.py +212 -20
  5. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/tools.py +21 -13
  6. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/PKG-INFO +1 -1
  7. {applied_cli-0.5.70 → applied_cli-0.5.71}/pyproject.toml +1 -1
  8. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_client.py +68 -40
  9. {applied_cli-0.5.70 → applied_cli-0.5.71}/README.md +0 -0
  10. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/agent_scoped_flows.py +0 -0
  11. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/conversation_lookup.py +0 -0
  12. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/conversations.py +0 -0
  13. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/credentials.py +0 -0
  14. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/flow_helpers.py +0 -0
  15. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli/formatters.py +0 -0
  16. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/SOURCES.txt +0 -0
  17. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/dependency_links.txt +0 -0
  18. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/entry_points.txt +0 -0
  19. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/requires.txt +0 -0
  20. {applied_cli-0.5.70 → applied_cli-0.5.71}/applied_cli.egg-info/top_level.txt +0 -0
  21. {applied_cli-0.5.70 → applied_cli-0.5.71}/setup.cfg +0 -0
  22. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_agent_scoped_flows.py +0 -0
  23. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_audit_tools.py +0 -0
  24. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_benchmark_scenario_tools.py +0 -0
  25. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_cli.py +0 -0
  26. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_conversation_tools.py +0 -0
  27. {applied_cli-0.5.70 → applied_cli-0.5.71}/tests/test_flow_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.70
3
+ Version: 0.5.71
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -4,6 +4,6 @@ from applied_cli import tools
4
4
  from applied_cli.client import AppliedClient
5
5
  from applied_cli.formatters import to_csv, to_json
6
6
 
7
- __version__ = "0.5.70"
7
+ __version__ = "0.5.71"
8
8
 
9
9
  __all__ = ["AppliedClient", "tools", "to_csv", "to_json", "__version__"]
@@ -289,7 +289,7 @@ def analytics(
289
289
  "--group-by",
290
290
  "-g",
291
291
  help=(
292
- "Comma-separated dimensions: label_name, sublabel_name, agent_name, "
292
+ "Comma-separated dimensions: topic, intent, label_name, sublabel_name, "
293
293
  "resolution, sentiment, type, user_id, label_id, sublabel_id, agent_id"
294
294
  ),
295
295
  ),
@@ -297,7 +297,7 @@ def analytics(
297
297
  "count",
298
298
  "--metrics",
299
299
  "-m",
300
- help="Comma-separated metrics: count, avg_score, avg_ttr, resolution_rate",
300
+ help="Comma-separated metrics: count",
301
301
  ),
302
302
  filter: list[str] | None = typer.Option( # noqa: B008
303
303
  None,
@@ -192,6 +192,23 @@ _ANALYTICS_DIMENSION_SQL = {
192
192
  "type": "type",
193
193
  "user_id": "user_id",
194
194
  }
195
+ _ANALYTICS_AUTOMATION_DIMENSIONS = {
196
+ "intent": "sublabel_name",
197
+ "label_id": "label_id",
198
+ "label_name": "label_name",
199
+ "sublabel_id": "sublabel_id",
200
+ "sublabel_name": "sublabel_name",
201
+ "topic": "label_name",
202
+ }
203
+ _ANALYTICS_DASHBOARD_DIMENSIONS = {
204
+ "agent_id": "agent_id",
205
+ "label_id": "label_id",
206
+ "resolution": "resolution",
207
+ "sentiment": "sentiment",
208
+ "sublabel_id": "sublabel_id",
209
+ "type": "type",
210
+ "user_id": "user_id",
211
+ }
195
212
  _ANALYTICS_FILTER_SQL = {
196
213
  **_ANALYTICS_DIMENSION_SQL,
197
214
  "score": "score",
@@ -297,6 +314,52 @@ def _build_legacy_analytics_sql(
297
314
  return "\n".join(sql_parts)
298
315
 
299
316
 
317
+ def _parse_legacy_analytics_filters(filters: list[str] | None) -> dict[str, Any]:
318
+ parsed: dict[str, Any] = {}
319
+ for raw_filter in filters or []:
320
+ if "=" not in raw_filter:
321
+ raise ValueError(f"Invalid analytics filter: {raw_filter}")
322
+ field, raw_value = raw_filter.split("=", 1)
323
+ field = field.strip()
324
+ if field not in _ANALYTICS_FILTER_SQL:
325
+ raise ValueError(f"Unsupported analytics filter field: {field}")
326
+ parsed[field] = _decode_filter_value(raw_value)
327
+ return parsed
328
+
329
+
330
+ def _validate_analytics_count_metrics(metrics: list[str] | None) -> list[str]:
331
+ metric_names = metrics or ["count"]
332
+ unsupported = [metric for metric in metric_names if metric != "count"]
333
+ if unsupported:
334
+ raise ValueError(
335
+ "The current analytics endpoint only supports the count metric for "
336
+ "applied analytics. Use applied analytics-report for richer metrics."
337
+ )
338
+ return metric_names
339
+
340
+
341
+ def _decode_dashboard_group(group: Any, expected_count: int) -> list[Any]:
342
+ if expected_count == 1:
343
+ return [group]
344
+ if isinstance(group, str):
345
+ try:
346
+ decoded = json.loads(group)
347
+ except json.JSONDecodeError:
348
+ decoded = group
349
+ if isinstance(decoded, list):
350
+ return decoded
351
+ if isinstance(group, list):
352
+ return group
353
+ return [group]
354
+
355
+
356
+ def _sort_analytics_rows(rows: list[dict[str, Any]], group_by: list[str]) -> None:
357
+ def sort_key(row: dict[str, Any]) -> tuple[str, ...]:
358
+ return tuple("" if row.get(dimension) is None else str(row.get(dimension)) for dimension in group_by)
359
+
360
+ rows.sort(key=sort_key)
361
+
362
+
300
363
  class AppliedClient:
301
364
  """Async client for Applied Labs API."""
302
365
 
@@ -612,20 +675,154 @@ class AppliedClient:
612
675
  end: str | None = None,
613
676
  limit: int | None = None,
614
677
  ) -> dict:
615
- """Run a GROUP BY query against conversation_latest_state."""
616
- sql = _build_legacy_analytics_sql(
617
- group_by=group_by,
618
- metrics=metrics,
619
- filters=filters,
620
- start=start,
621
- end=end,
622
- limit=limit,
623
- )
624
- data = await self.analytics_sql(sql=sql, max_rows=limit)
678
+ """Run a grouped conversation count query through the analytics API."""
679
+ if not group_by:
680
+ raise ValueError("group_by is required")
681
+
682
+ metric_names = _validate_analytics_count_metrics(metrics)
683
+ parsed_filters = _parse_legacy_analytics_filters(filters)
684
+
685
+ if all(dimension in _ANALYTICS_AUTOMATION_DIMENSIONS for dimension in group_by):
686
+ data = await self._taxonomy_analytics_query(
687
+ group_by=group_by,
688
+ filters=parsed_filters,
689
+ start=start,
690
+ end=end,
691
+ limit=limit,
692
+ )
693
+ else:
694
+ data = await self._dashboard_analytics_query(
695
+ group_by=group_by,
696
+ filters=parsed_filters,
697
+ start=start,
698
+ end=end,
699
+ limit=limit,
700
+ )
701
+
625
702
  data["dimensions"] = group_by
626
- data["metrics"] = metrics or ["count"]
703
+ data["metrics"] = metric_names
627
704
  return data
628
705
 
706
+ async def _taxonomy_analytics_query(
707
+ self,
708
+ *,
709
+ group_by: list[str],
710
+ filters: dict[str, Any],
711
+ start: str | None,
712
+ end: str | None,
713
+ limit: int | None,
714
+ ) -> dict:
715
+ payload: dict[str, Any] = {
716
+ "view": "automation_aggregates",
717
+ "model": "conversation",
718
+ **filters,
719
+ }
720
+ if start:
721
+ payload["start_date"] = start
722
+ if end:
723
+ payload["end_date"] = end
724
+
725
+ data = await self.analytics_report(payload)
726
+ source_key = (
727
+ "by_intent"
728
+ if any(
729
+ dimension in {"intent", "sublabel_id", "sublabel_name"}
730
+ for dimension in group_by
731
+ )
732
+ else "by_topic"
733
+ )
734
+
735
+ grouped: dict[tuple[Any, ...], int] = {}
736
+ for row in data.get(source_key, []):
737
+ key = tuple(
738
+ row.get(_ANALYTICS_AUTOMATION_DIMENSIONS[dimension])
739
+ for dimension in group_by
740
+ )
741
+ grouped[key] = grouped.get(key, 0) + int(row.get("total") or 0)
742
+
743
+ rows = [
744
+ {
745
+ **dict(zip(group_by, key, strict=False)),
746
+ "count": count,
747
+ }
748
+ for key, count in grouped.items()
749
+ ]
750
+ _sort_analytics_rows(rows, group_by)
751
+
752
+ total_rows = len(rows)
753
+ if limit is not None:
754
+ rows = rows[:limit]
755
+
756
+ return {
757
+ "columns": [*group_by, "count"],
758
+ "rows": rows,
759
+ "row_count": len(rows),
760
+ "truncated": limit is not None and total_rows > len(rows),
761
+ }
762
+
763
+ async def _dashboard_analytics_query(
764
+ self,
765
+ *,
766
+ group_by: list[str],
767
+ filters: dict[str, Any],
768
+ start: str | None,
769
+ end: str | None,
770
+ limit: int | None,
771
+ ) -> dict:
772
+ backend_group_by: list[str] = []
773
+ unsupported: list[str] = []
774
+ for dimension in group_by:
775
+ backend_dimension = _ANALYTICS_DASHBOARD_DIMENSIONS.get(dimension)
776
+ if backend_dimension is None:
777
+ unsupported.append(dimension)
778
+ else:
779
+ backend_group_by.append(backend_dimension)
780
+
781
+ if unsupported:
782
+ raise ValueError(
783
+ "Unsupported analytics dimension for the current endpoint: "
784
+ + ", ".join(unsupported)
785
+ )
786
+
787
+ payload: dict[str, Any] = {
788
+ "view": "dashboard_rate_metrics",
789
+ "model": "conversation",
790
+ "group_by": ",".join(backend_group_by),
791
+ "custom_rate_metrics_requests": [
792
+ {"rate": "count", "numerator": "total"},
793
+ ],
794
+ }
795
+ if start:
796
+ payload["start_date"] = start
797
+ if end:
798
+ payload["end_date"] = end
799
+ if filters:
800
+ payload["conversation_filters"] = filters
801
+
802
+ data = await self.analytics_report(payload)
803
+ rows: list[dict[str, Any]] = []
804
+ for item in data.get("count", []):
805
+ group_values = _decode_dashboard_group(item.get("group"), len(group_by))
806
+ group_values = [*group_values, *([None] * len(group_by))][: len(group_by)]
807
+ rows.append(
808
+ {
809
+ **dict(zip(group_by, group_values, strict=False)),
810
+ "count": item.get("numerator", item.get("rate", 0)),
811
+ }
812
+ )
813
+ _sort_analytics_rows(rows, group_by)
814
+
815
+ total_rows = len(rows)
816
+ if limit is not None:
817
+ rows = rows[:limit]
818
+
819
+ return {
820
+ "columns": [*group_by, "count"],
821
+ "rows": rows,
822
+ "row_count": len(rows),
823
+ "truncated": limit is not None and total_rows > len(rows),
824
+ }
825
+
629
826
  async def analytics_sql(
630
827
  self,
631
828
  *,
@@ -633,15 +830,10 @@ class AppliedClient:
633
830
  parameters: dict[str, Any] | None = None,
634
831
  max_rows: int | None = None,
635
832
  ) -> dict:
636
- body: dict[str, Any] = {
637
- "mode": "sql",
638
- "sql": sql,
639
- }
640
- if parameters:
641
- body["parameters"] = parameters
642
- if max_rows is not None:
643
- body["max_rows"] = max_rows
644
- return await self._request("POST", "/v2/agents/analytics/query/", body=body)
833
+ raise ValueError(
834
+ "Raw analytics SQL is no longer available through the public API. "
835
+ "Use applied analytics or applied analytics-report, which call /v2/analytics/."
836
+ )
645
837
 
646
838
  async def analytics_metrics(
647
839
  self,
@@ -660,21 +660,24 @@ async def analytics_query(
660
660
  limit: int | None = None,
661
661
  output_format: str = "csv",
662
662
  ) -> str:
663
- """Run a server-side ClickHouse GROUP BY query on conversation data.
663
+ """Run a server-side GROUP BY count query on conversation data.
664
664
 
665
- Dimensions: label_id, label_name, sublabel_id, sublabel_name,
666
- agent_id, agent_name, resolution, sentiment, score, type, user_id
667
- Metrics: count, avg_score, avg_ttr, resolution_rate
665
+ Dimensions: topic, intent, label_id, label_name, sublabel_id, sublabel_name,
666
+ agent_id, resolution, sentiment, type, user_id
667
+ Metrics: count
668
668
  Filters: agent_id, label_id, sublabel_id, type, resolution, sentiment, score, user_id
669
669
  """
670
- data = await client.analytics_query(
671
- group_by=group_by,
672
- metrics=metrics,
673
- filters=filters,
674
- start=start,
675
- end=end,
676
- limit=limit,
677
- )
670
+ try:
671
+ data = await client.analytics_query(
672
+ group_by=group_by,
673
+ metrics=metrics,
674
+ filters=filters,
675
+ start=start,
676
+ end=end,
677
+ limit=limit,
678
+ )
679
+ except ValueError as exc:
680
+ return _format_argument_error(str(exc))
678
681
  rows = data.get("rows", [])
679
682
  if not rows:
680
683
  return "No data found for the given query."
@@ -695,7 +698,12 @@ async def analytics_sql(
695
698
  output_format: str = "csv",
696
699
  ) -> str:
697
700
  """Run a raw scoped analytics SQL query."""
698
- data = await client.analytics_sql(sql=sql, parameters=parameters, max_rows=limit)
701
+ try:
702
+ data = await client.analytics_sql(
703
+ sql=sql, parameters=parameters, max_rows=limit
704
+ )
705
+ except ValueError as exc:
706
+ return _format_argument_error(str(exc))
699
707
  rows = data.get("rows", [])
700
708
  if not rows:
701
709
  return "No data found for the given query."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.70
3
+ Version: 0.5.71
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.5.70"
3
+ version = "0.5.71"
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"
@@ -170,52 +170,42 @@ async def test_analytics_report_posts_payload(monkeypatch):
170
170
 
171
171
 
172
172
  @pytest.mark.asyncio
173
- async def test_analytics_sql_posts_payload(monkeypatch):
173
+ async def test_analytics_sql_fails_fast_without_public_endpoint(monkeypatch):
174
174
  client = AppliedClient(token="test-token")
175
- seen = {}
176
175
 
177
176
  async def fake_request(method, path, params=None, body=None, shop_id=None):
178
- seen["method"] = method
179
- seen["path"] = path
180
- seen["body"] = body
181
- return {"rows": []}
177
+ raise AssertionError("analytics_sql should not call removed endpoints")
182
178
 
183
179
  monkeypatch.setattr(client, "_request", fake_request)
184
180
 
185
- result = await client.analytics_sql(
186
- sql="SELECT count() AS total FROM conversation_latest_state",
187
- parameters={"resolution": "escalated"},
188
- max_rows=25,
189
- )
190
-
191
- assert result == {"rows": []}
192
- assert seen == {
193
- "method": "POST",
194
- "path": "/v2/agents/analytics/query/",
195
- "body": {
196
- "mode": "sql",
197
- "sql": "SELECT count() AS total FROM conversation_latest_state",
198
- "parameters": {"resolution": "escalated"},
199
- "max_rows": 25,
200
- },
201
- }
181
+ with pytest.raises(ValueError, match="/v2/analytics/"):
182
+ await client.analytics_sql(
183
+ sql="SELECT count() AS total FROM conversation_latest_state",
184
+ parameters={"resolution": "escalated"},
185
+ max_rows=25,
186
+ )
202
187
 
203
188
 
204
189
  @pytest.mark.asyncio
205
- async def test_analytics_query_builds_legacy_sql(monkeypatch):
190
+ async def test_analytics_query_uses_dashboard_rate_metrics(monkeypatch):
206
191
  client = AppliedClient(token="test-token")
207
192
  seen = {}
208
193
 
209
- async def fake_analytics_sql(*, sql, parameters=None, max_rows=None):
210
- seen["sql"] = sql
211
- seen["parameters"] = parameters
212
- seen["max_rows"] = max_rows
194
+ async def fake_analytics_report(payload):
195
+ seen["payload"] = payload
213
196
  return {
214
- "columns": ["resolution", "count"],
215
- "rows": [{"resolution": "escalated", "count": 4}],
197
+ "count": [
198
+ {
199
+ "period": None,
200
+ "group": "escalated",
201
+ "denominator": 0,
202
+ "numerator": 4,
203
+ "rate": 4,
204
+ }
205
+ ]
216
206
  }
217
207
 
218
- monkeypatch.setattr(client, "analytics_sql", fake_analytics_sql)
208
+ monkeypatch.setattr(client, "analytics_report", fake_analytics_report)
219
209
 
220
210
  result = await client.analytics_query(
221
211
  group_by=["resolution"],
@@ -229,15 +219,53 @@ async def test_analytics_query_builds_legacy_sql(monkeypatch):
229
219
  assert result["rows"] == [{"resolution": "escalated", "count": 4}]
230
220
  assert result["dimensions"] == ["resolution"]
231
221
  assert result["metrics"] == ["count"]
232
- assert "SELECT resolution AS resolution, count() AS count" in seen["sql"]
233
- assert "FROM conversation_latest_state" in seen["sql"]
234
- assert "agent_id = 'agent-1'" in seen["sql"]
235
- assert "score = 2" in seen["sql"]
236
- assert "created_at >= '2026-03-01'" in seen["sql"]
237
- assert "created_at <= '2026-03-31'" in seen["sql"]
238
- assert "LIMIT 100" in seen["sql"]
239
- assert seen["parameters"] is None
240
- assert seen["max_rows"] == 100
222
+ assert seen["payload"] == {
223
+ "view": "dashboard_rate_metrics",
224
+ "model": "conversation",
225
+ "group_by": "resolution",
226
+ "custom_rate_metrics_requests": [{"rate": "count", "numerator": "total"}],
227
+ "start_date": "2026-03-01",
228
+ "end_date": "2026-03-31",
229
+ "conversation_filters": {"agent_id": "agent-1", "score": 2},
230
+ }
231
+
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_analytics_query_uses_automation_aggregates_for_intent_names(monkeypatch):
235
+ client = AppliedClient(token="test-token")
236
+ seen = {}
237
+
238
+ async def fake_analytics_report(payload):
239
+ seen["payload"] = payload
240
+ return {
241
+ "by_intent": [
242
+ {"sublabel_name": "Returns", "total": 2},
243
+ {"sublabel_name": "Returns", "total": 3},
244
+ {"sublabel_name": "Shipping", "total": 1},
245
+ ]
246
+ }
247
+
248
+ monkeypatch.setattr(client, "analytics_report", fake_analytics_report)
249
+
250
+ result = await client.analytics_query(
251
+ group_by=["sublabel_name"],
252
+ metrics=["count"],
253
+ start="2026-04-27",
254
+ end="2026-05-01",
255
+ limit=10,
256
+ )
257
+
258
+ assert result["columns"] == ["sublabel_name", "count"]
259
+ assert result["rows"] == [
260
+ {"sublabel_name": "Returns", "count": 5},
261
+ {"sublabel_name": "Shipping", "count": 1},
262
+ ]
263
+ assert seen["payload"] == {
264
+ "view": "automation_aggregates",
265
+ "model": "conversation",
266
+ "start_date": "2026-04-27",
267
+ "end_date": "2026-05-01",
268
+ }
241
269
 
242
270
 
243
271
  @pytest.mark.asyncio
File without changes
File without changes