openadapt-ml 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

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 (112) hide show
  1. openadapt_ml/baselines/__init__.py +121 -0
  2. openadapt_ml/baselines/adapter.py +185 -0
  3. openadapt_ml/baselines/cli.py +314 -0
  4. openadapt_ml/baselines/config.py +448 -0
  5. openadapt_ml/baselines/parser.py +922 -0
  6. openadapt_ml/baselines/prompts.py +787 -0
  7. openadapt_ml/benchmarks/__init__.py +13 -107
  8. openadapt_ml/benchmarks/agent.py +297 -374
  9. openadapt_ml/benchmarks/azure.py +62 -24
  10. openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
  11. openadapt_ml/benchmarks/cli.py +1874 -751
  12. openadapt_ml/benchmarks/trace_export.py +631 -0
  13. openadapt_ml/benchmarks/viewer.py +1236 -0
  14. openadapt_ml/benchmarks/vm_monitor.py +1111 -0
  15. openadapt_ml/benchmarks/waa_deploy/Dockerfile +216 -0
  16. openadapt_ml/benchmarks/waa_deploy/__init__.py +10 -0
  17. openadapt_ml/benchmarks/waa_deploy/api_agent.py +540 -0
  18. openadapt_ml/benchmarks/waa_deploy/start_waa_server.bat +53 -0
  19. openadapt_ml/cloud/azure_inference.py +3 -5
  20. openadapt_ml/cloud/lambda_labs.py +722 -307
  21. openadapt_ml/cloud/local.py +3194 -89
  22. openadapt_ml/cloud/ssh_tunnel.py +595 -0
  23. openadapt_ml/datasets/next_action.py +125 -96
  24. openadapt_ml/evals/grounding.py +32 -9
  25. openadapt_ml/evals/plot_eval_metrics.py +15 -13
  26. openadapt_ml/evals/trajectory_matching.py +120 -57
  27. openadapt_ml/experiments/demo_prompt/__init__.py +19 -0
  28. openadapt_ml/experiments/demo_prompt/format_demo.py +236 -0
  29. openadapt_ml/experiments/demo_prompt/results/experiment_20251231_002125.json +83 -0
  30. openadapt_ml/experiments/demo_prompt/results/experiment_n30_20251231_165958.json +1100 -0
  31. openadapt_ml/experiments/demo_prompt/results/multistep_20251231_025051.json +182 -0
  32. openadapt_ml/experiments/demo_prompt/run_experiment.py +541 -0
  33. openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
  34. openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
  35. openadapt_ml/experiments/representation_shootout/config.py +390 -0
  36. openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
  37. openadapt_ml/experiments/representation_shootout/runner.py +687 -0
  38. openadapt_ml/experiments/waa_demo/__init__.py +10 -0
  39. openadapt_ml/experiments/waa_demo/demos.py +357 -0
  40. openadapt_ml/experiments/waa_demo/runner.py +732 -0
  41. openadapt_ml/experiments/waa_demo/tasks.py +151 -0
  42. openadapt_ml/export/__init__.py +9 -0
  43. openadapt_ml/export/__main__.py +6 -0
  44. openadapt_ml/export/cli.py +89 -0
  45. openadapt_ml/export/parquet.py +277 -0
  46. openadapt_ml/grounding/detector.py +18 -14
  47. openadapt_ml/ingest/__init__.py +11 -10
  48. openadapt_ml/ingest/capture.py +97 -86
  49. openadapt_ml/ingest/loader.py +120 -69
  50. openadapt_ml/ingest/synthetic.py +344 -193
  51. openadapt_ml/models/api_adapter.py +14 -4
  52. openadapt_ml/models/base_adapter.py +10 -2
  53. openadapt_ml/models/providers/__init__.py +288 -0
  54. openadapt_ml/models/providers/anthropic.py +266 -0
  55. openadapt_ml/models/providers/base.py +299 -0
  56. openadapt_ml/models/providers/google.py +376 -0
  57. openadapt_ml/models/providers/openai.py +342 -0
  58. openadapt_ml/models/qwen_vl.py +46 -19
  59. openadapt_ml/perception/__init__.py +35 -0
  60. openadapt_ml/perception/integration.py +399 -0
  61. openadapt_ml/retrieval/README.md +226 -0
  62. openadapt_ml/retrieval/USAGE.md +391 -0
  63. openadapt_ml/retrieval/__init__.py +91 -0
  64. openadapt_ml/retrieval/demo_retriever.py +843 -0
  65. openadapt_ml/retrieval/embeddings.py +630 -0
  66. openadapt_ml/retrieval/index.py +194 -0
  67. openadapt_ml/retrieval/retriever.py +162 -0
  68. openadapt_ml/runtime/__init__.py +50 -0
  69. openadapt_ml/runtime/policy.py +27 -14
  70. openadapt_ml/runtime/safety_gate.py +471 -0
  71. openadapt_ml/schema/__init__.py +113 -0
  72. openadapt_ml/schema/converters.py +588 -0
  73. openadapt_ml/schema/episode.py +470 -0
  74. openadapt_ml/scripts/capture_screenshots.py +530 -0
  75. openadapt_ml/scripts/compare.py +102 -61
  76. openadapt_ml/scripts/demo_policy.py +4 -1
  77. openadapt_ml/scripts/eval_policy.py +19 -14
  78. openadapt_ml/scripts/make_gif.py +1 -1
  79. openadapt_ml/scripts/prepare_synthetic.py +16 -17
  80. openadapt_ml/scripts/train.py +98 -75
  81. openadapt_ml/segmentation/README.md +920 -0
  82. openadapt_ml/segmentation/__init__.py +97 -0
  83. openadapt_ml/segmentation/adapters/__init__.py +5 -0
  84. openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
  85. openadapt_ml/segmentation/annotator.py +610 -0
  86. openadapt_ml/segmentation/cache.py +290 -0
  87. openadapt_ml/segmentation/cli.py +674 -0
  88. openadapt_ml/segmentation/deduplicator.py +656 -0
  89. openadapt_ml/segmentation/frame_describer.py +788 -0
  90. openadapt_ml/segmentation/pipeline.py +340 -0
  91. openadapt_ml/segmentation/schemas.py +622 -0
  92. openadapt_ml/segmentation/segment_extractor.py +634 -0
  93. openadapt_ml/training/azure_ops_viewer.py +1097 -0
  94. openadapt_ml/training/benchmark_viewer.py +3255 -19
  95. openadapt_ml/training/shared_ui.py +7 -7
  96. openadapt_ml/training/stub_provider.py +57 -35
  97. openadapt_ml/training/trainer.py +255 -441
  98. openadapt_ml/training/trl_trainer.py +403 -0
  99. openadapt_ml/training/viewer.py +323 -108
  100. openadapt_ml/training/viewer_components.py +180 -0
  101. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/METADATA +312 -69
  102. openadapt_ml-0.2.1.dist-info/RECORD +116 -0
  103. openadapt_ml/benchmarks/base.py +0 -366
  104. openadapt_ml/benchmarks/data_collection.py +0 -432
  105. openadapt_ml/benchmarks/runner.py +0 -381
  106. openadapt_ml/benchmarks/waa.py +0 -704
  107. openadapt_ml/schemas/__init__.py +0 -53
  108. openadapt_ml/schemas/sessions.py +0 -122
  109. openadapt_ml/schemas/validation.py +0 -252
  110. openadapt_ml-0.1.0.dist-info/RECORD +0 -55
  111. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/WHEEL +0 -0
  112. {openadapt_ml-0.1.0.dist-info → openadapt_ml-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,151 @@
1
+ """Task definitions for WAA demo experiment.
2
+
3
+ 10 carefully selected tasks across 4 enterprise-relevant domains.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Optional
11
+
12
+
13
+ class Difficulty(Enum):
14
+ EASY = "easy"
15
+ MEDIUM = "medium"
16
+ HARD = "hard"
17
+
18
+
19
+ class Domain(Enum):
20
+ BROWSER = "msedge"
21
+ OFFICE_CALC = "libreoffice_calc"
22
+ OFFICE_WRITER = "libreoffice_writer"
23
+ SETTINGS = "settings"
24
+ FILE_EXPLORER = "file_explorer"
25
+
26
+
27
+ @dataclass
28
+ class WATask:
29
+ """A Windows Agent Arena task definition."""
30
+
31
+ task_id: str
32
+ instruction: str
33
+ domain: Domain
34
+ difficulty: Difficulty
35
+ first_action_hint: str
36
+ demo_method: str # "manual" or "recorded"
37
+ json_path: str # Path in WAA repo
38
+
39
+
40
+ TASKS: dict[str, WATask] = {
41
+ "1": WATask(
42
+ task_id="004587f8-6028-4656-94c1-681481abbc9c-wos",
43
+ instruction="Enable the 'Do Not Track' feature in Edge",
44
+ domain=Domain.BROWSER,
45
+ difficulty=Difficulty.MEDIUM,
46
+ first_action_hint="Click three-dot menu in Edge",
47
+ demo_method="manual",
48
+ json_path="examples/msedge/004587f8-6028-4656-94c1-681481abbc9c-wos.json",
49
+ ),
50
+ "2": WATask(
51
+ task_id="049d3788-c979-4ea6-934d-3a35c4630faf-WOS",
52
+ instruction="Save this webpage to bookmarks bar",
53
+ domain=Domain.BROWSER,
54
+ difficulty=Difficulty.EASY,
55
+ first_action_hint="Click star/bookmark icon or Ctrl+D",
56
+ demo_method="manual",
57
+ json_path="examples/msedge/049d3788-c979-4ea6-934d-3a35c4630faf-WOS.json",
58
+ ),
59
+ "3": WATask(
60
+ task_id="2acd62b4-a2ab-44a7-a7e3-f5227bbd8324-wos",
61
+ instruction="Set default font size to largest for grandmother",
62
+ domain=Domain.BROWSER,
63
+ difficulty=Difficulty.MEDIUM,
64
+ first_action_hint="Open Settings > Appearance",
65
+ demo_method="manual",
66
+ json_path="examples/msedge/2acd62b4-a2ab-44a7-a7e3-f5227bbd8324-wos.json",
67
+ ),
68
+ "4": WATask(
69
+ task_id="01b269ae-2111-4a07-81fd-3fcd711993b0-WOS",
70
+ instruction="Fill all blank cells with value from cell above",
71
+ domain=Domain.OFFICE_CALC,
72
+ difficulty=Difficulty.HARD,
73
+ first_action_hint="Select cells, use Go To Special > Blanks",
74
+ demo_method="recorded",
75
+ json_path="examples/libreoffice_calc/01b269ae-2111-4a07-81fd-3fcd711993b0-WOS.json",
76
+ ),
77
+ "5": WATask(
78
+ task_id="0a2e43bf-b26c-4631-a966-af9dfa12c9e5-WOS",
79
+ instruction="Calculate monthly totals and create line chart",
80
+ domain=Domain.OFFICE_CALC,
81
+ difficulty=Difficulty.HARD,
82
+ first_action_hint="Click cell for SUM formula",
83
+ demo_method="recorded",
84
+ json_path="examples/libreoffice_calc/0a2e43bf-b26c-4631-a966-af9dfa12c9e5-WOS.json",
85
+ ),
86
+ "6": WATask(
87
+ task_id="3ef2b351-8a84-4ff2-8724-d86eae9b842e-WOS",
88
+ instruction="Center align the heading in LibreOffice Writer",
89
+ domain=Domain.OFFICE_WRITER,
90
+ difficulty=Difficulty.EASY,
91
+ first_action_hint="Select text, click center align button",
92
+ demo_method="manual",
93
+ json_path="examples/libreoffice_writer/3ef2b351-8a84-4ff2-8724-d86eae9b842e-WOS.json",
94
+ ),
95
+ "7": WATask(
96
+ task_id="37e10fc4-b4c5-4b02-a65c-bfae8bc51d3f-wos",
97
+ instruction="Turn off notifications for system",
98
+ domain=Domain.SETTINGS,
99
+ difficulty=Difficulty.MEDIUM,
100
+ first_action_hint="Open Settings > System > Notifications",
101
+ demo_method="manual",
102
+ json_path="examples/settings/37e10fc4-b4c5-4b02-a65c-bfae8bc51d3f-wos.json",
103
+ ),
104
+ "8": WATask(
105
+ task_id="46adf721-2949-4426-b069-010b7c128d8f-wos",
106
+ instruction="Enable Night Light: on at 7PM, off at 7AM",
107
+ domain=Domain.SETTINGS,
108
+ difficulty=Difficulty.MEDIUM,
109
+ first_action_hint="Open Settings > Display > Night Light",
110
+ demo_method="manual",
111
+ json_path="examples/settings/46adf721-2949-4426-b069-010b7c128d8f-wos.json",
112
+ ),
113
+ "9": WATask(
114
+ task_id="0c9dda13-428c-492b-900b-f48562111f93-WOS",
115
+ instruction="Create Archive folder and move all .docx files",
116
+ domain=Domain.FILE_EXPLORER,
117
+ difficulty=Difficulty.MEDIUM,
118
+ first_action_hint="Right-click > New Folder, then select and move files",
119
+ demo_method="recorded",
120
+ json_path="examples/file_explorer/0c9dda13-428c-492b-900b-f48562111f93-WOS.json",
121
+ ),
122
+ "10": WATask(
123
+ task_id="34a4fee9-e52e-4a4a-96d2-68d35091504a-WOS",
124
+ instruction="Change view to Details view",
125
+ domain=Domain.FILE_EXPLORER,
126
+ difficulty=Difficulty.EASY,
127
+ first_action_hint="Click View menu or dropdown",
128
+ demo_method="manual",
129
+ json_path="examples/file_explorer/34a4fee9-e52e-4a4a-96d2-68d35091504a-WOS.json",
130
+ ),
131
+ }
132
+
133
+
134
+ def get_task(task_num: str | int) -> Optional[WATask]:
135
+ """Get a task by its number (1-10)."""
136
+ return TASKS.get(str(task_num))
137
+
138
+
139
+ def get_tasks_by_method(method: str) -> list[WATask]:
140
+ """Get all tasks that use a specific demo method."""
141
+ return [t for t in TASKS.values() if t.demo_method == method]
142
+
143
+
144
+ def get_manual_tasks() -> list[WATask]:
145
+ """Get tasks requiring manual demo writing."""
146
+ return get_tasks_by_method("manual")
147
+
148
+
149
+ def get_recorded_tasks() -> list[WATask]:
150
+ """Get tasks requiring recorded demos."""
151
+ return get_tasks_by_method("recorded")
@@ -0,0 +1,9 @@
1
+ """Export utilities for Episode data.
2
+
3
+ This module provides tools to export Episode trajectories to various formats
4
+ for analytics, training, and sharing.
5
+ """
6
+
7
+ from openadapt_ml.export.parquet import to_parquet, from_parquet
8
+
9
+ __all__ = ["to_parquet", "from_parquet"]
@@ -0,0 +1,6 @@
1
+ """Allow running export module as python -m openadapt_ml.export."""
2
+
3
+ from openadapt_ml.export.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,89 @@
1
+ """CLI for export utilities."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def main() -> int:
9
+ """Main entry point for export CLI."""
10
+ parser = argparse.ArgumentParser(
11
+ description="Export Episode data to various formats",
12
+ prog="python -m openadapt_ml.export",
13
+ )
14
+ subparsers = parser.add_subparsers(dest="command", help="Export format")
15
+
16
+ # Parquet subcommand
17
+ parquet_parser = subparsers.add_parser(
18
+ "parquet",
19
+ help="Export to Parquet format for analytics",
20
+ )
21
+ parquet_parser.add_argument(
22
+ "--input",
23
+ "-i",
24
+ required=True,
25
+ help="Directory containing Episode JSON files",
26
+ )
27
+ parquet_parser.add_argument(
28
+ "--output",
29
+ "-o",
30
+ required=True,
31
+ help="Output path for .parquet file",
32
+ )
33
+ parquet_parser.add_argument(
34
+ "--include-summary",
35
+ action="store_true",
36
+ help="Also generate episode-level summary table",
37
+ )
38
+
39
+ args = parser.parse_args()
40
+
41
+ if args.command == "parquet":
42
+ return export_parquet(args)
43
+ else:
44
+ parser.print_help()
45
+ return 1
46
+
47
+
48
+ def export_parquet(args: argparse.Namespace) -> int:
49
+ """Export Episodes to Parquet."""
50
+ try:
51
+ from openadapt_ml.export import to_parquet
52
+ from openadapt_ml.ingest import load_episodes
53
+ except ImportError as e:
54
+ print(f"Error: {e}", file=sys.stderr)
55
+ return 1
56
+
57
+ input_path = Path(args.input)
58
+ if not input_path.exists():
59
+ print(f"Error: Input path does not exist: {input_path}", file=sys.stderr)
60
+ return 1
61
+
62
+ print(f"Loading episodes from: {input_path}")
63
+ episodes = load_episodes(str(input_path))
64
+ print(f"Loaded {len(episodes)} episodes")
65
+
66
+ if not episodes:
67
+ print("Warning: No episodes found", file=sys.stderr)
68
+ return 1
69
+
70
+ total_steps = sum(len(ep.steps) for ep in episodes)
71
+ print(f"Total steps: {total_steps}")
72
+
73
+ print(f"Exporting to: {args.output}")
74
+ to_parquet(
75
+ episodes,
76
+ args.output,
77
+ include_summary=args.include_summary,
78
+ )
79
+
80
+ print("Done!")
81
+ if args.include_summary:
82
+ summary_path = args.output.replace(".parquet", "_summary.parquet")
83
+ print(f"Summary written to: {summary_path}")
84
+
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -0,0 +1,277 @@
1
+ """Parquet export utilities for Episode trajectories.
2
+
3
+ Parquet is a derived format for analytics and governance.
4
+ Episode JSON remains the canonical representation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from openadapt_ml.schema import Episode
14
+
15
+
16
+ def to_parquet(
17
+ episodes: list[Episode],
18
+ output_path: str,
19
+ flatten_steps: bool = True,
20
+ include_summary: bool = False,
21
+ ) -> None:
22
+ """Export Episodes to Parquet for analytics.
23
+
24
+ Creates a step-level Parquet file with one row per step.
25
+ Episode-level fields are repeated for each step.
26
+
27
+ Args:
28
+ episodes: List of Episode objects to export.
29
+ output_path: Path to output .parquet file.
30
+ flatten_steps: If True, one row per step. If False, one row per episode
31
+ with steps as nested structure (not yet implemented).
32
+ include_summary: If True, also generate {output_path}_summary.parquet
33
+ with episode-level aggregations.
34
+
35
+ Raises:
36
+ ImportError: If pyarrow is not installed.
37
+ ValueError: If flatten_steps is False (not yet implemented).
38
+
39
+ Example:
40
+ >>> from openadapt_ml.ingest import load_episodes
41
+ >>> from openadapt_ml.export import to_parquet
42
+ >>> episodes = load_episodes("workflow_exports/")
43
+ >>> to_parquet(episodes, "episodes.parquet")
44
+ """
45
+ try:
46
+ import pyarrow as pa
47
+ import pyarrow.parquet as pq
48
+ except ImportError:
49
+ raise ImportError(
50
+ "Parquet export requires pyarrow. "
51
+ "Install with: pip install openadapt-ml[parquet]"
52
+ )
53
+
54
+ if not flatten_steps:
55
+ raise ValueError(
56
+ "flatten_steps=False is not yet implemented. "
57
+ "Use flatten_steps=True for step-level rows."
58
+ )
59
+
60
+ rows = []
61
+ for episode in episodes:
62
+ episode_metadata = None
63
+ if hasattr(episode, "metadata") and episode.metadata:
64
+ episode_metadata = json.dumps(episode.metadata)
65
+
66
+ for step in episode.steps:
67
+ # Extract normalized coordinates if available
68
+ x, y = None, None
69
+ if step.action and step.action.normalized_coordinates:
70
+ x, y = step.action.normalized_coordinates
71
+
72
+ # Extract action type value (enum -> string)
73
+ action_type = None
74
+ if step.action:
75
+ action_type = (
76
+ step.action.type.value
77
+ if hasattr(step.action.type, "value")
78
+ else step.action.type
79
+ )
80
+
81
+ row = {
82
+ "episode_id": episode.episode_id,
83
+ "instruction": episode.instruction,
84
+ "task_id": getattr(episode, "task_id", None),
85
+ "step_index": step.step_index,
86
+ "timestamp": step.timestamp,
87
+ "action_type": action_type,
88
+ "x": x,
89
+ "y": y,
90
+ "end_x": step.action.normalized_end[0]
91
+ if step.action and step.action.normalized_end
92
+ else None,
93
+ "end_y": step.action.normalized_end[1]
94
+ if step.action and step.action.normalized_end
95
+ else None,
96
+ "text": getattr(step.action, "text", None) if step.action else None,
97
+ "key": getattr(step.action, "key", None) if step.action else None,
98
+ "scroll_direction": (
99
+ getattr(step.action, "scroll_direction", None)
100
+ if step.action
101
+ else None
102
+ ),
103
+ "screenshot_path": (
104
+ step.observation.screenshot_path if step.observation else None
105
+ ),
106
+ "window_title": (
107
+ getattr(step.observation, "window_title", None)
108
+ if step.observation
109
+ else None
110
+ ),
111
+ "app_name": (
112
+ None # Not in new schema at Observation level
113
+ ),
114
+ "url": (
115
+ None # Not in new schema at Observation level
116
+ ),
117
+ "reasoning": getattr(step, "reasoning", None),
118
+ "episode_metadata": episode_metadata,
119
+ }
120
+ rows.append(row)
121
+
122
+ table = pa.Table.from_pylist(rows)
123
+ pq.write_table(table, output_path)
124
+
125
+ if include_summary:
126
+ _write_summary(episodes, output_path)
127
+
128
+
129
+ def _write_summary(episodes: list[Episode], output_path: str) -> None:
130
+ """Write episode-level summary Parquet file."""
131
+ try:
132
+ import pyarrow as pa
133
+ import pyarrow.parquet as pq
134
+ except ImportError:
135
+ return
136
+
137
+ summary_rows = []
138
+ for episode in episodes:
139
+ first_t = episode.steps[0].timestamp if episode.steps else None
140
+ last_t = episode.steps[-1].timestamp if episode.steps else None
141
+ duration = (
142
+ (last_t - first_t) if first_t is not None and last_t is not None else None
143
+ )
144
+
145
+ # Extract action type values (enum -> string)
146
+ first_action_type = None
147
+ last_action_type = None
148
+ if episode.steps and episode.steps[0].action:
149
+ t = episode.steps[0].action.type
150
+ first_action_type = t.value if hasattr(t, "value") else t
151
+ if episode.steps and episode.steps[-1].action:
152
+ t = episode.steps[-1].action.type
153
+ last_action_type = t.value if hasattr(t, "value") else t
154
+
155
+ summary_rows.append(
156
+ {
157
+ "episode_id": episode.episode_id,
158
+ "instruction": episode.instruction,
159
+ "task_id": getattr(episode, "task_id", None),
160
+ "step_count": len(episode.steps),
161
+ "duration": duration,
162
+ "success": getattr(episode, "success", None),
163
+ "first_action_type": first_action_type,
164
+ "last_action_type": last_action_type,
165
+ "metadata": (
166
+ json.dumps(episode.metadata)
167
+ if hasattr(episode, "metadata") and episode.metadata
168
+ else None
169
+ ),
170
+ }
171
+ )
172
+
173
+ summary_table = pa.Table.from_pylist(summary_rows)
174
+ summary_path = str(output_path).replace(".parquet", "_summary.parquet")
175
+ pq.write_table(summary_table, summary_path)
176
+
177
+
178
+ def from_parquet(parquet_path: str) -> list[Episode]:
179
+ """Load Episodes from Parquet (inverse of to_parquet).
180
+
181
+ This is a lossy reconstruction. For full fidelity, always keep
182
+ Episode JSON as the source of truth.
183
+
184
+ Args:
185
+ parquet_path: Path to the Parquet file created by to_parquet().
186
+
187
+ Returns:
188
+ List of reconstructed Episode objects.
189
+
190
+ Raises:
191
+ ImportError: If pyarrow is not installed.
192
+
193
+ Note:
194
+ - Metadata fields are deserialized from JSON strings
195
+ - Step ordering is recovered from step_index
196
+ - Episode boundaries are recovered from episode_id grouping
197
+ """
198
+ try:
199
+ import pyarrow.parquet as pq
200
+ except ImportError:
201
+ raise ImportError(
202
+ "Parquet import requires pyarrow. "
203
+ "Install with: pip install openadapt-ml[parquet]"
204
+ )
205
+
206
+ from openadapt_ml.schema import Action, ActionType, Episode, Observation, Step
207
+
208
+ table = pq.read_table(parquet_path)
209
+ df = table.to_pandas()
210
+
211
+ episodes = []
212
+ for episode_id, group in df.groupby("episode_id"):
213
+ group = group.sort_values("step_index")
214
+
215
+ steps = []
216
+ for _, row in group.iterrows():
217
+ observation = Observation(
218
+ screenshot_path=row.get("screenshot_path") or row.get("image_path"),
219
+ window_title=row.get("window_title"),
220
+ )
221
+
222
+ action = None
223
+ if row.get("action_type"):
224
+ # Convert string action type to ActionType enum
225
+ action_type_str = row["action_type"]
226
+ try:
227
+ action_type = ActionType(action_type_str)
228
+ except ValueError:
229
+ action_type = ActionType.CLICK # Default fallback
230
+
231
+ # Build normalized coordinates tuple if x and y are present
232
+ normalized_coords = None
233
+ if row.get("x") is not None and row.get("y") is not None:
234
+ normalized_coords = (float(row["x"]), float(row["y"]))
235
+
236
+ # Build normalized end coordinates for drag
237
+ normalized_end = None
238
+ if row.get("end_x") is not None and row.get("end_y") is not None:
239
+ normalized_end = (float(row["end_x"]), float(row["end_y"]))
240
+
241
+ action = Action(
242
+ type=action_type,
243
+ normalized_coordinates=normalized_coords,
244
+ normalized_end=normalized_end,
245
+ text=row.get("text"),
246
+ key=row.get("key"),
247
+ scroll_direction=row.get("scroll_direction"),
248
+ )
249
+
250
+ step = Step(
251
+ step_index=int(row.get("step_index", 0)),
252
+ observation=observation,
253
+ action=action,
254
+ reasoning=row.get("reasoning") or row.get("thought"),
255
+ timestamp=row.get("timestamp"),
256
+ )
257
+ steps.append(step)
258
+
259
+ # Parse metadata if present
260
+ metadata = None
261
+ if group.iloc[0].get("episode_metadata"):
262
+ try:
263
+ metadata = json.loads(group.iloc[0]["episode_metadata"])
264
+ except (json.JSONDecodeError, TypeError):
265
+ pass
266
+
267
+ episode = Episode(
268
+ episode_id=str(episode_id),
269
+ instruction=group.iloc[0].get("instruction")
270
+ or group.iloc[0].get("goal", ""),
271
+ steps=steps,
272
+ task_id=group.iloc[0].get("task_id"),
273
+ metadata=metadata,
274
+ )
275
+ episodes.append(episode)
276
+
277
+ return episodes
@@ -20,7 +20,7 @@ from openadapt_ml.config import settings
20
20
  from openadapt_ml.grounding.base import GroundingModule, RegionCandidate
21
21
 
22
22
  if TYPE_CHECKING:
23
- from PIL import Image, ImageDraw, ImageFont
23
+ from PIL import Image
24
24
 
25
25
 
26
26
  class GeminiGrounder(GroundingModule):
@@ -104,7 +104,7 @@ class GeminiGrounder(GroundingModule):
104
104
 
105
105
  # Try to parse JSON from the response
106
106
  # Look for JSON array or object in the response
107
- json_match = re.search(r'\[[\s\S]*\]|\{[\s\S]*\}', response_text)
107
+ json_match = re.search(r"\[[\s\S]*\]|\{[\s\S]*\}", response_text)
108
108
  if not json_match:
109
109
  return candidates
110
110
 
@@ -340,11 +340,11 @@ Example output format:
340
340
  response_text = response.text
341
341
 
342
342
  # Try to extract JSON array from response
343
- json_match = re.search(r'\[[\s\S]*\]', response_text)
343
+ json_match = re.search(r"\[[\s\S]*\]", response_text)
344
344
  if not json_match:
345
345
  # Maybe it's just a plain array
346
- if response_text.strip().startswith('['):
347
- json_match = re.match(r'.*', response_text)
346
+ if response_text.strip().startswith("["):
347
+ json_match = re.match(r".*", response_text)
348
348
  else:
349
349
  return []
350
350
 
@@ -369,13 +369,18 @@ Example output format:
369
369
  max(0, min(1, y2 / screenshot.height)),
370
370
  ]
371
371
 
372
- normalized_elements.append({
373
- "id": elem.get("id", len(normalized_elements) + 1),
374
- "label": elem.get("label", f"Element {elem.get('id', len(normalized_elements) + 1)}"),
375
- "bbox": norm_bbox,
376
- "type": elem.get("type", "other"),
377
- "text": elem.get("text", ""),
378
- })
372
+ normalized_elements.append(
373
+ {
374
+ "id": elem.get("id", len(normalized_elements) + 1),
375
+ "label": elem.get(
376
+ "label",
377
+ f"Element {elem.get('id', len(normalized_elements) + 1)}",
378
+ ),
379
+ "bbox": norm_bbox,
380
+ "type": elem.get("type", "other"),
381
+ "text": elem.get("text", ""),
382
+ }
383
+ )
379
384
 
380
385
  return normalized_elements
381
386
 
@@ -549,8 +554,7 @@ class DetectorGrounder(GroundingModule):
549
554
  self._backend = GeminiGrounder(**kwargs)
550
555
  elif backend == "omniparser":
551
556
  raise NotImplementedError(
552
- "OmniParser backend not yet implemented. "
553
- "Use backend='gemini' for now."
557
+ "OmniParser backend not yet implemented. Use backend='gemini' for now."
554
558
  )
555
559
  else:
556
560
  raise ValueError(f"Unknown backend: {backend}")
@@ -6,7 +6,6 @@ and converting them to the format used for training.
6
6
  Data Model:
7
7
  - Episode: A single task attempt (e.g., "log into the app"). Contains a sequence
8
8
  of Steps, each with an Observation (screenshot) and Action (click/type/etc).
9
- - Session: A container grouping one or more Episodes with shared metadata.
10
9
 
11
10
  Functions:
12
11
  - load_episodes(): Load Episodes from JSON files (primary entry point)
@@ -14,30 +13,32 @@ Functions:
14
13
  - capture_to_episode(): Converts one openadapt-capture recording → one Episode
15
14
  - capture_to_session(): Converts one recording → Session containing one Episode
16
15
  - load_captures_as_sessions(): Loads multiple recordings → list of Sessions
17
- - generate_synthetic_sessions(): Creates synthetic training data
16
+ - generate_synthetic_episodes(): Creates synthetic training data
18
17
  """
19
18
 
20
19
  from openadapt_ml.ingest.loader import load_episodes, save_episodes
21
- from openadapt_ml.ingest.synthetic import generate_synthetic_sessions
20
+ from openadapt_ml.ingest.synthetic import generate_synthetic_episodes
22
21
 
23
22
  __all__ = [
24
23
  "load_episodes",
25
24
  "save_episodes",
26
- "generate_synthetic_sessions",
25
+ "generate_synthetic_episodes",
27
26
  ]
28
27
 
29
28
  # Conditionally export capture functions if openadapt-capture is installed
30
29
  try:
31
- from openadapt_ml.ingest.capture import (
30
+ from openadapt_ml.ingest.capture import ( # noqa: F401
32
31
  capture_to_episode,
33
32
  capture_to_session,
34
33
  load_captures_as_sessions,
35
34
  )
36
35
 
37
- __all__.extend([
38
- "capture_to_episode",
39
- "capture_to_session",
40
- "load_captures_as_sessions",
41
- ])
36
+ __all__.extend(
37
+ [
38
+ "capture_to_episode",
39
+ "capture_to_session",
40
+ "load_captures_as_sessions",
41
+ ]
42
+ )
42
43
  except ImportError:
43
44
  pass