diff --git a/docs/superpowers/specs/2026-07-15-gesture-primitives-humanize-design.md b/docs/superpowers/specs/2026-07-15-gesture-primitives-humanize-design.md new file mode 100644 index 0000000..e63f765 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-gesture-primitives-humanize-design.md @@ -0,0 +1,158 @@ +# 原子手势扩展 + 拟人化抖动 设计 + +- 日期:2026-07-15 +- 状态:Draft(待用户 review) +- 作者:Jerry Yan + Claude +- 关联代码区:`runtime/tool_specs.py`、`runtime/executor.py`、`runtime/planner_prompts.py`、`driver/base.py`、`driver/wda_driver.py`、`driver/android_driver.py`、`tools/` + +## 1. 背景与动机 + +当前 Runtime 暴露给 AI Planner 的设备动作只有 5 个:`tap` / `swipe` / `input_text` / `launch_app` / `terminate_app`(见 `runtime/tool_specs.py:42-145`)。Driver 抽象(`driver/base.py`)与两个实现(WDA / Android UiAutomator2)同样没有长按、双击。 + +两个缺口: + +1. **原子手势不全**:长按(long press)、双击(double tap)无法表达。AI Planner 无法完成依赖这两类手势的任务。 +2. **动作过于"机器化"**:`swipe` 在 driver 层走 2 点命令(WDA `mobile: dragFromToForDuration`、Android `mobile: dragGesture`),轨迹是完美直线;`tap` 每次落在精确坐标。这种确定性容易被目标 app 的反自动化检测识别。 + +本设计同时解决两者:新增两个原子手势,并引入一个集中的拟人化(humanize)层,对所有指针类动作注入坐标偏移、路径曲线、时序抖动。 + +## 2. 目标 / 非目标 + +### 目标 +- 新增 `long_press`、`double_tap` 两个工具,端到端贯通(driver → tool → tool_spec → executor registry → planner prompt)。 +- 新增 `tools/humanize.py`,提供坐标抖动、滑动 waypoint 生成、时长抖动三类纯函数。 +- `swipe` 在 humanize 开启时改走 W3C Actions 多点曲线;`double_tap` 始终走 W3C Actions(两 tap + 可调间隔)。 +- 通过 `APEX_HUMANIZE_ENABLED` env 开关(默认开)控制;关闭时现有 `tap`/`swipe` 行为零回归、现有精确坐标断言零改动。 + +### 非目标 +- 不做捏合、多指、3D Touch 等更复杂的手势(YAGNI,等真有需求)。 +- 不重构现有 `swipe` 关闭路径(保留 `mobile: dragGesture`/`dragFromToForDuration`)。 +- 不给 `tap`/`input_text` 等加新的可调参数(只加抖动,不改语义)。 +- 不引入时序相关的全局 sleep/节流策略。 +- 不做反检测的"高级"维度(设备指纹、传感器模拟等),只做指针轨迹层面的拟人。 + +## 3. 平台命令矩阵 + +实现期必须按 `android-driver` 变更 task 1.1 的先例,对照已安装的 appium-python-client 源码/官方文档核实命令名,不得凭记忆猜测。 + +| 手势 | iOS (WDA / XCUITest) | Android (UiAutomator2) | 备注 | +|---|---|---|---| +| `long_press` | `mobile: touchAndHold` `{x, y, duration}` | `mobile: longClickGesture` `{x, y}` | Android 时长系统固定、不可控;`duration_ms` 参数在 Android 实现里被忽略并记入 Risks | +| `double_tap` | **W3C Actions**(`pointerDown→up→pause→down→up`) | **W3C Actions**(同左) | 不用 `mobile: doubleTap`/`doubleClickGesture`,理由见 §4.3 | +| `swipe`(humanize 开) | **W3C Actions** 多点曲线 | **W3C Actions** 多点曲线 | 新增 `Driver.swipe_path` | +| `swipe`(humanize 关) | `mobile: dragFromToForDuration`(不变) | `mobile: dragGesture`(不变) | 保留原 `Driver.swipe` | + +## 4. 设计 + +### 4.1 新原子手势 + +**`long_press`** +- 参数:`x`、`y`、`duration_ms?=1200`(加 `purpose`/`expected_outcome` 元数据,见 `tool_specs.py:13-26` 的 `ACTION_METADATA_PROPERTIES`) +- 实现:driver 新增 `Driver.long_press(x, y, duration_ms)` 抽象方法;WDA 透传 `duration`,Android 忽略 `duration_ms` +- 工具:`tools/long_press.py`,薄封装,humanize 抖动坐标(+ WDA 时长) + +**`double_tap`** +- 参数:`x`、`y`、`interval_ms?=80` +- 实现:driver 新增 `Driver.double_tap(x, y, interval_ms)` 抽象方法,两个驱动统一用 W3C Actions 实现(§4.3) +- 工具:`tools/double_tap.py`,薄封装,humanize 抖动坐标 + 间隔 + +### 4.2 `tools/humanize.py`(新) + +集中式拟人模块,纯函数 + 一个 dataclass config。无 driver 依赖。 + +```python +@dataclass(frozen=True) +class HumanizeConfig: + enabled: bool = True # 读 APEX_HUMANIZE_ENABLED,默认 true + tap_radius_px: float = 5.0 # APEX_HUMANIZE_TAP_RADIUS_PX + swipe_curvature: float = 0.15 # 垂直噪声幅度占路径长度的比例 + swipe_waypoints: int = 8 # 中间点数 + duration_spread: float = 0.15 # 时长 ±15% + +def jitter_point(x, y, *, radius, rng) -> tuple[float, float]: ... +def swipe_waypoints(start, end, *, curvature, n, rng) -> list[tuple[float,float]]: ... +def jitter_duration(value_ms, *, spread, rng) -> int: ... +def jitter_interval(value_ms, *, spread, rng) -> int: ... +``` + +- 随机源:函数接受 `rng: random.Random`。模块级单例 `_rng` 默认 `random.Random()`;测试可注入固定种子构造的 `random.Random(seed)` 以可复现。 +- `swipe_waypoints`:在起止两点间用贝塞尔/线性插值生成 `n` 个点,每个点沿路径法线方向加高斯噪声(幅度 = 路径长度 × `curvature`)。保证首末点等于(抖动后的)起止点。 +- config 加载沿用仓库既有 `load_config` 模式(参考 `runtime/planner_config.py`),env 名统一前缀 `APEX_HUMANIZE_*`。 + +### 4.3 W3C Actions:`Driver.swipe_path` 与 `double_tap` 的共享实现 + +Appium Python Client 的 `ActionBuilder` / W3C `pointer` action 在 iOS 与 Android 两侧 API 一致,逐点 `pointer_move` 即可。为避免两个驱动各写一遍,在 driver 层新增一个共享辅助: + +- `driver/base.py` 新增抽象方法: + - `swipe_path(self, waypoints: list[tuple[float, float]], duration_ms: int) -> None` + - `double_tap(self, x: float, y: float, interval_ms: int) -> None` + - `long_press(self, x: float, y: float, duration_ms: int) -> None` +- 共享的 W3C Actions 构造逻辑放 `driver/_w3c_actions.py`(模块级函数,接收 appium `client` + 参数),两个驱动的 `swipe_path`/`double_tap` 实现各自调它。这样: + - driver 仍是薄适配器(只持 client、调辅助、包 `DriverError`) + - W3C 序列化逻辑不重复 + +**`swipe_path` 序列**:`pointerDown(start) → move(p1) → move(p2) → … → move(end) → pointerUp`,总时长按 waypoint 数均分(或按段长加权)。 + +**`double_tap` 序列**:`pointerDown(x,y) → pointerUp → pause(interval_ms) → pointerDown(x,y) → pointerUp`,单次 HTTP 往返,`interval_ms` 完全可控。 + +为什么 `double_tap` 不用原生 `mobile: doubleTap`/`doubleClickGesture`:它们是固定间隔、一次性机器手势,最易被反检测识别;W3C 两 tap + 高斯抖动间隔反而更像人,且和 `swipe_path` 共用同一套基础设施。 + +### 4.4 应用点(humanize 开/关矩阵) + +| 动作 | humanize 开 | humanize 关 | +|---|---|---| +| `tap` | `jitter_point` 后调 `driver.tap` | 原样(零变化) | +| `swipe` | `swipe_waypoints` 后调 `driver.swipe_path` | 原样调 `driver.swipe` | +| `long_press` | `jitter_point`(+WDA `jitter_duration`) 后调 `driver.long_press` | 直接调 `driver.long_press` | +| `double_tap` | `jitter_point` + `jitter_interval` 后调 `driver.double_tap` | 直接调 `driver.double_tap`(仍走 W3C,但不抖动) | +| `input_text`/`launch_app`/`terminate_app` | 不动 | 不动 | + +关键保证:**humanize 关闭时,现有 `tap`/`swipe` 走与今天完全相同的 driver 方法与命令**,现有精确坐标断言零改动。`long_press`/`double_tap` 是新工具,无"原行为"可回归。 + +### 4.5 tool_specs 与 executor + +- `runtime/tool_specs.py` 新增 `LONG_PRESS_SPEC`、`DOUBLE_TAP_SPEC`,加入 `ACTION_TOOL_SPECS`(`ALL_TOOL_SPECS` 自动带上,prompt 的 tool 列表自动生效)。 +- `runtime/executor.py::default_tool_registry` 注册 `"long_press"`、`"double_tap"`。 + +### 4.6 planner prompt + +`runtime/planner_prompts.py:18-22` 那段工具枚举("You must then call exactly one tool: - One of `tap`, `swipe`, …")补 `long_press`、`double_tap`,并各加一句使用场景提示(长按用于长按菜单/拖拽预备;双击用于缩放/选中)。仍保持"每回合恰好一个工具"的单步约束不变(多步规划是另一条独立议题,本次不动)。 + +## 5. 配置(env) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `APEX_HUMANIZE_ENABLED` | `true` | 总开关;`false` 时所有抖动关闭,tap/swipe 走原路径 | +| `APEX_HUMANIZE_TAP_RADIUS_PX` | `5.0` | tap/long_press/double_tap 坐标高斯偏移半径(px) | +| `APEX_HUMANIZE_SWIPE_CURVATURE` | `0.15` | 滑动垂直噪声占路径长度比例 | +| `APEX_HUMANIZE_SWIPE_WAYPOINTS` | `8` | 滑动中间点数 | +| `APEX_HUMANIZE_DURATION_SPREAD` | `0.15` | 时长/间隔 ±比例 | + +未设置时全部走默认;任一缺失不影响其它。测试通过在 fixture 里设 `APEX_HUMANIZE_ENABLED=false`(或注入固定种子 rng)获得确定性。 + +## 6. 默认值 + +- `long_press.duration_ms = 1200` +- `double_tap.interval_ms = 80` +- `swipe.duration_ms = 500`(不变) + +## 7. 测试策略 + +1. **humanize 单测**(`tests/test_humanize.py`):注入 `random.Random(42)`,断言 `jitter_point` 偏移在半径内、`swipe_waypoints` 首末点等于端点且点数正确、中间点存在垂直偏移、`jitter_duration` 在 ±spread 内。 +2. **driver 新方法单测**(humanize 关闭):mock appium client,断言 `swipe_path` 发出的 W3C action 序列含 N 个 pointer_move、`double_tap` 序列含两段 pointerDown/Up + 一个 pause、`long_press` 调对正确 mobile 命令(WDA `touchAndHold` 带 duration、Android `longClickGesture`)。 +3. **tool 集成**:`tools/long_press.py`/`tools/double_tap.py` 在 fake driver 上跑通;`tools/swipe.py` 在 humanize 开启时断言调用的是 `swipe_path` 且 waypoints > 2,关闭时调用的是 `swipe`。 +4. **回归**:现有 `tap`/`swipe` 相关测试在 humanize 关闭下全部不变;在 `tests/test_executor.py` 补两个新动作的 dispatch。 +5. **spec 校验**:`runtime/tool_specs.py` 的新 spec 满足 `_action_parameters` 要求(含 `purpose`/`expected_outcome`)。 + +## 8. 风险 + +- **R1(中)**:W3C Actions 在某些 Appium/驱动版本上对 `pause` 的时长支持不一致。缓解:`double_tap` 的 interval 先用 `pause(duration_ms)`,实现期在真实 Appium 上验证;若不可靠,退化为两次 `mobile: tap` + `time.sleep`(记入 Open Questions)。 +- **R2(低)**:Android `longClickGesture` 时长不可控,与 WDA 行为不对称。接受:参数保留,Android 忽略并文档化。 +- **R3(低)**:W3C Actions 曲线滑动比原生 2 点命令慢(多点序列化 + 执行)。接受:反检测优先于延迟,且单次滑动仍是一次 HTTP 往返。 +- **R4(低)**:humanize 默认开可能让未设 env 的既有测试出现非确定坐标。缓解:现有 `tap`/`swipe` 测试若断言精确坐标,需确认它们跑在 `APEX_HUMANIZE_ENABLED=false` 下;实现期 grep 评估影响面,必要时在测试 conftest 默认关 humanize。 + +## 9. 开放问题(实现期核实) + +- OQ1:`mobile: touchAndHold`(WDA)与 `mobile: longClickGesture`(Android)的精确参数名/duration 单位——对照已安装 appium-python-client 核实。 +- OQ2:W3C `pause` action 在目标 Appium 版本是否可靠传递 interval——真机验证,必要时按 R1 回退。 +- OQ3:是否需要在 `Driver` 抽象为 `swipe_path`/`double_tap`/`long_press` 提供 fake/mock 默认实现(现有 `driver/registry.py` 不注册 mock driver,保持一致——只做抽象方法 + 两个真实实现 + 单测里的 mock)。