captchakraken 3.2.0 → 4.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.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +621 -259
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +224 -131
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -11,18 +11,13 @@ from typing import Any, Dict, List, Optional
11
11
  import requests
12
12
  from PIL import Image
13
13
 
14
- from . import config, errors, prompts
14
+ from . import clipvideo, config, errors, prompts
15
+ from .clipvideo import ClipVideo
15
16
  from .kinds import ActionKind, PixelAnswerKind, PromptFamily, RetryMode
16
17
  from .server_manager import ensure_server
17
18
 
18
19
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
19
20
 
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.
23
- MIN_PIXELS = 448 * 448
24
-
25
-
26
21
  def _encode_image(path: str,
27
22
  budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
28
23
  budget = budget or prompts.pixel_budget(None)
@@ -159,28 +154,6 @@ PIXEL_ACTION_PROMPT = _LATEST.action_prompt
159
154
 
160
155
 
161
156
 
162
- # Greedy only, by measurement: (0.0, 0.35, 0.7) scored 0/5 against 4/5 and made the median failed attempt
163
- # 24.0s against 13.9s, because a differing re-ask resets the no-progress counter. The level plumbing stays for
164
- # a network-refusal trigger. Lives here once so the two ports cannot drift (TRIBAL_KNOWLEDGE.md).
165
- RESAMPLE_TEMPERATURES = (0.0,)
166
-
167
-
168
- def sampling_for_level(level: int) -> Dict[str, Any]:
169
- temps = RESAMPLE_TEMPERATURES or (0.0,)
170
- temp = temps[min(max(level, 0), len(temps) - 1)]
171
- if not temp:
172
- return {}
173
- return {"temperature": temp, "seed": 1000 + level}
174
-
175
-
176
- def _sampling_from_env() -> Dict[str, Any]:
177
- raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
178
- if not raw:
179
- return {}
180
- try:
181
- return sampling_for_level(int(raw))
182
- except ValueError:
183
- return {}
184
157
 
185
158
 
186
159
  #: The ceiling on one ask, and the old hardcoded value. Measured why it matters: against the hosted
@@ -206,6 +179,70 @@ def _warn_legacy_model_once(model: str) -> None:
206
179
  print(f"[captchakraken] {prompts.LEGACY_MODEL_WARNING.format(model=model)}", file=sys.stderr)
207
180
 
208
181
 
182
+ def _norm_xy(x: Any, y: Any) -> Optional[tuple]:
183
+ """A model point on the 0-1000 scale as 0-1; a normalised pair passes, anything off the image is clamped."""
184
+ try:
185
+ fx, fy = float(x) / 1000.0, float(y) / 1000.0
186
+ except (TypeError, ValueError):
187
+ return None
188
+ if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
189
+ if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
190
+ fx, fy = float(x), float(y)
191
+ else:
192
+ fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
193
+ return (fx, fy)
194
+
195
+
196
+ # Closer than this (0-1 of the board) and two pointer clicks are one target. Small enough that neighbouring icons in a
197
+ # click-in-order puzzle stay two targets.
198
+ REPEATED_POINT = 0.004
199
+ # Closer than this (0-1 of the board) at both ends and two pointer drags move one piece to one place: the server's
200
+ # SAME_PLACE.
201
+ REPEATED_DRAG = 0.025
202
+ #: Refused answers sent back with one pointer ask: the server's MAX_AVOID.
203
+ POINTER_AVOID_MAX = 32
204
+
205
+
206
+ def _without_repeated_points(action: Dict[str, Any]) -> Dict[str, Any]:
207
+ """A pointer click without its repeats. Two click slots that pick the same token answer the same point, and on a
208
+ tile board the second click deselects what the first selected."""
209
+ points = action.get("points")
210
+ if action.get("kind") != PixelAnswerKind.CLICK or not points:
211
+ return action
212
+ keep = [i for i, p in enumerate(points)
213
+ if all(abs(p[0] - q[0]) > REPEATED_POINT or abs(p[1] - q[1]) > REPEATED_POINT for q in points[:i])]
214
+ if len(keep) == len(points):
215
+ return action
216
+ ids = action.get("ids") or []
217
+ return {**action, "points": [points[i] for i in keep],
218
+ "ids": [ids[i] for i in keep] if len(ids) == len(points) else ids}
219
+
220
+
221
+ def _same_drag(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
222
+ return all(abs(a[end][i] - b[end][i]) <= REPEATED_DRAG for end in ("src", "dst") for i in (0, 1))
223
+
224
+
225
+ def _is_drag(action: Dict[str, Any]) -> bool:
226
+ return action.get("kind") == PixelAnswerKind.DRAG
227
+
228
+
229
+ def _without_repeated_drags(plan: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
230
+ """A pointer plan without a drag it already made: the piece is already there, and a board that counts its moves
231
+ counts the second one. The count head can ask for more drags than there are pieces, and then the extra slots read
232
+ the same piece and the same target."""
233
+ out: List[Dict[str, Any]] = []
234
+ for action in plan:
235
+ if not (_is_drag(action) and any(_is_drag(o) and _same_drag(action, o) for o in out)):
236
+ out.append(action)
237
+ return out
238
+
239
+
240
+ def _same_drags(a: List[Dict[str, Any]], b: List[Dict[str, Any]]) -> bool:
241
+ """Do two plans make the same drags, one after the other?"""
242
+ return bool(a) and len(a) == len(b) and all(_is_drag(x) and _is_drag(y) and _same_drag(x, y)
243
+ for x, y in zip(a, b))
244
+
245
+
209
246
  class ActionPlanner:
210
247
 
211
248
  def __init__(
@@ -223,7 +260,19 @@ class ActionPlanner:
223
260
  if config.is_hosted_endpoint(self.base_url) and prompts.is_legacy_hosted_name(self.model):
224
261
  _warn_legacy_model_once(self.model)
225
262
  self.api_key = api_key or config.api_key()
226
- self.sampling: Dict[str, Any] = {}
263
+ #: The pointer's answers on the current board, oldest first. The pointer's heads are an argmax, so a
264
+ #: re-ask cannot move them: once the caller sets `avoid_refused`, every ask names these and the
265
+ #: server answers with its likeliest OTHER answer. The caller clears both when the board changes.
266
+ self.pointer_answers: List[Dict[str, Any]] = []
267
+ #: The complete plans the server returned after the first (`actions[1:]`); the next retry uses them
268
+ #: without asking again. `pointer_has_actions` says the server answered with the list at all, so an
269
+ #: older server's `avoid` retry still works.
270
+ self.pointer_fallbacks: List[Dict[str, Any]] = []
271
+ self.pointer_has_actions = False
272
+ #: Earlier screens of the same paged puzzle, oldest first, as file paths. A FunCaptcha pager's answer
273
+ #: depends on the pages already seen; without them the model keeps answering "next arrow".
274
+ self.pointer_history: List[str] = []
275
+ self.avoid_refused = False
227
276
  #: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
228
277
  #: that outlives it cannot be cancelled once it is in flight — the deadline is only read
229
278
  #: between steps. Left alone it is the old ceiling; `PageSolver` lowers it to what remains.
@@ -304,7 +353,11 @@ class ActionPlanner:
304
353
  mime, b64 = _encode_image(p, self.pixel_budget)
305
354
  parts.append({"type": "image_url",
306
355
  "image_url": {"url": f"data:{mime};base64,{b64}"}})
356
+ return self._chat(prompt, parts, max_tokens=max_tokens, family=family)
307
357
 
358
+ def _chat(self, prompt: str, parts: List[Dict[str, Any]], max_tokens: int,
359
+ family: Optional[PromptFamily]) -> str:
360
+ """POST one chat completion: the media parts, then the prompt, in one user message."""
308
361
  messages = [
309
362
  {
310
363
  "role": "system",
@@ -326,10 +379,6 @@ class ActionPlanner:
326
379
  "chat_template_kwargs": {"enable_thinking": False},
327
380
  "reasoning_effort": "none",
328
381
  }
329
- sampling = self.sampling or _sampling_from_env()
330
- if sampling:
331
- payload.update(sampling)
332
-
333
382
  headers = {
334
383
  "Authorization": f"Bearer {self.api_key}",
335
384
  "Content-Type": "application/json",
@@ -345,7 +394,7 @@ class ActionPlanner:
345
394
 
346
395
  url = f"{self.base_url}/chat/completions"
347
396
  self._log(f"POST {url} model={model} max_tokens={max_tokens} "
348
- f"images={len(parts)}")
397
+ f"parts={[p['type'] for p in parts]}")
349
398
 
350
399
  resp = self._http.post(url, headers=headers, json=payload,
351
400
  timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
@@ -485,8 +534,111 @@ class ActionPlanner:
485
534
  out.append(iv)
486
535
  return out
487
536
 
537
+ #: Generation 4's endpoint, beside /chat/completions: the board in, generation 3's answer shape out.
538
+ _POINTER_PATH = "/pointer"
539
+
540
+ def _ask_pointer(self, body: Dict[str, Any]) -> Dict[str, Any]:
541
+ """POST one board to the pointer server. It numbers the board's elements, builds the prompt and reads the
542
+ answer from one prefill, so nothing here is a prompt."""
543
+ headers = {
544
+ "Authorization": f"Bearer {self.api_key}",
545
+ "Content-Type": "application/json",
546
+ **routing_headers(hosted=config.is_hosted_endpoint(self.base_url)),
547
+ }
548
+ url = f"{self.base_url}{self._POINTER_PATH}"
549
+ self._log(f"POST {url} model={self.model} media={sorted(k for k in body if k not in ('model', 'avoid'))}")
550
+ if self.avoid_refused and self.pointer_answers:
551
+ body = {**body, "avoid": self.pointer_answers[-POINTER_AVOID_MAX:]}
552
+ self._log(f"asking past {len(body['avoid'])} refused answer(s)")
553
+ resp = self._http.post(url, headers=headers, json={"model": self.model, **body},
554
+ timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
555
+ if not resp.ok:
556
+ raise errors.from_response(resp, url)
557
+ try:
558
+ data = resp.json()
559
+ except ValueError:
560
+ raise RuntimeError(f"the pointer server returned a non-JSON body from {url}: {(resp.text or '')[:300]}")
561
+ answer = data.get("answer") if isinstance(data, dict) else None
562
+ if not isinstance(answer, dict):
563
+ raise RuntimeError(f"the pointer server's reply from {url} has no answer: {str(data)[:300]}")
564
+ actions = data.get("actions") if isinstance(data, dict) else None
565
+ self.pointer_has_actions = isinstance(actions, list) and bool(actions)
566
+ self.pointer_fallbacks = self._other_plans(answer, actions) if self.pointer_has_actions else []
567
+ self._log(f"pointer answer: {answer}")
568
+ self.pointer_answers.append({k: v for k, v in answer.items() if k != "checks"})
569
+ return answer
570
+
571
+ def _other_plans(self, answer: Dict[str, Any], actions: List[Any]) -> List[Dict[str, Any]]:
572
+ """The server's answers after the first, without any that only repeats a drag of one before it: pressed again
573
+ on a board that refused it, it costs a move and can tell the board nothing new. The action is taken as read: on
574
+ 1,954 real boards, backups of another action rescued 2, and pressed 237 moves a vendor sees and refuses."""
575
+ seen = [_without_repeated_drags(self._normalize_general(answer))]
576
+ out = []
577
+ for other in [a for a in actions if isinstance(a, dict)][1:]:
578
+ if other.get("action") != answer.get("action"):
579
+ continue
580
+ plan = _without_repeated_drags(self._normalize_general(other))
581
+ if not any(_same_drags(plan, s) for s in seen):
582
+ seen.append(plan)
583
+ out.append(other)
584
+ return out
585
+
586
+ def pointer_fallback_actions(self) -> List[List[Dict[str, Any]]]:
587
+ """The complete plans after the first, and a refresh when they run out; [] for a non-pointer model."""
588
+ if not self.pointer_has_actions:
589
+ return []
590
+ plans = [[_without_repeated_points(a) for a in _without_repeated_drags(self._normalize_general(f))]
591
+ for f in self.pointer_fallbacks]
592
+ return [p for p in plans if p] + [[{"kind": PixelAnswerKind.REFRESH, "points": []}]]
593
+
594
+ def get_pointer_actions(self, image_path: str) -> List[Dict[str, Any]]:
595
+ """Generation 4 on a still: the screenshot as it is, PNG; the server sizes it and marks it.
596
+
597
+ On a retry the server's other complete plans come first, then a refresh: a refused answer is never
598
+ asked again, and the model's exhausted board is replaced rather than re-queried."""
599
+ if self.avoid_refused and self.pointer_has_actions:
600
+ if self.pointer_fallbacks:
601
+ answer = self.pointer_fallbacks.pop(0)
602
+ self.pointer_answers.append({k: v for k, v in answer.items() if k != "checks"})
603
+ self._log(f"pointer fallback: {answer.get('action')}")
604
+ else:
605
+ answer = {"action": ActionKind.REFRESH, "points": []}
606
+ self._log("pointer fallbacks exhausted; asking for a new board")
607
+ actions = [_without_repeated_points(a) for a in _without_repeated_drags(self._normalize_general(answer))]
608
+ else:
609
+ with open(image_path, "rb") as f:
610
+ raw = f.read()
611
+ body: Dict[str, Any] = {"image": base64.b64encode(raw).decode()}
612
+ if self.pointer_history:
613
+ history = []
614
+ for path in self.pointer_history[-8:]:
615
+ try:
616
+ with open(path, "rb") as f:
617
+ history.append(base64.b64encode(f.read()).decode())
618
+ except OSError:
619
+ continue
620
+ if history:
621
+ body["history"] = history
622
+ actions = [_without_repeated_points(a) for a in
623
+ _without_repeated_drags(self._normalize_general(self._ask_pointer(body)))]
624
+ self._log(f"pointer actions -> {actions}")
625
+ return actions
626
+
627
+ def get_general_actions(self, image_path: str) -> List[Dict[str, Any]]:
628
+ """Generation 3: the one prompt, whatever the puzzle, and no family to route by."""
629
+ if self.prompts.pointer:
630
+ return self.get_pointer_actions(image_path)
631
+ raw = self._chat_with_image(self.prompts.general or "", image_path, max_tokens=512)
632
+ actions = self._normalize_general(self._parse_json(raw))
633
+ self._log(f"general actions -> {actions}")
634
+ return actions
635
+
488
636
  def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
489
- prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
637
+ if self.prompts.pointer:
638
+ return self.get_pointer_actions(image_path)
639
+ if self.prompts.general is not None:
640
+ return self.get_general_actions(image_path)
641
+ prompt = self.prompts.text_prompt() if text_mode else self.prompts.pixel_prompt()
490
642
  raw = self._chat_with_image(prompt, image_path, max_tokens=512,
491
643
  family=PromptFamily.TEXT if text_mode else PromptFamily.PIXEL)
492
644
  data = self._parse_json(raw)
@@ -495,6 +647,9 @@ class ActionPlanner:
495
647
  return actions
496
648
 
497
649
  def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
650
+ if self.prompts.sends_video:
651
+ raise ValueError(f"prompt generation {self.prompts.version} answers from the clip, not from keyframes; "
652
+ "use get_video_actions")
498
653
  if not keyframe_paths:
499
654
  return []
500
655
  prompt = self.prompts.video_prompt(len(keyframe_paths))
@@ -508,6 +663,58 @@ class ActionPlanner:
508
663
  self._log(f"keyframe actions (frame={frame}) -> {actions}")
509
664
  return actions
510
665
 
666
+ def video_max_pixels(self) -> int:
667
+ """The frame budget a clip is cut to: the model's pixel ceiling, as its training frames were cut."""
668
+ if not self.pixel_budget.maximum:
669
+ raise ValueError(
670
+ f"model {self.model!r} declares no pixel ceiling (pixel_budget in models.json, or "
671
+ f"{prompts.MAX_PIXELS_ENV}), and a video is cut to exactly that size. Register the model's band.")
672
+ return int(self.pixel_budget.maximum)
673
+
674
+ def get_video_actions(self, clip: ClipVideo) -> List[Dict[str, Any]]:
675
+ """One request carrying the whole clip as video; each action names the moment `t` it acts on, or None."""
676
+ if not clip.frames:
677
+ return []
678
+ if not self.prompts.sends_video:
679
+ raise ValueError(f"prompt generation {self.prompts.version} answers from keyframes, not from a clip; "
680
+ "use get_keyframe_actions")
681
+ if self.prompts.pointer:
682
+ data: Any = self._ask_pointer({"frames": [base64.b64encode(clipvideo.encode_jpeg(f)).decode()
683
+ for f in clip.frames], "fps": clip.fps})
684
+ general = True
685
+ else:
686
+ parts = [{"type": "video_url", "video_url": {"url": clipvideo.data_url(clip)}}]
687
+ general = self.prompts.general is not None
688
+ raw = self._chat(self.prompts.video_prompt(), parts, max_tokens=512,
689
+ family=None if general else PromptFamily.VIDEO)
690
+ data = self._parse_json(raw)
691
+ actions = self._normalize_general(data) if general else self._normalize_pixel(data)
692
+ t = self._normalize_time(data, clip)
693
+ for a in actions:
694
+ a["t"] = t
695
+ self._log(f"video actions (t={t}) -> {actions}")
696
+ return actions
697
+
698
+ @staticmethod
699
+ def _normalize_time(data: Any, clip: ClipVideo) -> Optional[float]:
700
+ """A time outside the clip is None, not clamped: clamping would invent an intent."""
701
+ if not isinstance(data, dict):
702
+ return None
703
+ raw = data.get("t")
704
+ if raw is None and isinstance(data.get("action"), dict):
705
+ raw = data["action"].get("t")
706
+ if isinstance(raw, bool):
707
+ return None
708
+ try:
709
+ t = float(raw)
710
+ except (TypeError, ValueError):
711
+ return None
712
+ # Half a pair's width either side of the first and last label: anything further names no moment shown.
713
+ slack = 1.0 / clip.fps
714
+ if not clip.timestamps or not clip.timestamps[0] - slack <= t <= clip.timestamps[-1] + slack:
715
+ return None
716
+ return t
717
+
511
718
  @staticmethod
512
719
  def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
513
720
  """Out of range is None, not clamped: clamping a 7 to 6 would invent an intent."""
@@ -522,20 +729,61 @@ class ActionPlanner:
522
729
  return None
523
730
  return n if 1 <= n <= n_keyframes else None
524
731
 
732
+ @staticmethod
733
+ def _normalize_general(data: Any) -> List[Dict[str, Any]]:
734
+ """Generation 3's answer. Click and refresh are read here because an empty point list means something for
735
+ them (nothing matches; no control seen) and a click may name boxed ids; drag, hold and type have generation
736
+ 2's shape and go through `_normalize_pixel`."""
737
+ if not isinstance(data, dict):
738
+ return []
739
+ act = data.get("action")
740
+ if act == ActionKind.SLIDE:
741
+ return ActionPlanner._normalize_slide(data)
742
+ if act == ActionKind.HOLD and "press" in data:
743
+ return ActionPlanner._normalize_hold(data)
744
+ if act not in (ActionKind.CLICK, ActionKind.REFRESH):
745
+ return ActionPlanner._normalize_pixel(data)
746
+ raw_points = data.get("points")
747
+ points = [xy for xy in (_norm_xy(p[0], p[1]) for p in (raw_points if isinstance(raw_points, list) else [])
748
+ if isinstance(p, (list, tuple)) and len(p) >= 2) if xy]
749
+ if act == ActionKind.REFRESH:
750
+ return [{"kind": PixelAnswerKind.REFRESH, "points": points[:1]}]
751
+ raw_ids = data.get("ids")
752
+ ids = [int(i) for i in (raw_ids if isinstance(raw_ids, list) else [])
753
+ if not isinstance(i, bool) and str(i).strip().isdigit()]
754
+ return [{"kind": PixelAnswerKind.CLICK, "points": points, "ids": ids}]
755
+
756
+ @staticmethod
757
+ def _answer_point(data: Dict[str, Any], key: str) -> Optional[tuple]:
758
+ v = data.get(key)
759
+ if not isinstance(v, (list, tuple)) or len(v) < 2 or any(isinstance(c, bool) for c in v[:2]):
760
+ return None
761
+ return _norm_xy(v[0], v[1])
762
+
763
+ @staticmethod
764
+ def _normalize_hold(data: Dict[str, Any]) -> List[Dict[str, Any]]:
765
+ """The press, and the fill's line when both its ends are named: one end alone says nothing about where to let
766
+ go, so the hold falls back to waiting for the widget to finish."""
767
+ press = ActionPlanner._answer_point(data, "press")
768
+ if press is None:
769
+ return []
770
+ src, dst = ActionPlanner._answer_point(data, "from"), ActionPlanner._answer_point(data, "to")
771
+ line = {"src": src, "dst": dst} if src is not None and dst is not None else {}
772
+ return [{"kind": PixelAnswerKind.HOLD, "points": [press], **line}]
773
+
774
+ @staticmethod
775
+ def _normalize_slide(data: Dict[str, Any]) -> List[Dict[str, Any]]:
776
+ """All three points or nothing: a slide missing one has no defined stop, and guessing it would steer the
777
+ handle to a place the model never named."""
778
+ handle, src, dst = (ActionPlanner._answer_point(data, k) for k in ("handle", "from", "to"))
779
+ if handle is None or src is None or dst is None:
780
+ return []
781
+ return [{"kind": PixelAnswerKind.SLIDE, "handle": handle, "src": src, "dst": dst}]
782
+
525
783
  @staticmethod
526
784
  def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
527
785
  """Every answer shape the model has been seen to produce, oldest last; `kind` is a PixelAnswerKind."""
528
- def norm_xy(x: Any, y: Any) -> Optional[tuple]:
529
- try:
530
- fx, fy = float(x) / 1000.0, float(y) / 1000.0
531
- except (TypeError, ValueError):
532
- return None
533
- if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
534
- if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
535
- fx, fy = float(x), float(y)
536
- else:
537
- fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
538
- return (fx, fy)
786
+ norm_xy = _norm_xy
539
787
 
540
788
  def flat_numbers(v: Any) -> List[float]:
541
789
  nums: List[float] = []
@@ -696,6 +944,9 @@ class ActionPlanner:
696
944
  xy = None
697
945
  if xy:
698
946
  pts.append(xy)
947
+ if pts and data.get("action") == ActionKind.HOLD:
948
+ # One button, held: a second point would be a second press the widget never asked for.
949
+ return [{"kind": PixelAnswerKind.HOLD, "points": pts[:1]}]
699
950
  if pts:
700
951
  out.append({"kind": PixelAnswerKind.CLICK, "points": pts})
701
952
  return out
@@ -0,0 +1,20 @@
1
+ """The pointer solver: a captcha answered from ONE forward pass, never generated.
2
+
3
+ The board is shown to a Qwen3.5 model (still, or a video), optionally with the
4
+ element detector's findings drawn over it as thin numbered outlines and listed
5
+ in the prompt. Every question the answer needs (which action, how many clicks,
6
+ where each one lands, what to type, which moment of a video) has a marker
7
+ token in the prompt, and small heads read each marker's hidden state from the
8
+ prefill. The answer always carries points; it carries an element's number too
9
+ when the point lands on a numbered element.
10
+
11
+ layout the prompt, the questions and the packed decision vector
12
+ elements detector output -> numbered elements, and the element under a point
13
+ marks the outlines and numbers drawn on the board
14
+ heads the torch heads (training and serving share them)
15
+ detector the element detector (Mask R-CNN, ResNet-18 FPN)
16
+ vllm_plugin the heads inside vLLM's pooling runner
17
+ service board in, answer out: detector, marks, vLLM, decode
18
+
19
+ Only `layout`, `elements` and `marks` import without torch.
20
+ """