Skip to content

Commit a4cc859

Browse files
author
SentienceDEV
committed
ordinal support
1 parent e77d857 commit a4cc859

File tree

4 files changed

+732
-0
lines changed

4 files changed

+732
-0
lines changed

sentience/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
ViewportRect,
7878
WaitResult,
7979
)
80+
81+
# Ordinal support (Phase 3)
82+
from .ordinal import OrdinalIntent, boost_ordinal_elements, detect_ordinal_intent, select_by_ordinal
8083
from .overlay import clear_overlay, show_overlay
8184
from .query import find, query
8285
from .read import read
@@ -242,4 +245,9 @@
242245
"all_of",
243246
"any_of",
244247
"custom",
248+
# Ordinal support (Phase 3)
249+
"OrdinalIntent",
250+
"detect_ordinal_intent",
251+
"select_by_ordinal",
252+
"boost_ordinal_elements",
245253
]

sentience/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class Snapshot(BaseModel):
7474
screenshot_format: Literal["png", "jpeg"] | None = None
7575
error: str | None = None
7676
requires_license: bool | None = None
77+
# Phase 2: Dominant group key for ordinal selection
78+
dominant_group_key: str | None = None # The most common group_key (main content group)
7779

7880
def save(self, filepath: str) -> None:
7981
"""Save snapshot as JSON file"""

sentience/ordinal.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
from dataclasses import dataclass
22+
from typing import Literal
23+
import re
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

0 commit comments

Comments
 (0)