openadapt-ml 0.2.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 (95) 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 -115
  8. openadapt_ml/benchmarks/agent.py +265 -421
  9. openadapt_ml/benchmarks/azure.py +28 -19
  10. openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
  11. openadapt_ml/benchmarks/cli.py +1722 -4847
  12. openadapt_ml/benchmarks/trace_export.py +631 -0
  13. openadapt_ml/benchmarks/viewer.py +22 -5
  14. openadapt_ml/benchmarks/vm_monitor.py +530 -29
  15. openadapt_ml/benchmarks/waa_deploy/Dockerfile +47 -53
  16. openadapt_ml/benchmarks/waa_deploy/api_agent.py +21 -20
  17. openadapt_ml/cloud/azure_inference.py +3 -5
  18. openadapt_ml/cloud/lambda_labs.py +722 -307
  19. openadapt_ml/cloud/local.py +2038 -487
  20. openadapt_ml/cloud/ssh_tunnel.py +68 -26
  21. openadapt_ml/datasets/next_action.py +40 -30
  22. openadapt_ml/evals/grounding.py +8 -3
  23. openadapt_ml/evals/plot_eval_metrics.py +15 -13
  24. openadapt_ml/evals/trajectory_matching.py +41 -26
  25. openadapt_ml/experiments/demo_prompt/format_demo.py +16 -6
  26. openadapt_ml/experiments/demo_prompt/run_experiment.py +26 -16
  27. openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
  28. openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
  29. openadapt_ml/experiments/representation_shootout/config.py +390 -0
  30. openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
  31. openadapt_ml/experiments/representation_shootout/runner.py +687 -0
  32. openadapt_ml/experiments/waa_demo/runner.py +29 -14
  33. openadapt_ml/export/parquet.py +36 -24
  34. openadapt_ml/grounding/detector.py +18 -14
  35. openadapt_ml/ingest/__init__.py +8 -6
  36. openadapt_ml/ingest/capture.py +25 -22
  37. openadapt_ml/ingest/loader.py +7 -4
  38. openadapt_ml/ingest/synthetic.py +189 -100
  39. openadapt_ml/models/api_adapter.py +14 -4
  40. openadapt_ml/models/base_adapter.py +10 -2
  41. openadapt_ml/models/providers/__init__.py +288 -0
  42. openadapt_ml/models/providers/anthropic.py +266 -0
  43. openadapt_ml/models/providers/base.py +299 -0
  44. openadapt_ml/models/providers/google.py +376 -0
  45. openadapt_ml/models/providers/openai.py +342 -0
  46. openadapt_ml/models/qwen_vl.py +46 -19
  47. openadapt_ml/perception/__init__.py +35 -0
  48. openadapt_ml/perception/integration.py +399 -0
  49. openadapt_ml/retrieval/demo_retriever.py +50 -24
  50. openadapt_ml/retrieval/embeddings.py +9 -8
  51. openadapt_ml/retrieval/retriever.py +3 -1
  52. openadapt_ml/runtime/__init__.py +50 -0
  53. openadapt_ml/runtime/policy.py +18 -5
  54. openadapt_ml/runtime/safety_gate.py +471 -0
  55. openadapt_ml/schema/__init__.py +9 -0
  56. openadapt_ml/schema/converters.py +74 -27
  57. openadapt_ml/schema/episode.py +31 -18
  58. openadapt_ml/scripts/capture_screenshots.py +530 -0
  59. openadapt_ml/scripts/compare.py +85 -54
  60. openadapt_ml/scripts/demo_policy.py +4 -1
  61. openadapt_ml/scripts/eval_policy.py +15 -9
  62. openadapt_ml/scripts/make_gif.py +1 -1
  63. openadapt_ml/scripts/prepare_synthetic.py +3 -1
  64. openadapt_ml/scripts/train.py +21 -9
  65. openadapt_ml/segmentation/README.md +920 -0
  66. openadapt_ml/segmentation/__init__.py +97 -0
  67. openadapt_ml/segmentation/adapters/__init__.py +5 -0
  68. openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
  69. openadapt_ml/segmentation/annotator.py +610 -0
  70. openadapt_ml/segmentation/cache.py +290 -0
  71. openadapt_ml/segmentation/cli.py +674 -0
  72. openadapt_ml/segmentation/deduplicator.py +656 -0
  73. openadapt_ml/segmentation/frame_describer.py +788 -0
  74. openadapt_ml/segmentation/pipeline.py +340 -0
  75. openadapt_ml/segmentation/schemas.py +622 -0
  76. openadapt_ml/segmentation/segment_extractor.py +634 -0
  77. openadapt_ml/training/azure_ops_viewer.py +1097 -0
  78. openadapt_ml/training/benchmark_viewer.py +52 -41
  79. openadapt_ml/training/shared_ui.py +7 -7
  80. openadapt_ml/training/stub_provider.py +57 -35
  81. openadapt_ml/training/trainer.py +143 -86
  82. openadapt_ml/training/trl_trainer.py +70 -21
  83. openadapt_ml/training/viewer.py +323 -108
  84. openadapt_ml/training/viewer_components.py +180 -0
  85. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/METADATA +215 -14
  86. openadapt_ml-0.2.1.dist-info/RECORD +116 -0
  87. openadapt_ml/benchmarks/base.py +0 -366
  88. openadapt_ml/benchmarks/data_collection.py +0 -432
  89. openadapt_ml/benchmarks/live_tracker.py +0 -180
  90. openadapt_ml/benchmarks/runner.py +0 -418
  91. openadapt_ml/benchmarks/waa.py +0 -761
  92. openadapt_ml/benchmarks/waa_live.py +0 -619
  93. openadapt_ml-0.2.0.dist-info/RECORD +0 -86
  94. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/WHEEL +0 -0
  95. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,12 @@
1
1
  """Unified viewer HTML generation.
2
2
 
3
+ .. deprecated::
4
+ This module is deprecated. Use ``openadapt_viewer`` instead::
5
+
6
+ from openadapt_viewer import generate_unified_viewer
7
+
8
+ The openadapt-viewer package is the canonical location for viewer code.
9
+
3
10
  This module generates the Viewer HTML with step-by-step playback,
4
11
  transcript/audio sync, and model prediction comparison.
5
12
  """
@@ -7,6 +14,7 @@ transcript/audio sync, and model prediction comparison.
7
14
  from __future__ import annotations
8
15
 
9
16
  import json
17
+ import warnings
10
18
  from pathlib import Path
11
19
 
12
20
  from openadapt_ml.training.shared_ui import (
@@ -14,6 +22,13 @@ from openadapt_ml.training.shared_ui import (
14
22
  generate_shared_header_html as _generate_shared_header_html,
15
23
  )
16
24
 
25
+ warnings.warn(
26
+ "openadapt_ml.training.viewer is deprecated. "
27
+ "Use openadapt_viewer instead: from openadapt_viewer import generate_unified_viewer",
28
+ DeprecationWarning,
29
+ stacklevel=2,
30
+ )
31
+
17
32
 
18
33
  def _copy_transcript_and_audio(capture_path: Path | None, output_dir: Path) -> None:
19
34
  """Copy transcript.json and convert audio to mp3 for viewer playback.
@@ -33,7 +48,7 @@ def _copy_transcript_and_audio(capture_path: Path | None, output_dir: Path) -> N
33
48
  transcript_dst = output_dir / "transcript.json"
34
49
  if transcript_src.exists() and not transcript_dst.exists():
35
50
  shutil.copy2(transcript_src, transcript_dst)
36
- print(f" Copied transcript.json from capture")
51
+ print(" Copied transcript.json from capture")
37
52
 
38
53
  # Convert audio to mp3 if it exists (ffmpeg required)
39
54
  audio_dst = output_dir / "audio.mp3"
@@ -44,14 +59,24 @@ def _copy_transcript_and_audio(capture_path: Path | None, output_dir: Path) -> N
44
59
  if audio_src.exists():
45
60
  try:
46
61
  result = subprocess.run(
47
- ["ffmpeg", "-i", str(audio_src), "-y", "-q:a", "2", str(audio_dst)],
62
+ [
63
+ "ffmpeg",
64
+ "-i",
65
+ str(audio_src),
66
+ "-y",
67
+ "-q:a",
68
+ "2",
69
+ str(audio_dst),
70
+ ],
48
71
  capture_output=True,
49
72
  timeout=60,
50
73
  )
51
74
  if result.returncode == 0:
52
75
  print(f" Converted {audio_src.name} to audio.mp3")
53
76
  else:
54
- print(f" Warning: ffmpeg conversion failed for {audio_src.name}")
77
+ print(
78
+ f" Warning: ffmpeg conversion failed for {audio_src.name}"
79
+ )
55
80
  except FileNotFoundError:
56
81
  print(" Warning: ffmpeg not found, cannot convert audio")
57
82
  except subprocess.TimeoutExpired:
@@ -120,12 +145,12 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
120
145
  data = json.load(f)
121
146
 
122
147
  # Determine checkpoint name from filename
123
- name_match = re.search(r'predictions_(.+)\.json', json_file.name)
148
+ name_match = re.search(r"predictions_(.+)\.json", json_file.name)
124
149
  if name_match:
125
150
  raw_name = name_match.group(1)
126
- if raw_name.startswith('epoch'):
151
+ if raw_name.startswith("epoch"):
127
152
  checkpoint_name = f"Epoch {raw_name[5:]}"
128
- elif raw_name == 'preview':
153
+ elif raw_name == "preview":
129
154
  checkpoint_name = "Preview"
130
155
  else:
131
156
  checkpoint_name = raw_name.title()
@@ -133,19 +158,19 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
133
158
  checkpoint_name = json_file.stem
134
159
 
135
160
  # Extract base data from first file
136
- if base_data is None and 'base_data' in data:
137
- base_data = data['base_data']
161
+ if base_data is None and "base_data" in data:
162
+ base_data = data["base_data"]
138
163
 
139
164
  # Store predictions
140
- if 'predictions' in data:
141
- predictions_by_checkpoint[checkpoint_name] = data['predictions']
165
+ if "predictions" in data:
166
+ predictions_by_checkpoint[checkpoint_name] = data["predictions"]
142
167
  print(f" Loaded predictions from {json_file.name}")
143
168
  except Exception as e:
144
169
  print(f" Warning: Could not load {json_file.name}: {e}")
145
170
 
146
171
  # Fallback: look for comparison_epoch*.html files and extract their data
147
172
  for comp_file in sorted(output_dir.glob("comparison_epoch*.html")):
148
- match = re.search(r'epoch(\d+)', comp_file.name)
173
+ match = re.search(r"epoch(\d+)", comp_file.name)
149
174
  if not match:
150
175
  continue
151
176
 
@@ -157,9 +182,9 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
157
182
  html_content = comp_file.read_text()
158
183
  # Look for comparisonData = [...]; (supports both const and window. prefix)
159
184
  data_match = re.search(
160
- r'(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);',
185
+ r"(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);",
161
186
  html_content,
162
- re.DOTALL
187
+ re.DOTALL,
163
188
  )
164
189
  if data_match:
165
190
  comparison_data = json.loads(data_match.group(1))
@@ -168,20 +193,24 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
168
193
  if base_data is None:
169
194
  base_data = []
170
195
  for item in comparison_data:
171
- base_data.append({
172
- "index": item.get("index", 0),
173
- "time": item.get("time", 0),
174
- "image_path": item.get("image_path", ""),
175
- "human_action": item.get("human_action", {}),
176
- })
196
+ base_data.append(
197
+ {
198
+ "index": item.get("index", 0),
199
+ "time": item.get("time", 0),
200
+ "image_path": item.get("image_path", ""),
201
+ "human_action": item.get("human_action", {}),
202
+ }
203
+ )
177
204
 
178
205
  # Extract predictions
179
206
  predictions = []
180
207
  for item in comparison_data:
181
- predictions.append({
182
- "predicted_action": item.get("predicted_action"),
183
- "match": item.get("match"),
184
- })
208
+ predictions.append(
209
+ {
210
+ "predicted_action": item.get("predicted_action"),
211
+ "match": item.get("match"),
212
+ }
213
+ )
185
214
  predictions_by_checkpoint[checkpoint_name] = predictions
186
215
  print(f" Loaded predictions from {comp_file.name}")
187
216
  except Exception as e:
@@ -193,9 +222,9 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
193
222
  try:
194
223
  html_content = preview_file.read_text()
195
224
  data_match = re.search(
196
- r'(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);',
225
+ r"(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);",
197
226
  html_content,
198
- re.DOTALL
227
+ re.DOTALL,
199
228
  )
200
229
  if data_match:
201
230
  comparison_data = json.loads(data_match.group(1))
@@ -204,26 +233,32 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
204
233
  if base_data is None:
205
234
  base_data = []
206
235
  for item in comparison_data:
207
- base_data.append({
208
- "index": item.get("index", 0),
209
- "time": item.get("time", 0),
210
- "image_path": item.get("image_path", ""),
211
- "human_action": item.get("human_action", {}),
212
- })
236
+ base_data.append(
237
+ {
238
+ "index": item.get("index", 0),
239
+ "time": item.get("time", 0),
240
+ "image_path": item.get("image_path", ""),
241
+ "human_action": item.get("human_action", {}),
242
+ }
243
+ )
213
244
 
214
245
  predictions = []
215
246
  for item in comparison_data:
216
- predictions.append({
217
- "predicted_action": item.get("predicted_action"),
218
- "match": item.get("match"),
219
- })
247
+ predictions.append(
248
+ {
249
+ "predicted_action": item.get("predicted_action"),
250
+ "match": item.get("match"),
251
+ }
252
+ )
220
253
  # Only add if it has actual predictions
221
254
  has_predictions = any(p.get("predicted_action") for p in predictions)
222
255
  if has_predictions and "Preview" not in predictions_by_checkpoint:
223
256
  predictions_by_checkpoint["Preview"] = predictions
224
- print(f" Loaded predictions from comparison_preview.html")
257
+ print(" Loaded predictions from comparison_preview.html")
225
258
  except Exception as e:
226
- print(f" Warning: Could not extract data from comparison_preview.html: {e}")
259
+ print(
260
+ f" Warning: Could not extract data from comparison_preview.html: {e}"
261
+ )
227
262
 
228
263
  # If we still don't have base data, we can't generate the viewer
229
264
  if base_data is None:
@@ -237,6 +272,7 @@ def generate_unified_viewer_from_output_dir(output_dir: Path) -> Path | None:
237
272
  capture_modified_time = None
238
273
  if capture_path and capture_path.exists():
239
274
  import datetime
275
+
240
276
  mtime = capture_path.stat().st_mtime
241
277
  capture_modified_time = datetime.datetime.fromtimestamp(mtime).isoformat()
242
278
 
@@ -275,22 +311,28 @@ def _generate_unified_viewer_from_extracted_data(
275
311
  shared_header_css = _get_shared_header_css()
276
312
  shared_header_html = _generate_shared_header_html("viewer")
277
313
 
314
+ # Note: keyboard shortcuts CSS and JS are handled inline in the viewer HTML
315
+
278
316
  # Build base HTML from extracted data (standalone, no openadapt-capture dependency)
279
317
  base_data_json = json.dumps(base_data)
280
318
  predictions_json = json.dumps(predictions_by_checkpoint)
281
319
  evaluations_json = json.dumps(evaluations or [])
282
- captures_json = json.dumps([{
283
- "id": capture_id,
284
- "name": goal,
285
- "steps": len(base_data),
286
- }])
320
+ captures_json = json.dumps(
321
+ [
322
+ {
323
+ "id": capture_id,
324
+ "name": goal,
325
+ "steps": len(base_data),
326
+ }
327
+ ]
328
+ )
287
329
  current_capture_json = json.dumps(capture_id)
288
330
  capture_modified_time_json = json.dumps(capture_modified_time)
289
331
 
290
332
  # Find first image to get dimensions (for display)
291
- first_image_path = base_data[0].get("image_path", "") if base_data else ""
333
+ base_data[0].get("image_path", "") if base_data else ""
292
334
 
293
- html = f'''<!DOCTYPE html>
335
+ html = f"""<!DOCTYPE html>
294
336
  <html lang="en">
295
337
  <head>
296
338
  <meta charset="UTF-8">
@@ -366,6 +408,50 @@ def _generate_unified_viewer_from_extracted_data(
366
408
  flex-wrap: wrap;
367
409
  align-items: center;
368
410
  }}
411
+ .search-container {{
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 8px;
415
+ flex: 1;
416
+ max-width: 400px;
417
+ }}
418
+ .search-input {{
419
+ flex: 1;
420
+ padding: 10px 14px;
421
+ border-radius: 8px;
422
+ font-size: 0.85rem;
423
+ background: var(--bg-tertiary);
424
+ color: var(--text-primary);
425
+ border: 1px solid var(--border-color);
426
+ transition: all 0.2s;
427
+ }}
428
+ .search-input:focus {{
429
+ outline: none;
430
+ border-color: var(--accent);
431
+ box-shadow: 0 0 0 2px var(--accent-dim);
432
+ }}
433
+ .search-input::placeholder {{
434
+ color: var(--text-muted);
435
+ }}
436
+ .search-clear-btn {{
437
+ padding: 8px 12px;
438
+ border-radius: 6px;
439
+ font-size: 0.75rem;
440
+ background: var(--bg-tertiary);
441
+ color: var(--text-secondary);
442
+ border: 1px solid var(--border-color);
443
+ cursor: pointer;
444
+ transition: all 0.2s;
445
+ }}
446
+ .search-clear-btn:hover {{
447
+ border-color: var(--accent);
448
+ color: var(--text-primary);
449
+ }}
450
+ .search-count {{
451
+ font-size: 0.75rem;
452
+ color: var(--text-secondary);
453
+ white-space: nowrap;
454
+ }}
369
455
  .control-group {{
370
456
  display: flex;
371
457
  align-items: center;
@@ -1164,6 +1250,17 @@ def _generate_unified_viewer_from_extracted_data(
1164
1250
  <span class="control-label">Checkpoint:</span>
1165
1251
  <select class="control-select" id="checkpoint-select"></select>
1166
1252
  </div>
1253
+ <div class="search-container">
1254
+ <input
1255
+ type="text"
1256
+ id="search-input"
1257
+ class="search-input"
1258
+ placeholder="Search steps... (Ctrl+F / Cmd+F)"
1259
+ title="Search by step index, action type, or description"
1260
+ />
1261
+ <button class="search-clear-btn" id="search-clear-btn" title="Clear search">Clear</button>
1262
+ <span class="search-count" id="search-count"></span>
1263
+ </div>
1167
1264
  </div>
1168
1265
 
1169
1266
  <div class="cost-panel" id="cost-panel">
@@ -2563,9 +2660,9 @@ def _generate_unified_viewer_from_extracted_data(
2563
2660
  setupGalleryPanel();
2564
2661
  </script>
2565
2662
  </body>
2566
- </html>'''
2663
+ </html>"""
2567
2664
 
2568
- output_path.write_text(html, encoding='utf-8')
2665
+ output_path.write_text(html, encoding="utf-8")
2569
2666
  print(f"Generated unified viewer: {output_path}")
2570
2667
 
2571
2668
 
@@ -2593,9 +2690,7 @@ def _enhance_comparison_to_unified_viewer(
2593
2690
 
2594
2691
  # Extract base data from the existing comparisonData (supports both const and window. prefix)
2595
2692
  data_match = re.search(
2596
- r'(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);',
2597
- html,
2598
- re.DOTALL
2693
+ r"(?:const\s+|window\.)comparisonData\s*=\s*(\[.*?\]);", html, re.DOTALL
2599
2694
  )
2600
2695
  if not data_match:
2601
2696
  print(f"Could not find comparisonData in {base_html_file}")
@@ -2606,31 +2701,37 @@ def _enhance_comparison_to_unified_viewer(
2606
2701
  # Build base data (human actions only) and ensure predictions dict has base data
2607
2702
  base_data = []
2608
2703
  for item in base_comparison_data:
2609
- base_data.append({
2610
- "index": item.get("index", 0),
2611
- "time": item.get("time", 0),
2612
- "image_path": item.get("image_path", ""),
2613
- "human_action": item.get("human_action", {}),
2614
- })
2704
+ base_data.append(
2705
+ {
2706
+ "index": item.get("index", 0),
2707
+ "time": item.get("time", 0),
2708
+ "image_path": item.get("image_path", ""),
2709
+ "human_action": item.get("human_action", {}),
2710
+ }
2711
+ )
2615
2712
 
2616
2713
  # JSON encode predictions
2617
2714
  predictions_json = json.dumps(predictions_by_checkpoint)
2618
- captures_json = json.dumps([{
2619
- "id": capture_id,
2620
- "name": goal,
2621
- "steps": len(base_data),
2622
- }])
2715
+ captures_json = json.dumps(
2716
+ [
2717
+ {
2718
+ "id": capture_id,
2719
+ "name": goal,
2720
+ "steps": len(base_data),
2721
+ }
2722
+ ]
2723
+ )
2623
2724
 
2624
2725
  # 1. Replace nav bar with unified header combining nav + controls
2625
2726
  # Use shared header CSS and HTML for consistency with training dashboard
2626
- header_css = f'<style>{_get_shared_header_css()}</style>'
2727
+ header_css = f"<style>{_get_shared_header_css()}</style>"
2627
2728
 
2628
2729
  # Build the controls HTML for the viewer (example + checkpoint dropdowns)
2629
2730
  controls_html = f'''
2630
2731
  <div class="control-group">
2631
2732
  <span class="control-label">Example</span>
2632
2733
  <select id="capture-select">
2633
- <option value="{capture_id}">{goal[:40]}{'...' if len(goal) > 40 else ''} ({len(base_data)})</option>
2734
+ <option value="{capture_id}">{goal[:40]}{"..." if len(goal) > 40 else ""} ({len(base_data)})</option>
2634
2735
  </select>
2635
2736
  </div>
2636
2737
  <div class="control-group">
@@ -2640,17 +2741,15 @@ def _enhance_comparison_to_unified_viewer(
2640
2741
  '''
2641
2742
 
2642
2743
  unified_header = header_css + _generate_shared_header_html(
2643
- "viewer",
2644
- controls_html=controls_html,
2645
- meta_html=f"ID: {capture_id}"
2744
+ "viewer", controls_html=controls_html, meta_html=f"ID: {capture_id}"
2646
2745
  )
2647
2746
 
2648
2747
  # Remove any old viewer-controls div if it exists (from previous runs)
2649
2748
  html = re.sub(
2650
2749
  r'<div class="viewer-controls"[^>]*>.*?</div>\s*(?=<)',
2651
- '',
2750
+ "",
2652
2751
  html,
2653
- flags=re.DOTALL
2752
+ flags=re.DOTALL,
2654
2753
  )
2655
2754
 
2656
2755
  # Try to replace existing nav with unified header
@@ -2660,31 +2759,21 @@ def _enhance_comparison_to_unified_viewer(
2660
2759
  r'<nav class="nav-bar"[^>]*>.*?</nav>\s*',
2661
2760
  unified_header,
2662
2761
  html,
2663
- flags=re.DOTALL
2762
+ flags=re.DOTALL,
2664
2763
  )
2665
2764
  nav_replaced = True
2666
2765
 
2667
2766
  # Remove the old <header> element - unified header already contains all info
2668
- html = re.sub(
2669
- r'<header[^>]*>.*?</header>\s*',
2670
- '',
2671
- html,
2672
- flags=re.DOTALL
2673
- )
2767
+ html = re.sub(r"<header[^>]*>.*?</header>\s*", "", html, flags=re.DOTALL)
2674
2768
 
2675
2769
  # If no nav was found/replaced, insert unified header after <body>
2676
2770
  if not nav_replaced:
2677
- html = re.sub(
2678
- r'(<body[^>]*>)',
2679
- r'\1\n' + unified_header,
2680
- html,
2681
- count=1
2682
- )
2771
+ html = re.sub(r"(<body[^>]*>)", r"\1\n" + unified_header, html, count=1)
2683
2772
 
2684
2773
  # 3. Replace the comparisonData with multi-checkpoint system
2685
2774
  # We need to modify the JavaScript to use our checkpoint system
2686
2775
 
2687
- checkpoint_script = f'''
2776
+ checkpoint_script = f"""
2688
2777
  <script>
2689
2778
  // Unified viewer: multi-checkpoint support
2690
2779
  // Bridge local comparisonData to window scope for cross-script access
@@ -2814,6 +2903,121 @@ def _enhance_comparison_to_unified_viewer(
2814
2903
  }}
2815
2904
  }};
2816
2905
 
2906
+ // Search functionality
2907
+ let searchQuery = '';
2908
+ let filteredIndices = [];
2909
+
2910
+ function advancedSearch(items, query, fields = ['action']) {{
2911
+ if (!query || query.trim() === '') {{
2912
+ return items.map((_, i) => i);
2913
+ }}
2914
+
2915
+ // Tokenize query
2916
+ const queryTokens = query
2917
+ .toLowerCase()
2918
+ .replace(/[^a-z0-9\\s]/g, ' ')
2919
+ .replace(/\\s+/g, ' ')
2920
+ .trim()
2921
+ .split(' ')
2922
+ .filter(t => t.length > 0);
2923
+
2924
+ if (queryTokens.length === 0) {{
2925
+ return items.map((_, i) => i);
2926
+ }}
2927
+
2928
+ const results = [];
2929
+
2930
+ items.forEach((item, idx) => {{
2931
+ // Build searchable text
2932
+ const searchParts = [];
2933
+
2934
+ // Add step index
2935
+ searchParts.push(String(idx));
2936
+
2937
+ // Add action type and details
2938
+ if (item.human_action) {{
2939
+ const action = item.human_action;
2940
+ if (action.type) searchParts.push(action.type);
2941
+ if (action.text) searchParts.push(action.text);
2942
+ if (action.key) searchParts.push(action.key);
2943
+ }}
2944
+
2945
+ const searchText = searchParts
2946
+ .join(' ')
2947
+ .toLowerCase()
2948
+ .replace(/[^a-z0-9\\s]/g, ' ')
2949
+ .replace(/\\s+/g, ' ');
2950
+
2951
+ // All query tokens must match
2952
+ const matches = queryTokens.every(token => searchText.includes(token));
2953
+ if (matches) {{
2954
+ results.push(idx);
2955
+ }}
2956
+ }});
2957
+
2958
+ return results;
2959
+ }}
2960
+
2961
+ function updateSearchResults() {{
2962
+ searchQuery = document.getElementById('search-input').value;
2963
+ filteredIndices = advancedSearch(baseData, searchQuery, ['action']);
2964
+
2965
+ // Update count
2966
+ const countEl = document.getElementById('search-count');
2967
+ if (searchQuery) {{
2968
+ countEl.textContent = `${{filteredIndices.length}} of ${{baseData.length}} steps`;
2969
+ }} else {{
2970
+ countEl.textContent = '';
2971
+ }}
2972
+
2973
+ // Update step list visibility
2974
+ updateStepListVisibility();
2975
+
2976
+ // If no results, show message
2977
+ if (searchQuery && filteredIndices.length === 0) {{
2978
+ countEl.textContent = 'No matches';
2979
+ countEl.style.color = 'var(--text-muted)';
2980
+ }} else {{
2981
+ countEl.style.color = 'var(--text-secondary)';
2982
+ }}
2983
+ }}
2984
+
2985
+ function updateStepListVisibility() {{
2986
+ const stepList = document.querySelector('.step-list');
2987
+ if (!stepList) return;
2988
+
2989
+ const stepItems = stepList.querySelectorAll('.step-item');
2990
+ stepItems.forEach((item, idx) => {{
2991
+ if (searchQuery && !filteredIndices.includes(idx)) {{
2992
+ item.style.display = 'none';
2993
+ }} else {{
2994
+ item.style.display = '';
2995
+ }}
2996
+ }});
2997
+ }}
2998
+
2999
+ function clearSearch() {{
3000
+ document.getElementById('search-input').value = '';
3001
+ updateSearchResults();
3002
+ }}
3003
+
3004
+ // Setup search event listeners
3005
+ document.getElementById('search-input').addEventListener('input', updateSearchResults);
3006
+ document.getElementById('search-clear-btn').addEventListener('click', clearSearch);
3007
+
3008
+ // Keyboard shortcut: Ctrl+F / Cmd+F
3009
+ document.addEventListener('keydown', (e) => {{
3010
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {{
3011
+ e.preventDefault();
3012
+ document.getElementById('search-input').focus();
3013
+ }}
3014
+ // Escape to clear search
3015
+ if (e.key === 'Escape' && document.activeElement === document.getElementById('search-input')) {{
3016
+ clearSearch();
3017
+ document.getElementById('search-input').blur();
3018
+ }}
3019
+ }});
3020
+
2817
3021
  // Initialize on load
2818
3022
  setTimeout(window.initCheckpointDropdown, 200);
2819
3023
 
@@ -2867,19 +3071,19 @@ def _enhance_comparison_to_unified_viewer(
2867
3071
  }}, 200);
2868
3072
  }})();
2869
3073
  </script>
2870
- '''
3074
+ """
2871
3075
 
2872
3076
  # Insert checkpoint script before </body>
2873
- html = html.replace('</body>', checkpoint_script + '</body>')
3077
+ html = html.replace("</body>", checkpoint_script + "</body>")
2874
3078
 
2875
3079
  # 4. Disable the old discoverDashboards that creates wrong nav
2876
3080
  html = html.replace(
2877
- 'discoverDashboards();',
2878
- '// discoverDashboards disabled - using unified viewer nav'
3081
+ "discoverDashboards();",
3082
+ "// discoverDashboards disabled - using unified viewer nav",
2879
3083
  )
2880
3084
 
2881
3085
  # Write output
2882
- output_path.write_text(html, encoding='utf-8')
3086
+ output_path.write_text(html, encoding="utf-8")
2883
3087
  print(f"Generated unified viewer from {base_html_file.name}: {output_path}")
2884
3088
 
2885
3089
 
@@ -2904,67 +3108,78 @@ def _add_static_nav_to_comparison(
2904
3108
  # Move comparison panel to be a full-width sibling BEFORE main-content (not inside it)
2905
3109
  if '<div class="comparison-panel"' in html:
2906
3110
  # Check if panel is NOT already right before main-content
2907
- if '<div class="comparison-panel"' in html and 'class="comparison-panel"' in html:
3111
+ if (
3112
+ '<div class="comparison-panel"' in html
3113
+ and 'class="comparison-panel"' in html
3114
+ ):
2908
3115
  # Check if it's in the wrong place (inside sidebar or main-content)
2909
- in_sidebar = '<div class="sidebar">' in html and html.index('<div class="comparison-panel"') > html.index('<div class="sidebar">')
2910
- in_main = '<div class="main-content">' in html and '<div class="main-content">\n' in html and '<div class="main-content">\n <div class="comparison-panel"' in html
3116
+ in_sidebar = '<div class="sidebar">' in html and html.index(
3117
+ '<div class="comparison-panel"'
3118
+ ) > html.index('<div class="sidebar">')
3119
+ in_main = (
3120
+ '<div class="main-content">' in html
3121
+ and '<div class="main-content">\n' in html
3122
+ and '<div class="main-content">\n <div class="comparison-panel"'
3123
+ in html
3124
+ )
2911
3125
 
2912
3126
  if in_sidebar or in_main:
2913
3127
  # Extract comparison panel from wherever it is
2914
3128
  panel_match = re.search(
2915
3129
  r'(\s*<div class="comparison-panel"[^>]*>.*?</div>\s*</div>\s*</div>)',
2916
3130
  html,
2917
- re.DOTALL
3131
+ re.DOTALL,
2918
3132
  )
2919
3133
  if panel_match:
2920
3134
  panel_html = panel_match.group(1)
2921
3135
  # Remove from current location
2922
- html = html.replace(panel_html, '')
3136
+ html = html.replace(panel_html, "")
2923
3137
  # Insert as sibling BEFORE main-content
2924
3138
  html = html.replace(
2925
3139
  '<div class="main-content">',
2926
- panel_html.strip() + '\n <div class="main-content">'
3140
+ panel_html.strip() + '\n <div class="main-content">',
3141
+ )
3142
+ print(
3143
+ f" Moved Action Comparison above screenshot in {comparison_path.name}"
2927
3144
  )
2928
- print(f" Moved Action Comparison above screenshot in {comparison_path.name}")
2929
3145
 
2930
3146
  # Build nav links if not provided
2931
3147
  if nav_links is None:
2932
- nav_links = _build_nav_links()
3148
+ # Default nav links if not provided
3149
+ nav_links = []
2933
3150
 
2934
3151
  # Build nav HTML with active state for current file
2935
3152
  # NOTE: No "Dashboards:" label to match training dashboard nav
2936
3153
  current_file = comparison_path.name
2937
- nav_html = '''
3154
+ nav_html = """
2938
3155
  <nav class="nav-bar" style="display:flex;gap:8px;padding:12px 16px;background:#12121a;border:1px solid rgba(255,255,255,0.06);border-radius:8px;margin-bottom:16px;flex-wrap:wrap;">
2939
- '''
3156
+ """
2940
3157
  for filename, label in nav_links:
2941
3158
  is_active = filename == current_file
2942
- active_style = "background:#00d4aa;color:#0a0a0f;border-color:#00d4aa;font-weight:600;" if is_active else ""
3159
+ active_style = (
3160
+ "background:#00d4aa;color:#0a0a0f;border-color:#00d4aa;font-weight:600;"
3161
+ if is_active
3162
+ else ""
3163
+ )
2943
3164
  nav_html += f' <a href="{filename}" style="padding:8px 16px;border-radius:6px;font-size:0.8rem;text-decoration:none;color:#888;background:#1a1a24;border:1px solid rgba(255,255,255,0.06);{active_style}">{label}</a>\n'
2944
- nav_html += ' </nav>\n'
3165
+ nav_html += " </nav>\n"
2945
3166
 
2946
3167
  # ALWAYS replace existing nav or add new one (for consistency)
2947
3168
  if '<nav class="nav-bar"' in html:
2948
3169
  # Replace existing nav
2949
3170
  html = re.sub(
2950
- r'<nav class="nav-bar"[^>]*>.*?</nav>\s*',
2951
- nav_html,
2952
- html,
2953
- flags=re.DOTALL
3171
+ r'<nav class="nav-bar"[^>]*>.*?</nav>\s*', nav_html, html, flags=re.DOTALL
2954
3172
  )
2955
3173
  print(f" Updated navigation in {comparison_path.name}")
2956
3174
  elif '<div class="container">' in html:
2957
3175
  # Insert nav BEFORE the container, not inside it
2958
3176
  # This ensures the unified header is not affected by container padding
2959
3177
  html = html.replace(
2960
- '<div class="container">',
2961
- nav_html + '\n <div class="container">'
3178
+ '<div class="container">', nav_html + '\n <div class="container">'
2962
3179
  )
2963
3180
  print(f" Added navigation to {comparison_path.name}")
2964
- elif '<body>' in html:
2965
- html = html.replace('<body>', '<body>\n' + nav_html)
3181
+ elif "<body>" in html:
3182
+ html = html.replace("<body>", "<body>\n" + nav_html)
2966
3183
  print(f" Added navigation to {comparison_path.name}")
2967
3184
 
2968
3185
  comparison_path.write_text(html)
2969
-
2970
-