sentienceapi 0.90.16__py3-none-any.whl → 0.98.0__py3-none-any.whl
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.
Potentially problematic release.
This version of sentienceapi might be problematic. Click here for more details.
- sentience/__init__.py +120 -6
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +217 -0
- sentience/actions.py +758 -30
- sentience/agent.py +806 -293
- sentience/agent_config.py +3 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +89 -1141
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +483 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +678 -39
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +507 -42
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +77 -43
- sentience/cursor_policy.py +142 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +150 -287
- sentience/extension/injected_api.js +1088 -1368
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +275 -433
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/failure_artifacts.py +241 -0
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +765 -66
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +595 -3
- sentience/ordinal.py +280 -0
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +67 -5
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +128 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +599 -55
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +120 -5
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +105 -48
- sentience/tracer_factory.py +120 -9
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/{utils.py → utils/element.py} +3 -42
- sentience/utils/formatting.py +59 -0
- sentience/verification.py +618 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/constants.py
ADDED
|
@@ -5,12 +5,13 @@ Enables end users to control web automation using plain English
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import time
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Union
|
|
9
9
|
|
|
10
10
|
from .agent import SentienceAgent
|
|
11
11
|
from .browser import SentienceBrowser
|
|
12
12
|
from .llm_provider import LLMProvider
|
|
13
|
-
from .models import Snapshot, SnapshotOptions
|
|
13
|
+
from .models import ExtractionResult, Snapshot, SnapshotOptions, StepExecutionResult
|
|
14
|
+
from .protocols import BrowserProtocol
|
|
14
15
|
from .snapshot import snapshot
|
|
15
16
|
|
|
16
17
|
|
|
@@ -29,12 +30,18 @@ class ConversationalAgent:
|
|
|
29
30
|
The top result is from amazon.com selling the Apple Magic Mouse 2 for $79."
|
|
30
31
|
"""
|
|
31
32
|
|
|
32
|
-
def __init__(
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
browser: SentienceBrowser | BrowserProtocol,
|
|
36
|
+
llm: LLMProvider,
|
|
37
|
+
verbose: bool = True,
|
|
38
|
+
):
|
|
33
39
|
"""
|
|
34
40
|
Initialize conversational agent
|
|
35
41
|
|
|
36
42
|
Args:
|
|
37
|
-
browser: SentienceBrowser instance
|
|
43
|
+
browser: SentienceBrowser instance or BrowserProtocol-compatible object
|
|
44
|
+
(for testing, can use mock objects that implement BrowserProtocol)
|
|
38
45
|
llm: LLM provider (OpenAI, Anthropic, LocalLLM, etc.)
|
|
39
46
|
verbose: Print step-by-step execution logs (default: True)
|
|
40
47
|
"""
|
|
@@ -90,7 +97,7 @@ class ConversationalAgent:
|
|
|
90
97
|
step_result = self._execute_step(step)
|
|
91
98
|
execution_results.append(step_result)
|
|
92
99
|
|
|
93
|
-
if not step_result.
|
|
100
|
+
if not step_result.success:
|
|
94
101
|
# Early exit on failure
|
|
95
102
|
if self.verbose:
|
|
96
103
|
print(f"⚠️ Step failed: {step['description']}")
|
|
@@ -203,7 +210,7 @@ Create a step-by-step execution plan."""
|
|
|
203
210
|
"expected_outcome": "Complete user request",
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
def _execute_step(self, step: dict[str, Any]) ->
|
|
213
|
+
def _execute_step(self, step: dict[str, Any]) -> StepExecutionResult:
|
|
207
214
|
"""
|
|
208
215
|
Execute a single atomic step from the plan
|
|
209
216
|
|
|
@@ -230,46 +237,42 @@ Create a step-by-step execution plan."""
|
|
|
230
237
|
self.execution_context["current_url"] = url
|
|
231
238
|
time.sleep(1) # Brief wait for page to settle
|
|
232
239
|
|
|
233
|
-
return
|
|
240
|
+
return StepExecutionResult(success=True, action=action, data={"url": url})
|
|
234
241
|
|
|
235
242
|
elif action == "FIND_AND_CLICK":
|
|
236
243
|
element_desc = params["element_description"]
|
|
237
244
|
# Use technical agent to find and click (returns AgentActionResult)
|
|
238
245
|
result = self.technical_agent.act(f"Click the {element_desc}")
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
return StepExecutionResult(
|
|
247
|
+
success=result.success,
|
|
248
|
+
action=action,
|
|
249
|
+
data=result.model_dump(), # Convert to dict for flexibility
|
|
250
|
+
)
|
|
244
251
|
|
|
245
252
|
elif action == "FIND_AND_TYPE":
|
|
246
253
|
element_desc = params["element_description"]
|
|
247
254
|
text = params["text"]
|
|
248
255
|
# Use technical agent to find input and type (returns AgentActionResult)
|
|
249
256
|
result = self.technical_agent.act(f"Type '{text}' into {element_desc}")
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
return StepExecutionResult(
|
|
258
|
+
success=result.success,
|
|
259
|
+
action=action,
|
|
260
|
+
data={"text": text, "result": result.model_dump()},
|
|
261
|
+
)
|
|
255
262
|
|
|
256
263
|
elif action == "PRESS_KEY":
|
|
257
264
|
key = params["key"]
|
|
258
265
|
result = self.technical_agent.act(f"Press {key} key")
|
|
259
|
-
return
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
266
|
+
return StepExecutionResult(
|
|
267
|
+
success=result.success,
|
|
268
|
+
action=action,
|
|
269
|
+
data={"key": key, "result": result.model_dump()},
|
|
270
|
+
)
|
|
264
271
|
|
|
265
272
|
elif action == "WAIT":
|
|
266
273
|
duration = params.get("duration", 2.0)
|
|
267
274
|
time.sleep(duration)
|
|
268
|
-
return {
|
|
269
|
-
"success": True,
|
|
270
|
-
"action": action,
|
|
271
|
-
"data": {"duration": duration},
|
|
272
|
-
}
|
|
275
|
+
return StepExecutionResult(success=True, action=action, data={"duration": duration})
|
|
273
276
|
|
|
274
277
|
elif action == "EXTRACT_INFO":
|
|
275
278
|
info_type = params["info_type"]
|
|
@@ -279,21 +282,28 @@ Create a step-by-step execution plan."""
|
|
|
279
282
|
# Use LLM to extract specific information
|
|
280
283
|
extracted = self._extract_information(snap, info_type)
|
|
281
284
|
|
|
282
|
-
return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
return StepExecutionResult(
|
|
286
|
+
success=True,
|
|
287
|
+
action=action,
|
|
288
|
+
data={
|
|
289
|
+
"extracted": (
|
|
290
|
+
extracted.model_dump()
|
|
291
|
+
if isinstance(extracted, ExtractionResult)
|
|
292
|
+
else extracted
|
|
293
|
+
),
|
|
294
|
+
"info_type": info_type,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
287
297
|
|
|
288
298
|
elif action == "VERIFY":
|
|
289
299
|
condition = params["condition"]
|
|
290
300
|
# Verify condition using current page state
|
|
291
301
|
is_verified = self._verify_condition(condition)
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
302
|
+
return StepExecutionResult(
|
|
303
|
+
success=is_verified,
|
|
304
|
+
action=action,
|
|
305
|
+
data={"condition": condition, "verified": is_verified},
|
|
306
|
+
)
|
|
297
307
|
|
|
298
308
|
else:
|
|
299
309
|
raise ValueError(f"Unknown action: {action}")
|
|
@@ -301,9 +311,9 @@ Create a step-by-step execution plan."""
|
|
|
301
311
|
except Exception as e:
|
|
302
312
|
if self.verbose:
|
|
303
313
|
print(f"❌ Step failed: {e}")
|
|
304
|
-
return
|
|
314
|
+
return StepExecutionResult(success=False, action=action, error=str(e))
|
|
305
315
|
|
|
306
|
-
def _extract_information(self, snap: Snapshot, info_type: str) ->
|
|
316
|
+
def _extract_information(self, snap: Snapshot, info_type: str) -> ExtractionResult:
|
|
307
317
|
"""
|
|
308
318
|
Extract specific information from snapshot using LLM
|
|
309
319
|
|
|
@@ -403,14 +413,38 @@ Return JSON:
|
|
|
403
413
|
Human-readable response string
|
|
404
414
|
"""
|
|
405
415
|
# Build summary of what happened
|
|
406
|
-
successful_steps = [
|
|
407
|
-
|
|
416
|
+
successful_steps = [
|
|
417
|
+
r
|
|
418
|
+
for r in execution_results
|
|
419
|
+
if (isinstance(r, StepExecutionResult) and r.success)
|
|
420
|
+
or (isinstance(r, dict) and r.get("success", False))
|
|
421
|
+
]
|
|
422
|
+
failed_steps = [
|
|
423
|
+
r
|
|
424
|
+
for r in execution_results
|
|
425
|
+
if (isinstance(r, StepExecutionResult) and not r.success)
|
|
426
|
+
or (isinstance(r, dict) and not r.get("success", False))
|
|
427
|
+
]
|
|
408
428
|
|
|
409
429
|
# Extract key data
|
|
410
430
|
extracted_data = []
|
|
411
431
|
for result in execution_results:
|
|
412
|
-
if result
|
|
413
|
-
|
|
432
|
+
if isinstance(result, StepExecutionResult):
|
|
433
|
+
action = result.action
|
|
434
|
+
data = result.data
|
|
435
|
+
else:
|
|
436
|
+
action = result.get("action")
|
|
437
|
+
data = result.get("data", {})
|
|
438
|
+
|
|
439
|
+
if action == "EXTRACT_INFO":
|
|
440
|
+
extracted = data.get("extracted", {})
|
|
441
|
+
if isinstance(extracted, dict):
|
|
442
|
+
extracted_data.append(extracted)
|
|
443
|
+
else:
|
|
444
|
+
# If it's an ExtractionResult model, convert to dict
|
|
445
|
+
extracted_data.append(
|
|
446
|
+
extracted.model_dump() if hasattr(extracted, "model_dump") else extracted
|
|
447
|
+
)
|
|
414
448
|
|
|
415
449
|
# Use LLM to create natural response
|
|
416
450
|
system_prompt = """You are a helpful assistant that summarizes web automation results
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Human-like cursor movement policy + metadata.
|
|
3
|
+
|
|
4
|
+
This is intentionally SDK-local (no snapshot schema changes). It is used by actions to:
|
|
5
|
+
- generate more realistic mouse movement (multiple moves with easing, optional overshoot/jitter)
|
|
6
|
+
- emit trace/debug metadata describing the movement path
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CursorPolicy:
|
|
18
|
+
"""
|
|
19
|
+
Policy for cursor movement.
|
|
20
|
+
|
|
21
|
+
- mode="instant": current behavior (single click without multi-step motion)
|
|
22
|
+
- mode="human": move with a curved path + optional jitter/overshoot
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
mode: str = "instant" # "instant" | "human"
|
|
26
|
+
|
|
27
|
+
# Motion shaping (human mode)
|
|
28
|
+
steps: int | None = None
|
|
29
|
+
duration_ms: int | None = None
|
|
30
|
+
jitter_px: float = 1.0
|
|
31
|
+
overshoot_px: float = 6.0
|
|
32
|
+
pause_before_click_ms: int = 20
|
|
33
|
+
|
|
34
|
+
# Determinism hook for tests/repro
|
|
35
|
+
seed: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clamp(v: float, lo: float, hi: float) -> float:
|
|
39
|
+
return max(lo, min(hi, v))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ease_in_out(t: float) -> float:
|
|
43
|
+
# Smoothstep-ish easing
|
|
44
|
+
return t * t * (3 - 2 * t)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bezier(
|
|
48
|
+
p0: tuple[float, float],
|
|
49
|
+
p1: tuple[float, float],
|
|
50
|
+
p2: tuple[float, float],
|
|
51
|
+
p3: tuple[float, float],
|
|
52
|
+
t: float,
|
|
53
|
+
) -> tuple[float, float]:
|
|
54
|
+
u = 1.0 - t
|
|
55
|
+
tt = t * t
|
|
56
|
+
uu = u * u
|
|
57
|
+
uuu = uu * u
|
|
58
|
+
ttt = tt * t
|
|
59
|
+
x = uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0]
|
|
60
|
+
y = uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]
|
|
61
|
+
return (x, y)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_human_cursor_path(
|
|
65
|
+
*,
|
|
66
|
+
start: tuple[float, float],
|
|
67
|
+
target: tuple[float, float],
|
|
68
|
+
policy: CursorPolicy,
|
|
69
|
+
) -> dict:
|
|
70
|
+
"""
|
|
71
|
+
Build a human-like cursor path and metadata.
|
|
72
|
+
|
|
73
|
+
Returns a dict suitable for attaching to ActionResult/trace payloads:
|
|
74
|
+
{
|
|
75
|
+
"mode": "human",
|
|
76
|
+
"from": {"x":..., "y":...},
|
|
77
|
+
"to": {"x":..., "y":...},
|
|
78
|
+
"steps": ...,
|
|
79
|
+
"duration_ms": ...,
|
|
80
|
+
"pause_before_click_ms": ...,
|
|
81
|
+
"jitter_px": ...,
|
|
82
|
+
"overshoot_px": ...,
|
|
83
|
+
"path": [{"x":..., "y":..., "t":...}, ...]
|
|
84
|
+
}
|
|
85
|
+
"""
|
|
86
|
+
rng = random.Random(policy.seed)
|
|
87
|
+
|
|
88
|
+
x0, y0 = start
|
|
89
|
+
x1, y1 = target
|
|
90
|
+
dx = x1 - x0
|
|
91
|
+
dy = y1 - y0
|
|
92
|
+
dist = math.hypot(dx, dy)
|
|
93
|
+
|
|
94
|
+
# Defaults based on distance (bounded)
|
|
95
|
+
steps = int(policy.steps if policy.steps is not None else _clamp(10 + dist / 25.0, 12, 40))
|
|
96
|
+
duration_ms = int(
|
|
97
|
+
policy.duration_ms if policy.duration_ms is not None else _clamp(120 + dist * 0.9, 120, 700)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Control points: offset roughly perpendicular to travel direction
|
|
101
|
+
if dist < 1e-6:
|
|
102
|
+
dist = 1.0
|
|
103
|
+
ux, uy = dx / dist, dy / dist
|
|
104
|
+
px, py = -uy, ux
|
|
105
|
+
curve_mag = _clamp(dist / 3.5, 10.0, 140.0)
|
|
106
|
+
curve_mag *= rng.uniform(0.5, 1.2)
|
|
107
|
+
|
|
108
|
+
c1 = (x0 + dx * 0.25 + px * curve_mag, y0 + dy * 0.25 + py * curve_mag)
|
|
109
|
+
c2 = (x0 + dx * 0.75 - px * curve_mag, y0 + dy * 0.75 - py * curve_mag)
|
|
110
|
+
|
|
111
|
+
overshoot = float(policy.overshoot_px or 0.0)
|
|
112
|
+
overshoot_point = (x1 + ux * overshoot, y1 + uy * overshoot) if overshoot > 0 else (x1, y1)
|
|
113
|
+
|
|
114
|
+
pts: list[dict] = []
|
|
115
|
+
for i in range(steps):
|
|
116
|
+
t_raw = 0.0 if steps <= 1 else i / (steps - 1)
|
|
117
|
+
t = _ease_in_out(t_raw)
|
|
118
|
+
bx, by = _bezier((x0, y0), c1, c2, overshoot_point, t)
|
|
119
|
+
|
|
120
|
+
# Small jitter, decaying near target
|
|
121
|
+
jitter_scale = float(policy.jitter_px) * (1.0 - t_raw) * 0.9
|
|
122
|
+
jx = rng.uniform(-jitter_scale, jitter_scale)
|
|
123
|
+
jy = rng.uniform(-jitter_scale, jitter_scale)
|
|
124
|
+
|
|
125
|
+
pts.append({"x": bx + jx, "y": by + jy, "t": round(t_raw, 4)})
|
|
126
|
+
|
|
127
|
+
# If we overshot, add a small correction segment back to target.
|
|
128
|
+
if overshoot > 0:
|
|
129
|
+
pts.append({"x": x1, "y": y1, "t": 1.0})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"mode": "human",
|
|
133
|
+
"from": {"x": x0, "y": y0},
|
|
134
|
+
"to": {"x": x1, "y": y1},
|
|
135
|
+
"steps": steps,
|
|
136
|
+
"duration_ms": duration_ms,
|
|
137
|
+
"pause_before_click_ms": int(policy.pause_before_click_ms),
|
|
138
|
+
"jitter_px": float(policy.jitter_px),
|
|
139
|
+
"overshoot_px": overshoot,
|
|
140
|
+
# Keep path bounded for trace size
|
|
141
|
+
"path": pts[:64],
|
|
142
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Element filtering utilities for agent-based element selection.
|
|
3
|
+
|
|
4
|
+
This module provides centralized element filtering logic to reduce duplication
|
|
5
|
+
across agent implementations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .models import Element, Snapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ElementFilter:
|
|
14
|
+
"""
|
|
15
|
+
Centralized element filtering logic for agent-based element selection.
|
|
16
|
+
|
|
17
|
+
Provides static methods for filtering elements based on:
|
|
18
|
+
- Importance scores
|
|
19
|
+
- Goal-based keyword matching
|
|
20
|
+
- Role and visual properties
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Common stopwords for keyword extraction
|
|
24
|
+
STOPWORDS = {
|
|
25
|
+
"the",
|
|
26
|
+
"a",
|
|
27
|
+
"an",
|
|
28
|
+
"and",
|
|
29
|
+
"or",
|
|
30
|
+
"but",
|
|
31
|
+
"in",
|
|
32
|
+
"on",
|
|
33
|
+
"at",
|
|
34
|
+
"to",
|
|
35
|
+
"for",
|
|
36
|
+
"of",
|
|
37
|
+
"with",
|
|
38
|
+
"by",
|
|
39
|
+
"from",
|
|
40
|
+
"as",
|
|
41
|
+
"is",
|
|
42
|
+
"was",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def filter_by_importance(
|
|
47
|
+
snapshot: Snapshot,
|
|
48
|
+
max_elements: int = 50,
|
|
49
|
+
) -> list[Element]:
|
|
50
|
+
"""
|
|
51
|
+
Filter elements by importance score (simple top-N selection).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
snapshot: Current page snapshot
|
|
55
|
+
max_elements: Maximum number of elements to return
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Top N elements sorted by importance score
|
|
59
|
+
"""
|
|
60
|
+
# Filter out REMOVED elements - they're not actionable and shouldn't be in LLM context
|
|
61
|
+
elements = [el for el in snapshot.elements if el.diff_status != "REMOVED"]
|
|
62
|
+
# Elements are already sorted by importance in snapshot
|
|
63
|
+
return elements[:max_elements]
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def filter_by_goal(
|
|
67
|
+
snapshot: Snapshot,
|
|
68
|
+
goal: str | None,
|
|
69
|
+
max_elements: int = 100,
|
|
70
|
+
) -> list[Element]:
|
|
71
|
+
"""
|
|
72
|
+
Filter elements from snapshot based on goal context.
|
|
73
|
+
|
|
74
|
+
Applies goal-based keyword matching to boost relevant elements
|
|
75
|
+
and filters out irrelevant ones.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
snapshot: Current page snapshot
|
|
79
|
+
goal: User's goal (can inform filtering)
|
|
80
|
+
max_elements: Maximum number of elements to return
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Filtered list of elements sorted by boosted importance score
|
|
84
|
+
"""
|
|
85
|
+
# Filter out REMOVED elements - they're not actionable and shouldn't be in LLM context
|
|
86
|
+
elements = [el for el in snapshot.elements if el.diff_status != "REMOVED"]
|
|
87
|
+
|
|
88
|
+
# If no goal provided, return all elements (up to limit)
|
|
89
|
+
if not goal:
|
|
90
|
+
return elements[:max_elements]
|
|
91
|
+
|
|
92
|
+
goal_lower = goal.lower()
|
|
93
|
+
|
|
94
|
+
# Extract keywords from goal
|
|
95
|
+
keywords = ElementFilter._extract_keywords(goal_lower)
|
|
96
|
+
|
|
97
|
+
# Boost elements matching goal keywords
|
|
98
|
+
scored_elements = []
|
|
99
|
+
for el in elements:
|
|
100
|
+
score = el.importance
|
|
101
|
+
|
|
102
|
+
# Boost if element text matches goal
|
|
103
|
+
if el.text and any(kw in el.text.lower() for kw in keywords):
|
|
104
|
+
score += 0.3
|
|
105
|
+
|
|
106
|
+
# Boost if role matches goal intent
|
|
107
|
+
if "click" in goal_lower and el.visual_cues.is_clickable:
|
|
108
|
+
score += 0.2
|
|
109
|
+
if "type" in goal_lower and el.role in ["textbox", "searchbox"]:
|
|
110
|
+
score += 0.2
|
|
111
|
+
if "search" in goal_lower:
|
|
112
|
+
# Filter out non-interactive elements for search tasks
|
|
113
|
+
if el.role in ["link", "img"] and not el.visual_cues.is_primary:
|
|
114
|
+
score -= 0.5
|
|
115
|
+
|
|
116
|
+
scored_elements.append((score, el))
|
|
117
|
+
|
|
118
|
+
# Re-sort by boosted score
|
|
119
|
+
scored_elements.sort(key=lambda x: x[0], reverse=True)
|
|
120
|
+
elements = [el for _, el in scored_elements]
|
|
121
|
+
|
|
122
|
+
return elements[:max_elements]
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _extract_keywords(text: str) -> list[str]:
|
|
126
|
+
"""
|
|
127
|
+
Extract meaningful keywords from goal text.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
text: Text to extract keywords from
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of keywords (non-stopwords, length > 2)
|
|
134
|
+
"""
|
|
135
|
+
words = text.split()
|
|
136
|
+
return [w for w in words if w not in ElementFilter.STOPWORDS and len(w) > 2]
|
sentience/expect.py
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
Expect/Assert functionality
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import time
|
|
6
7
|
|
|
7
|
-
from .browser import SentienceBrowser
|
|
8
|
+
from .browser import AsyncSentienceBrowser, SentienceBrowser
|
|
8
9
|
from .models import Element
|
|
9
10
|
from .query import query
|
|
10
|
-
from .wait import wait_for
|
|
11
|
+
from .wait import wait_for, wait_for_async
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class Expectation:
|
|
@@ -90,3 +91,98 @@ def expect(browser: SentienceBrowser, selector: str | dict) -> Expectation:
|
|
|
90
91
|
Expectation helper
|
|
91
92
|
"""
|
|
92
93
|
return Expectation(browser, selector)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ExpectationAsync:
|
|
97
|
+
"""Assertion helper for element expectations (async)"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, browser: AsyncSentienceBrowser, selector: str | dict):
|
|
100
|
+
self.browser = browser
|
|
101
|
+
self.selector = selector
|
|
102
|
+
|
|
103
|
+
async def to_be_visible(self, timeout: float = 10.0) -> Element:
|
|
104
|
+
"""Assert element is visible (exists and in viewport)"""
|
|
105
|
+
result = await wait_for_async(self.browser, self.selector, timeout=timeout)
|
|
106
|
+
|
|
107
|
+
if not result.found:
|
|
108
|
+
raise AssertionError(f"Element not found: {self.selector} (timeout: {timeout}s)")
|
|
109
|
+
|
|
110
|
+
element = result.element
|
|
111
|
+
if not element.in_viewport:
|
|
112
|
+
raise AssertionError(f"Element found but not visible in viewport: {self.selector}")
|
|
113
|
+
|
|
114
|
+
return element
|
|
115
|
+
|
|
116
|
+
async def to_exist(self, timeout: float = 10.0) -> Element:
|
|
117
|
+
"""Assert element exists"""
|
|
118
|
+
result = await wait_for_async(self.browser, self.selector, timeout=timeout)
|
|
119
|
+
|
|
120
|
+
if not result.found:
|
|
121
|
+
raise AssertionError(f"Element does not exist: {self.selector} (timeout: {timeout}s)")
|
|
122
|
+
|
|
123
|
+
return result.element
|
|
124
|
+
|
|
125
|
+
async def to_have_text(self, expected_text: str, timeout: float = 10.0) -> Element:
|
|
126
|
+
"""Assert element has specific text"""
|
|
127
|
+
result = await wait_for_async(self.browser, self.selector, timeout=timeout)
|
|
128
|
+
|
|
129
|
+
if not result.found:
|
|
130
|
+
raise AssertionError(f"Element not found: {self.selector} (timeout: {timeout}s)")
|
|
131
|
+
|
|
132
|
+
element = result.element
|
|
133
|
+
if not element.text or expected_text not in element.text:
|
|
134
|
+
raise AssertionError(
|
|
135
|
+
f"Element text mismatch. Expected '{expected_text}', got '{element.text}'"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return element
|
|
139
|
+
|
|
140
|
+
async def to_have_count(self, expected_count: int, timeout: float = 10.0) -> None:
|
|
141
|
+
"""Assert selector matches exactly N elements"""
|
|
142
|
+
from .snapshot import snapshot_async
|
|
143
|
+
|
|
144
|
+
start_time = time.time()
|
|
145
|
+
while time.time() - start_time < timeout:
|
|
146
|
+
snap = await snapshot_async(self.browser)
|
|
147
|
+
matches = query(snap, self.selector)
|
|
148
|
+
|
|
149
|
+
if len(matches) == expected_count:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
await asyncio.sleep(0.25)
|
|
153
|
+
|
|
154
|
+
# Final check
|
|
155
|
+
snap = await snapshot_async(self.browser)
|
|
156
|
+
matches = query(snap, self.selector)
|
|
157
|
+
actual_count = len(matches)
|
|
158
|
+
|
|
159
|
+
raise AssertionError(
|
|
160
|
+
f"Element count mismatch. Expected {expected_count}, got {actual_count}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def expect_async(browser: AsyncSentienceBrowser, selector: str | dict) -> ExpectationAsync:
|
|
165
|
+
"""
|
|
166
|
+
Create expectation helper for assertions (async)
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
browser: AsyncSentienceBrowser instance
|
|
170
|
+
selector: String DSL or dict query
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
ExpectationAsync helper
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
# Assert element is visible
|
|
177
|
+
element = await expect_async(browser, "role=button").to_be_visible()
|
|
178
|
+
|
|
179
|
+
# Assert element has text
|
|
180
|
+
element = await expect_async(browser, "h1").to_have_text("Welcome")
|
|
181
|
+
|
|
182
|
+
# Assert element exists
|
|
183
|
+
element = await expect_async(browser, "role=link").to_exist()
|
|
184
|
+
|
|
185
|
+
# Assert count
|
|
186
|
+
await expect_async(browser, "role=button").to_have_count(5)
|
|
187
|
+
"""
|
|
188
|
+
return ExpectationAsync(browser, selector)
|