captchakraken 2.8.0 → 3.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/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,12 +1,3 @@
|
|
|
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
1
|
import base64
|
|
11
2
|
import io
|
|
12
3
|
import json
|
|
@@ -21,46 +12,19 @@ import requests
|
|
|
21
12
|
from PIL import Image
|
|
22
13
|
|
|
23
14
|
from . import config, errors, prompts
|
|
15
|
+
from .kinds import ActionKind, PixelAnswerKind, PromptFamily, RetryMode
|
|
24
16
|
from .server_manager import ensure_server
|
|
25
|
-
from .timing import timed
|
|
26
17
|
|
|
27
18
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
28
19
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# Training exports `MIN_PIXELS=200704` (448², in the finetune repo's
|
|
33
|
-
# scripts/train_unified.sh), so anything smaller is enlarged before the vision
|
|
34
|
-
# encoder sees it. Serving did none of that: vLLM runs with no
|
|
35
|
-
# `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
|
|
36
|
-
# so a small captcha arrived at a geometry the model was never tuned on. It
|
|
37
|
-
# fails as plausible-but-wrong coordinates, never as an error — measured on real
|
|
38
|
-
# geetest_v3_slide captures (277x285), predictions landed 80-105 px from the
|
|
39
|
-
# hand label at native size and 1-4 px away once upscaled.
|
|
40
|
-
#
|
|
41
|
-
# A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
|
|
42
|
-
# re-encoding every screenshot would spend time and fidelity on the types that
|
|
43
|
-
# were never affected.
|
|
44
|
-
#
|
|
45
|
-
# Unconditional as a DEFAULT, because the deployed v1.1 adapter improves under
|
|
46
|
-
# it too (mean error ~40 px native vs ~4 px upscaled). But it is no longer a
|
|
47
|
-
# constant: `models.json` may declare a `pixel_budget` per model, because the
|
|
48
|
-
# band an adapter TRAINED under is a property of that adapter, exactly like its
|
|
49
|
-
# prompt generation. See prompts.pixel_budget.
|
|
20
|
+
# A floor, not a resize, and the AREA is clamped rather than the sides: predictions on a 277x285 GeeTest
|
|
21
|
+
# board landed 80-105px from the hand label at native size and 1-4px once upscaled, and squashing the
|
|
22
|
+
# aspect moves every tile centre.
|
|
50
23
|
MIN_PIXELS = 448 * 448
|
|
51
24
|
|
|
52
25
|
|
|
53
26
|
def _encode_image(path: str,
|
|
54
27
|
budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
|
|
55
|
-
"""`(mime, base64)` for one image, fitted into `budget`'s pixel band.
|
|
56
|
-
|
|
57
|
-
Area is clamped, never dimensions: aspect ratio is the geometry of a grid
|
|
58
|
-
puzzle, and squashing it moves every tile centre — fatal when the answer is
|
|
59
|
-
a coordinate on a normalized 0-1000 scale.
|
|
60
|
-
|
|
61
|
-
Anything Pillow cannot open is passed through untouched — this is a
|
|
62
|
-
preprocessing nicety, and it must never be the reason a solve fails.
|
|
63
|
-
"""
|
|
64
28
|
budget = budget or prompts.pixel_budget(None)
|
|
65
29
|
with open(path, "rb") as f:
|
|
66
30
|
raw = f.read()
|
|
@@ -68,12 +32,10 @@ def _encode_image(path: str,
|
|
|
68
32
|
try:
|
|
69
33
|
im = Image.open(io.BytesIO(raw))
|
|
70
34
|
width, height = im.size
|
|
71
|
-
except Exception:
|
|
35
|
+
except Exception:
|
|
72
36
|
return mime, base64.b64encode(raw).decode()
|
|
73
37
|
cap = budget.maximum
|
|
74
38
|
if cap and width * height > cap:
|
|
75
|
-
# floor, not ceil: rounding up here can land back OVER the cap, which
|
|
76
|
-
# is the mirror of the bug the floor branch below guards against.
|
|
77
39
|
scale = math.sqrt(cap / (width * height))
|
|
78
40
|
im = im.convert("RGB").resize(
|
|
79
41
|
(max(1, int(width * scale)), max(1, int(height * scale))),
|
|
@@ -83,8 +45,6 @@ def _encode_image(path: str,
|
|
|
83
45
|
return "image/png", base64.b64encode(buf.getvalue()).decode()
|
|
84
46
|
if width * height >= budget.minimum:
|
|
85
47
|
return mime, base64.b64encode(raw).decode()
|
|
86
|
-
# ceil, not round: rounding both sides down lands just under the floor
|
|
87
|
-
# (442x454 = 200,668 for a 277x285 capture) and defeats the whole point.
|
|
88
48
|
scale = math.sqrt(budget.minimum / (width * height))
|
|
89
49
|
im = im.convert("RGB").resize(
|
|
90
50
|
(math.ceil(width * scale), math.ceil(height * scale)), Image.BICUBIC)
|
|
@@ -92,98 +52,39 @@ def _encode_image(path: str,
|
|
|
92
52
|
im.save(buf, "PNG")
|
|
93
53
|
return "image/png", base64.b64encode(buf.getvalue()).decode()
|
|
94
54
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# to the backup GPUs so it never competes with production traffic on the main
|
|
99
|
-
# 5090. The tier-2 CI gate sets this — its traffic is throwaway and must stay
|
|
100
|
-
# off the primary.
|
|
101
|
-
#
|
|
102
|
-
# Deliberately a HEADER, not vLLM's request-body `priority` field: that field is
|
|
103
|
-
# lower-is-higher and already means captcha=0 / apply=100 / label=200 on the
|
|
104
|
-
# priority-scheduled primary, so a value like 10 would MISORDER there (it would
|
|
105
|
-
# outrank apply/label). Routing is a fleet concern, kept out of the model's
|
|
106
|
-
# scheduler.
|
|
55
|
+
# A header, deliberately not vLLM's body `priority`: that field is lower-is-higher and already means
|
|
56
|
+
# captcha=0 / apply=100 / label=200 on the primary, so a 10 there would misorder. The gateway routes >5 to
|
|
57
|
+
# the backup GPUs; tier-2 CI sets it.
|
|
107
58
|
_PRIORITY_HEADER = "X-JH-Priority"
|
|
108
59
|
_PRIORITY_ENV = "CAPTCHA_REQUEST_PRIORITY"
|
|
109
60
|
|
|
110
|
-
# Hosted-API metadata. Both are OPTIONAL and absent unless deliberately set, so
|
|
111
|
-
# self-hosted users never send them and are unaffected.
|
|
112
|
-
#
|
|
113
|
-
# X-CK-Client — which integration issued this solve (e.g. "camoufox/0.4.11").
|
|
114
|
-
# camoufox sets CAPTCHA_KRAKEN_CLIENT in the env it spawns the solver with, so
|
|
115
|
-
# the gateway can account camoufox-attributed usage separately. Attribution
|
|
116
|
-
# ONLY: it is caller-supplied and therefore never priced on (the gateway
|
|
117
|
-
# derives billable puzzle class from the request body instead).
|
|
118
|
-
# X-CK-Session — groups the 1..N inference rounds of ONE captcha into a single
|
|
119
|
-
# billable attempt. The JS driver mints a UUID per solve() and reuses it for
|
|
120
|
-
# every CLI invocation in that solve, which is what lets the gateway cap an
|
|
121
|
-
# attempt's billable rounds instead of charging per round without limit.
|
|
122
61
|
_CLIENT_HEADER = "X-CK-Client"
|
|
123
62
|
_CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
|
|
63
|
+
# One id per solve lets the gateway cap an attempt's billable rounds.
|
|
124
64
|
_SESSION_HEADER = "X-CK-Session"
|
|
125
65
|
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
126
66
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
# ON by default because the report is what makes a failed solve improvable, and
|
|
130
|
-
# because it is the only way a hosted account's failures can be found at all.
|
|
131
|
-
# Opting out here stops the CLIENT sending it; `captureOptOut` on the account is
|
|
132
|
-
# the server-side half, and either one alone is enough to keep nothing.
|
|
67
|
+
# On by default; `captureOptOut` is the server half. Tier 3 sets it to 0 for ~100 solves a run.
|
|
133
68
|
_REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
|
|
134
69
|
|
|
135
|
-
#
|
|
136
|
-
# commas. Empty and absent by default.
|
|
137
|
-
#
|
|
138
|
-
# WHY THIS EXISTS. Some endpoints sit behind a gate that is not the API key —
|
|
139
|
-
# our own dev gateway (api.dev.captchakraken.com) wants X-CK-Dev-Auth, and a
|
|
140
|
-
# corporate egress proxy may want its own token. Without this the published
|
|
141
|
-
# client simply cannot reach such an endpoint, which meant the dev environment
|
|
142
|
-
# could not exercise the real client path at all: the thing dev exists to
|
|
143
|
-
# rehearse was the one thing it could not do.
|
|
144
|
-
#
|
|
145
|
-
# IT CANNOT OVERWRITE Authorization, Content-Type, OR THE X-CK-* HEADERS. Those
|
|
146
|
-
# carry the credential and the billing attribution. Allowing an env var to
|
|
147
|
-
# rewrite Authorization would turn a stray export into "your key is not the key
|
|
148
|
-
# being charged", and allowing it to rewrite X-CK-Session would let a caller
|
|
149
|
-
# escape the per-attempt billing cap by pinning one session id forever.
|
|
70
|
+
# Extra headers may not rewrite these: pinning one X-CK-Session forever would escape the per-attempt billing cap.
|
|
150
71
|
_EXTRA_HEADERS_ENV = "CAPTCHA_KRAKEN_EXTRA_HEADERS"
|
|
151
72
|
_PROTECTED_HEADERS = frozenset(
|
|
152
73
|
{"authorization", "content-type", _CLIENT_HEADER.lower(), _SESSION_HEADER.lower()}
|
|
153
74
|
)
|
|
154
75
|
|
|
155
|
-
# These values reach the wire verbatim from the environment, so they are
|
|
156
|
-
# sanitized rather than trusted: a CR/LF would otherwise splice arbitrary
|
|
157
|
-
# headers into the upstream request.
|
|
158
76
|
_HEADER_VALUE_MAX = 128
|
|
159
77
|
|
|
160
78
|
|
|
161
|
-
# RFC 7230 token characters. Anything else is not a header name.
|
|
162
79
|
_VALID_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
163
80
|
|
|
164
81
|
|
|
165
82
|
def _clean_header_value(raw: str) -> str:
|
|
166
|
-
|
|
83
|
+
# CR/LF would splice arbitrary headers into the request.
|
|
167
84
|
return "".join(c for c in raw.strip() if 0x20 <= ord(c) < 0x7F)[:_HEADER_VALUE_MAX]
|
|
168
85
|
|
|
169
86
|
|
|
170
87
|
def _extra_headers(raw: str) -> Dict[str, str]:
|
|
171
|
-
"""Parse CAPTCHA_KRAKEN_EXTRA_HEADERS into a header dict.
|
|
172
|
-
|
|
173
|
-
Accepts "Name: value" pairs separated by newlines or commas:
|
|
174
|
-
|
|
175
|
-
CAPTCHA_KRAKEN_EXTRA_HEADERS='X-CK-Dev-Auth: hunter2'
|
|
176
|
-
CAPTCHA_KRAKEN_EXTRA_HEADERS=$'X-One: a\nX-Two: b'
|
|
177
|
-
|
|
178
|
-
Malformed entries are DROPPED, never guessed at. A pair with no colon is
|
|
179
|
-
not a header, and inventing one from it would put an attacker-shaped string
|
|
180
|
-
on the wire. Names are restricted to the RFC 7230 token characters for the
|
|
181
|
-
same reason the values are sanitized: a CR/LF in either would splice extra
|
|
182
|
-
headers into the request.
|
|
183
|
-
|
|
184
|
-
Protected headers are refused, so this cannot be used to replace the
|
|
185
|
-
credential or the billing attribution.
|
|
186
|
-
"""
|
|
187
88
|
out: Dict[str, str] = {}
|
|
188
89
|
if not raw.strip():
|
|
189
90
|
return out
|
|
@@ -206,17 +107,7 @@ def _extra_headers(raw: str) -> Dict[str, str]:
|
|
|
206
107
|
|
|
207
108
|
|
|
208
109
|
def routing_headers(env=None) -> Dict[str, str]:
|
|
209
|
-
"""
|
|
210
|
-
|
|
211
|
-
Empty by default. Split out and env-injectable so it can be unit-tested
|
|
212
|
-
without a live server. A missing or non-integer CAPTCHA_REQUEST_PRIORITY
|
|
213
|
-
yields no header at all — an unset/garbage value must never silently tag
|
|
214
|
-
traffic for the backups.
|
|
215
|
-
|
|
216
|
-
Each header is derived independently: a malformed priority must not suppress
|
|
217
|
-
the attribution headers, or one typo would silently make a camoufox solve
|
|
218
|
-
look like direct traffic and understate the partner's revenue share.
|
|
219
|
-
"""
|
|
110
|
+
"""Each header is derived on its own: a malformed priority must not drop the attribution headers and understate a partner's revenue share."""
|
|
220
111
|
env = os.environ if env is None else env
|
|
221
112
|
headers: Dict[str, str] = {}
|
|
222
113
|
|
|
@@ -237,157 +128,79 @@ def routing_headers(env=None) -> Dict[str, str]:
|
|
|
237
128
|
return headers
|
|
238
129
|
|
|
239
130
|
|
|
240
|
-
# ── Prompt constants ────────────────────────────────────────────────────────
|
|
241
|
-
#
|
|
242
|
-
# These are ALIASES for the newest generation's built-ins, kept so that code and
|
|
243
|
-
# tests written against the old module-level constants keep working. They are
|
|
244
|
-
# NOT what a solve sends. The prompt a model gets is resolved per model
|
|
245
|
-
# (`prompts.resolve`), because a model answers in whatever schema its prompt
|
|
246
|
-
# asks for — sending a version-1 model the version-2 prompt does not error, it
|
|
247
|
-
# silently degrades every puzzle. See prompts.py and models.json.
|
|
248
|
-
#
|
|
249
|
-
# The text itself lives in prompts.py and nowhere else. Two copies of a prompt
|
|
250
|
-
# is the drift this module already shipped once (2026-07-18).
|
|
251
131
|
_LATEST = prompts.builtin(prompts.LATEST_PROMPT_VERSION)
|
|
252
132
|
|
|
253
133
|
SELECT_GRID_PROMPT = _LATEST.grid_template
|
|
254
134
|
|
|
255
135
|
|
|
256
|
-
# Non-grid click/drag puzzles, newest generation. Alias — see the note above.
|
|
257
136
|
PIXEL_ACTION_PROMPT = _LATEST.action_prompt
|
|
258
137
|
|
|
259
138
|
|
|
260
|
-
# Animated puzzles: the challenge is recorded, sliced into keyframes
|
|
261
|
-
# (`keyframes.py`) and sent as an ORDINARY MULTI-IMAGE request — one image per
|
|
262
|
-
# keyframe, all in one context so the model can compare them. The answer carries
|
|
263
|
-
# a `frame` naming which keyframe it acted on, and the page driver waits for the
|
|
264
|
-
# live widget to look like that frame before pressing the mouse down.
|
|
265
|
-
#
|
|
266
|
-
# Alias for the newest generation — see the note above. Generation 1 has NO
|
|
267
|
-
# animated prompt (the family did not exist), which is why resolution is per
|
|
268
|
-
# model rather than per client.
|
|
269
|
-
VIDEO_ACTION_PROMPT_TEMPLATE = _LATEST.video_template
|
|
270
139
|
|
|
271
140
|
|
|
272
|
-
|
|
273
|
-
|
|
141
|
+
# Greedy only, by measurement: (0.0, 0.35, 0.7) scored 0/5 against 4/5 and made the median failed attempt
|
|
142
|
+
# 24.0s against 13.9s, because a differing re-ask resets the no-progress counter. The level plumbing stays for
|
|
143
|
+
# a network-refusal trigger. Lives here once so the two ports cannot drift (TRIBAL_KNOWLEDGE.md).
|
|
144
|
+
RESAMPLE_TEMPERATURES = (0.0,)
|
|
145
|
+
|
|
274
146
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
147
|
+
def sampling_for_level(level: int) -> Dict[str, Any]:
|
|
148
|
+
temps = RESAMPLE_TEMPERATURES or (0.0,)
|
|
149
|
+
temp = temps[min(max(level, 0), len(temps) - 1)]
|
|
150
|
+
if not temp:
|
|
151
|
+
return {}
|
|
152
|
+
return {"temperature": temp, "seed": 1000 + level}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sampling_from_env() -> Dict[str, Any]:
|
|
156
|
+
raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
|
|
157
|
+
if not raw:
|
|
158
|
+
return {}
|
|
159
|
+
try:
|
|
160
|
+
return sampling_for_level(int(raw))
|
|
161
|
+
except ValueError:
|
|
162
|
+
return {}
|
|
284
163
|
|
|
285
164
|
|
|
286
165
|
class ActionPlanner:
|
|
287
|
-
"""Thin client for the vLLM `captcha` LoRA."""
|
|
288
166
|
|
|
289
167
|
def __init__(
|
|
290
168
|
self,
|
|
291
169
|
model: Optional[str] = None,
|
|
292
|
-
debug_callback: Optional[Any] = None,
|
|
293
170
|
base_url: Optional[str] = None,
|
|
294
171
|
api_key: Optional[str] = None,
|
|
295
|
-
expert: Optional[
|
|
172
|
+
expert: Optional[PromptFamily] = None,
|
|
296
173
|
**_: Any,
|
|
297
174
|
):
|
|
298
|
-
self.debug_callback = debug_callback
|
|
299
175
|
self.token_usage: List[Dict[str, Any]] = []
|
|
300
176
|
|
|
301
|
-
# All model/endpoint defaults come from `config` (env-overridable), so
|
|
302
|
-
# the planner itself is model-agnostic — swap models via env, not code.
|
|
303
177
|
self.model = model or config.lora_name()
|
|
304
178
|
self.base_url = base_url or config.base_url()
|
|
305
179
|
self.api_key = api_key or config.api_key()
|
|
306
|
-
|
|
307
|
-
# served name first (a hosted endpoint serves `captcha`, not a repo id,
|
|
308
|
-
# and models.json maps the alias back), falling back to the adapter repo
|
|
309
|
-
# id for a self-hosted deploy that serves under its own name.
|
|
310
|
-
#
|
|
311
|
-
# Doing this ONCE per planner rather than per request: resolution can
|
|
312
|
-
# touch the Hub, and a per-request lookup would put a network call in
|
|
313
|
-
# front of every round of every solve.
|
|
180
|
+
self.sampling: Dict[str, Any] = {}
|
|
314
181
|
_prompt_key = (self.model if prompts.canonical_model_id(self.model)
|
|
315
182
|
else config.lora_adapter())
|
|
316
183
|
self.prompts = prompts.resolve(_prompt_key)
|
|
317
|
-
# Resolution follows the MODEL for the same reason prompts do, and off
|
|
318
|
-
# the same key: an adapter reads a puzzle at the pixel band it trained
|
|
319
|
-
# under. Resolved once per planner — see the note above.
|
|
320
184
|
self.pixel_budget = prompts.pixel_budget(_prompt_key)
|
|
321
|
-
# WHICH ADAPTER answers each prompt family. Empty for every model that
|
|
322
|
-
# is not routed, which is every model published so far — `_model_for`
|
|
323
|
-
# then returns `self.model` for every request and the wire is unchanged.
|
|
324
|
-
#
|
|
325
|
-
# Resolved once, like the two above, and for a stronger reason: the pin
|
|
326
|
-
# is validated here, so `--expert grid` against a single-adapter model
|
|
327
|
-
# fails when the solver is BUILT rather than on the first grid it meets.
|
|
328
185
|
self.expert = (expert if expert is not None
|
|
329
186
|
else prompts.expert_pin())
|
|
330
187
|
self.experts = prompts.experts(_prompt_key)
|
|
331
188
|
if self.expert:
|
|
332
|
-
# Raises on an unknown family, and on a pin against an unrouted
|
|
333
|
-
# model. Both are configuration errors, and a benchmark that
|
|
334
|
-
# quietly measured the generalist while reporting an expert is the
|
|
335
|
-
# exact class of silent mispairing models.json exists to prevent.
|
|
336
189
|
prompts.route(_prompt_key, None, pin=self.expert)
|
|
337
|
-
# Auto-start a local vLLM server on the first request if one isn't up
|
|
338
|
-
# (no-op for a healthy or remote endpoint). Guarded so we only try once.
|
|
339
190
|
self._server_ensured = False
|
|
340
|
-
# ONE connection, reused for every round of every solve this planner
|
|
341
|
-
# serves. `requests.post` opens a fresh TCP connection each call — and
|
|
342
|
-
# to a hosted endpoint a TLS handshake with it. Measured against the
|
|
343
|
-
# gate's own endpoint: 258ms per request connectionless vs 144ms
|
|
344
|
-
# pooled, so every inference was paying ~110ms to re-dial a server it
|
|
345
|
-
# had just finished talking to. A multi-round grid pays it per round.
|
|
346
|
-
#
|
|
347
|
-
# The planner is per-PageSolver and a PageSolver is reusable across
|
|
348
|
-
# solves, so a caller who keeps one keeps the connection warm too.
|
|
349
191
|
self._http = requests.Session()
|
|
350
|
-
# Latched off by the first 404 — see `report_outcome`.
|
|
351
192
|
self._outcome_supported = True
|
|
352
193
|
|
|
353
|
-
def _model_for(self, family: Optional[
|
|
354
|
-
"""The `model` string for a request in `family`.
|
|
355
|
-
|
|
356
|
-
`self.model` whenever the model is not routed, so a caller may pass a
|
|
357
|
-
family unconditionally. A routed model with no expert for this family
|
|
358
|
-
degrades to the generalist — never to another expert, never to an
|
|
359
|
-
error (docs/MOE_LORA_DESIGN.md §11).
|
|
360
|
-
"""
|
|
194
|
+
def _model_for(self, family: Optional[PromptFamily]) -> str:
|
|
361
195
|
if not self.experts:
|
|
362
196
|
return self.model
|
|
363
197
|
return prompts.route(self.model, family, pin=self.expert) or self.model
|
|
364
198
|
|
|
365
|
-
# ── the solve's own verdict, sent back ─────────────────────────────────
|
|
366
|
-
#
|
|
367
|
-
# THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
|
|
368
|
-
# a 200 with well-formed JSON in it; only this driver watched the widget.
|
|
369
|
-
# Without this report the hosted API cannot tell a solved captcha from a
|
|
370
|
-
# failed one, and the failures — the exact boards the model is worst at, on
|
|
371
|
-
# real sites — are the one dataset that cannot be collected any other way.
|
|
372
|
-
#
|
|
373
|
-
# BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
|
|
374
|
-
# already decided, so nothing it does can change the answer the caller gets.
|
|
375
|
-
# Every failure is swallowed: a self-hosted vLLM has no such route, a
|
|
376
|
-
# network blip is not the caller's problem, and an exception here would turn
|
|
377
|
-
# a successful solve into a raised one.
|
|
378
|
-
#
|
|
379
|
-
# ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
|
|
380
|
-
# first one latches this off for the life of the planner rather than paying
|
|
381
|
-
# a round trip per solve to be told the same thing again.
|
|
382
199
|
_OUTCOME_PATH = "/solve-outcome"
|
|
383
200
|
_OUTCOME_TIMEOUT_S = 3.0
|
|
384
201
|
|
|
385
202
|
def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
|
|
386
|
-
"""
|
|
387
|
-
|
|
388
|
-
Never raises. The return value is for tests and for `--debug`; no caller
|
|
389
|
-
should branch on it.
|
|
390
|
-
"""
|
|
203
|
+
"""A 404 disables it for the planner's lifetime: a self-hosted vLLM has no /solve-outcome."""
|
|
391
204
|
if not session_id or not self._outcome_supported:
|
|
392
205
|
return False
|
|
393
206
|
if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
|
|
@@ -403,11 +216,10 @@ class ActionPlanner:
|
|
|
403
216
|
json={"session": session_id, "solved": bool(solved)},
|
|
404
217
|
timeout=self._OUTCOME_TIMEOUT_S,
|
|
405
218
|
)
|
|
406
|
-
except Exception as exc:
|
|
219
|
+
except Exception as exc:
|
|
407
220
|
self._log(f"outcome report failed: {exc}")
|
|
408
221
|
return False
|
|
409
222
|
if resp.status_code == 404:
|
|
410
|
-
# Not an error: this endpoint does not serve the route. Stop asking.
|
|
411
223
|
self._outcome_supported = False
|
|
412
224
|
self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
|
|
413
225
|
return False
|
|
@@ -418,28 +230,17 @@ class ActionPlanner:
|
|
|
418
230
|
def _log(self, message: str) -> None:
|
|
419
231
|
if DEBUG:
|
|
420
232
|
print(f"[Planner] {message}", file=sys.stderr)
|
|
421
|
-
if self.debug_callback:
|
|
422
|
-
self.debug_callback(f"[Planner] {message}")
|
|
423
233
|
|
|
424
234
|
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
|
|
425
|
-
family: Optional[
|
|
235
|
+
family: Optional[PromptFamily] = None) -> str:
|
|
426
236
|
return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
|
|
427
237
|
family=family)
|
|
428
238
|
|
|
429
239
|
def _chat_with_images(
|
|
430
240
|
self, prompt: str, image_paths: List[str], max_tokens: int = 512,
|
|
431
|
-
family: Optional[
|
|
241
|
+
family: Optional[PromptFamily] = None,
|
|
432
242
|
) -> str:
|
|
433
|
-
"""One request
|
|
434
|
-
|
|
435
|
-
The images go in ONE message, before the text, in the order given. That
|
|
436
|
-
ordering is what makes an animated challenge answerable: the model is told
|
|
437
|
-
"you are given N keyframes, in order: frame 1, frame 2, …" and matches those
|
|
438
|
-
names to the images positionally. Splitting them across requests, or putting
|
|
439
|
-
the text first, would break the correspondence between the numbers in the
|
|
440
|
-
prompt and the pictures — and the frame number is the part the page driver
|
|
441
|
-
acts on.
|
|
442
|
-
"""
|
|
243
|
+
"""One request per image set, images before the text in one message: the model reads frames positionally, and per-frame queries would cost N billable rounds."""
|
|
443
244
|
if not image_paths:
|
|
444
245
|
raise ValueError("no images to send")
|
|
445
246
|
|
|
@@ -466,29 +267,24 @@ class ActionPlanner:
|
|
|
466
267
|
"messages": messages,
|
|
467
268
|
"temperature": 0,
|
|
468
269
|
"max_tokens": max_tokens,
|
|
469
|
-
#
|
|
470
|
-
# in the prompt alone is unreliable; disabling at the chat-template
|
|
471
|
-
# level is the documented way.
|
|
270
|
+
# Both spellings: Ollama ignores chat_template_kwargs and defaults thinking on; vLLM reads reasoning_effort only when the kwargs are unset.
|
|
472
271
|
"chat_template_kwargs": {"enable_thinking": False},
|
|
272
|
+
"reasoning_effort": "none",
|
|
473
273
|
}
|
|
274
|
+
sampling = self.sampling or _sampling_from_env()
|
|
275
|
+
if sampling:
|
|
276
|
+
payload.update(sampling)
|
|
474
277
|
|
|
475
278
|
headers = {
|
|
476
279
|
"Authorization": f"Bearer {self.api_key}",
|
|
477
280
|
"Content-Type": "application/json",
|
|
478
|
-
# Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
|
|
479
|
-
# routing_headers). Low-priority batch traffic is steered to the
|
|
480
|
-
# backup GPUs by the haproxy front on this header.
|
|
481
281
|
**routing_headers(),
|
|
482
282
|
}
|
|
483
283
|
|
|
484
|
-
# Hands-off server: before the very first request, make sure something
|
|
485
|
-
# is listening (auto-start a local vLLM if needed; no-op if it's already
|
|
486
|
-
# up or the endpoint is remote). Best-effort — a failure here surfaces as
|
|
487
|
-
# a normal connection error on the request below.
|
|
488
284
|
if not self._server_ensured:
|
|
489
285
|
try:
|
|
490
286
|
ensure_server(self.base_url)
|
|
491
|
-
except Exception as e:
|
|
287
|
+
except Exception as e:
|
|
492
288
|
self._log(f"ensure_server: {e}")
|
|
493
289
|
self._server_ensured = True
|
|
494
290
|
|
|
@@ -496,19 +292,8 @@ class ActionPlanner:
|
|
|
496
292
|
self._log(f"POST {url} model={model} max_tokens={max_tokens} "
|
|
497
293
|
f"images={len(parts)}")
|
|
498
294
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
# Surface auth / billing / server errors as something the reader can act
|
|
503
|
-
# on, instead of letting resp.json() blow up with a cryptic "Expecting
|
|
504
|
-
# value: line 1 column 1" on a non-JSON response.
|
|
505
|
-
#
|
|
506
|
-
# `errors.from_response` decides which of the two worlds we are in by
|
|
507
|
-
# looking for the gateway's `error.code`: present means the hosted API
|
|
508
|
-
# refused us and there is a real explanation to give (out of credits,
|
|
509
|
-
# rate limited, attempt abandoned); absent means a local vLLM or a proxy,
|
|
510
|
-
# and the old message — including the bearer-token hint on 401/403 — is
|
|
511
|
-
# reproduced unchanged.
|
|
295
|
+
resp = self._http.post(url, headers=headers, json=payload, timeout=120)
|
|
296
|
+
|
|
512
297
|
if not resp.ok:
|
|
513
298
|
raise errors.from_response(resp, url)
|
|
514
299
|
|
|
@@ -547,17 +332,9 @@ class ActionPlanner:
|
|
|
547
332
|
else:
|
|
548
333
|
return None
|
|
549
334
|
|
|
550
|
-
# strict=False tolerates unescaped control chars INSIDE strings — the
|
|
551
|
-
# model routinely emits coordinates as pretty-printed strings with a
|
|
552
|
-
# literal newline, e.g. "click": ["277,\n 728", ...], which strict JSON
|
|
553
|
-
# rejects. Without this the whole response fails to parse and a solvable
|
|
554
|
-
# click puzzle is dropped as "unsupported".
|
|
555
335
|
try:
|
|
556
336
|
return json.loads(text[start:end], strict=False)
|
|
557
337
|
except json.JSONDecodeError:
|
|
558
|
-
# The model sometimes truncates (hit max_tokens) or over-nests its
|
|
559
|
-
# pretty-printed JSON, leaving brackets unclosed. Repair by balancing
|
|
560
|
-
# from the first opener to the end of the raw text.
|
|
561
338
|
repaired = ActionPlanner._balance_json(text[start:])
|
|
562
339
|
if repaired is not None:
|
|
563
340
|
try:
|
|
@@ -568,9 +345,6 @@ class ActionPlanner:
|
|
|
568
345
|
|
|
569
346
|
@staticmethod
|
|
570
347
|
def _balance_json(text: str) -> Optional[str]:
|
|
571
|
-
"""Close any brackets/braces the model left open (string-aware), so a
|
|
572
|
-
truncated ``{"action": {"points": [[1,2],`` still parses. Returns None if
|
|
573
|
-
there was nothing to balance."""
|
|
574
348
|
stack: List[str] = []
|
|
575
349
|
in_str = False
|
|
576
350
|
esc = False
|
|
@@ -598,7 +372,6 @@ class ActionPlanner:
|
|
|
598
372
|
out = text
|
|
599
373
|
if in_str:
|
|
600
374
|
out += '"'
|
|
601
|
-
# Drop a dangling comma / partial token before the closers.
|
|
602
375
|
out = out.rstrip().rstrip(",")
|
|
603
376
|
for opener in reversed(stack):
|
|
604
377
|
out += "}" if opener == "{" else "]"
|
|
@@ -609,16 +382,8 @@ class ActionPlanner:
|
|
|
609
382
|
image_path: str,
|
|
610
383
|
rows: int,
|
|
611
384
|
cols: int,
|
|
612
|
-
retry_mode: Optional[
|
|
385
|
+
retry_mode: Optional[RetryMode] = None,
|
|
613
386
|
) -> List[int]:
|
|
614
|
-
"""Return the list of 1-indexed cells the model wants to click.
|
|
615
|
-
|
|
616
|
-
retry_mode == "missed-tiles": the previous submission was rejected
|
|
617
|
-
by the captcha vendor with an under-selection error. Append an
|
|
618
|
-
explicit recovery instruction that tells the model the FULL grid
|
|
619
|
-
contains at least one matching tile it didn't pick last time. This
|
|
620
|
-
nudges it off the "I already covered everything" attractor.
|
|
621
|
-
"""
|
|
622
387
|
total = rows * cols
|
|
623
388
|
if rows == 4 and cols == 4:
|
|
624
389
|
grid_hint = "Hint: Single large image split into tiles. Select ALL parts."
|
|
@@ -628,7 +393,7 @@ class ActionPlanner:
|
|
|
628
393
|
prompt = self.prompts.grid_prompt(
|
|
629
394
|
rows=rows, cols=cols, grid_hint=grid_hint
|
|
630
395
|
)
|
|
631
|
-
if retry_mode ==
|
|
396
|
+
if retry_mode == RetryMode.MISSED_TILES:
|
|
632
397
|
prompt = (
|
|
633
398
|
prompt
|
|
634
399
|
+ "\n\nIMPORTANT: A previous submission was rejected because not all "
|
|
@@ -638,29 +403,16 @@ class ActionPlanner:
|
|
|
638
403
|
"including any matches you may have overlooked."
|
|
639
404
|
)
|
|
640
405
|
raw = self._chat_with_image(prompt, image_path, max_tokens=128,
|
|
641
|
-
family=
|
|
406
|
+
family=PromptFamily.GRID)
|
|
642
407
|
out = self._normalize_grid(self._parse_json(raw), total)
|
|
643
408
|
self._log(f"grid selection -> {out}")
|
|
644
409
|
return out
|
|
645
410
|
|
|
646
411
|
@staticmethod
|
|
647
412
|
def _normalize_grid(data: Any, total: int) -> List[int]:
|
|
648
|
-
"""Map the model's grid JSON to a list of 1-indexed cell numbers.
|
|
649
|
-
|
|
650
|
-
Accepts the trained shape (a bare JSON array) plus the wrapped forms the
|
|
651
|
-
model drifts into: {"target_ids": [...]} and {"action": {"target_ids":
|
|
652
|
-
[...]}}. Out-of-range and non-numeric entries are dropped rather than
|
|
653
|
-
raising — a single junk element must not cost the whole selection.
|
|
654
|
-
|
|
655
|
-
Split out of get_grid_selection so the parse is testable without a
|
|
656
|
-
model, mirroring _normalize_pixel.
|
|
657
|
-
"""
|
|
658
413
|
if isinstance(data, list):
|
|
659
414
|
ids = data
|
|
660
415
|
elif isinstance(data, dict):
|
|
661
|
-
# `action` is a dict on the grid path but a bare string ("drag") on
|
|
662
|
-
# the pixel path — guard so a mis-routed response returns [] rather
|
|
663
|
-
# than raising AttributeError.
|
|
664
416
|
nested = data.get("action")
|
|
665
417
|
nested_ids = nested.get("target_ids") if isinstance(nested, dict) else None
|
|
666
418
|
ids = data.get("target_ids") or nested_ids or []
|
|
@@ -678,56 +430,20 @@ class ActionPlanner:
|
|
|
678
430
|
return out
|
|
679
431
|
|
|
680
432
|
def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
|
|
681
|
-
"""Solve a non-grid click/drag/slide/text puzzle.
|
|
682
|
-
|
|
683
|
-
Sends the trained action prompt + image, parses the model's JSON, and
|
|
684
|
-
returns a list of normalized actions with all coordinates on a 0–1
|
|
685
|
-
scale:
|
|
686
|
-
|
|
687
|
-
{"kind": "click", "points": [(x, y), ...]}
|
|
688
|
-
{"kind": "drag", "src": (x, y), "dst": (x, y)}
|
|
689
|
-
{"kind": "slide", "dst": (x, y)} — puzzle-piece slider
|
|
690
|
-
{"kind": "type", "text": "<the code>"} — distorted text
|
|
691
|
-
|
|
692
|
-
`text_mode` swaps in the distorted-text prompt. It is chosen by the
|
|
693
|
-
DRIVER, from the widget's DOM (a visible text box in the challenge),
|
|
694
|
-
because nothing about the picture reliably says "this one is typed" —
|
|
695
|
-
BotDetect's warped letters and hCaptcha's "click the matching letter"
|
|
696
|
-
look alike to a pixel classifier, and the two want opposite answers.
|
|
697
|
-
|
|
698
|
-
Returns [] if the model produced nothing usable. The solver turns these
|
|
699
|
-
into ClickAction / DragAction / TypeAction.
|
|
700
|
-
"""
|
|
701
433
|
prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
|
|
702
434
|
raw = self._chat_with_image(prompt, image_path, max_tokens=512,
|
|
703
|
-
family=
|
|
435
|
+
family=PromptFamily.TEXT if text_mode else PromptFamily.PIXEL)
|
|
704
436
|
data = self._parse_json(raw)
|
|
705
437
|
actions = self._normalize_pixel(data)
|
|
706
438
|
self._log(f"pixel actions -> {actions}")
|
|
707
439
|
return actions
|
|
708
440
|
|
|
709
441
|
def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
|
|
710
|
-
"""Solve an ANIMATED challenge from its keyframes.
|
|
711
|
-
|
|
712
|
-
Same normalized actions as `get_pixel_actions`, each additionally carrying
|
|
713
|
-
`"frame"`: the 1-based keyframe the model chose to act on, or None if it did
|
|
714
|
-
not name a usable one.
|
|
715
|
-
|
|
716
|
-
The frame is not decoration. On these puzzles the target is only there part
|
|
717
|
-
of the time, so the coordinates are only correct while the widget looks like
|
|
718
|
-
the frame they were read off. The page driver holds the mouse until it does.
|
|
719
|
-
An action with `frame=None` is still returned — the caller decides whether to
|
|
720
|
-
click blind or give up, and dropping it here would look identical to "the
|
|
721
|
-
model had nothing to say".
|
|
722
|
-
"""
|
|
723
442
|
if not keyframe_paths:
|
|
724
443
|
return []
|
|
725
|
-
# Per model, not per client: generation 1 has no animated prompt at all,
|
|
726
|
-
# so this raises a clear error there instead of sending a v1 model a
|
|
727
|
-
# keyframe request it was never trained to read.
|
|
728
444
|
prompt = self.prompts.video_prompt(len(keyframe_paths))
|
|
729
445
|
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
|
|
730
|
-
family=
|
|
446
|
+
family=PromptFamily.VIDEO)
|
|
731
447
|
data = self._parse_json(raw)
|
|
732
448
|
actions = self._normalize_pixel(data)
|
|
733
449
|
frame = self._normalize_frame(data, len(keyframe_paths))
|
|
@@ -738,14 +454,7 @@ class ActionPlanner:
|
|
|
738
454
|
|
|
739
455
|
@staticmethod
|
|
740
456
|
def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
|
|
741
|
-
"""
|
|
742
|
-
|
|
743
|
-
Out-of-range is treated as "did not choose" rather than clamped. Clamping a
|
|
744
|
-
7 to 6 would invent an intent the model never had, and the driver would then
|
|
745
|
-
wait for a picture the answer was not about — a slow failure that looks like
|
|
746
|
-
a flaky page. Tolerates the nested shape the model drifts into
|
|
747
|
-
({"action": {"frame": n}}), the same way the click/drag parsing does.
|
|
748
|
-
"""
|
|
457
|
+
"""Out of range is None, not clamped: clamping a 7 to 6 would invent an intent."""
|
|
749
458
|
if not isinstance(data, dict):
|
|
750
459
|
return None
|
|
751
460
|
raw = data.get("frame")
|
|
@@ -759,22 +468,13 @@ class ActionPlanner:
|
|
|
759
468
|
|
|
760
469
|
@staticmethod
|
|
761
470
|
def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
|
|
762
|
-
"""
|
|
763
|
-
|
|
764
|
-
Tolerant of the two trained shapes plus a few near-misses:
|
|
765
|
-
click: {"action": {"action": "click", "points": [[x, y], ...]}}
|
|
766
|
-
or top-level {"points": [...]} / {"action": {"points": [...]}}
|
|
767
|
-
drag: {"output": [{"Action": "simulate_drag",
|
|
768
|
-
"SourcePosition": {x, y},
|
|
769
|
-
"EstimatedPosition": {x, y}}, ...]}
|
|
770
|
-
"""
|
|
471
|
+
"""Every answer shape the model has been seen to produce, oldest last; `kind` is a PixelAnswerKind."""
|
|
771
472
|
def norm_xy(x: Any, y: Any) -> Optional[tuple]:
|
|
772
473
|
try:
|
|
773
474
|
fx, fy = float(x) / 1000.0, float(y) / 1000.0
|
|
774
475
|
except (TypeError, ValueError):
|
|
775
476
|
return None
|
|
776
477
|
if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
|
|
777
|
-
# Some outputs use 0–1 already; accept those too.
|
|
778
478
|
if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
|
|
779
479
|
fx, fy = float(x), float(y)
|
|
780
480
|
else:
|
|
@@ -782,10 +482,6 @@ class ActionPlanner:
|
|
|
782
482
|
return (fx, fy)
|
|
783
483
|
|
|
784
484
|
def flat_numbers(v: Any) -> List[float]:
|
|
785
|
-
"""Every number anywhere inside v, in order. Handles nested lists,
|
|
786
|
-
{x,y} dicts, bare numbers, and numeric strings like "277, 728" (the
|
|
787
|
-
model sometimes emits coordinates as strings, occasionally even split
|
|
788
|
-
across separate array elements)."""
|
|
789
485
|
nums: List[float] = []
|
|
790
486
|
if isinstance(v, bool):
|
|
791
487
|
return nums
|
|
@@ -803,10 +499,6 @@ class ActionPlanner:
|
|
|
803
499
|
return nums
|
|
804
500
|
|
|
805
501
|
def coordish(v: Any) -> bool:
|
|
806
|
-
"""True when v is a non-empty list of COORDINATES (numbers, [x,y]
|
|
807
|
-
pairs, {x,y} dicts, or numeric strings) — not text labels like
|
|
808
|
-
"dog". Used to decide whether a "click"/"coordinates" array carries
|
|
809
|
-
points we should salvage."""
|
|
810
502
|
if not isinstance(v, (list, tuple)) or not v:
|
|
811
503
|
return False
|
|
812
504
|
for e in v:
|
|
@@ -823,29 +515,15 @@ class ActionPlanner:
|
|
|
823
515
|
if not isinstance(data, dict):
|
|
824
516
|
return out
|
|
825
517
|
|
|
826
|
-
# ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
|
|
827
|
-
# distorted-text captchas: {"action": "type", "text": "<the code>"}).
|
|
828
|
-
# First, because it is the one answer family with NO coordinate in it:
|
|
829
|
-
# every branch below keys off a number, so a typed answer that fell
|
|
830
|
-
# through reached the end and normalized to nothing.
|
|
831
|
-
# The code is passed through VERBATIM — case and spacing are part of what
|
|
832
|
-
# the model read off the image, and "tidying" them submits a different
|
|
833
|
-
# answer than the one it gave.
|
|
834
518
|
act = data.get("action")
|
|
835
|
-
if isinstance(act, dict) and act.get("action") ==
|
|
836
|
-
act = act.get("action")
|
|
837
|
-
if act ==
|
|
519
|
+
if isinstance(act, dict) and act.get("action") == ActionKind.TYPE:
|
|
520
|
+
act = act.get("action")
|
|
521
|
+
if act == ActionKind.TYPE or (isinstance(data.get("text"), str) and "drags" not in data):
|
|
838
522
|
container = data if isinstance(data.get("text"), str) else data.get("action")
|
|
839
523
|
text = container.get("text") if isinstance(container, dict) else None
|
|
840
524
|
if isinstance(text, str) and text:
|
|
841
|
-
return [{"kind":
|
|
842
|
-
|
|
843
|
-
# ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
|
|
844
|
-
# LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
|
|
845
|
-
# {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
|
|
846
|
-
# Note "action" here is the STRING "drag", not a dict, so the click path
|
|
847
|
-
# below never sees it. The legacy/simulate_drag branches that follow stay
|
|
848
|
-
# as fallbacks for older adapters.
|
|
525
|
+
return [{"kind": PixelAnswerKind.TYPE, "text": text}]
|
|
526
|
+
|
|
849
527
|
content_drags = data.get("drags")
|
|
850
528
|
if content_drags is None and isinstance(data.get("action"), dict):
|
|
851
529
|
content_drags = data["action"].get("drags")
|
|
@@ -858,27 +536,19 @@ class ActionPlanner:
|
|
|
858
536
|
snums = flat_numbers(d.get("from"))
|
|
859
537
|
dnums = flat_numbers(d.get("to"))
|
|
860
538
|
if len(dnums) < 2:
|
|
861
|
-
continue
|
|
539
|
+
continue
|
|
862
540
|
dst = norm_xy(dnums[0], dnums[1])
|
|
863
541
|
if not dst:
|
|
864
542
|
continue
|
|
865
543
|
if len(snums) >= 2:
|
|
866
544
|
src = norm_xy(snums[0], snums[1])
|
|
867
545
|
if src:
|
|
868
|
-
out.append({"kind":
|
|
546
|
+
out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
|
|
869
547
|
else:
|
|
870
|
-
|
|
871
|
-
# PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
|
|
872
|
-
# leave the source empty precisely because the piece is not
|
|
873
|
-
# what you pick up: the handle is, somewhere else entirely,
|
|
874
|
-
# and how far it has to travel is not knowable from the
|
|
875
|
-
# picture. Requiring both ends here dropped the one answer
|
|
876
|
-
# shape the prompt asks for on every slide puzzle.
|
|
877
|
-
out.append({"kind": "slide", "dst": dst})
|
|
548
|
+
out.append({"kind": PixelAnswerKind.SLIDE, "dst": dst})
|
|
878
549
|
if out:
|
|
879
550
|
return out
|
|
880
551
|
|
|
881
|
-
# ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
|
|
882
552
|
drags = data.get("output")
|
|
883
553
|
if isinstance(drags, list) and drags:
|
|
884
554
|
for d in drags:
|
|
@@ -889,34 +559,17 @@ class ActionPlanner:
|
|
|
889
559
|
src = norm_xy(sp.get("x"), sp.get("y")) if isinstance(sp, dict) else None
|
|
890
560
|
dst = norm_xy(ep.get("x"), ep.get("y")) if isinstance(ep, dict) else None
|
|
891
561
|
if src and dst:
|
|
892
|
-
out.append({"kind":
|
|
562
|
+
out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
|
|
893
563
|
if out:
|
|
894
564
|
return out
|
|
895
565
|
|
|
896
|
-
# ---- drag: {"action": {"simulate_drag": [{source_position,
|
|
897
|
-
# destination_position}]}} ----
|
|
898
|
-
# The full-puzzle LoRA actually emits drags in this snake_case shape
|
|
899
|
-
# (not the prompt's {"output":[{"Action":"simulate_drag",...}]}), and
|
|
900
|
-
# packs each coordinate as {"x": [x, y]} — flat_numbers() pulls the pair
|
|
901
|
-
# out of that. Without this branch a correctly-solved drag (e.g. hCaptcha
|
|
902
|
-
# "drag ONE character to the matching character") is dropped as
|
|
903
|
-
# "unsupported".
|
|
904
566
|
sd = data.get("simulate_drag")
|
|
905
567
|
if sd is None and isinstance(action := data.get("action"), dict):
|
|
906
568
|
sd = action.get("simulate_drag")
|
|
907
|
-
# The model emits simulate_drag as EITHER a list of drags OR a single
|
|
908
|
-
# drag object; normalize the single-object form to a one-element list so
|
|
909
|
-
# both parse (the object form was silently dropped as "unsupported").
|
|
910
569
|
if isinstance(sd, dict):
|
|
911
570
|
sd = [sd]
|
|
912
571
|
if isinstance(sd, list) and sd:
|
|
913
572
|
def _drag_coords(obj: Any, *roles: str) -> List[float]:
|
|
914
|
-
# Pull the source/destination coordinate pair regardless of key
|
|
915
|
-
# casing or separators — the LoRA freely varies between
|
|
916
|
-
# source_position / sourcePosition / SourcePosition and
|
|
917
|
-
# destination_position / destinationPosition / EstimatedPosition.
|
|
918
|
-
# The coord value may be {"x": [x, y]}, [x, y], {x, y}, or a
|
|
919
|
-
# string; flat_numbers() handles all of those.
|
|
920
573
|
if not isinstance(obj, dict):
|
|
921
574
|
return []
|
|
922
575
|
for k, v in obj.items():
|
|
@@ -937,15 +590,11 @@ class ActionPlanner:
|
|
|
937
590
|
src = norm_xy(snums[0], snums[1])
|
|
938
591
|
dst = norm_xy(dnums[0], dnums[1])
|
|
939
592
|
if src and dst:
|
|
940
|
-
out.append({"kind":
|
|
593
|
+
out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
|
|
941
594
|
if out:
|
|
942
595
|
return out
|
|
943
596
|
|
|
944
|
-
# ---- click: {"action": {"action":"click","points":[...]}} ----
|
|
945
597
|
action = data.get("action")
|
|
946
|
-
# The model sometimes over-wraps: {"action": {"action": {"action":
|
|
947
|
-
# "click", "points":[...]}}}. Peel nested "action" dicts until we reach
|
|
948
|
-
# the one carrying points/coords (bounded so we never loop forever).
|
|
949
598
|
for _ in range(4):
|
|
950
599
|
if (
|
|
951
600
|
isinstance(action, dict)
|
|
@@ -959,20 +608,13 @@ class ActionPlanner:
|
|
|
959
608
|
points = None
|
|
960
609
|
if isinstance(action, dict):
|
|
961
610
|
points = action.get("points")
|
|
962
|
-
|
|
963
|
-
if points is None and action.get("action") == "drag":
|
|
611
|
+
if points is None and action.get("action") == ActionKind.DRAG:
|
|
964
612
|
src = norm_xy(*(action.get("source") or (None, None)))
|
|
965
613
|
dst = norm_xy(*(action.get("target") or (None, None)))
|
|
966
614
|
if src and dst:
|
|
967
|
-
return [{"kind":
|
|
615
|
+
return [{"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst}]
|
|
968
616
|
if points is None:
|
|
969
617
|
points = data.get("points")
|
|
970
|
-
# Salvage: some responses carry the click coordinates under "click" (or
|
|
971
|
-
# "coordinates") instead of "points", as [x,y] pairs, {x,y} dicts, or
|
|
972
|
-
# "x, y" strings (sometimes split across elements), with no "points" key
|
|
973
|
-
# at all. Pull every number out of a coordinate-like value and pair them
|
|
974
|
-
# into points so a well-intentioned answer isn't dropped as "unsupported".
|
|
975
|
-
# Skipped when the value is text labels ("dog", "duck") rather than coords.
|
|
976
618
|
if not (isinstance(points, list) and points):
|
|
977
619
|
for container in (action if isinstance(action, dict) else None, data):
|
|
978
620
|
if not isinstance(container, dict):
|
|
@@ -999,5 +641,5 @@ class ActionPlanner:
|
|
|
999
641
|
if xy:
|
|
1000
642
|
pts.append(xy)
|
|
1001
643
|
if pts:
|
|
1002
|
-
out.append({"kind":
|
|
644
|
+
out.append({"kind": PixelAnswerKind.CLICK, "points": pts})
|
|
1003
645
|
return out
|