applied-cli 0.6.0__tar.gz → 0.6.1__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 (65) hide show
  1. {applied_cli-0.6.0 → applied_cli-0.6.1}/PKG-INFO +1 -1
  2. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/cli.py +101 -0
  3. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/client.py +54 -0
  4. applied_cli-0.6.1/applied_cli/recovery.py +400 -0
  5. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/tools.py +197 -0
  6. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/domains.py +1 -0
  7. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/scenarios.py +64 -0
  8. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/PKG-INFO +1 -1
  9. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/SOURCES.txt +3 -0
  10. {applied_cli-0.6.0 → applied_cli-0.6.1}/pyproject.toml +1 -1
  11. applied_cli-0.6.1/tests/test_benchmark_clone.py +242 -0
  12. applied_cli-0.6.1/tests/test_recovery.py +351 -0
  13. {applied_cli-0.6.0 → applied_cli-0.6.1}/README.md +0 -0
  14. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/__init__.py +0 -0
  15. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/agent_scoped_flows.py +0 -0
  16. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/auth.py +0 -0
  17. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/conversation_lookup.py +0 -0
  18. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/conversations.py +0 -0
  19. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/credentials.py +0 -0
  20. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/flow_helpers.py +0 -0
  21. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/formatters.py +0 -0
  22. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/mcp.py +0 -0
  23. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/toolkit.py +0 -0
  24. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/__init__.py +0 -0
  25. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/agents.py +0 -0
  26. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/articles.py +0 -0
  27. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/catalog.py +0 -0
  28. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/connectors.py +0 -0
  29. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/content.py +0 -0
  30. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/conversations.py +0 -0
  31. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/flows.py +0 -0
  32. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/knowledge.py +0 -0
  33. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/manifest.py +0 -0
  34. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/products.py +0 -0
  35. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/taxonomy.py +0 -0
  36. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli/v2/tickets.py +0 -0
  37. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/dependency_links.txt +0 -0
  38. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/entry_points.txt +0 -0
  39. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/requires.txt +0 -0
  40. {applied_cli-0.6.0 → applied_cli-0.6.1}/applied_cli.egg-info/top_level.txt +0 -0
  41. {applied_cli-0.6.0 → applied_cli-0.6.1}/setup.cfg +0 -0
  42. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_agent_scoped_flows.py +0 -0
  43. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_audit_tools.py +0 -0
  44. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_auth_context.py +0 -0
  45. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_benchmark_scenario_tools.py +0 -0
  46. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_cli.py +0 -0
  47. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_cli_v2.py +0 -0
  48. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_client.py +0 -0
  49. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_client_v2.py +0 -0
  50. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_conversation_tools.py +0 -0
  51. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_flow_tools.py +0 -0
  52. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_knowledge_content_tools.py +0 -0
  53. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_toolkit_contract.py +0 -0
  54. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_agents.py +0 -0
  55. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_articles.py +0 -0
  56. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_catalog_and_mcp.py +0 -0
  57. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_connectors.py +0 -0
  58. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_content.py +0 -0
  59. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_conversations.py +0 -0
  60. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_flows.py +0 -0
  61. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_knowledge.py +0 -0
  62. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_products.py +0 -0
  63. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_scenarios.py +0 -0
  64. {applied_cli-0.6.0 → applied_cli-0.6.1}/tests/test_v2_taxonomy.py +0 -0
  65. {applied_cli-0.6.0 → applied_cli-0.6.1}/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.0
3
+ Version: 0.6.1
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -42,6 +42,11 @@ app.add_typer(v2_app, name="v2")
42
42
 
43
43
  DEFAULT_BASE_URL = "https://api.appliedlabs.ai"
44
44
  DEFAULT_CLIENT_URL = "https://appliedlabs.ai"
45
+ DEFAULT_RECOVERY_DIR_OPTION = typer.Option(
46
+ "/tmp/applied-labs-recovery-jun4-1915",
47
+ "--recovery-dir",
48
+ help="Directory containing exports/ and deleted_only/ recovery files",
49
+ )
45
50
  AGENT_DEPLOY_AGENT_IDS_ARGUMENT = typer.Argument(
46
51
  ...,
47
52
  help="One or more Agent IDs to deploy",
@@ -1592,6 +1597,49 @@ def benchmark_create(
1592
1597
  typer.echo(result)
1593
1598
 
1594
1599
 
1600
+ @app.command("benchmark-clone")
1601
+ def benchmark_clone(
1602
+ source_benchmark_id: str = typer.Argument(
1603
+ ..., help="Benchmark to copy scenarios from"
1604
+ ),
1605
+ dest_benchmark_id: str = typer.Option(
1606
+ None, "--dest-benchmark-id", help="Existing destination benchmark UUID"
1607
+ ),
1608
+ dest_benchmark_name: str = typer.Option(
1609
+ None,
1610
+ "--dest-benchmark-name",
1611
+ help="Destination benchmark name (found or created under --target-agent-id)",
1612
+ ),
1613
+ target_agent_id: str = typer.Option(
1614
+ None,
1615
+ "--target-agent-id",
1616
+ help="Agent for the destination benchmark when created by name "
1617
+ "(e.g. port an email benchmark onto the chat agent)",
1618
+ ),
1619
+ apply: bool = typer.Option(
1620
+ False, "--apply", help="Write changes (default is a dry-run plan)"
1621
+ ),
1622
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
1623
+ format: str = typer.Option(
1624
+ "text", "--format", "-f", help="Output format: text or json"
1625
+ ),
1626
+ ) -> None:
1627
+ """Copy all scenarios from one benchmark into another (e.g. email -> chat)."""
1628
+ client = get_client(shop_id=shop_id)
1629
+ result = asyncio.run(
1630
+ tools.benchmark_clone(
1631
+ client,
1632
+ source_benchmark_id=source_benchmark_id,
1633
+ dest_benchmark_id=dest_benchmark_id,
1634
+ dest_benchmark_name=dest_benchmark_name,
1635
+ target_agent_id=target_agent_id,
1636
+ dry_run=not apply,
1637
+ output_format=format,
1638
+ )
1639
+ )
1640
+ typer.echo(result)
1641
+
1642
+
1595
1643
  @app.command()
1596
1644
  def scenarios(
1597
1645
  benchmark_id: str = typer.Option(
@@ -1772,6 +1820,59 @@ def scenario_run_update_cmd(
1772
1820
  typer.echo(result)
1773
1821
 
1774
1822
 
1823
+ @app.command("scenario-recover-catalog")
1824
+ def scenario_recover_catalog(
1825
+ recovery_dir: Path = DEFAULT_RECOVERY_DIR_OPTION,
1826
+ apply: bool = typer.Option(
1827
+ False,
1828
+ "--apply",
1829
+ help="Create missing benchmarks/scenarios and restore rated runs",
1830
+ ),
1831
+ restore_ratings: bool = typer.Option(
1832
+ True,
1833
+ "--restore-ratings/--no-restore-ratings",
1834
+ help="Restore rated scenario runs linked to recovered scenarios",
1835
+ ),
1836
+ max_rated_runs: int | None = typer.Option(
1837
+ None,
1838
+ "--max-rated-runs",
1839
+ help="Cap rated run restore count for staged recovery",
1840
+ ),
1841
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
1842
+ format: str = typer.Option(
1843
+ "json", "--format", "-f", help="Output format: json or text"
1844
+ ),
1845
+ ) -> None:
1846
+ """Recover deleted benchmark/scenario catalog rows from local PITR exports."""
1847
+ from applied_cli.recovery import recover_scenario_catalog
1848
+
1849
+ client = get_client(shop_id=shop_id)
1850
+ result = asyncio.run(
1851
+ recover_scenario_catalog(
1852
+ client,
1853
+ recovery_dir=recovery_dir,
1854
+ dry_run=not apply,
1855
+ restore_ratings=restore_ratings,
1856
+ max_rated_runs=max_rated_runs,
1857
+ )
1858
+ )
1859
+ if format == "text":
1860
+ typer.echo(
1861
+ "\n".join(
1862
+ [
1863
+ f"dry_run: {result['dry_run']}",
1864
+ f"shop_id: {result['shop_id']}",
1865
+ f"restore_point_utc: {result['restore_point_utc']}",
1866
+ f"benchmarks: {result['benchmarks']}",
1867
+ f"scenarios: {result['scenarios']}",
1868
+ f"scenario_runs: {result['scenario_runs']}",
1869
+ ]
1870
+ )
1871
+ )
1872
+ return
1873
+ typer.echo(json.dumps(result, indent=2, default=str))
1874
+
1875
+
1775
1876
  @app.command("scenario-bulk-run")
1776
1877
  def scenario_bulk_run(
1777
1878
  scenario_ids: str = typer.Option(
@@ -2426,6 +2426,40 @@ class AppliedClient:
2426
2426
  break
2427
2427
  return all_results
2428
2428
 
2429
+ async def list_recovery_scenarios(
2430
+ self,
2431
+ *,
2432
+ agent_id: str,
2433
+ names: list[str],
2434
+ limit: int = 50,
2435
+ ) -> list[dict]:
2436
+ """List scenarios for recovery by exact name and agent."""
2437
+ by_id: dict[str, dict] = {}
2438
+ for name in sorted({item for item in names if item}):
2439
+ params: dict[str, Any] = {
2440
+ "agent": agent_id,
2441
+ "limit": limit,
2442
+ "name": name,
2443
+ "summary": "true",
2444
+ }
2445
+ page = 1
2446
+ while True:
2447
+ params["page"] = page
2448
+ data = await self._request(
2449
+ "GET",
2450
+ "/v1/conversation-scenarios/",
2451
+ params=params,
2452
+ )
2453
+ for scenario in self._normalize_response(data):
2454
+ scenario_id = scenario.get("id")
2455
+ if scenario_id:
2456
+ by_id[str(scenario_id)] = scenario
2457
+ if isinstance(data, dict) and data.get("next"):
2458
+ page += 1
2459
+ else:
2460
+ break
2461
+ return list(by_id.values())
2462
+
2429
2463
  async def get_scenario(self, scenario_id: str) -> dict:
2430
2464
  """Get a single scenario by ID."""
2431
2465
  return await self._request("GET", f"/v1/conversation-scenarios/{scenario_id}/")
@@ -2469,6 +2503,22 @@ class AppliedClient:
2469
2503
  "PATCH", f"/v1/conversation-scenarios/{scenario_id}/", body=kwargs
2470
2504
  )
2471
2505
 
2506
+ async def create_recovery_scenario(self, body: dict[str, Any]) -> dict:
2507
+ """Create a scenario with raw recovery fields."""
2508
+ return await self._request("POST", "/v1/conversation-scenarios/", body=body)
2509
+
2510
+ async def update_recovery_scenario(
2511
+ self,
2512
+ scenario_id: str,
2513
+ body: dict[str, Any],
2514
+ ) -> dict:
2515
+ """Patch scenario benchmark links with raw recovery fields."""
2516
+ return await self._request(
2517
+ "PATCH",
2518
+ f"/v1/conversation-scenarios/{scenario_id}/",
2519
+ body=body,
2520
+ )
2521
+
2472
2522
  # -------------------------------------------------------------------------
2473
2523
  # Scenario Runs
2474
2524
  # -------------------------------------------------------------------------
@@ -2506,6 +2556,10 @@ class AppliedClient:
2506
2556
  """Update a scenario run's properties (ratings, feedback, etc.)."""
2507
2557
  return await self._request("PATCH", f"/v1/scenario-runs/{run_id}/", body=kwargs)
2508
2558
 
2559
+ async def create_recovery_scenario_run(self, body: dict[str, Any]) -> dict:
2560
+ """Create a scenario run linked to an existing output conversation."""
2561
+ return await self._request("POST", "/v1/scenario-runs/", body=body)
2562
+
2509
2563
  async def delete_scenario(self, scenario_id: str) -> None:
2510
2564
  """Delete a scenario."""
2511
2565
  await self._request("DELETE", f"/v1/conversation-scenarios/{scenario_id}/")
@@ -0,0 +1,400 @@
1
+ """Helpers for recovering scenario catalog rows from local PITR exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class RecoveryDataset:
14
+ shop_id: str
15
+ restore_point_utc: str | None
16
+ benchmarks: list[dict[str, Any]]
17
+ scenarios: list[dict[str, Any]]
18
+ scenario_benchmark_links: list[dict[str, Any]]
19
+ scenario_runs: list[dict[str, Any]]
20
+
21
+ def links_by_scenario(self) -> dict[str, list[str]]:
22
+ links: dict[str, list[str]] = defaultdict(list)
23
+ for link in self.scenario_benchmark_links:
24
+ scenario_id = link.get("scenario_id")
25
+ benchmark_id = link.get("benchmark_id")
26
+ if scenario_id and benchmark_id:
27
+ links[str(scenario_id)].append(str(benchmark_id))
28
+ return links
29
+
30
+ def benchmark_ids_for_scenario(self, scenario: dict[str, Any]) -> list[str]:
31
+ scenario_id = str(scenario.get("id") or "")
32
+ ids: list[str] = []
33
+ primary = scenario.get("benchmark_id")
34
+ if primary:
35
+ ids.append(str(primary))
36
+ for benchmark_id in self.links_by_scenario().get(scenario_id, []):
37
+ if benchmark_id not in ids:
38
+ ids.append(benchmark_id)
39
+ return ids
40
+
41
+ def rated_runs(self) -> list[dict[str, Any]]:
42
+ return [run for run in self.scenario_runs if _is_rated_run(run)]
43
+
44
+ def rated_runs_by_scenario(self) -> dict[str, list[dict[str, Any]]]:
45
+ runs: dict[str, list[dict[str, Any]]] = defaultdict(list)
46
+ for run in self.rated_runs():
47
+ scenario_id = run.get("scenario_id")
48
+ if scenario_id:
49
+ runs[str(scenario_id)].append(run)
50
+ return runs
51
+
52
+
53
+ def _read_json(path: Path) -> dict[str, Any]:
54
+ return json.loads(path.read_text(encoding="utf-8"))
55
+
56
+
57
+ def _read_ndjson(path: Path) -> list[dict[str, Any]]:
58
+ rows: list[dict[str, Any]] = []
59
+ with path.open(encoding="utf-8") as handle:
60
+ for line in handle:
61
+ stripped = line.strip()
62
+ if stripped:
63
+ rows.append(json.loads(stripped))
64
+ return rows
65
+
66
+
67
+ def load_recovery_dataset(recovery_dir: str | Path) -> RecoveryDataset:
68
+ root = Path(recovery_dir)
69
+ exports = root / "exports"
70
+ deleted_only = root / "deleted_only"
71
+ export_manifest = _read_json(exports / "manifest.json")
72
+ deleted_manifest = _read_json(deleted_only / "manifest.json")
73
+
74
+ return RecoveryDataset(
75
+ shop_id=str(deleted_manifest.get("shop_id") or export_manifest.get("shop_id")),
76
+ restore_point_utc=deleted_manifest.get("restore_point_utc")
77
+ or export_manifest.get("restore_point_utc"),
78
+ benchmarks=_read_ndjson(exports / "core_conversationbenchmark.ndjson"),
79
+ scenarios=_read_ndjson(deleted_only / "core_conversationscenario.ndjson"),
80
+ scenario_benchmark_links=_read_ndjson(
81
+ deleted_only / "core_conversationscenariobenchmark.ndjson"
82
+ ),
83
+ scenario_runs=_read_ndjson(deleted_only / "core_scenariorun.ndjson"),
84
+ )
85
+
86
+
87
+ def _agent_id(row: dict[str, Any]) -> str | None:
88
+ if row.get("agent_id"):
89
+ return str(row["agent_id"])
90
+ agent = row.get("agent")
91
+ if isinstance(agent, dict) and agent.get("id"):
92
+ return str(agent["id"])
93
+ return None
94
+
95
+
96
+ def _input_conversation_id(row: dict[str, Any]) -> str | None:
97
+ if row.get("input_conversation_id"):
98
+ return str(row["input_conversation_id"])
99
+ input_conversation = row.get("input_conversation")
100
+ if isinstance(input_conversation, dict) and input_conversation.get("id"):
101
+ return str(input_conversation["id"])
102
+ return None
103
+
104
+
105
+ def _current_benchmark_ids(row: dict[str, Any]) -> list[str]:
106
+ ids: list[str] = []
107
+ primary = row.get("benchmark")
108
+ if isinstance(primary, dict) and primary.get("id"):
109
+ ids.append(str(primary["id"]))
110
+ for benchmark in row.get("benchmarks") or []:
111
+ if isinstance(benchmark, dict) and benchmark.get("id"):
112
+ benchmark_id = str(benchmark["id"])
113
+ if benchmark_id not in ids:
114
+ ids.append(benchmark_id)
115
+ return ids
116
+
117
+
118
+ def _is_rated_run(run: dict[str, Any]) -> bool:
119
+ return bool(
120
+ run.get("pass_status") is not None
121
+ or run.get("csat_score") is not None
122
+ or run.get("reference_score") is not None
123
+ or (run.get("reference_notes") or "")
124
+ or (run.get("feedback") or "")
125
+ or run.get("is_escalated") is True
126
+ )
127
+
128
+
129
+ def _scenario_run_body(run: dict[str, Any], scenario_id: str) -> dict[str, Any]:
130
+ body: dict[str, Any] = {"scenario": scenario_id}
131
+ if run.get("output_conversation_id"):
132
+ body["output_conversation_id"] = str(run["output_conversation_id"])
133
+ if run.get("pass_status") is not None:
134
+ body["pass_status"] = run.get("pass_status")
135
+ if run.get("csat_score") is not None:
136
+ body["csat_score"] = run.get("csat_score")
137
+ if run.get("reference_score") is not None:
138
+ body["reference_score"] = run.get("reference_score")
139
+ if run.get("reference_notes"):
140
+ body["reference_notes"] = run.get("reference_notes")
141
+ if run.get("feedback") is not None:
142
+ body["feedback"] = run.get("feedback")
143
+ if run.get("is_escalated") is True:
144
+ body["is_escalated"] = True
145
+ return body
146
+
147
+
148
+ def _run_signature(row: dict[str, Any]) -> tuple[Any, ...]:
149
+ output_conversation = row.get("output_conversation")
150
+ output_conversation_id = row.get("output_conversation_id")
151
+ if isinstance(output_conversation, dict) and output_conversation.get("id"):
152
+ output_conversation_id = output_conversation["id"]
153
+ return (
154
+ str(output_conversation_id) if output_conversation_id else None,
155
+ row.get("pass_status"),
156
+ row.get("csat_score"),
157
+ row.get("reference_score"),
158
+ row.get("reference_notes") or "",
159
+ row.get("feedback") or "",
160
+ bool(row.get("is_escalated")),
161
+ )
162
+
163
+
164
+ async def _list_current_benchmarks(
165
+ client: Any, agent_ids: list[str]
166
+ ) -> list[dict[str, Any]]:
167
+ by_id: dict[str, dict[str, Any]] = {}
168
+ for agent_id in agent_ids:
169
+ for benchmark in await client.list_benchmarks(agent_id=agent_id, limit=500):
170
+ benchmark_id = benchmark.get("id")
171
+ if benchmark_id:
172
+ by_id[str(benchmark_id)] = benchmark
173
+ return list(by_id.values())
174
+
175
+
176
+ async def _list_current_scenarios(
177
+ client: Any, scenarios: list[dict[str, Any]]
178
+ ) -> list[dict[str, Any]]:
179
+ by_id: dict[str, dict[str, Any]] = {}
180
+ names_by_agent: dict[str, list[str]] = defaultdict(list)
181
+ for scenario in scenarios:
182
+ agent_id = _agent_id(scenario)
183
+ name = scenario.get("name")
184
+ if agent_id and name:
185
+ names_by_agent[agent_id].append(str(name))
186
+
187
+ for agent_id, names in sorted(names_by_agent.items()):
188
+ if hasattr(client, "list_recovery_scenarios"):
189
+ rows = await client.list_recovery_scenarios(
190
+ agent_id=agent_id,
191
+ names=names,
192
+ limit=50,
193
+ )
194
+ else:
195
+ rows = await client.list_scenarios(
196
+ agent_id=agent_id,
197
+ limit=500,
198
+ fetch_all=True,
199
+ )
200
+ for scenario in rows:
201
+ scenario_id = scenario.get("id")
202
+ if scenario_id:
203
+ by_id[str(scenario_id)] = scenario
204
+ return list(by_id.values())
205
+
206
+
207
+ async def recover_scenario_catalog(
208
+ client: Any,
209
+ *,
210
+ recovery_dir: str | Path,
211
+ dry_run: bool = True,
212
+ restore_ratings: bool = True,
213
+ max_rated_runs: int | None = None,
214
+ ) -> dict[str, Any]:
215
+ dataset = load_recovery_dataset(recovery_dir)
216
+ benchmark_agent_ids = sorted(
217
+ {agent_id for row in dataset.benchmarks if (agent_id := _agent_id(row))}
218
+ )
219
+
220
+ current_benchmarks = await _list_current_benchmarks(client, benchmark_agent_ids)
221
+ current_benchmark_by_key = {
222
+ (_agent_id(row), row.get("name")): row for row in current_benchmarks
223
+ }
224
+ benchmark_id_map: dict[str, str] = {}
225
+ benchmark_summary = {
226
+ "available": len(dataset.benchmarks),
227
+ "existing": 0,
228
+ "to_create": 0,
229
+ "created": 0,
230
+ }
231
+
232
+ for benchmark in dataset.benchmarks:
233
+ old_id = str(benchmark["id"])
234
+ key = (_agent_id(benchmark), benchmark.get("name"))
235
+ current = current_benchmark_by_key.get(key)
236
+ if current and current.get("id"):
237
+ benchmark_summary["existing"] += 1
238
+ benchmark_id_map[old_id] = str(current["id"])
239
+ continue
240
+
241
+ benchmark_summary["to_create"] += 1
242
+ if dry_run:
243
+ continue
244
+
245
+ created = await client.create_benchmark(
246
+ agent_id=str(benchmark["agent_id"]),
247
+ name=benchmark["name"],
248
+ description=benchmark.get("description") or "",
249
+ )
250
+ benchmark_summary["created"] += 1
251
+ benchmark_id_map[old_id] = str(created["id"])
252
+
253
+ current_scenarios = await _list_current_scenarios(client, dataset.scenarios)
254
+ current_scenario_by_exact = {
255
+ (_agent_id(row), row.get("name"), _input_conversation_id(row)): row
256
+ for row in current_scenarios
257
+ }
258
+ current_scenario_by_name = {
259
+ (_agent_id(row), row.get("name")): row for row in current_scenarios
260
+ }
261
+ scenario_id_map: dict[str, str] = {}
262
+ scenario_summary = {
263
+ "available": len(dataset.scenarios),
264
+ "existing": 0,
265
+ "to_create": 0,
266
+ "created": 0,
267
+ "to_update_links": 0,
268
+ "updated_links": 0,
269
+ }
270
+
271
+ for scenario in dataset.scenarios:
272
+ old_id = str(scenario["id"])
273
+ current = current_scenario_by_exact.get(
274
+ (
275
+ _agent_id(scenario),
276
+ scenario.get("name"),
277
+ _input_conversation_id(scenario),
278
+ )
279
+ ) or current_scenario_by_name.get((_agent_id(scenario), scenario.get("name")))
280
+
281
+ old_benchmark_ids = dataset.benchmark_ids_for_scenario(scenario)
282
+ current_benchmark_ids = [
283
+ benchmark_id_map[old_benchmark_id]
284
+ for old_benchmark_id in old_benchmark_ids
285
+ if old_benchmark_id in benchmark_id_map
286
+ ]
287
+ primary_benchmark_id = (
288
+ benchmark_id_map.get(str(scenario.get("benchmark_id")))
289
+ if scenario.get("benchmark_id")
290
+ else (current_benchmark_ids[0] if current_benchmark_ids else None)
291
+ )
292
+
293
+ if current and current.get("id"):
294
+ scenario_summary["existing"] += 1
295
+ current_id = str(current["id"])
296
+ scenario_id_map[old_id] = current_id
297
+ missing_links = [
298
+ benchmark_id
299
+ for benchmark_id in current_benchmark_ids
300
+ if benchmark_id not in _current_benchmark_ids(current)
301
+ ]
302
+ if missing_links:
303
+ scenario_summary["to_update_links"] += 1
304
+ if not dry_run:
305
+ body = {"benchmark_ids": _current_benchmark_ids(current)}
306
+ for benchmark_id in missing_links:
307
+ if benchmark_id not in body["benchmark_ids"]:
308
+ body["benchmark_ids"].append(benchmark_id)
309
+ if primary_benchmark_id:
310
+ body["benchmark_id"] = primary_benchmark_id
311
+ await client.update_recovery_scenario(current_id, body)
312
+ scenario_summary["updated_links"] += 1
313
+ continue
314
+
315
+ scenario_summary["to_create"] += 1
316
+ if dry_run:
317
+ continue
318
+
319
+ body: dict[str, Any] = {
320
+ "agent_id": str(scenario["agent_id"]),
321
+ "allow_remote_ticket_creation": bool(
322
+ scenario.get("allow_remote_ticket_creation")
323
+ ),
324
+ "benchmark_ids": current_benchmark_ids,
325
+ "context": scenario.get("context") or {},
326
+ "input_conversation_id": str(scenario["input_conversation_id"]),
327
+ "name": scenario["name"],
328
+ }
329
+ if primary_benchmark_id:
330
+ body["benchmark_id"] = primary_benchmark_id
331
+
332
+ created = await client.create_recovery_scenario(body)
333
+ scenario_summary["created"] += 1
334
+ scenario_id_map[old_id] = str(created["id"])
335
+
336
+ rated_runs_by_scenario = dataset.rated_runs_by_scenario()
337
+ rated_runs = dataset.rated_runs()
338
+ if max_rated_runs is not None:
339
+ rated_runs = rated_runs[:max_rated_runs]
340
+ allowed_old_run_ids = {str(run["id"]) for run in rated_runs if run.get("id")}
341
+ run_summary = {
342
+ "rated_available": len(dataset.rated_runs()),
343
+ "to_create": len(rated_runs) if restore_ratings else 0,
344
+ "created": 0,
345
+ "skipped_existing": 0,
346
+ "skipped_missing_scenario": 0,
347
+ }
348
+
349
+ if restore_ratings:
350
+ existing_run_signatures: dict[str, set[tuple[Any, ...]]] = {}
351
+ for scenario in dataset.scenarios:
352
+ old_scenario_id = str(scenario["id"])
353
+ current_scenario_id = scenario_id_map.get(old_scenario_id)
354
+ scenario_runs = [
355
+ run
356
+ for run in rated_runs_by_scenario.get(old_scenario_id, [])
357
+ if str(run.get("id")) in allowed_old_run_ids
358
+ ]
359
+ for run in scenario_runs:
360
+ if not current_scenario_id:
361
+ run_summary["skipped_missing_scenario"] += 1
362
+ continue
363
+
364
+ body = _scenario_run_body(run, current_scenario_id)
365
+ if dry_run:
366
+ continue
367
+
368
+ if current_scenario_id not in existing_run_signatures:
369
+ existing_runs = await client.list_scenario_runs(
370
+ scenario_id=current_scenario_id,
371
+ latest=False,
372
+ limit=500,
373
+ )
374
+ existing_run_signatures[current_scenario_id] = {
375
+ _run_signature(existing_run) for existing_run in existing_runs
376
+ }
377
+
378
+ signature = _run_signature(body)
379
+ if signature in existing_run_signatures[current_scenario_id]:
380
+ run_summary["skipped_existing"] += 1
381
+ continue
382
+
383
+ await client.create_recovery_scenario_run(body)
384
+ existing_run_signatures[current_scenario_id].add(signature)
385
+ run_summary["created"] += 1
386
+
387
+ return {
388
+ "dry_run": dry_run,
389
+ "shop_id": dataset.shop_id,
390
+ "restore_point_utc": dataset.restore_point_utc,
391
+ "source": {
392
+ "benchmarks": len(dataset.benchmarks),
393
+ "scenarios": len(dataset.scenarios),
394
+ "scenario_benchmark_links": len(dataset.scenario_benchmark_links),
395
+ "scenario_runs": len(dataset.scenario_runs),
396
+ },
397
+ "benchmarks": benchmark_summary,
398
+ "scenarios": scenario_summary,
399
+ "scenario_runs": run_summary,
400
+ }