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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ require_relative 'section'
7
+
8
+ module SignalWire
9
+ module POM
10
+ # A structured data format for composing, organising, and rendering
11
+ # prompt instructions for large language models.
12
+ #
13
+ # The Prompt Object Model provides a tree-based representation of a
14
+ # prompt document composed of nested Section objects, each of which
15
+ # can include a title, body text, bullet points, and arbitrarily
16
+ # nested subsections.
17
+ #
18
+ # Mirrors Python's ``signalwire.pom.pom.PromptObjectModel``. The
19
+ # rendered output (Markdown / XML / JSON / YAML) is byte-for-byte
20
+ # identical to the Python reference so cross-language POM documents
21
+ # interoperate.
22
+ class PromptObjectModel
23
+ attr_accessor :sections, :debug
24
+
25
+ def initialize(debug: false)
26
+ @sections = []
27
+ @debug = debug
28
+ end
29
+
30
+ # Build a PromptObjectModel from JSON.
31
+ #
32
+ # +json_data+ may be either a JSON string or an already-parsed
33
+ # Array. Mirrors Python's
34
+ # ``PromptObjectModel.from_json(json_data: Union[str, dict])``.
35
+ def self.from_json(json_data)
36
+ data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
37
+ _from_array(data)
38
+ end
39
+
40
+ # Build a PromptObjectModel from YAML.
41
+ #
42
+ # +yaml_data+ may be either a YAML string or an already-parsed
43
+ # Array. Mirrors Python's
44
+ # ``PromptObjectModel.from_yaml(yaml_data: Union[str, dict])``.
45
+ def self.from_yaml(yaml_data)
46
+ data = yaml_data.is_a?(String) ? YAML.safe_load(yaml_data) : yaml_data
47
+ _from_array(data)
48
+ end
49
+
50
+ # Internal: build a PromptObjectModel from a raw Array of Hash
51
+ # section descriptors. Mirrors Python's ``_from_dict`` (which
52
+ # confusingly takes a list, not a dict).
53
+ def self._from_array(data)
54
+ pom = new
55
+ data = [] if data.nil?
56
+ unless data.is_a?(Array)
57
+ raise ArgumentError, "POM root must be an Array, got #{data.class.name}"
58
+ end
59
+
60
+ data.each_with_index do |sec, idx|
61
+ if idx.positive? && !sec.key?('title')
62
+ sec['title'] = 'Untitled Section'
63
+ end
64
+ pom.sections << _build_section(sec)
65
+ end
66
+ pom
67
+ end
68
+
69
+ # Internal: build a Section (recursively) from a Hash section
70
+ # descriptor. Mirrors Python's ``build_section`` inner helper.
71
+ def self._build_section(hash, is_subsection: false)
72
+ unless hash.is_a?(Hash)
73
+ raise ArgumentError, 'Each section must be a Hash.'
74
+ end
75
+
76
+ if hash.key?('title') && !hash['title'].is_a?(String)
77
+ raise ArgumentError, "'title' must be a string if present."
78
+ end
79
+ if hash.key?('subsections') && !hash['subsections'].is_a?(Array)
80
+ raise ArgumentError, "'subsections' must be an Array if provided."
81
+ end
82
+ if hash.key?('bullets') && !hash['bullets'].is_a?(Array)
83
+ raise ArgumentError, "'bullets' must be an Array if provided."
84
+ end
85
+ if hash.key?('numbered') && ![true, false].include?(hash['numbered'])
86
+ raise ArgumentError, "'numbered' must be a boolean if provided."
87
+ end
88
+ if hash.key?('numberedBullets') && ![true, false].include?(hash['numberedBullets'])
89
+ raise ArgumentError, "'numberedBullets' must be a boolean if provided."
90
+ end
91
+
92
+ has_body = hash.key?('body') && hash['body'] && !hash['body'].empty?
93
+ has_bullets = hash.key?('bullets') && hash['bullets'] && !hash['bullets'].empty?
94
+ has_subsections = hash.key?('subsections') && hash['subsections'] && !hash['subsections'].empty?
95
+ unless has_body || has_bullets || has_subsections
96
+ raise ArgumentError,
97
+ 'All sections must have either a non-empty body, non-empty bullets, or subsections'
98
+ end
99
+
100
+ if is_subsection && !hash.key?('title')
101
+ raise ArgumentError, 'All subsections must have a title'
102
+ end
103
+
104
+ kwargs = {
105
+ body: hash.fetch('body', ''),
106
+ bullets: hash.fetch('bullets', [])
107
+ }
108
+ kwargs[:numbered] = hash['numbered'] if hash.key?('numbered')
109
+ kwargs[:numbered_bullets] = hash['numberedBullets'] if hash.key?('numberedBullets')
110
+
111
+ section = Section.new(hash['title'], **kwargs)
112
+
113
+ (hash['subsections'] || []).each do |sub|
114
+ section.subsections << _build_section(sub, is_subsection: true)
115
+ end
116
+ section
117
+ end
118
+
119
+ # Add a top-level section to the model and return the new Section.
120
+ #
121
+ # Mirrors Python's ``PromptObjectModel.add_section``. If +bullets+
122
+ # is a String it is wrapped into a single-element Array (Python
123
+ # parity). Raises ArgumentError when +title+ is nil and the model
124
+ # already has at least one section (only the first section may
125
+ # be untitled).
126
+ def add_section(title = nil, body: '', bullets: nil, numbered: nil, numbered_bullets: false)
127
+ if title.nil? && !@sections.empty?
128
+ raise ArgumentError, 'Only the first section can have no title'
129
+ end
130
+
131
+ bullets_list = bullets.is_a?(String) ? [bullets] : (bullets || [])
132
+
133
+ section = Section.new(title, body: body, bullets: bullets_list,
134
+ numbered: numbered, numbered_bullets: numbered_bullets)
135
+ @sections << section
136
+ section
137
+ end
138
+
139
+ # Find a section by title, recursing into subsections. Returns
140
+ # +nil+ when the title is not present anywhere in the tree.
141
+ def find_section(title)
142
+ recurse = lambda do |sections|
143
+ sections.each do |section|
144
+ return section if section.title == title
145
+
146
+ found = recurse.call(section.subsections)
147
+ return found if found
148
+ end
149
+ nil
150
+ end
151
+ recurse.call(@sections)
152
+ end
153
+
154
+ # Convert the model to a JSON string. Output matches Python's
155
+ # ``json.dumps(..., indent=2)`` byte-for-byte, with one
156
+ # special case: an empty model serializes to ``"[]"`` (Ruby's
157
+ # default ``JSON.pretty_generate([])`` emits ``"[\n\n]"``).
158
+ def to_json(*_args)
159
+ return '[]' if @sections.empty?
160
+
161
+ JSON.pretty_generate(@sections.map(&:to_h))
162
+ end
163
+
164
+ # Convert the model to a YAML string. Output matches Python's
165
+ # ``yaml.dump(..., default_flow_style=False, sort_keys=False)``
166
+ # byte-for-byte. Ruby's ``YAML.dump`` prepends ``---\n``; we strip
167
+ # it. The empty-list case (Ruby emits ``--- []\n``) is normalised
168
+ # to Python's ``[]\n``.
169
+ def to_yaml
170
+ return "[]\n" if @sections.empty?
171
+
172
+ yaml = YAML.dump(@sections.map(&:to_h))
173
+ yaml.sub(/\A---\s*\n/, '')
174
+ end
175
+
176
+ # Convert the model to an Array of Hash section descriptors.
177
+ # Mirrors Python's ``PromptObjectModel.to_dict`` (Ruby idiom uses
178
+ # ``to_h``).
179
+ def to_h
180
+ @sections.map(&:to_h)
181
+ end
182
+
183
+ # Render the entire model as Markdown. Output is byte-for-byte
184
+ # identical to Python's ``PromptObjectModel.render_markdown``.
185
+ def render_markdown
186
+ any_section_numbered = @sections.any? { |s| s.numbered }
187
+
188
+ if @debug
189
+ warn "Any section numbered: #{any_section_numbered}"
190
+ @sections.each_with_index do |section, idx|
191
+ warn "Section #{idx + 1}: #{section.title}, numbered=#{section.numbered}"
192
+ end
193
+ end
194
+
195
+ md = []
196
+ section_counter = 0
197
+ @sections.each_with_index do |section, idx|
198
+ if !section.title.nil?
199
+ section_counter += 1
200
+ section_number =
201
+ if any_section_numbered && section.numbered != false
202
+ [section_counter]
203
+ else
204
+ []
205
+ end
206
+ else
207
+ section_number = []
208
+ end
209
+
210
+ if @debug
211
+ warn "Rendering section #{idx}: #{section.title} with section_number=#{section_number.inspect}"
212
+ end
213
+
214
+ md << section.render_markdown(section_number: section_number)
215
+ end
216
+
217
+ md.join("\n")
218
+ end
219
+
220
+ # Render the entire model as XML. Output is byte-for-byte identical
221
+ # to Python's ``PromptObjectModel.render_xml``.
222
+ def render_xml
223
+ xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<prompt>']
224
+ any_section_numbered = @sections.any? { |s| s.numbered }
225
+
226
+ section_counter = 0
227
+ @sections.each do |section|
228
+ if !section.title.nil?
229
+ section_counter += 1
230
+ section_number =
231
+ if any_section_numbered && section.numbered != false
232
+ [section_counter]
233
+ else
234
+ []
235
+ end
236
+ else
237
+ section_number = []
238
+ end
239
+
240
+ xml << section.render_xml(indent: 1, section_number: section_number)
241
+ end
242
+
243
+ xml << '</prompt>'
244
+ xml.join("\n")
245
+ end
246
+
247
+ # Add another PromptObjectModel as a subsection of an existing
248
+ # section identified either by title or by Section reference.
249
+ #
250
+ # Mirrors Python's
251
+ # ``PromptObjectModel.add_pom_as_subsection(target, pom_to_add)``.
252
+ def add_pom_as_subsection(target, pom_to_add)
253
+ case target
254
+ when String
255
+ target_section = find_section(target)
256
+ raise ArgumentError, "No section with title '#{target}' found." if target_section.nil?
257
+ when Section
258
+ target_section = target
259
+ else
260
+ raise TypeError, 'Target must be a String or a Section object.'
261
+ end
262
+
263
+ pom_to_add.sections.each do |section|
264
+ target_section.subsections << section
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWire
4
+ module POM
5
+ # Represents a section in the Prompt Object Model.
6
+ #
7
+ # Each section contains a title, optional body text, optional bullet
8
+ # points, and can have any number of nested subsections.
9
+ #
10
+ # Mirrors Python's ``signalwire.pom.pom.Section`` exactly. See
11
+ # ``signalwire-python/signalwire/signalwire/pom/pom.py`` for the
12
+ # source-of-truth specification; rendering output (markdown / XML /
13
+ # JSON / YAML) must match Python byte-for-byte so cross-language POM
14
+ # documents are interoperable.
15
+ #
16
+ # Attributes:
17
+ # * +title+ — the name of the section.
18
+ # * +body+ — a paragraph of text associated with the section.
19
+ # * +bullets+ — bullet-pointed items (Array<String>).
20
+ # * +subsections+ — nested Section objects.
21
+ # * +numbered+ — whether this section should be numbered.
22
+ # * +numbered_bullets+ — whether bullets should be numbered (rendered
23
+ # to/from the JSON/YAML key +numberedBullets+ for Python parity).
24
+ class Section
25
+ attr_accessor :title, :body, :bullets, :subsections, :numbered, :numbered_bullets
26
+
27
+ # Construct a Section.
28
+ #
29
+ # All arguments after +title+ are keyword arguments mirroring the
30
+ # Python ``Section.__init__`` signature. ``numbered_bullets`` is
31
+ # snake_case in Ruby; the camelCase ``numberedBullets`` form used
32
+ # by Python's JSON/YAML serialization is preserved on the wire.
33
+ def initialize(title = nil, body: '', bullets: nil, numbered: nil, numbered_bullets: false)
34
+ @title = title
35
+
36
+ unless body.is_a?(String)
37
+ raise TypeError,
38
+ "body must be a string, not #{body.class.name}. " \
39
+ 'If you meant to pass a list of bullet points, use bullets parameter instead.'
40
+ end
41
+ @body = body
42
+
43
+ if !bullets.nil? && !bullets.is_a?(Array)
44
+ raise TypeError, "bullets must be an Array or nil, not #{bullets.class.name}"
45
+ end
46
+
47
+ @bullets = bullets || []
48
+ @subsections = []
49
+ @numbered = numbered
50
+ @numbered_bullets = numbered_bullets
51
+ end
52
+
53
+ # Add or replace the body text for this section. Mirrors Python's
54
+ # ``Section.add_body`` (which is documented to "Add or replace").
55
+ def add_body(body)
56
+ unless body.is_a?(String)
57
+ raise TypeError, "body must be a string, not #{body.class.name}"
58
+ end
59
+
60
+ @body = body
61
+ end
62
+
63
+ # Append bullet points to this section. Does not replace existing
64
+ # bullets — mirrors Python's ``self.bullets.extend(bullets)``.
65
+ def add_bullets(bullets)
66
+ unless bullets.is_a?(Array)
67
+ raise TypeError, "bullets must be an Array, not #{bullets.class.name}"
68
+ end
69
+
70
+ @bullets.concat(bullets)
71
+ end
72
+
73
+ # Add a subsection to this section, returning the new Section.
74
+ #
75
+ # Raises ArgumentError when +title+ is nil (Python raises
76
+ # ``ValueError("Subsections must have a title")``; Ruby idiom
77
+ # is ArgumentError for invalid arguments).
78
+ def add_subsection(title, body: '', bullets: nil, numbered: false, numbered_bullets: false)
79
+ raise ArgumentError, 'Subsections must have a title' if title.nil?
80
+
81
+ sub = Section.new(title, body: body, bullets: bullets || [],
82
+ numbered: numbered, numbered_bullets: numbered_bullets)
83
+ @subsections << sub
84
+ sub
85
+ end
86
+
87
+ # Convert the section to a Hash representation suitable for JSON or
88
+ # YAML serialization. Keys are emitted in the same order as Python
89
+ # so cross-port string comparisons line up.
90
+ def to_h
91
+ data = {}
92
+ data['title'] = @title unless @title.nil?
93
+ data['body'] = @body if @body && !@body.empty?
94
+ data['bullets'] = @bullets if @bullets && !@bullets.empty?
95
+ data['subsections'] = @subsections.map(&:to_h) if @subsections && !@subsections.empty?
96
+ data['numbered'] = @numbered if @numbered
97
+ data['numberedBullets'] = @numbered_bullets if @numbered_bullets
98
+ data
99
+ end
100
+
101
+ # Render this section and all its subsections as Markdown. The
102
+ # output is byte-for-byte identical to Python's
103
+ # ``Section.render_markdown``.
104
+ def render_markdown(level: 2, section_number: nil)
105
+ md = []
106
+ section_number = [] if section_number.nil?
107
+
108
+ unless @title.nil?
109
+ prefix = ''
110
+ prefix = "#{section_number.join('.')}. " unless section_number.empty?
111
+ md << "#{'#' * level} #{prefix}#{@title}\n"
112
+ end
113
+
114
+ md << "#{@body}\n" if @body && !@body.empty?
115
+
116
+ @bullets.each_with_index do |bullet, idx|
117
+ if @numbered_bullets
118
+ md << "#{idx + 1}. #{bullet}"
119
+ else
120
+ md << "- #{bullet}"
121
+ end
122
+ end
123
+
124
+ md << '' unless @bullets.empty?
125
+
126
+ any_subsection_numbered = @subsections.any? { |sub| sub.numbered }
127
+
128
+ @subsections.each_with_index do |subsection, idx|
129
+ if !@title.nil? || !section_number.empty?
130
+ new_section_number =
131
+ if any_subsection_numbered && subsection.numbered != false
132
+ section_number + [idx + 1]
133
+ else
134
+ section_number
135
+ end
136
+ next_level = level + 1
137
+ else
138
+ new_section_number = section_number
139
+ next_level = level
140
+ end
141
+
142
+ md << subsection.render_markdown(level: next_level, section_number: new_section_number)
143
+ end
144
+
145
+ md.join("\n")
146
+ end
147
+
148
+ # Render this section and all its subsections as XML. Output is
149
+ # byte-for-byte identical to Python's ``Section.render_xml``.
150
+ def render_xml(indent: 0, section_number: nil)
151
+ indent_str = ' ' * indent
152
+ xml = []
153
+ section_number = [] if section_number.nil?
154
+
155
+ xml << "#{indent_str}<section>"
156
+
157
+ unless @title.nil?
158
+ prefix = ''
159
+ prefix = "#{section_number.join('.')}. " unless section_number.empty?
160
+ xml << "#{indent_str} <title>#{prefix}#{@title}</title>"
161
+ end
162
+
163
+ xml << "#{indent_str} <body>#{@body}</body>" if @body && !@body.empty?
164
+
165
+ if @bullets && !@bullets.empty?
166
+ xml << "#{indent_str} <bullets>"
167
+ @bullets.each_with_index do |bullet, idx|
168
+ if @numbered_bullets
169
+ xml << "#{indent_str} <bullet id=\"#{idx + 1}\">#{bullet}</bullet>"
170
+ else
171
+ xml << "#{indent_str} <bullet>#{bullet}</bullet>"
172
+ end
173
+ end
174
+ xml << "#{indent_str} </bullets>"
175
+ end
176
+
177
+ if @subsections && !@subsections.empty?
178
+ xml << "#{indent_str} <subsections>"
179
+ any_subsection_numbered = @subsections.any? { |sub| sub.numbered }
180
+
181
+ @subsections.each_with_index do |subsection, idx|
182
+ if !@title.nil? || !section_number.empty?
183
+ new_section_number =
184
+ if any_subsection_numbered && subsection.numbered != false
185
+ section_number + [idx + 1]
186
+ else
187
+ section_number
188
+ end
189
+ else
190
+ new_section_number = section_number
191
+ end
192
+ xml << subsection.render_xml(indent: indent + 2, section_number: new_section_number)
193
+ end
194
+ xml << "#{indent_str} </subsections>"
195
+ end
196
+
197
+ xml << "#{indent_str}</section>"
198
+ xml.join("\n")
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,92 @@
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
+
10
+ module SignalWire
11
+ module Prefabs
12
+ # Prefab agent for providing virtual concierge services.
13
+ #
14
+ # agent = Concierge.new(
15
+ # venue_name: 'Grand Hotel',
16
+ # services: ['room service', 'spa bookings'],
17
+ # amenities: { 'pool' => { 'hours' => '7 AM - 10 PM', 'location' => '2nd Floor' } }
18
+ # )
19
+ #
20
+ class Concierge
21
+ attr_reader :venue_name, :services, :amenities, :name, :route
22
+
23
+ def initialize(venue_name:, services:, amenities:, hours_of_operation: nil,
24
+ special_instructions: nil, welcome_message: nil,
25
+ name: 'concierge', route: '/concierge', **_opts)
26
+ @venue_name = venue_name
27
+ @services = services || []
28
+ @amenities = (amenities || {}).transform_keys(&:to_s)
29
+ @hours = hours_of_operation
30
+ @instructions = special_instructions || []
31
+ @welcome = welcome_message || "Welcome to #{venue_name}! How can I assist you today?"
32
+ @name = name
33
+ @route = route
34
+ end
35
+
36
+ def tools
37
+ %w[get_amenity_info get_service_info]
38
+ end
39
+
40
+ def prompt_sections
41
+ amenity_bullets = @amenities.map { |k, v| "#{k}: #{v.is_a?(Hash) ? v.map { |a, b| "#{a}: #{b}" }.join(', ') : v}" }
42
+ service_bullets = @services.map { |s| s.to_s }
43
+
44
+ sections = [
45
+ {
46
+ 'title' => "#{@venue_name} Concierge",
47
+ 'body' => @welcome,
48
+ 'bullets' => service_bullets + amenity_bullets
49
+ }
50
+ ]
51
+
52
+ if @hours
53
+ sections << {
54
+ 'title' => 'Hours of Operation',
55
+ 'body' => @hours.is_a?(Hash) ? @hours.map { |k, v| "#{k}: #{v}" }.join('; ') : @hours.to_s
56
+ }
57
+ end
58
+
59
+ sections
60
+ end
61
+
62
+ def global_data
63
+ {
64
+ 'venue_name' => @venue_name,
65
+ 'services' => @services,
66
+ 'amenities' => @amenities
67
+ }
68
+ end
69
+
70
+ def handle_amenity_info(args, _raw_data)
71
+ amenity = (args['amenity'] || '').downcase
72
+ info = @amenities.find { |k, _v| k.downcase == amenity }&.last
73
+ if info
74
+ detail = info.is_a?(Hash) ? info.map { |k, v| "#{k}: #{v}" }.join(', ') : info.to_s
75
+ Swaig::FunctionResult.new("#{amenity.capitalize}: #{detail}")
76
+ else
77
+ Swaig::FunctionResult.new("I don't have information about '#{amenity}'. Available amenities: #{@amenities.keys.join(', ')}")
78
+ end
79
+ end
80
+
81
+ def handle_service_info(args, _raw_data)
82
+ service = (args['service'] || '').downcase
83
+ match = @services.find { |s| s.downcase.include?(service) }
84
+ if match
85
+ Swaig::FunctionResult.new("#{match} is available at #{@venue_name}.")
86
+ else
87
+ Swaig::FunctionResult.new("Available services: #{@services.join(', ')}")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,67 @@
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
+
10
+ module SignalWire
11
+ module Prefabs
12
+ # Prefab agent for answering frequently asked questions.
13
+ #
14
+ # agent = FaqBot.new(
15
+ # faqs: [
16
+ # { 'question' => 'What is SignalWire?', 'answer' => 'A cloud communications platform.' }
17
+ # ]
18
+ # )
19
+ #
20
+ class FaqBot
21
+ attr_reader :faqs, :name, :route
22
+
23
+ def initialize(faqs:, suggest_related: true, persona: nil,
24
+ name: 'faq_bot', route: '/faq', **_opts)
25
+ raise ArgumentError, 'faqs must be a non-empty Array' unless faqs.is_a?(Array) && !faqs.empty?
26
+
27
+ @faqs = faqs.map { |f| f.transform_keys(&:to_s) }
28
+ @suggest_related = suggest_related
29
+ @persona = persona || 'You are a helpful FAQ bot that provides accurate answers to common questions.'
30
+ @name = name
31
+ @route = route
32
+ end
33
+
34
+ def tools
35
+ %w[search_faq]
36
+ end
37
+
38
+ def prompt_sections
39
+ bullets = @faqs.map { |f| "Q: #{f['question']}" }
40
+ [
41
+ {
42
+ 'title' => 'FAQ Bot',
43
+ 'body' => @persona,
44
+ 'bullets' => bullets
45
+ }
46
+ ]
47
+ end
48
+
49
+ def global_data
50
+ {
51
+ 'faqs' => @faqs,
52
+ 'suggest_related' => @suggest_related
53
+ }
54
+ end
55
+
56
+ def handle_search(args, _raw_data)
57
+ query = (args['query'] || '').downcase
58
+ match = @faqs.find { |f| f['question'].downcase.include?(query) || query.include?(f['question'].downcase) }
59
+ if match
60
+ Swaig::FunctionResult.new(match['answer'])
61
+ else
62
+ Swaig::FunctionResult.new("I don't have a specific answer for that. Here are the topics I can help with: #{@faqs.map { |f| f['question'] }.join('; ')}")
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end