lumivor 0.1.7__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.
- lumivor/README.md +51 -0
- lumivor/__init__.py +25 -0
- lumivor/agent/message_manager/service.py +252 -0
- lumivor/agent/message_manager/tests.py +246 -0
- lumivor/agent/message_manager/views.py +37 -0
- lumivor/agent/prompts.py +208 -0
- lumivor/agent/service.py +1017 -0
- lumivor/agent/tests.py +204 -0
- lumivor/agent/views.py +272 -0
- lumivor/browser/browser.py +208 -0
- lumivor/browser/context.py +993 -0
- lumivor/browser/tests/screenshot_test.py +38 -0
- lumivor/browser/tests/test_clicks.py +77 -0
- lumivor/browser/views.py +48 -0
- lumivor/controller/registry/service.py +140 -0
- lumivor/controller/registry/views.py +71 -0
- lumivor/controller/service.py +557 -0
- lumivor/controller/views.py +47 -0
- lumivor/dom/__init__.py +0 -0
- lumivor/dom/buildDomTree.js +428 -0
- lumivor/dom/history_tree_processor/service.py +112 -0
- lumivor/dom/history_tree_processor/view.py +33 -0
- lumivor/dom/service.py +100 -0
- lumivor/dom/tests/extraction_test.py +44 -0
- lumivor/dom/tests/process_dom_test.py +40 -0
- lumivor/dom/views.py +187 -0
- lumivor/logging_config.py +128 -0
- lumivor/telemetry/service.py +114 -0
- lumivor/telemetry/views.py +51 -0
- lumivor/utils.py +54 -0
- lumivor-0.1.7.dist-info/METADATA +100 -0
- lumivor-0.1.7.dist-info/RECORD +34 -0
- lumivor-0.1.7.dist-info/WHEEL +4 -0
- lumivor-0.1.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
import base64
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from lumivor.browser.browser import Browser, BrowserConfig
|
6
|
+
|
7
|
+
|
8
|
+
@pytest.fixture
|
9
|
+
async def browser():
|
10
|
+
browser_service = Browser(config=BrowserConfig(headless=True))
|
11
|
+
yield browser_service
|
12
|
+
|
13
|
+
await browser_service.close()
|
14
|
+
|
15
|
+
|
16
|
+
# @pytest.mark.skip(reason='takes too long')
|
17
|
+
def test_take_full_page_screenshot(browser):
|
18
|
+
# Go to a test page
|
19
|
+
browser.go_to_url('https://example.com')
|
20
|
+
|
21
|
+
# Take full page screenshot
|
22
|
+
screenshot_b64 = browser.take_screenshot(full_page=True)
|
23
|
+
|
24
|
+
# Verify screenshot is not empty and is valid base64
|
25
|
+
assert screenshot_b64 is not None
|
26
|
+
assert isinstance(screenshot_b64, str)
|
27
|
+
assert len(screenshot_b64) > 0
|
28
|
+
|
29
|
+
# Test we can decode the base64 string
|
30
|
+
try:
|
31
|
+
base64.b64decode(screenshot_b64)
|
32
|
+
except Exception as e:
|
33
|
+
pytest.fail(f'Failed to decode base64 screenshot: {str(e)}')
|
34
|
+
|
35
|
+
|
36
|
+
if __name__ == '__main__':
|
37
|
+
test_take_full_page_screenshot(
|
38
|
+
Browser(config=BrowserConfig(headless=False)))
|
@@ -0,0 +1,77 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
|
4
|
+
import pytest
|
5
|
+
|
6
|
+
from lumivor.browser.browser import Browser, BrowserConfig
|
7
|
+
from lumivor.dom.views import ElementTreeSerializer
|
8
|
+
from lumivor.utils import time_execution_sync
|
9
|
+
|
10
|
+
|
11
|
+
# run with: pytest lumivor/browser/tests/test_clicks.py
|
12
|
+
@pytest.mark.asyncio
|
13
|
+
async def test_highlight_elements():
|
14
|
+
browser = Browser(config=BrowserConfig(
|
15
|
+
headless=False, disable_security=True))
|
16
|
+
|
17
|
+
async with await browser.new_context() as context:
|
18
|
+
page = await context.get_current_page()
|
19
|
+
# await page.goto('https://immobilienscout24.de')
|
20
|
+
# await page.goto('https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/service-plans')
|
21
|
+
# await page.goto('https://google.com/search?q=elon+musk')
|
22
|
+
# await page.goto('https://kayak.com')
|
23
|
+
# await page.goto('https://www.w3schools.com/tags/tryit.asp?filename=tryhtml_iframe')
|
24
|
+
# await page.goto('https://dictionary.cambridge.org')
|
25
|
+
# await page.goto('https://github.com')
|
26
|
+
await page.goto('https://huggingface.co/')
|
27
|
+
|
28
|
+
await asyncio.sleep(1)
|
29
|
+
|
30
|
+
while True:
|
31
|
+
try:
|
32
|
+
# await asyncio.sleep(10)
|
33
|
+
state = await context.get_state()
|
34
|
+
|
35
|
+
with open('./tmp/page.json', 'w') as f:
|
36
|
+
json.dump(
|
37
|
+
ElementTreeSerializer.dom_element_node_to_json(
|
38
|
+
state.element_tree),
|
39
|
+
f,
|
40
|
+
indent=1,
|
41
|
+
)
|
42
|
+
|
43
|
+
# await time_execution_sync('highlight_selector_map_elements')(
|
44
|
+
# browser.highlight_selector_map_elements
|
45
|
+
# )(state.selector_map)
|
46
|
+
|
47
|
+
# Find and print duplicate XPaths
|
48
|
+
xpath_counts = {}
|
49
|
+
if not state.selector_map:
|
50
|
+
continue
|
51
|
+
for selector in state.selector_map.values():
|
52
|
+
xpath = selector.xpath
|
53
|
+
if xpath in xpath_counts:
|
54
|
+
xpath_counts[xpath] += 1
|
55
|
+
else:
|
56
|
+
xpath_counts[xpath] = 1
|
57
|
+
|
58
|
+
print('\nDuplicate XPaths found:')
|
59
|
+
for xpath, count in xpath_counts.items():
|
60
|
+
if count > 1:
|
61
|
+
print(f'XPath: {xpath}')
|
62
|
+
print(f'Count: {count}\n')
|
63
|
+
|
64
|
+
print(list(state.selector_map.keys()), 'Selector map keys')
|
65
|
+
print(state.element_tree.clickable_elements_to_string())
|
66
|
+
action = input('Select next action: ')
|
67
|
+
|
68
|
+
await time_execution_sync('remove_highlight_elements')(context.remove_highlights)()
|
69
|
+
|
70
|
+
node_element = state.selector_map[int(action)]
|
71
|
+
|
72
|
+
# check if index of selector map are the same as index of items in dom_items
|
73
|
+
|
74
|
+
await context._click_element_node(node_element)
|
75
|
+
|
76
|
+
except Exception as e:
|
77
|
+
print(e)
|
lumivor/browser/views.py
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Any, Optional
|
3
|
+
|
4
|
+
from pydantic import BaseModel
|
5
|
+
|
6
|
+
from lumivor.dom.history_tree_processor.service import DOMHistoryElement
|
7
|
+
from lumivor.dom.views import DOMState
|
8
|
+
|
9
|
+
|
10
|
+
# Pydantic
|
11
|
+
class TabInfo(BaseModel):
|
12
|
+
"""Represents information about a browser tab"""
|
13
|
+
|
14
|
+
page_id: int
|
15
|
+
url: str
|
16
|
+
title: str
|
17
|
+
|
18
|
+
|
19
|
+
@dataclass
|
20
|
+
class BrowserState(DOMState):
|
21
|
+
url: str
|
22
|
+
title: str
|
23
|
+
tabs: list[TabInfo]
|
24
|
+
screenshot: Optional[str] = None
|
25
|
+
|
26
|
+
|
27
|
+
@dataclass
|
28
|
+
class BrowserStateHistory:
|
29
|
+
url: str
|
30
|
+
title: str
|
31
|
+
tabs: list[TabInfo]
|
32
|
+
interacted_element: list[DOMHistoryElement | None] | list[None]
|
33
|
+
screenshot: Optional[str] = None
|
34
|
+
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
36
|
+
data = {}
|
37
|
+
data['tabs'] = [tab.model_dump() for tab in self.tabs]
|
38
|
+
data['screenshot'] = self.screenshot
|
39
|
+
data['interacted_element'] = [
|
40
|
+
el.to_dict() if el else None for el in self.interacted_element
|
41
|
+
]
|
42
|
+
data['url'] = self.url
|
43
|
+
data['title'] = self.title
|
44
|
+
return data
|
45
|
+
|
46
|
+
|
47
|
+
class BrowserError(Exception):
|
48
|
+
"""Base class for all browser errors"""
|
@@ -0,0 +1,140 @@
|
|
1
|
+
import asyncio
|
2
|
+
from inspect import iscoroutinefunction, signature
|
3
|
+
from typing import Any, Callable, Optional, Type
|
4
|
+
|
5
|
+
from pydantic import BaseModel, create_model
|
6
|
+
|
7
|
+
from lumivor.browser.context import BrowserContext
|
8
|
+
from lumivor.controller.registry.views import (
|
9
|
+
ActionModel,
|
10
|
+
ActionRegistry,
|
11
|
+
RegisteredAction,
|
12
|
+
)
|
13
|
+
from lumivor.telemetry.service import ProductTelemetry
|
14
|
+
from lumivor.telemetry.views import (
|
15
|
+
ControllerRegisteredFunctionsTelemetryEvent,
|
16
|
+
RegisteredFunction,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
class Registry:
|
21
|
+
"""Service for registering and managing actions"""
|
22
|
+
|
23
|
+
def __init__(self):
|
24
|
+
self.registry = ActionRegistry()
|
25
|
+
self.telemetry = ProductTelemetry()
|
26
|
+
|
27
|
+
def _create_param_model(self, function: Callable) -> Type[BaseModel]:
|
28
|
+
"""Creates a Pydantic model from function signature"""
|
29
|
+
sig = signature(function)
|
30
|
+
params = {
|
31
|
+
name: (param.annotation, ... if param.default ==
|
32
|
+
param.empty else param.default)
|
33
|
+
for name, param in sig.parameters.items()
|
34
|
+
if name != 'browser'
|
35
|
+
}
|
36
|
+
# TODO: make the types here work
|
37
|
+
return create_model(
|
38
|
+
f'{function.__name__}Params',
|
39
|
+
__base__=ActionModel,
|
40
|
+
**params, # type: ignore
|
41
|
+
)
|
42
|
+
|
43
|
+
def action(
|
44
|
+
self,
|
45
|
+
description: str,
|
46
|
+
param_model: Optional[Type[BaseModel]] = None,
|
47
|
+
requires_browser: bool = False,
|
48
|
+
):
|
49
|
+
"""Decorator for registering actions"""
|
50
|
+
|
51
|
+
def decorator(func: Callable):
|
52
|
+
# Create param model from function if not provided
|
53
|
+
actual_param_model = param_model or self._create_param_model(func)
|
54
|
+
|
55
|
+
# Wrap sync functions to make them async
|
56
|
+
if not iscoroutinefunction(func):
|
57
|
+
|
58
|
+
async def async_wrapper(*args, **kwargs):
|
59
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
60
|
+
|
61
|
+
# Copy the signature and other metadata from the original function
|
62
|
+
async_wrapper.__signature__ = signature(func)
|
63
|
+
async_wrapper.__name__ = func.__name__
|
64
|
+
async_wrapper.__annotations__ = func.__annotations__
|
65
|
+
wrapped_func = async_wrapper
|
66
|
+
else:
|
67
|
+
wrapped_func = func
|
68
|
+
|
69
|
+
action = RegisteredAction(
|
70
|
+
name=func.__name__,
|
71
|
+
description=description,
|
72
|
+
function=wrapped_func,
|
73
|
+
param_model=actual_param_model,
|
74
|
+
requires_browser=requires_browser,
|
75
|
+
)
|
76
|
+
self.registry.actions[func.__name__] = action
|
77
|
+
return func
|
78
|
+
|
79
|
+
return decorator
|
80
|
+
|
81
|
+
async def execute_action(
|
82
|
+
self, action_name: str, params: dict, browser: Optional[BrowserContext] = None
|
83
|
+
) -> Any:
|
84
|
+
"""Execute a registered action"""
|
85
|
+
if action_name not in self.registry.actions:
|
86
|
+
raise ValueError(f'Action {action_name} not found')
|
87
|
+
|
88
|
+
action = self.registry.actions[action_name]
|
89
|
+
try:
|
90
|
+
# Create the validated Pydantic model
|
91
|
+
validated_params = action.param_model(**params)
|
92
|
+
|
93
|
+
# Check if the first parameter is a Pydantic model
|
94
|
+
sig = signature(action.function)
|
95
|
+
parameters = list(sig.parameters.values())
|
96
|
+
is_pydantic = parameters and issubclass(
|
97
|
+
parameters[0].annotation, BaseModel)
|
98
|
+
|
99
|
+
# Prepare arguments based on parameter type
|
100
|
+
if action.requires_browser:
|
101
|
+
if not browser:
|
102
|
+
raise ValueError(
|
103
|
+
f'Action {
|
104
|
+
action_name} requires browser but none provided. This has to be used in combination of `requires_browser=True` when registering the action.'
|
105
|
+
)
|
106
|
+
if is_pydantic:
|
107
|
+
return await action.function(validated_params, browser=browser)
|
108
|
+
return await action.function(**validated_params.model_dump(), browser=browser)
|
109
|
+
|
110
|
+
if is_pydantic:
|
111
|
+
return await action.function(validated_params)
|
112
|
+
return await action.function(**validated_params.model_dump())
|
113
|
+
|
114
|
+
except Exception as e:
|
115
|
+
raise RuntimeError(f'Error executing action {
|
116
|
+
action_name}: {str(e)}') from e
|
117
|
+
|
118
|
+
def create_action_model(self) -> Type[ActionModel]:
|
119
|
+
"""Creates a Pydantic model from registered actions"""
|
120
|
+
fields = {
|
121
|
+
name: (Optional[action.param_model], None)
|
122
|
+
for name, action in self.registry.actions.items()
|
123
|
+
}
|
124
|
+
|
125
|
+
self.telemetry.capture(
|
126
|
+
ControllerRegisteredFunctionsTelemetryEvent(
|
127
|
+
registered_functions=[
|
128
|
+
RegisteredFunction(
|
129
|
+
name=name, params=action.param_model.model_json_schema())
|
130
|
+
for name, action in self.registry.actions.items()
|
131
|
+
]
|
132
|
+
)
|
133
|
+
)
|
134
|
+
|
135
|
+
# type:ignore
|
136
|
+
return create_model('ActionModel', __base__=ActionModel, **fields)
|
137
|
+
|
138
|
+
def get_prompt_description(self) -> str:
|
139
|
+
"""Get a description of all actions for the prompt"""
|
140
|
+
return self.registry.get_prompt_description()
|
@@ -0,0 +1,71 @@
|
|
1
|
+
from typing import Callable, Dict, Type
|
2
|
+
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
4
|
+
|
5
|
+
|
6
|
+
class RegisteredAction(BaseModel):
|
7
|
+
"""Model for a registered action"""
|
8
|
+
|
9
|
+
name: str
|
10
|
+
description: str
|
11
|
+
function: Callable
|
12
|
+
param_model: Type[BaseModel]
|
13
|
+
requires_browser: bool = False
|
14
|
+
|
15
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
16
|
+
|
17
|
+
def prompt_description(self) -> str:
|
18
|
+
"""Get a description of the action for the prompt"""
|
19
|
+
skip_keys = ['title']
|
20
|
+
s = f'{self.description}: \n'
|
21
|
+
s += '{' + str(self.name) + ': '
|
22
|
+
s += str(
|
23
|
+
{
|
24
|
+
k: {sub_k: sub_v for sub_k, sub_v in v.items() if sub_k not in skip_keys}
|
25
|
+
for k, v in self.param_model.schema()['properties'].items()
|
26
|
+
}
|
27
|
+
)
|
28
|
+
s += '}'
|
29
|
+
return s
|
30
|
+
|
31
|
+
|
32
|
+
class ActionModel(BaseModel):
|
33
|
+
"""Base model for dynamically created action models"""
|
34
|
+
|
35
|
+
# this will have all the registered actions, e.g.
|
36
|
+
# click_element = param_model = ClickElementParams
|
37
|
+
# done = param_model = None
|
38
|
+
#
|
39
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
40
|
+
|
41
|
+
def get_index(self) -> int | None:
|
42
|
+
"""Get the index of the action"""
|
43
|
+
# {'clicked_element': {'index':5}}
|
44
|
+
params = self.model_dump(exclude_unset=True).values()
|
45
|
+
if not params:
|
46
|
+
return None
|
47
|
+
for param in params:
|
48
|
+
if param is not None and 'index' in param:
|
49
|
+
return param['index']
|
50
|
+
return None
|
51
|
+
|
52
|
+
def set_index(self, index: int):
|
53
|
+
"""Overwrite the index of the action"""
|
54
|
+
# Get the action name and params
|
55
|
+
action_data = self.model_dump(exclude_unset=True)
|
56
|
+
action_name = next(iter(action_data.keys()))
|
57
|
+
action_params = getattr(self, action_name)
|
58
|
+
|
59
|
+
# Update the index directly on the model
|
60
|
+
if hasattr(action_params, 'index'):
|
61
|
+
action_params.index = index
|
62
|
+
|
63
|
+
|
64
|
+
class ActionRegistry(BaseModel):
|
65
|
+
"""Model representing the action registry"""
|
66
|
+
|
67
|
+
actions: Dict[str, RegisteredAction] = {}
|
68
|
+
|
69
|
+
def get_prompt_description(self) -> str:
|
70
|
+
"""Get a description of all actions for the prompt"""
|
71
|
+
return '\n'.join([action.prompt_description() for action in self.actions.values()])
|