smart_prompt 0.4.4 → 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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -10
  3. data/README.cn.md +307 -64
  4. data/README.md +311 -64
  5. data/Rakefile +10 -1
  6. data/config/anthropic_config.yml +151 -0
  7. data/config/image_generation_config.yml +22 -0
  8. data/config/multimodal_config.yml +85 -0
  9. data/config/sensenova_config.yml +63 -0
  10. data/config/zhipu_config.yml +73 -0
  11. data/examples/anthropic_basic_chat.rb +143 -0
  12. data/examples/anthropic_example.rb +232 -0
  13. data/examples/anthropic_multimodal.rb +212 -0
  14. data/examples/anthropic_streaming.rb +312 -0
  15. data/examples/anthropic_tool_calling.rb +393 -0
  16. data/examples/automatic_cleanup_example.rb +109 -0
  17. data/examples/history_management_examples.rb +522 -0
  18. data/examples/image_generation_example.rb +130 -0
  19. data/examples/monitoring_example.rb +121 -0
  20. data/examples/multimodal_example.rb +63 -0
  21. data/examples/relevance_based_strategy_example.rb +87 -0
  22. data/examples/sensenova_example.rb +129 -0
  23. data/examples/stt_example.rb +287 -0
  24. data/examples/tts_example.rb +244 -0
  25. data/examples/video_generation_example.rb +189 -0
  26. data/examples/zhipu_example.rb +151 -0
  27. data/lib/smart_prompt/anthropic_adapter.rb +363 -281
  28. data/lib/smart_prompt/compression_engine.rb +201 -0
  29. data/lib/smart_prompt/context_strategy.rb +22 -0
  30. data/lib/smart_prompt/conversation.rb +81 -191
  31. data/lib/smart_prompt/engine.rb +36 -19
  32. data/lib/smart_prompt/history_manager.rb +596 -0
  33. data/lib/smart_prompt/hybrid_strategy.rb +222 -0
  34. data/lib/smart_prompt/image_generation_adapter.rb +297 -0
  35. data/lib/smart_prompt/lru_cache.rb +133 -0
  36. data/lib/smart_prompt/message.rb +57 -0
  37. data/lib/smart_prompt/multimodal_adapter.rb +277 -0
  38. data/lib/smart_prompt/openai_adapter.rb +1 -25
  39. data/lib/smart_prompt/persistence_layer.rb +197 -0
  40. data/lib/smart_prompt/relevance_based_strategy.rb +221 -0
  41. data/lib/smart_prompt/sensenova_adapter.rb +410 -0
  42. data/lib/smart_prompt/session.rb +140 -0
  43. data/lib/smart_prompt/sliding_window_strategy.rb +100 -0
  44. data/lib/smart_prompt/stt_adapter.rb +381 -0
  45. data/lib/smart_prompt/summary_based_strategy.rb +152 -0
  46. data/lib/smart_prompt/token_counter.rb +74 -0
  47. data/lib/smart_prompt/tts_adapter.rb +403 -0
  48. data/lib/smart_prompt/version.rb +1 -1
  49. data/lib/smart_prompt/video_generation_adapter.rb +330 -0
  50. data/lib/smart_prompt/worker.rb +25 -3
  51. data/lib/smart_prompt/zhipu_adapter.rb +616 -0
  52. data/lib/smart_prompt.rb +22 -2
  53. data/workers/history_management_examples.rb +407 -0
  54. data/workers/image_generation_workers.rb +119 -0
  55. data/workers/multimodal_workers.rb +110 -0
  56. data/workers/sensenova_workers.rb +62 -0
  57. data/workers/stt_workers.rb +195 -0
  58. data/workers/tts_workers.rb +388 -0
  59. data/workers/video_generation_workers.rb +264 -0
  60. data/workers/zhipu_workers.rb +113 -0
  61. metadata +84 -8
@@ -0,0 +1,57 @@
1
+ require 'time'
2
+
3
+ module SmartPrompt
4
+ # Message represents a single message in a conversation history
5
+ # It contains role, content, timestamp, and metadata
6
+ class Message
7
+ attr_reader :role, :content, :timestamp, :metadata, :token_count
8
+ attr_accessor :importance_score, :is_summary
9
+
10
+ def initialize(data)
11
+ @role = data[:role] || data["role"]
12
+ @content = data[:content] || data["content"]
13
+ @timestamp = parse_timestamp(data[:timestamp] || data["timestamp"])
14
+ @metadata = data[:metadata] || data["metadata"] || {}
15
+ @token_count = nil # Lazy calculation
16
+ @importance_score = data[:importance_score] || data["importance_score"]
17
+ @is_summary = data[:is_summary] || data["is_summary"] || false
18
+ end
19
+
20
+ # Calculate token count using provided counter
21
+ def calculate_tokens(counter)
22
+ @token_count ||= counter.count(@content)
23
+ end
24
+
25
+ # Check if this is a system message
26
+ def system_message?
27
+ @role == "system" || @role == :system
28
+ end
29
+
30
+ # Convert message to hash format
31
+ def to_h
32
+ {
33
+ role: @role,
34
+ content: @content,
35
+ timestamp: @timestamp.iso8601,
36
+ metadata: @metadata,
37
+ importance_score: @importance_score,
38
+ is_summary: @is_summary
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def parse_timestamp(timestamp)
45
+ case timestamp
46
+ when Time
47
+ timestamp
48
+ when String
49
+ Time.parse(timestamp)
50
+ when nil
51
+ Time.now
52
+ else
53
+ Time.now
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,277 @@
1
+ require "openai"
2
+ require "base64"
3
+
4
+ module SmartPrompt
5
+ class MultimodalAdapter < LLMAdapter
6
+ SUPPORTED_IMAGE_FORMATS = %w[jpg jpeg png gif bmp webp]
7
+ SUPPORTED_VIDEO_FORMATS = %w[mp4 mov avi mkv webm]
8
+
9
+ def initialize(config)
10
+ super
11
+ api_key = @config["api_key"]
12
+ if api_key.is_a?(String) && api_key.start_with?("ENV[") && api_key.end_with?("]")
13
+ api_key = eval(api_key)
14
+ end
15
+ begin
16
+ @client = OpenAI::Client.new(
17
+ access_token: api_key,
18
+ uri_base: @config["url"],
19
+ request_timeout: 240,
20
+ )
21
+ rescue OpenAI::ConfigurationError => e
22
+ SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
23
+ raise LLMAPIError, "Invalid Multimodal configuration: #{e.message}"
24
+ rescue OpenAI::Error => e
25
+ SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
26
+ raise LLMAPIError, "Multimodal authentication failed: #{e.message}"
27
+ rescue SocketError => e
28
+ SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
29
+ raise LLMAPIError, "Network error: Unable to connect to Multimodal API"
30
+ rescue => e
31
+ SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
32
+ raise Error, "Unexpected error initializing Multimodal client: #{e.message}"
33
+ ensure
34
+ SmartPrompt.logger.info "Successfully created a Multimodal client."
35
+ end
36
+ end
37
+
38
+ def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil)
39
+ SmartPrompt.logger.info "MultimodalAdapter: Sending multimodal request"
40
+
41
+ # Process messages to handle multimodal content
42
+ processed_messages = process_multimodal_messages(messages)
43
+
44
+ temperature = 0.7 if temperature.nil?
45
+ model_name = model || @config["model"]
46
+
47
+ SmartPrompt.logger.info "MultimodalAdapter: Using model #{model_name}"
48
+
49
+ begin
50
+ parameters = {
51
+ model: model_name,
52
+ messages: processed_messages,
53
+ temperature: @config["temperature"] || temperature,
54
+ }
55
+
56
+ if proc
57
+ parameters[:stream] = proc
58
+ end
59
+
60
+ if tools
61
+ parameters[:tools] = tools
62
+ end
63
+
64
+ SmartPrompt.logger.info "Send parameters is: #{parameters}"
65
+ response = @client.chat(parameters: parameters)
66
+
67
+ rescue OpenAI::Error => e
68
+ SmartPrompt.logger.error "Multimodal API error: #{e.message}"
69
+ raise LLMAPIError, "Multimodal API error: #{e.message}"
70
+ rescue OpenAI::MiddlewareErrors => e
71
+ SmartPrompt.logger.error "Multimodal HTTP Error: #{e.message}"
72
+ raise LLMAPIError, "Multimodal HTTP Error"
73
+ rescue JSON::ParserError => e
74
+ SmartPrompt.logger.error "Failed to parse Multimodal API response"
75
+ raise LLMAPIError, "Failed to parse Multimodal API response"
76
+ rescue => e
77
+ SmartPrompt.logger.error "Unexpected error during Multimodal request: #{e.message}"
78
+ raise Error, "Unexpected error during Multimodal request: #{e.message}"
79
+ ensure
80
+ SmartPrompt.logger.info "Successfully sent multimodal message"
81
+ end
82
+
83
+ SmartPrompt.logger.info "MultimodalAdapter: Received response from Multimodal API"
84
+
85
+ if proc.nil?
86
+ @last_response = response
87
+ return response.dig("choices", 0, "message", "content")
88
+ end
89
+ end
90
+
91
+ # Analyze image with text prompt
92
+ def analyze_image(image_input, prompt, model = nil, detail: "auto", max_tokens: nil)
93
+ SmartPrompt.logger.info "MultimodalAdapter: Analyzing image"
94
+
95
+ messages = [
96
+ {
97
+ role: "user",
98
+ content: [
99
+ { type: "text", text: prompt },
100
+ { type: "image_url", image_url: prepare_image_input(image_input, detail) }
101
+ ]
102
+ }
103
+ ]
104
+
105
+ model_name = model || @config["model"]
106
+ parameters = {
107
+ model: model_name,
108
+ messages: messages,
109
+ temperature: @config["temperature"] || 0.7,
110
+ }
111
+
112
+ parameters[:max_tokens] = max_tokens if max_tokens
113
+
114
+ response = @client.chat(parameters: parameters)
115
+ @last_response = response
116
+ response.dig("choices", 0, "message", "content")
117
+ end
118
+
119
+ # Analyze video with text prompt
120
+ def analyze_video(video_input, prompt, model = nil, max_frames: 10, fps: 1, detail: "auto")
121
+ SmartPrompt.logger.info "MultimodalAdapter: Analyzing video"
122
+
123
+ messages = [
124
+ {
125
+ role: "user",
126
+ content: [
127
+ { type: "text", text: prompt },
128
+ { type: "video_url", video_url: prepare_video_input(video_input, max_frames, fps, detail) }
129
+ ]
130
+ }
131
+ ]
132
+
133
+ model_name = model || @config["model"]
134
+ response = @client.chat(parameters: {
135
+ model: model_name,
136
+ messages: messages,
137
+ temperature: @config["temperature"] || 0.7,
138
+ })
139
+
140
+ @last_response = response
141
+ response.dig("choices", 0, "message", "content")
142
+ end
143
+
144
+ # Multi-image analysis
145
+ def analyze_multiple_images(images, prompt, model = nil, detail: "auto")
146
+ SmartPrompt.logger.info "MultimodalAdapter: Analyzing multiple images"
147
+
148
+ content = [{ type: "text", text: prompt }]
149
+ images.each do |image_input|
150
+ content << { type: "image_url", image_url: prepare_image_input(image_input, detail) }
151
+ end
152
+
153
+ messages = [{ role: "user", content: content }]
154
+
155
+ model_name = model || @config["model"]
156
+ response = @client.chat(parameters: {
157
+ model: model_name,
158
+ messages: messages,
159
+ temperature: @config["temperature"] || 0.7,
160
+ })
161
+
162
+ @last_response = response
163
+ response.dig("choices", 0, "message", "content")
164
+ end
165
+
166
+ private
167
+
168
+ def process_multimodal_messages(messages)
169
+ messages.map do |message|
170
+ if message[:content].is_a?(Array)
171
+ # Process content array with multimodal elements
172
+ processed_content = message[:content].map do |content_item|
173
+ if content_item.is_a?(Hash)
174
+ case content_item[:type]
175
+ when "image_url"
176
+ { type: "image_url", image_url: prepare_image_input(content_item[:image_url], content_item[:detail]) }
177
+ when "video_url"
178
+ { type: "video_url", video_url: prepare_video_input(content_item[:video_url], content_item[:max_frames], content_item[:fps], content_item[:detail]) }
179
+ else
180
+ content_item
181
+ end
182
+ else
183
+ { type: "text", text: content_item.to_s }
184
+ end
185
+ end
186
+ { role: message[:role], content: processed_content }
187
+ else
188
+ message
189
+ end
190
+ end
191
+ end
192
+
193
+ def prepare_image_input(image_input, detail = "auto")
194
+ detail ||= "auto"
195
+
196
+ case image_input
197
+ when String
198
+ if image_input.start_with?("http://", "https://")
199
+ { url: image_input, detail: detail }
200
+ elsif File.exist?(image_input)
201
+ # Convert local file to base64
202
+ file_ext = File.extname(image_input).downcase.delete(".")
203
+ unless SUPPORTED_IMAGE_FORMATS.include?(file_ext)
204
+ raise Error, "Unsupported image format: #{file_ext}"
205
+ end
206
+
207
+ image_data = File.binread(image_input)
208
+ base64_data = Base64.strict_encode64(image_data)
209
+ mime_type = "image/#{file_ext == 'jpg' ? 'jpeg' : file_ext}"
210
+
211
+ { url: "data:#{mime_type};base64,#{base64_data}", detail: detail }
212
+ else
213
+ raise Error, "Invalid image input: #{image_input}"
214
+ end
215
+ when Hash
216
+ # Assume it's already formatted
217
+ image_input[:detail] ||= detail
218
+ image_input
219
+ else
220
+ raise Error, "Unsupported image input type: #{image_input.class}"
221
+ end
222
+ end
223
+
224
+ def prepare_video_input(video_input, max_frames = 10, fps = 1, detail = "auto")
225
+ max_frames ||= 10
226
+ fps ||= 1
227
+ detail ||= "auto"
228
+
229
+ case video_input
230
+ when String
231
+ if video_input.start_with?("http://", "https://")
232
+ {
233
+ url: video_input,
234
+ detail: detail,
235
+ max_frames: max_frames,
236
+ fps: fps
237
+ }
238
+ elsif File.exist?(video_input)
239
+ # For local files, we'd need to upload or convert
240
+ # Currently only support URLs for videos
241
+ raise Error, "Local video files not yet supported. Please provide a URL."
242
+ else
243
+ raise Error, "Invalid video input: #{video_input}"
244
+ end
245
+ when Hash
246
+ # Assume it's already formatted
247
+ video_input[:max_frames] ||= max_frames
248
+ video_input[:fps] ||= fps
249
+ video_input[:detail] ||= detail
250
+ video_input
251
+ else
252
+ raise Error, "Unsupported video input type: #{video_input.class}"
253
+ end
254
+ end
255
+
256
+ def embeddings(text, model)
257
+ SmartPrompt.logger.info "MultimodalAdapter: Getting embeddings"
258
+
259
+ model_name = model || @config["model"]
260
+ begin
261
+ response = @client.embeddings(
262
+ parameters: {
263
+ model: model_name,
264
+ input: text.to_s,
265
+ },
266
+ )
267
+ rescue => e
268
+ SmartPrompt.logger.error "Unexpected error during embeddings request: #{e.message}"
269
+ raise Error, "Unexpected error during embeddings request: #{e.message}"
270
+ ensure
271
+ SmartPrompt.logger.info "Successfully got embeddings"
272
+ end
273
+
274
+ response.dig("data", 0, "embedding")
275
+ end
276
+ end
277
+ end
@@ -31,19 +31,7 @@ module SmartPrompt
31
31
  end
32
32
  end
33
33
 
34
- REQUEST_PARAMETER_KEYS = %w[
35
- max_tokens
36
- max_completion_tokens
37
- top_p
38
- top_k
39
- response_format
40
- tool_choice
41
- parallel_tool_calls
42
- seed
43
- stop
44
- ].freeze
45
-
46
- def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil, request_options = {})
34
+ def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil)
47
35
  SmartPrompt.logger.info "OpenAIAdapter: Sending request to OpenAI"
48
36
  temperature = 0.7 if temperature == nil
49
37
  if model
@@ -58,8 +46,6 @@ module SmartPrompt
58
46
  messages: messages,
59
47
  temperature: @config["temperature"] || temperature,
60
48
  }
61
- parameters.merge!(configured_request_parameters)
62
- parameters.merge!(request_options || {})
63
49
  if proc
64
50
  parameters[:stream] = proc
65
51
  end
@@ -113,15 +99,5 @@ module SmartPrompt
113
99
  end
114
100
  return response.dig("data", 0, "embedding")
115
101
  end
116
-
117
- private
118
-
119
- def configured_request_parameters
120
- REQUEST_PARAMETER_KEYS.each_with_object({}) do |key, parameters|
121
- next unless @config.key?(key)
122
-
123
- parameters[key.to_sym] = @config[key]
124
- end
125
- end
126
102
  end
127
103
  end
@@ -0,0 +1,197 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require 'thread'
4
+
5
+ module SmartPrompt
6
+ # AsyncWriter handles asynchronous write operations to avoid blocking
7
+ class AsyncWriter
8
+ def initialize
9
+ @queue = Queue.new
10
+ @worker_thread = nil
11
+ @running = false
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Enqueue a block to be executed asynchronously
16
+ def enqueue(&block)
17
+ ensure_worker_running
18
+ @queue << block
19
+ end
20
+
21
+ # Stop the worker thread gracefully
22
+ def stop
23
+ @mutex.synchronize do
24
+ @running = false
25
+ end
26
+ @queue << :stop if @worker_thread
27
+ @worker_thread&.join
28
+ end
29
+
30
+ # Check if the worker is running
31
+ def running?
32
+ @mutex.synchronize { @running }
33
+ end
34
+
35
+ private
36
+
37
+ def ensure_worker_running
38
+ @mutex.synchronize do
39
+ return if @running
40
+
41
+ @running = true
42
+ @worker_thread = Thread.new { worker_loop }
43
+ end
44
+ end
45
+
46
+ def worker_loop
47
+ loop do
48
+ task = @queue.pop
49
+ break if task == :stop
50
+
51
+ begin
52
+ task.call if task.respond_to?(:call)
53
+ rescue => e
54
+ SmartPrompt.logger.error "AsyncWriter task failed: #{e.message}\n#{e.backtrace.join("\n")}"
55
+ end
56
+ end
57
+ rescue => e
58
+ SmartPrompt.logger.error "AsyncWriter worker loop crashed: #{e.message}"
59
+ ensure
60
+ @mutex.synchronize { @running = false }
61
+ end
62
+ end
63
+
64
+ # PersistenceLayer handles saving and loading session data to/from disk
65
+ class PersistenceLayer
66
+ attr_reader :storage_path, :enabled
67
+
68
+ def initialize(config = {})
69
+ @backend = config[:backend] || :filesystem
70
+ @storage_path = config[:storage_path] || "./history_data"
71
+ @async_writer = AsyncWriter.new
72
+ @enabled = config[:enabled] != false
73
+ @async = config[:async] != false
74
+
75
+ # Create storage directory if persistence is enabled
76
+ ensure_storage_directory if @enabled
77
+ end
78
+
79
+ # Save a session synchronously
80
+ def save(session)
81
+ return unless @enabled
82
+
83
+ file_path = session_file_path(session.id)
84
+ data = serialize_session(session)
85
+
86
+ File.write(file_path, data)
87
+ SmartPrompt.logger.info "Session #{session.id} saved to #{file_path}"
88
+ rescue => e
89
+ SmartPrompt.logger.error "Failed to save session #{session.id}: #{e.message}"
90
+ # Continue operating with in-memory storage (fallback behavior)
91
+ end
92
+
93
+ # Save a session asynchronously
94
+ def save_async(session)
95
+ return unless @enabled
96
+
97
+ if @async
98
+ @async_writer.enqueue do
99
+ save(session)
100
+ end
101
+ else
102
+ # If async is disabled, fall back to synchronous save
103
+ save(session)
104
+ end
105
+ end
106
+
107
+ # Load a session from disk
108
+ def load(session_id)
109
+ return nil unless @enabled
110
+
111
+ file_path = session_file_path(session_id)
112
+ return nil unless File.exist?(file_path)
113
+
114
+ data = File.read(file_path)
115
+ session_data = deserialize_session(data)
116
+
117
+ SmartPrompt.logger.info "Session #{session_id} loaded from #{file_path}"
118
+ session_data
119
+ rescue => e
120
+ SmartPrompt.logger.error "Failed to load session #{session_id}: #{e.message}"
121
+ nil
122
+ end
123
+
124
+ # Delete a session from disk
125
+ def delete(session_id)
126
+ return unless @enabled
127
+
128
+ file_path = session_file_path(session_id)
129
+ if File.exist?(file_path)
130
+ File.delete(file_path)
131
+ SmartPrompt.logger.info "Session #{session_id} deleted from disk"
132
+ end
133
+ rescue => e
134
+ SmartPrompt.logger.error "Failed to delete session #{session_id}: #{e.message}"
135
+ end
136
+
137
+ # Check if a session exists on disk
138
+ def exists?(session_id)
139
+ return false unless @enabled
140
+
141
+ file_path = session_file_path(session_id)
142
+ File.exist?(file_path)
143
+ end
144
+
145
+ # List all session IDs stored on disk
146
+ def list_sessions
147
+ return [] unless @enabled
148
+
149
+ Dir.glob(File.join(@storage_path, "*.json")).map do |file|
150
+ File.basename(file, ".json")
151
+ end
152
+ rescue => e
153
+ SmartPrompt.logger.error "Failed to list sessions: #{e.message}"
154
+ []
155
+ end
156
+
157
+ # Stop the async writer gracefully
158
+ def shutdown
159
+ @async_writer.stop if @async_writer
160
+ end
161
+
162
+ private
163
+
164
+ # Ensure the storage directory exists
165
+ def ensure_storage_directory
166
+ return if Dir.exist?(@storage_path)
167
+
168
+ FileUtils.mkdir_p(@storage_path)
169
+ SmartPrompt.logger.info "Created storage directory: #{@storage_path}"
170
+ rescue => e
171
+ SmartPrompt.logger.error "Failed to create storage directory #{@storage_path}: #{e.message}"
172
+ @enabled = false
173
+ end
174
+
175
+ # Get the file path for a session
176
+ def session_file_path(session_id)
177
+ File.join(@storage_path, "#{session_id}.json")
178
+ end
179
+
180
+ # Serialize a session to JSON
181
+ def serialize_session(session)
182
+ JSON.pretty_generate({
183
+ id: session.id,
184
+ messages: session.messages.map(&:to_h),
185
+ metadata: session.metadata,
186
+ created_at: session.created_at.iso8601,
187
+ updated_at: session.updated_at.iso8601,
188
+ config: session.config
189
+ })
190
+ end
191
+
192
+ # Deserialize session data from JSON
193
+ def deserialize_session(data)
194
+ JSON.parse(data, symbolize_names: true)
195
+ end
196
+ end
197
+ end