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
sentience/recorder.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recorder - captures user actions into a trace
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .browser import AsyncSentienceBrowser, SentienceBrowser
|
|
10
|
+
from .models import Element, Snapshot
|
|
11
|
+
from .snapshot import snapshot, snapshot_async
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TraceStep:
|
|
15
|
+
"""A single step in a trace"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
ts: int,
|
|
20
|
+
type: str,
|
|
21
|
+
selector: str | None = None,
|
|
22
|
+
element_id: int | None = None,
|
|
23
|
+
text: str | None = None,
|
|
24
|
+
key: str | None = None,
|
|
25
|
+
url: str | None = None,
|
|
26
|
+
snapshot: Snapshot | None = None,
|
|
27
|
+
):
|
|
28
|
+
self.ts = ts
|
|
29
|
+
self.type = type
|
|
30
|
+
self.selector = selector
|
|
31
|
+
self.element_id = element_id
|
|
32
|
+
self.text = text
|
|
33
|
+
self.key = key
|
|
34
|
+
self.url = url
|
|
35
|
+
self.snapshot = snapshot
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Convert to dictionary for JSON serialization"""
|
|
39
|
+
result = {
|
|
40
|
+
"ts": self.ts,
|
|
41
|
+
"type": self.type,
|
|
42
|
+
}
|
|
43
|
+
if self.selector is not None:
|
|
44
|
+
result["selector"] = self.selector
|
|
45
|
+
if self.element_id is not None:
|
|
46
|
+
result["element_id"] = self.element_id
|
|
47
|
+
if self.text is not None:
|
|
48
|
+
result["text"] = self.text
|
|
49
|
+
if self.key is not None:
|
|
50
|
+
result["key"] = self.key
|
|
51
|
+
if self.url is not None:
|
|
52
|
+
result["url"] = self.url
|
|
53
|
+
if self.snapshot is not None:
|
|
54
|
+
result["snapshot"] = self.snapshot.model_dump()
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Trace:
|
|
59
|
+
"""Trace of user actions"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, start_url: str):
|
|
62
|
+
self.version = "1.0.0"
|
|
63
|
+
self.created_at = datetime.now().isoformat()
|
|
64
|
+
self.start_url = start_url
|
|
65
|
+
self.steps: list[TraceStep] = []
|
|
66
|
+
self._start_time = datetime.now()
|
|
67
|
+
|
|
68
|
+
def add_step(self, step: TraceStep) -> None:
|
|
69
|
+
"""Add a step to the trace"""
|
|
70
|
+
self.steps.append(step)
|
|
71
|
+
|
|
72
|
+
def add_navigation(self, url: str) -> None:
|
|
73
|
+
"""Add navigation step"""
|
|
74
|
+
ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
|
|
75
|
+
step = TraceStep(ts=ts, type="navigation", url=url)
|
|
76
|
+
self.add_step(step)
|
|
77
|
+
|
|
78
|
+
def add_click(self, element_id: int, selector: str | None = None) -> None:
|
|
79
|
+
"""Add click step"""
|
|
80
|
+
ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
|
|
81
|
+
step = TraceStep(ts=ts, type="click", element_id=element_id, selector=selector)
|
|
82
|
+
self.add_step(step)
|
|
83
|
+
|
|
84
|
+
def add_type(
|
|
85
|
+
self,
|
|
86
|
+
element_id: int,
|
|
87
|
+
text: str,
|
|
88
|
+
selector: str | None = None,
|
|
89
|
+
mask: bool = False,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Add type step"""
|
|
92
|
+
ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
|
|
93
|
+
# Mask sensitive data if requested
|
|
94
|
+
masked_text = "***" if mask else text
|
|
95
|
+
step = TraceStep(
|
|
96
|
+
ts=ts,
|
|
97
|
+
type="type",
|
|
98
|
+
element_id=element_id,
|
|
99
|
+
text=masked_text,
|
|
100
|
+
selector=selector,
|
|
101
|
+
)
|
|
102
|
+
self.add_step(step)
|
|
103
|
+
|
|
104
|
+
def add_press(self, key: str) -> None:
|
|
105
|
+
"""Add press key step"""
|
|
106
|
+
ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
|
|
107
|
+
step = TraceStep(ts=ts, type="press", key=key)
|
|
108
|
+
self.add_step(step)
|
|
109
|
+
|
|
110
|
+
def save(self, filepath: str) -> None:
|
|
111
|
+
"""Save trace to JSON file"""
|
|
112
|
+
data = {
|
|
113
|
+
"version": self.version,
|
|
114
|
+
"created_at": self.created_at,
|
|
115
|
+
"start_url": self.start_url,
|
|
116
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
117
|
+
}
|
|
118
|
+
with open(filepath, "w") as f:
|
|
119
|
+
json.dump(data, f, indent=2)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def load(cls, filepath: str) -> "Trace":
|
|
123
|
+
"""Load trace from JSON file"""
|
|
124
|
+
with open(filepath) as f:
|
|
125
|
+
data = json.load(f)
|
|
126
|
+
|
|
127
|
+
trace = cls(data["start_url"])
|
|
128
|
+
trace.version = data["version"]
|
|
129
|
+
trace.created_at = data["created_at"]
|
|
130
|
+
|
|
131
|
+
for step_data in data["steps"]:
|
|
132
|
+
snapshot_data = step_data.get("snapshot")
|
|
133
|
+
snapshot_obj = None
|
|
134
|
+
if snapshot_data:
|
|
135
|
+
snapshot_obj = Snapshot(**snapshot_data)
|
|
136
|
+
|
|
137
|
+
step = TraceStep(
|
|
138
|
+
ts=step_data["ts"],
|
|
139
|
+
type=step_data["type"],
|
|
140
|
+
selector=step_data.get("selector"),
|
|
141
|
+
element_id=step_data.get("element_id"),
|
|
142
|
+
text=step_data.get("text"),
|
|
143
|
+
key=step_data.get("key"),
|
|
144
|
+
url=step_data.get("url"),
|
|
145
|
+
snapshot=snapshot_obj,
|
|
146
|
+
)
|
|
147
|
+
trace.steps.append(step)
|
|
148
|
+
|
|
149
|
+
return trace
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Recorder:
|
|
153
|
+
"""Recorder for capturing user actions"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, browser: SentienceBrowser, capture_snapshots: bool = False):
|
|
156
|
+
self.browser = browser
|
|
157
|
+
self.capture_snapshots = capture_snapshots
|
|
158
|
+
self.trace: Trace | None = None
|
|
159
|
+
self._active = False
|
|
160
|
+
self._mask_patterns: list[str] = [] # Patterns to mask (e.g., "password", "email")
|
|
161
|
+
|
|
162
|
+
def start(self) -> None:
|
|
163
|
+
"""Start recording"""
|
|
164
|
+
if not self.browser.page:
|
|
165
|
+
raise RuntimeError("Browser not started. Call browser.start() first.")
|
|
166
|
+
|
|
167
|
+
self._active = True
|
|
168
|
+
start_url = self.browser.page.url
|
|
169
|
+
self.trace = Trace(start_url)
|
|
170
|
+
|
|
171
|
+
# Set up event listeners in the browser
|
|
172
|
+
self._setup_listeners()
|
|
173
|
+
|
|
174
|
+
def stop(self) -> None:
|
|
175
|
+
"""Stop recording"""
|
|
176
|
+
self._active = False
|
|
177
|
+
self._cleanup_listeners()
|
|
178
|
+
|
|
179
|
+
def add_mask_pattern(self, pattern: str) -> None:
|
|
180
|
+
"""Add a pattern to mask in recorded text (e.g., "password", "email")"""
|
|
181
|
+
self._mask_patterns.append(pattern.lower())
|
|
182
|
+
|
|
183
|
+
def _should_mask(self, text: str) -> bool:
|
|
184
|
+
"""Check if text should be masked"""
|
|
185
|
+
text_lower = text.lower()
|
|
186
|
+
return any(pattern in text_lower for pattern in self._mask_patterns)
|
|
187
|
+
|
|
188
|
+
def _setup_listeners(self) -> None:
|
|
189
|
+
"""Set up event listeners to capture actions"""
|
|
190
|
+
# Note: We'll capture actions through the SDK methods rather than DOM events
|
|
191
|
+
# This is cleaner and more reliable
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def _cleanup_listeners(self) -> None:
|
|
195
|
+
"""Clean up event listeners"""
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
def _infer_selector(self, element_id: int) -> str | None: # noqa: C901
|
|
199
|
+
"""
|
|
200
|
+
Infer a semantic selector for an element
|
|
201
|
+
|
|
202
|
+
Uses heuristics to build a robust selector:
|
|
203
|
+
- role=... text~"..."
|
|
204
|
+
- If text empty: use name/aria-label/placeholder
|
|
205
|
+
- Include clickable=true when relevant
|
|
206
|
+
- Validate against snapshot (should match 1 element)
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
# Take a snapshot to get element info
|
|
210
|
+
snap = snapshot(self.browser)
|
|
211
|
+
|
|
212
|
+
# Find the element in the snapshot
|
|
213
|
+
element = None
|
|
214
|
+
for el in snap.elements:
|
|
215
|
+
if el.id == element_id:
|
|
216
|
+
element = el
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
if not element:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
# Build candidate selector
|
|
223
|
+
parts = []
|
|
224
|
+
|
|
225
|
+
# Add role
|
|
226
|
+
if element.role and element.role != "generic":
|
|
227
|
+
parts.append(f"role={element.role}")
|
|
228
|
+
|
|
229
|
+
# Add text if available
|
|
230
|
+
if element.text:
|
|
231
|
+
# Use contains match for text
|
|
232
|
+
text_part = element.text.replace('"', '\\"')[:50] # Limit length
|
|
233
|
+
parts.append(f'text~"{text_part}"')
|
|
234
|
+
else:
|
|
235
|
+
# Try to get name/aria-label/placeholder from DOM
|
|
236
|
+
try:
|
|
237
|
+
el = self.browser.page.evaluate(
|
|
238
|
+
f"""
|
|
239
|
+
() => {{
|
|
240
|
+
const el = window.sentience_registry[{element_id}];
|
|
241
|
+
if (!el) return null;
|
|
242
|
+
return {{
|
|
243
|
+
name: el.name || null,
|
|
244
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
245
|
+
placeholder: el.placeholder || null
|
|
246
|
+
}};
|
|
247
|
+
}}
|
|
248
|
+
"""
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if el:
|
|
252
|
+
if el.get("name"):
|
|
253
|
+
parts.append(f'name="{el["name"]}"')
|
|
254
|
+
elif el.get("ariaLabel"):
|
|
255
|
+
parts.append(f'text~"{el["ariaLabel"]}"')
|
|
256
|
+
elif el.get("placeholder"):
|
|
257
|
+
parts.append(f'text~"{el["placeholder"]}"')
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
# Add clickable if relevant
|
|
262
|
+
if element.visual_cues.is_clickable:
|
|
263
|
+
parts.append("clickable=true")
|
|
264
|
+
|
|
265
|
+
if not parts:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
selector = " ".join(parts)
|
|
269
|
+
|
|
270
|
+
# Validate selector - should match exactly 1 element
|
|
271
|
+
matches = [el for el in snap.elements if self._match_element(el, selector)]
|
|
272
|
+
|
|
273
|
+
if len(matches) == 1:
|
|
274
|
+
return selector
|
|
275
|
+
elif len(matches) > 1:
|
|
276
|
+
# Add more constraints (importance threshold, near-center)
|
|
277
|
+
# For now, just return the selector with a note
|
|
278
|
+
return selector
|
|
279
|
+
else:
|
|
280
|
+
# Selector doesn't match - return None (will use element_id)
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
except Exception:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def _match_element(self, element: Element, selector: str) -> bool:
|
|
287
|
+
"""Simple selector matching (basic implementation)"""
|
|
288
|
+
# This is a simplified version - in production, use the full query engine
|
|
289
|
+
from .query import match_element, parse_selector
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
query_dict = parse_selector(selector)
|
|
293
|
+
return match_element(element, query_dict)
|
|
294
|
+
except Exception:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
def record_navigation(self, url: str) -> None:
|
|
298
|
+
"""Record a navigation event"""
|
|
299
|
+
if self._active and self.trace:
|
|
300
|
+
self.trace.add_navigation(url)
|
|
301
|
+
|
|
302
|
+
def record_click(self, element_id: int, selector: str | None = None) -> None:
|
|
303
|
+
"""Record a click event with smart selector inference"""
|
|
304
|
+
if self._active and self.trace:
|
|
305
|
+
# If no selector provided, try to infer one
|
|
306
|
+
if selector is None:
|
|
307
|
+
selector = self._infer_selector(element_id)
|
|
308
|
+
|
|
309
|
+
# Optionally capture snapshot
|
|
310
|
+
if self.capture_snapshots:
|
|
311
|
+
try:
|
|
312
|
+
snap = snapshot(self.browser)
|
|
313
|
+
step = TraceStep(
|
|
314
|
+
ts=int((datetime.now() - self.trace._start_time).total_seconds() * 1000),
|
|
315
|
+
type="click",
|
|
316
|
+
element_id=element_id,
|
|
317
|
+
selector=selector,
|
|
318
|
+
snapshot=snap,
|
|
319
|
+
)
|
|
320
|
+
self.trace.add_step(step)
|
|
321
|
+
except Exception:
|
|
322
|
+
# If snapshot fails, just record without it
|
|
323
|
+
self.trace.add_click(element_id, selector)
|
|
324
|
+
else:
|
|
325
|
+
self.trace.add_click(element_id, selector)
|
|
326
|
+
|
|
327
|
+
def record_type(self, element_id: int, text: str, selector: str | None = None) -> None:
|
|
328
|
+
"""Record a type event with smart selector inference"""
|
|
329
|
+
if self._active and self.trace:
|
|
330
|
+
# If no selector provided, try to infer one
|
|
331
|
+
if selector is None:
|
|
332
|
+
selector = self._infer_selector(element_id)
|
|
333
|
+
|
|
334
|
+
mask = self._should_mask(text)
|
|
335
|
+
self.trace.add_type(element_id, text, selector, mask=mask)
|
|
336
|
+
|
|
337
|
+
def record_press(self, key: str) -> None:
|
|
338
|
+
"""Record a key press event"""
|
|
339
|
+
if self._active and self.trace:
|
|
340
|
+
self.trace.add_press(key)
|
|
341
|
+
|
|
342
|
+
def save(self, filepath: str) -> None:
|
|
343
|
+
"""Save trace to file"""
|
|
344
|
+
if not self.trace:
|
|
345
|
+
raise RuntimeError("No trace to save. Start recording first.")
|
|
346
|
+
self.trace.save(filepath)
|
|
347
|
+
|
|
348
|
+
def __enter__(self):
|
|
349
|
+
"""Context manager entry"""
|
|
350
|
+
self.start()
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
354
|
+
"""Context manager exit"""
|
|
355
|
+
self.stop()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def record(browser: SentienceBrowser, capture_snapshots: bool = False) -> Recorder:
|
|
359
|
+
"""
|
|
360
|
+
Create a recorder instance
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
browser: SentienceBrowser instance
|
|
364
|
+
capture_snapshots: Whether to capture snapshots at each step
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Recorder instance
|
|
368
|
+
"""
|
|
369
|
+
return Recorder(browser, capture_snapshots=capture_snapshots)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class RecorderAsync:
|
|
373
|
+
"""Recorder for capturing user actions (async)"""
|
|
374
|
+
|
|
375
|
+
def __init__(self, browser: AsyncSentienceBrowser, capture_snapshots: bool = False):
|
|
376
|
+
self.browser = browser
|
|
377
|
+
self.capture_snapshots = capture_snapshots
|
|
378
|
+
self.trace: Trace | None = None
|
|
379
|
+
self._active = False
|
|
380
|
+
self._mask_patterns: list[str] = [] # Patterns to mask (e.g., "password", "email")
|
|
381
|
+
|
|
382
|
+
async def start(self) -> None:
|
|
383
|
+
"""Start recording"""
|
|
384
|
+
if not self.browser.page:
|
|
385
|
+
raise RuntimeError("Browser not started. Call await browser.start() first.")
|
|
386
|
+
|
|
387
|
+
self._active = True
|
|
388
|
+
start_url = self.browser.page.url
|
|
389
|
+
self.trace = Trace(start_url)
|
|
390
|
+
|
|
391
|
+
# Set up event listeners in the browser
|
|
392
|
+
self._setup_listeners()
|
|
393
|
+
|
|
394
|
+
def stop(self) -> None:
|
|
395
|
+
"""Stop recording"""
|
|
396
|
+
self._active = False
|
|
397
|
+
self._cleanup_listeners()
|
|
398
|
+
|
|
399
|
+
def add_mask_pattern(self, pattern: str) -> None:
|
|
400
|
+
"""Add a pattern to mask in recorded text (e.g., "password", "email")"""
|
|
401
|
+
self._mask_patterns.append(pattern.lower())
|
|
402
|
+
|
|
403
|
+
def _should_mask(self, text: str) -> bool:
|
|
404
|
+
"""Check if text should be masked"""
|
|
405
|
+
text_lower = text.lower()
|
|
406
|
+
return any(pattern in text_lower for pattern in self._mask_patterns)
|
|
407
|
+
|
|
408
|
+
def _setup_listeners(self) -> None:
|
|
409
|
+
"""Set up event listeners to capture actions"""
|
|
410
|
+
# Note: We'll capture actions through the SDK methods rather than DOM events
|
|
411
|
+
# This is cleaner and more reliable
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
def _cleanup_listeners(self) -> None:
|
|
415
|
+
"""Clean up event listeners"""
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
async def _infer_selector(self, element_id: int) -> str | None: # noqa: C901
|
|
419
|
+
"""
|
|
420
|
+
Infer a semantic selector for an element (async)
|
|
421
|
+
|
|
422
|
+
Uses heuristics to build a robust selector:
|
|
423
|
+
- role=... text~"..."
|
|
424
|
+
- If text empty: use name/aria-label/placeholder
|
|
425
|
+
- Include clickable=true when relevant
|
|
426
|
+
- Validate against snapshot (should match 1 element)
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
# Take a snapshot to get element info
|
|
430
|
+
snap = await snapshot_async(self.browser)
|
|
431
|
+
|
|
432
|
+
# Find the element in the snapshot
|
|
433
|
+
element = None
|
|
434
|
+
for el in snap.elements:
|
|
435
|
+
if el.id == element_id:
|
|
436
|
+
element = el
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
if not element:
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
# Build candidate selector
|
|
443
|
+
parts = []
|
|
444
|
+
|
|
445
|
+
# Add role
|
|
446
|
+
if element.role and element.role != "generic":
|
|
447
|
+
parts.append(f"role={element.role}")
|
|
448
|
+
|
|
449
|
+
# Add text if available
|
|
450
|
+
if element.text:
|
|
451
|
+
# Use contains match for text
|
|
452
|
+
text_part = element.text.replace('"', '\\"')[:50] # Limit length
|
|
453
|
+
parts.append(f'text~"{text_part}"')
|
|
454
|
+
else:
|
|
455
|
+
# Try to get name/aria-label/placeholder from DOM
|
|
456
|
+
try:
|
|
457
|
+
el = await self.browser.page.evaluate(
|
|
458
|
+
f"""
|
|
459
|
+
() => {{
|
|
460
|
+
const el = window.sentience_registry[{element_id}];
|
|
461
|
+
if (!el) return null;
|
|
462
|
+
return {{
|
|
463
|
+
name: el.name || null,
|
|
464
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
465
|
+
placeholder: el.placeholder || null
|
|
466
|
+
}};
|
|
467
|
+
}}
|
|
468
|
+
"""
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if el:
|
|
472
|
+
if el.get("name"):
|
|
473
|
+
parts.append(f'name="{el["name"]}"')
|
|
474
|
+
elif el.get("ariaLabel"):
|
|
475
|
+
parts.append(f'text~"{el["ariaLabel"]}"')
|
|
476
|
+
elif el.get("placeholder"):
|
|
477
|
+
parts.append(f'text~"{el["placeholder"]}"')
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
# Add clickable if relevant
|
|
482
|
+
if element.visual_cues.is_clickable:
|
|
483
|
+
parts.append("clickable=true")
|
|
484
|
+
|
|
485
|
+
if not parts:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
selector = " ".join(parts)
|
|
489
|
+
|
|
490
|
+
# Validate selector - should match exactly 1 element
|
|
491
|
+
matches = [el for el in snap.elements if self._match_element(el, selector)]
|
|
492
|
+
|
|
493
|
+
if len(matches) == 1:
|
|
494
|
+
return selector
|
|
495
|
+
elif len(matches) > 1:
|
|
496
|
+
# Add more constraints (importance threshold, near-center)
|
|
497
|
+
# For now, just return the selector with a note
|
|
498
|
+
return selector
|
|
499
|
+
else:
|
|
500
|
+
# Selector doesn't match - return None (will use element_id)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
except Exception:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
def _match_element(self, element: Element, selector: str) -> bool:
|
|
507
|
+
"""Simple selector matching (basic implementation)"""
|
|
508
|
+
# This is a simplified version - in production, use the full query engine
|
|
509
|
+
from .query import match_element, parse_selector
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
query_dict = parse_selector(selector)
|
|
513
|
+
return match_element(element, query_dict)
|
|
514
|
+
except Exception:
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
def record_navigation(self, url: str) -> None:
|
|
518
|
+
"""Record a navigation event"""
|
|
519
|
+
if self._active and self.trace:
|
|
520
|
+
self.trace.add_navigation(url)
|
|
521
|
+
|
|
522
|
+
async def record_click(self, element_id: int, selector: str | None = None) -> None:
|
|
523
|
+
"""Record a click event with smart selector inference (async)"""
|
|
524
|
+
if self._active and self.trace:
|
|
525
|
+
# If no selector provided, try to infer one
|
|
526
|
+
if selector is None:
|
|
527
|
+
selector = await self._infer_selector(element_id)
|
|
528
|
+
|
|
529
|
+
# Optionally capture snapshot
|
|
530
|
+
if self.capture_snapshots:
|
|
531
|
+
try:
|
|
532
|
+
snap = await snapshot_async(self.browser)
|
|
533
|
+
step = TraceStep(
|
|
534
|
+
ts=int((datetime.now() - self.trace._start_time).total_seconds() * 1000),
|
|
535
|
+
type="click",
|
|
536
|
+
element_id=element_id,
|
|
537
|
+
selector=selector,
|
|
538
|
+
snapshot=snap,
|
|
539
|
+
)
|
|
540
|
+
self.trace.add_step(step)
|
|
541
|
+
except Exception:
|
|
542
|
+
# If snapshot fails, just record without it
|
|
543
|
+
self.trace.add_click(element_id, selector)
|
|
544
|
+
else:
|
|
545
|
+
self.trace.add_click(element_id, selector)
|
|
546
|
+
|
|
547
|
+
async def record_type(self, element_id: int, text: str, selector: str | None = None) -> None:
|
|
548
|
+
"""Record a type event with smart selector inference (async)"""
|
|
549
|
+
if self._active and self.trace:
|
|
550
|
+
# If no selector provided, try to infer one
|
|
551
|
+
if selector is None:
|
|
552
|
+
selector = await self._infer_selector(element_id)
|
|
553
|
+
|
|
554
|
+
mask = self._should_mask(text)
|
|
555
|
+
self.trace.add_type(element_id, text, selector, mask=mask)
|
|
556
|
+
|
|
557
|
+
def record_press(self, key: str) -> None:
|
|
558
|
+
"""Record a key press event"""
|
|
559
|
+
if self._active and self.trace:
|
|
560
|
+
self.trace.add_press(key)
|
|
561
|
+
|
|
562
|
+
def save(self, filepath: str) -> None:
|
|
563
|
+
"""Save trace to file"""
|
|
564
|
+
if not self.trace:
|
|
565
|
+
raise RuntimeError("No trace to save. Start recording first.")
|
|
566
|
+
self.trace.save(filepath)
|
|
567
|
+
|
|
568
|
+
async def __aenter__(self):
|
|
569
|
+
"""Context manager entry"""
|
|
570
|
+
await self.start()
|
|
571
|
+
return self
|
|
572
|
+
|
|
573
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
574
|
+
"""Context manager exit"""
|
|
575
|
+
self.stop()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def record_async(browser: AsyncSentienceBrowser, capture_snapshots: bool = False) -> RecorderAsync:
|
|
579
|
+
"""
|
|
580
|
+
Create a recorder instance (async)
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
browser: AsyncSentienceBrowser instance
|
|
584
|
+
capture_snapshots: Whether to capture snapshots at each step
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
RecorderAsync instance
|
|
588
|
+
"""
|
|
589
|
+
return RecorderAsync(browser, capture_snapshots=capture_snapshots)
|