prompt_manager 0.4.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c566743ba6741a0787db6ea680e77676ab18282013e3475cad1c30b85b9ce1b
4
- data.tar.gz: 1e9e57eecaf97d05799d6b173b1ce90518dc02abeeddbd50eed90c34c376509f
3
+ metadata.gz: 267e48a65e377f2fc3390d3bbff69a6e1f241c96951fc86437f5ab02d2e93daa
4
+ data.tar.gz: db8af7d8515e72bf12f847e3544a7413a0acb0707a8321bcae7f13e1ffa1ff86
5
5
  SHA512:
6
- metadata.gz: e1bcda6797d895b4dd78baf6c6c6cd2b83e2f06bb3446f9112795a6d216eb9450af9f1c2d0b3f762d4f21ee40a1257fec8610eadec33f7f19dabf078e7a859bf
7
- data.tar.gz: f69ed112a2b22801dc830d87b11f0a231755141fa4eccded5ee2dc8019343cf6cf61705af560fed211866869940029bbde88cca7c821423da245593839515e03
6
+ metadata.gz: 259cb1494a9390f94d40c669bd87960d9257a9126c3d71cbc6fdcdbaa82dae3e0209f23acc322d019e781abe48090a070fba20761a2ffe8a1818a303c8ef2741
7
+ data.tar.gz: 97e6ce3eb0743b02804b44c745a2710f15e4048957c7a039c6313b2d061d65660f9d4435465fd0139ff8e348ba841cb4ae543d06a7709391fab19a6419ea1e55
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  ## Unreleased
2
2
 
3
3
  ## Released
4
+ ### [0.5.0] = 2025-03-29
5
+ - Major refactoring of to improve processing of parameters and directives.
6
+ - Added PromptManager::DirectiveProcessor as an example of how to implement custom directives.
7
+ - Added support for //include directive that protects against loops.
8
+ - Added support for embedding system environment variables.
9
+ - Added support for ERB processing within a prompt.
10
+ - Improved test coverage.
4
11
 
5
12
  ### [0.4.2] = 2024-10-26
6
13
  - Added configurable parameter_regex to customize keyword pattern
data/README.md CHANGED
@@ -9,6 +9,8 @@ Manage the parameterized prompts (text) used in generative AI (aka chatGPT, Open
9
9
 
10
10
  ## Table of Contents
11
11
 
12
+ - [PromptManager](#promptmanager)
13
+ - [Table of Contents](#table-of-contents)
12
14
  - [Installation](#installation)
13
15
  - [Usage](#usage)
14
16
  - [Overview](#overview)
@@ -16,15 +18,15 @@ Manage the parameterized prompts (text) used in generative AI (aka chatGPT, Open
16
18
  - [What does a keyword look like?](#what-does-a-keyword-look-like)
17
19
  - [All about directives](#all-about-directives)
18
20
  - [Example Prompt with Directives](#example-prompt-with-directives)
19
- - [Accessing Directives](#accessing-directives)
21
+ - [Accessing Directives and Setting Parameter Values](#accessing-directives-and-setting-parameter-values)
20
22
  - [Dynamic Directives](#dynamic-directives)
21
23
  - [Executing Directives](#executing-directives)
22
24
  - [Comments Are Ignored](#comments-are-ignored)
23
25
  - [Storage Adapters](#storage-adapters)
24
26
  - [FileSystemAdapter](#filesystemadapter)
25
27
  - [Configuration](#configuration)
26
- - [prompts_dir](#prompts_dir)
27
- - [search_proc](#search_proc)
28
+ - [prompts\_dir](#prompts_dir)
29
+ - [search\_proc](#search_proc)
28
30
  - [File Extensions](#file-extensions)
29
31
  - [Example Prompt Text File](#example-prompt-text-file)
30
32
  - [Example Prompt Parameters JSON File](#example-prompt-parameters-json-file)
@@ -32,15 +34,21 @@ Manage the parameterized prompts (text) used in generative AI (aka chatGPT, Open
32
34
  - [ActiveRecordAdapter](#activerecordadapter)
33
35
  - [Configuration](#configuration-1)
34
36
  - [model](#model)
35
- - [id_column](#id_column)
36
- - [text_column](#text_column)
37
- - [parameters_column](#parameters_column)
37
+ - [id\_column](#id_column)
38
+ - [text\_column](#text_column)
39
+ - [parameters\_column](#parameters_column)
38
40
  - [Other Potential Storage Adapters](#other-potential-storage-adapters)
39
41
  - [Development](#development)
40
42
  - [Contributing](#contributing)
41
43
  - [License](#license)
42
44
 
43
45
  <!-- Tocer[finish]: Auto-generated, don't remove. -->
46
+ ### Latest Capabilities
47
+ - **Directive Processing:** Processes directives such as `//include` (aliased as `//import`) with loop protection.
48
+ - **ERB Processing:** Supports ERB templating within prompts.
49
+ - **Environment Variable Embedding:** Automatically substitutes system environment variables in prompts.
50
+ - **Improved Parameter Handling:** Refactored to maintain a history of parameter values.
51
+ - **ActiveRecord Adapter:** Facilitates storing and retrieving prompts via an ActiveRecord model.
44
52
 
45
53
  ## Installation
46
54
 
@@ -60,11 +68,19 @@ See also [examples/using_search_proc.rb](examples/using_search_proc.rb)
60
68
 
61
69
  ## Overview
62
70
 
71
+ ### Prompt Initialization Options
72
+ - `id`: A String name for the prompt.
73
+ - `context`: An Array for additional context.
74
+ - `directives_processor`: An instance of PromptManager::DirectiveProcessor (default), can be customized.
75
+ - `external_binding`: A Ruby binding to be used for ERB processing.
76
+ - `erb_flag`: Boolean flag to enable ERB processing in the prompt text.
77
+ - `envar_flag`: Boolean flag to enable environment variable substitution in the prompt text.
78
+
63
79
  The `prompt_manager` gem provides functionality to manage prompts that have keywords and directives for use with generative AI processes.
64
80
 
65
81
  ### Generative AI (gen-AI)
66
82
 
67
- Gen-AI deals with the conversion (some would say execution) of a human natural language text (the "prompt") into somthing else using what are known as large language models (LLM) such as those available from OpenAI. A parameterized prompt is one in which there are embedded keywords (parameters) which are place holders for other text to be inserted into the prompt.
83
+ Gen-AI deals with the conversion (some would say execution) of a human natural language text (the "prompt") into something else using what are known as large language models (LLM) such as those available from OpenAI. A parameterized prompt is one in which there are embedded keywords (parameters) which are place holders for other text to be inserted into the prompt.
68
84
 
69
85
  The prompt_manager uses a regular expression to identify these keywords within the prompt. It uses the keywords as keys in a `parameters` Hash which is stored with the prompt text in a serialized form - for example as JSON.
70
86
 
@@ -84,7 +100,7 @@ The regex must include capturing parentheses () to extract the keyword. The defa
84
100
 
85
101
  A directive is a line in the prompt text that starts with the two characters '//' - slash slash - just like in the old days of IBM JCL - Job Control Language. A prompt can have zero or more directives. Directives can have parameters and can make use of keywords.
86
102
 
87
- The `prompt_manager` only collects directives. It extracts keywords from directive lines and provides the substitution of those keywords with other text just like it does for the prompt.
103
+ The `prompt_manager` only collects directives. It extracts keywords from directive lines and provides the substitution of those keywords with other text just like it does for the prompt.
88
104
 
89
105
  ##### Example Prompt with Directives
90
106
 
@@ -102,20 +118,30 @@ __END__
102
118
  Computers will never replace Frank Sinatra
103
119
  ```
104
120
 
105
- ##### Accessing Directives
121
+ ##### Accessing Directives and Setting Parameter Values
106
122
 
107
- Getting directives from a prompt is as easy as getting the kewyords:
123
+ Getting directives and keywords from a prompt is straightforward:
108
124
 
109
125
  ```ruby
110
- prompt = PromptManager::Prompt.new(...)
111
- prompt.keywords #=> an Array
126
+ prompt = PromptManager::Prompt.new(id: 'some_id')
127
+ prompt.keywords #=> an Array of keywords found in the prompt text
112
128
  prompt.directives #=> an Array of entries like: ['directive', 'parameters']
113
129
 
130
+ # Or directly update the parameters hash
131
+ prompt.parameters = {
132
+ "[KEYWORD1]" => "value1",
133
+ "[KEYWORD2]" => "value2"
134
+ }
135
+
136
+ # After setting parameter values, call to_s to build the final prompt
114
137
  # to_s builds the prompt by substituting
115
- # values for keywords amd removing comments.
138
+ # values for keywords and removing comments.
116
139
  # The resulting text contains directives and
117
140
  # prompt text ready for the LLM process.
118
- puts prompt.to_s
141
+ final_text = prompt.to_s
142
+
143
+ # To persist any parameter changes to storage
144
+ prompt.save
119
145
  ```
120
146
 
121
147
  The entries in the Array returned by the `prompt.directives` method is in the order that the directives were defined within the prompt. Each entry has two elements:
@@ -138,7 +164,7 @@ Since directies are collected after the keywords in the prompt have been substit
138
164
 
139
165
  The `prompt_manager` gem only collects directives. Executing those directives is left up to some down stream process. Here are some ideas on how directives could be used in prompt downstream process:
140
166
 
141
- - "//model gpt-5" could be used to set the LLM model to be used for a specific prompt.
167
+ - "//model gpt-5" could be used to set the LLM model to be used for a specific prompt.
142
168
  - "//backend mods" could be used to set the backend prompt processor on the command line to be the `mods` utility.
143
169
  - "//include path_to_file" could be used to add the contents of a file to the prompt.
144
170
  - "//chat" could be used to send the prompts and then start up a chat session about the prompt and its response.
@@ -172,8 +198,8 @@ Use a `config` block to establish the configuration for the class.
172
198
 
173
199
  ```ruby
174
200
  PromptManager::Storage::FileSystemAdapter.config do |o|
175
- o.prompts_dir = "path/to/prompts_directory"
176
- o.search_proc = nil # default
201
+ o.prompts_dir = "path/to/prompts_directory"
202
+ o.search_proc = nil # default
177
203
  o.prompt_extension = '.txt' # default
178
204
  o.params_extension = '.json' # default
179
205
  end
@@ -183,7 +209,7 @@ The `config` block returns `self` so that means you can do this to setup the sto
183
209
 
184
210
  ```ruby
185
211
  PromptManager::Prompt
186
- .storage_adapter =
212
+ .storage_adapter =
187
213
  PromptManager::Storage::FileSystemAdapter
188
214
  .config do |config|
189
215
  config.prompts_dir = 'path/to/prompts_dir'
@@ -198,7 +224,7 @@ An `ArgumentError` will be raised when `prompts_dir` does not exist or if it is
198
224
 
199
225
  ##### search_proc
200
226
 
201
- The default for `search_proc` is nil which means that the search will be preformed by a default `search` method which is basically reading all the prompt files to see which ones contain the search term. It will return an Array of prompt IDs for each prompt file found that contains the search term. Its up to the application to select which returned prompt ID to use.
227
+ The default for `search_proc` is nil which means that the search will be preformed by a default `search` method which is basically reading all the prompt files to see which ones contain the search term. It will return an Array of prompt IDs for each prompt file found that contains the search term. Its up to the application to select which returned prompt ID to use.
202
228
 
203
229
  There are faster ways to search and select files. For example there are specialized search and selection utilities that are available for the command line. The `examples` directory contains a `bash` script named `rgfzf` that uses `rg` (aka `ripgrep`) to do the searching and `fzf` to do the selecting.
204
230
 
@@ -273,7 +299,7 @@ The `PromptManager::Prompt` class expects an instance of a storage adapter class
273
299
 
274
300
  ```ruby
275
301
  PromptManager::Prompt
276
- .storage_adapter =
302
+ .storage_adapter =
277
303
  PromptManager::Storage::ActiveRecordAdapter.config do |config|
278
304
  config.model = DbPromptModel # any ActiveRecord::Base model
279
305
  config.id_column = :prompt_name
@@ -0,0 +1,47 @@
1
+ # lib/prompt_manager/directive_processor.rb
2
+
3
+ # This is an example of a directive processor class.
4
+ # It only supports the //include directive which is also
5
+ # aliased as //import.
6
+
7
+ module PromptManager
8
+ class DirectiveProcessor
9
+ EXCLUDED_METHODS = %w[ run initialize ]
10
+
11
+ def initialize
12
+ @prefix_size = PromptManager::Prompt::DIRECTIVE_SIGNAL.size
13
+ @included_files = []
14
+ end
15
+
16
+ def run(directives)
17
+ return {} if directives.nil? || directives.empty?
18
+ directives.each do |key, _|
19
+ sans_prefix = key[@prefix_size..]
20
+ args = sans_prefix.split(' ')
21
+ method_name = args.shift
22
+
23
+ if EXCLUDED_METHODS.include?(method_name)
24
+ directives[key] = "Error: #{method_name} is not a valid directive: #{key}"
25
+ elsif respond_to?(method_name)
26
+ directives[key] = send(method_name, *args)
27
+ else
28
+ directives[key] = "Error: Unknown directive '#{key}'"
29
+ end
30
+ end
31
+ directives
32
+ end
33
+
34
+ def include(file_path)
35
+ if File.exist?(file_path) &&
36
+ File.readable?(file_path) &&
37
+ !@included_files.include?(file_path)
38
+ content = File.read(file_path)
39
+ @included_files << file_path
40
+ content
41
+ else
42
+ "Error: File '#{file_path}' not accessible"
43
+ end
44
+ end
45
+ alias_method :import, :include
46
+ end
47
+ end
@@ -1,34 +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
6
+ COMMENT_SIGNAL = '#' # lines beginning with this are a comment
7
+ DIRECTIVE_SIGNAL = '//' # Like the old IBM JCL
24
8
  DEFAULT_PARAMETER_REGEX = /(\[[A-Z _|]+\])/
25
- @storage_adapter = nil
26
- @parameter_regex = DEFAULT_PARAMETER_REGEX
9
+ @parameter_regex = DEFAULT_PARAMETER_REGEX
10
+
11
+ ##############################################
12
+ ## Public class methods
27
13
 
28
14
  class << self
29
15
  attr_accessor :storage_adapter, :parameter_regex
30
16
 
31
- alias_method :get, :new
17
+ def get(id:)
18
+ storage_adapter.get(id: id) # Return the hash directly from storage
19
+ end
32
20
 
33
21
  def create(id:, text: "", parameters: {})
34
22
  storage_adapter.save(
@@ -37,15 +25,22 @@ class PromptManager::Prompt
37
25
  parameters: parameters
38
26
  )
39
27
 
40
- new(id: id)
28
+ ::PromptManager::Prompt.new(id: id, context: [], directives_processor: PromptManager::DirectiveProcessor.new)
41
29
  end
42
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
43
39
 
44
40
  def search(for_what)
45
41
  storage_adapter.search(for_what)
46
42
  end
47
43
 
48
-
49
44
  def method_missing(method_name, *args, &block)
50
45
  if storage_adapter.respond_to?(method_name)
51
46
  storage_adapter.send(method_name, *args, &block)
@@ -54,176 +49,108 @@ class PromptManager::Prompt
54
49
  end
55
50
  end
56
51
 
57
-
58
52
  def respond_to_missing?(method_name, include_private = false)
59
53
  storage_adapter.respond_to?(method_name, include_private) || super
60
54
  end
61
55
  end
62
-
63
- # SMELL: Does the db (aka storage adapter) really need
64
- # to be accessible by the main program?
65
- 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
66
63
 
67
64
 
68
- # Retrieve the specific prompt ID from the Storage system.
69
65
  def initialize(
70
- id: nil, # A String name for the prompt
71
- 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
72
72
  )
73
73
 
74
74
  @id = id
75
- @db = self.class.storage_adapter
76
-
77
- validate_arguments(@id, @db)
75
+ @directives_processor = directives_processor
78
76
 
79
- @record = db.get(id: id)
80
- @text = @record[:text]
81
- @parameters = @record[:parameters]
82
- @keywords = [] # Array of String
83
- @directives = [] # Array of arrays. directive is first entry, rest are parameters
77
+ validate_arguments(@id)
84
78
 
85
- update_keywords
86
-
87
- 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
88
86
  end
89
87
 
90
-
91
- # Make sure the ID and DB are good-to-go
92
- def validate_arguments(prompt_id, prompts_db)
93
- 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?
94
90
  raise(ArgumentError, 'storage_adapter is not set') if prompts_db.nil?
95
91
  end
96
92
 
97
-
98
- # Return tje prompt text suitable for passing to a
99
- # gen-AI process.
100
93
  def to_s
101
- 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)
102
99
  end
103
- alias_method :prompt, :to_s
104
-
105
100
 
106
- # Save the prompt to the Storage system
107
101
  def save
108
102
  db.save(
109
- id: id,
110
- text: text,
103
+ id: id,
104
+ text: text, # Save the original text
111
105
  parameters: parameters
112
- )
113
- end
114
-
115
-
116
- # Delete this prompt from the Storage system
117
- def delete
118
- db.delete(id: id)
119
- end
120
-
121
-
122
- # Build the @prompt String by replacing the keywords
123
- # with there parameterized values and removing all
124
- # the comments.
125
- #
126
- def build
127
- @prompt = text.gsub(self.class.parameter_regex) do |match|
128
- param_name = match
129
- Array(parameters[param_name]).last || match
130
- end
131
-
132
- save_directives(@prompt)
133
- remove_comments
106
+ )
134
107
  end
135
108
 
136
-
137
- def keywords
138
- update_keywords
139
- end
109
+ def delete = db.delete(id: id)
140
110
 
141
111
 
142
112
  ######################################
143
113
  private
144
114
 
145
- def update_keywords
146
- @keywords = @text.scan(self.class.parameter_regex).flatten.uniq
147
- @keywords.each do |kw|
148
- @parameters[kw] = [] unless @parameters.has_key?(kw)
149
- end
115
+ def db = self.class.storage_adapter
150
116
 
151
- @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")
152
121
  end
153
122
 
154
-
155
- def save_directives(keyword_substituted_string)
156
- @directives = []
157
-
158
- keyword_substituted_string.split("\n").each do |a_line|
159
- line = a_line.strip
160
- next unless line.start_with?(DIRECTIVE_SIGNAL)
161
-
162
- parts = line.split(' ')
163
- directive = parts.shift[DIRECTIVE_SIGNAL.length..] # drop the directive signal
164
- @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
165
128
  end
166
-
167
- @directives
129
+ input_text
168
130
  end
169
131
 
132
+ def erb? = @erb_flag
133
+ def envvar? = @envvar_flag
170
134
 
171
- def remove_comments
172
- lines = @prompt
173
- .split("\n")
174
- .reject{|a_line|
175
- a_line.strip.start_with?(COMMENT_SIGNAL) ||
176
- a_line.strip.start_with?(DIRECTIVE_SIGNAL)
177
- }
178
-
179
- # Remove empty lines at the start of the prompt
180
- #
181
- lines = lines.drop_while(&:empty?)
182
-
183
- # Drop all the lines at __END__ and after
184
- #
185
- logical_end_inx = lines.index("__END__")
186
-
187
- @prompt = if logical_end_inx
188
- lines[0...logical_end_inx] # NOTE: ... means to not include last index
189
- else
190
- lines
191
- end.join("\n")
192
- end
193
-
194
-
195
- # Handle storage errors
196
- # SMELL: Just raise them or get out of their way and let the
197
- # main program do tje job.
198
- def handle_storage_error(error)
199
- # Log the error message, notify, or take appropriate action
200
- log_error("Storage operation failed: #{error.message}")
201
- # Re-raise the error if necessary, or define recovery steps
202
- raise error
203
- end
204
-
135
+ def substitute_env_vars(input_text)
136
+ return input_text unless envvar?
205
137
 
206
- # Let the storage adapter instance take a crake at
207
- # these unknown methods. Don't care what the args
208
- # are, just pass the prompt's ID.
209
- def method_missing(method_name, *args, &block)
210
- if db.respond_to?(method_name)
211
- db.send(method_name, id, &block)
212
- else
213
- super
138
+ input_text.gsub(/\$(\w+)|\$\{(\w+)\}/) do |match|
139
+ env_var = $1 || $2
140
+ ENV[env_var] || match
214
141
  end
215
142
  end
216
143
 
217
-
218
- def respond_to_missing?(method_name, include_private = false)
219
- 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)
220
149
  end
221
150
 
151
+ def process_erb(input_text)
152
+ return input_text unless erb?
222
153
 
223
- # SMELL: should this gem log errors or is that a function of
224
- # main program? I believe its the main program's job.
225
- def log_error(message)
226
- puts "ERROR: #{message}"
154
+ ERB.new(input_text).result(@external_binding)
227
155
  end
228
156
  end
229
-
@@ -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
-
@@ -3,36 +3,45 @@
3
3
  # Use the local (or remote) file system as a place to
4
4
  # store and access prompts.
5
5
  #
6
- # Adds two additional methods to the Promp class:
6
+ # Adds two additional methods to the Prompt class:
7
7
  # list - returns Array of prompt IDs
8
- # path = returns a Pathname object to the prompt's text file
8
+ # path - returns a Pathname object to the prompt's text file
9
9
  # path(prompt_id) - same as path on the prompt instance
10
10
  #
11
11
  # Allows sub-directories of the prompts_dir to be
12
- # used like categories. For example the prompt_id "toy/magic"
12
+ # used like categories. For example the prompt_id "toy/magic"
13
13
  # is found in the `magic.txt` file inside the `toy` sub-directory
14
14
  # of the prompts_dir.
15
15
  #
16
- # There can man be many layers of categories (sub-directories)
16
+ # There can be many layers of categories (sub-directories)
17
17
  #
18
+ # This adapter serves as the file-based storage backend for PromptManager::Prompt,
19
+ # enabling prompt retrieval and persistence using file system operations.
18
20
 
19
21
  require 'json' # basic serialization of parameters
20
22
  require 'pathname'
23
+ require 'fileutils'
21
24
 
22
25
  class PromptManager::Storage::FileSystemAdapter
23
- SEARCH_PROC = nil # placeholder
26
+ # Placeholder for search proc
27
+ SEARCH_PROC = nil
28
+ # File extension for parameters
24
29
  PARAMS_EXTENSION = '.json'.freeze
30
+ # File extension for prompts
25
31
  PROMPT_EXTENSION = '.txt'.freeze
32
+ # Regular expression for valid prompt IDs
26
33
  PROMPT_ID_FORMAT = /^[a-zA-Z0-9\-\/_]+$/
27
34
 
28
35
  class << self
29
- attr_accessor :prompts_dir, :search_proc,
36
+ # Accessors for configuration options
37
+ attr_accessor :prompts_dir, :search_proc,
30
38
  :params_extension, :prompt_extension
31
-
39
+
40
+ # Configure the adapter
32
41
  def config
33
42
  if block_given?
34
43
  yield self
35
- validate_configuration
44
+ validate_configuration
36
45
  else
37
46
  raise ArgumentError, "No block given to config"
38
47
  end
@@ -48,7 +57,6 @@ class PromptManager::Storage::FileSystemAdapter
48
57
  new.list
49
58
  end
50
59
 
51
-
52
60
  def path(prompt_id)
53
61
  new.path(prompt_id)
54
62
  end
@@ -56,6 +64,7 @@ class PromptManager::Storage::FileSystemAdapter
56
64
  #################################################
57
65
  private
58
66
 
67
+ # Validate the configuration
59
68
  def validate_configuration
60
69
  validate_prompts_dir
61
70
  validate_search_proc
@@ -63,28 +72,33 @@ class PromptManager::Storage::FileSystemAdapter
63
72
  validate_params_extension
64
73
  end
65
74
 
66
-
75
+ # Validate the prompts directory
67
76
  def validate_prompts_dir
68
- # This is a work around for a Ruby scope issue where the
69
- # class getter/setter method is becoming confused with a
70
- # local variable when anything other than plain 'ol get and
71
- # set are used.'This error is in both Ruby v3.2.2 and
77
+ # This is a work around for a Ruby scope issue where the
78
+ # class getter/setter method is becoming confused with a
79
+ # local variable when anything other than plain 'ol get and
80
+ # set are used. This error is in both Ruby v3.2.2 and
72
81
  # v3.3.0-preview3.
73
82
  #
74
83
  prompts_dir_local = self.prompts_dir
75
84
 
85
+ raise ArgumentError, "prompts_dir must be set" if prompts_dir_local.nil? || prompts_dir_local.to_s.strip.empty?
86
+
76
87
  unless prompts_dir_local.is_a?(Pathname)
77
- prompts_dir_local = Pathname.new(prompts_dir_local) unless prompts_dir_local.nil?
88
+ prompts_dir_local = Pathname.new(prompts_dir_local)
78
89
  end
79
90
 
80
91
  prompts_dir_local = prompts_dir_local.expand_path
81
92
 
82
- raise(ArgumentError, "prompts_dir: #{prompts_dir_local}") unless prompts_dir_local.exist? && prompts_dir_local.directory?
83
-
93
+ unless prompts_dir_local.exist?
94
+ FileUtils.mkdir_p(prompts_dir_local)
95
+ end
96
+ raise(ArgumentError, "prompts_dir: #{prompts_dir_local} is not a directory") unless prompts_dir_local.directory?
97
+
84
98
  self.prompts_dir = prompts_dir_local
85
99
  end
86
100
 
87
-
101
+ # Validate the search proc
88
102
  def validate_search_proc
89
103
  search_proc_local = self.search_proc
90
104
 
@@ -97,7 +111,7 @@ class PromptManager::Storage::FileSystemAdapter
97
111
  self.search_proc = search_proc_local
98
112
  end
99
113
 
100
-
114
+ # Validate the prompt extension
101
115
  def validate_prompt_extension
102
116
  prompt_extension_local = self.prompt_extension
103
117
 
@@ -113,7 +127,7 @@ class PromptManager::Storage::FileSystemAdapter
113
127
  self.prompt_extension = prompt_extension_local
114
128
  end
115
129
 
116
-
130
+ # Validate the params extension
117
131
  def validate_params_extension
118
132
  params_extension_local = self.params_extension
119
133
 
@@ -130,25 +144,25 @@ class PromptManager::Storage::FileSystemAdapter
130
144
  end
131
145
  end
132
146
 
133
-
134
147
  ##################################################
135
148
  ###
136
149
  ## Instance
137
150
  #
138
151
 
152
+ # Accessors for instance variables
139
153
  def prompts_dir = self.class.prompts_dir
140
154
  def search_proc = self.class.search_proc
141
155
  def prompt_extension = self.class.prompt_extension
142
156
  def params_extension = self.class.params_extension
143
157
 
144
-
158
+ # Initialize the adapter
145
159
  def initialize
146
160
  # NOTE: validate because main program may have made
147
161
  # changes outside of the config block
148
162
  self.class.send(:validate_configuration) # send gets around private designations of a method
149
163
  end
150
164
 
151
-
165
+ # Get a prompt by ID
152
166
  def get(id:)
153
167
  validate_id(id)
154
168
  verify_id(id)
@@ -160,17 +174,15 @@ class PromptManager::Storage::FileSystemAdapter
160
174
  }
161
175
  end
162
176
 
163
-
164
177
  # Retrieve prompt text by its id
165
178
  def prompt_text(prompt_id)
166
179
  read_file(file_path(prompt_id, prompt_extension))
167
180
  end
168
181
 
169
-
170
182
  # Retrieve parameter values by its id
171
183
  def parameter_values(prompt_id)
172
184
  params_path = file_path(prompt_id, params_extension)
173
-
185
+
174
186
  if params_path.exist?
175
187
  parms_content = read_file(params_path)
176
188
  deserialize(parms_content)
@@ -179,35 +191,33 @@ class PromptManager::Storage::FileSystemAdapter
179
191
  end
180
192
  end
181
193
 
182
-
183
194
  # Save prompt text and parameter values to corresponding files
184
195
  def save(
185
- id:,
186
- text: "",
196
+ id:,
197
+ text: "",
187
198
  parameters: {}
188
199
  )
189
200
  validate_id(id)
190
201
 
191
202
  prompt_filepath = file_path(id, prompt_extension)
192
203
  params_filepath = file_path(id, params_extension)
193
-
204
+
194
205
  write_with_error_handling(prompt_filepath, text)
195
206
  write_with_error_handling(params_filepath, serialize(parameters))
196
207
  end
197
208
 
198
-
199
- # Delete prompted text and parameter values files
209
+ # Delete prompt text and parameter values files
200
210
  def delete(id:)
201
211
  validate_id(id)
202
212
 
203
213
  prompt_filepath = file_path(id, prompt_extension)
204
214
  params_filepath = file_path(id, params_extension)
205
-
215
+
206
216
  delete_with_error_handling(prompt_filepath)
207
217
  delete_with_error_handling(params_filepath)
208
218
  end
209
219
 
210
-
220
+ # Search for prompts
211
221
  def search(for_what)
212
222
  search_term = for_what.downcase
213
223
 
@@ -218,25 +228,23 @@ class PromptManager::Storage::FileSystemAdapter
218
228
  end
219
229
  end
220
230
 
221
-
222
231
  # Return an Array of prompt IDs
223
232
  def list(*)
224
233
  prompt_ids = []
225
-
234
+
226
235
  Pathname.glob(prompts_dir.join("**/*#{prompt_extension}")).each do |file_path|
227
236
  prompt_id = file_path.relative_path_from(prompts_dir).to_s.gsub(prompt_extension, '')
228
237
  prompt_ids << prompt_id
229
238
  end
230
239
 
231
- prompt_ids
240
+ prompt_ids.sort
232
241
  end
233
242
 
234
-
235
243
  # Returns a Pathname object for a prompt ID text file
236
244
  # However, it is possible that the file does not exist.
237
245
  def path(id)
238
246
  validate_id(id)
239
- file_path(id, prompt_extension)
247
+ file_path(id, prompt_extension)
240
248
  end
241
249
 
242
250
  ##########################################
@@ -247,14 +255,14 @@ class PromptManager::Storage::FileSystemAdapter
247
255
  raise ArgumentError, "Invalid ID format id: #{id}" unless id =~ PROMPT_ID_FORMAT
248
256
  end
249
257
 
250
-
258
+ # Verify that the ID exists
251
259
  def verify_id(id)
252
260
  unless file_path(id, prompt_extension).exist?
253
261
  raise ArgumentError, "Invalid prompt_id: #{id}"
254
262
  end
255
263
  end
256
264
 
257
-
265
+ # Write to a file with error handling
258
266
  def write_with_error_handling(file_path, content)
259
267
  begin
260
268
  file_path.write content
@@ -264,8 +272,7 @@ class PromptManager::Storage::FileSystemAdapter
264
272
  end
265
273
  end
266
274
 
267
-
268
- # file_path (Pathname)
275
+ # Delete a file with error handling
269
276
  def delete_with_error_handling(file_path)
270
277
  begin
271
278
  file_path.delete
@@ -275,39 +282,37 @@ class PromptManager::Storage::FileSystemAdapter
275
282
  end
276
283
  end
277
284
 
278
-
285
+ # Get the file path for a prompt ID and extension
279
286
  def file_path(id, extension)
280
287
  prompts_dir + "#{id}#{extension}"
281
288
  end
282
289
 
283
-
290
+ # Read a file
284
291
  def read_file(full_path)
285
292
  raise IOError, 'File does not exist' unless File.exist?(full_path)
286
293
  File.read(full_path)
287
294
  end
288
295
 
289
-
296
+ # Search for prompts
290
297
  def search_prompts(search_term)
291
298
  prompt_ids = []
292
-
299
+
293
300
  Pathname.glob(prompts_dir.join("**/*#{prompt_extension}")).each do |prompt_path|
294
301
  if prompt_path.read.downcase.include?(search_term)
295
- prompt_id = prompt_path.relative_path_from(prompts_dir).to_s.gsub(prompt_extension, '')
302
+ prompt_id = prompt_path.relative_path_from(prompts_dir).to_s.gsub(prompt_extension, '')
296
303
  prompt_ids << prompt_id
297
304
  end
298
305
  end
299
306
 
300
- prompt_ids
307
+ prompt_ids.sort
301
308
  end
302
309
 
303
-
304
- # TODO: Should the serializer be generic?
305
-
310
+ # Serialize data to JSON
306
311
  def serialize(data)
307
312
  data.to_json
308
313
  end
309
314
 
310
-
315
+ # Deserialize JSON data
311
316
  def deserialize(data)
312
317
  JSON.parse(data)
313
318
  end
@@ -1,7 +1,34 @@
1
1
  # prompt_manager/lib/prompt_manager/storage.rb
2
2
 
3
+ # The Storage module provides a namespace for different storage adapters
4
+ # that handle persistence of prompts. Each adapter implements a common
5
+ # interface for saving, retrieving, searching, and deleting prompts.
6
+ #
7
+ # Available adapters:
8
+ # - FileSystemAdapter: Stores prompts in text files on the local filesystem
9
+ # - ActiveRecordAdapter: Stores prompts in a database using ActiveRecord
10
+ #
11
+ # To use an adapter, configure it before using PromptManager::
12
+ #
13
+ # Example with FileSystemAdapter:
14
+ # PromptManager::Storage::FileSystemAdapter.config do |config|
15
+ # config.prompts_dir = Pathname.new('/path/to/prompts')
16
+ # end
17
+ # PromptManager::Prompt.storage_adapter = PromptManager::Storage::FileSystemAdapter.new
18
+ #
19
+ # Example with ActiveRecordAdapter:
20
+ # PromptManager::Storage::ActiveRecordAdapter.config do |config|
21
+ # config.model = MyPromptModel
22
+ # config.id_column = :prompt_id
23
+ # config.text_column = :content
24
+ # config.parameters_column = :params
25
+ # end
26
+ # PromptManager::Prompt.storage_adapter = PromptManager::Storage::ActiveRecordAdapter.new
3
27
  module PromptManager
28
+ # The Storage module provides adapters for different storage backends.
29
+ # Each adapter implements a common interface for managing prompts.
30
+ # Note: PromptManager::Prompt uses one of these adapters as its storage backend to
31
+ # perform all CRUD operations on prompt data.
4
32
  module Storage
5
33
  end
6
34
  end
7
-
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PromptManager
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -5,10 +5,20 @@
5
5
  require 'ostruct'
6
6
 
7
7
  require_relative "prompt_manager/version"
8
- require_relative "prompt_manager/storage"
9
8
  require_relative "prompt_manager/prompt"
9
+ require_relative "prompt_manager/storage"
10
+ require_relative "prompt_manager/storage/file_system_adapter"
10
11
 
12
+ # The PromptManager module provides functionality for managing, storing,
13
+ # retrieving, and parameterizing text prompts used with generative AI systems.
14
+ # It supports different storage backends through adapters and offers features
15
+ # like parameter substitution, directives processing, and comment handling.
11
16
  module PromptManager
17
+ # Base error class for all PromptManager-specific errors
12
18
  class Error < StandardError; end
13
- # TODO: Add some more module specific errors here
19
+
20
+ # TODO: Add additional module-specific error classes such as:
21
+ # - StorageError - For issues with storing or retrieving prompts
22
+ # - ParameterError - For issues with parameter substitution
23
+ # - ConfigurationError - For setup and configuration issues
14
24
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompt_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-10-26 00:00:00.000000000 Z
10
+ date: 2025-03-30 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -139,7 +138,6 @@ files:
139
138
  - README.md
140
139
  - Rakefile
141
140
  - examples/directives.rb
142
- - examples/prompts_dir/directive_example.txt
143
141
  - examples/prompts_dir/todo.json
144
142
  - examples/prompts_dir/todo.txt
145
143
  - examples/prompts_dir/toy/8-ball.txt
@@ -147,6 +145,7 @@ files:
147
145
  - examples/simple.rb
148
146
  - examples/using_search_proc.rb
149
147
  - lib/prompt_manager.rb
148
+ - lib/prompt_manager/directive_processor.rb
150
149
  - lib/prompt_manager/prompt.rb
151
150
  - lib/prompt_manager/storage.rb
152
151
  - lib/prompt_manager/storage/active_record_adapter.rb
@@ -160,7 +159,6 @@ metadata:
160
159
  homepage_uri: https://github.com/MadBomber/prompt_manager
161
160
  source_code_uri: https://github.com/MadBomber/prompt_manager
162
161
  changelog_uri: https://github.com/MadBomber/prompt_manager
163
- post_install_message:
164
162
  rdoc_options: []
165
163
  require_paths:
166
164
  - lib
@@ -175,8 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
175
173
  - !ruby/object:Gem::Version
176
174
  version: '0'
177
175
  requirements: []
178
- rubygems_version: 3.5.22
179
- signing_key:
176
+ rubygems_version: 3.6.6
180
177
  specification_version: 4
181
178
  summary: Manage prompts for use with gen-AI processes
182
179
  test_files: []
@@ -1,14 +0,0 @@
1
- # directive_example.txt
2
- # Desc: Shows how directives work
3
-
4
- //good_directive param1 param2
5
- //bad_directive param3 param4
6
-
7
- say hello to me in {language} as well as English.
8
-
9
- # The default parameter is delimited by square brackets
10
- # and is all uppercase
11
-
12
- write a [PROGRAMMING LANGUAGE] program that predicts the lottery.
13
-
14
-