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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +206 -79
- data/lib/sentiment_insights/clients/entities/aws_client.rb +1 -1
- data/lib/sentiment_insights/clients/entities/claude_client.rb +131 -0
- 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/claude_client.rb +151 -0
- 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/claude_client.rb +126 -0
- 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/configuration.rb +2 -1
- data/lib/sentiment_insights/insights/entities.rb +5 -3
- data/lib/sentiment_insights/insights/key_phrases.rb +6 -3
- data/lib/sentiment_insights/insights/sentiment.rb +6 -3
- data/lib/sentiment_insights/version.rb +1 -1
- data/sentiment_insights.gemspec +1 -3
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ab2970402e6ee32bfa46d70caaafa135d17e780528c76ab7d42c5a6d9a62acb5
|
4
|
+
data.tar.gz: ca8fcc08f054c4f348d53697c3a4fed6a9b36485d628c7b89b0112cc081b332f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1af62b7158097b907df3609521c75997f2919294c7a725f12bc7243d9b89721b66d1a0c982fc3daab1ab7e5623ed140db5360355b84e67b309d1392aaf515d21
|
7
|
+
data.tar.gz: e42bf88ffd443546a20617cfcab9818982c19180425047306e8ee59e022bc0d0de50b3908d2d3f2fed587fb373d31e6f4d7d7d799b79c40c10cbd33cb09bc964
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,101 @@
|
|
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, Claude AI, 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, 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
|
-
|
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
|
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
|
-
|
246
|
+
---
|
89
247
|
|
90
|
-
|
248
|
+
### Entity Extraction
|
91
249
|
|
92
250
|
```ruby
|
93
251
|
insight = SentimentInsights::Insights::Entities.new
|
94
|
-
result = insight.extract(entries
|
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
|
-
##
|
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
|
-
|
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)
|
@@ -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
|
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
|
|
@@ -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
|
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|
|
@@ -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
|
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
|
@@ -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|
|
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.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-
|
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:
|