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.
- checksums.yaml +4 -4
- data/docs/tutorial/advanced/performance.md +695 -0
- data/docs/tutorial/advanced/tool-registration.md +776 -0
- data/docs/tutorial/basic-usage.md +351 -0
- data/docs/tutorial/components/chat-components.md +552 -0
- data/docs/tutorial/components/formatting.md +623 -0
- data/docs/tutorial/components/index.md +366 -0
- data/docs/tutorial/components/media-components.md +259 -0
- data/docs/tutorial/components/schema-components.md +668 -0
- data/docs/tutorial/index.md +185 -0
- data/docs/tutorial/output-formats.md +689 -0
- data/docs/tutorial/quickstart.md +30 -0
- data/docs/tutorial/template-engine.md +540 -0
- data/lib/poml/components/base.rb +146 -4
- data/lib/poml/components/content.rb +10 -3
- data/lib/poml/components/data.rb +539 -19
- data/lib/poml/components/examples.rb +235 -1
- data/lib/poml/components/formatting.rb +184 -18
- data/lib/poml/components/layout.rb +7 -2
- data/lib/poml/components/lists.rb +69 -35
- data/lib/poml/components/meta.rb +134 -5
- data/lib/poml/components/output_schema.rb +19 -1
- data/lib/poml/components/template.rb +72 -61
- data/lib/poml/components/text.rb +30 -1
- data/lib/poml/components/tool.rb +81 -0
- data/lib/poml/components/tool_definition.rb +339 -10
- data/lib/poml/components/tools.rb +14 -0
- data/lib/poml/components/utilities.rb +34 -18
- data/lib/poml/components.rb +19 -0
- data/lib/poml/context.rb +19 -4
- data/lib/poml/parser.rb +88 -63
- data/lib/poml/renderer.rb +191 -9
- data/lib/poml/template_engine.rb +138 -13
- data/lib/poml/version.rb +1 -1
- data/lib/poml.rb +16 -1
- data/readme.md +157 -30
- metadata +87 -4
- 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
|
-
|
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
|
-
|
124
|
-
|
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('&', '&')
|
262
|
+
.gsub('<', '<')
|
263
|
+
.gsub('>', '>')
|
264
|
+
.gsub('"', '"')
|
265
|
+
.gsub("'", ''')
|
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('&', '&')
|
282
|
+
.gsub('<', '<')
|
283
|
+
.gsub('>', '>')
|
284
|
+
.gsub('"', '"')
|
285
|
+
.gsub("'", ''')
|
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
|
-
#
|
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
|
-
#
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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)
|
data/lib/poml/template_engine.rb
CHANGED
@@ -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
|
-
#
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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
|
-
|
135
|
-
|
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
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
|