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.
- {applied_cli-0.5.72 → applied_cli-0.5.74}/PKG-INFO +14 -1
- {applied_cli-0.5.72 → applied_cli-0.5.74}/README.md +13 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/__init__.py +1 -1
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/cli.py +245 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/client.py +312 -12
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/PKG-INFO +14 -1
- {applied_cli-0.5.72 → applied_cli-0.5.74}/pyproject.toml +1 -1
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/agent_scoped_flows.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/conversation_lookup.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/conversations.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/credentials.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/flow_helpers.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/formatters.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli/tools.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/SOURCES.txt +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/dependency_links.txt +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/entry_points.txt +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/requires.txt +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/applied_cli.egg-info/top_level.txt +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/setup.cfg +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_agent_scoped_flows.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_audit_tools.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_benchmark_scenario_tools.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_cli.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_client.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_conversation_tools.py +0 -0
- {applied_cli-0.5.72 → applied_cli-0.5.74}/tests/test_flow_tools.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|