sentienceapi 0.95.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 +253 -0
- sentience/_extension_loader.py +195 -0
- sentience/action_executor.py +215 -0
- sentience/actions.py +1020 -0
- sentience/agent.py +1181 -0
- sentience/agent_config.py +46 -0
- sentience/agent_runtime.py +424 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +108 -0
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +343 -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 +427 -0
- sentience/base_agent.py +196 -0
- sentience/browser.py +1215 -0
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cli.py +130 -0
- sentience/cloud_tracing.py +807 -0
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +543 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +188 -0
- sentience/extension/background.js +104 -0
- sentience/extension/content.js +161 -0
- sentience/extension/injected_api.js +914 -0
- sentience/extension/manifest.json +36 -0
- sentience/extension/pkg/sentience_core.d.ts +51 -0
- sentience/extension/pkg/sentience_core.js +323 -0
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
- sentience/extension/release.json +115 -0
- sentience/formatting.py +15 -0
- sentience/generator.py +202 -0
- sentience/inspector.py +367 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +875 -0
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +846 -0
- sentience/ordinal.py +280 -0
- sentience/overlay.py +222 -0
- sentience/protocols.py +228 -0
- sentience/query.py +303 -0
- sentience/read.py +188 -0
- sentience/recorder.py +589 -0
- sentience/schemas/trace_v1.json +335 -0
- sentience/screenshot.py +100 -0
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +706 -0
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +262 -0
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/__init__.py +27 -0
- sentience/trace_indexing/index_schema.py +199 -0
- sentience/trace_indexing/indexer.py +414 -0
- sentience/tracer_factory.py +322 -0
- sentience/tracing.py +449 -0
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/utils/element.py +257 -0
- sentience/utils/formatting.py +59 -0
- sentience/utils.py +296 -0
- sentience/verification.py +380 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +139 -0
- sentienceapi-0.95.0.dist-info/METADATA +984 -0
- sentienceapi-0.95.0.dist-info/RECORD +82 -0
- sentienceapi-0.95.0.dist-info/WHEEL +5 -0
- sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
- sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser evaluation helper for common window.sentience API patterns.
|
|
3
|
+
|
|
4
|
+
Consolidates repeated patterns for:
|
|
5
|
+
- Waiting for extension injection
|
|
6
|
+
- Calling window.sentience methods
|
|
7
|
+
- Error handling with diagnostics
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional, Union
|
|
11
|
+
|
|
12
|
+
from playwright.async_api import Page as AsyncPage
|
|
13
|
+
from playwright.sync_api import Page
|
|
14
|
+
|
|
15
|
+
from .browser import AsyncSentienceBrowser, SentienceBrowser
|
|
16
|
+
from .sentience_methods import SentienceMethod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BrowserEvaluator:
|
|
20
|
+
"""Helper class for common browser evaluation patterns"""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def wait_for_extension(
|
|
24
|
+
page: Page | AsyncPage,
|
|
25
|
+
timeout_ms: int = 5000,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Wait for window.sentience API to be available.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
page: Playwright Page instance (sync or async)
|
|
32
|
+
timeout_ms: Timeout in milliseconds (default: 5000)
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
RuntimeError: If extension fails to inject within timeout
|
|
36
|
+
"""
|
|
37
|
+
if hasattr(page, "wait_for_function"):
|
|
38
|
+
# Sync page
|
|
39
|
+
try:
|
|
40
|
+
page.wait_for_function(
|
|
41
|
+
"typeof window.sentience !== 'undefined'",
|
|
42
|
+
timeout=timeout_ms,
|
|
43
|
+
)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
diag = BrowserEvaluator._gather_diagnostics(page)
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
f"Sentience extension failed to inject window.sentience API. "
|
|
48
|
+
f"Is the extension loaded? Diagnostics: {diag}"
|
|
49
|
+
) from e
|
|
50
|
+
else:
|
|
51
|
+
# Async page - should use async version
|
|
52
|
+
raise TypeError("Use wait_for_extension_async for async pages")
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
async def wait_for_extension_async(
|
|
56
|
+
page: AsyncPage,
|
|
57
|
+
timeout_ms: int = 5000,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Wait for window.sentience API to be available (async).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
page: Playwright AsyncPage instance
|
|
64
|
+
timeout_ms: Timeout in milliseconds (default: 5000)
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
RuntimeError: If extension fails to inject within timeout
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
await page.wait_for_function(
|
|
71
|
+
"typeof window.sentience !== 'undefined'",
|
|
72
|
+
timeout=timeout_ms,
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
diag = await BrowserEvaluator._gather_diagnostics_async(page)
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"Sentience extension failed to inject window.sentience API. "
|
|
78
|
+
f"Is the extension loaded? Diagnostics: {diag}"
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _gather_diagnostics(page: Page | AsyncPage) -> dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Gather diagnostics about extension state.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
page: Playwright Page instance
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Dictionary with diagnostic information
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
if hasattr(page, "evaluate"):
|
|
94
|
+
# Sync page
|
|
95
|
+
return page.evaluate(
|
|
96
|
+
"""() => ({
|
|
97
|
+
sentience_defined: typeof window.sentience !== 'undefined',
|
|
98
|
+
extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
|
|
99
|
+
url: window.location.href
|
|
100
|
+
})"""
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
return {"error": "Could not gather diagnostics - invalid page type"}
|
|
104
|
+
except Exception:
|
|
105
|
+
return {"error": "Could not gather diagnostics"}
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
async def _gather_diagnostics_async(page: AsyncPage) -> dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Gather diagnostics about extension state (async).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
page: Playwright AsyncPage instance
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Dictionary with diagnostic information
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
return await page.evaluate(
|
|
120
|
+
"""() => ({
|
|
121
|
+
sentience_defined: typeof window.sentience !== 'undefined',
|
|
122
|
+
extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
|
|
123
|
+
url: window.location.href
|
|
124
|
+
})"""
|
|
125
|
+
)
|
|
126
|
+
except Exception:
|
|
127
|
+
return {"error": "Could not gather diagnostics"}
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def invoke(
|
|
131
|
+
page: Page,
|
|
132
|
+
method: SentienceMethod | str,
|
|
133
|
+
*args: Any,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""
|
|
137
|
+
Invoke a window.sentience method with error handling (sync).
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
page: Playwright Page instance (sync)
|
|
141
|
+
method: SentienceMethod enum value or method name string (e.g., SentienceMethod.SNAPSHOT or "snapshot")
|
|
142
|
+
*args: Positional arguments to pass to the method
|
|
143
|
+
**kwargs: Keyword arguments to pass to the method
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Result from the method call
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
RuntimeError: If method is not available or call fails
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
```python
|
|
153
|
+
result = BrowserEvaluator.invoke(page, SentienceMethod.SNAPSHOT, limit=50)
|
|
154
|
+
success = BrowserEvaluator.invoke(page, SentienceMethod.CLICK, element_id)
|
|
155
|
+
```
|
|
156
|
+
"""
|
|
157
|
+
# Convert enum to string if needed
|
|
158
|
+
method_name = method.value if isinstance(method, SentienceMethod) else method
|
|
159
|
+
|
|
160
|
+
# Build JavaScript call
|
|
161
|
+
if args and kwargs:
|
|
162
|
+
# Both args and kwargs - use object spread
|
|
163
|
+
js_code = f"""
|
|
164
|
+
(args, kwargs) => {{
|
|
165
|
+
return window.sentience.{method_name}(...args, kwargs);
|
|
166
|
+
}}
|
|
167
|
+
"""
|
|
168
|
+
result = page.evaluate(js_code, list(args), kwargs)
|
|
169
|
+
elif args:
|
|
170
|
+
# Only args
|
|
171
|
+
js_code = f"""
|
|
172
|
+
(args) => {{
|
|
173
|
+
return window.sentience.{method_name}(...args);
|
|
174
|
+
}}
|
|
175
|
+
"""
|
|
176
|
+
result = page.evaluate(js_code, list(args))
|
|
177
|
+
elif kwargs:
|
|
178
|
+
# Only kwargs - pass as single object
|
|
179
|
+
js_code = f"""
|
|
180
|
+
(options) => {{
|
|
181
|
+
return window.sentience.{method_name}(options);
|
|
182
|
+
}}
|
|
183
|
+
"""
|
|
184
|
+
result = page.evaluate(js_code, kwargs)
|
|
185
|
+
else:
|
|
186
|
+
# No arguments
|
|
187
|
+
js_code = f"""
|
|
188
|
+
() => {{
|
|
189
|
+
return window.sentience.{method_name}();
|
|
190
|
+
}}
|
|
191
|
+
"""
|
|
192
|
+
result = page.evaluate(js_code)
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
async def invoke_async(
|
|
198
|
+
page: AsyncPage,
|
|
199
|
+
method: SentienceMethod | str,
|
|
200
|
+
*args: Any,
|
|
201
|
+
**kwargs: Any,
|
|
202
|
+
) -> Any:
|
|
203
|
+
"""
|
|
204
|
+
Invoke a window.sentience method with error handling (async).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
page: Playwright AsyncPage instance
|
|
208
|
+
method: SentienceMethod enum value or method name string (e.g., SentienceMethod.SNAPSHOT or "snapshot")
|
|
209
|
+
*args: Positional arguments to pass to the method
|
|
210
|
+
**kwargs: Keyword arguments to pass to the method
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Result from the method call
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
RuntimeError: If method is not available or call fails
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
```python
|
|
220
|
+
result = await BrowserEvaluator.invoke_async(page, SentienceMethod.SNAPSHOT, limit=50)
|
|
221
|
+
success = await BrowserEvaluator.invoke_async(page, SentienceMethod.CLICK, element_id)
|
|
222
|
+
```
|
|
223
|
+
"""
|
|
224
|
+
# Convert enum to string if needed
|
|
225
|
+
method_name = method.value if isinstance(method, SentienceMethod) else method
|
|
226
|
+
|
|
227
|
+
# Build JavaScript call
|
|
228
|
+
if args and kwargs:
|
|
229
|
+
js_code = f"""
|
|
230
|
+
(args, kwargs) => {{
|
|
231
|
+
return window.sentience.{method_name}(...args, kwargs);
|
|
232
|
+
}}
|
|
233
|
+
"""
|
|
234
|
+
result = await page.evaluate(js_code, list(args), kwargs)
|
|
235
|
+
elif args:
|
|
236
|
+
js_code = f"""
|
|
237
|
+
(args) => {{
|
|
238
|
+
return window.sentience.{method_name}(...args);
|
|
239
|
+
}}
|
|
240
|
+
"""
|
|
241
|
+
result = await page.evaluate(js_code, list(args))
|
|
242
|
+
elif kwargs:
|
|
243
|
+
js_code = f"""
|
|
244
|
+
(options) => {{
|
|
245
|
+
return window.sentience.{method_name}(options);
|
|
246
|
+
}}
|
|
247
|
+
"""
|
|
248
|
+
result = await page.evaluate(js_code, kwargs)
|
|
249
|
+
else:
|
|
250
|
+
js_code = f"""
|
|
251
|
+
() => {{
|
|
252
|
+
return window.sentience.{method_name}();
|
|
253
|
+
}}
|
|
254
|
+
"""
|
|
255
|
+
result = await page.evaluate(js_code)
|
|
256
|
+
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def verify_method_exists(
|
|
261
|
+
page: Page,
|
|
262
|
+
method: SentienceMethod | str,
|
|
263
|
+
) -> bool:
|
|
264
|
+
"""
|
|
265
|
+
Verify that a window.sentience method exists.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
page: Playwright Page instance (sync)
|
|
269
|
+
method: SentienceMethod enum value or method name string
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
True if method exists, False otherwise
|
|
273
|
+
"""
|
|
274
|
+
method_name = method.value if isinstance(method, SentienceMethod) else method
|
|
275
|
+
try:
|
|
276
|
+
return page.evaluate(f"typeof window.sentience.{method_name} !== 'undefined'")
|
|
277
|
+
except Exception:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
async def verify_method_exists_async(
|
|
282
|
+
page: AsyncPage,
|
|
283
|
+
method: SentienceMethod | str,
|
|
284
|
+
) -> bool:
|
|
285
|
+
"""
|
|
286
|
+
Verify that a window.sentience method exists (async).
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
page: Playwright AsyncPage instance
|
|
290
|
+
method: SentienceMethod enum value or method name string
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
True if method exists, False otherwise
|
|
294
|
+
"""
|
|
295
|
+
method_name = method.value if isinstance(method, SentienceMethod) else method
|
|
296
|
+
try:
|
|
297
|
+
return await page.evaluate(f"typeof window.sentience.{method_name} !== 'undefined'")
|
|
298
|
+
except Exception:
|
|
299
|
+
return False
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared canonicalization utilities for snapshot comparison and indexing.
|
|
3
|
+
|
|
4
|
+
This module provides consistent normalization functions used by both:
|
|
5
|
+
- trace_indexing/indexer.py (for computing stable digests)
|
|
6
|
+
- snapshot_diff.py (for computing diff_status labels)
|
|
7
|
+
|
|
8
|
+
By sharing these helpers, we ensure consistent behavior:
|
|
9
|
+
- Same text normalization (whitespace, case, length)
|
|
10
|
+
- Same bbox rounding (2px precision)
|
|
11
|
+
- Same change detection thresholds
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_text(text: str | None, max_len: int = 80) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Normalize text for canonical comparison.
|
|
20
|
+
|
|
21
|
+
Transforms:
|
|
22
|
+
- Trims leading/trailing whitespace
|
|
23
|
+
- Collapses internal whitespace to single spaces
|
|
24
|
+
- Lowercases
|
|
25
|
+
- Caps length
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: Input text (may be None)
|
|
29
|
+
max_len: Maximum length to retain (default: 80)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Normalized text string (empty string if input is None)
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> normalize_text(" Hello World ")
|
|
36
|
+
'hello world'
|
|
37
|
+
>>> normalize_text(None)
|
|
38
|
+
''
|
|
39
|
+
"""
|
|
40
|
+
if not text:
|
|
41
|
+
return ""
|
|
42
|
+
# Trim and collapse whitespace
|
|
43
|
+
normalized = " ".join(text.split())
|
|
44
|
+
# Lowercase
|
|
45
|
+
normalized = normalized.lower()
|
|
46
|
+
# Cap length
|
|
47
|
+
if len(normalized) > max_len:
|
|
48
|
+
normalized = normalized[:max_len]
|
|
49
|
+
return normalized
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def round_bbox(bbox: dict[str, float], precision: int = 2) -> dict[str, int]:
|
|
53
|
+
"""
|
|
54
|
+
Round bbox coordinates to reduce noise.
|
|
55
|
+
|
|
56
|
+
Snaps coordinates to grid of `precision` pixels to ignore
|
|
57
|
+
sub-pixel rendering differences.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
bbox: Bounding box with x, y, width, height
|
|
61
|
+
precision: Grid size in pixels (default: 2)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Rounded bbox with integer coordinates
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> round_bbox({"x": 101, "y": 203, "width": 50, "height": 25})
|
|
68
|
+
{'x': 100, 'y': 202, 'width': 50, 'height': 24}
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"x": round(bbox.get("x", 0) / precision) * precision,
|
|
72
|
+
"y": round(bbox.get("y", 0) / precision) * precision,
|
|
73
|
+
"width": round(bbox.get("width", 0) / precision) * precision,
|
|
74
|
+
"height": round(bbox.get("height", 0) / precision) * precision,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bbox_equal(bbox1: dict[str, Any], bbox2: dict[str, Any], threshold: float = 5.0) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Check if two bboxes are equal within a threshold.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
bbox1: First bounding box
|
|
84
|
+
bbox2: Second bounding box
|
|
85
|
+
threshold: Maximum allowed difference in pixels (default: 5.0)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if all bbox properties differ by less than threshold
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> bbox_equal({"x": 100, "y": 200, "width": 50, "height": 25},
|
|
92
|
+
... {"x": 102, "y": 200, "width": 50, "height": 25})
|
|
93
|
+
True # 2px difference is below 5px threshold
|
|
94
|
+
"""
|
|
95
|
+
return (
|
|
96
|
+
abs(bbox1.get("x", 0) - bbox2.get("x", 0)) <= threshold
|
|
97
|
+
and abs(bbox1.get("y", 0) - bbox2.get("y", 0)) <= threshold
|
|
98
|
+
and abs(bbox1.get("width", 0) - bbox2.get("width", 0)) <= threshold
|
|
99
|
+
and abs(bbox1.get("height", 0) - bbox2.get("height", 0)) <= threshold
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def bbox_changed(bbox1: dict[str, Any], bbox2: dict[str, Any], threshold: float = 5.0) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Check if two bboxes differ beyond the threshold.
|
|
106
|
+
|
|
107
|
+
This is the inverse of bbox_equal, provided for semantic clarity
|
|
108
|
+
in diff detection code.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
bbox1: First bounding box
|
|
112
|
+
bbox2: Second bounding box
|
|
113
|
+
threshold: Maximum allowed difference in pixels (default: 5.0)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if any bbox property differs by more than threshold
|
|
117
|
+
"""
|
|
118
|
+
return not bbox_equal(bbox1, bbox2, threshold)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def canonicalize_element(elem: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
"""
|
|
123
|
+
Create canonical representation of an element for comparison/hashing.
|
|
124
|
+
|
|
125
|
+
Extracts and normalizes the fields that matter for identity:
|
|
126
|
+
- id, role, normalized text, rounded bbox
|
|
127
|
+
- is_primary, is_clickable from visual_cues
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
elem: Raw element dictionary
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Canonical element dictionary with normalized fields
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> canonicalize_element({
|
|
137
|
+
... "id": 1,
|
|
138
|
+
... "role": "button",
|
|
139
|
+
... "text": " Click Me ",
|
|
140
|
+
... "bbox": {"x": 101, "y": 200, "width": 50, "height": 25},
|
|
141
|
+
... "visual_cues": {"is_primary": True, "is_clickable": True}
|
|
142
|
+
... })
|
|
143
|
+
{'id': 1, 'role': 'button', 'text_norm': 'click me', 'bbox': {'x': 100, 'y': 200, 'width': 50, 'height': 24}, 'is_primary': True, 'is_clickable': True}
|
|
144
|
+
"""
|
|
145
|
+
# Extract is_primary and is_clickable from visual_cues if present
|
|
146
|
+
visual_cues = elem.get("visual_cues", {})
|
|
147
|
+
is_primary = (
|
|
148
|
+
visual_cues.get("is_primary", False)
|
|
149
|
+
if isinstance(visual_cues, dict)
|
|
150
|
+
else elem.get("is_primary", False)
|
|
151
|
+
)
|
|
152
|
+
is_clickable = (
|
|
153
|
+
visual_cues.get("is_clickable", False)
|
|
154
|
+
if isinstance(visual_cues, dict)
|
|
155
|
+
else elem.get("is_clickable", False)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"id": elem.get("id"),
|
|
160
|
+
"role": elem.get("role", ""),
|
|
161
|
+
"text_norm": normalize_text(elem.get("text")),
|
|
162
|
+
"bbox": round_bbox(elem.get("bbox", {"x": 0, "y": 0, "width": 0, "height": 0})),
|
|
163
|
+
"is_primary": is_primary,
|
|
164
|
+
"is_clickable": is_clickable,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def content_equal(elem1: dict[str, Any], elem2: dict[str, Any]) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Check if two elements have equal content (ignoring position).
|
|
171
|
+
|
|
172
|
+
Compares normalized text, role, and visual cues.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
elem1: First element (raw or canonical)
|
|
176
|
+
elem2: Second element (raw or canonical)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if content is equal after normalization
|
|
180
|
+
"""
|
|
181
|
+
# Normalize both elements
|
|
182
|
+
c1 = canonicalize_element(elem1)
|
|
183
|
+
c2 = canonicalize_element(elem2)
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
c1["role"] == c2["role"]
|
|
187
|
+
and c1["text_norm"] == c2["text_norm"]
|
|
188
|
+
and c1["is_primary"] == c2["is_primary"]
|
|
189
|
+
and c1["is_clickable"] == c2["is_clickable"]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def content_changed(elem1: dict[str, Any], elem2: dict[str, Any]) -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Check if two elements have different content (ignoring position).
|
|
196
|
+
|
|
197
|
+
This is the inverse of content_equal, provided for semantic clarity
|
|
198
|
+
in diff detection code.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
elem1: First element
|
|
202
|
+
elem2: Second element
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if content differs after normalization
|
|
206
|
+
"""
|
|
207
|
+
return not content_equal(elem1, elem2)
|
sentience/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for Sentience SDK
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .browser import SentienceBrowser
|
|
9
|
+
from .generator import ScriptGenerator
|
|
10
|
+
from .inspector import inspect
|
|
11
|
+
from .recorder import Trace, record
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_inspect(args):
|
|
15
|
+
"""Start inspector mode"""
|
|
16
|
+
browser = SentienceBrowser(headless=False)
|
|
17
|
+
try:
|
|
18
|
+
browser.start()
|
|
19
|
+
print("ā
Inspector started. Hover elements to see info, click to see full details.")
|
|
20
|
+
print("Press Ctrl+C to stop.")
|
|
21
|
+
|
|
22
|
+
with inspect(browser):
|
|
23
|
+
# Keep running until interrupted
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
time.sleep(1)
|
|
29
|
+
except KeyboardInterrupt:
|
|
30
|
+
print("\nš Inspector stopped.")
|
|
31
|
+
finally:
|
|
32
|
+
browser.close()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_record(args):
|
|
36
|
+
"""Start recording mode"""
|
|
37
|
+
browser = SentienceBrowser(headless=False)
|
|
38
|
+
try:
|
|
39
|
+
browser.start()
|
|
40
|
+
|
|
41
|
+
# Navigate to start URL if provided
|
|
42
|
+
if args.url:
|
|
43
|
+
browser.page.goto(args.url)
|
|
44
|
+
browser.page.wait_for_load_state("networkidle")
|
|
45
|
+
|
|
46
|
+
print("ā
Recording started. Perform actions in the browser.")
|
|
47
|
+
print("Press Ctrl+C to stop and save trace.")
|
|
48
|
+
|
|
49
|
+
with record(browser, capture_snapshots=args.snapshots) as rec:
|
|
50
|
+
# Add mask patterns if provided
|
|
51
|
+
for pattern in args.mask or []:
|
|
52
|
+
rec.add_mask_pattern(pattern)
|
|
53
|
+
|
|
54
|
+
# Keep running until interrupted
|
|
55
|
+
import time
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
while True:
|
|
59
|
+
time.sleep(1)
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
print("\nš¾ Saving trace...")
|
|
62
|
+
output = args.output or "trace.json"
|
|
63
|
+
rec.save(output)
|
|
64
|
+
print(f"ā
Trace saved to {output}")
|
|
65
|
+
finally:
|
|
66
|
+
browser.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_gen(args):
|
|
70
|
+
"""Generate script from trace"""
|
|
71
|
+
# Load trace
|
|
72
|
+
trace = Trace.load(args.trace)
|
|
73
|
+
|
|
74
|
+
# Generate script
|
|
75
|
+
generator = ScriptGenerator(trace)
|
|
76
|
+
|
|
77
|
+
if args.lang == "py":
|
|
78
|
+
output = args.output or "generated.py"
|
|
79
|
+
generator.save_python(output)
|
|
80
|
+
elif args.lang == "ts":
|
|
81
|
+
output = args.output or "generated.ts"
|
|
82
|
+
generator.save_typescript(output)
|
|
83
|
+
else:
|
|
84
|
+
print(f"ā Unsupported language: {args.lang}")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
print(f"ā
Generated {args.lang.upper()} script: {output}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
"""Main CLI entry point"""
|
|
92
|
+
parser = argparse.ArgumentParser(description="Sentience SDK CLI")
|
|
93
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
94
|
+
|
|
95
|
+
# Inspect command
|
|
96
|
+
inspect_parser = subparsers.add_parser("inspect", help="Start inspector mode")
|
|
97
|
+
inspect_parser.set_defaults(func=cmd_inspect)
|
|
98
|
+
|
|
99
|
+
# Record command
|
|
100
|
+
record_parser = subparsers.add_parser("record", help="Start recording mode")
|
|
101
|
+
record_parser.add_argument("--url", help="Start URL")
|
|
102
|
+
record_parser.add_argument("--output", "-o", help="Output trace file", default="trace.json")
|
|
103
|
+
record_parser.add_argument(
|
|
104
|
+
"--snapshots", action="store_true", help="Capture snapshots at each step"
|
|
105
|
+
)
|
|
106
|
+
record_parser.add_argument(
|
|
107
|
+
"--mask",
|
|
108
|
+
action="append",
|
|
109
|
+
help="Pattern to mask in recorded text (e.g., password)",
|
|
110
|
+
)
|
|
111
|
+
record_parser.set_defaults(func=cmd_record)
|
|
112
|
+
|
|
113
|
+
# Generate command
|
|
114
|
+
gen_parser = subparsers.add_parser("gen", help="Generate script from trace")
|
|
115
|
+
gen_parser.add_argument("trace", help="Trace JSON file")
|
|
116
|
+
gen_parser.add_argument("--lang", choices=["py", "ts"], default="py", help="Output language")
|
|
117
|
+
gen_parser.add_argument("--output", "-o", help="Output script file")
|
|
118
|
+
gen_parser.set_defaults(func=cmd_gen)
|
|
119
|
+
|
|
120
|
+
args = parser.parse_args()
|
|
121
|
+
|
|
122
|
+
if not args.command:
|
|
123
|
+
parser.print_help()
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
args.func(args)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|