captchakraken 2.1.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 +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- package/scripts/setup-python.js +104 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ActionPlanner (v2) — talks to local vLLM serving the `captcha` LoRA over the
|
|
3
|
+
OpenAI-compatible /v1/chat/completions endpoint.
|
|
4
|
+
|
|
5
|
+
v1 supported transformers/vllm-local/gemini/openrouter and a whole tool-using
|
|
6
|
+
planner with detect/segment/drag-refine; that code is preserved on the
|
|
7
|
+
`v1-old-architecture` branch.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from mimetypes import guess_type
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
from .server_manager import ensure_server
|
|
21
|
+
from .timing import timed
|
|
22
|
+
|
|
23
|
+
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Matches the training-distribution grid prompts in
|
|
27
|
+
# cleanSamples/test/test_solutions.json -> grade.synthesize_instruction.
|
|
28
|
+
# Drifting from these costs measurable accuracy.
|
|
29
|
+
SELECT_GRID_PROMPT = """Solve the captcha grid by choosing the cell numbers that match the description from the captcha image prompt.
|
|
30
|
+
|
|
31
|
+
Grid: {rows}x{cols} ({total} cells)
|
|
32
|
+
{grid_hint}
|
|
33
|
+
|
|
34
|
+
If no tiles match the description (e.g., they have all been cleared or none were present), return an empty list for target_ids: [].
|
|
35
|
+
|
|
36
|
+
Return JSON Array: [list of cell numbers (1-{total})]"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Non-grid click/drag puzzles. MUST stay byte-identical to
|
|
40
|
+
# src/synthetic/reasoning/instructions.py::ACTION_INSTRUCTION in the finetune
|
|
41
|
+
# repo — that is the exact prompt the LoRA was trained (and graded) on. Drift
|
|
42
|
+
# here silently degrades every click/drag puzzle. Coordinates come back on a
|
|
43
|
+
# 0–1000 scale; the solver converts them to 0–1 bboxes.
|
|
44
|
+
PIXEL_ACTION_PROMPT = (
|
|
45
|
+
"Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
|
|
46
|
+
"Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
|
|
47
|
+
"normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
|
|
48
|
+
"Choose ONE response:\n\n"
|
|
49
|
+
"FOR CLICK PUZZLES:\n"
|
|
50
|
+
" Identify every position you need to click and emit them as a list of points:\n"
|
|
51
|
+
" → \"action\": { \"action\": \"click\", \"points\": [[x1, y1], [x2, y2], ...] }\n\n"
|
|
52
|
+
"FOR DRAG PUZZLES:\n"
|
|
53
|
+
" Drag ONE item at a time. The source position is the centroid of the piece you are picking up; "
|
|
54
|
+
"the destination position is where it should end up. If multiple drags are needed, drag the topmost "
|
|
55
|
+
"item first.\n"
|
|
56
|
+
" → \"output\": [{ \"Action\": \"simulate_drag\", "
|
|
57
|
+
"\"SourceDescription\": \"...\", \"SourcePosition\": { \"x\": 1-1000, \"y\": 1-1000 }, "
|
|
58
|
+
"\"DestinationDescription\": \"...\", \"EstimatedPosition\": { \"x\": 1-1000, \"y\": 1-1000 } }]\n\n"
|
|
59
|
+
"Respond ONLY with JSON:\n"
|
|
60
|
+
"{\n"
|
|
61
|
+
" \"action\": { ... }\n"
|
|
62
|
+
" // OR \"output\": [ ... ]\n"
|
|
63
|
+
"}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ActionPlanner:
|
|
68
|
+
"""Thin client for the vLLM `captcha` LoRA."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
model: Optional[str] = None,
|
|
73
|
+
debug_callback: Optional[Any] = None,
|
|
74
|
+
base_url: Optional[str] = None,
|
|
75
|
+
api_key: Optional[str] = None,
|
|
76
|
+
**_: Any,
|
|
77
|
+
):
|
|
78
|
+
self.debug_callback = debug_callback
|
|
79
|
+
self.token_usage: List[Dict[str, Any]] = []
|
|
80
|
+
|
|
81
|
+
# All model/endpoint defaults come from `config` (env-overridable), so
|
|
82
|
+
# the planner itself is model-agnostic — swap models via env, not code.
|
|
83
|
+
self.model = model or config.lora_name()
|
|
84
|
+
self.base_url = base_url or config.base_url()
|
|
85
|
+
self.api_key = api_key or config.api_key()
|
|
86
|
+
# Auto-start a local vLLM server on the first request if one isn't up
|
|
87
|
+
# (no-op for a healthy or remote endpoint). Guarded so we only try once.
|
|
88
|
+
self._server_ensured = False
|
|
89
|
+
|
|
90
|
+
def _log(self, message: str) -> None:
|
|
91
|
+
if DEBUG:
|
|
92
|
+
print(f"[Planner] {message}", file=sys.stderr)
|
|
93
|
+
if self.debug_callback:
|
|
94
|
+
self.debug_callback(f"[Planner] {message}")
|
|
95
|
+
|
|
96
|
+
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
|
|
97
|
+
with open(image_path, "rb") as f:
|
|
98
|
+
b64 = base64.b64encode(f.read()).decode()
|
|
99
|
+
mime, _ = guess_type(image_path)
|
|
100
|
+
if mime is None:
|
|
101
|
+
mime = "image/png"
|
|
102
|
+
|
|
103
|
+
messages = [
|
|
104
|
+
{
|
|
105
|
+
"role": "system",
|
|
106
|
+
"content": "You are an expert captcha solver. Respond ONLY with the JSON action.",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"role": "user",
|
|
110
|
+
"content": [
|
|
111
|
+
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
|
|
112
|
+
{"type": "text", "text": prompt},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
payload = {
|
|
118
|
+
"model": self.model,
|
|
119
|
+
"messages": messages,
|
|
120
|
+
"temperature": 0,
|
|
121
|
+
"max_tokens": max_tokens,
|
|
122
|
+
# Qwen3.5's reasoning otherwise eats the token budget. `/no_think`
|
|
123
|
+
# in the prompt alone is unreliable; disabling at the chat-template
|
|
124
|
+
# level is the documented way.
|
|
125
|
+
"chat_template_kwargs": {"enable_thinking": False},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
headers = {
|
|
129
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Hands-off server: before the very first request, make sure something
|
|
134
|
+
# is listening (auto-start a local vLLM if needed; no-op if it's already
|
|
135
|
+
# up or the endpoint is remote). Best-effort — a failure here surfaces as
|
|
136
|
+
# a normal connection error on the request below.
|
|
137
|
+
if not self._server_ensured:
|
|
138
|
+
try:
|
|
139
|
+
ensure_server(self.base_url)
|
|
140
|
+
except Exception as e: # noqa: BLE001 — don't mask the real request error
|
|
141
|
+
self._log(f"ensure_server: {e}")
|
|
142
|
+
self._server_ensured = True
|
|
143
|
+
|
|
144
|
+
url = f"{self.base_url}/chat/completions"
|
|
145
|
+
self._log(f"POST {url} model={self.model} max_tokens={max_tokens}")
|
|
146
|
+
|
|
147
|
+
with timed("planner.chat"):
|
|
148
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=120)
|
|
149
|
+
|
|
150
|
+
# Surface auth / server errors with the actual body instead of letting
|
|
151
|
+
# resp.json() blow up with a cryptic "Expecting value: line 1 column 1"
|
|
152
|
+
# on a non-JSON response (e.g. a 401 {"error":"Unauthorized"} or an
|
|
153
|
+
# HTML error page). 401/403 almost always means the bearer token
|
|
154
|
+
# (CAPTCHA_KRAKEN_API_KEY) didn't reach this process.
|
|
155
|
+
if not resp.ok:
|
|
156
|
+
body = (resp.text or "")[:300]
|
|
157
|
+
hint = ""
|
|
158
|
+
if resp.status_code in (401, 403):
|
|
159
|
+
hint = (
|
|
160
|
+
" — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
|
|
161
|
+
)
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"vLLM {resp.status_code} {resp.reason} at {url}{hint}. Body: {body}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
data = resp.json()
|
|
168
|
+
except ValueError:
|
|
169
|
+
body = (resp.text or "")[:300]
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"vLLM returned a non-JSON body from {url} (is the server up and "
|
|
172
|
+
f"is VLLM_BASE_URL correct?). Body: {body}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if data.get("usage"):
|
|
176
|
+
self.token_usage.append(data["usage"])
|
|
177
|
+
|
|
178
|
+
content = data["choices"][0]["message"].get("content") or ""
|
|
179
|
+
self._log(f"Raw content: {content[:300]}")
|
|
180
|
+
return content
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _parse_json(text: str) -> Any:
|
|
184
|
+
text = (text or "").strip()
|
|
185
|
+
if "```json" in text:
|
|
186
|
+
text = text.split("```json", 1)[1].split("```", 1)[0]
|
|
187
|
+
elif "```" in text:
|
|
188
|
+
text = text.split("```", 1)[1].split("```", 1)[0]
|
|
189
|
+
|
|
190
|
+
start_obj = text.find("{")
|
|
191
|
+
start_list = text.find("[")
|
|
192
|
+
if start_list != -1 and (start_obj == -1 or start_list < start_obj):
|
|
193
|
+
start = start_list
|
|
194
|
+
end = text.rfind("]") + 1
|
|
195
|
+
elif start_obj != -1:
|
|
196
|
+
start = start_obj
|
|
197
|
+
end = text.rfind("}") + 1
|
|
198
|
+
else:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
return json.loads(text[start:end])
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
# The model sometimes truncates (hit max_tokens) or over-nests its
|
|
205
|
+
# pretty-printed JSON, leaving brackets unclosed. Repair by balancing
|
|
206
|
+
# from the first opener to the end of the raw text.
|
|
207
|
+
repaired = ActionPlanner._balance_json(text[start:])
|
|
208
|
+
if repaired is not None:
|
|
209
|
+
try:
|
|
210
|
+
return json.loads(repaired)
|
|
211
|
+
except json.JSONDecodeError:
|
|
212
|
+
return None
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _balance_json(text: str) -> Optional[str]:
|
|
217
|
+
"""Close any brackets/braces the model left open (string-aware), so a
|
|
218
|
+
truncated ``{"action": {"points": [[1,2],`` still parses. Returns None if
|
|
219
|
+
there was nothing to balance."""
|
|
220
|
+
stack: List[str] = []
|
|
221
|
+
in_str = False
|
|
222
|
+
esc = False
|
|
223
|
+
for ch in text:
|
|
224
|
+
if in_str:
|
|
225
|
+
if esc:
|
|
226
|
+
esc = False
|
|
227
|
+
elif ch == "\\":
|
|
228
|
+
esc = True
|
|
229
|
+
elif ch == '"':
|
|
230
|
+
in_str = False
|
|
231
|
+
continue
|
|
232
|
+
if ch == '"':
|
|
233
|
+
in_str = True
|
|
234
|
+
elif ch in "{[":
|
|
235
|
+
stack.append(ch)
|
|
236
|
+
elif ch == "}":
|
|
237
|
+
if stack and stack[-1] == "{":
|
|
238
|
+
stack.pop()
|
|
239
|
+
elif ch == "]":
|
|
240
|
+
if stack and stack[-1] == "[":
|
|
241
|
+
stack.pop()
|
|
242
|
+
if not stack and not in_str:
|
|
243
|
+
return None
|
|
244
|
+
out = text
|
|
245
|
+
if in_str:
|
|
246
|
+
out += '"'
|
|
247
|
+
# Drop a dangling comma / partial token before the closers.
|
|
248
|
+
out = out.rstrip().rstrip(",")
|
|
249
|
+
for opener in reversed(stack):
|
|
250
|
+
out += "}" if opener == "{" else "]"
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
def get_grid_selection(
|
|
254
|
+
self,
|
|
255
|
+
image_path: str,
|
|
256
|
+
rows: int,
|
|
257
|
+
cols: int,
|
|
258
|
+
retry_mode: Optional[str] = None,
|
|
259
|
+
) -> List[int]:
|
|
260
|
+
"""Return the list of 1-indexed cells the model wants to click.
|
|
261
|
+
|
|
262
|
+
retry_mode == "missed-tiles": the previous submission was rejected
|
|
263
|
+
by the captcha vendor with an under-selection error. Append an
|
|
264
|
+
explicit recovery instruction that tells the model the FULL grid
|
|
265
|
+
contains at least one matching tile it didn't pick last time. This
|
|
266
|
+
nudges it off the "I already covered everything" attractor.
|
|
267
|
+
"""
|
|
268
|
+
total = rows * cols
|
|
269
|
+
if rows == 4 and cols == 4:
|
|
270
|
+
grid_hint = "Hint: Single large image split into tiles. Select ALL parts."
|
|
271
|
+
else:
|
|
272
|
+
grid_hint = "Hint: Separate images. Select only clear matches."
|
|
273
|
+
|
|
274
|
+
prompt = SELECT_GRID_PROMPT.format(
|
|
275
|
+
rows=rows, cols=cols, total=total, grid_hint=grid_hint
|
|
276
|
+
)
|
|
277
|
+
if retry_mode == "missed-tiles":
|
|
278
|
+
prompt = (
|
|
279
|
+
prompt
|
|
280
|
+
+ "\n\nIMPORTANT: A previous submission was rejected because not all "
|
|
281
|
+
"matching tiles were selected. Re-examine EVERY cell in the grid "
|
|
282
|
+
"carefully. There is at least one more matching tile you missed. "
|
|
283
|
+
"Return the complete list of cell numbers that match the description, "
|
|
284
|
+
"including any matches you may have overlooked."
|
|
285
|
+
)
|
|
286
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=128)
|
|
287
|
+
data = self._parse_json(raw)
|
|
288
|
+
|
|
289
|
+
if isinstance(data, list):
|
|
290
|
+
ids = data
|
|
291
|
+
elif isinstance(data, dict):
|
|
292
|
+
ids = data.get("target_ids") or data.get("action", {}).get("target_ids") or []
|
|
293
|
+
else:
|
|
294
|
+
ids = []
|
|
295
|
+
|
|
296
|
+
out: List[int] = []
|
|
297
|
+
for v in ids:
|
|
298
|
+
try:
|
|
299
|
+
iv = int(v)
|
|
300
|
+
if 1 <= iv <= total:
|
|
301
|
+
out.append(iv)
|
|
302
|
+
except (TypeError, ValueError):
|
|
303
|
+
continue
|
|
304
|
+
self._log(f"grid selection -> {out}")
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
|
|
308
|
+
"""Solve a non-grid click/drag puzzle.
|
|
309
|
+
|
|
310
|
+
Sends the trained action prompt + image, parses the model's JSON, and
|
|
311
|
+
returns a list of normalized actions with all coordinates on a 0–1
|
|
312
|
+
scale:
|
|
313
|
+
|
|
314
|
+
{"kind": "click", "points": [(x, y), ...]}
|
|
315
|
+
{"kind": "drag", "src": (x, y), "dst": (x, y)}
|
|
316
|
+
|
|
317
|
+
Returns [] if the model produced nothing usable. The solver turns these
|
|
318
|
+
into ClickAction / DragAction bboxes.
|
|
319
|
+
"""
|
|
320
|
+
raw = self._chat_with_image(PIXEL_ACTION_PROMPT, image_path, max_tokens=512)
|
|
321
|
+
data = self._parse_json(raw)
|
|
322
|
+
actions = self._normalize_pixel(data)
|
|
323
|
+
self._log(f"pixel actions -> {actions}")
|
|
324
|
+
return actions
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
|
|
328
|
+
"""Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
|
|
329
|
+
|
|
330
|
+
Tolerant of the two trained shapes plus a few near-misses:
|
|
331
|
+
click: {"action": {"action": "click", "points": [[x, y], ...]}}
|
|
332
|
+
or top-level {"points": [...]} / {"action": {"points": [...]}}
|
|
333
|
+
drag: {"output": [{"Action": "simulate_drag",
|
|
334
|
+
"SourcePosition": {x, y},
|
|
335
|
+
"EstimatedPosition": {x, y}}, ...]}
|
|
336
|
+
"""
|
|
337
|
+
def norm_xy(x: Any, y: Any) -> Optional[tuple]:
|
|
338
|
+
try:
|
|
339
|
+
fx, fy = float(x) / 1000.0, float(y) / 1000.0
|
|
340
|
+
except (TypeError, ValueError):
|
|
341
|
+
return None
|
|
342
|
+
if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
|
|
343
|
+
# Some outputs use 0–1 already; accept those too.
|
|
344
|
+
if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
|
|
345
|
+
fx, fy = float(x), float(y)
|
|
346
|
+
else:
|
|
347
|
+
fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
|
|
348
|
+
return (fx, fy)
|
|
349
|
+
|
|
350
|
+
out: List[Dict[str, Any]] = []
|
|
351
|
+
if not isinstance(data, dict):
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
# ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
|
|
355
|
+
drags = data.get("output")
|
|
356
|
+
if isinstance(drags, list) and drags:
|
|
357
|
+
for d in drags:
|
|
358
|
+
if not isinstance(d, dict):
|
|
359
|
+
continue
|
|
360
|
+
sp = d.get("SourcePosition") or {}
|
|
361
|
+
ep = d.get("EstimatedPosition") or d.get("DestinationPosition") or {}
|
|
362
|
+
src = norm_xy(sp.get("x"), sp.get("y")) if isinstance(sp, dict) else None
|
|
363
|
+
dst = norm_xy(ep.get("x"), ep.get("y")) if isinstance(ep, dict) else None
|
|
364
|
+
if src and dst:
|
|
365
|
+
out.append({"kind": "drag", "src": src, "dst": dst})
|
|
366
|
+
if out:
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
# ---- click: {"action": {"action":"click","points":[...]}} ----
|
|
370
|
+
action = data.get("action")
|
|
371
|
+
# The model sometimes over-wraps: {"action": {"action": {"action":
|
|
372
|
+
# "click", "points":[...]}}}. Peel nested "action" dicts until we reach
|
|
373
|
+
# the one carrying points/coords (bounded so we never loop forever).
|
|
374
|
+
for _ in range(4):
|
|
375
|
+
if (
|
|
376
|
+
isinstance(action, dict)
|
|
377
|
+
and action.get("points") is None
|
|
378
|
+
and action.get("source") is None
|
|
379
|
+
and isinstance(action.get("action"), dict)
|
|
380
|
+
):
|
|
381
|
+
action = action["action"]
|
|
382
|
+
else:
|
|
383
|
+
break
|
|
384
|
+
points = None
|
|
385
|
+
if isinstance(action, dict):
|
|
386
|
+
points = action.get("points")
|
|
387
|
+
# single drag emitted under "action"
|
|
388
|
+
if points is None and action.get("action") == "drag":
|
|
389
|
+
src = norm_xy(*(action.get("source") or (None, None)))
|
|
390
|
+
dst = norm_xy(*(action.get("target") or (None, None)))
|
|
391
|
+
if src and dst:
|
|
392
|
+
return [{"kind": "drag", "src": src, "dst": dst}]
|
|
393
|
+
if points is None:
|
|
394
|
+
points = data.get("points")
|
|
395
|
+
if isinstance(points, list) and points:
|
|
396
|
+
pts = []
|
|
397
|
+
for p in points:
|
|
398
|
+
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
|
399
|
+
xy = norm_xy(p[0], p[1])
|
|
400
|
+
elif isinstance(p, dict):
|
|
401
|
+
xy = norm_xy(p.get("x"), p.get("y"))
|
|
402
|
+
else:
|
|
403
|
+
xy = None
|
|
404
|
+
if xy:
|
|
405
|
+
pts.append(xy)
|
|
406
|
+
if pts:
|
|
407
|
+
out.append({"kind": "click", "points": pts})
|
|
408
|
+
return out
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import List, Literal, Optional, Union, Dict
|
|
2
|
+
from pydantic import BaseModel, Field, model_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PlannerAction(BaseModel):
|
|
6
|
+
goal: Optional[str] = Field(None, description="Description of the visual goal")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PlannerClickAction(PlannerAction):
|
|
10
|
+
action: Literal["click"]
|
|
11
|
+
target_ids: List[int] = Field(description="List of object IDs or grid cell numbers to click")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PlannerDragAction(PlannerAction):
|
|
15
|
+
action: Literal["drag"]
|
|
16
|
+
source_id: int = Field(description="Object ID of item to drag")
|
|
17
|
+
target_id: int = Field(description="Object ID of destination")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlannerTypeAction(PlannerAction):
|
|
21
|
+
action: Literal["type"]
|
|
22
|
+
text: str = Field(description="Text to type into the input")
|
|
23
|
+
target_id: int = Field(description="Object ID of the input field")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlannerWaitAction(PlannerAction):
|
|
27
|
+
action: Literal["wait"]
|
|
28
|
+
duration_ms: int = Field(default=500, description="Duration to wait in milliseconds")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PlannerDoneAction(PlannerAction):
|
|
32
|
+
action: Literal["done"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DragGuess(BaseModel):
|
|
36
|
+
Action: Literal["simulate_drag"]
|
|
37
|
+
SourceID: int
|
|
38
|
+
DestinationDescription: str
|
|
39
|
+
EstimatedPosition: Dict[str, int] = Field(description="{'x': 1-1000, 'y': 1-1000}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DragRefineResult(BaseModel):
|
|
43
|
+
SourceId: int
|
|
44
|
+
estimatedVerticalDistanceFromTarget: int = Field(description="negative = above, positive = below")
|
|
45
|
+
estimatedHorizontalDistanceFromTarget: int = Field(description="negative = left, positive = right")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PlannerDragRefineAction(PlannerAction):
|
|
49
|
+
# This is for Step 3: Verification and adjustment
|
|
50
|
+
output: List[DragRefineResult]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PlannerToolCall(BaseModel):
|
|
54
|
+
name: Literal["detect", "simulate_drag"]
|
|
55
|
+
args: dict
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PlannerPlan(BaseModel):
|
|
59
|
+
goal: Optional[str] = None
|
|
60
|
+
tool_calls: Optional[List[PlannerToolCall]] = None
|
|
61
|
+
# Step 1: Detect all draggable items
|
|
62
|
+
objectDescription: Optional[str] = None
|
|
63
|
+
# Step 2: Initial guess (general LoRa) uses output
|
|
64
|
+
output: Optional[List[DragGuess]] = None
|
|
65
|
+
# We allow the planner to return either a direct action or tool calls or new output format
|
|
66
|
+
action: Optional[Union[PlannerClickAction, PlannerDragAction, PlannerTypeAction, PlannerWaitAction, PlannerDoneAction]] = None
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def check_exclusive(self) -> "PlannerPlan":
|
|
70
|
+
# At least one must be provided
|
|
71
|
+
if not any([self.action, self.tool_calls, self.output, self.objectDescription]):
|
|
72
|
+
raise ValueError("Provide EITHER 'action', 'tool_calls', 'output', OR 'objectDescription'.")
|
|
73
|
+
return self
|
|
74
|
+
|