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,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
|