android-skill-router / src /parameter_binder.py
kriyanshi's picture
Document parameterized replay and add Gmail/YouTube bindings.
9cbd5f0
Raw
History Blame Contribute Delete
7.69 kB
"""
Substitute runtime intent parameters into recorded trajectory steps.
Used by the Gradio preview and mirrored in Pocket Automator's ParameterBinder at replay time.
"""
from __future__ import annotations
import copy
import json
from typing import Any
from src.paths import DATA_DIR
SKILL_SCHEMAS_PATH = DATA_DIR / "skill_schemas.json"
_SEARCH_INPUT_SUFFIXES = (
"search_input",
"search_src_text",
"search_container",
"search_box",
"search_bar",
"search",
)
_MESSAGE_BODY_SUFFIXES = (
"entry",
"message",
"message_text",
)
_COMPOSE_BODY_SUFFIXES = (
"editor",
"body",
)
_COMPOSE_EXCLUDED_SUFFIXES = (
"subject",
"editor",
)
def _is_compose_recipient_step(step: dict[str, Any]) -> bool:
action = step.get("action") or {}
if action.get("type") != "set_text":
return False
package_name = (step.get("packageName") or "").lower()
if "gm" not in package_name and "gmail" not in package_name:
return False
resource_id = action.get("resourceId") or ""
return not any(_suffix_matches(resource_id, suffix) for suffix in _COMPOSE_EXCLUDED_SUFFIXES)
def load_skill_schemas() -> dict[str, Any]:
return json.loads(SKILL_SCHEMAS_PATH.read_text(encoding="utf-8"))
def load_skill_schema(skill: str) -> dict[str, Any]:
schemas = load_skill_schemas()
if skill not in schemas:
raise KeyError(f"Unknown skill schema: {skill!r}")
return schemas[skill]
def _resource_id_suffix(resource_id: str | None) -> str:
if not resource_id:
return ""
return resource_id.rsplit("/", 1)[-1].lower()
def _suffix_matches(resource_id: str | None, suffix: str) -> bool:
if not suffix:
return False
needle = suffix.lower()
rid = _resource_id_suffix(resource_id)
return rid == needle or rid.endswith(needle)
def is_search_input_step(step: dict[str, Any]) -> bool:
action = step.get("action") or {}
if action.get("type") != "set_text":
return False
resource_id = action.get("resourceId") or ""
if any(_suffix_matches(resource_id, suffix) for suffix in _SEARCH_INPUT_SUFFIXES):
return True
package_name = (step.get("packageName") or "").lower()
class_name = (action.get("className") or "").lower()
return "spotify" in package_name and "edittext" in class_name
def find_preceding_search_step(steps: list[dict[str, Any]], before_index: int) -> dict[str, Any] | None:
for index in range(before_index - 1, -1, -1):
step = steps[index]
action = step.get("action") or {}
if action.get("type") != "set_text":
continue
value = action.get("value")
if not value or not str(value).strip():
continue
if is_search_input_step(step):
return step
return None
def _binding_matches(
binding: dict[str, Any],
step: dict[str, Any],
steps: list[dict[str, Any]],
index: int,
) -> bool:
action = step.get("action") or {}
if action.get("type") != binding.get("action"):
return False
resource_id_suffix = binding.get("resource_id_suffix")
if resource_id_suffix and not _suffix_matches(action.get("resourceId"), resource_id_suffix):
return False
if binding.get("after_search"):
return find_preceding_search_step(steps, index) is not None
package_contains = binding.get("package_contains")
if package_contains and package_contains.lower() not in (step.get("packageName") or "").lower():
return False
if binding.get("compose_recipient"):
return _is_compose_recipient_step(step)
return True
def _apply_binding(action: dict[str, Any], binding: dict[str, Any], value: str) -> dict[str, Any]:
updated = dict(action)
if binding.get("action") == "set_text":
updated["value"] = value
elif binding.get("action") == "click":
updated["text"] = value
if binding.get("set_content_description"):
updated["contentDescription"] = value
return updated
def apply_parameters(
trajectory: dict[str, Any],
skill: str,
parameters: dict[str, str] | None,
) -> dict[str, Any]:
"""Return a copy of the trajectory with bound parameter values substituted."""
if not parameters:
return trajectory
schema = load_skill_schema(skill)
bindings = schema.get("bindings") or []
if not bindings:
return trajectory
result = copy.deepcopy(trajectory)
steps = result.get("steps") or []
for index, step in enumerate(steps):
action = step.get("action")
if not isinstance(action, dict):
continue
for binding in bindings:
param_name = binding.get("parameter")
if not param_name or param_name not in parameters:
continue
if not _binding_matches(binding, step, steps, index):
continue
step["action"] = _apply_binding(action, binding, str(parameters[param_name]))
action = step["action"]
return result
def _self_test() -> None:
from src.skill_router import load_trajectory
whatsapp = load_trajectory("whatsapp_send_message")
bound = apply_parameters(
whatsapp,
"whatsapp_send_message",
{"contact": "mom", "message": "i'm on my way"},
)
search_values = [
step["action"]["value"]
for step in bound["steps"]
if step["action"]["type"] == "set_text"
and _suffix_matches(step["action"].get("resourceId"), "search_input")
]
message_values = [
step["action"]["value"]
for step in bound["steps"]
if step["action"]["type"] == "set_text"
and _suffix_matches(step["action"].get("resourceId"), "entry")
]
assert search_values and all(value == "mom" for value in search_values), search_values
assert message_values and all(value == "i'm on my way" for value in message_values), message_values
post_search_clicks = [
step["action"]
for index, step in enumerate(bound["steps"])
if step["action"]["type"] == "click"
and find_preceding_search_step(bound["steps"], index) is not None
]
assert post_search_clicks and all(click.get("text") == "mom" for click in post_search_clicks)
gmail = load_trajectory("gmail_send_email")
bound_gmail = apply_parameters(
gmail,
"gmail_send_email",
{"recipient": "alice@example.com", "message": "running late"},
)
recipient_values = [
step["action"]["value"]
for step in bound_gmail["steps"]
if _is_compose_recipient_step(step)
]
body_values = [
step["action"]["value"]
for step in bound_gmail["steps"]
if step["action"]["type"] == "set_text"
and _suffix_matches(step["action"].get("resourceId"), "editor")
]
assert recipient_values and all(value == "alice@example.com" for value in recipient_values), recipient_values
assert body_values and all(value == "running late" for value in body_values), body_values
youtube = load_trajectory("youtube_search")
bound_youtube = apply_parameters(
youtube,
"youtube_search",
{"query": "pasta recipes"},
)
youtube_search_values = [
step["action"]["value"]
for step in bound_youtube["steps"]
if step["action"]["type"] == "set_text"
and _suffix_matches(step["action"].get("resourceId"), "search_edit_text")
]
assert youtube_search_values and all(value == "pasta recipes" for value in youtube_search_values), youtube_search_values
if __name__ == "__main__":
_self_test()
print("parameter_binder: ok")