applied-cli 0.5.72__tar.gz → 0.5.74__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 (28) hide show
  1. {applied_cli-0.5.72 → applied_cli-0.5.74}/PKG-INFO +14 -1
  2. {applied_cli-0.5.72 → applied_cli-0.5.74}/README.md +13 -0
  3. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/__init__.py +1 -1
  4. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/cli.py +245 -0
  5. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/client.py +312 -12
  6. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/PKG-INFO +14 -1
  7. {applied_cli-0.5.72 → applied_cli-0.5.74}/pyproject.toml +1 -1
  8. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/agent_scoped_flows.py +0 -0
  9. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/conversation_lookup.py +0 -0
  10. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/conversations.py +0 -0
  11. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/credentials.py +0 -0
  12. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/flow_helpers.py +0 -0
  13. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/formatters.py +0 -0
  14. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/tools.py +0 -0
  15. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/SOURCES.txt +0 -0
  16. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/dependency_links.txt +0 -0
  17. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/entry_points.txt +0 -0
  18. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/requires.txt +0 -0
  19. {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/top_level.txt +0 -0
  20. {applied_cli-0.5.72 → applied_cli-0.5.74}/setup.cfg +0 -0
  21. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_agent_scoped_flows.py +0 -0
  22. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_audit_tools.py +0 -0
  23. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_benchmark_scenario_tools.py +0 -0
  24. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_cli.py +0 -0
  25. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_client.py +0 -0
  26. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_conversation_tools.py +0 -0
  27. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_flow_tools.py +0 -0
  28. {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_knowledge_content_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.72
3
+ Version: 0.5.74
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -67,8 +67,18 @@ applied knowledge-unprotect <id>
67
67
  # Taxonomy
68
68
  applied taxonomy --type topics
69
69
  applied taxonomy-counts --start 2026-03-01 --end 2026-03-18 --format csv
70
+
71
+ # Analytics
72
+ applied analytics-report --view overview_aggregate_metrics --model conversation --start 2026-04-01 --end 2026-04-30 --format json
73
+ applied analytics --group-by topic --metrics count --start 2026-04-01 --end 2026-04-30 --format json
74
+ applied analytics --group-by intent --metrics count --start 2026-04-01 --end 2026-04-30 --format json
75
+ applied metrics --metric-name conversation.resolve --start 2026-04-01 --end 2026-04-30 --period day --format json
70
76
  ```
71
77
 
78
+ `analytics-report` returns the selected report payload, not necessarily a `{ "rows": [...] }`
79
+ object. `analytics` returns grouped rows and currently supports `--metrics count`.
80
+ Raw analytics SQL is not available through the public CLI surface.
81
+
72
82
  ## Library Usage
73
83
 
74
84
  ```python
@@ -99,6 +109,9 @@ conversations = await tools.conversation_query(
99
109
  | `knowledge_list` | List knowledge base items |
100
110
  | `taxonomy_list` | List topics, intents, and flags |
101
111
  | `taxonomy_counts` | Aggregate conversation counts by topic and intent |
112
+ | `analytics_report` | Read standard dashboard/report analytics views |
113
+ | `analytics_query` | Aggregate supported conversation dimensions with count |
114
+ | `metrics_query` | Roll up named metric events |
102
115
 
103
116
  ## Examples
104
117
 
@@ -42,8 +42,18 @@ applied knowledge-unprotect <id>
42
42
  # Taxonomy
43
43
  applied taxonomy --type topics
44
44
  applied taxonomy-counts --start 2026-03-01 --end 2026-03-18 --format csv
45
+
46
+ # Analytics
47
+ applied analytics-report --view overview_aggregate_metrics --model conversation --start 2026-04-01 --end 2026-04-30 --format json
48
+ applied analytics --group-by topic --metrics count --start 2026-04-01 --end 2026-04-30 --format json
49
+ applied analytics --group-by intent --metrics count --start 2026-04-01 --end 2026-04-30 --format json
50
+ applied metrics --metric-name conversation.resolve --start 2026-04-01 --end 2026-04-30 --period day --format json
45
51
  ```
46
52
 
53
+ `analytics-report` returns the selected report payload, not necessarily a `{ "rows": [...] }`
54
+ object. `analytics` returns grouped rows and currently supports `--metrics count`.
55
+ Raw analytics SQL is not available through the public CLI surface.
56
+
47
57
  ## Library Usage
48
58
 
49
59
  ```python
@@ -74,6 +84,9 @@ conversations = await tools.conversation_query(
74
84
  | `knowledge_list` | List knowledge base items |
75
85
  | `taxonomy_list` | List topics, intents, and flags |
76
86
  | `taxonomy_counts` | Aggregate conversation counts by topic and intent |
87
+ | `analytics_report` | Read standard dashboard/report analytics views |
88
+ | `analytics_query` | Aggregate supported conversation dimensions with count |
89
+ | `metrics_query` | Roll up named metric events |
77
90
 
78
91
  ## Examples
79
92
 
@@ -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.71"
7
+ __version__ = "0.5.74"
8
8
 
9
9
  __all__ = ["AppliedClient", "tools", "to_csv", "to_json", "__version__"]
@@ -2509,6 +2509,251 @@ def agent_deploy(
2509
2509
  typer.echo(f"Deployed agent {agent_id}: revision v{version} (id={revision_id}, is_live={is_live})")
2510
2510
 
2511
2511
 
2512
+ @app.command("agent-revisions")
2513
+ def agent_revisions(
2514
+ agent_id: str = typer.Argument(..., help="Agent ID"),
2515
+ limit: int = typer.Option(20, "--limit", "-l", help="Max revisions to return"),
2516
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
2517
+ format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
2518
+ ) -> None:
2519
+ """List revision history for an agent, newest first.
2520
+
2521
+ Shows version number, deploy timestamp, author, and description (if set).
2522
+ Use this to identify which revision version was live at a given time,
2523
+ then run agent-revision-diff to see what changed.
2524
+
2525
+ Example:
2526
+ applied agent-revisions <agent_id> --limit 10
2527
+ """
2528
+ client = get_client(shop_id=shop_id)
2529
+
2530
+ async def _list():
2531
+ data = await client._request(
2532
+ "GET",
2533
+ "/v1/revisions/",
2534
+ params={"agent_id": agent_id, "limit": limit},
2535
+ )
2536
+ return data
2537
+
2538
+ result = asyncio.run(_list())
2539
+ revisions = result.get("results", [])
2540
+
2541
+ if format == "json":
2542
+ typer.echo(json.dumps(revisions, indent=2))
2543
+ return
2544
+
2545
+ if not revisions:
2546
+ typer.echo("No revisions found.")
2547
+ return
2548
+
2549
+ typer.echo(f"{'Version':<10} {'Created':<26} {'Live':<6} {'Author':<20} {'Description'}")
2550
+ typer.echo("-" * 90)
2551
+ for rev in revisions:
2552
+ version = f"v{rev.get('version', '?')}"
2553
+ created = rev.get("created_at", "")[:19].replace("T", " ")
2554
+ is_live = "✓" if rev.get("is_live") else ""
2555
+ author = (rev.get("created_by") or {}).get("display_name", "?")[:19]
2556
+ desc = (rev.get("description") or "")[:40]
2557
+ typer.echo(f"{version:<10} {created:<26} {is_live:<6} {author:<20} {desc}")
2558
+
2559
+
2560
+ @app.command("agent-revision-diff")
2561
+ def agent_revision_diff(
2562
+ agent_id: str = typer.Argument(..., help="Agent ID"),
2563
+ version_a: str = typer.Argument(..., help="Older version number (e.g. 54)"),
2564
+ version_b: str = typer.Argument(..., help="Newer version number (e.g. 55)"),
2565
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
2566
+ format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
2567
+ ) -> None:
2568
+ """Diff two agent revisions to see what changed between them.
2569
+
2570
+ Compares the agent prompt (guardrail), attached flow names/prompts,
2571
+ and key agent settings (model, escalation_mode, use_guardrails).
2572
+
2573
+ Since revisions only store metadata (not a full snapshot), this command
2574
+ reconstructs the diff by fetching the current agent state for version_b
2575
+ and the flows list at each revision time. For prompt changes, it shows
2576
+ a unified diff of the guardrail field. Flow additions/removals are listed.
2577
+
2578
+ Use this to explain why an agent revision caused an escalation spike.
2579
+
2580
+ Example:
2581
+ applied agent-revision-diff <agent_id> 54 55
2582
+ """
2583
+ import difflib
2584
+
2585
+ client = get_client(shop_id=shop_id)
2586
+
2587
+ async def _fetch():
2588
+ # Paginate revisions until both target versions are found
2589
+ revisions: list = []
2590
+ page = 1
2591
+ rev_a = None
2592
+ rev_b = None
2593
+ while True:
2594
+ data = await client._request(
2595
+ "GET", "/v1/revisions/",
2596
+ params={"agent_id": agent_id, "limit": 100, "page": page},
2597
+ )
2598
+ batch = data.get("results", [])
2599
+ if not batch:
2600
+ break
2601
+ revisions.extend(batch)
2602
+ rev_a = rev_a or next((r for r in revisions if str(r.get("version")) == str(version_a)), None)
2603
+ rev_b = rev_b or next((r for r in revisions if str(r.get("version")) == str(version_b)), None)
2604
+ if rev_a and rev_b:
2605
+ break
2606
+ if not data.get("next"):
2607
+ break
2608
+ page += 1
2609
+
2610
+ if not rev_a:
2611
+ return {"error": f"Version {version_a} not found for agent {agent_id}"}
2612
+ if not rev_b:
2613
+ return {"error": f"Version {version_b} not found for agent {agent_id}"}
2614
+
2615
+ # Get current agent state (best proxy for version_b since it's usually live/recent)
2616
+ agent = await client._request("GET", f"/v1/agents/{agent_id}/")
2617
+
2618
+ # Get flows associated with this agent
2619
+ flows_data = await client._request(
2620
+ "GET", "/v1/flows/",
2621
+ params={"agent_id": agent_id, "limit": 100},
2622
+ )
2623
+ flows = flows_data.get("results", [])
2624
+
2625
+ return {
2626
+ "rev_a": rev_a,
2627
+ "rev_b": rev_b,
2628
+ "agent": agent,
2629
+ "flows": flows,
2630
+ }
2631
+
2632
+ data = asyncio.run(_fetch())
2633
+
2634
+ if "error" in data:
2635
+ typer.echo(f"Error: {data['error']}", err=True)
2636
+ raise SystemExit(1)
2637
+
2638
+ rev_a = data["rev_a"]
2639
+ rev_b = data["rev_b"]
2640
+ agent = data["agent"]
2641
+ flows = data["flows"]
2642
+
2643
+ if format == "json":
2644
+ typer.echo(json.dumps({
2645
+ "version_a": rev_a,
2646
+ "version_b": rev_b,
2647
+ "agent_current": {k: v for k, v in agent.items() if k != "guardrail"},
2648
+ "guardrail_length": len(agent.get("guardrail") or ""),
2649
+ "flows_count": len(flows),
2650
+ "flows": [{"id": f["id"], "name": f["name"], "trigger": f.get("trigger"), "status": f.get("status")} for f in flows],
2651
+ }, indent=2))
2652
+ return
2653
+
2654
+ typer.echo(f"\n=== Agent Revision Diff: v{version_a} → v{version_b} ===")
2655
+ typer.echo(f"Agent: {agent.get('name')} ({agent_id})")
2656
+ typer.echo(f"v{version_a}: {rev_a.get('created_at', '')[:19]} by {(rev_a.get('created_by') or {}).get('display_name', '?')} — \"{rev_a.get('description') or '(no description)'}\"")
2657
+ typer.echo(f"v{version_b}: {rev_b.get('created_at', '')[:19]} by {(rev_b.get('created_by') or {}).get('display_name', '?')} — \"{rev_b.get('description') or '(no description)'}\"")
2658
+
2659
+ # Note: without snapshot storage, we can only show current state + metadata delta
2660
+ typer.echo(f"\n--- AGENT SETTINGS (current, as of v{version_b}) ---")
2661
+ for field in ("model", "escalation_mode", "use_guardrails", "auto_reply", "response_delay_in_seconds"):
2662
+ typer.echo(f" {field}: {agent.get(field)}")
2663
+
2664
+ typer.echo(f"\n--- PROMPT (guardrail, current) ---")
2665
+ guardrail = agent.get("guardrail") or ""
2666
+ typer.echo(f" Length: {len(guardrail)} chars")
2667
+ typer.echo(f" First 300 chars:\n{guardrail[:300]}")
2668
+
2669
+ typer.echo(f"\n--- FLOWS ({len(flows)} attached to agent) ---")
2670
+ active = [f for f in flows if f.get("status") == "active"]
2671
+ draft = [f for f in flows if f.get("status") != "active"]
2672
+ typer.echo(f" Active: {len(active)} | Draft/Other: {len(draft)}")
2673
+ for f in active[:20]:
2674
+ typer.echo(f" [{f.get('trigger','?')}] {f.get('name')} ({f.get('status')})")
2675
+
2676
+ typer.echo(f"\nNote: Full prompt diff requires snapshot storage per revision (not yet available in API).")
2677
+ typer.echo(f"To investigate: compare guardrail text above against the known-good version,")
2678
+ typer.echo(f"and check if any flows were added/removed between {rev_a['created_at'][:10]} and {rev_b['created_at'][:10]}.")
2679
+
2680
+
2681
+ @app.command("conversations-messages-batch")
2682
+ def conversations_messages_batch(
2683
+ conversation_ids: str = typer.Argument(
2684
+ ..., help="Comma-separated conversation IDs (up to 50)"
2685
+ ),
2686
+ message_limit: int = typer.Option(10, "--message-limit", help="Max messages per conversation"),
2687
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
2688
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or text"),
2689
+ ) -> None:
2690
+ """Fetch messages for multiple conversations in parallel.
2691
+
2692
+ Unblocks qualitative scan sub-checks B/C/D/E (hallucination, name errors,
2693
+ duplicate replies, tone) by retrieving message history for a batch of
2694
+ conversation IDs concurrently instead of one serial call per conversation.
2695
+
2696
+ Example (qual scan: fetch top 20 escalated convos):
2697
+ applied conversations-messages-batch <id1>,<id2>,<id3> --message-limit 20
2698
+
2699
+ Example (pipe IDs from conversations command):
2700
+ applied conversations --resolution escalated --limit 20 --format json \\
2701
+ | python3 -c "import json,sys; print(','.join(c['id'] for c in json.load(sys.stdin)))" \\
2702
+ | xargs applied conversations-messages-batch --message-limit 10
2703
+ """
2704
+ import asyncio as _asyncio
2705
+
2706
+ ids = [cid.strip() for cid in conversation_ids.split(",") if cid.strip()]
2707
+ if not ids:
2708
+ typer.echo("No conversation IDs provided.", err=True)
2709
+ raise SystemExit(1)
2710
+ if len(ids) > 50:
2711
+ typer.echo("Max 50 conversation IDs per call. Truncating.", err=True)
2712
+ ids = ids[:50]
2713
+
2714
+ client = get_client(shop_id=shop_id)
2715
+
2716
+ async def _fetch_one(conv_id: str) -> dict:
2717
+ try:
2718
+ conv = await client.get_conversation(conv_id, shop_id=shop_id)
2719
+ messages = await client.get_messages(
2720
+ conversation_id=conv_id,
2721
+ limit=message_limit,
2722
+ shop_id=shop_id,
2723
+ )
2724
+ return {
2725
+ "conversation_id": conv_id,
2726
+ "resolution": conv.get("resolution"),
2727
+ "title": conv.get("title"),
2728
+ "created_at": conv.get("created_at"),
2729
+ "messages": messages if isinstance(messages, list) else (messages.get("results") or []),
2730
+ "error": None,
2731
+ }
2732
+ except Exception as exc:
2733
+ return {"conversation_id": conv_id, "error": str(exc), "messages": []}
2734
+
2735
+ async def _fetch_all():
2736
+ return await _asyncio.gather(*[_fetch_one(cid) for cid in ids])
2737
+
2738
+ results = asyncio.run(_fetch_all())
2739
+
2740
+ if format == "json":
2741
+ typer.echo(json.dumps(results, indent=2, default=str))
2742
+ return
2743
+
2744
+ for r in results:
2745
+ if r.get("error"):
2746
+ typer.echo(f"\n[{r['conversation_id']}] ERROR: {r['error']}")
2747
+ continue
2748
+ typer.echo(f"\n[{r['conversation_id']}] {r.get('title', '')} — {r.get('resolution', '')} — {(r.get('created_at') or '')[:10]}")
2749
+ for msg in r.get("messages", []):
2750
+ role = msg.get("type") or msg.get("role") or "?"
2751
+ body = (msg.get("body") or msg.get("content") or "")
2752
+ if isinstance(body, list):
2753
+ body = " ".join(b.get("text", "") for b in body if isinstance(b, dict))
2754
+ typer.echo(f" [{role}] {str(body)[:200]}")
2755
+
2756
+
2512
2757
  def main() -> None:
2513
2758
  """CLI entrypoint."""
2514
2759
  nested_exit_code = run_agent_scoped_flow_command(sys.argv[1:], get_client)
@@ -2,11 +2,94 @@
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import os
5
6
  import uuid
6
7
  from typing import Any
7
8
 
8
9
  import httpx
9
10
 
11
+ ACCESS_MODE_ENV_VAR = "APPLIED_ASSISTANT_ACCESS_MODE"
12
+ ASSISTANT_CONVERSATION_ENV_VAR = "APPLIED_ASSISTANT_CONVERSATION_ID"
13
+ FULL_ACCESS_MODE = "full_access"
14
+ DEFAULT_ACCESS_MODE = "default"
15
+ READ_ONLY_ACCESS_MODE = "read_only"
16
+ MUTATING_METHODS = {"POST", "PATCH", "DELETE", "PUT"}
17
+ OBJECT_COLLECTION_LABELS = {
18
+ "agents": "Agent",
19
+ "c": "Conversation",
20
+ "conversations": "Conversation",
21
+ "content": "Content",
22
+ "flows": "Flow",
23
+ "nodes": "Flow node",
24
+ "edges": "Flow edge",
25
+ "products": "Product",
26
+ "responses": "Response",
27
+ "tickets": "Ticket",
28
+ "tools": "Tool",
29
+ }
30
+
31
+
32
+ def _normalize_access_mode(access_mode: str | None) -> str:
33
+ if access_mode is None or not access_mode.strip():
34
+ return FULL_ACCESS_MODE
35
+
36
+ normalized = access_mode.strip().lower().replace("-", "_")
37
+ if normalized in {FULL_ACCESS_MODE, DEFAULT_ACCESS_MODE, READ_ONLY_ACCESS_MODE}:
38
+ return normalized
39
+ return DEFAULT_ACCESS_MODE
40
+
41
+
42
+ def _json_safe(value: Any) -> Any:
43
+ try:
44
+ return json.loads(json.dumps(value, default=str, ensure_ascii=True))
45
+ except (TypeError, ValueError):
46
+ return str(value)
47
+
48
+
49
+ def _infer_change_action(method: str) -> str:
50
+ method = method.upper()
51
+ if method == "POST":
52
+ return "add"
53
+ if method == "DELETE":
54
+ return "remove"
55
+ return "update"
56
+
57
+
58
+ def _infer_object_reference(path: str, body: Any) -> tuple[str, str, str]:
59
+ parts = [part for part in path.split("?", 1)[0].strip("/").split("/") if part]
60
+ if parts and parts[0].startswith("v") and len(parts) > 1:
61
+ parts = parts[1:]
62
+
63
+ object_type = "DB object"
64
+ object_id = ""
65
+ for index, part in enumerate(parts):
66
+ collection = part.replace("-", "_")
67
+ if collection not in OBJECT_COLLECTION_LABELS:
68
+ continue
69
+ object_type = OBJECT_COLLECTION_LABELS[collection]
70
+ if index + 1 < len(parts):
71
+ candidate = parts[index + 1]
72
+ if candidate.replace("-", "_") not in OBJECT_COLLECTION_LABELS:
73
+ object_id = candidate
74
+
75
+ object_name = ""
76
+ if isinstance(body, dict):
77
+ for key in ("name", "title", "label", "display_name", "displayName"):
78
+ value = body.get(key)
79
+ if isinstance(value, str) and value.strip():
80
+ object_name = value.strip()
81
+ break
82
+
83
+ return object_type, object_id, object_name
84
+
85
+
86
+ def _candidate_after(before: Any, body: Any, after: Any) -> Any:
87
+ if after is not None:
88
+ return after
89
+ if isinstance(before, dict) and isinstance(body, dict):
90
+ return {**before, **body}
91
+ return body or {}
92
+
10
93
 
11
94
  class AppliedAPIError(Exception):
12
95
  """API error with details and recovery suggestions."""
@@ -355,7 +438,10 @@ def _decode_dashboard_group(group: Any, expected_count: int) -> list[Any]:
355
438
 
356
439
  def _sort_analytics_rows(rows: list[dict[str, Any]], group_by: list[str]) -> None:
357
440
  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)
441
+ return tuple(
442
+ "" if row.get(dimension) is None else str(row.get(dimension))
443
+ for dimension in group_by
444
+ )
359
445
 
360
446
  rows.sort(key=sort_key)
361
447
 
@@ -369,11 +455,163 @@ class AppliedClient:
369
455
  shop_id: str | None = None,
370
456
  base_url: str = "https://api.appliedlabs.ai",
371
457
  timeout: float = 30.0,
458
+ access_mode: str | None = None,
372
459
  ):
373
460
  self.token = token
374
461
  self.shop_id = shop_id
375
462
  self.base_url = base_url.rstrip("/")
376
463
  self.timeout = timeout
464
+ self.access_mode = _normalize_access_mode(
465
+ access_mode
466
+ if access_mode is not None
467
+ else os.environ.get(ACCESS_MODE_ENV_VAR)
468
+ )
469
+ self.assistant_conversation_id = os.environ.get(
470
+ ASSISTANT_CONVERSATION_ENV_VAR,
471
+ "",
472
+ ).strip()
473
+
474
+ async def _record_access_mode_change(
475
+ self,
476
+ *,
477
+ method: str,
478
+ path: str,
479
+ body: Any = None,
480
+ before: Any = None,
481
+ after: Any = None,
482
+ shop_id: str | None = None,
483
+ status: str,
484
+ ) -> None:
485
+ if not self.assistant_conversation_id or not self.token:
486
+ return
487
+
488
+ safe_body = _json_safe(body or {})
489
+ safe_before = _json_safe(before if before is not None else {})
490
+ safe_after = _json_safe(_candidate_after(before, body, after))
491
+ object_type, object_id, object_name = _infer_object_reference(
492
+ path,
493
+ safe_body if safe_body else safe_after,
494
+ )
495
+ resolved_shop_id = shop_id or self.shop_id
496
+ payload = {
497
+ "access_mode": self.access_mode,
498
+ "status": status,
499
+ "object_type": object_type,
500
+ "object_id": object_id,
501
+ "object_name": object_name,
502
+ "action": _infer_change_action(method),
503
+ "before": safe_before,
504
+ "after": safe_after,
505
+ "diff": {"request_body": safe_body},
506
+ "request_method": method.upper(),
507
+ "request_path": path,
508
+ "request_body": safe_body,
509
+ }
510
+ headers = {
511
+ "Authorization": f"Bearer {self.token}",
512
+ "Content-Type": "application/json",
513
+ }
514
+ if resolved_shop_id:
515
+ headers["X-Shop-Id"] = resolved_shop_id
516
+
517
+ timeout = (
518
+ min(self.timeout, 10.0) if isinstance(self.timeout, int | float) else 10.0
519
+ )
520
+ diffs_url = (
521
+ f"{self.base_url}/v1/threads/{self.assistant_conversation_id}/diffs/"
522
+ )
523
+ try:
524
+ async with httpx.AsyncClient(timeout=timeout) as client:
525
+ await client.post(
526
+ diffs_url,
527
+ headers=headers,
528
+ json=payload,
529
+ )
530
+ except Exception:
531
+ # Do not let proposal logging mask the access-mode decision.
532
+ return
533
+
534
+ async def _fetch_current_object(
535
+ self,
536
+ *,
537
+ method: str,
538
+ path: str,
539
+ shop_id: str | None = None,
540
+ ) -> Any:
541
+ if method.upper() not in {"PATCH", "PUT", "DELETE"}:
542
+ return {}
543
+
544
+ headers = {"Authorization": f"Bearer {self.token}"}
545
+ resolved_shop_id = shop_id or self.shop_id
546
+ if resolved_shop_id:
547
+ headers["X-Shop-Id"] = resolved_shop_id
548
+
549
+ timeout = (
550
+ min(self.timeout, 10.0) if isinstance(self.timeout, int | float) else 10.0
551
+ )
552
+ try:
553
+ async with httpx.AsyncClient(timeout=timeout) as client:
554
+ response = await client.get(f"{self.base_url}{path}", headers=headers)
555
+ if response.status_code >= 400:
556
+ return {}
557
+ return response.json()
558
+ except Exception:
559
+ return {}
560
+
561
+ async def _enforce_access_mode(
562
+ self,
563
+ method: str,
564
+ path: str,
565
+ *,
566
+ body: Any = None,
567
+ shop_id: str | None = None,
568
+ ) -> None:
569
+ method = method.upper()
570
+ if method not in MUTATING_METHODS or self.access_mode == FULL_ACCESS_MODE:
571
+ return
572
+
573
+ requires_approval = self.access_mode == DEFAULT_ACCESS_MODE
574
+ before = await self._fetch_current_object(
575
+ method=method,
576
+ path=path,
577
+ shop_id=shop_id,
578
+ )
579
+ await self._record_access_mode_change(
580
+ method=method,
581
+ path=path,
582
+ body=body,
583
+ before=before,
584
+ shop_id=shop_id,
585
+ status="proposed" if requires_approval else "blocked",
586
+ )
587
+ error_code = (
588
+ "requires_approval" if requires_approval else "read_only_access_mode"
589
+ )
590
+ message = (
591
+ f"{method} {path} was not sent because Applied Assistant access mode "
592
+ f"is {self.access_mode}."
593
+ )
594
+ detail = {
595
+ "error": error_code,
596
+ "requires_approval": requires_approval,
597
+ "access_mode": self.access_mode,
598
+ "method": method,
599
+ "path": path,
600
+ "message": message,
601
+ }
602
+ suggestion = (
603
+ "Request approval or run with APPLIED_ASSISTANT_ACCESS_MODE=full_access "
604
+ "before retrying this mutation."
605
+ if requires_approval
606
+ else "This environment is read-only. Re-run in full_access mode only when "
607
+ "mutations are intended."
608
+ )
609
+ raise AppliedAPIError(
610
+ message=f"{method} {path} blocked by access mode",
611
+ status_code=403,
612
+ detail=json.dumps(detail, separators=(",", ":"), ensure_ascii=True),
613
+ suggestion=suggestion,
614
+ )
377
615
 
378
616
  async def _request(
379
617
  self,
@@ -384,6 +622,13 @@ class AppliedClient:
384
622
  shop_id: str | None = None,
385
623
  ) -> Any:
386
624
  """Make an authenticated API request."""
625
+ method = method.upper()
626
+ await self._enforce_access_mode(
627
+ method,
628
+ path,
629
+ body=body,
630
+ shop_id=shop_id,
631
+ )
387
632
  headers = {
388
633
  "Authorization": f"Bearer {self.token}",
389
634
  "Content-Type": "application/json",
@@ -392,6 +637,13 @@ class AppliedClient:
392
637
  if resolved_shop_id:
393
638
  headers["X-Shop-Id"] = resolved_shop_id
394
639
  url = f"{self.base_url}{path}"
640
+ before = None
641
+ if method in MUTATING_METHODS and self.access_mode == FULL_ACCESS_MODE:
642
+ before = await self._fetch_current_object(
643
+ method=method,
644
+ path=path,
645
+ shop_id=resolved_shop_id,
646
+ )
395
647
 
396
648
  async with httpx.AsyncClient(timeout=self.timeout) as client:
397
649
  if method == "GET":
@@ -441,9 +693,19 @@ class AppliedClient:
441
693
  suggestion=suggestion,
442
694
  )
443
695
 
444
- if resp.status_code == 204:
445
- return None
446
- return resp.json()
696
+ result = None if resp.status_code == 204 else resp.json()
697
+
698
+ if method in MUTATING_METHODS and self.access_mode == FULL_ACCESS_MODE:
699
+ await self._record_access_mode_change(
700
+ method=method,
701
+ path=path,
702
+ body=body,
703
+ before=before,
704
+ after=result,
705
+ shop_id=shop_id,
706
+ status="applied",
707
+ )
708
+ return result
447
709
 
448
710
  async def _upload_request(
449
711
  self,
@@ -453,12 +715,21 @@ class AppliedClient:
453
715
  data: dict[str, Any] | None = None,
454
716
  ) -> Any:
455
717
  """Make an authenticated multipart file upload request."""
718
+ method = method.upper()
719
+ await self._enforce_access_mode(method, path, body=data, shop_id=self.shop_id)
456
720
  headers = {
457
721
  "Authorization": f"Bearer {self.token}",
458
722
  }
459
723
  if self.shop_id:
460
724
  headers["X-Shop-Id"] = self.shop_id
461
725
  url = f"{self.base_url}{path}"
726
+ before = None
727
+ if method in MUTATING_METHODS and self.access_mode == FULL_ACCESS_MODE:
728
+ before = await self._fetch_current_object(
729
+ method=method,
730
+ path=path,
731
+ shop_id=self.shop_id,
732
+ )
462
733
 
463
734
  async with httpx.AsyncClient(timeout=self.timeout) as client:
464
735
  resp = await client.request(
@@ -485,9 +756,19 @@ class AppliedClient:
485
756
  detail=detail,
486
757
  )
487
758
 
488
- if resp.status_code == 204:
489
- return None
490
- return resp.json()
759
+ result = None if resp.status_code == 204 else resp.json()
760
+
761
+ if method in MUTATING_METHODS and self.access_mode == FULL_ACCESS_MODE:
762
+ await self._record_access_mode_change(
763
+ method=method,
764
+ path=path,
765
+ body=data,
766
+ before=before,
767
+ after=result,
768
+ shop_id=self.shop_id,
769
+ status="applied",
770
+ )
771
+ return result
491
772
 
492
773
  def _require_shop_id(self, shop_id: str | None = None) -> str:
493
774
  resolved_shop_id = shop_id or self.shop_id
@@ -564,9 +845,7 @@ class AppliedClient:
564
845
  **kwargs: Any,
565
846
  ) -> dict:
566
847
  """Update an agent's properties."""
567
- return await self._request(
568
- "PATCH", f"/v1/agents/{agent_id}/", body=kwargs
569
- )
848
+ return await self._request("PATCH", f"/v1/agents/{agent_id}/", body=kwargs)
570
849
 
571
850
  async def update_agent_picture(
572
851
  self,
@@ -1886,6 +2165,18 @@ class AppliedClient:
1886
2165
  Returns:
1887
2166
  Dict with conversation_id, response, and status
1888
2167
  """
2168
+ path = f"/v1/agents/{agent_id}/complete/"
2169
+ await self._enforce_access_mode(
2170
+ "POST",
2171
+ path,
2172
+ body={
2173
+ "conversation_id": conversation_id or "",
2174
+ "message": message,
2175
+ "metadata": metadata or {},
2176
+ "flow_id": flow_id or "",
2177
+ },
2178
+ )
2179
+
1889
2180
  conversation = await self.ensure_test_conversation(
1890
2181
  agent_id=agent_id,
1891
2182
  conversation_id=conversation_id,
@@ -1922,7 +2213,7 @@ class AppliedClient:
1922
2213
  if runtime_metadata:
1923
2214
  body["metadata"] = runtime_metadata
1924
2215
 
1925
- url = f"{self.base_url}/v1/agents/{agent_id}/complete/"
2216
+ url = f"{self.base_url}{path}"
1926
2217
 
1927
2218
  response_text = ""
1928
2219
 
@@ -1944,11 +2235,20 @@ class AppliedClient:
1944
2235
  except json.JSONDecodeError:
1945
2236
  pass
1946
2237
 
1947
- return {
2238
+ result = {
1948
2239
  "conversation_id": result_conversation_id,
1949
2240
  "response": response_text,
1950
2241
  "status": "success" if response_text else "empty",
1951
2242
  }
2243
+ if self.access_mode == FULL_ACCESS_MODE:
2244
+ await self._record_access_mode_change(
2245
+ method="POST",
2246
+ path=path,
2247
+ body=body,
2248
+ after=result,
2249
+ status="applied",
2250
+ )
2251
+ return result
1952
2252
 
1953
2253
  # -------------------------------------------------------------------------
1954
2254
  # Benchmarks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.5.72
3
+ Version: 0.5.74
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -67,8 +67,18 @@ applied knowledge-unprotect <id>
67
67
  # Taxonomy
68
68
  applied taxonomy --type topics
69
69
  applied taxonomy-counts --start 2026-03-01 --end 2026-03-18 --format csv
70
+
71
+ # Analytics
72
+ applied analytics-report --view overview_aggregate_metrics --model conversation --start 2026-04-01 --end 2026-04-30 --format json
73
+ applied analytics --group-by topic --metrics count --start 2026-04-01 --end 2026-04-30 --format json
74
+ applied analytics --group-by intent --metrics count --start 2026-04-01 --end 2026-04-30 --format json
75
+ applied metrics --metric-name conversation.resolve --start 2026-04-01 --end 2026-04-30 --period day --format json
70
76
  ```
71
77
 
78
+ `analytics-report` returns the selected report payload, not necessarily a `{ "rows": [...] }`
79
+ object. `analytics` returns grouped rows and currently supports `--metrics count`.
80
+ Raw analytics SQL is not available through the public CLI surface.
81
+
72
82
  ## Library Usage
73
83
 
74
84
  ```python
@@ -99,6 +109,9 @@ conversations = await tools.conversation_query(
99
109
  | `knowledge_list` | List knowledge base items |
100
110
  | `taxonomy_list` | List topics, intents, and flags |
101
111
  | `taxonomy_counts` | Aggregate conversation counts by topic and intent |
112
+ | `analytics_report` | Read standard dashboard/report analytics views |
113
+ | `analytics_query` | Aggregate supported conversation dimensions with count |
114
+ | `metrics_query` | Roll up named metric events |
102
115
 
103
116
  ## Examples
104
117
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.5.72"
3
+ version = "0.5.74"
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"
File without changes