Skip to content

Commit bb445c0

Browse files
committed
Add AI-powered automation generation, refinement, explanation, and self-healing
Introduces NLAutomationMixin to backend for AI-driven step generation, step refinement, automation explanation, and self-healing of failed UI steps. Updates API endpoints and client logic to support these features. Frontend now allows users to generate automation steps from natural language, refine steps with AI, view AI explanations, and enable self-healing for automation runs. UI updates include new controls for AI features in AutomationEditor, Automations, and AutomationRunDetail views.
1 parent 732ee06 commit bb445c0

8 files changed

Lines changed: 863 additions & 38 deletions

File tree

‎backend/devicekit/client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from devicekit.mixins.automation import AutomationMixin
1616
from devicekit.mixins.profile import ProfileMixin
1717
from devicekit.mixins.prompture_agent import PromptureAgentMixin
18+
from devicekit.mixins.nl_automation import NLAutomationMixin
1819
from devicekit.mixins.fleet import FleetMixin
1920
from devicekit.mixins.device_lock import DeviceLockMixin
2021
from devicekit.mixins.auth import AuthMixin
@@ -28,6 +29,7 @@ class Client(
2829
AwsStorageMixin,
2930
AutomationMixin,
3031
ProfileMixin,
32+
NLAutomationMixin,
3133
PromptureAgentMixin,
3234
FleetMixin,
3335
DeviceLockMixin,

‎backend/devicekit/mixins/api_app.py‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,8 +900,9 @@ def automations_run(automation_id):
900900
device_id = data.get('device_id')
901901
if not device_id:
902902
return jsonify({'error': 'device_id is required'}), 400
903+
self_heal = bool(data.get('self_heal', False))
903904
try:
904-
run_record = client.execute_automation(automation_id, device_id)
905+
run_record = client.execute_automation(automation_id, device_id, self_heal=self_heal)
905906
return jsonify(run_record), 201
906907
except ValueError as e:
907908
return jsonify({'error': str(e)}), 404
@@ -1066,6 +1067,54 @@ def automations_import():
10661067
except Exception as e:
10671068
return jsonify({'error': str(e)}), 500
10681069

1070+
# ── NL Automation (AI-powered) ──
1071+
@app.route('/automations/generate', methods=['POST'])
1072+
def automations_generate():
1073+
data = request.get_json(silent=True) or {}
1074+
description = data.get('description', '')
1075+
if not description:
1076+
return jsonify({'error': 'description is required'}), 400
1077+
device_id = data.get('device_id')
1078+
try:
1079+
result = client.generate_automation_steps(description, device_id=device_id)
1080+
return jsonify(result)
1081+
except Exception as e:
1082+
return jsonify({'error': str(e)}), 500
1083+
1084+
@app.route('/automations/refine-step', methods=['POST'])
1085+
def automations_refine_step():
1086+
data = request.get_json(silent=True) or {}
1087+
step = data.get('step')
1088+
instruction = data.get('instruction', '')
1089+
if not step or not instruction:
1090+
return jsonify({'error': 'step and instruction are required'}), 400
1091+
device_id = data.get('device_id')
1092+
try:
1093+
refined = client.refine_step(step, instruction, device_id=device_id)
1094+
return jsonify(refined)
1095+
except Exception as e:
1096+
return jsonify({'error': str(e)}), 500
1097+
1098+
@app.route('/automations/<automation_id>/explain')
1099+
def automations_explain(automation_id):
1100+
try:
1101+
result = client.explain_automation(automation_id)
1102+
if 'error' in result and result['error'] == 'Automation not found':
1103+
return jsonify(result), 404
1104+
return jsonify(result)
1105+
except Exception as e:
1106+
return jsonify({'error': str(e)}), 500
1107+
1108+
@app.route('/devices/<device_id>/ui-hierarchy')
1109+
def device_ui_hierarchy(device_id):
1110+
try:
1111+
hierarchy = client.fetch_ui_hierarchy(device_id)
1112+
if hierarchy:
1113+
return jsonify(hierarchy)
1114+
return jsonify({'error': 'Could not fetch UI hierarchy'}), 500
1115+
except Exception as e:
1116+
return jsonify({'error': str(e)}), 500
1117+
10691118
# -----------------------------------------------------------
10701119
# Profiles
10711120
# -----------------------------------------------------------

‎backend/devicekit/mixins/automation.py‎

Lines changed: 70 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def delete_automation(self, automation_id):
196196
# ---------------------------------------------------------------
197197
# Execution
198198
# ---------------------------------------------------------------
199-
def execute_automation(self, automation_id, device_id):
199+
def execute_automation(self, automation_id, device_id, self_heal=False):
200200
automation = self.get_automation(automation_id)
201201
if not automation:
202202
raise ValueError(f"Automation {automation_id} not found")
@@ -215,6 +215,7 @@ def execute_automation(self, automation_id, device_id):
215215
"current_step_index": 0,
216216
"step_results": [],
217217
"error": None,
218+
"self_heal": self_heal,
218219
}
219220
self._automation_runs.append(run_record)
220221

@@ -231,8 +232,11 @@ def execute_automation(self, automation_id, device_id):
231232
return run_record
232233

233234
def _run_automation_thread(self, run_record, steps, device_id, cancel_event):
235+
from devicekit.mixins.nl_automation import UI_TARGETING_STEP_TYPES
236+
234237
step_results = []
235238
completed = 0
239+
self_heal_enabled = run_record.get("self_heal", False)
236240

237241
for idx, step in enumerate(steps):
238242
if cancel_event.is_set():
@@ -267,39 +271,72 @@ def _run_automation_thread(self, run_record, steps, device_id, cancel_event):
267271
result["duration_ms"] = elapsed
268272
completed += 1
269273
except Exception as e:
270-
elapsed = int((time.time() - start_ts) * 1000)
271-
result["status"] = "failed"
272-
result["error"] = str(e)
273-
result["duration_ms"] = elapsed
274-
# Best-effort screenshot capture on failure
275-
try:
276-
screenshot_data = self.take_screenshot(device_id)
277-
if screenshot_data:
278-
result["failure_screenshot"] = base64.b64encode(screenshot_data).decode('ascii')
279-
except Exception:
280-
pass
281-
step_results.append(result)
282-
# Mark remaining steps as skipped
283-
for remaining in steps[idx + 1:]:
284-
step_results.append({
285-
"step_id": remaining.get("id", ""),
286-
"step_type": remaining.get("type", "unknown"),
287-
"label": remaining.get("label", ""),
288-
"status": "skipped",
289-
"output": None,
290-
"error": None,
291-
"duration_ms": 0,
274+
error_str = str(e)
275+
step_type = step.get("type", "")
276+
277+
# Attempt self-healing for UI-targeting steps
278+
healed = False
279+
if self_heal_enabled and step_type in UI_TARGETING_STEP_TYPES:
280+
try:
281+
heal_result = self.self_heal_step(device_id, step, error_str)
282+
if heal_result.get("healed"):
283+
# Re-execute with healed step config
284+
healed_step = heal_result["new_step"]
285+
heal_start = time.time()
286+
output = self._execute_step(healed_step, device_id)
287+
elapsed = int((time.time() - start_ts) * 1000)
288+
result["status"] = "completed"
289+
result["output"] = str(output) if output else None
290+
result["duration_ms"] = elapsed
291+
result["healed"] = True
292+
result["original_step"] = copy.deepcopy(step)
293+
result["healed_step"] = healed_step
294+
result["heal_reasoning"] = heal_result.get("reasoning", "")
295+
completed += 1
296+
healed = True
297+
else:
298+
# Heal attempted but failed
299+
result["healed"] = False
300+
result["heal_reasoning"] = heal_result.get("reasoning", "Could not find element")
301+
except Exception as heal_err:
302+
logger.warning(f"Self-heal error for step {idx}: {heal_err}")
303+
result["healed"] = False
304+
result["heal_reasoning"] = f"Heal error: {heal_err}"
305+
306+
if not healed:
307+
elapsed = int((time.time() - start_ts) * 1000)
308+
result["status"] = "failed"
309+
result["error"] = error_str
310+
result["duration_ms"] = elapsed
311+
# Best-effort screenshot capture on failure
312+
try:
313+
screenshot_data = self.take_screenshot(device_id)
314+
if screenshot_data:
315+
result["failure_screenshot"] = base64.b64encode(screenshot_data).decode('ascii')
316+
except Exception:
317+
pass
318+
step_results.append(result)
319+
# Mark remaining steps as skipped
320+
for remaining in steps[idx + 1:]:
321+
step_results.append({
322+
"step_id": remaining.get("id", ""),
323+
"step_type": remaining.get("type", "unknown"),
324+
"label": remaining.get("label", ""),
325+
"status": "skipped",
326+
"output": None,
327+
"error": None,
328+
"duration_ms": 0,
329+
})
330+
run_record.update({
331+
"status": "failed",
332+
"finished_at": time.time(),
333+
"completed_steps": completed,
334+
"current_step_index": idx,
335+
"step_results": step_results,
336+
"error": error_str,
292337
})
293-
run_record.update({
294-
"status": "failed",
295-
"finished_at": time.time(),
296-
"completed_steps": completed,
297-
"current_step_index": idx,
298-
"step_results": step_results,
299-
"error": str(e),
300-
})
301-
self._active_runs.pop(run_record["id"], None)
302-
return
338+
self._active_runs.pop(run_record["id"], None)
339+
return
303340

304341
step_results.append(result)
305342
run_record["completed_steps"] = completed

0 commit comments

Comments
 (0)