sentienceapi 0.92.2__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.

Files changed (64) hide show
  1. sentience/__init__.py +107 -2
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +2 -0
  4. sentience/actions.py +354 -9
  5. sentience/agent.py +4 -0
  6. sentience/agent_runtime.py +840 -0
  7. sentience/asserts/__init__.py +70 -0
  8. sentience/asserts/expect.py +621 -0
  9. sentience/asserts/query.py +383 -0
  10. sentience/async_api.py +8 -1
  11. sentience/backends/__init__.py +137 -0
  12. sentience/backends/actions.py +372 -0
  13. sentience/backends/browser_use_adapter.py +241 -0
  14. sentience/backends/cdp_backend.py +393 -0
  15. sentience/backends/exceptions.py +211 -0
  16. sentience/backends/playwright_backend.py +194 -0
  17. sentience/backends/protocol.py +216 -0
  18. sentience/backends/sentience_context.py +469 -0
  19. sentience/backends/snapshot.py +483 -0
  20. sentience/browser.py +230 -74
  21. sentience/canonicalization.py +207 -0
  22. sentience/cloud_tracing.py +65 -24
  23. sentience/constants.py +6 -0
  24. sentience/cursor_policy.py +142 -0
  25. sentience/extension/content.js +35 -0
  26. sentience/extension/injected_api.js +310 -15
  27. sentience/extension/manifest.json +1 -1
  28. sentience/extension/pkg/sentience_core.d.ts +22 -22
  29. sentience/extension/pkg/sentience_core.js +192 -144
  30. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  31. sentience/extension/release.json +29 -29
  32. sentience/failure_artifacts.py +241 -0
  33. sentience/integrations/__init__.py +6 -0
  34. sentience/integrations/langchain/__init__.py +12 -0
  35. sentience/integrations/langchain/context.py +18 -0
  36. sentience/integrations/langchain/core.py +326 -0
  37. sentience/integrations/langchain/tools.py +180 -0
  38. sentience/integrations/models.py +46 -0
  39. sentience/integrations/pydanticai/__init__.py +15 -0
  40. sentience/integrations/pydanticai/deps.py +20 -0
  41. sentience/integrations/pydanticai/toolset.py +468 -0
  42. sentience/llm_provider.py +695 -18
  43. sentience/models.py +536 -3
  44. sentience/ordinal.py +280 -0
  45. sentience/query.py +66 -4
  46. sentience/schemas/trace_v1.json +27 -1
  47. sentience/snapshot.py +384 -93
  48. sentience/snapshot_diff.py +39 -54
  49. sentience/text_search.py +1 -0
  50. sentience/trace_event_builder.py +20 -1
  51. sentience/trace_indexing/indexer.py +3 -49
  52. sentience/tracer_factory.py +1 -3
  53. sentience/verification.py +618 -0
  54. sentience/visual_agent.py +3 -1
  55. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
  56. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  57. sentience/utils.py +0 -296
  58. sentienceapi-0.92.2.dist-info/RECORD +0 -65
  59. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  60. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  61. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  62. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  63. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  64. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/ordinal.py ADDED
@@ -0,0 +1,280 @@
1
+ """
2
+ Phase 3: Ordinal Intent Detection for Semantic Search
3
+
4
+ This module provides functions to detect ordinal intent in natural language goals
5
+ and select elements based on their position within groups.
6
+
7
+ Ordinal operators supported:
8
+ - Position-based: "first", "second", "third", "1st", "2nd", "3rd", etc.
9
+ - Relative: "top", "bottom", "last", "next", "previous"
10
+ - Numeric: "#1", "#2", "number 1", "item 3"
11
+
12
+ Example usage:
13
+ from sentience.ordinal import detect_ordinal_intent, select_by_ordinal
14
+
15
+ intent = detect_ordinal_intent("click the first search result")
16
+ # OrdinalIntent(kind='nth', n=1, detected=True)
17
+
18
+ element = select_by_ordinal(elements, dominant_group_key, intent)
19
+ """
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+ from typing import Literal
24
+
25
+ from sentience.models import Element
26
+
27
+
28
+ @dataclass
29
+ class OrdinalIntent:
30
+ """Detected ordinal intent from a goal string."""
31
+
32
+ detected: bool
33
+ kind: Literal["first", "last", "nth", "top_k", "next", "previous"] | None = None
34
+ n: int | None = None # For "nth" kind: 1-indexed position (1=first, 2=second)
35
+ k: int | None = None # For "top_k" kind: number of items
36
+
37
+
38
+ # Ordinal word to number mapping
39
+ ORDINAL_WORDS = {
40
+ "first": 1,
41
+ "second": 2,
42
+ "third": 3,
43
+ "fourth": 4,
44
+ "fifth": 5,
45
+ "sixth": 6,
46
+ "seventh": 7,
47
+ "eighth": 8,
48
+ "ninth": 9,
49
+ "tenth": 10,
50
+ "1st": 1,
51
+ "2nd": 2,
52
+ "3rd": 3,
53
+ "4th": 4,
54
+ "5th": 5,
55
+ "6th": 6,
56
+ "7th": 7,
57
+ "8th": 8,
58
+ "9th": 9,
59
+ "10th": 10,
60
+ }
61
+
62
+ # Patterns for detecting ordinal intent
63
+ ORDINAL_PATTERNS = [
64
+ # "first", "second", etc.
65
+ (
66
+ r"\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\b",
67
+ "ordinal_word",
68
+ ),
69
+ # "1st", "2nd", "3rd", etc.
70
+ (r"\b(\d+)(st|nd|rd|th)\b", "ordinal_suffix"),
71
+ # "#1", "#2", etc.
72
+ (r"#(\d+)\b", "hash_number"),
73
+ # "number 1", "item 3", "result 5"
74
+ (r"\b(?:number|item|result|option|choice)\s*(\d+)\b", "labeled_number"),
75
+ # "top" (implies first/best)
76
+ (r"\btop\b(?!\s*\d)", "top"),
77
+ # "top 3", "top 5"
78
+ (r"\btop\s+(\d+)\b", "top_k"),
79
+ # "last", "final", "bottom"
80
+ (r"\b(last|final|bottom)\b", "last"),
81
+ # "next", "following"
82
+ (r"\b(next|following)\b", "next"),
83
+ # "previous", "preceding", "prior"
84
+ (r"\b(previous|preceding|prior)\b", "previous"),
85
+ ]
86
+
87
+
88
+ def detect_ordinal_intent(goal: str) -> OrdinalIntent:
89
+ """
90
+ Detect ordinal intent from a goal string.
91
+
92
+ Args:
93
+ goal: Natural language goal (e.g., "click the first search result")
94
+
95
+ Returns:
96
+ OrdinalIntent with detected=True if ordinal intent found, False otherwise.
97
+
98
+ Examples:
99
+ >>> detect_ordinal_intent("click the first item")
100
+ OrdinalIntent(detected=True, kind='nth', n=1)
101
+
102
+ >>> detect_ordinal_intent("select the 3rd option")
103
+ OrdinalIntent(detected=True, kind='nth', n=3)
104
+
105
+ >>> detect_ordinal_intent("show top 5 results")
106
+ OrdinalIntent(detected=True, kind='top_k', k=5)
107
+
108
+ >>> detect_ordinal_intent("click the last button")
109
+ OrdinalIntent(detected=True, kind='last')
110
+
111
+ >>> detect_ordinal_intent("find the submit button")
112
+ OrdinalIntent(detected=False)
113
+ """
114
+ goal_lower = goal.lower()
115
+
116
+ for pattern, pattern_type in ORDINAL_PATTERNS:
117
+ match = re.search(pattern, goal_lower, re.IGNORECASE)
118
+ if match:
119
+ if pattern_type == "ordinal_word":
120
+ word = match.group(1).lower()
121
+ n = ORDINAL_WORDS.get(word)
122
+ if n:
123
+ return OrdinalIntent(detected=True, kind="nth", n=n)
124
+
125
+ elif pattern_type == "ordinal_suffix":
126
+ n = int(match.group(1))
127
+ return OrdinalIntent(detected=True, kind="nth", n=n)
128
+
129
+ elif pattern_type == "hash_number":
130
+ n = int(match.group(1))
131
+ return OrdinalIntent(detected=True, kind="nth", n=n)
132
+
133
+ elif pattern_type == "labeled_number":
134
+ n = int(match.group(1))
135
+ return OrdinalIntent(detected=True, kind="nth", n=n)
136
+
137
+ elif pattern_type == "top":
138
+ # "top" without a number means "first/best"
139
+ return OrdinalIntent(detected=True, kind="first")
140
+
141
+ elif pattern_type == "top_k":
142
+ k = int(match.group(1))
143
+ return OrdinalIntent(detected=True, kind="top_k", k=k)
144
+
145
+ elif pattern_type == "last":
146
+ return OrdinalIntent(detected=True, kind="last")
147
+
148
+ elif pattern_type == "next":
149
+ return OrdinalIntent(detected=True, kind="next")
150
+
151
+ elif pattern_type == "previous":
152
+ return OrdinalIntent(detected=True, kind="previous")
153
+
154
+ return OrdinalIntent(detected=False)
155
+
156
+
157
+ def select_by_ordinal(
158
+ elements: list[Element],
159
+ dominant_group_key: str | None,
160
+ intent: OrdinalIntent,
161
+ current_element_id: int | None = None,
162
+ ) -> Element | list[Element] | None:
163
+ """
164
+ Select element(s) from a list based on ordinal intent.
165
+
166
+ Uses the dominant_group_key to filter to the "main content" group,
167
+ then selects by group_index based on the ordinal intent.
168
+
169
+ Args:
170
+ elements: List of elements with group_key and group_index populated
171
+ dominant_group_key: The most common group key (main content group)
172
+ intent: Detected ordinal intent
173
+ current_element_id: Current element ID (for next/previous navigation)
174
+
175
+ Returns:
176
+ Single Element for nth/first/last, list of Elements for top_k,
177
+ or None if no matching element found.
178
+
179
+ Examples:
180
+ >>> intent = OrdinalIntent(detected=True, kind='nth', n=1)
181
+ >>> element = select_by_ordinal(elements, "x5-w2-h1", intent)
182
+ # Returns element with group_key="x5-w2-h1" and group_index=0
183
+ """
184
+ if not intent.detected:
185
+ return None
186
+
187
+ # Filter to dominant group if available
188
+ if dominant_group_key:
189
+ group_elements = [e for e in elements if e.group_key == dominant_group_key]
190
+ else:
191
+ # Fallback: use all elements with group_index
192
+ group_elements = [e for e in elements if e.group_index is not None]
193
+
194
+ if not group_elements:
195
+ return None
196
+
197
+ # Sort by group_index to ensure correct ordering
198
+ group_elements.sort(key=lambda e: e.group_index if e.group_index is not None else 0)
199
+
200
+ if intent.kind == "first" or (intent.kind == "nth" and intent.n == 1):
201
+ # First element (group_index=0)
202
+ return group_elements[0] if group_elements else None
203
+
204
+ elif intent.kind == "nth" and intent.n is not None:
205
+ # Nth element (1-indexed, so n=2 means group_index=1)
206
+ target_index = intent.n - 1
207
+ if 0 <= target_index < len(group_elements):
208
+ return group_elements[target_index]
209
+ return None
210
+
211
+ elif intent.kind == "last":
212
+ # Last element
213
+ return group_elements[-1] if group_elements else None
214
+
215
+ elif intent.kind == "top_k" and intent.k is not None:
216
+ # Top K elements
217
+ return group_elements[: intent.k]
218
+
219
+ elif intent.kind == "next" and current_element_id is not None:
220
+ # Next element after current
221
+ for i, elem in enumerate(group_elements):
222
+ if elem.id == current_element_id and i + 1 < len(group_elements):
223
+ return group_elements[i + 1]
224
+ return None
225
+
226
+ elif intent.kind == "previous" and current_element_id is not None:
227
+ # Previous element before current
228
+ for i, elem in enumerate(group_elements):
229
+ if elem.id == current_element_id and i > 0:
230
+ return group_elements[i - 1]
231
+ return None
232
+
233
+ return None
234
+
235
+
236
+ def boost_ordinal_elements(
237
+ elements: list[Element],
238
+ dominant_group_key: str | None,
239
+ intent: OrdinalIntent,
240
+ boost_factor: int = 10000,
241
+ ) -> list[Element]:
242
+ """
243
+ Boost the importance of elements matching ordinal intent.
244
+
245
+ This is useful for integrating ordinal selection with existing
246
+ importance-based ranking. Elements matching the ordinal intent
247
+ get a significant importance boost.
248
+
249
+ Args:
250
+ elements: List of elements (not modified)
251
+ dominant_group_key: The most common group key
252
+ intent: Detected ordinal intent
253
+ boost_factor: Amount to add to importance (default: 10000)
254
+
255
+ Returns:
256
+ A new list with copies of elements, with boosted importance for matches.
257
+ """
258
+ if not intent.detected or not dominant_group_key:
259
+ return [e.model_copy() for e in elements]
260
+
261
+ target = select_by_ordinal(elements, dominant_group_key, intent)
262
+
263
+ if target is None:
264
+ return [e.model_copy() for e in elements]
265
+
266
+ # Handle single element or list
267
+ if isinstance(target, list):
268
+ target_ids = {e.id for e in target}
269
+ else:
270
+ target_ids = {target.id}
271
+
272
+ # Create copies and boost matching elements
273
+ result = []
274
+ for elem in elements:
275
+ copy = elem.model_copy()
276
+ if copy.id in target_ids:
277
+ copy.importance = (copy.importance or 0) + boost_factor
278
+ result.append(copy)
279
+
280
+ return result
sentience/query.py CHANGED
@@ -52,16 +52,28 @@ def parse_selector(selector: str) -> dict[str, Any]: # noqa: C901
52
52
  query["visible"] = False
53
53
  elif op == "~":
54
54
  # Substring match (case-insensitive)
55
- if key == "text" or key == "name":
55
+ if key == "text":
56
56
  query["text_contains"] = value
57
+ elif key == "name":
58
+ query["name_contains"] = value
59
+ elif key == "value":
60
+ query["value_contains"] = value
57
61
  elif op == "^=":
58
62
  # Prefix match
59
- if key == "text" or key == "name":
63
+ if key == "text":
60
64
  query["text_prefix"] = value
65
+ elif key == "name":
66
+ query["name_prefix"] = value
67
+ elif key == "value":
68
+ query["value_prefix"] = value
61
69
  elif op == "$=":
62
70
  # Suffix match
63
- if key == "text" or key == "name":
71
+ if key == "text":
64
72
  query["text_suffix"] = value
73
+ elif key == "name":
74
+ query["name_suffix"] = value
75
+ elif key == "value":
76
+ query["value_suffix"] = value
65
77
  elif op == ">":
66
78
  # Greater than
67
79
  if is_numeric:
@@ -116,8 +128,14 @@ def parse_selector(selector: str) -> dict[str, Any]: # noqa: C901
116
128
  query["visible"] = value.lower() == "true"
117
129
  elif key == "tag":
118
130
  query["tag"] = value
119
- elif key == "name" or key == "text":
131
+ elif key == "text":
120
132
  query["text"] = value
133
+ elif key == "name":
134
+ query["name"] = value
135
+ elif key == "value":
136
+ query["value"] = value
137
+ elif key in ("checked", "disabled", "expanded"):
138
+ query[key] = value.lower() == "true"
121
139
  elif key == "importance" and is_numeric:
122
140
  query["importance"] = numeric_value
123
141
  elif key.startswith("attr."):
@@ -192,6 +210,50 @@ def match_element(element: Element, query: dict[str, Any]) -> bool: # noqa: C90
192
210
  if not element.text.lower().endswith(query["text_suffix"].lower()):
193
211
  return False
194
212
 
213
+ # Name matching (best-effort; fallback to text for backward compatibility)
214
+ name_val = element.name or element.text or ""
215
+ if "name" in query:
216
+ if not name_val or name_val != query["name"]:
217
+ return False
218
+ if "name_contains" in query:
219
+ if not name_val or query["name_contains"].lower() not in name_val.lower():
220
+ return False
221
+ if "name_prefix" in query:
222
+ if not name_val or not name_val.lower().startswith(query["name_prefix"].lower()):
223
+ return False
224
+ if "name_suffix" in query:
225
+ if not name_val or not name_val.lower().endswith(query["name_suffix"].lower()):
226
+ return False
227
+
228
+ # Value matching (inputs/textarea/select)
229
+ if "value" in query:
230
+ if element.value is None or element.value != query["value"]:
231
+ return False
232
+ if "value_contains" in query:
233
+ if element.value is None or query["value_contains"].lower() not in element.value.lower():
234
+ return False
235
+ if "value_prefix" in query:
236
+ if element.value is None or not element.value.lower().startswith(
237
+ query["value_prefix"].lower()
238
+ ):
239
+ return False
240
+ if "value_suffix" in query:
241
+ if element.value is None or not element.value.lower().endswith(
242
+ query["value_suffix"].lower()
243
+ ):
244
+ return False
245
+
246
+ # State matching (best-effort)
247
+ if "checked" in query:
248
+ if (element.checked is True) != query["checked"]:
249
+ return False
250
+ if "disabled" in query:
251
+ if (element.disabled is True) != query["disabled"]:
252
+ return False
253
+ if "expanded" in query:
254
+ if (element.expanded is True) != query["expanded"]:
255
+ return False
256
+
195
257
  # Importance filtering
196
258
  if "importance" in query:
197
259
  if element.importance != query["importance"]:
@@ -248,7 +248,24 @@
248
248
  }
249
249
  }
250
250
  }
251
- }
251
+ },
252
+ "assertions": {
253
+ "type": "array",
254
+ "description": "Assertion results from agent verification loop",
255
+ "items": {
256
+ "type": "object",
257
+ "required": ["label", "passed"],
258
+ "properties": {
259
+ "label": {"type": "string", "description": "Human-readable assertion label"},
260
+ "passed": {"type": "boolean", "description": "Whether the assertion passed"},
261
+ "required": {"type": "boolean", "description": "If true, assertion gates step success"},
262
+ "reason": {"type": "string", "description": "Explanation (especially when failed)"},
263
+ "details": {"type": "object", "description": "Additional structured data for debugging"}
264
+ }
265
+ }
266
+ },
267
+ "task_done": {"type": "boolean", "description": "True if task completion assertion passed"},
268
+ "task_done_label": {"type": "string", "description": "Label of the task completion assertion"}
252
269
  }
253
270
  }
254
271
  }
@@ -270,6 +287,15 @@
270
287
  "properties": {
271
288
  "step_id": {"type": "string"},
272
289
  "passed": {"type": "boolean"},
290
+ "kind": {
291
+ "type": "string",
292
+ "enum": ["assert", "task_done"],
293
+ "description": "Type of verification event"
294
+ },
295
+ "label": {"type": "string", "description": "Human-readable label for the assertion"},
296
+ "required": {"type": "boolean", "description": "If true, assertion gates step success"},
297
+ "reason": {"type": "string", "description": "Explanation (especially when failed)"},
298
+ "details": {"type": "object", "description": "Additional structured data for debugging"},
273
299
  "signals": {"type": "object"}
274
300
  }
275
301
  },