kriyanshi Cursor commited on
Commit
e26a40b
·
1 Parent(s): 92a189a

Add WhatsApp trajectory parameter preview in Gradio.

Browse files

Substitute runtime intent parameters into recorded steps using skill schema bindings before showing trajectory JSON in the demo.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (3) hide show
  1. app.py +6 -1
  2. data/skill_schemas.json +21 -1
  3. src/parameter_binder.py +195 -0
app.py CHANGED
@@ -17,6 +17,7 @@ from pathlib import Path
17
  import gradio as gr
18
  import requests
19
 
 
20
  from src.skill_router import SKILL_TO_TRAJECTORY, load_trajectory
21
  from src.skill_utils import resolve_skill
22
 
@@ -216,11 +217,13 @@ def _build_skills_catalog_card() -> str:
216
 
217
  def _trajectory_summary(skill: str, parameters: dict | None = None) -> tuple[str, str, str]:
218
  data = load_trajectory(skill)
 
 
 
219
  steps = data.get("steps", [])
220
  task = data.get("task", "—")
221
  app_pkg = data.get("app", "—")
222
  trajectory_file = Path(SKILL_TO_TRAJECTORY[skill]).name
223
- params = parameters or {}
224
 
225
  summary = (
226
  f"### {SKILL_LABELS.get(skill, skill)}\n\n"
@@ -233,10 +236,12 @@ def _trajectory_summary(skill: str, parameters: dict | None = None) -> tuple[str
233
  if params:
234
  param_lines = " \n".join(f"**{k}** · {v}" for k, v in params.items())
235
  summary += f"\n\n**Extracted parameters** \n{param_lines}"
 
236
 
237
  preview = {
238
  "skill": skill,
239
  "parameters": params,
 
240
  "task": task,
241
  "app": app_pkg,
242
  "steps": len(steps),
 
17
  import gradio as gr
18
  import requests
19
 
20
+ from src.parameter_binder import apply_parameters
21
  from src.skill_router import SKILL_TO_TRAJECTORY, load_trajectory
22
  from src.skill_utils import resolve_skill
23
 
 
217
 
218
  def _trajectory_summary(skill: str, parameters: dict | None = None) -> tuple[str, str, str]:
219
  data = load_trajectory(skill)
220
+ params = parameters or {}
221
+ if params:
222
+ data = apply_parameters(data, skill, params)
223
  steps = data.get("steps", [])
224
  task = data.get("task", "—")
225
  app_pkg = data.get("app", "—")
226
  trajectory_file = Path(SKILL_TO_TRAJECTORY[skill]).name
 
227
 
228
  summary = (
229
  f"### {SKILL_LABELS.get(skill, skill)}\n\n"
 
236
  if params:
237
  param_lines = " \n".join(f"**{k}** · {v}" for k, v in params.items())
238
  summary += f"\n\n**Extracted parameters** \n{param_lines}"
239
+ summary += "\n\n**Trajectory preview** · runtime parameters substituted into recorded steps"
240
 
241
  preview = {
242
  "skill": skill,
243
  "parameters": params,
244
+ "parameterized": bool(params),
245
  "task": task,
246
  "app": app_pkg,
247
  "steps": len(steps),
data/skill_schemas.json CHANGED
@@ -55,7 +55,27 @@
55
  "required": true,
56
  "description": "Message body text"
57
  }
58
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  },
60
  "camera_take_photo": {
61
  "description": "Open the camera and take a photo",
 
55
  "required": true,
56
  "description": "Message body text"
57
  }
58
+ },
59
+ "bindings": [
60
+ {
61
+ "parameter": "contact",
62
+ "action": "set_text",
63
+ "resource_id_suffix": "search_input",
64
+ "package_contains": "whatsapp"
65
+ },
66
+ {
67
+ "parameter": "contact",
68
+ "action": "click",
69
+ "after_search": true,
70
+ "set_content_description": false
71
+ },
72
+ {
73
+ "parameter": "message",
74
+ "action": "set_text",
75
+ "resource_id_suffix": "entry",
76
+ "package_contains": "whatsapp"
77
+ }
78
+ ]
79
  },
80
  "camera_take_photo": {
81
  "description": "Open the camera and take a photo",
src/parameter_binder.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Substitute runtime intent parameters into recorded trajectory steps.
3
+
4
+ Preview-only for the Gradio demo today; Pocket Automator will mirror this at replay time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ from typing import Any
12
+
13
+ from src.paths import DATA_DIR
14
+
15
+ SKILL_SCHEMAS_PATH = DATA_DIR / "skill_schemas.json"
16
+
17
+ _SEARCH_INPUT_SUFFIXES = (
18
+ "search_input",
19
+ "search_src_text",
20
+ "search_container",
21
+ "search_box",
22
+ "search_bar",
23
+ "search",
24
+ )
25
+
26
+ _MESSAGE_BODY_SUFFIXES = (
27
+ "entry",
28
+ "message",
29
+ "message_text",
30
+ )
31
+
32
+ _COMPOSE_BODY_SUFFIXES = (
33
+ "editor",
34
+ "body",
35
+ )
36
+
37
+
38
+ def load_skill_schemas() -> dict[str, Any]:
39
+ return json.loads(SKILL_SCHEMAS_PATH.read_text(encoding="utf-8"))
40
+
41
+
42
+ def load_skill_schema(skill: str) -> dict[str, Any]:
43
+ schemas = load_skill_schemas()
44
+ if skill not in schemas:
45
+ raise KeyError(f"Unknown skill schema: {skill!r}")
46
+ return schemas[skill]
47
+
48
+
49
+ def _resource_id_suffix(resource_id: str | None) -> str:
50
+ if not resource_id:
51
+ return ""
52
+ return resource_id.rsplit("/", 1)[-1].lower()
53
+
54
+
55
+ def _suffix_matches(resource_id: str | None, suffix: str) -> bool:
56
+ if not suffix:
57
+ return False
58
+ needle = suffix.lower()
59
+ rid = _resource_id_suffix(resource_id)
60
+ return rid == needle or rid.endswith(needle)
61
+
62
+
63
+ def is_search_input_step(step: dict[str, Any]) -> bool:
64
+ action = step.get("action") or {}
65
+ if action.get("type") != "set_text":
66
+ return False
67
+ resource_id = action.get("resourceId") or ""
68
+ if any(_suffix_matches(resource_id, suffix) for suffix in _SEARCH_INPUT_SUFFIXES):
69
+ return True
70
+ package_name = (step.get("packageName") or "").lower()
71
+ class_name = (action.get("className") or "").lower()
72
+ return "spotify" in package_name and "edittext" in class_name
73
+
74
+
75
+ def find_preceding_search_step(steps: list[dict[str, Any]], before_index: int) -> dict[str, Any] | None:
76
+ for index in range(before_index - 1, -1, -1):
77
+ step = steps[index]
78
+ action = step.get("action") or {}
79
+ if action.get("type") != "set_text":
80
+ continue
81
+ value = action.get("value")
82
+ if not value or not str(value).strip():
83
+ continue
84
+ if is_search_input_step(step):
85
+ return step
86
+ return None
87
+
88
+
89
+ def _binding_matches(
90
+ binding: dict[str, Any],
91
+ step: dict[str, Any],
92
+ steps: list[dict[str, Any]],
93
+ index: int,
94
+ ) -> bool:
95
+ action = step.get("action") or {}
96
+ if action.get("type") != binding.get("action"):
97
+ return False
98
+
99
+ resource_id_suffix = binding.get("resource_id_suffix")
100
+ if resource_id_suffix and not _suffix_matches(action.get("resourceId"), resource_id_suffix):
101
+ return False
102
+
103
+ if binding.get("after_search"):
104
+ return find_preceding_search_step(steps, index) is not None
105
+
106
+ package_contains = binding.get("package_contains")
107
+ if package_contains and package_contains.lower() not in (step.get("packageName") or "").lower():
108
+ return False
109
+
110
+ return True
111
+
112
+
113
+ def _apply_binding(action: dict[str, Any], binding: dict[str, Any], value: str) -> dict[str, Any]:
114
+ updated = dict(action)
115
+ if binding.get("action") == "set_text":
116
+ updated["value"] = value
117
+ elif binding.get("action") == "click":
118
+ updated["text"] = value
119
+ if binding.get("set_content_description"):
120
+ updated["contentDescription"] = value
121
+ return updated
122
+
123
+
124
+ def apply_parameters(
125
+ trajectory: dict[str, Any],
126
+ skill: str,
127
+ parameters: dict[str, str] | None,
128
+ ) -> dict[str, Any]:
129
+ """Return a copy of the trajectory with bound parameter values substituted."""
130
+ if not parameters:
131
+ return trajectory
132
+
133
+ schema = load_skill_schema(skill)
134
+ bindings = schema.get("bindings") or []
135
+ if not bindings:
136
+ return trajectory
137
+
138
+ result = copy.deepcopy(trajectory)
139
+ steps = result.get("steps") or []
140
+
141
+ for index, step in enumerate(steps):
142
+ action = step.get("action")
143
+ if not isinstance(action, dict):
144
+ continue
145
+
146
+ for binding in bindings:
147
+ param_name = binding.get("parameter")
148
+ if not param_name or param_name not in parameters:
149
+ continue
150
+ if not _binding_matches(binding, step, steps, index):
151
+ continue
152
+ step["action"] = _apply_binding(action, binding, str(parameters[param_name]))
153
+ action = step["action"]
154
+
155
+ return result
156
+
157
+
158
+ def _self_test() -> None:
159
+ from src.skill_router import load_trajectory
160
+
161
+ trajectory = load_trajectory("whatsapp_send_message")
162
+ bound = apply_parameters(
163
+ trajectory,
164
+ "whatsapp_send_message",
165
+ {"contact": "mom", "message": "i'm on my way"},
166
+ )
167
+
168
+ search_values = [
169
+ step["action"]["value"]
170
+ for step in bound["steps"]
171
+ if step["action"]["type"] == "set_text"
172
+ and _suffix_matches(step["action"].get("resourceId"), "search_input")
173
+ ]
174
+ message_values = [
175
+ step["action"]["value"]
176
+ for step in bound["steps"]
177
+ if step["action"]["type"] == "set_text"
178
+ and _suffix_matches(step["action"].get("resourceId"), "entry")
179
+ ]
180
+
181
+ assert search_values and all(value == "mom" for value in search_values), search_values
182
+ assert message_values and all(value == "i'm on my way" for value in message_values), message_values
183
+
184
+ post_search_clicks = [
185
+ step["action"]
186
+ for index, step in enumerate(bound["steps"])
187
+ if step["action"]["type"] == "click"
188
+ and find_preceding_search_step(bound["steps"], index) is not None
189
+ ]
190
+ assert post_search_clicks and all(click.get("text") == "mom" for click in post_search_clicks)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ _self_test()
195
+ print("parameter_binder: ok")