prompt_manager 0.4.1 → 0.5.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.
@@ -1,33 +1,22 @@
1
1
  # prompt_manager/lib/prompt_manager/prompt.rb
2
2
 
3
- # This class is responsible for managing prompts which can be utilized by
4
- # generative AI processes. This includes creation, retrieval, storage management,
5
- # as well as building prompts with replacement of parameterized values and
6
- # comment removal. It communicates with a storage system through a storage
7
- # adapter.
8
- #
9
- # Directives are collected into an Array where each entry is an Array
10
- # of two elements. The first is the directive name as a String. The
11
- # second is a string of parameters used by the directive.
12
- #
13
- # Directives are collected from the prompt after keyword
14
- # substitution has occured. This means that directives within a
15
- # prompt can be dynamic.
16
- #
17
- # PromptManager does not execute directives. They
18
- # are made available to be passed on to down stream
19
- # process.
3
+ require_relative "directive_processor"
20
4
 
21
5
  class PromptManager::Prompt
22
- COMMENT_SIGNAL = '#' # lines beginning with this are a comment
23
- DIRECTIVE_SIGNAL = '//' # Like the old IBM JCL
24
- PARAMETER_REGEX = /(\[[A-Z _|]+\])/
25
- @storage_adapter = nil
6
+ COMMENT_SIGNAL = '#' # lines beginning with this are a comment
7
+ DIRECTIVE_SIGNAL = '//' # Like the old IBM JCL
8
+ DEFAULT_PARAMETER_REGEX = /(\[[A-Z _|]+\])/
9
+ @parameter_regex = DEFAULT_PARAMETER_REGEX
10
+
11
+ ##############################################
12
+ ## Public class methods
26
13
 
27
14
  class << self
28
- attr_accessor :storage_adapter
15
+ attr_accessor :storage_adapter, :parameter_regex
29
16
 
30
- alias_method :get, :new
17
+ def get(id:)
18
+ storage_adapter.get(id: id) # Return the hash directly from storage
19
+ end
31
20
 
32
21
  def create(id:, text: "", parameters: {})
33
22
  storage_adapter.save(
@@ -36,15 +25,22 @@ class PromptManager::Prompt
36
25
  parameters: parameters
37
26
  )
38
27
 
39
- new(id: id)
28
+ ::PromptManager::Prompt.new(id: id, context: [], directives_processor: PromptManager::DirectiveProcessor.new)
40
29
  end
41
30
 
31
+ def find(id:)
32
+ ::PromptManager::Prompt.new(id: id, context: [], directives_processor: PromptManager::DirectiveProcessor.new)
33
+ end
34
+
35
+ def destroy(id:)
36
+ prompt = find(id: id)
37
+ prompt.delete
38
+ end
42
39
 
43
40
  def search(for_what)
44
41
  storage_adapter.search(for_what)
45
42
  end
46
43
 
47
-
48
44
  def method_missing(method_name, *args, &block)
49
45
  if storage_adapter.respond_to?(method_name)
50
46
  storage_adapter.send(method_name, *args, &block)
@@ -53,176 +49,108 @@ class PromptManager::Prompt
53
49
  end
54
50
  end
55
51
 
56
-
57
52
  def respond_to_missing?(method_name, include_private = false)
58
53
  storage_adapter.respond_to?(method_name, include_private) || super
59
54
  end
60
55
  end
61
-
62
- # SMELL: Does the db (aka storage adapter) really need
63
- # to be accessible by the main program?
64
- attr_accessor :db, :id, :text, :parameters, :directives
56
+
57
+ ##############################################
58
+ ## Public Instance Methods
59
+
60
+ attr_accessor :id, # String name for the prompt
61
+ :text, # String, full text of the prompt
62
+ :parameters # Hash, Key and Value are Strings
65
63
 
66
64
 
67
- # Retrieve the specific prompt ID from the Storage system.
68
65
  def initialize(
69
- id: nil, # A String name for the prompt
70
- context: [] # FIXME: Array of Strings or Pathname?
66
+ id: nil, # A String name for the prompt
67
+ context: [], # TODO: Array of Strings or Pathname?
68
+ directives_processor: PromptManager::DirectiveProcessor.new,
69
+ external_binding: binding,
70
+ erb_flag: false, # replace $ENVAR and ${ENVAR} when true
71
+ envar_flag: false # process ERB against the external_binding when true
71
72
  )
72
73
 
73
74
  @id = id
74
- @db = self.class.storage_adapter
75
-
76
- validate_arguments(@id, @db)
75
+ @directives_processor = directives_processor
77
76
 
78
- @record = db.get(id: id)
79
- @text = @record[:text]
80
- @parameters = @record[:parameters]
81
- @keywords = [] # Array of String
82
- @directives = [] # Array of arrays. directive is first entry, rest are parameters
77
+ validate_arguments(@id)
83
78
 
84
- update_keywords
85
-
86
- build
79
+ @record = db.get(id: id)
80
+ @text = @record[:text] || ""
81
+ @parameters = @record[:parameters] || {}
82
+ @directives = {}
83
+ @external_binding = external_binding
84
+ @erb_flag = erb_flag
85
+ @envar_flag = envar_flag
87
86
  end
88
87
 
89
-
90
- # Make sure the ID and DB are good-to-go
91
- def validate_arguments(prompt_id, prompts_db)
92
- raise ArgumentError, 'id cannot be blank' if prompt_id.nil? || id.strip.empty?
88
+ def validate_arguments(prompt_id, prompts_db=db)
89
+ raise ArgumentError, 'id cannot be blank' if prompt_id.nil? || prompt_id.strip.empty?
93
90
  raise(ArgumentError, 'storage_adapter is not set') if prompts_db.nil?
94
91
  end
95
92
 
96
-
97
- # Return tje prompt text suitable for passing to a
98
- # gen-AI process.
99
93
  def to_s
100
- build
94
+ processed_text = remove_comments
95
+ processed_text = substitute_values(processed_text, @parameters)
96
+ processed_text = substitute_env_vars(processed_text)
97
+ processed_text = process_directives(processed_text)
98
+ process_erb(processed_text)
101
99
  end
102
- alias_method :prompt, :to_s
103
-
104
100
 
105
- # Save the prompt to the Storage system
106
101
  def save
107
102
  db.save(
108
- id: id,
109
- text: text,
103
+ id: id,
104
+ text: text, # Save the original text
110
105
  parameters: parameters
111
- )
112
- end
113
-
114
-
115
- # Delete this prompt from the Storage system
116
- def delete
117
- db.delete(id: id)
118
- end
119
-
120
-
121
- # Build the @prompt String by replacing the keywords
122
- # with there parameterized values and removing all
123
- # the comments.
124
- #
125
- def build
126
- @prompt = text.gsub(PARAMETER_REGEX) do |match|
127
- param_name = match
128
- Array(parameters[param_name]).last || match
129
- end
130
-
131
- save_directives(@prompt)
132
- remove_comments
106
+ )
133
107
  end
134
108
 
135
-
136
- def keywords
137
- update_keywords
138
- end
109
+ def delete = db.delete(id: id)
139
110
 
140
111
 
141
112
  ######################################
142
113
  private
143
114
 
144
- def update_keywords
145
- @keywords = @text.scan(PARAMETER_REGEX).flatten.uniq
146
- @keywords.each do |kw|
147
- @parameters[kw] = [] unless @parameters.has_key?(kw)
148
- end
115
+ def db = self.class.storage_adapter
149
116
 
150
- @keywords
117
+ def remove_comments
118
+ lines = @text.split("\n")
119
+ end_index = lines.index("__END__") || lines.size
120
+ lines[0...end_index].reject { |line| line.strip.start_with?(COMMENT_SIGNAL) }.join("\n")
151
121
  end
152
122
 
153
-
154
- def save_directives(keyword_substituted_string)
155
- @directives = []
156
-
157
- keyword_substituted_string.split("\n").each do |a_line|
158
- line = a_line.strip
159
- next unless line.start_with?(DIRECTIVE_SIGNAL)
160
-
161
- parts = line.split(' ')
162
- directive = parts.shift[DIRECTIVE_SIGNAL.length..] # drop the directive signal
163
- @directives << [directive, parts.join(' ')]
123
+ def substitute_values(input_text, values_hash)
124
+ if values_hash.is_a?(Hash) && !values_hash.empty?
125
+ values_hash.each do |key, value|
126
+ input_text = input_text.gsub(key, value)
127
+ end
164
128
  end
165
-
166
- @directives
129
+ input_text
167
130
  end
168
131
 
132
+ def erb? = @erb_flag
133
+ def envvar? = @envvar_flag
169
134
 
170
- def remove_comments
171
- lines = @prompt
172
- .split("\n")
173
- .reject{|a_line|
174
- a_line.strip.start_with?(COMMENT_SIGNAL) ||
175
- a_line.strip.start_with?(DIRECTIVE_SIGNAL)
176
- }
177
-
178
- # Remove empty lines at the start of the prompt
179
- #
180
- lines = lines.drop_while(&:empty?)
181
-
182
- # Drop all the lines at __END__ and after
183
- #
184
- logical_end_inx = lines.index("__END__")
185
-
186
- @prompt = if logical_end_inx
187
- lines[0...logical_end_inx] # NOTE: ... means to not include last index
188
- else
189
- lines
190
- end.join("\n")
191
- end
192
-
193
-
194
- # Handle storage errors
195
- # SMELL: Just raise them or get out of their way and let the
196
- # main program do tje job.
197
- def handle_storage_error(error)
198
- # Log the error message, notify, or take appropriate action
199
- log_error("Storage operation failed: #{error.message}")
200
- # Re-raise the error if necessary, or define recovery steps
201
- raise error
202
- end
203
-
135
+ def substitute_env_vars(input_text)
136
+ return input_text unless envvar?
204
137
 
205
- # Let the storage adapter instance take a crake at
206
- # these unknown methods. Don't care what the args
207
- # are, just pass the prompt's ID.
208
- def method_missing(method_name, *args, &block)
209
- if db.respond_to?(method_name)
210
- db.send(method_name, id, &block)
211
- else
212
- super
138
+ input_text.gsub(/\$(\w+)|\$\{(\w+)\}/) do |match|
139
+ env_var = $1 || $2
140
+ ENV[env_var] || match
213
141
  end
214
142
  end
215
143
 
216
-
217
- def respond_to_missing?(method_name, include_private = false)
218
- db.respond_to?(method_name, include_private) || super
144
+ def process_directives(input_text)
145
+ directive_lines = input_text.split("\n").select { |line| line.strip.start_with?(DIRECTIVE_SIGNAL) }
146
+ @directives = directive_lines.each_with_object({}) { |line, hash| hash[line.strip] = "" }
147
+ @directives = @directives_processor.run(@directives)
148
+ substitute_values(input_text, @directives)
219
149
  end
220
150
 
151
+ def process_erb(input_text)
152
+ return input_text unless erb?
221
153
 
222
- # SMELL: should this gem log errors or is that a function of
223
- # main program? I believe its the main program's job.
224
- def log_error(message)
225
- puts "ERROR: #{message}"
154
+ ERB.new(input_text).result(@external_binding)
226
155
  end
227
156
  end
228
-
@@ -1,18 +1,28 @@
1
1
  # prompt_manager/lib/prompt_manager/storage/active_record_adapter.rb
2
2
 
3
3
  # This class acts as an adapter for interacting with an ActiveRecord model
4
+ require 'active_record'
4
5
  # to manage storage operations for PromptManager::Prompt instances. It defines
5
6
  # methods that allow for saving, searching, retrieving by ID, and deleting
6
7
  # prompts.
8
+ #
9
+ # To use this adapter, you must configure it with an ActiveRecord model and
10
+ # the column names for ID, text content, and parameters. The adapter will
11
+ # handle serialization and deserialization of parameters.
12
+ #
13
+ # This adapter is used by PromptManager::Prompt as its storage backend, enabling CRUD operations on persistent prompt data.
7
14
 
8
15
  class PromptManager::Storage::ActiveRecordAdapter
9
16
 
10
17
  class << self
11
- attr_accessor :model,
12
- :id_column,
13
- :text_column,
18
+ # Configure the ActiveRecord model and column mappings
19
+ attr_accessor :model,
20
+ :id_column,
21
+ :text_column,
14
22
  :parameters_column
15
23
 
24
+ # Configure the adapter with the required settings
25
+ # Must be called with a block before using the adapter
16
26
  def config
17
27
  if block_given?
18
28
  yield self
@@ -24,19 +34,19 @@ class PromptManager::Storage::ActiveRecordAdapter
24
34
  self
25
35
  end
26
36
 
27
-
37
+ # Validate that all required configuration is present and valid
28
38
  def validate_configuration
29
39
  validate_model
30
40
  validate_columns
31
41
  end
32
42
 
33
-
43
+ # Ensure the provided model is a valid ActiveRecord model
34
44
  def validate_model
35
45
  raise ArgumentError, "AR Model not set" unless model
36
46
  raise ArgumentError, "AR Model is not an ActiveRecord model" unless model < ActiveRecord::Base
37
47
  end
38
48
 
39
-
49
+ # Verify that all required columns exist in the model
40
50
  def validate_columns
41
51
  columns = model.column_names # Array of Strings
42
52
  [id_column, text_column, parameters_column].each do |column|
@@ -44,8 +54,7 @@ class PromptManager::Storage::ActiveRecordAdapter
44
54
  end
45
55
  end
46
56
 
47
-
48
-
57
+ # Delegate unknown methods to the ActiveRecord model
49
58
  def method_missing(method_name, *args, &block)
50
59
  if model.respond_to?(method_name)
51
60
  model.send(method_name, *args, &block)
@@ -54,7 +63,7 @@ class PromptManager::Storage::ActiveRecordAdapter
54
63
  end
55
64
  end
56
65
 
57
-
66
+ # Support respond_to? for delegated methods
58
67
  def respond_to_missing?(method_name, include_private = false)
59
68
  model.respond_to?(method_name, include_private) || super
60
69
  end
@@ -62,30 +71,29 @@ class PromptManager::Storage::ActiveRecordAdapter
62
71
 
63
72
 
64
73
  ##############################################
74
+ # The ActiveRecord object representing the current prompt
65
75
  attr_accessor :record
66
76
 
77
+ # Accessor methods to avoid repeated self.class prefixes
78
+ def model = self.class.model
79
+ def id_column = self.class.id_column
80
+ def text_column = self.class.text_column
81
+ def parameters_column = self.class.parameters_column
67
82
 
68
- # Avoid code littered with self.class prefixes ...
69
- def model = self.class.model
70
- def id_column = self.class.id_column
71
- def text_column = self.class.text_column
72
- def parameters_column = self.class.parameters_column
73
-
74
-
83
+ # Initialize the adapter and validate configuration
75
84
  def initialize
76
85
  self.class.send(:validate_configuration) # send gets around private designations of a method
77
86
  @record = model.first
78
87
  end
79
88
 
80
-
89
+ # Retrieve a prompt by its ID
90
+ # Returns a hash with id, text, and parameters
81
91
  def get(id:)
82
92
  @record = model.find_by(id_column => id)
83
93
  raise ArgumentError, "Prompt not found with id: #{id}" unless @record
84
94
 
85
- # kludge? testing showed that parameters was being
86
- # returned as a String. Did serialization fail or is
87
- # there something else going on?
88
- # FIXME: expected the parameters_column to be a HAsh after de-serialization
95
+ # Handle case where parameters might be stored as a JSON string
96
+ # instead of a native Hash
89
97
  parameters = @record[parameters_column]
90
98
 
91
99
  if parameters.is_a? String
@@ -93,54 +101,57 @@ class PromptManager::Storage::ActiveRecordAdapter
93
101
  end
94
102
 
95
103
  {
96
- id: id, # same as the id_column
104
+ id: id,
97
105
  text: @record[text_column],
98
106
  parameters: parameters
99
107
  }
100
108
  end
101
109
 
102
-
110
+ # Save a prompt with the given ID, text, and parameters
111
+ # Creates a new record if one doesn't exist, otherwise updates existing record
103
112
  def save(id:, text: "", parameters: {})
104
113
  @record = model.find_or_initialize_by(id_column => id)
105
114
 
106
115
  @record[text_column] = text
107
- @record[parameters_column] = parameters
116
+ @record[parameters_column] = parameters
108
117
  @record.save!
109
118
  end
110
119
 
111
-
120
+ # Delete a prompt with the given ID
112
121
  def delete(id:)
113
122
  @record = model.find_by(id_column => id)
114
123
  @record&.destroy
115
124
  end
116
125
 
117
-
118
-
126
+ # Return an array of all prompt IDs
119
127
  def list(*)
120
128
  model.all.pluck(id_column)
121
129
  end
122
130
 
123
-
131
+ # Search for prompts containing the given text
132
+ # Returns an array of matching prompt IDs
124
133
  def search(for_what)
125
134
  model.where("#{text_column} LIKE ?", "%#{for_what}%").pluck(id_column)
126
135
  end
127
136
 
128
-
129
137
  ##############################################
130
138
  private
131
139
 
132
-
140
+ # Delegate unknown methods to the current record
133
141
  def method_missing(method_name, *args, &block)
134
- if @record.respond_to?(method_name)
135
- model.send(method_name, args.first, &block)
142
+ if @record && @record.respond_to?(method_name)
143
+ @record.send(method_name, *args, &block)
144
+ elsif model.respond_to?(method_name)
145
+ model.send(method_name, *args, &block)
136
146
  else
137
147
  super
138
148
  end
139
149
  end
140
150
 
141
-
151
+ # Support respond_to? for delegated methods
142
152
  def respond_to_missing?(method_name, include_private = false)
143
- model.respond_to?(method_name, include_private) || super
153
+ (model.respond_to?(method_name, include_private) ||
154
+ (@record && @record.respond_to?(method_name, include_private)) ||
155
+ super)
144
156
  end
145
157
  end
146
-