omniai-tools 0.4.0 → 0.5.1

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -0
  3. data/README.md +45 -0
  4. data/lib/omniai/tools/browser/base_driver.rb +78 -0
  5. data/lib/omniai/tools/browser/base_tool.rb +50 -0
  6. data/lib/omniai/tools/browser/button_click_tool.rb +27 -0
  7. data/lib/omniai/tools/browser/element_click_tool.rb +30 -0
  8. data/lib/omniai/tools/browser/elements/element_grouper.rb +73 -0
  9. data/lib/omniai/tools/browser/elements/nearby_element_detector.rb +108 -0
  10. data/lib/omniai/tools/browser/formatters/action_formatter.rb +37 -0
  11. data/lib/omniai/tools/browser/formatters/data_entry_formatter.rb +135 -0
  12. data/lib/omniai/tools/browser/formatters/element_formatter.rb +52 -0
  13. data/lib/omniai/tools/browser/formatters/input_formatter.rb +59 -0
  14. data/lib/omniai/tools/browser/inspect_tool.rb +64 -0
  15. data/lib/omniai/tools/browser/inspect_utils.rb +51 -0
  16. data/lib/omniai/tools/browser/link_click_tool.rb +26 -0
  17. data/lib/omniai/tools/browser/page_inspect/button_summarizer.rb +140 -0
  18. data/lib/omniai/tools/browser/page_inspect/form_summarizer.rb +98 -0
  19. data/lib/omniai/tools/browser/page_inspect/html_summarizer.rb +37 -0
  20. data/lib/omniai/tools/browser/page_inspect/link_summarizer.rb +103 -0
  21. data/lib/omniai/tools/browser/page_inspect_tool.rb +30 -0
  22. data/lib/omniai/tools/browser/page_screenshot_tool.rb +22 -0
  23. data/lib/omniai/tools/browser/selector_generator/base_selectors.rb +28 -0
  24. data/lib/omniai/tools/browser/selector_generator/contextual_selectors.rb +140 -0
  25. data/lib/omniai/tools/browser/selector_generator.rb +73 -0
  26. data/lib/omniai/tools/browser/selector_inspect_tool.rb +44 -0
  27. data/lib/omniai/tools/browser/text_field_area_set_tool.rb +30 -0
  28. data/lib/omniai/tools/browser/visit_tool.rb +28 -0
  29. data/lib/omniai/tools/browser/watir_driver.rb +224 -0
  30. data/lib/omniai/tools/browser_tool.rb +265 -0
  31. data/lib/omniai/tools/database/sqlite_tool.rb +2 -2
  32. data/lib/omniai/tools/version.rb +1 -1
  33. metadata +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f361e26584723aa8d30ba41edaefafb44a7e08c5c6345656dbf25e37c965448d
4
- data.tar.gz: c718feeb39dfdd1c2c5ff9a2976ece26eec48aae12f52405724fc21209101d32
3
+ metadata.gz: cc814f27a50bb0ecceaff25eb4153aed6ade80ca24f7abcb74198e0a31afe627
4
+ data.tar.gz: c68820e0b080dc4812cd15871c8da74963d27b20960dd9e9e254b214bb313dfa
5
5
  SHA512:
6
- metadata.gz: fa14b3333edc0b235e046d9f408f75f201ea37be76435d897873fb7585999cd0aeac3d36c86c16e72a772ab6a28f48dd40560074767720736583de79a80d8847
7
- data.tar.gz: 6ec511fc27bae83abdf0d827572ad2784419039b0d37d128d5670b19d58645a8e6efd82bfb7132397473181b56897f966314396a76172aaec33883826ff14f12
6
+ metadata.gz: 6ad4b2eb6e1cee4bbfd6b34f1957bb663de22785f4b2071de5323d38d5614c9438f813bbab9b98b9a232335a43243e82191103e59aca054400323f6137f5886e
7
+ data.tar.gz: faf87bbacaf66022c34beb835674deda75b359e937bc4a734918b8f2cc28985438eba994b40ac0553c8fb1ab7bfa433532cd4cbe0d6dffa3fa5298fa904565dd
data/Gemfile CHANGED
@@ -6,6 +6,11 @@ gemspec
6
6
 
7
7
  gem "factory_bot"
8
8
  gem "irb"
9
+ gem "nokogiri"
10
+ gem "omniai-anthropic"
11
+ gem "omniai-google"
12
+ gem "omniai-mistral"
13
+ gem "omniai-openai"
9
14
  gem "rake"
10
15
  gem "redcarpet"
11
16
  gem "rspec"
@@ -17,4 +22,5 @@ gem "rubocop-rake"
17
22
  gem "rubocop-rspec"
18
23
  gem "simplecov"
19
24
  gem "sqlite3"
25
+ gem "watir"
20
26
  gem "yard"
data/README.md CHANGED
@@ -8,6 +8,51 @@
8
8
 
9
9
  `OmniAI::Tools` is a library of pre-built tools to simplify integrating common tasks with [OmniAI](https://github.com/ksylvest/omniai).
10
10
 
11
+ ## Browser
12
+
13
+ Database tools are focused on running SQL statements:
14
+
15
+ ```ruby
16
+ require "omniai/openai"
17
+ require "omniai/tools"
18
+
19
+ client = OmniAI::OpenAI::Client.new
20
+
21
+ logger = Logger.new($stdout)
22
+ driver = OmniAI::Tools::Browser::WatirDriver.new
23
+ tools = [OmniAI::Tools::BrowserTool.new(driver: logger:)]
24
+
25
+ puts "Type 'exit' or 'quit' to leave."
26
+
27
+ prompt = OmniAI::Chat::Prompt.build do |builder|
28
+ builder.system <<~TEXT
29
+ You are tasked with assisting a user in browsing the web.
30
+ TEXT
31
+ end
32
+
33
+ loop do
34
+ print "# "
35
+ text = gets.strip
36
+ break if %w[exit quit].include?(text)
37
+
38
+ prompt.user(text)
39
+ response = client.chat(prompt, stream: $stdout, tools:)
40
+ prompt.assistant(response.text)
41
+ end
42
+ ```
43
+
44
+ ```
45
+ Type 'exit' or 'quit' to leave.
46
+ # Visit news.ycombinator.com and list the top 5 posts.
47
+
48
+ [browser] OmniAI::Tools::Browser::VisitTool#execute url="https://news.ycombinator.com"
49
+ [browser] OmniAI::Tools::Browser::InspectTool#execute
50
+
51
+ Here are the top 5 posts on Hacker News right now:
52
+
53
+ ...
54
+ ```
55
+
11
56
  ## Database
12
57
 
13
58
  Database tools are focused on running SQL statements:
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ # A base driver intended to be overridden by specific browser drivers (e.g. waitir).
7
+ class BaseDriver
8
+ TIMEOUT = Integer(ENV.fetch("OMNIAI_BROWSER_TIMEOUT", 10))
9
+
10
+ # @param logger [Logger]
11
+ def initialize(logger: Logger.new(IO::NULL))
12
+ @logger = logger
13
+ end
14
+
15
+ def close
16
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
17
+ end
18
+
19
+ # @return [String]
20
+ def url
21
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
22
+ end
23
+
24
+ # @return [String]
25
+ def title
26
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
27
+ end
28
+
29
+ # @return [String]
30
+ def html
31
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
32
+ end
33
+
34
+ # @yield [file]
35
+ # @yieldparam file [File]
36
+ def screenshot
37
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
38
+ end
39
+
40
+ # @param url [String]
41
+ #
42
+ # @return [Hash]
43
+ def goto(url:)
44
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
45
+ end
46
+
47
+ # @param selector [String]
48
+ # @param text [String]
49
+ #
50
+ # @return [Hash]
51
+ def fill_in(selector:, text:)
52
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
53
+ end
54
+
55
+ # @param selector [String]
56
+ #
57
+ # @return [Hash]
58
+ def button_click(selector:)
59
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
60
+ end
61
+
62
+ # @param selector [String]
63
+ #
64
+ # @return [Hash]
65
+ def link_click(selector:)
66
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
67
+ end
68
+
69
+ # @param selector [String]
70
+ #
71
+ # @return [Hash]
72
+ def element_click(selector:)
73
+ raise NotImplementedError, "#{self.class.name}#{__method__} undefined"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "watir"
4
+
5
+ module OmniAI
6
+ module Tools
7
+ module Browser
8
+ # @example
9
+ # class SeleniumTool < BaseTool
10
+ # # ...
11
+ # end
12
+ class BaseTool < OmniAI::Tool
13
+ # @param logger [Logger]
14
+ # @param driver [BaseDriver]
15
+ def initialize(driver:, logger: Logger.new(IO::NULL))
16
+ super()
17
+ @driver = driver
18
+ @logger = logger
19
+ end
20
+
21
+ protected
22
+
23
+ def wait_for_element
24
+ return yield if defined?(RSpec) # Skip waiting in tests
25
+
26
+ Watir::Wait.until(timeout: 10) do
27
+ element = yield
28
+ element if element && element_visible?(element)
29
+ end
30
+ rescue Watir::Wait::TimeoutError
31
+ log_element_timeout
32
+ nil
33
+ end
34
+
35
+ def element_visible?(element)
36
+ return true unless element.respond_to?(:visible?)
37
+
38
+ element.visible?
39
+ end
40
+
41
+ def log_element_timeout
42
+ return unless @browser.respond_to?(:elements)
43
+
44
+ visible_elements = @browser.elements.select(&:visible?).map(&:text).compact.first(10)
45
+ @logger.error("Element not found after 10s. Sample visible elements: #{visible_elements}")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+
5
+ module OmniAI
6
+ module Tools
7
+ module Browser
8
+ # @example
9
+ # browser = Watir::Browser.new(:chrome)
10
+ # tool = OmniAI::Tools::Browser::VisitTool.new(browser:)
11
+ # tool.click_link(selector: "link_id")
12
+ class ButtonClickTool < BaseTool
13
+ description "A browser automation tool for clicking a specific button."
14
+
15
+ parameter :selector, :string, description: "The ID or text of the button to interact with."
16
+
17
+ required %i[selector]
18
+
19
+ def execute(selector:)
20
+ @logger.info("#{self.class.name}##{__method__} selector=#{selector.inspect}")
21
+
22
+ @driver.button_click(selector:)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ # @example
7
+ # browser = Watir::Browser.new(:chrome)
8
+ # tool = OmniAI::Tools::Browser::ElementClickTool.new(browser:)
9
+ # tool.execute(selector: "#some-id")
10
+ # tool.execute(selector: ".some-class")
11
+ # tool.execute(selector: "some text")
12
+ # tool.execute(selector: "//div[@role='button']")
13
+ class ElementClickTool < BaseTool
14
+ description "A browser automation tool for clicking any clickable element."
15
+
16
+ parameter :selector, :string,
17
+ description: "CSS selector, ID, text content, or other identifier for the element to click."
18
+
19
+ required %i[selector]
20
+
21
+ # @param selector [String] CSS selector, ID, text content, or other identifier for the element to click.
22
+ def execute(selector:)
23
+ @logger.info("#{self.class.name}##{__method__} selector=#{selector.inspect}")
24
+
25
+ @driver.element_click(selector:)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ module Elements
7
+ # Groups HTML elements by their relevance for data entry
8
+ module ElementGrouper
9
+ module_function
10
+
11
+ def group_for_data_entry(elements, text)
12
+ groups = {
13
+ inputs: [], # text, number, email inputs - primary targets
14
+ form_controls: {}, # radio buttons, checkboxes grouped by name
15
+ labels: [], # labels, headers, spans
16
+ actions: [], # buttons and actionable elements
17
+ }
18
+
19
+ elements.each do |element|
20
+ categorize_element(element, groups, text)
21
+ end
22
+
23
+ groups
24
+ end
25
+
26
+ def categorize_element(element, groups, text)
27
+ case element.name.downcase
28
+ when "input"
29
+ categorize_input_element(element, groups)
30
+ when "textarea", "select"
31
+ groups[:inputs] << element
32
+ when "button"
33
+ groups[:actions] << element if contains_text_match?(element, text)
34
+ when "label", "span", "div", "th"
35
+ groups[:labels] << element if contains_text_match?(element, text)
36
+ end
37
+ end
38
+
39
+ def categorize_input_element(element, groups)
40
+ if data_entry_input?(element)
41
+ groups[:inputs] << element
42
+ else
43
+ group_form_control(element, groups[:form_controls])
44
+ end
45
+ end
46
+
47
+ def data_entry_input?(element)
48
+ # Handle missing type attribute - HTML default is "text"
49
+ type = (element["type"] || "text").downcase
50
+
51
+ # Include common data entry input types
52
+ %w[text number email tel url date datetime-local time month week
53
+ password search].include?(type)
54
+ end
55
+
56
+ def group_form_control(element, form_controls)
57
+ type = (element["type"] || "text").downcase
58
+ return unless %w[radio checkbox].include?(type)
59
+
60
+ name = element["name"] || "unnamed"
61
+ form_controls[name] ||= []
62
+ form_controls[name] << element
63
+ end
64
+
65
+ def contains_text_match?(element, text)
66
+ element.text.downcase.include?(text.downcase) ||
67
+ element["value"]&.downcase&.include?(text.downcase)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ module Elements
7
+ # Handles detection of interactive elements near text matches
8
+ module NearbyElementDetector
9
+ module_function
10
+
11
+ def add_nearby_interactive_elements(elements)
12
+ nearby_elements = []
13
+ elements.each do |element|
14
+ nearby_elements += find_interactive_in_context(element)
15
+ end
16
+ combined = elements.to_a + nearby_elements
17
+ combined.uniq
18
+ end
19
+
20
+ def find_interactive_in_context(element)
21
+ interactive_elements = []
22
+ interactive_elements += check_table_context(element)
23
+ interactive_elements += check_parent_containers(element)
24
+ interactive_elements.uniq
25
+ end
26
+
27
+ def check_table_context(element)
28
+ elements = []
29
+ table_cell = element.ancestors("td").first || element.ancestors("th").first
30
+ elements += find_interactive_in_container(table_cell) if table_cell
31
+ table_row = element.ancestors("tr").first
32
+ elements += find_interactive_in_container(table_row) if table_row
33
+ if table_cell && table_row
34
+ table = element.ancestors("table").first
35
+ elements += find_interactive_in_same_column(table, table_cell, table_row) if table
36
+ end
37
+ elements
38
+ end
39
+
40
+ def find_interactive_in_same_column(table, header_cell, header_row)
41
+ return [] unless table
42
+
43
+ column_index = header_row.css("th, td").index(header_cell)
44
+ return [] unless column_index
45
+
46
+ interactive_elements = []
47
+ table.css("tbody tr").each do |row|
48
+ interactive_elements += find_column_cell_elements(row, column_index)
49
+ end
50
+ interactive_elements
51
+ end
52
+
53
+ def find_column_cell_elements(row, column_index)
54
+ target_cell = row.css("td, th")[column_index]
55
+ return [] unless target_cell
56
+
57
+ find_interactive_in_container(target_cell) + find_interactive_in_nested_tables(target_cell, column_index)
58
+ end
59
+
60
+ def find_interactive_in_nested_tables(cell, original_column_index)
61
+ interactive_elements = []
62
+
63
+ cell.css("table").each do |nested_table|
64
+ nested_table.css("tr").each do |nested_row|
65
+ nested_cells = nested_row.css("td, th")
66
+ if nested_cells[original_column_index]
67
+ interactive_elements += find_interactive_in_container(nested_cells[original_column_index])
68
+ end
69
+ end
70
+ end
71
+
72
+ interactive_elements
73
+ end
74
+
75
+ def check_parent_containers(element)
76
+ interactive_elements = []
77
+ parent_container = element.parent
78
+ 3.times do
79
+ break unless parent_container
80
+
81
+ interactive_elements += find_interactive_in_container(parent_container)
82
+ parent_container = parent_container.parent
83
+ end
84
+ interactive_elements
85
+ end
86
+
87
+ def find_interactive_in_container(container)
88
+ return [] unless container
89
+
90
+ container.css("input, textarea, select, button, [role='button'], [tabindex='0']")
91
+ .reject { |el| non_interactive_element?(el) }
92
+ end
93
+
94
+ def non_interactive_element?(element)
95
+ return true if element["type"] == "hidden"
96
+ return true if element["tabindex"] == "-1"
97
+ return true if element["aria-hidden"] == "true"
98
+
99
+ style = element["style"]
100
+ return true if style&.include?("display: none") || style&.include?("visibility: hidden")
101
+
102
+ false
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ module Formatters
7
+ # Handles formatting of action elements (buttons, links)
8
+ module ActionFormatter
9
+ module_function
10
+
11
+ def format_actions(actions)
12
+ result = "⚡ Available Actions:\n"
13
+ actions.first(5).each do |action|
14
+ result += format_action_element(action)
15
+ end
16
+ result += " ... and #{actions.size - 5} more\n" if actions.size > 5
17
+ result += "\n"
18
+ end
19
+
20
+ def format_action_element(action)
21
+ text = action.text.strip
22
+ selector = get_action_selector(action)
23
+ " • #{text} (#{selector})\n"
24
+ end
25
+
26
+ def get_action_selector(action)
27
+ return action["id"] if action["id"] && !action["id"].empty?
28
+ return "text:#{action.text.strip}" if action.text.strip.length > 2
29
+ return action["class"].split.first if action["class"]
30
+
31
+ "css-needed"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ module Formatters
7
+ # Formats grouped elements for optimal data entry display
8
+ module DataEntryFormatter
9
+ module_function
10
+
11
+ def format_groups(grouped_elements, total_count, text)
12
+ result = build_header(total_count, text)
13
+ result += format_each_group(grouped_elements)
14
+ result
15
+ end
16
+
17
+ def format_inputs(inputs)
18
+ result = "📝 Data Entry Fields:\n"
19
+ table_groups = group_by_table_context(inputs)
20
+ result += format_table_groups(table_groups)
21
+ "#{result}\n"
22
+ end
23
+
24
+ def format_form_controls(form_controls)
25
+ result = "🎛️ Form Controls:\n"
26
+ form_controls.each do |name, controls|
27
+ result += format_control_group(name, controls)
28
+ end
29
+ result += "\n"
30
+ end
31
+
32
+ def format_labels(labels)
33
+ result = "🏷️ Labels & Headers:\n"
34
+ labels.first(3).each do |label|
35
+ result += " • #{label.name}: #{label.text.strip}\n"
36
+ end
37
+ result += " ... and #{labels.size - 3} more\n" if labels.size > 3
38
+ result += "\n"
39
+ end
40
+
41
+ def build_header(total_count, text)
42
+ "Found #{total_count} elements containing '#{text}':\n\n"
43
+ end
44
+
45
+ def format_each_group(grouped_elements)
46
+ [
47
+ format_inputs_section(grouped_elements[:inputs]),
48
+ format_actions_section(grouped_elements[:actions]),
49
+ format_controls_section(grouped_elements[:form_controls]),
50
+ format_labels_section(grouped_elements[:labels]),
51
+ ].join
52
+ end
53
+
54
+ def format_inputs_section(inputs)
55
+ inputs.any? ? format_inputs(inputs) : ""
56
+ end
57
+
58
+ def format_actions_section(actions)
59
+ actions.any? ? ActionFormatter.format_actions(actions) : ""
60
+ end
61
+
62
+ def format_controls_section(form_controls)
63
+ form_controls.any? ? format_form_controls(form_controls) : ""
64
+ end
65
+
66
+ def format_labels_section(labels)
67
+ labels.any? ? format_labels(labels) : ""
68
+ end
69
+
70
+ def format_control_group(name, controls)
71
+ result = "\n #{name.humanize} options:\n"
72
+ controls.each do |control|
73
+ checked = control["checked"] ? " \u2713" : ""
74
+ result += " • #{control['value']}#{checked}\n"
75
+ end
76
+ result
77
+ end
78
+
79
+ def format_table_groups(table_groups)
80
+ return format_single_group(table_groups.values.first) if table_groups.size == 1
81
+
82
+ result = ""
83
+ table_groups.each do |(context, group_inputs)|
84
+ result += "\n #{context}:\n"
85
+ group_inputs.each { |input| result += InputFormatter.format_input_field(input, " ") }
86
+ end
87
+ result
88
+ end
89
+
90
+ def format_single_group(inputs)
91
+ result = ""
92
+ inputs.each { |input| result += InputFormatter.format_input_field(input, " ") }
93
+ result
94
+ end
95
+
96
+ def group_by_table_context(inputs)
97
+ groups = {}
98
+ inputs.each do |input|
99
+ context = find_context(input)
100
+ groups[context] ||= []
101
+ groups[context] << input
102
+ end
103
+ groups
104
+ end
105
+
106
+ def find_context(input)
107
+ table = input.ancestors("table").first
108
+ return "Form Fields" unless table
109
+
110
+ find_table_context(input)
111
+ end
112
+
113
+ def find_table_context(input)
114
+ row = input.ancestors("tr").first
115
+ return "Table" unless row
116
+
117
+ find_meaningful_cell_text(row)
118
+ end
119
+
120
+ def find_meaningful_cell_text(row)
121
+ meaningful_cell = row.css("td, th").find do |cell|
122
+ cell.text.strip.length > 2 && !cell.text.match?(/^\d+\.?\d*$/)
123
+ end
124
+
125
+ meaningful_cell ? meaningful_cell.text.strip[0..30] : "Table"
126
+ end
127
+
128
+ def humanize(text)
129
+ text.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Tools
5
+ module Browser
6
+ module Formatters
7
+ # Handles formatting of HTML elements for display with data entry focus
8
+ module ElementFormatter
9
+ module_function
10
+
11
+ def format_matching_elements(elements, text, _context_size = nil)
12
+ grouped = Elements::ElementGrouper.group_for_data_entry(elements, text)
13
+ DataEntryFormatter.format_groups(grouped, elements.size, text)
14
+ end
15
+
16
+ # Keep existing methods for backward compatibility
17
+ def format_single_element(element, index, context_size)
18
+ result = "--- Element #{index + 1} ---\n"
19
+ result += "Tag: #{element.name}\n"
20
+ result += format_element_attributes(element)
21
+ result += get_parent_context(element, context_size) if context_size.positive?
22
+ result += "HTML: #{element.to_html}\n\n"
23
+ result
24
+ end
25
+
26
+ def format_element_attributes(element)
27
+ result = ""
28
+ result += "ID: #{element['id']}\n" if element["id"]
29
+ result += "Classes: #{element['class']}\n" if element["class"]
30
+ %w[href src alt type value placeholder].each do |attr|
31
+ result += "#{attr}: #{element[attr]}\n" if element[attr] && !element[attr].empty?
32
+ end
33
+ result
34
+ end
35
+
36
+ def get_parent_context(element, context_size)
37
+ result = ""
38
+ parent = element.parent
39
+ context_count = 0
40
+ while parent && context_count < context_size
41
+ attrs = parent.attributes.map { |name, attr| " #{name}=\"#{attr.value}\"" }.join
42
+ result += "Parent #{context_count + 1}: <#{parent.name}#{attrs}>\n"
43
+ parent = parent.parent
44
+ context_count += 1
45
+ end
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end