sentiment_insights 0.1.0 → 0.2.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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +192 -79
- data/lib/sentiment_insights/clients/entities/aws_client.rb +1 -1
- data/lib/sentiment_insights/clients/entities/open_ai_client.rb +24 -7
- data/lib/sentiment_insights/clients/key_phrases/aws_client.rb +1 -1
- data/lib/sentiment_insights/clients/key_phrases/open_ai_client.rb +28 -10
- data/lib/sentiment_insights/clients/sentiment/aws_comprehend_client.rb +1 -1
- data/lib/sentiment_insights/clients/sentiment/open_ai_client.rb +66 -56
- data/lib/sentiment_insights/clients/sentiment/sentimental_client.rb +1 -1
- data/lib/sentiment_insights/insights/entities.rb +2 -3
- data/lib/sentiment_insights/insights/key_phrases.rb +3 -3
- data/lib/sentiment_insights/insights/sentiment.rb +3 -3
- data/lib/sentiment_insights/version.rb +1 -1
- data/sentiment_insights.gemspec +1 -3
- metadata +4 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '008d6ea6d7343f377abedc02ac6c28bd072c21f8f69d150fc5c5a2dd744fbf96'
|
4
|
+
data.tar.gz: 1cd6db74422570ea2ec02a6e9b8976cd9b8a91120d271ee900057431a4b4665e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e393148073465c4f022bff7440d11df6e2249b77aee022a2f7c6a6d941a7271c1541b331688f00c5d9cdf3928fcfb45dec8a087ea151316595830b769c9974e1
|
7
|
+
data.tar.gz: b8229c858ff529e53144214d567d679eb608c3f03dc5779a3541f07a2167e3eba6c56090bc0260b1c92ca7a32e4b2572ecf8ce5db315802c2a44b7f878c04e2c
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,94 @@
|
|
1
|
-
# SentimentInsights
|
1
|
+
# SentimentInsights
|
2
2
|
|
3
|
-
**SentimentInsights** is a Ruby gem
|
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 and AWS.
|
4
4
|
|
5
5
|
---
|
6
6
|
|
7
|
-
##
|
7
|
+
## Table of Contents
|
8
8
|
|
9
|
-
|
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 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 AWS
|
58
|
+
SentimentInsights.configure do |config|
|
59
|
+
config.provider = :aws
|
60
|
+
config.aws_region = 'us-east-1'
|
61
|
+
end
|
62
|
+
|
63
|
+
# For sentimental
|
64
|
+
SentimentInsights.configure do |config|
|
65
|
+
config.provider = :sentimental
|
66
|
+
end
|
67
|
+
```
|
68
|
+
|
69
|
+
Supported providers:
|
70
|
+
- `:openai`
|
71
|
+
- `:aws`
|
72
|
+
- `:sentimental` (local fallback, limited feature set)
|
73
|
+
|
74
|
+
---
|
75
|
+
|
76
|
+
## Usage
|
77
|
+
|
78
|
+
Data entries should be hashes with at least an `:answer` key. Optionally include segmentation info under `:segment`.
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
entries = [
|
82
|
+
{ answer: "Amazon Checkout was smooth!", segment: { age_group: "18-25", gender: "Female" } },
|
83
|
+
{ answer: "Walmart Shipping was delayed.", segment: { age_group: "18-25", gender: "Female" } },
|
84
|
+
{ answer: "Target Support was decent.", segment: { age_group: "26-35", gender: "Male" } },
|
85
|
+
{ answer: "Loved the product!", segment: { age_group: "18-25", gender: "Male" } }
|
86
|
+
]
|
87
|
+
```
|
88
|
+
|
89
|
+
---
|
90
|
+
|
91
|
+
### Sentiment Analysis
|
10
92
|
|
11
93
|
Quickly classify and summarize user responses as positive, neutral, or negative — globally or by segment (e.g., age, region).
|
12
94
|
|
@@ -17,6 +99,34 @@ insight = SentimentInsights::Insights::Sentiment.new
|
|
17
99
|
result = insight.analyze(entries)
|
18
100
|
```
|
19
101
|
|
102
|
+
With options:
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
custom_prompt = <<~PROMPT
|
106
|
+
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).
|
107
|
+
|
108
|
+
Reply with a numbered list like:
|
109
|
+
1. Positive (0.9)
|
110
|
+
2. Negative (-0.8)
|
111
|
+
3. Neutral (0.0)
|
112
|
+
PROMPT
|
113
|
+
|
114
|
+
insight = SentimentInsights::Insights::Sentiment.new
|
115
|
+
result = insight.analyze(
|
116
|
+
entries,
|
117
|
+
question: "How was your experience today?",
|
118
|
+
prompt: custom_prompt,
|
119
|
+
batch_size: 10
|
120
|
+
)
|
121
|
+
```
|
122
|
+
|
123
|
+
#### Available Options (`analyze`)
|
124
|
+
| Option | Type | Description | Provider |
|
125
|
+
|---------------|---------|------------------------------------------------------------------------|-------------|
|
126
|
+
| `question` | String | Contextual question for the batch | OpenAI only |
|
127
|
+
| `prompt` | String | Custom prompt text for LLM | OpenAI only |
|
128
|
+
| `batch_size` | Integer | Number of entries per OpenAI completion call (default: 50) | OpenAI only |
|
129
|
+
|
20
130
|
#### 📾 Sample Output
|
21
131
|
|
22
132
|
```ruby
|
@@ -59,15 +169,56 @@ result = insight.analyze(entries)
|
|
59
169
|
:sentiment_score=>0.9}]}}
|
60
170
|
```
|
61
171
|
|
62
|
-
|
172
|
+
---
|
173
|
+
|
174
|
+
### Key Phrase Extraction
|
63
175
|
|
64
176
|
Extract frequently mentioned phrases and identify their associated sentiment and segment spread.
|
65
177
|
|
66
178
|
```ruby
|
67
179
|
insight = SentimentInsights::Insights::KeyPhrases.new
|
68
|
-
result = insight.extract(entries
|
180
|
+
result = insight.extract(entries)
|
69
181
|
```
|
70
182
|
|
183
|
+
With options:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
key_phrase_prompt = <<~PROMPT.strip
|
187
|
+
Extract the most important key phrases that represent the main ideas or feedback in the sentence below.
|
188
|
+
Ignore stop words and return each key phrase in its natural form, comma-separated.
|
189
|
+
|
190
|
+
Question: %{question}
|
191
|
+
|
192
|
+
Text: %{text}
|
193
|
+
PROMPT
|
194
|
+
|
195
|
+
sentiment_prompt = <<~PROMPT
|
196
|
+
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).
|
197
|
+
|
198
|
+
Reply with a numbered list like:
|
199
|
+
1. Positive (0.9)
|
200
|
+
2. Negative (-0.8)
|
201
|
+
3. Neutral (0.0)
|
202
|
+
PROMPT
|
203
|
+
|
204
|
+
insight = SentimentInsights::Insights::KeyPhrases.new
|
205
|
+
result = insight.extract(
|
206
|
+
entries,
|
207
|
+
question: "What are the recurring themes?",
|
208
|
+
key_phrase_prompt: key_phrase_prompt,
|
209
|
+
sentiment_prompt: sentiment_prompt
|
210
|
+
)
|
211
|
+
```
|
212
|
+
|
213
|
+
#### Available Options (`extract`)
|
214
|
+
| Option | Type | Description | Provider |
|
215
|
+
|--------------------|---------|------------------------------------------------------------|--------------|
|
216
|
+
| `question` | String | Context question to help guide phrase extraction | OpenAI only |
|
217
|
+
| `key_phrase_prompt`| String | Custom prompt for extracting key phrases | OpenAI only |
|
218
|
+
| `sentiment_prompt` | String | Custom prompt for classifying tone of extracted phrases | OpenAI only |
|
219
|
+
|
220
|
+
#### 📾 Sample Output
|
221
|
+
|
71
222
|
```ruby
|
72
223
|
{:phrases=>
|
73
224
|
[{:phrase=>"everlane",
|
@@ -85,15 +236,44 @@ result = insight.extract(entries, question: question)
|
|
85
236
|
:segment=>{:age=>"25-34", :region=>"West"}}]}
|
86
237
|
```
|
87
238
|
|
88
|
-
|
239
|
+
---
|
89
240
|
|
90
|
-
|
241
|
+
### Entity Extraction
|
91
242
|
|
92
243
|
```ruby
|
93
244
|
insight = SentimentInsights::Insights::Entities.new
|
94
|
-
result = insight.extract(entries
|
245
|
+
result = insight.extract(entries)
|
95
246
|
```
|
96
247
|
|
248
|
+
With options:
|
249
|
+
|
250
|
+
```ruby
|
251
|
+
entity_prompt = <<~PROMPT.strip
|
252
|
+
Identify brand names, competitors, and product references in the sentence below.
|
253
|
+
Return each as a JSON object with "text" and "type" (e.g., BRAND, PRODUCT, COMPANY).
|
254
|
+
|
255
|
+
Question: %{question}
|
256
|
+
|
257
|
+
Sentence: "%{text}"
|
258
|
+
PROMPT
|
259
|
+
|
260
|
+
insight = SentimentInsights::Insights::Entities.new
|
261
|
+
result = insight.extract(
|
262
|
+
entries,
|
263
|
+
question: "Which products or brands are mentioned?",
|
264
|
+
prompt: entity_prompt
|
265
|
+
)
|
266
|
+
|
267
|
+
```
|
268
|
+
|
269
|
+
#### Available Options (`extract`)
|
270
|
+
| Option | Type | Description | Provider |
|
271
|
+
|-------------|---------|---------------------------------------------------|--------------|
|
272
|
+
| `question` | String | Context question to guide entity extraction | OpenAI only |
|
273
|
+
| `prompt` | String | Custom instructions for OpenAI entity extraction | OpenAI only |
|
274
|
+
|
275
|
+
#### 📾 Sample Output
|
276
|
+
|
97
277
|
```ruby
|
98
278
|
{:entities=>
|
99
279
|
[{:entity=>"everlane",
|
@@ -126,73 +306,12 @@ result = insight.extract(entries, question: question)
|
|
126
306
|
"the response was copy-paste and didn't address my issue directly.",
|
127
307
|
:segment=>{:age=>"45-54", :region=>"Midwest"}}]}
|
128
308
|
```
|
129
|
-
|
130
|
-
### ✅ 4. Topic Modeling *(Coming Soon)*
|
131
|
-
|
132
|
-
Automatically group similar responses into topics and subthemes.
|
133
|
-
|
134
|
-
---
|
135
|
-
|
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 | ❌ |
|
144
|
-
|
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
309
|
---
|
179
310
|
|
180
|
-
##
|
311
|
+
## Provider Options & Custom Prompts
|
181
312
|
|
182
|
-
|
183
|
-
|
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
|
-
```
|
313
|
+
> ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai` provider.
|
314
|
+
> They are safely ignored for `:aws` and `:sentimental`.
|
196
315
|
|
197
316
|
---
|
198
317
|
|
@@ -217,7 +336,6 @@ AWS_REGION=us-east-1
|
|
217
336
|
## 💎 Ruby Compatibility
|
218
337
|
|
219
338
|
- **Minimum Ruby version:** 2.7
|
220
|
-
- Tested on: 2.7, 3.0, 3.1, 3.2
|
221
339
|
|
222
340
|
---
|
223
341
|
|
@@ -258,8 +376,3 @@ Pull requests welcome! Please open an issue to discuss major changes first.
|
|
258
376
|
- [AWS Comprehend](https://docs.aws.amazon.com/comprehend/latest/dg/what-is.html)
|
259
377
|
- [Sentimental Gem](https://github.com/7compass/sentimental)
|
260
378
|
|
261
|
-
---
|
262
|
-
|
263
|
-
## 📢 Questions?
|
264
|
-
|
265
|
-
File an issue or reach out on [GitHub](https://github.com/your-repo)
|
@@ -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
|
59
|
-
|
60
|
-
|
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
|
-
|
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
|
|
@@ -8,7 +8,7 @@ module SentimentInsights
|
|
8
8
|
module Clients
|
9
9
|
module KeyPhrases
|
10
10
|
class OpenAIClient
|
11
|
-
DEFAULT_MODEL
|
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
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
-
|
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|
|
@@ -7,7 +7,7 @@ module SentimentInsights
|
|
7
7
|
module Clients
|
8
8
|
module Sentiment
|
9
9
|
class OpenAIClient
|
10
|
-
DEFAULT_MODEL
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
attempt
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
74
|
+
all_sentiments
|
68
75
|
end
|
69
76
|
|
70
77
|
private
|
71
78
|
|
72
|
-
def build_prompt_content(entries, question)
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
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
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
93
|
+
content << instructions.strip + "\n\n"
|
84
94
|
|
85
95
|
entries.each_with_index do |entry, index|
|
86
|
-
|
96
|
+
content << "#{index + 1}. \"#{entry[:answer]}\"\n"
|
87
97
|
end
|
88
98
|
|
89
|
-
|
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
|
@@ -22,11 +22,10 @@ module SentimentInsights
|
|
22
22
|
# Extract named entities and build summarized output
|
23
23
|
# @param entries [Array<Hash>] each with :answer and optional :segment
|
24
24
|
# @return [Hash] { entities: [...], responses: [...] }
|
25
|
-
def extract(entries, question: nil)
|
25
|
+
def extract(entries, question: nil, prompt: nil)
|
26
26
|
entries = entries.to_a
|
27
|
-
raw_result = @provider_client.extract_batch(entries, question: question)
|
27
|
+
raw_result = @provider_client.extract_batch(entries, question: question, prompt: prompt)
|
28
28
|
|
29
|
-
puts "raw_result = #{raw_result}"
|
30
29
|
responses = raw_result[:responses] || []
|
31
30
|
entities = raw_result[:entities] || []
|
32
31
|
|
@@ -24,9 +24,9 @@ module SentimentInsights
|
|
24
24
|
# @param entries [Array<Hash>] each with :answer and optional :segment
|
25
25
|
# @param question [String, nil] optional context
|
26
26
|
# @return [Hash] { phrases: [...], responses: [...] }
|
27
|
-
def extract(entries, question: nil)
|
27
|
+
def extract(entries, question: nil, key_phrase_prompt: nil, sentiment_prompt: nil)
|
28
28
|
entries = entries.to_a
|
29
|
-
raw_result = @provider_client.extract_batch(entries, question: question)
|
29
|
+
raw_result = @provider_client.extract_batch(entries, question: question, key_phrase_prompt: key_phrase_prompt, sentiment_prompt: sentiment_prompt)
|
30
30
|
|
31
31
|
responses = raw_result[:responses] || []
|
32
32
|
phrases = raw_result[:phrases] || []
|
@@ -77,4 +77,4 @@ module SentimentInsights
|
|
77
77
|
end
|
78
78
|
end
|
79
79
|
end
|
80
|
-
end
|
80
|
+
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require_relative '../clients/sentiment/open_ai_client'
|
2
2
|
require_relative '../clients/sentiment/sentimental_client'
|
3
|
+
require_relative '../clients/sentiment/aws_comprehend_client'
|
3
4
|
|
4
5
|
module SentimentInsights
|
5
6
|
module Insights
|
@@ -15,7 +16,6 @@ module SentimentInsights
|
|
15
16
|
when :openai
|
16
17
|
Clients::Sentiment::OpenAIClient.new
|
17
18
|
when :aws
|
18
|
-
require_relative '../clients/sentiment/aws_comprehend_client'
|
19
19
|
Clients::Sentiment::AwsComprehendClient.new
|
20
20
|
else
|
21
21
|
Clients::Sentiment::SentimentalClient.new
|
@@ -27,11 +27,11 @@ module SentimentInsights
|
|
27
27
|
# @param entries [Array<Hash>] An array of response hashes, each with :answer and :segment.
|
28
28
|
# @param question [String, nil] Optional global question text or metadata for context.
|
29
29
|
# @return [Hash] Summary of sentiment analysis (global, segment-wise, top comments, and annotated responses).
|
30
|
-
def analyze(entries, question: nil)
|
30
|
+
def analyze(entries, question: nil, prompt: nil, batch_size: 50)
|
31
31
|
# Ensure entries is an array of hashes with required keys
|
32
32
|
entries = entries.to_a
|
33
33
|
# Get sentiment results for each entry from the provider client
|
34
|
-
results = @provider_client.analyze_entries(entries, question: question)
|
34
|
+
results = @provider_client.analyze_entries(entries, question: question, prompt: prompt, batch_size: batch_size)
|
35
35
|
|
36
36
|
# Combine original entries with sentiment results
|
37
37
|
annotated_responses = entries.each_with_index.map do |entry, idx|
|
data/sentiment_insights.gemspec
CHANGED
@@ -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", "
|
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.
|
4
|
+
version: 0.2.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-
|
11
|
+
date: 2025-05-26 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
|
@@ -136,7 +136,6 @@ licenses:
|
|
136
136
|
metadata:
|
137
137
|
homepage_uri: https://github.com/mathrailsAI/sentiment_insights
|
138
138
|
source_code_uri: https://github.com/mathrailsAI/sentiment_insights
|
139
|
-
changelog_uri: https://github.com/mathrailsAI/sentiment_insights/blob/main/CHANGELOG.md
|
140
139
|
post_install_message:
|
141
140
|
rdoc_options: []
|
142
141
|
require_paths:
|