captchakraken 2.3.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.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Track the puzzle piece of a slide captcha by what MOVED, not by what it is.
|
|
2
|
+
|
|
3
|
+
A puzzle-piece slider gives the model one thing to say — where the gap is — and
|
|
4
|
+
gives the driver no way to act on it directly. You drag a handle somewhere else
|
|
5
|
+
on the widget; the piece follows at a ratio nobody publishes, clamped to a track
|
|
6
|
+
whose ends you cannot read off the picture. Vendors vary the ratio precisely so
|
|
7
|
+
that a solver cannot compute the handle offset from the gap position.
|
|
8
|
+
|
|
9
|
+
So the driver does not compute it. It presses the handle, nudges, looks at what
|
|
10
|
+
changed on screen, and closes the loop. This module is the "looks at what
|
|
11
|
+
changed" step, and nothing more: one function, one bounding box.
|
|
12
|
+
|
|
13
|
+
changed_bbox(before, after, exclude=...) -> [x1, y1, x2, y2] | None
|
|
14
|
+
|
|
15
|
+
The box is the union of every pixel that differs between two screenshots of the
|
|
16
|
+
same widget. During a slider drag exactly one thing has moved — the piece — so
|
|
17
|
+
that union is:
|
|
18
|
+
|
|
19
|
+
left edge = the piece's ORIGINAL left edge (the vacated ground)
|
|
20
|
+
right edge = the piece's CURRENT right edge
|
|
21
|
+
|
|
22
|
+
which is what makes it useful: neither edge alone gives the piece's centre, but
|
|
23
|
+
two of these boxes at two known handle offsets give both the piece's width and
|
|
24
|
+
the handle-to-piece ratio, by simple algebra the caller does. See
|
|
25
|
+
`PageSolver._execute_slide`.
|
|
26
|
+
|
|
27
|
+
`exclude` is the reason this takes an argument at all. The handle is also
|
|
28
|
+
moving, and on most vendors so is a filled progress bar behind it, and both are
|
|
29
|
+
inside the same screenshot. Left in, they would dominate the union and the
|
|
30
|
+
tracked "piece" would just be the handle. The caller passes the handle's own
|
|
31
|
+
rectangle — it has the element, so it knows exactly where it is — and this masks
|
|
32
|
+
it out. Everything else in the frame is static during a drag.
|
|
33
|
+
|
|
34
|
+
Pure OpenCV, no network, byte-deterministic.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from typing import Optional, Sequence
|
|
40
|
+
|
|
41
|
+
import cv2
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
# A pixel counts as changed at this 0–255 grayscale delta. Low enough to catch a
|
|
45
|
+
# piece sliding over background of similar luminance, high enough to ignore JPEG
|
|
46
|
+
# ringing and the 1–2 level jitter of an antialiased edge redrawn a subpixel
|
|
47
|
+
# over.
|
|
48
|
+
_DIFF_THRESHOLD = 18
|
|
49
|
+
|
|
50
|
+
# Below this many changed pixels the frame is treated as UNCHANGED. A press that
|
|
51
|
+
# has not moved yet, or a drag against the end of the track, produces a handful
|
|
52
|
+
# of stray pixels; calling that a piece would hand the caller a garbage box and
|
|
53
|
+
# it would steer by it.
|
|
54
|
+
_MIN_CHANGED_PIXELS = 40
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def changed_bbox(
|
|
58
|
+
before_path: str,
|
|
59
|
+
after_path: str,
|
|
60
|
+
exclude: Optional[Sequence[float]] = None,
|
|
61
|
+
) -> Optional[list]:
|
|
62
|
+
"""Bounding box of everything that moved between two shots of one widget.
|
|
63
|
+
|
|
64
|
+
`exclude` is an [x1, y1, x2, y2] rectangle in the same pixel space, masked
|
|
65
|
+
out before measuring — pass the slider handle's own box.
|
|
66
|
+
|
|
67
|
+
Returns [x1, y1, x2, y2] in pixels, or None when nothing moved (or the
|
|
68
|
+
images are unreadable / mismatched, which is the same answer to the caller:
|
|
69
|
+
steer by the last good reading instead of by noise).
|
|
70
|
+
"""
|
|
71
|
+
a = cv2.imread(before_path, cv2.IMREAD_GRAYSCALE)
|
|
72
|
+
b = cv2.imread(after_path, cv2.IMREAD_GRAYSCALE)
|
|
73
|
+
if a is None or b is None or a.shape != b.shape:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
mask = (cv2.absdiff(a, b) > _DIFF_THRESHOLD).astype(np.uint8)
|
|
77
|
+
|
|
78
|
+
if exclude is not None:
|
|
79
|
+
x1, y1, x2, y2 = (int(round(float(v))) for v in exclude)
|
|
80
|
+
# Clamp to the frame — a handle drawn flush with the widget edge, or one
|
|
81
|
+
# whose box is reported a pixel outside it, must still mask.
|
|
82
|
+
x1, y1 = max(0, x1), max(0, y1)
|
|
83
|
+
x2, y2 = min(mask.shape[1], x2), min(mask.shape[0], y2)
|
|
84
|
+
if x2 > x1 and y2 > y1:
|
|
85
|
+
mask[y1:y2, x1:x2] = 0
|
|
86
|
+
|
|
87
|
+
# Erode away single-pixel speckle (compression noise, cursor artifacts)
|
|
88
|
+
# before measuring: the box is an EXTREME of the mask, so one stray pixel in
|
|
89
|
+
# the corner moves it as much as the piece does.
|
|
90
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
|
|
91
|
+
|
|
92
|
+
if int(mask.sum()) < _MIN_CHANGED_PIXELS:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
ys, xs = np.nonzero(mask)
|
|
96
|
+
return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Human-like mouse trajectories for the page driver.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
human trace out of a bundled ~MB corpus (`data/trajectories.json.gz`). That
|
|
7
|
-
corpus is the package's own asset, so the Python port synthesises comparable
|
|
8
|
-
motion instead of shipping a second copy of someone else's data.
|
|
4
|
+
The Python side of `js/src/trajectory.ts`; the two are one implementation in
|
|
5
|
+
two languages, pinned by `test_trajectory_parity.py`.
|
|
9
6
|
|
|
10
7
|
The CONTRACT is identical, which is what matters for keeping the two drivers in
|
|
11
8
|
step: given a start point, an end point and a sampling frequency, return
|
|
@@ -89,8 +86,6 @@ def generate_trajectory(
|
|
|
89
86
|
frequency_randomizer: float = 0.12,
|
|
90
87
|
) -> Tuple[List[Point], List[float]]:
|
|
91
88
|
"""
|
|
92
|
-
Mirror of cursory-ts `generate_trajectory`.
|
|
93
|
-
|
|
94
89
|
Returns `(points, timings)`. `timings` is CUMULATIVE milliseconds from the
|
|
95
90
|
gesture start — not per-step deltas — because that is what the TS driver's
|
|
96
91
|
`tracePath` sleeps against, and the two must pace identically.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-solve watcher — the Python mirror of `js/src/watcher.ts`.
|
|
3
|
+
|
|
4
|
+
Install it once and captchas are solved as they appear, instead of the caller
|
|
5
|
+
having to know where in their script a challenge might show up.
|
|
6
|
+
|
|
7
|
+
WHY THIS IS A POLL AND NOT AN INJECTED OBSERVER
|
|
8
|
+
The obvious implementation is a `MutationObserver` in the page that calls out
|
|
9
|
+
through an exposed binding. That reacts faster, and it is also the one design
|
|
10
|
+
that cannot be made stealthy everywhere: an exposed binding puts a function on
|
|
11
|
+
`window`, and an observer is script the page can enumerate. Under Camoufox both
|
|
12
|
+
are sandboxed and invisible; under vanilla Playwright or patchright they are
|
|
13
|
+
not, and a captcha vendor is precisely the party that looks.
|
|
14
|
+
|
|
15
|
+
So this injects NOTHING. It drives the same `detect_captcha()` the solver
|
|
16
|
+
already uses, from the driver side, on a timer:
|
|
17
|
+
|
|
18
|
+
- nothing is added to the page on ANY launcher, so there is no new detection
|
|
19
|
+
surface to reason about per platform;
|
|
20
|
+
- under Camoufox the probe's DOM reads land in its isolated Juggler world for
|
|
21
|
+
free — that is Camoufox's default for all Playwright evaluation, with
|
|
22
|
+
`main_world_eval` / an "mw:" prefix as the opt-OUT. This never opts out, so
|
|
23
|
+
it is isolated there by construction rather than by special-casing;
|
|
24
|
+
- reaction time is bounded by `interval_ms`, not by the mutation.
|
|
25
|
+
|
|
26
|
+
ONE WATCHER COVERS THE WHOLE PAGE, FOR ITS WHOLE LIFE
|
|
27
|
+
A page outlives navigation, and so does the watcher holding it: install it once
|
|
28
|
+
and it keeps probing across every `goto`, staying quiet while there is nothing
|
|
29
|
+
to solve and re-arming after each solve. That is deliberately the whole API --
|
|
30
|
+
the case that motivates a browser-wide installer is almost always a challenge
|
|
31
|
+
appearing on request 40 of a run, on the SAME page object the run started with,
|
|
32
|
+
and that is already covered by one line. Pinned in test_browser_compat.py.
|
|
33
|
+
|
|
34
|
+
WHY IT BLOCKS, WHERE THE TS PORT DOES NOT (CLAUDE.md 1c)
|
|
35
|
+
`solver.watch(page)` in TypeScript returns immediately and watches in the
|
|
36
|
+
background. It cannot here, and the reason is Playwright's, not ours: a SYNC
|
|
37
|
+
Playwright handle is bound to the greenlet that created it, so driving the page
|
|
38
|
+
from a worker thread raises. `threading` would not buy a background watcher, it
|
|
39
|
+
would buy an exception on the first probe.
|
|
40
|
+
|
|
41
|
+
The two shapes offered instead:
|
|
42
|
+
|
|
43
|
+
solver.watch(page).run() # blocking: hold this page clean
|
|
44
|
+
watcher = solver.watch(page)
|
|
45
|
+
while my_own_loop():
|
|
46
|
+
watcher.poll_once() # cooperative: you own the cadence
|
|
47
|
+
|
|
48
|
+
`poll_once()` is the one to reach for inside an existing automation loop; it
|
|
49
|
+
does exactly one probe, solves if there is something to solve, and returns.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import time
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from typing import Any, Callable, Optional, Tuple
|
|
57
|
+
|
|
58
|
+
__all__ = ["CaptchaWatcher"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _page_is_closed(page: Any) -> bool:
|
|
62
|
+
"""True when the launcher says the page is gone.
|
|
63
|
+
|
|
64
|
+
Duck-typed like everything else that touches the page: Playwright and
|
|
65
|
+
Puppeteer both expose `is_closed`/`isClosed`, but this module refuses to
|
|
66
|
+
depend on either, and a launcher without it is simply treated as open.
|
|
67
|
+
"""
|
|
68
|
+
for name in ("is_closed", "isClosed"):
|
|
69
|
+
probe = getattr(page, name, None)
|
|
70
|
+
if callable(probe):
|
|
71
|
+
try:
|
|
72
|
+
return bool(probe())
|
|
73
|
+
except Exception:
|
|
74
|
+
return False
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _report(callback: Optional[Callable[[Any], Any]], value: Any) -> None:
|
|
79
|
+
"""Hand a value to a user callback without letting its failure reach the loop."""
|
|
80
|
+
if callback is None:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
callback(value)
|
|
84
|
+
except Exception:
|
|
85
|
+
# A throwing callback is the caller's bug, not a reason to stop watching.
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class CaptchaWatcher:
|
|
91
|
+
"""Polls one page and solves whatever captcha becomes visible on it."""
|
|
92
|
+
|
|
93
|
+
solver: Any
|
|
94
|
+
page: Any
|
|
95
|
+
#: Milliseconds between probes. Each tick costs one `detect_captcha()` — a
|
|
96
|
+
#: handful of selector queries. Below ~250ms you pay real CPU for latency
|
|
97
|
+
#: the solve itself (seconds) makes irrelevant.
|
|
98
|
+
interval_ms: int = 1000
|
|
99
|
+
#: Stop after this many successful solves. None means unlimited.
|
|
100
|
+
max_solves: Optional[int] = None
|
|
101
|
+
#: Wait this long after a solve RAISES before probing again. Without it a
|
|
102
|
+
#: permanently unsupported challenge — an invisible reCAPTCHA v3, say —
|
|
103
|
+
#: becomes a hot loop that re-attempts and re-bills every interval forever.
|
|
104
|
+
error_backoff_ms: int = 5000
|
|
105
|
+
#: Called with the `SolveResult` of each completed solve.
|
|
106
|
+
on_solved: Optional[Callable[[Any], Any]] = None
|
|
107
|
+
#: Called with any exception from detection or solving. Errors are reported,
|
|
108
|
+
#: never fatal: `NoCaptchaFoundError` fires routinely when a widget vanishes
|
|
109
|
+
#: between the probe and the solve, and a watcher that died on it would be
|
|
110
|
+
#: useless.
|
|
111
|
+
on_error: Optional[Callable[[BaseException], Any]] = None
|
|
112
|
+
|
|
113
|
+
solves: int = field(default=0, init=False)
|
|
114
|
+
_stopped: bool = field(default=False, init=False)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def running(self) -> bool:
|
|
118
|
+
"""False once stopped, the page closed, or `max_solves` was reached."""
|
|
119
|
+
if self._stopped or _page_is_closed(self.page):
|
|
120
|
+
return False
|
|
121
|
+
return self.max_solves is None or self.solves < self.max_solves
|
|
122
|
+
|
|
123
|
+
def stop(self) -> None:
|
|
124
|
+
"""End the loop. Safe to call from `on_solved` / `on_error`, or twice."""
|
|
125
|
+
self._stopped = True
|
|
126
|
+
|
|
127
|
+
def poll_once(self) -> Optional[Any]:
|
|
128
|
+
"""
|
|
129
|
+
One probe. Solves if a captcha is visible; returns its `SolveResult`,
|
|
130
|
+
or None when there was nothing to do (or the attempt failed and was
|
|
131
|
+
reported to `on_error`).
|
|
132
|
+
|
|
133
|
+
Does NOT sleep — the caller owns the cadence. `run()` is this plus the
|
|
134
|
+
waiting and the backoff.
|
|
135
|
+
"""
|
|
136
|
+
return self._attempt()[0]
|
|
137
|
+
|
|
138
|
+
def _attempt(self) -> Tuple[Optional[Any], bool]:
|
|
139
|
+
"""
|
|
140
|
+
`(result, failed)`.
|
|
141
|
+
|
|
142
|
+
The second element is the whole reason this is not just `poll_once`:
|
|
143
|
+
that method returns None both for "no captcha on the page" and for "a
|
|
144
|
+
solve was attempted and raised", and `run()` must back off after the
|
|
145
|
+
second while re-probing cheaply after the first. Collapsing the two
|
|
146
|
+
either hot-loops on an unsupported challenge or adds a five-second
|
|
147
|
+
stall to every idle tick.
|
|
148
|
+
"""
|
|
149
|
+
if not self.running:
|
|
150
|
+
return None, False
|
|
151
|
+
try:
|
|
152
|
+
if not self.solver.detect_captcha(self.page):
|
|
153
|
+
return None, False
|
|
154
|
+
result = self.solver.solve(self.page)
|
|
155
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
156
|
+
# KeyboardInterrupt / SystemExit are deliberately NOT caught: a
|
|
157
|
+
# Ctrl-C during a long solve must reach the caller.
|
|
158
|
+
if _page_is_closed(self.page):
|
|
159
|
+
self.stop()
|
|
160
|
+
return None, False
|
|
161
|
+
_report(self.on_error, exc)
|
|
162
|
+
return None, True
|
|
163
|
+
if result is not None:
|
|
164
|
+
self.solves += 1
|
|
165
|
+
_report(self.on_solved, result)
|
|
166
|
+
return result, False
|
|
167
|
+
|
|
168
|
+
def _sleep(self, ms: float) -> None:
|
|
169
|
+
"""Sleep in slices so `stop()` and Ctrl-C are not held for a whole interval."""
|
|
170
|
+
deadline = time.monotonic() + ms / 1000.0
|
|
171
|
+
while time.monotonic() < deadline:
|
|
172
|
+
if self._stopped:
|
|
173
|
+
return
|
|
174
|
+
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
|
175
|
+
|
|
176
|
+
def run(self, timeout_ms: Optional[int] = None) -> int:
|
|
177
|
+
"""
|
|
178
|
+
Watch until `stop()`, the page closes, `max_solves` is hit, or
|
|
179
|
+
`timeout_ms` elapses. Returns the number of captchas solved.
|
|
180
|
+
|
|
181
|
+
Blocking by design — see this module's docstring on why the TypeScript
|
|
182
|
+
port can hand back a background handle and this cannot.
|
|
183
|
+
"""
|
|
184
|
+
deadline = None if timeout_ms is None else time.monotonic() + timeout_ms / 1000.0
|
|
185
|
+
while self.running:
|
|
186
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
187
|
+
break
|
|
188
|
+
self._sleep(self.interval_ms)
|
|
189
|
+
if not self.running:
|
|
190
|
+
break
|
|
191
|
+
if self._attempt()[1]:
|
|
192
|
+
self._sleep(self.error_backoff_ms)
|
|
193
|
+
return self.solves
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
from typing import List, Literal, Optional, Union, Dict
|
|
2
|
-
from pydantic import BaseModel, Field, model_validator
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class PlannerAction(BaseModel):
|
|
6
|
-
goal: Optional[str] = Field(None, description="Description of the visual goal")
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class PlannerClickAction(PlannerAction):
|
|
10
|
-
action: Literal["click"]
|
|
11
|
-
target_ids: List[int] = Field(description="List of object IDs or grid cell numbers to click")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class PlannerDragAction(PlannerAction):
|
|
15
|
-
action: Literal["drag"]
|
|
16
|
-
source_id: int = Field(description="Object ID of item to drag")
|
|
17
|
-
target_id: int = Field(description="Object ID of destination")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class PlannerTypeAction(PlannerAction):
|
|
21
|
-
action: Literal["type"]
|
|
22
|
-
text: str = Field(description="Text to type into the input")
|
|
23
|
-
target_id: int = Field(description="Object ID of the input field")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class PlannerWaitAction(PlannerAction):
|
|
27
|
-
action: Literal["wait"]
|
|
28
|
-
duration_ms: int = Field(default=500, description="Duration to wait in milliseconds")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class PlannerDoneAction(PlannerAction):
|
|
32
|
-
action: Literal["done"]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class DragGuess(BaseModel):
|
|
36
|
-
Action: Literal["simulate_drag"]
|
|
37
|
-
SourceID: int
|
|
38
|
-
DestinationDescription: str
|
|
39
|
-
EstimatedPosition: Dict[str, int] = Field(description="{'x': 1-1000, 'y': 1-1000}")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class DragRefineResult(BaseModel):
|
|
43
|
-
SourceId: int
|
|
44
|
-
estimatedVerticalDistanceFromTarget: int = Field(description="negative = above, positive = below")
|
|
45
|
-
estimatedHorizontalDistanceFromTarget: int = Field(description="negative = left, positive = right")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class PlannerDragRefineAction(PlannerAction):
|
|
49
|
-
# This is for Step 3: Verification and adjustment
|
|
50
|
-
output: List[DragRefineResult]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class PlannerToolCall(BaseModel):
|
|
54
|
-
name: Literal["detect", "simulate_drag"]
|
|
55
|
-
args: dict
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class PlannerPlan(BaseModel):
|
|
59
|
-
goal: Optional[str] = None
|
|
60
|
-
tool_calls: Optional[List[PlannerToolCall]] = None
|
|
61
|
-
# Step 1: Detect all draggable items
|
|
62
|
-
objectDescription: Optional[str] = None
|
|
63
|
-
# Step 2: Initial guess (general LoRa) uses output
|
|
64
|
-
output: Optional[List[DragGuess]] = None
|
|
65
|
-
# We allow the planner to return either a direct action or tool calls or new output format
|
|
66
|
-
action: Optional[Union[PlannerClickAction, PlannerDragAction, PlannerTypeAction, PlannerWaitAction, PlannerDoneAction]] = None
|
|
67
|
-
|
|
68
|
-
@model_validator(mode="after")
|
|
69
|
-
def check_exclusive(self) -> "PlannerPlan":
|
|
70
|
-
# At least one must be provided
|
|
71
|
-
if not any([self.action, self.tool_calls, self.output, self.objectDescription]):
|
|
72
|
-
raise ValueError("Provide EITHER 'action', 'tool_calls', 'output', OR 'objectDescription'.")
|
|
73
|
-
return self
|
|
74
|
-
|