@qubiqlabs/mobiflow 0.9.0 → 1.0.0
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.
- package/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- package/src/mobiflow/suite.py +263 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Explore-then-generate: discovery LLM walks the app before codegen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mobiflow.llm import ChatUsage, invoke_chat_text, merge_usage_list, profile_to_llm_config
|
|
14
|
+
from mobiflow.llm_catalog import ModelEntry
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
ProgressFn = Callable[[str], None] | None
|
|
19
|
+
|
|
20
|
+
_JSON_FENCE_RE = re.compile(
|
|
21
|
+
r"```(?:json)?\s*\n(.*?)```",
|
|
22
|
+
re.DOTALL | re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_EXPLORE_SYSTEM = """You are a mobile QA explorer for Maestro automation.
|
|
26
|
+
You observe the current screen hierarchy and decide the next exploration step
|
|
27
|
+
toward the user's test goal. You do NOT write the final Maestro test yet.
|
|
28
|
+
|
|
29
|
+
Respond with ONLY a JSON object (optionally in a ```json fence) with keys:
|
|
30
|
+
{
|
|
31
|
+
"status": "continue" | "done",
|
|
32
|
+
"observation": "what you see on this screen that matters for the goal",
|
|
33
|
+
"screen": "short screen name/label",
|
|
34
|
+
"plan_so_far": ["ordered steps discovered so far toward the goal"],
|
|
35
|
+
"selectors": [{"label": "human name", "text": "visible text or id hint"}],
|
|
36
|
+
"next_action": null | {
|
|
37
|
+
"command": "tapOn|scroll|swipe|inputText|pressKey|scrollUntilVisible|waitForAnimationToEnd",
|
|
38
|
+
"text": "selector or value when needed",
|
|
39
|
+
"optional": false
|
|
40
|
+
},
|
|
41
|
+
"notes": "risks, onboarding to dismiss, assertions to make later"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
1) Prefer stable visible text selectors from the hierarchy.
|
|
46
|
+
2) If onboarding/dialogs block the goal, dismiss them first (Skip/Next/Continue/Allow/Not now).
|
|
47
|
+
3) status=done when you have enough grounded steps/selectors to author a full test.
|
|
48
|
+
4) next_action must be ONE atomic UI action when status=continue.
|
|
49
|
+
5) Never invent UI that is not supported by the hierarchy (unless launching/navigating obviously required).
|
|
50
|
+
6) Keep plan_so_far cumulative and specific.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ExploreAction:
|
|
56
|
+
command: str
|
|
57
|
+
text: str = ""
|
|
58
|
+
optional: bool = False
|
|
59
|
+
|
|
60
|
+
def to_maestro_lines(self) -> list[str]:
|
|
61
|
+
cmd = (self.command or "").strip()
|
|
62
|
+
if not cmd:
|
|
63
|
+
return []
|
|
64
|
+
# Normalize common aliases
|
|
65
|
+
aliases = {
|
|
66
|
+
"tap": "tapOn",
|
|
67
|
+
"click": "tapOn",
|
|
68
|
+
"type": "inputText",
|
|
69
|
+
"entertext": "inputText",
|
|
70
|
+
"press": "pressKey",
|
|
71
|
+
"scroll_until": "scrollUntilVisible",
|
|
72
|
+
"wait": "waitForAnimationToEnd",
|
|
73
|
+
}
|
|
74
|
+
cmd = aliases.get(cmd.lower().replace(" ", ""), cmd)
|
|
75
|
+
if cmd == "waitForAnimationToEnd":
|
|
76
|
+
return ["- waitForAnimationToEnd"]
|
|
77
|
+
if cmd == "scroll":
|
|
78
|
+
return ["- scroll"]
|
|
79
|
+
if cmd == "swipe":
|
|
80
|
+
return ["- swipe:", " direction: UP"]
|
|
81
|
+
if cmd == "pressKey":
|
|
82
|
+
key = self.text or "Enter"
|
|
83
|
+
return [f"- pressKey: {key}"]
|
|
84
|
+
if cmd == "inputText":
|
|
85
|
+
return [f'- inputText: "{_escape(self.text)}"']
|
|
86
|
+
if cmd == "scrollUntilVisible":
|
|
87
|
+
return [
|
|
88
|
+
"- scrollUntilVisible:",
|
|
89
|
+
f' text: "{_escape(self.text)}"',
|
|
90
|
+
" direction: DOWN",
|
|
91
|
+
]
|
|
92
|
+
# default tapOn
|
|
93
|
+
line = f'- tapOn: "{_escape(self.text)}"'
|
|
94
|
+
if self.optional:
|
|
95
|
+
return ["- tapOn:", f' text: "{_escape(self.text)}"', " optional: true"]
|
|
96
|
+
return [line]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ExploreStep:
|
|
101
|
+
index: int
|
|
102
|
+
screen: str = ""
|
|
103
|
+
observation: str = ""
|
|
104
|
+
action: ExploreAction | None = None
|
|
105
|
+
action_ok: bool | None = None
|
|
106
|
+
hierarchy_excerpt: str = ""
|
|
107
|
+
notes: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ExplorationResult:
|
|
112
|
+
goal: str
|
|
113
|
+
app_id: str
|
|
114
|
+
platform: str
|
|
115
|
+
steps: list[ExploreStep] = field(default_factory=list)
|
|
116
|
+
plan: list[str] = field(default_factory=list)
|
|
117
|
+
selectors: list[dict[str, str]] = field(default_factory=list)
|
|
118
|
+
notes: list[str] = field(default_factory=list)
|
|
119
|
+
final_hierarchy: str = ""
|
|
120
|
+
completed: bool = False
|
|
121
|
+
mode: str = "device" # device | plan_only | skipped
|
|
122
|
+
usage: ChatUsage = field(default_factory=ChatUsage)
|
|
123
|
+
|
|
124
|
+
def to_prompt_block(self) -> str:
|
|
125
|
+
"""Serialize for codegen LLM context."""
|
|
126
|
+
if self.mode == "skipped" or (not self.steps and not self.plan):
|
|
127
|
+
return ""
|
|
128
|
+
lines = [
|
|
129
|
+
"Exploration results (observe the app BEFORE writing YAML):",
|
|
130
|
+
f"mode: {self.mode}",
|
|
131
|
+
f"appId: {self.app_id}",
|
|
132
|
+
f"platform: {self.platform}",
|
|
133
|
+
]
|
|
134
|
+
if self.plan:
|
|
135
|
+
lines.append("Grounded plan:")
|
|
136
|
+
for i, step in enumerate(self.plan, 1):
|
|
137
|
+
lines.append(f" {i}. {step}")
|
|
138
|
+
if self.selectors:
|
|
139
|
+
lines.append("Observed selectors:")
|
|
140
|
+
for sel in self.selectors[:40]:
|
|
141
|
+
label = sel.get("label") or sel.get("text") or ""
|
|
142
|
+
text = sel.get("text") or ""
|
|
143
|
+
lines.append(f" - {label}: {text}".rstrip(": "))
|
|
144
|
+
if self.notes:
|
|
145
|
+
lines.append("Explorer notes:")
|
|
146
|
+
for n in self.notes[-12:]:
|
|
147
|
+
lines.append(f" - {n}")
|
|
148
|
+
if self.steps:
|
|
149
|
+
lines.append("Screen observations:")
|
|
150
|
+
for st in self.steps:
|
|
151
|
+
act = ""
|
|
152
|
+
if st.action:
|
|
153
|
+
act = f" → {st.action.command} {st.action.text}".rstrip()
|
|
154
|
+
if st.action_ok is not None:
|
|
155
|
+
act += " (ok)" if st.action_ok else " (failed)"
|
|
156
|
+
lines.append(
|
|
157
|
+
f" [{st.index}] {st.screen or 'screen'}: {st.observation}{act}"
|
|
158
|
+
)
|
|
159
|
+
if self.final_hierarchy.strip():
|
|
160
|
+
lines.append(
|
|
161
|
+
"Final hierarchy (truncated):\n" + self.final_hierarchy.strip()[:8000]
|
|
162
|
+
)
|
|
163
|
+
return "\n".join(lines)
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> dict[str, Any]:
|
|
166
|
+
return asdict(self)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _escape(text: str) -> str:
|
|
170
|
+
return (text or "").replace("\\", "\\\\").replace('"', '\\"')
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def extract_json_object(text: str) -> dict[str, Any]:
|
|
174
|
+
"""Best-effort parse of a JSON object from model output."""
|
|
175
|
+
raw = (text or "").strip()
|
|
176
|
+
if not raw:
|
|
177
|
+
return {}
|
|
178
|
+
m = _JSON_FENCE_RE.search(raw)
|
|
179
|
+
if m:
|
|
180
|
+
raw = m.group(1).strip()
|
|
181
|
+
# Find outermost {...}
|
|
182
|
+
start = raw.find("{")
|
|
183
|
+
end = raw.rfind("}")
|
|
184
|
+
if start >= 0 and end > start:
|
|
185
|
+
raw = raw[start : end + 1]
|
|
186
|
+
try:
|
|
187
|
+
data = json.loads(raw)
|
|
188
|
+
return data if isinstance(data, dict) else {}
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
# Trailing commas / light repair
|
|
191
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", raw)
|
|
192
|
+
try:
|
|
193
|
+
data = json.loads(repaired)
|
|
194
|
+
return data if isinstance(data, dict) else {}
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
logger.warning("Explore decision JSON parse failed")
|
|
197
|
+
return {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_explore_decision(text: str) -> dict[str, Any]:
|
|
201
|
+
data = extract_json_object(text)
|
|
202
|
+
status = str(data.get("status") or "done").strip().lower()
|
|
203
|
+
if status not in {"continue", "done"}:
|
|
204
|
+
status = "done"
|
|
205
|
+
action_raw = data.get("next_action")
|
|
206
|
+
action: ExploreAction | None = None
|
|
207
|
+
if isinstance(action_raw, dict) and status == "continue":
|
|
208
|
+
cmd = str(action_raw.get("command") or action_raw.get("type") or "").strip()
|
|
209
|
+
if cmd:
|
|
210
|
+
action = ExploreAction(
|
|
211
|
+
command=cmd,
|
|
212
|
+
text=str(action_raw.get("text") or action_raw.get("value") or ""),
|
|
213
|
+
optional=bool(action_raw.get("optional") or False),
|
|
214
|
+
)
|
|
215
|
+
plan = data.get("plan_so_far") or data.get("plan") or []
|
|
216
|
+
if not isinstance(plan, list):
|
|
217
|
+
plan = [str(plan)]
|
|
218
|
+
selectors = data.get("selectors") or []
|
|
219
|
+
if not isinstance(selectors, list):
|
|
220
|
+
selectors = []
|
|
221
|
+
norm_sels: list[dict[str, str]] = []
|
|
222
|
+
for sel in selectors:
|
|
223
|
+
if isinstance(sel, dict):
|
|
224
|
+
norm_sels.append(
|
|
225
|
+
{
|
|
226
|
+
"label": str(sel.get("label") or sel.get("name") or ""),
|
|
227
|
+
"text": str(sel.get("text") or sel.get("id") or ""),
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
elif isinstance(sel, str):
|
|
231
|
+
norm_sels.append({"label": sel, "text": sel})
|
|
232
|
+
return {
|
|
233
|
+
"status": status,
|
|
234
|
+
"observation": str(data.get("observation") or ""),
|
|
235
|
+
"screen": str(data.get("screen") or ""),
|
|
236
|
+
"plan": [str(p) for p in plan if str(p).strip()],
|
|
237
|
+
"selectors": norm_sels,
|
|
238
|
+
"action": action,
|
|
239
|
+
"notes": str(data.get("notes") or ""),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_action_flow_yaml(app_id: str, action: ExploreAction, *, launch: bool = False) -> str:
|
|
244
|
+
lines = [f"appId: {app_id}", "name: MobiFlow explore step", "---"]
|
|
245
|
+
if launch:
|
|
246
|
+
lines.append("- launchApp")
|
|
247
|
+
lines.extend(action.to_maestro_lines())
|
|
248
|
+
return "\n".join(lines) + "\n"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_launch_flow_yaml(app_id: str) -> str:
|
|
252
|
+
return (
|
|
253
|
+
f"appId: {app_id}\n"
|
|
254
|
+
f"name: MobiFlow explore launch\n"
|
|
255
|
+
f"---\n"
|
|
256
|
+
f"- launchApp\n"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def summarize_hierarchy(hierarchy: str, *, limit: int = 3500) -> str:
|
|
261
|
+
text = (hierarchy or "").strip()
|
|
262
|
+
if len(text) <= limit:
|
|
263
|
+
return text
|
|
264
|
+
return text[:limit] + "\n…(truncated)"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def decide_explore_step(
|
|
268
|
+
*,
|
|
269
|
+
goal: str,
|
|
270
|
+
app_id: str,
|
|
271
|
+
platform: str,
|
|
272
|
+
hierarchy: str,
|
|
273
|
+
plan: list[str],
|
|
274
|
+
history: list[str],
|
|
275
|
+
profile: ModelEntry,
|
|
276
|
+
step_index: int,
|
|
277
|
+
max_steps: int,
|
|
278
|
+
usage_out: list[ChatUsage] | None = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
llm_config = profile_to_llm_config(profile)
|
|
281
|
+
user = "\n\n".join(
|
|
282
|
+
[
|
|
283
|
+
f"Platform: {platform}",
|
|
284
|
+
f"App ID: {app_id}",
|
|
285
|
+
f"Explore step: {step_index}/{max_steps}",
|
|
286
|
+
f"Goal:\n{goal}",
|
|
287
|
+
"Plan so far:\n"
|
|
288
|
+
+ ("\n".join(f"- {p}" for p in plan) if plan else "- (empty)"),
|
|
289
|
+
"Recent history:\n"
|
|
290
|
+
+ ("\n".join(f"- {h}" for h in history[-8:]) if history else "- (none)"),
|
|
291
|
+
"Current hierarchy (truncated):\n" + summarize_hierarchy(hierarchy),
|
|
292
|
+
"Return the JSON decision now.",
|
|
293
|
+
]
|
|
294
|
+
)
|
|
295
|
+
text = await asyncio.to_thread(
|
|
296
|
+
invoke_chat_text,
|
|
297
|
+
_EXPLORE_SYSTEM,
|
|
298
|
+
user,
|
|
299
|
+
llm_config,
|
|
300
|
+
max_tokens=2048,
|
|
301
|
+
temperature=0.2,
|
|
302
|
+
log_prefix="MobiFlowExplore",
|
|
303
|
+
usage_out=usage_out,
|
|
304
|
+
)
|
|
305
|
+
return parse_explore_decision(text or "")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def plan_only_explore(
|
|
309
|
+
*,
|
|
310
|
+
goal: str,
|
|
311
|
+
app_id: str,
|
|
312
|
+
platform: str,
|
|
313
|
+
profile: ModelEntry,
|
|
314
|
+
progress: ProgressFn = None,
|
|
315
|
+
) -> ExplorationResult:
|
|
316
|
+
"""No device: ask discovery LLM for a grounded plan from the goal alone."""
|
|
317
|
+
if progress:
|
|
318
|
+
progress("Explore (plan-only — no live device hierarchy)…")
|
|
319
|
+
llm_config = profile_to_llm_config(profile)
|
|
320
|
+
system = (
|
|
321
|
+
_EXPLORE_SYSTEM
|
|
322
|
+
+ "\nNo hierarchy is available. Set status=done and produce the best "
|
|
323
|
+
"plan_so_far + likely selectors from the goal. next_action must be null."
|
|
324
|
+
)
|
|
325
|
+
user = (
|
|
326
|
+
f"Platform: {platform}\nApp ID: {app_id}\nGoal:\n{goal}\n"
|
|
327
|
+
"Return JSON with status=done, plan_so_far, selectors, notes."
|
|
328
|
+
)
|
|
329
|
+
usage_bucket: list[ChatUsage] = []
|
|
330
|
+
text = await asyncio.to_thread(
|
|
331
|
+
invoke_chat_text,
|
|
332
|
+
system,
|
|
333
|
+
user,
|
|
334
|
+
llm_config,
|
|
335
|
+
max_tokens=2048,
|
|
336
|
+
temperature=0.2,
|
|
337
|
+
log_prefix="MobiFlowExplore",
|
|
338
|
+
usage_out=usage_bucket,
|
|
339
|
+
)
|
|
340
|
+
decision = parse_explore_decision(text or "")
|
|
341
|
+
result = ExplorationResult(
|
|
342
|
+
goal=goal,
|
|
343
|
+
app_id=app_id,
|
|
344
|
+
platform=platform,
|
|
345
|
+
plan=decision.get("plan") or [],
|
|
346
|
+
selectors=decision.get("selectors") or [],
|
|
347
|
+
notes=[decision["notes"]] if decision.get("notes") else [],
|
|
348
|
+
completed=True,
|
|
349
|
+
mode="plan_only",
|
|
350
|
+
usage=merge_usage_list(usage_bucket),
|
|
351
|
+
)
|
|
352
|
+
if decision.get("observation"):
|
|
353
|
+
result.steps.append(
|
|
354
|
+
ExploreStep(
|
|
355
|
+
index=1,
|
|
356
|
+
screen=decision.get("screen") or "planned",
|
|
357
|
+
observation=decision.get("observation") or "",
|
|
358
|
+
notes=decision.get("notes") or "",
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Interactive decision: accept | skip | edit | done | quit
|
|
365
|
+
AskFn = Callable[[dict[str, Any]], dict[str, Any]]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def explore_app(
|
|
369
|
+
goal: str,
|
|
370
|
+
*,
|
|
371
|
+
app_id: str,
|
|
372
|
+
platform: str,
|
|
373
|
+
device_id: str,
|
|
374
|
+
profile: ModelEntry,
|
|
375
|
+
max_steps: int = 5,
|
|
376
|
+
step_timeout_s: int = 90,
|
|
377
|
+
progress: ProgressFn = None,
|
|
378
|
+
interactive: bool = False,
|
|
379
|
+
ask: AskFn | None = None,
|
|
380
|
+
) -> ExplorationResult:
|
|
381
|
+
"""Live explore loop: hierarchy → discovery decision → optional nav action.
|
|
382
|
+
|
|
383
|
+
When ``interactive=True``, each proposed action is confirmed via ``ask``
|
|
384
|
+
(or a default stdin prompt). Choices: accept | skip | edit | done | quit.
|
|
385
|
+
This is a separate operator-driven mode — not Maestro Studio.
|
|
386
|
+
"""
|
|
387
|
+
from mobiflow.maestro import fetch_hierarchy, resolve_app_id, run_flow_yaml
|
|
388
|
+
|
|
389
|
+
resolved = resolve_app_id(app_id, platform, goal)
|
|
390
|
+
result = ExplorationResult(
|
|
391
|
+
goal=goal,
|
|
392
|
+
app_id=resolved,
|
|
393
|
+
platform=platform,
|
|
394
|
+
mode="interactive" if interactive else "device",
|
|
395
|
+
)
|
|
396
|
+
history: list[str] = []
|
|
397
|
+
usage_bucket: list[ChatUsage] = []
|
|
398
|
+
max_steps = max(1, min(int(max_steps or 5), 12))
|
|
399
|
+
ask_fn = ask or (default_interactive_ask if interactive else None)
|
|
400
|
+
|
|
401
|
+
if progress:
|
|
402
|
+
progress(f"Explore: launching {resolved}…")
|
|
403
|
+
launch = await run_flow_yaml(
|
|
404
|
+
build_launch_flow_yaml(resolved),
|
|
405
|
+
device_id=device_id,
|
|
406
|
+
timeout_s=step_timeout_s,
|
|
407
|
+
)
|
|
408
|
+
history.append(
|
|
409
|
+
"launchApp " + ("ok" if launch.get("ok") else f"failed:{launch.get('error')}")
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
for i in range(1, max_steps + 1):
|
|
413
|
+
if progress:
|
|
414
|
+
progress(f"Explore step {i}/{max_steps}: reading hierarchy…")
|
|
415
|
+
hierarchy = await fetch_hierarchy(device_id)
|
|
416
|
+
result.final_hierarchy = hierarchy
|
|
417
|
+
decision = await decide_explore_step(
|
|
418
|
+
goal=goal,
|
|
419
|
+
app_id=resolved,
|
|
420
|
+
platform=platform,
|
|
421
|
+
hierarchy=hierarchy,
|
|
422
|
+
plan=result.plan,
|
|
423
|
+
history=history,
|
|
424
|
+
profile=profile,
|
|
425
|
+
step_index=i,
|
|
426
|
+
max_steps=max_steps,
|
|
427
|
+
usage_out=usage_bucket,
|
|
428
|
+
)
|
|
429
|
+
if decision.get("plan"):
|
|
430
|
+
result.plan = decision["plan"]
|
|
431
|
+
if decision.get("selectors"):
|
|
432
|
+
# merge unique selectors
|
|
433
|
+
seen = {(s.get("label"), s.get("text")) for s in result.selectors}
|
|
434
|
+
for sel in decision["selectors"]:
|
|
435
|
+
key = (sel.get("label"), sel.get("text"))
|
|
436
|
+
if key not in seen and (sel.get("text") or sel.get("label")):
|
|
437
|
+
result.selectors.append(sel)
|
|
438
|
+
seen.add(key)
|
|
439
|
+
if decision.get("notes"):
|
|
440
|
+
result.notes.append(decision["notes"])
|
|
441
|
+
|
|
442
|
+
step = ExploreStep(
|
|
443
|
+
index=i,
|
|
444
|
+
screen=decision.get("screen") or "",
|
|
445
|
+
observation=decision.get("observation") or "",
|
|
446
|
+
hierarchy_excerpt=summarize_hierarchy(hierarchy, limit=1200),
|
|
447
|
+
notes=decision.get("notes") or "",
|
|
448
|
+
action=decision.get("action"),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Model thinks we're done
|
|
452
|
+
if decision.get("status") == "done" or not decision.get("action"):
|
|
453
|
+
if interactive and ask_fn is not None and decision.get("status") == "done":
|
|
454
|
+
choice = ask_fn(
|
|
455
|
+
{
|
|
456
|
+
"kind": "done_proposal",
|
|
457
|
+
"step": i,
|
|
458
|
+
"max_steps": max_steps,
|
|
459
|
+
"decision": decision,
|
|
460
|
+
"hierarchy_excerpt": step.hierarchy_excerpt,
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
selected = str(choice.get("choice") or "done").lower()
|
|
464
|
+
if selected == "quit":
|
|
465
|
+
result.notes.append("interactive: quit by operator")
|
|
466
|
+
result.steps.append(step)
|
|
467
|
+
break
|
|
468
|
+
if selected == "skip":
|
|
469
|
+
# Operator wants to keep exploring despite model done
|
|
470
|
+
history.append("operator: continue after model done")
|
|
471
|
+
step.action = None
|
|
472
|
+
result.steps.append(step)
|
|
473
|
+
continue
|
|
474
|
+
step.action = None
|
|
475
|
+
result.steps.append(step)
|
|
476
|
+
result.completed = True
|
|
477
|
+
if progress:
|
|
478
|
+
progress("Explore complete — enough context for codegen.")
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
action: ExploreAction = decision["action"]
|
|
482
|
+
|
|
483
|
+
if interactive and ask_fn is not None:
|
|
484
|
+
choice = ask_fn(
|
|
485
|
+
{
|
|
486
|
+
"kind": "action_proposal",
|
|
487
|
+
"step": i,
|
|
488
|
+
"max_steps": max_steps,
|
|
489
|
+
"decision": decision,
|
|
490
|
+
"action": action,
|
|
491
|
+
"hierarchy_excerpt": step.hierarchy_excerpt,
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
selected = str(choice.get("choice") or "accept").lower()
|
|
495
|
+
if selected == "quit":
|
|
496
|
+
result.notes.append("interactive: quit by operator")
|
|
497
|
+
result.steps.append(step)
|
|
498
|
+
break
|
|
499
|
+
if selected == "done":
|
|
500
|
+
step.action = None
|
|
501
|
+
result.steps.append(step)
|
|
502
|
+
result.completed = True
|
|
503
|
+
if progress:
|
|
504
|
+
progress("Explore stopped by operator — ready for codegen.")
|
|
505
|
+
break
|
|
506
|
+
if selected == "skip":
|
|
507
|
+
history.append(
|
|
508
|
+
f"operator skipped {action.command}:{action.text}"
|
|
509
|
+
)
|
|
510
|
+
step.action = None
|
|
511
|
+
step.notes = (step.notes + " | skipped by operator").strip(" |")
|
|
512
|
+
result.steps.append(step)
|
|
513
|
+
continue
|
|
514
|
+
if selected == "edit":
|
|
515
|
+
edited = choice.get("action") or {}
|
|
516
|
+
action = ExploreAction(
|
|
517
|
+
command=str(edited.get("command") or action.command),
|
|
518
|
+
text=str(edited.get("text") if "text" in edited else action.text),
|
|
519
|
+
optional=bool(edited.get("optional", action.optional)),
|
|
520
|
+
)
|
|
521
|
+
step.action = action
|
|
522
|
+
|
|
523
|
+
if progress:
|
|
524
|
+
progress(
|
|
525
|
+
f"Explore step {i}/{max_steps}: {action.command} "
|
|
526
|
+
f"{action.text or ''}".rstrip()
|
|
527
|
+
)
|
|
528
|
+
flow = build_action_flow_yaml(resolved, action, launch=False)
|
|
529
|
+
run = await run_flow_yaml(
|
|
530
|
+
flow,
|
|
531
|
+
device_id=device_id,
|
|
532
|
+
timeout_s=step_timeout_s,
|
|
533
|
+
)
|
|
534
|
+
step.action_ok = bool(run.get("ok"))
|
|
535
|
+
history.append(
|
|
536
|
+
f"{action.command}:{action.text} -> "
|
|
537
|
+
+ ("ok" if step.action_ok else f"fail:{run.get('error')}")
|
|
538
|
+
)
|
|
539
|
+
result.steps.append(step)
|
|
540
|
+
else:
|
|
541
|
+
result.completed = bool(result.plan or result.selectors)
|
|
542
|
+
|
|
543
|
+
if not result.plan and result.steps:
|
|
544
|
+
# Derive a minimal plan from observations
|
|
545
|
+
result.plan = [
|
|
546
|
+
s.observation
|
|
547
|
+
for s in result.steps
|
|
548
|
+
if s.observation
|
|
549
|
+
][:8]
|
|
550
|
+
result.usage = merge_usage_list(usage_bucket)
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def default_interactive_ask(payload: dict[str, Any]) -> dict[str, Any]:
|
|
555
|
+
"""Stdin/questionary prompt used by ``mobiflow explore --interactive``."""
|
|
556
|
+
import questionary
|
|
557
|
+
|
|
558
|
+
kind = payload.get("kind")
|
|
559
|
+
decision = payload.get("decision") or {}
|
|
560
|
+
step = payload.get("step")
|
|
561
|
+
max_steps = payload.get("max_steps")
|
|
562
|
+
print()
|
|
563
|
+
print(f"— Explore {step}/{max_steps} —")
|
|
564
|
+
if decision.get("screen"):
|
|
565
|
+
print(f"Screen: {decision.get('screen')}")
|
|
566
|
+
if decision.get("observation"):
|
|
567
|
+
print(f"See: {decision.get('observation')}")
|
|
568
|
+
if decision.get("notes"):
|
|
569
|
+
print(f"Notes: {decision.get('notes')}")
|
|
570
|
+
plan = decision.get("plan") or []
|
|
571
|
+
if plan:
|
|
572
|
+
print("Plan: " + " → ".join(plan[-4:]))
|
|
573
|
+
|
|
574
|
+
if kind == "done_proposal":
|
|
575
|
+
choice = questionary.select(
|
|
576
|
+
"Discovery thinks exploration is complete. What next?",
|
|
577
|
+
choices=[
|
|
578
|
+
questionary.Choice("Finish explore (use plan for codegen)", value="done"),
|
|
579
|
+
questionary.Choice("Keep exploring", value="skip"),
|
|
580
|
+
questionary.Choice("Quit", value="quit"),
|
|
581
|
+
],
|
|
582
|
+
default="done",
|
|
583
|
+
).ask()
|
|
584
|
+
return {"choice": choice or "done"}
|
|
585
|
+
|
|
586
|
+
action: ExploreAction | None = payload.get("action")
|
|
587
|
+
label = (
|
|
588
|
+
f"{action.command} {action.text}".strip()
|
|
589
|
+
if action
|
|
590
|
+
else "(no action)"
|
|
591
|
+
)
|
|
592
|
+
choice = questionary.select(
|
|
593
|
+
f"Proposed action: {label}",
|
|
594
|
+
choices=[
|
|
595
|
+
questionary.Choice("Accept & run on device", value="accept"),
|
|
596
|
+
questionary.Choice("Edit action text, then run", value="edit"),
|
|
597
|
+
questionary.Choice("Skip this action", value="skip"),
|
|
598
|
+
questionary.Choice("Finish explore now", value="done"),
|
|
599
|
+
questionary.Choice("Quit", value="quit"),
|
|
600
|
+
],
|
|
601
|
+
default="accept",
|
|
602
|
+
).ask()
|
|
603
|
+
if not choice:
|
|
604
|
+
return {"choice": "quit"}
|
|
605
|
+
if choice != "edit" or action is None:
|
|
606
|
+
return {"choice": choice}
|
|
607
|
+
|
|
608
|
+
new_cmd = questionary.text(
|
|
609
|
+
"Command (tapOn/scroll/inputText/pressKey/…):",
|
|
610
|
+
default=action.command,
|
|
611
|
+
).ask()
|
|
612
|
+
new_text = questionary.text(
|
|
613
|
+
"Text / selector / value:",
|
|
614
|
+
default=action.text or "",
|
|
615
|
+
).ask()
|
|
616
|
+
return {
|
|
617
|
+
"choice": "edit",
|
|
618
|
+
"action": {
|
|
619
|
+
"command": (new_cmd or action.command).strip(),
|
|
620
|
+
"text": (new_text if new_text is not None else action.text),
|
|
621
|
+
"optional": action.optional,
|
|
622
|
+
},
|
|
623
|
+
}
|