Class: SignalWire::POM::PromptObjectModel

Inherits:
Object
  • Object
show all
Defined in:
lib/signalwire/pom/prompt_object_model.rb

Overview

A structured data format for composing, organising, and rendering prompt instructions for large language models.

The Prompt Object Model provides a tree-based representation of a prompt document composed of nested Section objects, each of which can include a title, body text, bullet points, and arbitrarily nested subsections.

Mirrors Python’s “signalwire.pom.pom.PromptObjectModel“. The rendered output (Markdown / XML / JSON / YAML) is byte-for-byte identical to the Python reference so cross-language POM documents interoperate.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(debug: false) ⇒ PromptObjectModel

Returns a new instance of PromptObjectModel.



25
26
27
28
# File 'lib/signalwire/pom/prompt_object_model.rb', line 25

def initialize(debug: false)
  @sections = []
  @debug = debug
end

Instance Attribute Details

#debugObject

Returns the value of attribute debug.



23
24
25
# File 'lib/signalwire/pom/prompt_object_model.rb', line 23

def debug
  @debug
end

#sectionsObject

Returns the value of attribute sections.



23
24
25
# File 'lib/signalwire/pom/prompt_object_model.rb', line 23

def sections
  @sections
end

Class Method Details

._build_section(hash, is_subsection: false) ⇒ Object

Internal: build a Section (recursively) from a Hash section descriptor. Mirrors Python’s “build_section“ inner helper.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/signalwire/pom/prompt_object_model.rb', line 71

def self._build_section(hash, is_subsection: false)
  unless hash.is_a?(Hash)
    raise ArgumentError, 'Each section must be a Hash.'
  end

  if hash.key?('title') && !hash['title'].is_a?(String)
    raise ArgumentError, "'title' must be a string if present."
  end
  if hash.key?('subsections') && !hash['subsections'].is_a?(Array)
    raise ArgumentError, "'subsections' must be an Array if provided."
  end
  if hash.key?('bullets') && !hash['bullets'].is_a?(Array)
    raise ArgumentError, "'bullets' must be an Array if provided."
  end
  if hash.key?('numbered') && ![true, false].include?(hash['numbered'])
    raise ArgumentError, "'numbered' must be a boolean if provided."
  end
  if hash.key?('numberedBullets') && ![true, false].include?(hash['numberedBullets'])
    raise ArgumentError, "'numberedBullets' must be a boolean if provided."
  end

  has_body = hash.key?('body') && hash['body'] && !hash['body'].empty?
  has_bullets = hash.key?('bullets') && hash['bullets'] && !hash['bullets'].empty?
  has_subsections = hash.key?('subsections') && hash['subsections'] && !hash['subsections'].empty?
  unless has_body || has_bullets || has_subsections
    raise ArgumentError,
          'All sections must have either a non-empty body, non-empty bullets, or subsections'
  end

  if is_subsection && !hash.key?('title')
    raise ArgumentError, 'All subsections must have a title'
  end

  kwargs = {
    body: hash.fetch('body', ''),
    bullets: hash.fetch('bullets', [])
  }
  kwargs[:numbered] = hash['numbered'] if hash.key?('numbered')
  kwargs[:numbered_bullets] = hash['numberedBullets'] if hash.key?('numberedBullets')

  section = Section.new(hash['title'], **kwargs)

  (hash['subsections'] || []).each do |sub|
    section.subsections << _build_section(sub, is_subsection: true)
  end
  section
end

._from_array(data) ⇒ Object

Internal: build a PromptObjectModel from a raw Array of Hash section descriptors. Mirrors Python’s “_from_dict“ (which confusingly takes a list, not a dict).



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/signalwire/pom/prompt_object_model.rb', line 53

def self._from_array(data)
  pom = new
  data = [] if data.nil?
  unless data.is_a?(Array)
    raise ArgumentError, "POM root must be an Array, got #{data.class.name}"
  end

  data.each_with_index do |sec, idx|
    if idx.positive? && !sec.key?('title')
      sec['title'] = 'Untitled Section'
    end
    pom.sections << _build_section(sec)
  end
  pom
end

.from_json(json_data) ⇒ Object

Build a PromptObjectModel from JSON.

json_data may be either a JSON string or an already-parsed Array. Mirrors Python’s “PromptObjectModel.from_json(json_data: Union[str, dict])“.



35
36
37
38
# File 'lib/signalwire/pom/prompt_object_model.rb', line 35

def self.from_json(json_data)
  data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
  _from_array(data)
end

.from_yaml(yaml_data) ⇒ Object

Build a PromptObjectModel from YAML.

yaml_data may be either a YAML string or an already-parsed Array. Mirrors Python’s “PromptObjectModel.from_yaml(yaml_data: Union[str, dict])“.



45
46
47
48
# File 'lib/signalwire/pom/prompt_object_model.rb', line 45

def self.from_yaml(yaml_data)
  data = yaml_data.is_a?(String) ? YAML.safe_load(yaml_data) : yaml_data
  _from_array(data)
end

Instance Method Details

#add_pom_as_subsection(target, pom_to_add) ⇒ Object

Add another PromptObjectModel as a subsection of an existing section identified either by title or by Section reference.

Mirrors Python’s “PromptObjectModel.add_pom_as_subsection(target, pom_to_add)“.



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/signalwire/pom/prompt_object_model.rb', line 252

def add_pom_as_subsection(target, pom_to_add)
  case target
  when String
    target_section = find_section(target)
    raise ArgumentError, "No section with title '#{target}' found." if target_section.nil?
  when Section
    target_section = target
  else
    raise TypeError, 'Target must be a String or a Section object.'
  end

  pom_to_add.sections.each do |section|
    target_section.subsections << section
  end
end

#add_section(title = nil, body: '', bullets: nil, numbered: nil, numbered_bullets: false) ⇒ Object

Add a top-level section to the model and return the new Section.

Mirrors Python’s “PromptObjectModel.add_section“. If bullets is a String it is wrapped into a single-element Array (Python parity). Raises ArgumentError when title is nil and the model already has at least one section (only the first section may be untitled).



126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/signalwire/pom/prompt_object_model.rb', line 126

def add_section(title = nil, body: '', bullets: nil, numbered: nil, numbered_bullets: false)
  if title.nil? && !@sections.empty?
    raise ArgumentError, 'Only the first section can have no title'
  end

  bullets_list = bullets.is_a?(String) ? [bullets] : (bullets || [])

  section = Section.new(title, body: body, bullets: bullets_list,
                               numbered: numbered, numbered_bullets: numbered_bullets)
  @sections << section
  section
end

#find_section(title) ⇒ Object

Find a section by title, recursing into subsections. Returns nil when the title is not present anywhere in the tree.



141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/signalwire/pom/prompt_object_model.rb', line 141

def find_section(title)
  recurse = lambda do |sections|
    sections.each do |section|
      return section if section.title == title

      found = recurse.call(section.subsections)
      return found if found
    end
    nil
  end
  recurse.call(@sections)
end

#render_markdownObject

Render the entire model as Markdown. Output is byte-for-byte identical to Python’s “PromptObjectModel.render_markdown“.



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/signalwire/pom/prompt_object_model.rb', line 185

def render_markdown
  any_section_numbered = @sections.any? { |s| s.numbered }

  if @debug
    warn "Any section numbered: #{any_section_numbered}"
    @sections.each_with_index do |section, idx|
      warn "Section #{idx + 1}: #{section.title}, numbered=#{section.numbered}"
    end
  end

  md = []
  section_counter = 0
  @sections.each_with_index do |section, idx|
    if !section.title.nil?
      section_counter += 1
      section_number =
        if any_section_numbered && section.numbered != false
          [section_counter]
        else
          []
        end
    else
      section_number = []
    end

    if @debug
      warn "Rendering section #{idx}: #{section.title} with section_number=#{section_number.inspect}"
    end

    md << section.render_markdown(section_number: section_number)
  end

  md.join("\n")
end

#render_xmlObject

Render the entire model as XML. Output is byte-for-byte identical to Python’s “PromptObjectModel.render_xml“.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/signalwire/pom/prompt_object_model.rb', line 222

def render_xml
  xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<prompt>']
  any_section_numbered = @sections.any? { |s| s.numbered }

  section_counter = 0
  @sections.each do |section|
    if !section.title.nil?
      section_counter += 1
      section_number =
        if any_section_numbered && section.numbered != false
          [section_counter]
        else
          []
        end
    else
      section_number = []
    end

    xml << section.render_xml(indent: 1, section_number: section_number)
  end

  xml << '</prompt>'
  xml.join("\n")
end

#to_hObject

Convert the model to an Array of Hash section descriptors. Mirrors Python’s “PromptObjectModel.to_dict“ (Ruby idiom uses “to_h“).



179
180
181
# File 'lib/signalwire/pom/prompt_object_model.rb', line 179

def to_h
  @sections.map(&:to_h)
end

#to_json(*_args) ⇒ Object

Convert the model to a JSON string. Output matches Python’s “json.dumps(…, indent=2)“ byte-for-byte, with one special case: an empty model serializes to ““[]”“ (Ruby’s default “JSON.pretty_generate([])“ emits ““[nn]”“).



158
159
160
161
162
# File 'lib/signalwire/pom/prompt_object_model.rb', line 158

def to_json(*_args)
  return '[]' if @sections.empty?

  JSON.pretty_generate(@sections.map(&:to_h))
end

#to_yamlObject

Convert the model to a YAML string. Output matches Python’s “yaml.dump(…, default_flow_style=False, sort_keys=False)“ byte-for-byte. Ruby’s “YAML.dump“ prepends “—n“; we strip it. The empty-list case (Ruby emits “— []n“) is normalised to Python’s “[]n“.



169
170
171
172
173
174
# File 'lib/signalwire/pom/prompt_object_model.rb', line 169

def to_yaml
  return "[]\n" if @sections.empty?

  yaml = YAML.dump(@sections.map(&:to_h))
  yaml.sub(/\A---\s*\n/, '')
end