poml 0.0.6 → 0.0.8

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/docs/tutorial/advanced/performance.md +695 -0
  3. data/docs/tutorial/advanced/tool-registration.md +776 -0
  4. data/docs/tutorial/basic-usage.md +351 -0
  5. data/docs/tutorial/components/chat-components.md +552 -0
  6. data/docs/tutorial/components/formatting.md +623 -0
  7. data/docs/tutorial/components/index.md +366 -0
  8. data/docs/tutorial/components/media-components.md +259 -0
  9. data/docs/tutorial/components/schema-components.md +668 -0
  10. data/docs/tutorial/index.md +185 -0
  11. data/docs/tutorial/output-formats.md +689 -0
  12. data/docs/tutorial/quickstart.md +30 -0
  13. data/docs/tutorial/template-engine.md +540 -0
  14. data/lib/poml/components/base.rb +146 -4
  15. data/lib/poml/components/content.rb +10 -3
  16. data/lib/poml/components/data.rb +539 -19
  17. data/lib/poml/components/examples.rb +235 -1
  18. data/lib/poml/components/formatting.rb +184 -18
  19. data/lib/poml/components/layout.rb +7 -2
  20. data/lib/poml/components/lists.rb +69 -35
  21. data/lib/poml/components/meta.rb +134 -5
  22. data/lib/poml/components/output_schema.rb +19 -1
  23. data/lib/poml/components/template.rb +72 -61
  24. data/lib/poml/components/text.rb +30 -1
  25. data/lib/poml/components/tool.rb +81 -0
  26. data/lib/poml/components/tool_definition.rb +339 -10
  27. data/lib/poml/components/tools.rb +14 -0
  28. data/lib/poml/components/utilities.rb +34 -18
  29. data/lib/poml/components.rb +19 -0
  30. data/lib/poml/context.rb +19 -4
  31. data/lib/poml/parser.rb +88 -63
  32. data/lib/poml/renderer.rb +191 -9
  33. data/lib/poml/template_engine.rb +138 -13
  34. data/lib/poml/version.rb +1 -1
  35. data/lib/poml.rb +16 -1
  36. data/readme.md +157 -30
  37. metadata +87 -4
  38. data/TUTORIAL.md +0 -987
data/lib/poml/parser.rb CHANGED
@@ -35,6 +35,9 @@ module Poml
35
35
  # Pre-process to handle JSON in attributes (convert \" to " inside attribute values)
36
36
  content = preprocess_json_attributes(content)
37
37
 
38
+ # Pre-process code blocks to escape XML special characters
39
+ content = preprocess_code_blocks(content)
40
+
38
41
  # Remove XML comments but preserve surrounding whitespace
39
42
  content = content.gsub(/(\s*)<!--.*?-->(\s*)/m) do |match|
40
43
  before_space = $1
@@ -50,8 +53,9 @@ module Poml
50
53
  # Convert HTML-style void elements to XML self-closing format
51
54
  content = preprocess_void_elements(content)
52
55
 
53
- # Apply template substitutions
54
- content = @template_engine.substitute(content)
56
+ # Apply safe template substitutions - only for simple string values
57
+ # This avoids XML parsing issues with complex values in attributes
58
+ content = @template_engine.safe_substitute(content)
55
59
 
56
60
  # Check if content is wrapped in <poml> tags and extract syntax
57
61
  content = content.strip
@@ -120,8 +124,43 @@ module Poml
120
124
 
121
125
  # Handle for loop rendering
122
126
  if for_attribute
123
- loop_elements = render_for_loop(child, attrs, for_attribute)
124
- elements.concat(loop_elements)
127
+ # Convert for attribute to ForComponent structure
128
+ # Parse for attribute like "item in items"
129
+ if for_attribute =~ /^(\w+)\s+in\s+(.+)$/
130
+ loop_var = $1
131
+ list_expr = $2.strip
132
+
133
+ # Create a for element that wraps the original element
134
+ # Remove the for attribute from the original element
135
+ loop_attrs = attrs.dup
136
+ loop_attrs.delete('for')
137
+
138
+ # Create the wrapper element
139
+ original_element = Element.new(
140
+ tag_name: child.name.downcase.to_sym,
141
+ attributes: loop_attrs,
142
+ content: extract_text_content(child),
143
+ children: parse_element(child)
144
+ )
145
+
146
+ # Create for component element
147
+ for_element = Element.new(
148
+ tag_name: :for,
149
+ attributes: { 'variable' => loop_var, 'items' => list_expr },
150
+ content: '',
151
+ children: [original_element]
152
+ )
153
+
154
+ elements << for_element
155
+ else
156
+ # Invalid for syntax, process normally
157
+ elements << Element.new(
158
+ tag_name: child.name.downcase.to_sym,
159
+ attributes: attrs,
160
+ content: extract_text_content(child),
161
+ children: parse_element(child)
162
+ )
163
+ end
125
164
  else
126
165
  elements << Element.new(
127
166
  tag_name: child.name.downcase.to_sym,
@@ -174,65 +213,6 @@ module Poml
174
213
  !!value
175
214
  end
176
215
 
177
- def render_for_loop(xml_element, attrs, for_attribute)
178
- # Parse for attribute like "i in [1,2,3]" or "item in items"
179
- if for_attribute =~ /^(\w+)\s+in\s+(.+)$/
180
- loop_var = $1
181
- list_expr = $2.strip
182
-
183
- # Evaluate the list expression
184
- list = @template_engine.evaluate_attribute_expression(list_expr)
185
- return [] unless list.is_a?(Array)
186
-
187
- # Create elements for each item in the list
188
- elements = []
189
- list.each_with_index do |item, index|
190
- # Create loop context
191
- old_loop_var = @context.variables[loop_var]
192
- old_loop_context = @context.variables['loop']
193
-
194
- @context.variables[loop_var] = item
195
- @context.variables['loop'] = {
196
- 'index' => index,
197
- 'length' => list.length,
198
- 'first' => index == 0,
199
- 'last' => index == list.length - 1
200
- }
201
-
202
- # Remove for attribute and process element normally
203
- loop_attrs = attrs.dup
204
- loop_attrs.delete('for')
205
-
206
- element = Element.new(
207
- tag_name: xml_element.name.downcase.to_sym,
208
- attributes: loop_attrs,
209
- content: extract_text_content(xml_element),
210
- children: parse_element(xml_element)
211
- )
212
-
213
- elements << element
214
-
215
- # Restore previous context
216
- if old_loop_var
217
- @context.variables[loop_var] = old_loop_var
218
- else
219
- @context.variables.delete(loop_var)
220
- end
221
-
222
- if old_loop_context
223
- @context.variables['loop'] = old_loop_context
224
- else
225
- @context.variables.delete('loop')
226
- end
227
- end
228
-
229
- elements
230
- else
231
- # Invalid for syntax, return empty
232
- []
233
- end
234
- end
235
-
236
216
  def preprocess_void_elements(content)
237
217
  # List of HTML void elements that should be self-closing in XML
238
218
  # Note: 'meta' is removed from this list because POML meta components can have content
@@ -264,5 +244,50 @@ module Poml
264
244
 
265
245
  content
266
246
  end
247
+
248
+ def preprocess_code_blocks(content)
249
+ # Escape XML special characters within <code> and <code-block> blocks to prevent XML parsing errors
250
+ # Use negative lookahead to avoid matching <code-block> when looking for <code>
251
+ content = content.gsub(/<code(?!-)[^>]*>(.*?)<\/code>/m) do |match|
252
+ full_match = match
253
+ opening_tag = full_match[/<code(?!-)[^>]*>/]
254
+ closing_tag = '</code>'
255
+ code_content = full_match.match(/<code(?!-)[^>]*>(.*?)<\/code>/m)[1]
256
+
257
+ # Handle nil case
258
+ code_content ||= ''
259
+
260
+ # Escape XML special characters in the code content
261
+ escaped_content = code_content.gsub('&', '&amp;')
262
+ .gsub('<', '&lt;')
263
+ .gsub('>', '&gt;')
264
+ .gsub('"', '&quot;')
265
+ .gsub("'", '&apos;')
266
+
267
+ "#{opening_tag}#{escaped_content}#{closing_tag}"
268
+ end
269
+
270
+ # Also handle <code-block> tags
271
+ content = content.gsub(/<code-block\b[^>]*>(.*?)<\/code-block>/m) do |match|
272
+ full_match = match
273
+ opening_tag = full_match[/<code-block\b[^>]*>/]
274
+ closing_tag = '</code-block>'
275
+ code_content = full_match.match(/<code-block\b[^>]*>(.*?)<\/code-block>/m)[1]
276
+
277
+ # Handle nil case
278
+ code_content ||= ''
279
+
280
+ # Escape XML special characters in the code content
281
+ escaped_content = code_content.gsub('&', '&amp;')
282
+ .gsub('<', '&lt;')
283
+ .gsub('>', '&gt;')
284
+ .gsub('"', '&quot;')
285
+ .gsub("'", '&apos;')
286
+
287
+ "#{opening_tag}#{escaped_content}#{closing_tag}"
288
+ end
289
+
290
+ content
291
+ end
267
292
  end
268
293
  end
data/lib/poml/renderer.rb CHANGED
@@ -8,6 +8,9 @@ module Poml
8
8
  end
9
9
 
10
10
  def render(elements, format = 'dict')
11
+ # Set the output format in context so components can adjust behavior
12
+ @context.output_format = format
13
+
11
14
  case format
12
15
  when 'raw'
13
16
  render_raw(elements)
@@ -15,6 +18,8 @@ module Poml
15
18
  render_dict(elements)
16
19
  when 'openai_chat'
17
20
  render_openai_chat(elements)
21
+ when 'openaiResponse'
22
+ render_openai_response(elements)
18
23
  when 'langchain'
19
24
  render_langchain(elements)
20
25
  when 'pydantic'
@@ -57,27 +62,125 @@ module Poml
57
62
 
58
63
  # Include additional metadata if present
59
64
  metadata['response_schema'] = @context.response_schema if @context.response_schema
60
- metadata['tools'] = @context.tools if @context.tools && !@context.tools.empty?
61
65
  metadata['runtime_parameters'] = @context.runtime_parameters if @context.runtime_parameters && !@context.runtime_parameters.empty?
62
66
 
63
- {
67
+ # Add schemas array for backward compatibility with tests
68
+ if @context.response_schema_with_metadata
69
+ metadata['schemas'] = [@context.response_schema_with_metadata]
70
+ end
71
+
72
+ result = {
64
73
  'content' => content,
65
74
  'metadata' => metadata
66
75
  }
76
+
77
+ # Include tools at top level (matching original implementation)
78
+ result['tools'] = @context.tools && !@context.tools.empty? ? @context.tools : []
79
+
80
+ # Include output content if present
81
+ result['output'] = @context.output_content if @context.output_content
82
+
83
+ result
67
84
  end
68
85
 
69
86
  def render_openai_chat(elements)
70
87
  # First render to collect structured messages
71
88
  content = render_raw(elements)
72
89
 
73
- # Use structured messages if available
74
- if @context.respond_to?(:chat_messages) && !@context.chat_messages.empty?
90
+ # Build messages array
91
+ messages = if @context.respond_to?(:chat_messages) && !@context.chat_messages.empty?
75
92
  @context.chat_messages
76
93
  elsif @context.chat
77
94
  parse_chat_messages(content)
78
95
  else
79
96
  [{ 'role' => 'user', 'content' => content }]
80
97
  end
98
+
99
+ # openai_chat format always returns just the messages array
100
+ # Tools should be accessed via openaiResponse format instead
101
+ messages
102
+ end
103
+
104
+ def convert_to_openai_schema(tool)
105
+ # If tool already has a schema field (from JSON format), use it
106
+ if tool['schema']
107
+ begin
108
+ return JSON.parse(tool['schema']) if tool['schema'].is_a?(String)
109
+ return tool['schema'] if tool['schema'].is_a?(Hash)
110
+ rescue JSON::ParserError
111
+ # Fall through to build from parameters
112
+ end
113
+ end
114
+
115
+ # Build schema from flattened parameters (declarative format)
116
+ if tool['parameters']
117
+ if tool['parameters'].is_a?(Hash) && tool['parameters']['type'] == 'object'
118
+ # Already in JSON schema format
119
+ return tool['parameters']
120
+ else
121
+ # Convert flattened format to JSON schema
122
+ properties = {}
123
+ required = []
124
+
125
+ tool['parameters'].each do |param_name, param_def|
126
+ properties[param_name] = {
127
+ 'type' => param_def['type']
128
+ }
129
+ properties[param_name]['description'] = param_def['description'] if param_def['description']
130
+ required << param_name if param_def['required']
131
+ end
132
+
133
+ return {
134
+ 'type' => 'object',
135
+ 'properties' => properties,
136
+ 'required' => required
137
+ }
138
+ end
139
+ end
140
+
141
+ # Fallback empty schema
142
+ { 'type' => 'object', 'properties' => {}, 'required' => [] }
143
+ end
144
+
145
+ def render_openai_response(elements)
146
+ # OpenAI Response format - standardized AI response structure
147
+ # Different from openai_chat which focuses on conversation messages
148
+ content = render_raw(elements)
149
+
150
+ response = {
151
+ 'content' => content.strip,
152
+ 'type' => 'assistant'
153
+ }
154
+
155
+ # Include chat messages if available (for compatibility)
156
+ if @context.chat_messages && !@context.chat_messages.empty?
157
+ response['messages'] = @context.chat_messages
158
+ end
159
+
160
+ # Include metadata if available
161
+ metadata = {}
162
+ metadata['variables'] = @context.variables if @context.variables && !@context.variables.empty?
163
+ metadata['response_schema'] = @context.response_schema if @context.response_schema
164
+ metadata['runtime_parameters'] = @context.runtime_parameters if @context.runtime_parameters && !@context.runtime_parameters.empty?
165
+
166
+ # Include custom metadata (title, description, etc.)
167
+ metadata.merge!(@context.custom_metadata) if @context.custom_metadata && !@context.custom_metadata.empty?
168
+
169
+ # Include tools in metadata only if there are tools
170
+ metadata['tools'] = @context.tools if @context.tools && !@context.tools.empty?
171
+
172
+ # Include schemas in metadata for backward compatibility
173
+ if @context.response_schema_with_metadata
174
+ metadata['schemas'] = [@context.response_schema_with_metadata]
175
+ end
176
+
177
+ # Only include metadata if it has meaningful content
178
+ response['metadata'] = metadata unless metadata.empty?
179
+
180
+ # Include tools at top level (matching existing structure)
181
+ response['tools'] = @context.tools && !@context.tools.empty? ? @context.tools : []
182
+
183
+ response
81
184
  end
82
185
 
83
186
  def render_langchain(elements)
@@ -89,12 +192,91 @@ module Poml
89
192
  end
90
193
 
91
194
  def render_pydantic(elements)
92
- # Simplified pydantic-like structure
93
- {
94
- 'prompt' => render_raw(elements),
95
- 'variables' => @context.variables,
96
- 'chat_enabled' => @context.chat
195
+ # Enhanced pydantic format with Python interoperability features
196
+ raw_content = render_raw(elements)
197
+
198
+ # Enhanced Pydantic-compatible structure
199
+ pydantic_output = {
200
+ 'content' => raw_content,
201
+ 'variables' => @context.variables || {},
202
+ 'chat_enabled' => @context.chat,
203
+ 'metadata' => {
204
+ 'format' => 'pydantic',
205
+ 'version' => '1.0',
206
+ 'python_compatible' => true,
207
+ 'strict_json_schema' => true
208
+ }
209
+ }
210
+
211
+ # Add response schema if available (from output-schema components)
212
+ if @context.response_schema
213
+ pydantic_output['schemas'] = [make_schema_strict(@context.response_schema)]
214
+ else
215
+ pydantic_output['schemas'] = []
216
+ end
217
+
218
+ # Add tools if available (from tool-definition components)
219
+ if @context.tools && !@context.tools.empty?
220
+ pydantic_output['tools'] = @context.tools.map { |tool| format_tool_for_pydantic(tool) }
221
+ else
222
+ pydantic_output['tools'] = []
223
+ end
224
+
225
+ # Add custom metadata if available (from meta components)
226
+ if @context.custom_metadata && !@context.custom_metadata.empty?
227
+ pydantic_output['custom_metadata'] = @context.custom_metadata
228
+ else
229
+ pydantic_output['custom_metadata'] = {}
230
+ end
231
+
232
+ pydantic_output
233
+ end
234
+
235
+ private
236
+
237
+ def make_schema_strict(schema)
238
+ # Convert schema to strict JSON schema format (Pydantic compatible)
239
+ return schema unless schema.is_a?(Hash)
240
+
241
+ strict_schema = schema.dup
242
+
243
+ if strict_schema['type'] == 'object'
244
+ strict_schema['additionalProperties'] = false
245
+
246
+ # Make all properties required if not specified
247
+ if strict_schema['properties'] && !strict_schema['required']
248
+ strict_schema['required'] = strict_schema['properties'].keys
249
+ end
250
+
251
+ # Recursively process nested objects
252
+ if strict_schema['properties']
253
+ strict_schema['properties'] = strict_schema['properties'].transform_values do |prop|
254
+ make_schema_strict(prop)
255
+ end
256
+ end
257
+ elsif strict_schema['type'] == 'array' && strict_schema['items']
258
+ strict_schema['items'] = make_schema_strict(strict_schema['items'])
259
+ end
260
+
261
+ # Remove null defaults for strict compatibility
262
+ strict_schema.delete('default') if strict_schema['default'].nil?
263
+
264
+ strict_schema
265
+ end
266
+
267
+ def format_tool_for_pydantic(tool)
268
+ # Format tool definition for Pydantic compatibility
269
+ formatted_tool = {
270
+ 'name' => tool['name'],
271
+ 'description' => tool['description']
97
272
  }
273
+
274
+ if tool['parameters']
275
+ # Convert parameters to Pydantic-compatible format
276
+ formatted_tool['parameters'] = make_schema_strict(tool['parameters'])
277
+ end
278
+
279
+ formatted_tool
98
280
  end
99
281
 
100
282
  def parse_chat_messages(content)
@@ -15,8 +15,50 @@ module Poml
15
15
  end
16
16
  end
17
17
 
18
+ def safe_substitute(text)
19
+ return text unless text.is_a?(String)
20
+
21
+ # Handle {{variable}} substitutions, but only substitute simple string values
22
+ # to avoid XML parsing issues with complex values in attributes
23
+ text.gsub(/\{\{(.+?)\}\}/) do |match|
24
+ expression = $1.strip
25
+
26
+ # Don't substitute complex expressions (comparisons, ternary operators, etc.)
27
+ # These should be handled during rendering when variables are in proper scope
28
+ if expression.include?('==') || expression.include?('!=') ||
29
+ expression.include?('>=') || expression.include?('<=') ||
30
+ expression.include?('>') || expression.include?('<') ||
31
+ expression.include?('?') || expression.include?(':')
32
+ match # Keep {{...}} for complex expressions
33
+ else
34
+ # Check what type of value this expression would resolve to
35
+ # without actually evaluating it to string first
36
+ raw_value = evaluate_attribute_expression(expression)
37
+
38
+ # Only substitute if:
39
+ # 1. The value exists (not nil)
40
+ # 2. The value is a simple type (string, number, boolean)
41
+ # Leave nil values and complex objects as template variables for component-level handling
42
+ case raw_value
43
+ when String, Numeric, TrueClass, FalseClass
44
+ raw_value.to_s
45
+ when NilClass
46
+ match # Keep {{...}} for nil values - they might be valid in child contexts
47
+ else
48
+ match # Return original {{...}} for complex values like arrays/hashes
49
+ end
50
+ end
51
+ end
52
+ end
53
+
18
54
  def evaluate_attribute_expression(expression)
19
55
  # Handle attribute expressions that might return non-string values
56
+
57
+ # Handle string literals with quotes
58
+ if expression =~ /^"([^"]*)"$/ || expression =~ /^'([^']*)'$/
59
+ return $1 # Return the content without quotes
60
+ end
61
+
20
62
  if expression =~ /^(\w+(?:\.\w+)*)\s*\+\s*(\d+)$/
21
63
  variable_path = $1
22
64
  increment = $2.to_i
@@ -51,6 +93,11 @@ module Poml
51
93
  def evaluate_expression(expression)
52
94
  # Handle dot notation and arithmetic expressions
53
95
 
96
+ # Handle string literals with quotes
97
+ if expression =~ /^"([^"]*)"$/ || expression =~ /^'([^']*)'$/
98
+ return $1 # Return the content without quotes
99
+ end
100
+
54
101
  # Simple arithmetic operations like loop.index+1
55
102
  if expression =~ /^(\w+(?:\.\w+)*)\s*\+\s*(\d+)$/
56
103
  variable_path = $1
@@ -80,6 +127,22 @@ module Poml
80
127
  def evaluate_complex_expression(expression)
81
128
  # Handle more complex expressions like array literals, object access, etc.
82
129
 
130
+ # Handle logical OR operator (||) - return first truthy value
131
+ if expression =~ /^(.+?)\s*\|\|\s*(.+)$/
132
+ left_expression = $1.strip
133
+ right_expression = $2.strip
134
+
135
+ left_value = evaluate_expression(left_expression)
136
+
137
+ # Return left value if it's truthy (not nil, false, or empty string)
138
+ if left_value && left_value != "" && left_value != false
139
+ return left_value
140
+ else
141
+ # Evaluate and return the right expression
142
+ return evaluate_expression(right_expression)
143
+ end
144
+ end
145
+
83
146
  # Try to parse as JSON first (for arrays and objects)
84
147
  begin
85
148
  require 'json'
@@ -112,32 +175,85 @@ module Poml
112
175
 
113
176
  condition_result = evaluate_condition(condition)
114
177
  if condition_result
115
- evaluate_expression(true_value)
178
+ return evaluate_expression(true_value)
116
179
  else
117
- evaluate_expression(false_value)
180
+ return evaluate_expression(false_value)
118
181
  end
119
182
  end
120
183
 
184
+ # Handle comparison operations as standalone expressions
185
+ if expression =~ /^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/
186
+ condition_result = evaluate_condition(expression)
187
+ return condition_result
188
+ end
189
+
121
190
  nil
122
191
  end
123
192
 
124
193
  def evaluate_condition(condition)
125
- # Simple condition evaluation
126
- case condition
127
- when 'true'
128
- true
129
- when 'false'
130
- false
131
- when /^!(.+)$/
132
- !evaluate_condition($1.strip)
194
+ # Handle comparison operations
195
+ if condition =~ /^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/
196
+ left_operand = $1.strip
197
+ operator = $2.strip
198
+ right_operand = $3.strip
199
+
200
+ # Evaluate operands
201
+ left_value = evaluate_operand(left_operand)
202
+ right_value = evaluate_operand(right_operand)
203
+
204
+ # Perform comparison
205
+ case operator
206
+ when '=='
207
+ left_value == right_value
208
+ when '!='
209
+ left_value != right_value
210
+ when '>='
211
+ left_value >= right_value if left_value.respond_to?(:>=)
212
+ when '<='
213
+ left_value <= right_value if left_value.respond_to?(:<=)
214
+ when '>'
215
+ left_value > right_value if left_value.respond_to?(:>)
216
+ when '<'
217
+ left_value < right_value if left_value.respond_to?(:<)
218
+ else
219
+ false
220
+ end
221
+ else
222
+ # Simple condition evaluation
223
+ case condition
224
+ when 'true'
225
+ true
226
+ when 'false'
227
+ false
228
+ when /^!(.+)$/
229
+ !evaluate_condition($1.strip)
230
+ else
231
+ value = get_nested_variable(condition)
232
+ !!value
233
+ end
234
+ end
235
+ end
236
+
237
+ def evaluate_operand(operand)
238
+ # Handle string literals with quotes
239
+ if operand =~ /^"([^"]*)"$/ || operand =~ /^'([^']*)'$/
240
+ return $1
241
+ elsif operand =~ /^-?\d+$/
242
+ return operand.to_i
243
+ elsif operand =~ /^-?\d*\.\d+$/
244
+ return operand.to_f
245
+ elsif operand == 'true'
246
+ return true
247
+ elsif operand == 'false'
248
+ return false
133
249
  else
134
- value = get_nested_variable(condition)
135
- !!value
250
+ # Variable lookup
251
+ get_nested_variable(operand)
136
252
  end
137
253
  end
138
254
 
139
255
  def get_nested_variable(path)
140
- # Handle dot notation like "loop.index"
256
+ # Handle dot notation like "loop.index" or "items.length"
141
257
  parts = path.split('.')
142
258
 
143
259
  # Handle both Context objects and raw variable hashes
@@ -152,6 +268,15 @@ module Poml
152
268
  value = value[part]
153
269
  elsif value.is_a?(Hash) && value.key?(part.to_sym)
154
270
  value = value[part.to_sym]
271
+ elsif value.respond_to?(part) && (value.is_a?(Array) || value.is_a?(String))
272
+ # Handle method calls like .length, .size, .count on arrays/strings only
273
+ # Don't call methods on Hash objects to avoid conflicts with variable names
274
+ begin
275
+ value = value.send(part)
276
+ rescue
277
+ # If method call fails, return nil
278
+ return nil
279
+ end
155
280
  else
156
281
  return nil
157
282
  end
data/lib/poml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Poml
4
- VERSION = "0.0.6"
4
+ VERSION = "0.0.7"
5
5
  end
data/lib/poml.rb CHANGED
@@ -21,14 +21,16 @@ module Poml
21
21
  #
22
22
  # Parameters:
23
23
  # - markup: POML markup string or file path
24
- # - format: 'raw', 'dict', 'openai_chat', 'langchain', 'pydantic' (default: 'dict')
24
+ # - format: 'raw', 'dict', 'openai_chat', 'openaiResponse', 'langchain', 'pydantic' (default: 'dict')
25
25
  # - context/variables: Hash of template variables
26
26
  # - stylesheet: CSS/styling rules
27
27
  # - chat: Enable chat mode (default: true)
28
28
  # - output_file: File path to write output to
29
29
  def self.process(markup:, format: 'dict', **options)
30
30
  # Handle file paths
31
+ source_file = nil
31
32
  content = if File.exist?(markup)
33
+ source_file = File.expand_path(markup)
32
34
  File.read(markup)
33
35
  else
34
36
  markup
@@ -51,6 +53,7 @@ module Poml
51
53
  context_options[:stylesheet] = options.delete(:stylesheet) if options.key?(:stylesheet)
52
54
  context_options[:chat] = options.delete(:chat) if options.key?(:chat)
53
55
  context_options[:syntax] = options.delete(:syntax) if options.key?(:syntax)
56
+ context_options[:source_path] = source_file if source_file
54
57
 
55
58
  result = render(content, format: format, **context_options)
56
59
 
@@ -78,6 +81,8 @@ module Poml
78
81
 
79
82
  # Convenience method for quick text rendering
80
83
  def self.to_text(content, **options)
84
+ # For text rendering, disable chat mode to allow chat components to render content
85
+ options[:chat] = false unless options.key?(:chat)
81
86
  render(content, format: 'raw', **options)
82
87
  end
83
88
 
@@ -86,8 +91,18 @@ module Poml
86
91
  render(content, format: 'openai_chat', **options)
87
92
  end
88
93
 
94
+ # Convenience method for OpenAI response format
95
+ def self.to_openai_response(content, **options)
96
+ render(content, format: 'openaiResponse', **options)
97
+ end
98
+
89
99
  # Convenience method for dict format
90
100
  def self.to_dict(content, **options)
91
101
  render(content, format: 'dict', **options)
92
102
  end
103
+
104
+ # Convenience method for Pydantic format with Python interoperability
105
+ def self.to_pydantic(content, **options)
106
+ render(content, format: 'pydantic', **options)
107
+ end
93
108
  end