signalwire-sdk 2.0.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +259 -0
- data/bin/swaig-test +872 -0
- data/lib/signalwire/agent/agent_base.rb +2134 -0
- data/lib/signalwire/contexts/context_builder.rb +861 -0
- data/lib/signalwire/core/logging_config.rb +54 -0
- data/lib/signalwire/datamap/data_map.rb +315 -0
- data/lib/signalwire/logging.rb +92 -0
- data/lib/signalwire/pom/prompt_object_model.rb +269 -0
- data/lib/signalwire/pom/section.rb +202 -0
- data/lib/signalwire/prefabs/concierge.rb +92 -0
- data/lib/signalwire/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire/prefabs/receptionist.rb +74 -0
- data/lib/signalwire/prefabs/survey.rb +75 -0
- data/lib/signalwire/relay/action.rb +291 -0
- data/lib/signalwire/relay/call.rb +523 -0
- data/lib/signalwire/relay/client.rb +789 -0
- data/lib/signalwire/relay/constants.rb +124 -0
- data/lib/signalwire/relay/message.rb +137 -0
- data/lib/signalwire/relay/relay_event.rb +670 -0
- data/lib/signalwire/rest/http_client.rb +159 -0
- data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
- data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
- data/lib/signalwire/rest/namespaces/project.rb +33 -0
- data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire/rest/namespaces/video.rb +129 -0
- data/lib/signalwire/rest/pagination.rb +89 -0
- data/lib/signalwire/rest/phone_call_handler.rb +56 -0
- data/lib/signalwire/rest/rest_client.rb +114 -0
- data/lib/signalwire/runtime.rb +98 -0
- data/lib/signalwire/security/session_manager.rb +124 -0
- data/lib/signalwire/security/webhook_middleware.rb +191 -0
- data/lib/signalwire/security/webhook_validator.rb +327 -0
- data/lib/signalwire/server/agent_server.rb +413 -0
- data/lib/signalwire/serverless/lambda_handler.rb +251 -0
- data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
- data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
- data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire/skills/builtin/joke.rb +65 -0
- data/lib/signalwire/skills/builtin/math.rb +176 -0
- data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire/skills/builtin/spider.rb +169 -0
- data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
- data/lib/signalwire/skills/builtin/web_search.rb +141 -0
- data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
- data/lib/signalwire/skills/skill_base.rb +82 -0
- data/lib/signalwire/skills/skill_manager.rb +97 -0
- data/lib/signalwire/skills/skill_registry.rb +258 -0
- data/lib/signalwire/swaig/function_result.rb +777 -0
- data/lib/signalwire/swml/document.rb +84 -0
- data/lib/signalwire/swml/schema.json +12250 -0
- data/lib/signalwire/swml/schema.rb +81 -0
- data/lib/signalwire/swml/service.rb +650 -0
- data/lib/signalwire/utils/schema_utils.rb +298 -0
- data/lib/signalwire/utils/serverless.rb +19 -0
- data/lib/signalwire/utils/url_validator.rb +138 -0
- data/lib/signalwire/version.rb +5 -0
- data/lib/signalwire.rb +114 -0
- metadata +225 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_base'
|
|
4
|
+
require_relative '../skill_registry'
|
|
5
|
+
require_relative '../../datamap/data_map'
|
|
6
|
+
|
|
7
|
+
module SignalWire
|
|
8
|
+
module Skills
|
|
9
|
+
module Builtin
|
|
10
|
+
class WeatherApiSkill < SkillBase
|
|
11
|
+
def name; 'weather_api'; end
|
|
12
|
+
def description; 'Get current weather information from WeatherAPI.com'; end
|
|
13
|
+
|
|
14
|
+
def setup
|
|
15
|
+
@api_key = get_param('api_key', env_var: 'WEATHER_API_KEY')
|
|
16
|
+
@tool_name = get_param('tool_name', default: 'get_weather')
|
|
17
|
+
@temp_unit = get_param('temperature_unit', default: 'fahrenheit')
|
|
18
|
+
return false unless @api_key && !@api_key.empty?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register_tools
|
|
23
|
+
if @temp_unit == 'celsius'
|
|
24
|
+
temp_field = 'temp_c'
|
|
25
|
+
feels_field = 'feelslike_c'
|
|
26
|
+
unit_name = 'Celsius'
|
|
27
|
+
else
|
|
28
|
+
temp_field = 'temp_f'
|
|
29
|
+
feels_field = 'feelslike_f'
|
|
30
|
+
unit_name = 'Fahrenheit'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
response_template =
|
|
34
|
+
"Tell the user the current weather conditions. " \
|
|
35
|
+
"Express all temperatures in #{unit_name} using natural language numbers " \
|
|
36
|
+
"without abbreviations or symbols for clear text-to-speech pronunciation. " \
|
|
37
|
+
"Current conditions: ${current.condition.text}. " \
|
|
38
|
+
"Temperature: ${current.#{temp_field}} degrees #{unit_name}. " \
|
|
39
|
+
"Wind: ${current.wind_dir} at ${current.wind_mph} miles per hour. " \
|
|
40
|
+
"Cloud coverage: ${current.cloud} percent. " \
|
|
41
|
+
"Feels like: ${current.#{feels_field}} degrees #{unit_name}."
|
|
42
|
+
|
|
43
|
+
# Default to the WeatherAPI.com host; WEATHER_API_BASE_URL
|
|
44
|
+
# overrides for tests and the audit fixture. The `/v1/current.json`
|
|
45
|
+
# path is preserved so the audit can match on `current.json`.
|
|
46
|
+
base = ENV['WEATHER_API_BASE_URL']
|
|
47
|
+
base = 'https://api.weatherapi.com' if base.nil? || base.empty?
|
|
48
|
+
base = base.sub(/\/$/, '')
|
|
49
|
+
|
|
50
|
+
tool = {
|
|
51
|
+
'function' => @tool_name,
|
|
52
|
+
'description' => 'Get current weather information for any location',
|
|
53
|
+
'parameters' => {
|
|
54
|
+
'type' => 'object',
|
|
55
|
+
'properties' => {
|
|
56
|
+
'location' => { 'type' => 'string', 'description' => 'The city, state, country, or location to get weather for' }
|
|
57
|
+
},
|
|
58
|
+
'required' => ['location']
|
|
59
|
+
},
|
|
60
|
+
'data_map' => {
|
|
61
|
+
'webhooks' => [
|
|
62
|
+
{
|
|
63
|
+
'url' => "#{base}/v1/current.json?key=#{@api_key}&q=${lc:enc:args.location}&aqi=no",
|
|
64
|
+
'method' => 'GET',
|
|
65
|
+
'output' => Swaig::FunctionResult.new(response_template).to_h
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
'error_keys' => ['error'],
|
|
69
|
+
'output' => Swaig::FunctionResult.new(
|
|
70
|
+
'Sorry, I cannot get weather information right now. Please try again later or check if the location name is correct.'
|
|
71
|
+
).to_h
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
[{ datamap: tool }]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_parameter_schema
|
|
79
|
+
{
|
|
80
|
+
'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'WEATHER_API_KEY' },
|
|
81
|
+
'tool_name' => { 'type' => 'string', 'default' => 'get_weather' },
|
|
82
|
+
'temperature_unit' => { 'type' => 'string', 'default' => 'fahrenheit', 'enum' => %w[fahrenheit celsius] }
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
SignalWire::Skills::SkillRegistry.register('weather_api') do |params|
|
|
91
|
+
SignalWire::Skills::Builtin::WeatherApiSkill.new(params)
|
|
92
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative '../skill_base'
|
|
8
|
+
require_relative '../skill_registry'
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Skills
|
|
12
|
+
module Builtin
|
|
13
|
+
class WebSearchSkill < SkillBase
|
|
14
|
+
def name; 'web_search'; end
|
|
15
|
+
def description; 'Search the web for information using Google Custom Search API'; end
|
|
16
|
+
def version; '2.0.0'; end
|
|
17
|
+
def supports_multiple_instances?; true; end
|
|
18
|
+
|
|
19
|
+
def setup
|
|
20
|
+
@api_key = get_param('api_key', env_var: 'GOOGLE_SEARCH_API_KEY')
|
|
21
|
+
@search_engine_id = get_param('search_engine_id', env_var: 'GOOGLE_SEARCH_ENGINE_ID')
|
|
22
|
+
@num_results = (get_param('num_results', default: 3)).to_i
|
|
23
|
+
@tool_name = get_param('tool_name', default: 'web_search')
|
|
24
|
+
@no_results_msg = get_param('no_results_message',
|
|
25
|
+
default: "I couldn't find quality results for that query. Try rephrasing your search.")
|
|
26
|
+
|
|
27
|
+
# Optional prefix/postfix wrapped around every non-empty search
|
|
28
|
+
# result. Use these to give the calling agent a mechanical cue
|
|
29
|
+
# (e.g. "tell the user this came from a public web search")
|
|
30
|
+
# without needing prompt-side rules. Mirrors Python parity.
|
|
31
|
+
@response_prefix = get_param('response_prefix', default: '')
|
|
32
|
+
@response_postfix = get_param('response_postfix', default: '')
|
|
33
|
+
|
|
34
|
+
return false unless @api_key && !@api_key.empty?
|
|
35
|
+
return false unless @search_engine_id && !@search_engine_id.empty?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def instance_key; "web_search_#{@tool_name}"; end
|
|
40
|
+
|
|
41
|
+
def register_tools
|
|
42
|
+
[
|
|
43
|
+
{
|
|
44
|
+
name: @tool_name,
|
|
45
|
+
description: 'Search the web for high-quality information, automatically filtering low-quality results',
|
|
46
|
+
parameters: {
|
|
47
|
+
'query' => { 'type' => 'string', 'description' => 'The search query - what you want to find information about' }
|
|
48
|
+
},
|
|
49
|
+
handler: method(:handle_search)
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_global_data
|
|
55
|
+
{ 'web_search_enabled' => true, 'search_provider' => 'Google Custom Search', 'quality_filtering' => true }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_prompt_sections
|
|
59
|
+
[
|
|
60
|
+
{
|
|
61
|
+
'title' => 'Web Search Capability (Quality Enhanced)',
|
|
62
|
+
'body' => "You can search the internet for high-quality information using the #{@tool_name} tool.",
|
|
63
|
+
'bullets' => [
|
|
64
|
+
"Use the #{@tool_name} tool when users ask for information you need to look up",
|
|
65
|
+
'The search automatically filters out low-quality results like empty pages',
|
|
66
|
+
'Results are ranked by content quality, relevance, and domain reputation',
|
|
67
|
+
'Summarize the high-quality results in a clear, helpful way'
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_parameter_schema
|
|
74
|
+
{
|
|
75
|
+
'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_SEARCH_API_KEY' },
|
|
76
|
+
'search_engine_id' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_SEARCH_ENGINE_ID' },
|
|
77
|
+
'num_results' => { 'type' => 'integer', 'default' => 3, 'min' => 1, 'max' => 10 },
|
|
78
|
+
'no_results_message' => { 'type' => 'string' },
|
|
79
|
+
'response_prefix' => { 'type' => 'string', 'default' => '' },
|
|
80
|
+
'response_postfix' => { 'type' => 'string', 'default' => '' }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def handle_search(args, _raw_data)
|
|
87
|
+
query = (args['query'] || '').strip
|
|
88
|
+
if query.empty?
|
|
89
|
+
return Swaig::FunctionResult.new('Please provide a search query. What would you like me to search for?')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
results = google_search(query, @num_results)
|
|
94
|
+
if results.empty?
|
|
95
|
+
return Swaig::FunctionResult.new(@no_results_msg)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
formatted = results.map.with_index(1) do |r, i|
|
|
99
|
+
"=== RESULT #{i} ===\nTitle: #{r['title']}\nURL: #{r['url']}\nSnippet: #{r['snippet']}\n#{'=' * 50}"
|
|
100
|
+
end.join("\n\n")
|
|
101
|
+
|
|
102
|
+
response = "Web search results for '#{query}':\n\n#{formatted}"
|
|
103
|
+
response = "#{@response_prefix}\n\n#{response}" unless @response_prefix.nil? || @response_prefix.empty?
|
|
104
|
+
response = "#{response}\n\n#{@response_postfix}" unless @response_postfix.nil? || @response_postfix.empty?
|
|
105
|
+
Swaig::FunctionResult.new(response)
|
|
106
|
+
rescue => e
|
|
107
|
+
Swaig::FunctionResult.new("Sorry, I encountered an error while searching: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def google_search(query, num)
|
|
112
|
+
# Default to Google CSE; WEB_SEARCH_BASE_URL overrides for tests
|
|
113
|
+
# and the audit fixture (matches Rust SDK's behavior — env var is
|
|
114
|
+
# the *host*, the `/customsearch/v1` path is appended below so
|
|
115
|
+
# the audit can match on `customsearch` in req.path).
|
|
116
|
+
base = ENV['WEB_SEARCH_BASE_URL']
|
|
117
|
+
base = 'https://www.googleapis.com' if base.nil? || base.empty?
|
|
118
|
+
uri = URI("#{base.sub(/\/$/, '')}/customsearch/v1")
|
|
119
|
+
uri.query = URI.encode_www_form(
|
|
120
|
+
key: @api_key,
|
|
121
|
+
cx: @search_engine_id,
|
|
122
|
+
q: query,
|
|
123
|
+
num: [num, 10].min
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
response = Net::HTTP.get_response(uri)
|
|
127
|
+
return [] unless response.is_a?(Net::HTTPSuccess)
|
|
128
|
+
|
|
129
|
+
data = JSON.parse(response.body)
|
|
130
|
+
(data['items'] || []).first(num).map do |item|
|
|
131
|
+
{ 'title' => item['title'] || '', 'url' => item['link'] || '', 'snippet' => item['snippet'] || '' }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
SignalWire::Skills::SkillRegistry.register('web_search') do |params|
|
|
140
|
+
SignalWire::Skills::Builtin::WebSearchSkill.new(params)
|
|
141
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative '../skill_base'
|
|
8
|
+
require_relative '../skill_registry'
|
|
9
|
+
|
|
10
|
+
module SignalWire
|
|
11
|
+
module Skills
|
|
12
|
+
module Builtin
|
|
13
|
+
class WikipediaSearchSkill < SkillBase
|
|
14
|
+
def name; 'wikipedia_search'; end
|
|
15
|
+
def description; 'Search Wikipedia for information about a topic and get article summaries'; end
|
|
16
|
+
|
|
17
|
+
def setup
|
|
18
|
+
@num_results = [1, (get_param('num_results', default: 1)).to_i].max
|
|
19
|
+
@no_results_msg = get_param('no_results_message',
|
|
20
|
+
default: "I couldn't find any Wikipedia articles for that query. Try rephrasing your search or using different keywords.")
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def register_tools
|
|
25
|
+
[
|
|
26
|
+
{
|
|
27
|
+
name: 'search_wiki',
|
|
28
|
+
description: 'Search Wikipedia for information about a topic and get article summaries',
|
|
29
|
+
parameters: {
|
|
30
|
+
'query' => { 'type' => 'string', 'description' => 'The search term or topic to look up on Wikipedia' }
|
|
31
|
+
},
|
|
32
|
+
handler: method(:handle_search)
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_prompt_sections
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
'title' => 'Wikipedia Search',
|
|
41
|
+
'body' => "You can search Wikipedia for factual information using search_wiki. This will return up to #{@num_results || 1} Wikipedia article summaries.",
|
|
42
|
+
'bullets' => [
|
|
43
|
+
'Use search_wiki for factual, encyclopedic information',
|
|
44
|
+
'Great for answering questions about people, places, concepts, and history',
|
|
45
|
+
'Returns reliable, well-sourced information from Wikipedia articles'
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def get_parameter_schema
|
|
52
|
+
{
|
|
53
|
+
'num_results' => { 'type' => 'integer', 'default' => 1, 'min' => 1, 'max' => 5 },
|
|
54
|
+
'no_results_message' => { 'type' => 'string' }
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def handle_search(args, _raw_data)
|
|
61
|
+
query = (args['query'] || '').strip
|
|
62
|
+
if query.empty?
|
|
63
|
+
return Swaig::FunctionResult.new('Please provide a search query for Wikipedia.')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
result = search_wiki(query)
|
|
68
|
+
Swaig::FunctionResult.new(result)
|
|
69
|
+
rescue => e
|
|
70
|
+
Swaig::FunctionResult.new("Error searching Wikipedia: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def search_wiki(query)
|
|
75
|
+
# Default to en.wikipedia.org host; WIKIPEDIA_BASE_URL overrides
|
|
76
|
+
# for tests and the audit fixture. The env var is the *host*; the
|
|
77
|
+
# `/w/api.php` path is appended below so audit_skills_dispatch
|
|
78
|
+
# can match on `api.php` in req.path.
|
|
79
|
+
base = ENV['WIKIPEDIA_BASE_URL']
|
|
80
|
+
base = 'https://en.wikipedia.org' if base.nil? || base.empty?
|
|
81
|
+
api_endpoint = "#{base.sub(/\/$/, '')}/w/api.php"
|
|
82
|
+
|
|
83
|
+
# Step 1: Search
|
|
84
|
+
search_uri = URI("#{api_endpoint}?action=query&list=search&format=json&srsearch=#{URI.encode_www_form_component(query)}&srlimit=#{@num_results}")
|
|
85
|
+
search_resp = Net::HTTP.get_response(search_uri)
|
|
86
|
+
return @no_results_msg unless search_resp.is_a?(Net::HTTPSuccess)
|
|
87
|
+
|
|
88
|
+
search_data = JSON.parse(search_resp.body)
|
|
89
|
+
results = search_data.dig('query', 'search') || []
|
|
90
|
+
return @no_results_msg if results.empty?
|
|
91
|
+
|
|
92
|
+
# Step 2: Get extracts. If the upstream returns extracts
|
|
93
|
+
# (production behavior on en.wikipedia.org), prefer those; if the
|
|
94
|
+
# response shape doesn't include `query.pages` (test fixtures,
|
|
95
|
+
# truncated responses), fall back to the snippet from step 1.
|
|
96
|
+
articles = results.first(@num_results).filter_map do |r|
|
|
97
|
+
title = r['title']
|
|
98
|
+
snippet = (r['snippet'] || '').gsub(/<[^>]+>/, '').strip
|
|
99
|
+
extract_uri = URI("#{api_endpoint}?action=query&prop=extracts&exintro&explaintext&format=json&titles=#{URI.encode_www_form_component(title)}")
|
|
100
|
+
extract_resp = Net::HTTP.get_response(extract_uri)
|
|
101
|
+
|
|
102
|
+
extract = nil
|
|
103
|
+
if extract_resp.is_a?(Net::HTTPSuccess)
|
|
104
|
+
pages = JSON.parse(extract_resp.body).dig('query', 'pages') || {}
|
|
105
|
+
page = pages.values.first
|
|
106
|
+
extract = page&.dig('extract')&.strip
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
content = extract && !extract.empty? ? extract : snippet
|
|
110
|
+
next if content.nil? || content.empty?
|
|
111
|
+
|
|
112
|
+
"**#{title}**\n\n#{content}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
return @no_results_msg if articles.empty?
|
|
116
|
+
articles.join("\n\n#{'=' * 50}\n\n")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
SignalWire::Skills::SkillRegistry.register('wikipedia_search') do |params|
|
|
124
|
+
SignalWire::Skills::Builtin::WikipediaSearchSkill.new(params)
|
|
125
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
|
|
8
|
+
require_relative '../swaig/function_result'
|
|
9
|
+
require_relative '../logging'
|
|
10
|
+
|
|
11
|
+
module SignalWire
|
|
12
|
+
module Skills
|
|
13
|
+
# Base class for all skills. Subclasses override the metadata methods
|
|
14
|
+
# and +register_tools+ to supply tool hashes.
|
|
15
|
+
class SkillBase
|
|
16
|
+
# Python parity:
|
|
17
|
+
# - ``params`` — params hash passed at construction
|
|
18
|
+
# - ``agent`` — owning AgentBase instance (or nil for standalone)
|
|
19
|
+
# - ``logger`` — namespaced logger ``signalwire.skills.<name>``
|
|
20
|
+
# - ``swaig_fields`` — extra SWAIG fields merged into tool defs;
|
|
21
|
+
# pulled out of ``params`` if provided
|
|
22
|
+
attr_reader :params, :agent, :logger, :swaig_fields
|
|
23
|
+
|
|
24
|
+
def name; raise NotImplementedError, "#{self.class}#name"; end
|
|
25
|
+
def description; raise NotImplementedError, "#{self.class}#description"; end
|
|
26
|
+
def version; '1.0.0'; end
|
|
27
|
+
def required_env_vars; []; end
|
|
28
|
+
def supports_multiple_instances?; false; end
|
|
29
|
+
|
|
30
|
+
# Python parity: ``SkillBase.__init__(self, agent, params=None)``.
|
|
31
|
+
# First positional arg is the owning AgentBase (or nil for
|
|
32
|
+
# standalone). The second is the params hash. We accept the legacy
|
|
33
|
+
# 1-arg form for backwards compatibility (``DateTimeSkill.new({...})``).
|
|
34
|
+
def initialize(agent = nil, params = nil)
|
|
35
|
+
# Backwards compat: a single Hash means params-only (no agent).
|
|
36
|
+
if agent.is_a?(Hash) && params.nil?
|
|
37
|
+
params = agent
|
|
38
|
+
agent = nil
|
|
39
|
+
end
|
|
40
|
+
@agent = agent
|
|
41
|
+
@params = (params || {}).transform_keys(&:to_s)
|
|
42
|
+
# Python: pop swaig_fields out of params for separate access.
|
|
43
|
+
@swaig_fields = @params.delete('swaig_fields') || {}
|
|
44
|
+
@logger = ::SignalWire::Logging.logger("signalwire.skills.#{begin
|
|
45
|
+
name
|
|
46
|
+
rescue NotImplementedError
|
|
47
|
+
self.class.name
|
|
48
|
+
end}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Called once after construction. Return +true+ if the skill is ready.
|
|
52
|
+
def setup; true; end
|
|
53
|
+
|
|
54
|
+
# Return an Array of tool definition hashes. Each hash should have:
|
|
55
|
+
# :name, :description, :parameters, :handler (lambda/proc)
|
|
56
|
+
def register_tools; []; end
|
|
57
|
+
|
|
58
|
+
# Speech recognition hints.
|
|
59
|
+
def get_hints; []; end
|
|
60
|
+
|
|
61
|
+
# Global data to merge into the agent.
|
|
62
|
+
def get_global_data; {}; end
|
|
63
|
+
|
|
64
|
+
# Prompt sections to add to the agent.
|
|
65
|
+
def get_prompt_sections; []; end
|
|
66
|
+
|
|
67
|
+
# Called when the skill is unloaded.
|
|
68
|
+
def cleanup; end
|
|
69
|
+
|
|
70
|
+
# Unique key for tracking this skill instance.
|
|
71
|
+
def instance_key; name; end
|
|
72
|
+
|
|
73
|
+
# Parameter schema for GUI / validation.
|
|
74
|
+
def get_parameter_schema; {}; end
|
|
75
|
+
|
|
76
|
+
# Helper to get a param with env-var fallback.
|
|
77
|
+
def get_param(key, env_var: nil, default: nil)
|
|
78
|
+
@params[key.to_s] || @params[key.to_sym.to_s] || (env_var && ENV[env_var]) || default
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
|
|
8
|
+
require 'thread'
|
|
9
|
+
require_relative 'skill_base'
|
|
10
|
+
require_relative '../logging'
|
|
11
|
+
|
|
12
|
+
module SignalWire
|
|
13
|
+
module Skills
|
|
14
|
+
# Thread-safe lifecycle manager for loaded skill instances.
|
|
15
|
+
#
|
|
16
|
+
# manager = SkillManager.new
|
|
17
|
+
# manager.load('datetime', DateTimeSkill.new)
|
|
18
|
+
# manager.get('datetime') #=> <DateTimeSkill>
|
|
19
|
+
# manager.unload('datetime')
|
|
20
|
+
#
|
|
21
|
+
class SkillManager
|
|
22
|
+
# Python parity:
|
|
23
|
+
# - ``agent`` — owning AgentBase instance (or nil)
|
|
24
|
+
# - ``logger`` — namespaced logger
|
|
25
|
+
attr_reader :agent, :logger
|
|
26
|
+
|
|
27
|
+
# Python parity: ``SkillManager.__init__(self, agent)`` —
|
|
28
|
+
# SkillManager keeps a back-pointer to its agent so loaded
|
|
29
|
+
# skills can attach prompt sections / SWAIG tools directly.
|
|
30
|
+
# Ruby allows nil for standalone use (tests, registry tools).
|
|
31
|
+
def initialize(agent = nil)
|
|
32
|
+
@agent = agent
|
|
33
|
+
@skills = {} # instance_key => SkillBase instance
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@logger = ::SignalWire::Logging.logger('signalwire.skill_manager')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Load a skill instance. Calls +setup+ on the skill; raises if it fails.
|
|
39
|
+
# @param key [String] the instance key
|
|
40
|
+
# @param skill [SkillBase] the skill instance
|
|
41
|
+
# @return [SkillBase] the loaded skill
|
|
42
|
+
def load(key, skill)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
raise ArgumentError, "Skill already loaded: #{key}" if @skills.key?(key)
|
|
45
|
+
|
|
46
|
+
unless skill.setup
|
|
47
|
+
raise "Skill setup failed for '#{key}'"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@skills[key] = skill
|
|
51
|
+
end
|
|
52
|
+
skill
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Unload a skill by instance key. Calls +cleanup+ on it.
|
|
56
|
+
# @param key [String]
|
|
57
|
+
# @return [SkillBase, nil] the removed skill, or nil
|
|
58
|
+
def unload(key)
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
skill = @skills.delete(key)
|
|
61
|
+
skill&.cleanup
|
|
62
|
+
skill
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Retrieve a loaded skill.
|
|
67
|
+
# @param key [String]
|
|
68
|
+
# @return [SkillBase, nil]
|
|
69
|
+
def get(key)
|
|
70
|
+
@mutex.synchronize { @skills[key] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def loaded?(key)
|
|
75
|
+
@mutex.synchronize { @skills.key?(key) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array<String>]
|
|
79
|
+
def loaded_keys
|
|
80
|
+
@mutex.synchronize { @skills.keys.dup }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
def size
|
|
85
|
+
@mutex.synchronize { @skills.size }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Unload all skills.
|
|
89
|
+
def clear
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@skills.each_value(&:cleanup)
|
|
92
|
+
@skills.clear
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|