sentiment_insights 0.2.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 +1 -1
- data/README.md +31 -17
- data/lib/sentiment_insights/clients/entities/claude_client.rb +131 -0
- data/lib/sentiment_insights/clients/key_phrases/claude_client.rb +151 -0
- data/lib/sentiment_insights/clients/sentiment/claude_client.rb +126 -0
- data/lib/sentiment_insights/configuration.rb +2 -1
- data/lib/sentiment_insights/insights/entities.rb +3 -0
- data/lib/sentiment_insights/insights/key_phrases.rb +3 -0
- data/lib/sentiment_insights/insights/sentiment.rb +3 -0
- data/lib/sentiment_insights/version.rb +1 -1
- metadata +5 -2
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,6 +1,6 @@
|
|
1
1
|
# SentimentInsights
|
2
2
|
|
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.
|
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
|
|
@@ -43,7 +43,7 @@ gem install sentiment_insights
|
|
43
43
|
|
44
44
|
## Configuration
|
45
45
|
|
46
|
-
Configure the provider and (if using OpenAI or AWS) your API key:
|
46
|
+
Configure the provider and (if using OpenAI, Claude AI, or AWS) your API key:
|
47
47
|
|
48
48
|
```ruby
|
49
49
|
require 'sentiment_insights'
|
@@ -54,6 +54,12 @@ SentimentInsights.configure do |config|
|
|
54
54
|
config.openai_api_key = ENV["OPENAI_API_KEY"]
|
55
55
|
end
|
56
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
|
+
|
57
63
|
# For AWS
|
58
64
|
SentimentInsights.configure do |config|
|
59
65
|
config.provider = :aws
|
@@ -68,6 +74,7 @@ end
|
|
68
74
|
|
69
75
|
Supported providers:
|
70
76
|
- `:openai`
|
77
|
+
- `:claude`
|
71
78
|
- `:aws`
|
72
79
|
- `:sentimental` (local fallback, limited feature set)
|
73
80
|
|
@@ -121,11 +128,11 @@ result = insight.analyze(
|
|
121
128
|
```
|
122
129
|
|
123
130
|
#### 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
|
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 |
|
129
136
|
|
130
137
|
#### 📾 Sample Output
|
131
138
|
|
@@ -211,11 +218,11 @@ result = insight.extract(
|
|
211
218
|
```
|
212
219
|
|
213
220
|
#### 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
|
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 |
|
219
226
|
|
220
227
|
#### 📾 Sample Output
|
221
228
|
|
@@ -267,10 +274,10 @@ result = insight.extract(
|
|
267
274
|
```
|
268
275
|
|
269
276
|
#### 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
|
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 |
|
274
281
|
|
275
282
|
#### 📾 Sample Output
|
276
283
|
|
@@ -310,7 +317,7 @@ result = insight.extract(
|
|
310
317
|
|
311
318
|
## Provider Options & Custom Prompts
|
312
319
|
|
313
|
-
> ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai`
|
320
|
+
> ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai` and `:claude` providers.
|
314
321
|
> They are safely ignored for `:aws` and `:sentimental`.
|
315
322
|
|
316
323
|
---
|
@@ -323,6 +330,12 @@ result = insight.extract(
|
|
323
330
|
OPENAI_API_KEY=your_openai_key_here
|
324
331
|
```
|
325
332
|
|
333
|
+
### Claude AI
|
334
|
+
|
335
|
+
```bash
|
336
|
+
CLAUDE_API_KEY=your_claude_key_here
|
337
|
+
```
|
338
|
+
|
326
339
|
### AWS Comprehend
|
327
340
|
|
328
341
|
```bash
|
@@ -373,6 +386,7 @@ Pull requests welcome! Please open an issue to discuss major changes first.
|
|
373
386
|
## 💬 Acknowledgements
|
374
387
|
|
375
388
|
- [OpenAI GPT](https://platform.openai.com/docs)
|
389
|
+
- [Claude AI](https://docs.anthropic.com/claude/reference/getting-started-with-the-api)
|
376
390
|
- [AWS Comprehend](https://docs.aws.amazon.com/comprehend/latest/dg/what-is.html)
|
377
391
|
- [Sentimental Gem](https://github.com/7compass/sentimental)
|
378
392
|
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require_relative '../clients/sentiment/open_ai_client'
|
2
|
+
require_relative '../clients/sentiment/claude_client'
|
2
3
|
require_relative '../clients/sentiment/sentimental_client'
|
3
4
|
require_relative '../clients/sentiment/aws_comprehend_client'
|
4
5
|
|
@@ -15,6 +16,8 @@ module SentimentInsights
|
|
15
16
|
@provider_client = provider_client || case effective_provider
|
16
17
|
when :openai
|
17
18
|
Clients::Sentiment::OpenAIClient.new
|
19
|
+
when :claude
|
20
|
+
Clients::Sentiment::ClaudeClient.new
|
18
21
|
when :aws
|
19
22
|
Clients::Sentiment::AwsComprehendClient.new
|
20
23
|
else
|
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
|
@@ -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
|