sentiment_insights 0.1.0 → 0.3.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: 7c3ede714068979aaa53076ed6fef752235b50d45ab57f388d9baf8a0e952baf
4
- data.tar.gz: 6fc6b77e018b41a3503d45938b65575f0bd90dfbd8a16ac26257c738591709f3
3
+ metadata.gz: ab2970402e6ee32bfa46d70caaafa135d17e780528c76ab7d42c5a6d9a62acb5
4
+ data.tar.gz: ca8fcc08f054c4f348d53697c3a4fed6a9b36485d628c7b89b0112cc081b332f
5
5
  SHA512:
6
- metadata.gz: d5025dc6ab42d5b4358d66084e3306773984e796da881acf917fd879d579375d152cb7458477d3071f080d43bbb90b0ea8b122833343b963f5b84aac15d11adf
7
- data.tar.gz: 63f1abac85a05be6ee68006eeb5729bcc53f1a0f4932ad13067e9ca06f4b89be9ce62f88ca5c8008d647862b29fd7704658a6bb3153dcd8063923650a6c2eb63
6
+ metadata.gz: 1af62b7158097b907df3609521c75997f2919294c7a725f12bc7243d9b89721b66d1a0c982fc3daab1ab7e5623ed140db5360355b84e67b309d1392aaf515d21
7
+ data.tar.gz: e42bf88ffd443546a20617cfcab9818982c19180425047306e8ee59e022bc0d0de50b3908d2d3f2fed587fb373d31e6f4d7d7d799b79c40c10cbd33cb09bc964
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sentiment_insights (0.1.0)
5
- aws-sdk-comprehend (~> 1.98.0)
4
+ sentiment_insights (0.3.0)
5
+ aws-sdk-comprehend (>= 1.98.0)
6
6
  sentimental (~> 1.4.0)
7
7
 
8
8
  GEM
data/README.md CHANGED
@@ -1,12 +1,101 @@
1
- # SentimentInsights 💬📊
1
+ # SentimentInsights
2
2
 
3
- **SentimentInsights** is a Ruby gem that helps you uncover meaningful insights from open-ended survey responses using Natural Language Processing (NLP). It supports multi-provider analysis via OpenAI, AWS Comprehend, or a local fallback engine.
3
+ **SentimentInsights** is a Ruby gem for extracting sentiment, key phrases, and named entities from survey responses or free-form textual data. It offers a plug-and-play interface to different NLP providers, including OpenAI, Claude AI, and AWS.
4
4
 
5
5
  ---
6
6
 
7
- ## Features
7
+ ## Table of Contents
8
8
 
9
- ### ✅ 1. Sentiment Analysis
9
+ - [Installation](#installation)
10
+ - [Configuration](#configuration)
11
+ - [Usage](#usage)
12
+ - [Sentiment Analysis](#sentiment-analysis)
13
+ - [Key Phrase Extraction](#key-phrase-extraction)
14
+ - [Entity Extraction](#entity-extraction)
15
+ - [Provider Options & Custom Prompts](#provider-options--custom-prompts)
16
+ - [Full Example](#full-example)
17
+ - [Contributing](#contributing)
18
+ - [License](#license)
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ Add to your Gemfile:
25
+
26
+ ```ruby
27
+ gem 'sentiment_insights'
28
+ ```
29
+
30
+ Then install:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
35
+
36
+ Or install it directly:
37
+
38
+ ```bash
39
+ gem install sentiment_insights
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Configuration
45
+
46
+ Configure the provider and (if using OpenAI, Claude AI, or AWS) your API key:
47
+
48
+ ```ruby
49
+ require 'sentiment_insights'
50
+
51
+ # For OpenAI
52
+ SentimentInsights.configure do |config|
53
+ config.provider = :openai
54
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
55
+ end
56
+
57
+ # For Claude AI
58
+ SentimentInsights.configure do |config|
59
+ config.provider = :claude
60
+ config.claude_api_key = ENV["CLAUDE_API_KEY"]
61
+ end
62
+
63
+ # For AWS
64
+ SentimentInsights.configure do |config|
65
+ config.provider = :aws
66
+ config.aws_region = 'us-east-1'
67
+ end
68
+
69
+ # For sentimental
70
+ SentimentInsights.configure do |config|
71
+ config.provider = :sentimental
72
+ end
73
+ ```
74
+
75
+ Supported providers:
76
+ - `:openai`
77
+ - `:claude`
78
+ - `:aws`
79
+ - `:sentimental` (local fallback, limited feature set)
80
+
81
+ ---
82
+
83
+ ## Usage
84
+
85
+ Data entries should be hashes with at least an `:answer` key. Optionally include segmentation info under `:segment`.
86
+
87
+ ```ruby
88
+ entries = [
89
+ { answer: "Amazon Checkout was smooth!", segment: { age_group: "18-25", gender: "Female" } },
90
+ { answer: "Walmart Shipping was delayed.", segment: { age_group: "18-25", gender: "Female" } },
91
+ { answer: "Target Support was decent.", segment: { age_group: "26-35", gender: "Male" } },
92
+ { answer: "Loved the product!", segment: { age_group: "18-25", gender: "Male" } }
93
+ ]
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Sentiment Analysis
10
99
 
11
100
  Quickly classify and summarize user responses as positive, neutral, or negative — globally or by segment (e.g., age, region).
12
101
 
@@ -17,6 +106,34 @@ insight = SentimentInsights::Insights::Sentiment.new
17
106
  result = insight.analyze(entries)
18
107
  ```
19
108
 
109
+ With options:
110
+
111
+ ```ruby
112
+ custom_prompt = <<~PROMPT
113
+ For each of the following customer responses, classify the sentiment as Positive, Neutral, or Negative, and assign a score between -1.0 (very negative) and 1.0 (very positive).
114
+
115
+ Reply with a numbered list like:
116
+ 1. Positive (0.9)
117
+ 2. Negative (-0.8)
118
+ 3. Neutral (0.0)
119
+ PROMPT
120
+
121
+ insight = SentimentInsights::Insights::Sentiment.new
122
+ result = insight.analyze(
123
+ entries,
124
+ question: "How was your experience today?",
125
+ prompt: custom_prompt,
126
+ batch_size: 10
127
+ )
128
+ ```
129
+
130
+ #### Available Options (`analyze`)
131
+ | Option | Type | Description | Provider |
132
+ |---------------|---------|------------------------------------------------------------------------|--------------------|
133
+ | `question` | String | Contextual question for the batch | OpenAI, Claude only |
134
+ | `prompt` | String | Custom prompt text for LLM | OpenAI, Claude only |
135
+ | `batch_size` | Integer | Number of entries per completion call (default: 50) | OpenAI, Claude only |
136
+
20
137
  #### 📾 Sample Output
21
138
 
22
139
  ```ruby
@@ -59,15 +176,56 @@ result = insight.analyze(entries)
59
176
  :sentiment_score=>0.9}]}}
60
177
  ```
61
178
 
62
- ### ✅ 2. Key Phrase Extraction
179
+ ---
180
+
181
+ ### Key Phrase Extraction
63
182
 
64
183
  Extract frequently mentioned phrases and identify their associated sentiment and segment spread.
65
184
 
66
185
  ```ruby
67
186
  insight = SentimentInsights::Insights::KeyPhrases.new
68
- result = insight.extract(entries, question: question)
187
+ result = insight.extract(entries)
69
188
  ```
70
189
 
190
+ With options:
191
+
192
+ ```ruby
193
+ key_phrase_prompt = <<~PROMPT.strip
194
+ Extract the most important key phrases that represent the main ideas or feedback in the sentence below.
195
+ Ignore stop words and return each key phrase in its natural form, comma-separated.
196
+
197
+ Question: %{question}
198
+
199
+ Text: %{text}
200
+ PROMPT
201
+
202
+ sentiment_prompt = <<~PROMPT
203
+ For each of the following customer responses, classify the sentiment as Positive, Neutral, or Negative, and assign a score between -1.0 (very negative) and 1.0 (very positive).
204
+
205
+ Reply with a numbered list like:
206
+ 1. Positive (0.9)
207
+ 2. Negative (-0.8)
208
+ 3. Neutral (0.0)
209
+ PROMPT
210
+
211
+ insight = SentimentInsights::Insights::KeyPhrases.new
212
+ result = insight.extract(
213
+ entries,
214
+ question: "What are the recurring themes?",
215
+ key_phrase_prompt: key_phrase_prompt,
216
+ sentiment_prompt: sentiment_prompt
217
+ )
218
+ ```
219
+
220
+ #### Available Options (`extract`)
221
+ | Option | Type | Description | Provider |
222
+ |--------------------|---------|------------------------------------------------------------|--------------------|
223
+ | `question` | String | Context question to help guide phrase extraction | OpenAI, Claude only |
224
+ | `key_phrase_prompt`| String | Custom prompt for extracting key phrases | OpenAI, Claude only |
225
+ | `sentiment_prompt` | String | Custom prompt for classifying tone of extracted phrases | OpenAI, Claude only |
226
+
227
+ #### 📾 Sample Output
228
+
71
229
  ```ruby
72
230
  {:phrases=>
73
231
  [{:phrase=>"everlane",
@@ -85,15 +243,44 @@ result = insight.extract(entries, question: question)
85
243
  :segment=>{:age=>"25-34", :region=>"West"}}]}
86
244
  ```
87
245
 
88
- ### ✅ 3. Entity Recognition
246
+ ---
89
247
 
90
- Identify named entities like organizations, products, and people, and track them by sentiment and segment.
248
+ ### Entity Extraction
91
249
 
92
250
  ```ruby
93
251
  insight = SentimentInsights::Insights::Entities.new
94
- result = insight.extract(entries, question: question)
252
+ result = insight.extract(entries)
95
253
  ```
96
254
 
255
+ With options:
256
+
257
+ ```ruby
258
+ entity_prompt = <<~PROMPT.strip
259
+ Identify brand names, competitors, and product references in the sentence below.
260
+ Return each as a JSON object with "text" and "type" (e.g., BRAND, PRODUCT, COMPANY).
261
+
262
+ Question: %{question}
263
+
264
+ Sentence: "%{text}"
265
+ PROMPT
266
+
267
+ insight = SentimentInsights::Insights::Entities.new
268
+ result = insight.extract(
269
+ entries,
270
+ question: "Which products or brands are mentioned?",
271
+ prompt: entity_prompt
272
+ )
273
+
274
+ ```
275
+
276
+ #### Available Options (`extract`)
277
+ | Option | Type | Description | Provider |
278
+ |-------------|---------|---------------------------------------------------|--------------------|
279
+ | `question` | String | Context question to guide entity extraction | OpenAI, Claude only |
280
+ | `prompt` | String | Custom instructions for entity extraction | OpenAI, Claude only |
281
+
282
+ #### 📾 Sample Output
283
+
97
284
  ```ruby
98
285
  {:entities=>
99
286
  [{:entity=>"everlane",
@@ -126,73 +313,12 @@ result = insight.extract(entries, question: question)
126
313
  "the response was copy-paste and didn't address my issue directly.",
127
314
  :segment=>{:age=>"45-54", :region=>"Midwest"}}]}
128
315
  ```
129
-
130
- ### ✅ 4. Topic Modeling *(Coming Soon)*
131
-
132
- Automatically group similar responses into topics and subthemes.
133
-
134
316
  ---
135
317
 
136
- ## 🔌 Supported Providers
137
-
138
- | Feature | OpenAI ✅ | AWS Comprehend ✅ | Sentimental (Local) ⚠️ |
139
- | ------------------ | -------------- | ---------------- | ---------------------- |
140
- | Sentiment Analysis | ✅ | ✅ | ✅ |
141
- | Key Phrases | ✅ | ✅ | ❌ Not supported |
142
- | Entities | ✅ | ✅ | ❌ Not supported |
143
- | Topics | 🔜 Coming Soon | 🔜 Coming Soon | ❌ |
318
+ ## Provider Options & Custom Prompts
144
319
 
145
- Legend: Supported | 🔜 Coming Soon | Not Available | ⚠️ Partial
146
-
147
- ---
148
-
149
- ## 📅 Example Input
150
-
151
- ```ruby
152
- question = "What did you like or dislike about your recent shopping experience with us?"
153
-
154
- entries = [
155
- {
156
- answer: "I absolutely loved the experience shopping with Everlane. The website is clean,\nproduct descriptions are spot-on, and my jeans arrived two days early with eco-friendly packaging.",
157
- segment: { age: "25-34", region: "West" }
158
- },
159
- {
160
- answer: "The checkout flow on your site was a nightmare. The promo code from your Instagram campaign didn’t work,\nand it kept redirecting me to the homepage. Shopify integration needs a serious fix.",
161
- segment: { age: "35-44", region: "South" }
162
- },
163
- {
164
- answer: "Apple Pay made the mobile checkout super fast. I placed an order while waiting for my coffee at Starbucks.\nGreat job optimizing the app UX—this is a game-changer.",
165
- segment: { age: "25-34", region: "West" }
166
- },
167
- {
168
- answer: "I reached out to your Zendesk support team about a missing package, and while they responded within 24 hours,\nthe response was copy-paste and didn't address my issue directly.",
169
- segment: { age: "45-54", region: "Midwest" }
170
- },
171
- {
172
- answer: "Shipping delays aside, I really liked the personalized note inside the box. Small gestures like that\nmake the Uniqlo brand stand out. Will definitely recommend to friends.",
173
- segment: { age: "25-34", region: "West" }
174
- }
175
- ]
176
- ```
177
-
178
- ---
179
-
180
- ## 🚀 Quick Start
181
-
182
- ```ruby
183
- # Install the gem
184
- $ gem install sentiment_insights
185
-
186
- # Configure the provider
187
- SentimentInsights.configure do |config|
188
- config.provider = :openai # or :aws, :sentimental
189
- end
190
-
191
- # Run analysis
192
- insight = SentimentInsights::Insights::Sentiment.new
193
- result = insight.analyze(entries)
194
- puts JSON.pretty_generate(result)
195
- ```
320
+ > ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai` and `:claude` providers.
321
+ > They are safely ignored for `:aws` and `:sentimental`.
196
322
 
197
323
  ---
198
324
 
@@ -204,6 +330,12 @@ puts JSON.pretty_generate(result)
204
330
  OPENAI_API_KEY=your_openai_key_here
205
331
  ```
206
332
 
333
+ ### Claude AI
334
+
335
+ ```bash
336
+ CLAUDE_API_KEY=your_claude_key_here
337
+ ```
338
+
207
339
  ### AWS Comprehend
208
340
 
209
341
  ```bash
@@ -217,7 +349,6 @@ AWS_REGION=us-east-1
217
349
  ## 💎 Ruby Compatibility
218
350
 
219
351
  - **Minimum Ruby version:** 2.7
220
- - Tested on: 2.7, 3.0, 3.1, 3.2
221
352
 
222
353
  ---
223
354
 
@@ -255,11 +386,7 @@ Pull requests welcome! Please open an issue to discuss major changes first.
255
386
  ## 💬 Acknowledgements
256
387
 
257
388
  - [OpenAI GPT](https://platform.openai.com/docs)
389
+ - [Claude AI](https://docs.anthropic.com/claude/reference/getting-started-with-the-api)
258
390
  - [AWS Comprehend](https://docs.aws.amazon.com/comprehend/latest/dg/what-is.html)
259
391
  - [Sentimental Gem](https://github.com/7compass/sentimental)
260
392
 
261
- ---
262
-
263
- ## 📢 Questions?
264
-
265
- File an issue or reach out on [GitHub](https://github.com/your-repo)
@@ -12,7 +12,7 @@ module SentimentInsights
12
12
  @logger = Logger.new($stdout)
13
13
  end
14
14
 
15
- def extract_batch(entries, question: nil)
15
+ def extract_batch(entries, question: nil, prompt: nil)
16
16
  responses = []
17
17
  entity_map = Hash.new { |h, k| h[k] = [] }
18
18
 
@@ -0,0 +1,131 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module SentimentInsights
7
+ module Clients
8
+ module Entities
9
+ class ClaudeClient
10
+ DEFAULT_MODEL = "claude-3-haiku-20240307"
11
+ DEFAULT_RETRIES = 3
12
+
13
+ def initialize(api_key: ENV['CLAUDE_API_KEY'], model: DEFAULT_MODEL, max_retries: DEFAULT_RETRIES)
14
+ @api_key = api_key or raise ArgumentError, "Claude API key is required"
15
+ @model = model
16
+ @max_retries = max_retries
17
+ @logger = Logger.new($stdout)
18
+ end
19
+
20
+ def extract_batch(entries, question: nil, prompt: nil)
21
+ responses = []
22
+ entity_map = Hash.new { |h, k| h[k] = [] }
23
+
24
+ entries.each_with_index do |entry, index|
25
+ sentence = entry[:answer].to_s.strip
26
+ next if sentence.empty?
27
+
28
+ response_id = "r_#{index + 1}"
29
+ entities = extract_entities_from_sentence(sentence, question: question, prompt: prompt)
30
+
31
+ responses << {
32
+ id: response_id,
33
+ sentence: sentence,
34
+ segment: entry[:segment] || {}
35
+ }
36
+
37
+ entities.each do |ent|
38
+ next if ent[:text].to_s.empty? || ent[:type].to_s.empty?
39
+ key = [ent[:text].downcase, ent[:type]]
40
+ entity_map[key] << response_id
41
+ end
42
+ end
43
+
44
+ entity_records = entity_map.map do |(text, type), ref_ids|
45
+ {
46
+ entity: text,
47
+ type: type,
48
+ mentions: ref_ids.uniq,
49
+ summary: nil
50
+ }
51
+ end
52
+
53
+ { entities: entity_records, responses: responses }
54
+ end
55
+
56
+ private
57
+
58
+ def extract_entities_from_sentence(text, question: nil, prompt: nil)
59
+ # Default prompt with interpolation placeholders
60
+ default_prompt = <<~PROMPT
61
+ Extract named entities from this sentence based on the question.
62
+ Return them as a JSON array with each item having "text" and "type" (e.g., PERSON, ORGANIZATION, LOCATION, PRODUCT).
63
+ %{question}
64
+ Sentence: "%{text}"
65
+ PROMPT
66
+
67
+ # If a custom prompt is provided, interpolate %{text} and %{question} if present
68
+ if prompt
69
+ interpolated = prompt.dup
70
+ interpolated.gsub!('%{text}', text.to_s)
71
+ interpolated.gsub!('%{question}', question.to_s) if question
72
+ interpolated.gsub!('{text}', text.to_s)
73
+ interpolated.gsub!('{question}', question.to_s) if question
74
+ prompt_to_use = interpolated
75
+ else
76
+ question_line = question ? "Question: #{question}" : ""
77
+ prompt_to_use = default_prompt % { question: question_line, text: text }
78
+ end
79
+
80
+ body = build_request_body(prompt_to_use)
81
+ response = post_claude(body)
82
+
83
+ begin
84
+ raw_json = response.dig("content", 0, "text").to_s.strip
85
+ JSON.parse(raw_json, symbolize_names: true)
86
+ rescue JSON::ParserError => e
87
+ @logger.warn "Failed to parse entity JSON: #{e.message}"
88
+ []
89
+ end
90
+ end
91
+
92
+ def build_request_body(prompt)
93
+ {
94
+ model: @model,
95
+ max_tokens: 1000,
96
+ messages: [{ role: "user", content: prompt }]
97
+ }
98
+ end
99
+
100
+ def post_claude(body)
101
+ uri = URI("https://api.anthropic.com/v1/messages")
102
+ http = Net::HTTP.new(uri.host, uri.port)
103
+ http.use_ssl = true
104
+
105
+ attempt = 0
106
+ while attempt < @max_retries
107
+ attempt += 1
108
+
109
+ request = Net::HTTP::Post.new(uri)
110
+ request["Content-Type"] = "application/json"
111
+ request["x-api-key"] = @api_key
112
+ request["anthropic-version"] = "2023-06-01"
113
+ request.body = JSON.generate(body)
114
+
115
+ begin
116
+ response = http.request(request)
117
+ return JSON.parse(response.body) if response.code.to_i == 200
118
+ @logger.warn "Claude entity extraction failed (#{response.code}): #{response.body}"
119
+ rescue => e
120
+ @logger.error "Error during entity extraction: #{e.class} - #{e.message}"
121
+ end
122
+
123
+ sleep(2 ** (attempt - 1)) if attempt < @max_retries
124
+ end
125
+
126
+ {}
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -17,7 +17,7 @@ module SentimentInsights
17
17
  @logger = Logger.new($stdout)
18
18
  end
19
19
 
20
- def extract_batch(entries, question: nil)
20
+ def extract_batch(entries, question: nil, prompt: nil)
21
21
  responses = []
22
22
  entity_map = Hash.new { |h, k| h[k] = [] }
23
23
 
@@ -26,7 +26,7 @@ module SentimentInsights
26
26
  next if sentence.empty?
27
27
 
28
28
  response_id = "r_#{index + 1}"
29
- entities = extract_entities_from_sentence(sentence)
29
+ entities = extract_entities_from_sentence(sentence, question: question, prompt: prompt)
30
30
 
31
31
  responses << {
32
32
  id: response_id,
@@ -35,6 +35,7 @@ module SentimentInsights
35
35
  }
36
36
 
37
37
  entities.each do |ent|
38
+ next if ent[:text].empty? || ent[:type].empty?
38
39
  key = [ent[:text].downcase, ent[:type]]
39
40
  entity_map[key] << response_id
40
41
  end
@@ -54,13 +55,29 @@ module SentimentInsights
54
55
 
55
56
  private
56
57
 
57
- def extract_entities_from_sentence(text)
58
- prompt = <<~PROMPT
59
- Extract named entities from this sentence. Return them as a JSON array with each item having "text" and "type" (e.g., PERSON, ORGANIZATION, LOCATION, PRODUCT).
60
- Sentence: "#{text}"
58
+ def extract_entities_from_sentence(text, question: nil, prompt: nil)
59
+ # Default prompt with interpolation placeholders
60
+ default_prompt = <<~PROMPT
61
+ Extract named entities from this sentence based on the question.
62
+ Return them as a JSON array with each item having "text" and "type" (e.g., PERSON, ORGANIZATION, LOCATION, PRODUCT).
63
+ %{question}
64
+ Sentence: "%{text}"
61
65
  PROMPT
62
66
 
63
- body = build_request_body(prompt)
67
+ # If a custom prompt is provided, interpolate %{text} and %{question} if present
68
+ if prompt
69
+ interpolated = prompt.dup
70
+ interpolated.gsub!('%{text}', text.to_s)
71
+ interpolated.gsub!('%{question}', question.to_s) if question
72
+ interpolated.gsub!('{text}', text.to_s)
73
+ interpolated.gsub!('{question}', question.to_s) if question
74
+ prompt_to_use = interpolated
75
+ else
76
+ question_line = question ? "Question: #{question}" : ""
77
+ prompt_to_use = default_prompt % { question: question_line, text: text }
78
+ end
79
+
80
+ body = build_request_body(prompt_to_use)
64
81
  response = post_openai(body)
65
82
 
66
83
  begin
@@ -12,7 +12,7 @@ module SentimentInsights
12
12
  @logger = Logger.new($stdout)
13
13
  end
14
14
 
15
- def extract_batch(entries, question: nil)
15
+ def extract_batch(entries, question: nil, key_phrase_prompt: nil, sentiment_prompt: nil)
16
16
  responses = []
17
17
  phrase_map = Hash.new { |h, k| h[k] = [] }
18
18
 
@@ -0,0 +1,151 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module SentimentInsights
7
+ module Clients
8
+ module KeyPhrases
9
+ class ClaudeClient
10
+ DEFAULT_MODEL = "claude-3-haiku-20240307"
11
+ DEFAULT_RETRIES = 3
12
+
13
+ def initialize(api_key: ENV['CLAUDE_API_KEY'], model: DEFAULT_MODEL, max_retries: DEFAULT_RETRIES)
14
+ @api_key = api_key or raise ArgumentError, "Claude API key is required"
15
+ @model = model
16
+ @max_retries = max_retries
17
+ @logger = Logger.new($stdout)
18
+ end
19
+
20
+ def extract_batch(entries, question: nil, key_phrase_prompt: nil, sentiment_prompt: nil)
21
+ responses = []
22
+ phrase_map = Hash.new { |h, k| h[k] = [] }
23
+
24
+ entries.each_with_index do |entry, index|
25
+ sentence = entry[:answer].to_s.strip
26
+ next if sentence.empty?
27
+
28
+ response_id = "r_#{index + 1}"
29
+
30
+ # Extract key phrases
31
+ phrases = extract_key_phrases(sentence, question: question, prompt: key_phrase_prompt)
32
+
33
+ # Get sentiment for this response
34
+ sentiment = get_sentiment(sentence, prompt: sentiment_prompt)
35
+
36
+ responses << {
37
+ id: response_id,
38
+ sentence: sentence,
39
+ sentiment: sentiment,
40
+ segment: entry[:segment] || {}
41
+ }
42
+
43
+ phrases.each do |phrase|
44
+ next if phrase.strip.empty?
45
+ phrase_map[phrase.downcase] << response_id
46
+ end
47
+ end
48
+
49
+ phrase_records = phrase_map.map do |phrase, ref_ids|
50
+ {
51
+ phrase: phrase,
52
+ mentions: ref_ids.uniq,
53
+ summary: nil
54
+ }
55
+ end
56
+
57
+ { phrases: phrase_records, responses: responses }
58
+ end
59
+
60
+ private
61
+
62
+ def extract_key_phrases(text, question: nil, prompt: nil)
63
+ default_prompt = <<~PROMPT.strip
64
+ Extract the most important key phrases that represent the main ideas or feedback in the sentence below.
65
+ Ignore stop words and return each key phrase in its natural form, comma-separated.
66
+
67
+ Question: %{question}
68
+
69
+ Text: %{text}
70
+ PROMPT
71
+
72
+ if prompt
73
+ interpolated = prompt.dup
74
+ interpolated.gsub!('%{text}', text.to_s)
75
+ interpolated.gsub!('%{question}', question.to_s) if question
76
+ interpolated.gsub!('{text}', text.to_s)
77
+ interpolated.gsub!('{question}', question.to_s) if question
78
+ prompt_to_use = interpolated
79
+ else
80
+ question_line = question ? question.to_s : ""
81
+ prompt_to_use = default_prompt % { question: question_line, text: text }
82
+ end
83
+
84
+ body = build_request_body(prompt_to_use)
85
+ response = post_claude(body)
86
+
87
+ content = response.dig("content", 0, "text").to_s.strip
88
+ content.split(',').map(&:strip).reject(&:empty?)
89
+ end
90
+
91
+ def get_sentiment(text, prompt: nil)
92
+ default_prompt = <<~PROMPT
93
+ Classify the sentiment of this text as Positive, Neutral, or Negative.
94
+ Reply with just the sentiment label.
95
+
96
+ Text: "#{text}"
97
+ PROMPT
98
+
99
+ prompt_to_use = prompt ? prompt.gsub('%{text}', text) : default_prompt
100
+
101
+ body = build_request_body(prompt_to_use)
102
+ response = post_claude(body)
103
+
104
+ content = response.dig("content", 0, "text").to_s.strip.downcase
105
+ case content
106
+ when /positive/ then :positive
107
+ when /negative/ then :negative
108
+ else :neutral
109
+ end
110
+ end
111
+
112
+ def build_request_body(prompt)
113
+ {
114
+ model: @model,
115
+ max_tokens: 1000,
116
+ messages: [{ role: "user", content: prompt }]
117
+ }
118
+ end
119
+
120
+ def post_claude(body)
121
+ uri = URI("https://api.anthropic.com/v1/messages")
122
+ http = Net::HTTP.new(uri.host, uri.port)
123
+ http.use_ssl = true
124
+
125
+ attempt = 0
126
+ while attempt < @max_retries
127
+ attempt += 1
128
+
129
+ request = Net::HTTP::Post.new(uri)
130
+ request["Content-Type"] = "application/json"
131
+ request["x-api-key"] = @api_key
132
+ request["anthropic-version"] = "2023-06-01"
133
+ request.body = JSON.generate(body)
134
+
135
+ begin
136
+ response = http.request(request)
137
+ return JSON.parse(response.body) if response.code.to_i == 200
138
+ @logger.warn "Claude key phrase extraction failed (#{response.code}): #{response.body}"
139
+ rescue => e
140
+ @logger.error "Error during key phrase extraction: #{e.class} - #{e.message}"
141
+ end
142
+
143
+ sleep(2 ** (attempt - 1)) if attempt < @max_retries
144
+ end
145
+
146
+ {}
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -8,7 +8,7 @@ module SentimentInsights
8
8
  module Clients
9
9
  module KeyPhrases
10
10
  class OpenAIClient
11
- DEFAULT_MODEL = "gpt-3.5-turbo"
11
+ DEFAULT_MODEL = "gpt-3.5-turbo"
12
12
  DEFAULT_RETRIES = 3
13
13
 
14
14
  def initialize(api_key: ENV['OPENAI_API_KEY'], model: DEFAULT_MODEL, max_retries: DEFAULT_RETRIES)
@@ -20,19 +20,19 @@ module SentimentInsights
20
20
  end
21
21
 
22
22
  # Extract key phrases from entries and enrich with sentiment
23
- def extract_batch(entries, question: nil)
23
+ def extract_batch(entries, question: nil, key_phrase_prompt: nil, sentiment_prompt: nil)
24
24
  responses = []
25
25
  phrase_map = Hash.new { |h, k| h[k] = [] }
26
26
 
27
27
  # Fetch sentiments in batch from sentiment client
28
- sentiments = @sentiment_client.analyze_entries(entries, question: question)
28
+ sentiments = @sentiment_client.analyze_entries(entries, question: question, prompt: sentiment_prompt)
29
29
 
30
30
  entries.each_with_index do |entry, index|
31
31
  sentence = entry[:answer].to_s.strip
32
32
  next if sentence.empty?
33
33
 
34
34
  response_id = "r_#{index + 1}"
35
- phrases = extract_phrases_from_sentence(sentence)
35
+ phrases = extract_phrases_from_sentence(sentence, question: question, prompt: key_phrase_prompt)
36
36
 
37
37
  sentiment = sentiments[index] || { label: :neutral }
38
38
 
@@ -61,15 +61,33 @@ module SentimentInsights
61
61
 
62
62
  private
63
63
 
64
- def extract_phrases_from_sentence(text)
65
- prompt = <<~PROMPT
66
- Extract the key phrases from this sentence:
67
- "#{text}"
68
- Return them as a comma-separated list.
64
+ def extract_phrases_from_sentence(text, question: nil, prompt: nil)
65
+ # Default prompt with interpolation placeholders
66
+ default_prompt = <<~PROMPT
67
+ Extract the most important key phrases that represent the main ideas or feedback in the sentence below.
68
+ Ignore stop words and return each key phrase in its natural form, comma-separated.
69
+ %{question}
70
+ Sentence: "%{text}"
69
71
  PROMPT
70
72
 
71
- body = build_request_body(prompt)
73
+ # If a custom prompt is provided, attempt to interpolate %{text} and %{question} if present
74
+ if prompt
75
+ interpolated = prompt.dup
76
+ interpolated.gsub!('%{text}', text.to_s)
77
+ interpolated.gsub!('%{question}', question.to_s) if question
78
+ # For compatibility: if “{text}” is used instead of “%{text}”
79
+ interpolated.gsub!('{text}', text.to_s)
80
+ interpolated.gsub!('{question}', question.to_s) if question
81
+ prompt_to_use = interpolated
82
+ else
83
+ question_line = question ? "Question: #{question}" : ""
84
+ prompt_to_use = default_prompt % { question: question_line, text: text }
85
+ end
86
+
87
+ body = build_request_body(prompt_to_use)
72
88
  response = post_openai(body)
89
+
90
+ # The response is expected as a comma- or newline-separated list of key phrases
73
91
  parse_phrases(response)
74
92
  end
75
93
 
@@ -15,7 +15,7 @@ module SentimentInsights
15
15
  # Analyze a batch of entries using AWS Comprehend.
16
16
  # @param entries [Array<Hash>] each with :answer key
17
17
  # @return [Array<Hash>] each with :label (symbol) and :score (float)
18
- def analyze_entries(entries, question: nil)
18
+ def analyze_entries(entries, question: nil, prompt: nil, batch_size: nil)
19
19
  results = []
20
20
 
21
21
  entries.each_slice(MAX_BATCH_SIZE) do |batch|
@@ -0,0 +1,126 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module SentimentInsights
7
+ module Clients
8
+ module Sentiment
9
+ class ClaudeClient
10
+ DEFAULT_MODEL = "claude-3-haiku-20240307"
11
+ DEFAULT_RETRIES = 3
12
+
13
+ def initialize(api_key: ENV['CLAUDE_API_KEY'], model: DEFAULT_MODEL, max_retries: DEFAULT_RETRIES, return_scores: true)
14
+ @api_key = api_key or raise ArgumentError, "Claude API key is required"
15
+ @model = model
16
+ @max_retries = max_retries
17
+ @return_scores = return_scores
18
+ @logger = Logger.new($stdout)
19
+ end
20
+
21
+ def analyze_entries(entries, question: nil, prompt: nil, batch_size: 50)
22
+ all_sentiments = []
23
+
24
+ entries.each_slice(batch_size) do |batch|
25
+ prompt_content = build_prompt_content(batch, question: question, prompt: prompt)
26
+ request_body = {
27
+ model: @model,
28
+ max_tokens: 1000,
29
+ messages: [
30
+ { role: "user", content: prompt_content }
31
+ ]
32
+ }
33
+
34
+ uri = URI("https://api.anthropic.com/v1/messages")
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = true
37
+
38
+ response_content = nil
39
+ attempt = 0
40
+
41
+ while attempt < @max_retries
42
+ attempt += 1
43
+ request = Net::HTTP::Post.new(uri)
44
+ request["Content-Type"] = "application/json"
45
+ request["x-api-key"] = @api_key
46
+ request["anthropic-version"] = "2023-06-01"
47
+ request.body = JSON.generate(request_body)
48
+
49
+ begin
50
+ response = http.request(request)
51
+ rescue StandardError => e
52
+ @logger.error "Claude API request error: #{e.class} - #{e.message}"
53
+ raise
54
+ end
55
+
56
+ status = response.code.to_i
57
+ if status == 429
58
+ @logger.warn "Rate limit (HTTP 429) on attempt #{attempt}. Retrying..."
59
+ sleep(2 ** (attempt - 1))
60
+ next
61
+ elsif status != 200
62
+ @logger.error "Request failed (#{status}): #{response.body}"
63
+ raise "Claude API Error: #{status}"
64
+ else
65
+ data = JSON.parse(response.body)
66
+ response_content = data.dig("content", 0, "text")
67
+ break
68
+ end
69
+ end
70
+
71
+ sentiments = parse_sentiments(response_content, batch.size)
72
+ all_sentiments.concat(sentiments)
73
+ end
74
+
75
+ all_sentiments
76
+ end
77
+
78
+ private
79
+
80
+ def build_prompt_content(entries, question: nil, prompt: nil)
81
+ content = ""
82
+ content << "Question: #{question}\n\n" if question
83
+
84
+ # Use custom instructions or default
85
+ instructions = prompt || <<~DEFAULT
86
+ For each of the following customer responses, classify the sentiment as Positive, Neutral, or Negative, and assign a score between -1.0 (very negative) and 1.0 (very positive).
87
+
88
+ Reply with a numbered list like:
89
+ 1. Positive (0.9)
90
+ 2. Negative (-0.8)
91
+ 3. Neutral (0.0)
92
+ DEFAULT
93
+
94
+ content << instructions.strip + "\n\n"
95
+
96
+ entries.each_with_index do |entry, index|
97
+ content << "#{index + 1}. \"#{entry[:answer]}\"\n"
98
+ end
99
+
100
+ content
101
+ end
102
+
103
+ def parse_sentiments(content, expected_count)
104
+ sentiments = []
105
+
106
+ content.to_s.strip.split(/\r?\n/).each do |line|
107
+ if line.strip =~ /^\d+[\.:)]?\s*(Positive|Negative|Neutral)\s*\(([-\d\.]+)\)/i
108
+ label = $1.downcase.to_sym
109
+ score = $2.to_f
110
+ sentiments << { label: label, score: score }
111
+ end
112
+ end
113
+
114
+ if sentiments.size != expected_count
115
+ @logger.warn "Expected #{expected_count} results, got #{sentiments.size}. Padding with neutral."
116
+ while sentiments.size < expected_count
117
+ sentiments << { label: :neutral, score: 0.0 }
118
+ end
119
+ end
120
+
121
+ sentiments.first(expected_count)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -7,7 +7,7 @@ module SentimentInsights
7
7
  module Clients
8
8
  module Sentiment
9
9
  class OpenAIClient
10
- DEFAULT_MODEL = "gpt-3.5-turbo"
10
+ DEFAULT_MODEL = "gpt-3.5-turbo"
11
11
  DEFAULT_RETRIES = 3
12
12
 
13
13
  def initialize(api_key: ENV['OPENAI_API_KEY'], model: DEFAULT_MODEL, max_retries: DEFAULT_RETRIES, return_scores: true)
@@ -18,75 +18,85 @@ module SentimentInsights
18
18
  @logger = Logger.new($stdout)
19
19
  end
20
20
 
21
- def analyze_entries(entries, question: nil)
22
- prompt_content = build_prompt_content(entries, question)
23
- request_body = {
24
- model: @model,
25
- messages: [
26
- { role: "user", content: prompt_content }
27
- ],
28
- temperature: 0.0
29
- }
30
-
31
- uri = URI("https://api.openai.com/v1/chat/completions")
32
- http = Net::HTTP.new(uri.host, uri.port)
33
- http.use_ssl = true
34
-
35
- response_content = nil
36
- attempt = 0
37
-
38
- while attempt < @max_retries
39
- attempt += 1
40
- request = Net::HTTP::Post.new(uri)
41
- request["Content-Type"] = "application/json"
42
- request["Authorization"] = "Bearer #{@api_key}"
43
- request.body = JSON.generate(request_body)
44
-
45
- begin
46
- response = http.request(request)
47
- rescue StandardError => e
48
- @logger.error "OpenAI API request error: #{e.class} - #{e.message}"
49
- raise
21
+ def analyze_entries(entries, question: nil, prompt: nil, batch_size: 50)
22
+ all_sentiments = []
23
+
24
+ entries.each_slice(batch_size) do |batch|
25
+ prompt_content = build_prompt_content(batch, question: question, prompt: prompt)
26
+ request_body = {
27
+ model: @model,
28
+ messages: [
29
+ { role: "user", content: prompt_content }
30
+ ],
31
+ temperature: 0.0
32
+ }
33
+
34
+ uri = URI("https://api.openai.com/v1/chat/completions")
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = true
37
+
38
+ response_content = nil
39
+ attempt = 0
40
+
41
+ while attempt < @max_retries
42
+ attempt += 1
43
+ request = Net::HTTP::Post.new(uri)
44
+ request["Content-Type"] = "application/json"
45
+ request["Authorization"] = "Bearer #{@api_key}"
46
+ request.body = JSON.generate(request_body)
47
+
48
+ begin
49
+ response = http.request(request)
50
+ rescue StandardError => e
51
+ @logger.error "OpenAI API request error: #{e.class} - #{e.message}"
52
+ raise
53
+ end
54
+
55
+ status = response.code.to_i
56
+ if status == 429
57
+ @logger.warn "Rate limit (HTTP 429) on attempt #{attempt}. Retrying..."
58
+ sleep(2 ** (attempt - 1))
59
+ next
60
+ elsif status != 200
61
+ @logger.error "Request failed (#{status}): #{response.body}"
62
+ raise "OpenAI API Error: #{status}"
63
+ else
64
+ data = JSON.parse(response.body)
65
+ response_content = data.dig("choices", 0, "message", "content")
66
+ break
67
+ end
50
68
  end
51
69
 
52
- status = response.code.to_i
53
- if status == 429
54
- @logger.warn "Rate limit (HTTP 429) on attempt #{attempt}. Retrying..."
55
- sleep(2 ** (attempt - 1))
56
- next
57
- elsif status != 200
58
- @logger.error "Request failed (#{status}): #{response.body}"
59
- raise "OpenAI API Error: #{status}"
60
- else
61
- data = JSON.parse(response.body)
62
- response_content = data.dig("choices", 0, "message", "content")
63
- break
64
- end
70
+ sentiments = parse_sentiments(response_content, batch.size)
71
+ all_sentiments.concat(sentiments)
65
72
  end
66
73
 
67
- parse_sentiments(response_content, entries.size)
74
+ all_sentiments
68
75
  end
69
76
 
70
77
  private
71
78
 
72
- def build_prompt_content(entries, question)
73
- prompt = ""
74
- prompt << "Question: #{question}\n" if question
75
- prompt << <<~INSTRUCTIONS
76
- For each of the following customer responses, classify the sentiment as Positive, Neutral, or Negative, and assign a score between -1.0 (very negative) and 1.0 (very positive).
79
+ def build_prompt_content(entries, question: nil, prompt: nil)
80
+ content = ""
81
+ content << "Question: #{question}\n\n" if question
82
+
83
+ # Use custom instructions or default
84
+ instructions = prompt || <<~DEFAULT
85
+ For each of the following customer responses, classify the sentiment as Positive, Neutral, or Negative, and assign a score between -1.0 (very negative) and 1.0 (very positive).
77
86
 
78
- Reply with a numbered list like:
79
- 1. Positive (0.9)
80
- 2. Negative (-0.8)
81
- 3. Neutral (0.0)
87
+ Reply with a numbered list like:
88
+ 1. Positive (0.9)
89
+ 2. Negative (-0.8)
90
+ 3. Neutral (0.0)
91
+ DEFAULT
82
92
 
83
- INSTRUCTIONS
93
+ content << instructions.strip + "\n\n"
84
94
 
85
95
  entries.each_with_index do |entry, index|
86
- prompt << "#{index + 1}. \"#{entry[:answer]}\"\n"
96
+ content << "#{index + 1}. \"#{entry[:answer]}\"\n"
87
97
  end
88
98
 
89
- prompt
99
+ content
90
100
  end
91
101
 
92
102
  def parse_sentiments(content, expected_count)
@@ -14,7 +14,7 @@ module SentimentInsights
14
14
  # @param entries [Array<Hash>] An array of response hashes (each with :answer).
15
15
  # @param question [String, nil] (unused) Global question context, not needed for local analysis.
16
16
  # @return [Array<Hash>] An array of hashes with sentiment classification and score for each entry.
17
- def analyze_entries(entries, question: nil)
17
+ def analyze_entries(entries, question: nil, prompt: nil, batch_size: nil)
18
18
  puts "Inside sentimental"
19
19
  entries.map do |entry|
20
20
  text = entry[:answer].to_s.strip
@@ -1,10 +1,11 @@
1
1
  module SentimentInsights
2
2
  class Configuration
3
- attr_accessor :provider, :openai_api_key, :aws_region
3
+ attr_accessor :provider, :openai_api_key, :aws_region, :claude_api_key
4
4
 
5
5
  def initialize
6
6
  @provider = :openai
7
7
  @openai_api_key = ENV["OPENAI_API_KEY"]
8
+ @claude_api_key = ENV["CLAUDE_API_KEY"]
8
9
  @aws_region = "us-east-1"
9
10
  end
10
11
  end
@@ -9,6 +9,9 @@ module SentimentInsights
9
9
  when :openai
10
10
  require_relative '../clients/entities/open_ai_client'
11
11
  Clients::Entities::OpenAIClient.new
12
+ when :claude
13
+ require_relative '../clients/entities/claude_client'
14
+ Clients::Entities::ClaudeClient.new
12
15
  when :aws
13
16
  require_relative '../clients/entities/aws_client'
14
17
  Clients::Entities::AwsClient.new
@@ -22,11 +25,10 @@ module SentimentInsights
22
25
  # Extract named entities and build summarized output
23
26
  # @param entries [Array<Hash>] each with :answer and optional :segment
24
27
  # @return [Hash] { entities: [...], responses: [...] }
25
- def extract(entries, question: nil)
28
+ def extract(entries, question: nil, prompt: nil)
26
29
  entries = entries.to_a
27
- raw_result = @provider_client.extract_batch(entries, question: question)
30
+ raw_result = @provider_client.extract_batch(entries, question: question, prompt: prompt)
28
31
 
29
- puts "raw_result = #{raw_result}"
30
32
  responses = raw_result[:responses] || []
31
33
  entities = raw_result[:entities] || []
32
34
 
@@ -1,4 +1,5 @@
1
1
  require_relative '../clients/key_phrases/open_ai_client'
2
+ require_relative '../clients/key_phrases/claude_client'
2
3
  require_relative '../clients/key_phrases/aws_client'
3
4
 
4
5
  module SentimentInsights
@@ -11,6 +12,8 @@ module SentimentInsights
11
12
  @provider_client = provider_client || case effective_provider
12
13
  when :openai
13
14
  Clients::KeyPhrases::OpenAIClient.new
15
+ when :claude
16
+ Clients::KeyPhrases::ClaudeClient.new
14
17
  when :aws
15
18
  Clients::KeyPhrases::AwsClient.new
16
19
  when :sentimental
@@ -24,9 +27,9 @@ module SentimentInsights
24
27
  # @param entries [Array<Hash>] each with :answer and optional :segment
25
28
  # @param question [String, nil] optional context
26
29
  # @return [Hash] { phrases: [...], responses: [...] }
27
- def extract(entries, question: nil)
30
+ def extract(entries, question: nil, key_phrase_prompt: nil, sentiment_prompt: nil)
28
31
  entries = entries.to_a
29
- raw_result = @provider_client.extract_batch(entries, question: question)
32
+ raw_result = @provider_client.extract_batch(entries, question: question, key_phrase_prompt: key_phrase_prompt, sentiment_prompt: sentiment_prompt)
30
33
 
31
34
  responses = raw_result[:responses] || []
32
35
  phrases = raw_result[:phrases] || []
@@ -77,4 +80,4 @@ module SentimentInsights
77
80
  end
78
81
  end
79
82
  end
80
- end
83
+ end
@@ -1,5 +1,7 @@
1
1
  require_relative '../clients/sentiment/open_ai_client'
2
+ require_relative '../clients/sentiment/claude_client'
2
3
  require_relative '../clients/sentiment/sentimental_client'
4
+ require_relative '../clients/sentiment/aws_comprehend_client'
3
5
 
4
6
  module SentimentInsights
5
7
  module Insights
@@ -14,8 +16,9 @@ module SentimentInsights
14
16
  @provider_client = provider_client || case effective_provider
15
17
  when :openai
16
18
  Clients::Sentiment::OpenAIClient.new
19
+ when :claude
20
+ Clients::Sentiment::ClaudeClient.new
17
21
  when :aws
18
- require_relative '../clients/sentiment/aws_comprehend_client'
19
22
  Clients::Sentiment::AwsComprehendClient.new
20
23
  else
21
24
  Clients::Sentiment::SentimentalClient.new
@@ -27,11 +30,11 @@ module SentimentInsights
27
30
  # @param entries [Array<Hash>] An array of response hashes, each with :answer and :segment.
28
31
  # @param question [String, nil] Optional global question text or metadata for context.
29
32
  # @return [Hash] Summary of sentiment analysis (global, segment-wise, top comments, and annotated responses).
30
- def analyze(entries, question: nil)
33
+ def analyze(entries, question: nil, prompt: nil, batch_size: 50)
31
34
  # Ensure entries is an array of hashes with required keys
32
35
  entries = entries.to_a
33
36
  # Get sentiment results for each entry from the provider client
34
- results = @provider_client.analyze_entries(entries, question: question)
37
+ results = @provider_client.analyze_entries(entries, question: question, prompt: prompt, batch_size: batch_size)
35
38
 
36
39
  # Combine original entries with sentiment results
37
40
  annotated_responses = entries.each_with_index.map do |entry, idx|
@@ -1,3 +1,3 @@
1
1
  module SentimentInsights
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -17,8 +17,6 @@ Gem::Specification.new do |spec|
17
17
  if spec.respond_to?(:metadata)
18
18
  spec.metadata["homepage_uri"] = spec.homepage
19
19
  spec.metadata["source_code_uri"] = "https://github.com/mathrailsAI/sentiment_insights"
20
- spec.metadata["changelog_uri"] = "https://github.com/mathrailsAI/sentiment_insights/blob/main/CHANGELOG.md"
21
- # Removed allowed_push_host — usually not needed unless you have a private server
22
20
  else
23
21
  raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
24
22
  end
@@ -32,7 +30,7 @@ Gem::Specification.new do |spec|
32
30
 
33
31
  # Runtime dependencies
34
32
  spec.add_dependency "sentimental", "~> 1.4.0"
35
- spec.add_dependency "aws-sdk-comprehend", "~> 1.98.0"
33
+ spec.add_dependency "aws-sdk-comprehend", ">= 1.98.0"
36
34
 
37
35
  # Development dependencies
38
36
  spec.add_development_dependency "bundler", "~> 2.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sentiment_insights
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mathrailsAI
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-03 00:00:00.000000000 Z
11
+ date: 2025-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sentimental
@@ -28,14 +28,14 @@ dependencies:
28
28
  name: aws-sdk-comprehend
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: 1.98.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.98.0
41
41
  - !ruby/object:Gem::Dependency
@@ -117,10 +117,13 @@ files:
117
117
  - lib/sentiment_insights.rb
118
118
  - lib/sentiment_insights/analyzer.rb
119
119
  - lib/sentiment_insights/clients/entities/aws_client.rb
120
+ - lib/sentiment_insights/clients/entities/claude_client.rb
120
121
  - lib/sentiment_insights/clients/entities/open_ai_client.rb
121
122
  - lib/sentiment_insights/clients/key_phrases/aws_client.rb
123
+ - lib/sentiment_insights/clients/key_phrases/claude_client.rb
122
124
  - lib/sentiment_insights/clients/key_phrases/open_ai_client.rb
123
125
  - lib/sentiment_insights/clients/sentiment/aws_comprehend_client.rb
126
+ - lib/sentiment_insights/clients/sentiment/claude_client.rb
124
127
  - lib/sentiment_insights/clients/sentiment/open_ai_client.rb
125
128
  - lib/sentiment_insights/clients/sentiment/sentimental_client.rb
126
129
  - lib/sentiment_insights/configuration.rb
@@ -136,7 +139,6 @@ licenses:
136
139
  metadata:
137
140
  homepage_uri: https://github.com/mathrailsAI/sentiment_insights
138
141
  source_code_uri: https://github.com/mathrailsAI/sentiment_insights
139
- changelog_uri: https://github.com/mathrailsAI/sentiment_insights/blob/main/CHANGELOG.md
140
142
  post_install_message:
141
143
  rdoc_options: []
142
144
  require_paths: