captchakraken 2.4.0 → 2.6.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 (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +214 -12
  19. package/dist/solver.js +1285 -165
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +128 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +2 -5
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +25 -8
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +4 -3
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1465 -102
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
@@ -9,20 +9,18 @@
9
9
  "for the legacy output/PascalCase schema. The model answered in a hybrid,",
10
10
  "the parser dropped it, and EVERY drag puzzle failed as 'unsupported' with",
11
11
  "no test going red. Recording the prompt hashes here means any edit to a",
12
- "serving prompt fails CI until someone consciously re-pins — which forces",
12
+ "serving prompt fails CI until someone consciously re-pins \u2014 which forces",
13
13
  "the question 'does the pinned adapter still expect this prompt?'.",
14
14
  "",
15
15
  "To ship a new adapter: update lora_adapter/lora_revision, re-run the Tier 2",
16
16
  "static-image gate against it, and update prompt_sha256 if the prompts moved."
17
17
  ],
18
-
19
18
  "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
20
- "lora_adapter": "CaptchaKraken/CaptchaKraken_v1.1",
19
+ "lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
21
20
  "lora_revision": "main",
22
- "lora_name": "captcha",
23
-
21
+ "lora_name": "captcha-v12",
24
22
  "prompt_sha256": {
25
- "PIXEL_ACTION_PROMPT": "15d4833135330aa57ec64eb9132d15739cbf4a10bcda2e6d85d2d0ad92e0216a",
26
- "SELECT_GRID_PROMPT": "f014dbc1cb8c773cc9501ff97aea92ff0281e5687b3d637d1c626ded9212fd3c"
23
+ "PIXEL_ACTION_PROMPT": "21afc1def6faaa706ba4a8afc2e243f949fc6bb8936e624ecf8be0b82baeaae1",
24
+ "SELECT_GRID_PROMPT": "b259d39ac78adc92c4fe4e78aee248d073342aa4a797e9708d37dcea7532367d"
27
25
  }
28
26
  }
@@ -8,7 +8,9 @@ planner with detect/segment/drag-refine; that code is preserved on the
8
8
  """
9
9
 
10
10
  import base64
11
+ import io
11
12
  import json
13
+ import math
12
14
  import os
13
15
  import re
14
16
  import sys
@@ -16,13 +18,80 @@ from mimetypes import guess_type
16
18
  from typing import Any, Dict, List, Optional
17
19
 
18
20
  import requests
21
+ from PIL import Image
19
22
 
20
- from . import config, errors
23
+ from . import config, errors, prompts
21
24
  from .server_manager import ensure_server
22
25
  from .timing import timed
23
26
 
24
27
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
25
28
 
29
+ # The pixel floor the adapters are TRAINED at, and therefore the floor an image
30
+ # has to clear before it is worth sending.
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.
50
+ MIN_PIXELS = 448 * 448
51
+
52
+
53
+ def _encode_image(path: str,
54
+ 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
+ budget = budget or prompts.pixel_budget(None)
65
+ with open(path, "rb") as f:
66
+ raw = f.read()
67
+ mime = guess_type(path)[0] or "image/png"
68
+ try:
69
+ im = Image.open(io.BytesIO(raw))
70
+ width, height = im.size
71
+ except Exception: # noqa: BLE001 — unreadable/animated: send it as-is
72
+ return mime, base64.b64encode(raw).decode()
73
+ cap = budget.maximum
74
+ 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
+ scale = math.sqrt(cap / (width * height))
78
+ im = im.convert("RGB").resize(
79
+ (max(1, int(width * scale)), max(1, int(height * scale))),
80
+ Image.BICUBIC)
81
+ buf = io.BytesIO()
82
+ im.save(buf, "PNG")
83
+ return "image/png", base64.b64encode(buf.getvalue()).decode()
84
+ if width * height >= budget.minimum:
85
+ 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
+ scale = math.sqrt(budget.minimum / (width * height))
89
+ im = im.convert("RGB").resize(
90
+ (math.ceil(width * scale), math.ceil(height * scale)), Image.BICUBIC)
91
+ buf = io.BytesIO()
92
+ im.save(buf, "PNG")
93
+ return "image/png", base64.b64encode(buf.getvalue()).decode()
94
+
26
95
  # Header the fleet's haproxy front (on the reverse-proxy EC2) routes on. When a
27
96
  # caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
28
97
  # `X-JH-Priority: <n>`; haproxy sends anything above its threshold (5) straight
@@ -160,45 +229,50 @@ def routing_headers(env=None) -> Dict[str, str]:
160
229
  return headers
161
230
 
162
231
 
163
- # Matches the training-distribution grid prompts in
164
- # cleanSamples/test/test_solutions.json -> grade.synthesize_instruction.
165
- # Drifting from these costs measurable accuracy.
166
- SELECT_GRID_PROMPT = """Solve the captcha grid by choosing the cell numbers that match the description from the captcha image prompt.
167
-
168
- Grid: {rows}x{cols} ({total} cells)
169
- {grid_hint}
170
-
171
- If no tiles match the description (e.g., they have all been cleared or none were present), return an empty list for target_ids: [].
172
-
173
- Return JSON Array: [list of cell numbers (1-{total})]"""
174
-
175
-
176
- # Non-grid click/drag puzzles. MUST stay byte-identical to
177
- # src/synthetic/reasoning/instructions.py::ACTION_INSTRUCTION in the finetune
178
- # repo — that is the exact prompt the LoRA was trained (and graded) on. Drift
179
- # here silently degrades every click/drag puzzle. Coordinates come back on a
180
- # 0–1000 scale; the solver converts them to 0–1 bboxes.
181
- PIXEL_ACTION_PROMPT = (
182
- "Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
183
- "Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
184
- "normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
185
- "Name WHAT each object is with a short 1–2 word label, then give its position. "
186
- "Choose ONE response:\n\n"
187
- "FOR CLICK PUZZLES:\n"
188
- " Label each thing you click and give its point — subjects[i] names points[i]:\n"
189
- " → \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
190
- "\"points\": [[x1, y1], [x2, y2], ...]\n\n"
191
- "FOR DRAG PUZZLES:\n"
192
- " Drag ONE item at a time. Label the source (the piece you pick up) and the destination "
193
- "(where it belongs), each with a short 1–2 word label, and give both points:\n"
194
- " → \"action\": \"drag\", \"drags\": [{ \"source\": \"<label>\", \"from\": [x, y], "
195
- "\"destination\": \"<label>\", \"to\": [x, y] }, ...]\n\n"
196
- "Respond ONLY with JSON:\n"
197
- "{\n"
198
- " \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
199
- " // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
200
- "}"
201
- )
232
+ # ── Prompt constants ────────────────────────────────────────────────────────
233
+ #
234
+ # These are ALIASES for the newest generation's built-ins, kept so that code and
235
+ # tests written against the old module-level constants keep working. They are
236
+ # NOT what a solve sends. The prompt a model gets is resolved per model
237
+ # (`prompts.resolve`), because a model answers in whatever schema its prompt
238
+ # asks for — sending a version-1 model the version-2 prompt does not error, it
239
+ # silently degrades every puzzle. See prompts.py and models.json.
240
+ #
241
+ # The text itself lives in prompts.py and nowhere else. Two copies of a prompt
242
+ # is the drift this module already shipped once (2026-07-18).
243
+ _LATEST = prompts.builtin(prompts.LATEST_PROMPT_VERSION)
244
+
245
+ SELECT_GRID_PROMPT = _LATEST.grid_template
246
+
247
+
248
+ # Non-grid click/drag puzzles, newest generation. Alias — see the note above.
249
+ PIXEL_ACTION_PROMPT = _LATEST.action_prompt
250
+
251
+
252
+ # Animated puzzles: the challenge is recorded, sliced into keyframes
253
+ # (`keyframes.py`) and sent as an ORDINARY MULTI-IMAGE request — one image per
254
+ # keyframe, all in one context so the model can compare them. The answer carries
255
+ # a `frame` naming which keyframe it acted on, and the page driver waits for the
256
+ # live widget to look like that frame before pressing the mouse down.
257
+ #
258
+ # Alias for the newest generation — see the note above. Generation 1 has NO
259
+ # animated prompt (the family did not exist), which is why resolution is per
260
+ # model rather than per client.
261
+ VIDEO_ACTION_PROMPT_TEMPLATE = _LATEST.video_template
262
+
263
+
264
+ def video_action_prompt(n_keyframes: int) -> str:
265
+ """The prompt for a challenge served as `n_keyframes` stills.
266
+
267
+ The count is in the text because the model has no other way to know how many
268
+ images arrived — frame identities live in the prompt, not in the image payload.
269
+ Mirrors `instructions.video_instruction` in the finetune repo exactly.
270
+ """
271
+ n = int(n_keyframes)
272
+ if n < 1:
273
+ raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
274
+ listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
275
+ return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
202
276
 
203
277
 
204
278
  class ActionPlanner:
@@ -220,6 +294,21 @@ class ActionPlanner:
220
294
  self.model = model or config.lora_name()
221
295
  self.base_url = base_url or config.base_url()
222
296
  self.api_key = api_key or config.api_key()
297
+ # Prompts follow the MODEL, not this client's release. Resolve by the
298
+ # served name first (a hosted endpoint serves `captcha`, not a repo id,
299
+ # and models.json maps the alias back), falling back to the adapter repo
300
+ # id for a self-hosted deploy that serves under its own name.
301
+ #
302
+ # Doing this ONCE per planner rather than per request: resolution can
303
+ # touch the Hub, and a per-request lookup would put a network call in
304
+ # front of every round of every solve.
305
+ _prompt_key = (self.model if prompts.canonical_model_id(self.model)
306
+ else config.lora_adapter())
307
+ self.prompts = prompts.resolve(_prompt_key)
308
+ # Resolution follows the MODEL for the same reason prompts do, and off
309
+ # the same key: an adapter reads a puzzle at the pixel band it trained
310
+ # under. Resolved once per planner — see the note above.
311
+ self.pixel_budget = prompts.pixel_budget(_prompt_key)
223
312
  # Auto-start a local vLLM server on the first request if one isn't up
224
313
  # (no-op for a healthy or remote endpoint). Guarded so we only try once.
225
314
  self._server_ensured = False
@@ -231,11 +320,29 @@ class ActionPlanner:
231
320
  self.debug_callback(f"[Planner] {message}")
232
321
 
233
322
  def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
234
- with open(image_path, "rb") as f:
235
- b64 = base64.b64encode(f.read()).decode()
236
- mime, _ = guess_type(image_path)
237
- if mime is None:
238
- mime = "image/png"
323
+ return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens)
324
+
325
+ def _chat_with_images(
326
+ self, prompt: str, image_paths: List[str], max_tokens: int = 512
327
+ ) -> str:
328
+ """One request carrying N images followed by the prompt.
329
+
330
+ The images go in ONE message, before the text, in the order given. That
331
+ ordering is what makes an animated challenge answerable: the model is told
332
+ "you are given N keyframes, in order: frame 1, frame 2, …" and matches those
333
+ names to the images positionally. Splitting them across requests, or putting
334
+ the text first, would break the correspondence between the numbers in the
335
+ prompt and the pictures — and the frame number is the part the page driver
336
+ acts on.
337
+ """
338
+ if not image_paths:
339
+ raise ValueError("no images to send")
340
+
341
+ parts: List[Dict[str, Any]] = []
342
+ for p in image_paths:
343
+ mime, b64 = _encode_image(p, self.pixel_budget)
344
+ parts.append({"type": "image_url",
345
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
239
346
 
240
347
  messages = [
241
348
  {
@@ -244,10 +351,7 @@ class ActionPlanner:
244
351
  },
245
352
  {
246
353
  "role": "user",
247
- "content": [
248
- {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
249
- {"type": "text", "text": prompt},
250
- ],
354
+ "content": [*parts, {"type": "text", "text": prompt}],
251
355
  },
252
356
  ]
253
357
 
@@ -283,7 +387,8 @@ class ActionPlanner:
283
387
  self._server_ensured = True
284
388
 
285
389
  url = f"{self.base_url}/chat/completions"
286
- self._log(f"POST {url} model={self.model} max_tokens={max_tokens}")
390
+ self._log(f"POST {url} model={self.model} max_tokens={max_tokens} "
391
+ f"images={len(parts)}")
287
392
 
288
393
  with timed("planner.chat"):
289
394
  resp = requests.post(url, headers=headers, json=payload, timeout=120)
@@ -414,8 +519,8 @@ class ActionPlanner:
414
519
  else:
415
520
  grid_hint = "Hint: Separate images. Select only clear matches."
416
521
 
417
- prompt = SELECT_GRID_PROMPT.format(
418
- rows=rows, cols=cols, total=total, grid_hint=grid_hint
522
+ prompt = self.prompts.grid_prompt(
523
+ rows=rows, cols=cols, grid_hint=grid_hint
419
524
  )
420
525
  if retry_mode == "missed-tiles":
421
526
  prompt = (
@@ -465,8 +570,8 @@ class ActionPlanner:
465
570
  out.append(iv)
466
571
  return out
467
572
 
468
- def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
469
- """Solve a non-grid click/drag puzzle.
573
+ def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
574
+ """Solve a non-grid click/drag/slide/text puzzle.
470
575
 
471
576
  Sends the trained action prompt + image, parses the model's JSON, and
472
577
  returns a list of normalized actions with all coordinates on a 0–1
@@ -474,16 +579,75 @@ class ActionPlanner:
474
579
 
475
580
  {"kind": "click", "points": [(x, y), ...]}
476
581
  {"kind": "drag", "src": (x, y), "dst": (x, y)}
582
+ {"kind": "slide", "dst": (x, y)} — puzzle-piece slider
583
+ {"kind": "type", "text": "<the code>"} — distorted text
584
+
585
+ `text_mode` swaps in the distorted-text prompt. It is chosen by the
586
+ DRIVER, from the widget's DOM (a visible text box in the challenge),
587
+ because nothing about the picture reliably says "this one is typed" —
588
+ BotDetect's warped letters and hCaptcha's "click the matching letter"
589
+ look alike to a pixel classifier, and the two want opposite answers.
477
590
 
478
591
  Returns [] if the model produced nothing usable. The solver turns these
479
- into ClickAction / DragAction bboxes.
592
+ into ClickAction / DragAction / TypeAction.
480
593
  """
481
- raw = self._chat_with_image(PIXEL_ACTION_PROMPT, image_path, max_tokens=512)
594
+ prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
595
+ raw = self._chat_with_image(prompt, image_path, max_tokens=512)
482
596
  data = self._parse_json(raw)
483
597
  actions = self._normalize_pixel(data)
484
598
  self._log(f"pixel actions -> {actions}")
485
599
  return actions
486
600
 
601
+ def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
602
+ """Solve an ANIMATED challenge from its keyframes.
603
+
604
+ Same normalized actions as `get_pixel_actions`, each additionally carrying
605
+ `"frame"`: the 1-based keyframe the model chose to act on, or None if it did
606
+ not name a usable one.
607
+
608
+ The frame is not decoration. On these puzzles the target is only there part
609
+ of the time, so the coordinates are only correct while the widget looks like
610
+ the frame they were read off. The page driver holds the mouse until it does.
611
+ An action with `frame=None` is still returned — the caller decides whether to
612
+ click blind or give up, and dropping it here would look identical to "the
613
+ model had nothing to say".
614
+ """
615
+ if not keyframe_paths:
616
+ return []
617
+ # Per model, not per client: generation 1 has no animated prompt at all,
618
+ # so this raises a clear error there instead of sending a v1 model a
619
+ # keyframe request it was never trained to read.
620
+ prompt = self.prompts.video_prompt(len(keyframe_paths))
621
+ raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512)
622
+ data = self._parse_json(raw)
623
+ actions = self._normalize_pixel(data)
624
+ frame = self._normalize_frame(data, len(keyframe_paths))
625
+ for a in actions:
626
+ a["frame"] = frame
627
+ self._log(f"keyframe actions (frame={frame}) -> {actions}")
628
+ return actions
629
+
630
+ @staticmethod
631
+ def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
632
+ """The keyframe number the model chose, or None.
633
+
634
+ Out-of-range is treated as "did not choose" rather than clamped. Clamping a
635
+ 7 to 6 would invent an intent the model never had, and the driver would then
636
+ wait for a picture the answer was not about — a slow failure that looks like
637
+ a flaky page. Tolerates the nested shape the model drifts into
638
+ ({"action": {"frame": n}}), the same way the click/drag parsing does.
639
+ """
640
+ if not isinstance(data, dict):
641
+ return None
642
+ raw = data.get("frame")
643
+ if raw is None and isinstance(data.get("action"), dict):
644
+ raw = data["action"].get("frame")
645
+ try:
646
+ n = int(raw)
647
+ except (TypeError, ValueError):
648
+ return None
649
+ return n if 1 <= n <= n_keyframes else None
650
+
487
651
  @staticmethod
488
652
  def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
489
653
  """Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
@@ -550,6 +714,23 @@ class ActionPlanner:
550
714
  if not isinstance(data, dict):
551
715
  return out
552
716
 
717
+ # ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
718
+ # distorted-text captchas: {"action": "type", "text": "<the code>"}).
719
+ # First, because it is the one answer family with NO coordinate in it:
720
+ # every branch below keys off a number, so a typed answer that fell
721
+ # through reached the end and normalized to nothing.
722
+ # The code is passed through VERBATIM — case and spacing are part of what
723
+ # the model read off the image, and "tidying" them submits a different
724
+ # answer than the one it gave.
725
+ act = data.get("action")
726
+ if isinstance(act, dict) and act.get("action") == "type":
727
+ act = act.get("action") # unwrap {"action": {"action": "type", ...}}
728
+ if act == "type" or (isinstance(data.get("text"), str) and "drags" not in data):
729
+ container = data if isinstance(data.get("text"), str) else data.get("action")
730
+ text = container.get("text") if isinstance(container, dict) else None
731
+ if isinstance(text, str) and text:
732
+ return [{"kind": "type", "text": text}]
733
+
553
734
  # ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
554
735
  # LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
555
736
  # {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
@@ -567,11 +748,24 @@ class ActionPlanner:
567
748
  continue
568
749
  snums = flat_numbers(d.get("from"))
569
750
  dnums = flat_numbers(d.get("to"))
570
- if len(snums) >= 2 and len(dnums) >= 2:
751
+ if len(dnums) < 2:
752
+ continue # a piece with nowhere to go is not actionable
753
+ dst = norm_xy(dnums[0], dnums[1])
754
+ if not dst:
755
+ continue
756
+ if len(snums) >= 2:
571
757
  src = norm_xy(snums[0], snums[1])
572
- dst = norm_xy(dnums[0], dnums[1])
573
- if src and dst:
758
+ if src:
574
759
  out.append({"kind": "drag", "src": src, "dst": dst})
760
+ else:
761
+ # SOURCELESS — a puzzle-piece slider. The prompt's "FOR
762
+ # PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
763
+ # leave the source empty precisely because the piece is not
764
+ # what you pick up: the handle is, somewhere else entirely,
765
+ # and how far it has to travel is not knowable from the
766
+ # picture. Requiring both ends here dropped the one answer
767
+ # shape the prompt asks for on every slide puzzle.
768
+ out.append({"kind": "slide", "dst": dst})
575
769
  if out:
576
770
  return out
577
771