Skip to content

Commit 5be5999

Browse files
authored
Merge pull request #3 from mathrailsAI/feature/integrate-claudeai
Integrate Claude AI as a new provider
2 parents bcf53ba + 4d821f7 commit 5be5999

File tree

11 files changed

+643
-18
lines changed

11 files changed

+643
-18
lines changed

README.md

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SentimentInsights
22

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.
44

55
---
66

@@ -43,7 +43,7 @@ gem install sentiment_insights
4343

4444
## Configuration
4545

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:
4747

4848
```ruby
4949
require 'sentiment_insights'
@@ -54,6 +54,12 @@ SentimentInsights.configure do |config|
5454
config.openai_api_key = ENV["OPENAI_API_KEY"]
5555
end
5656

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+
5763
# For AWS
5864
SentimentInsights.configure do |config|
5965
config.provider = :aws
@@ -68,6 +74,7 @@ end
6874

6975
Supported providers:
7076
- `:openai`
77+
- `:claude`
7178
- `:aws`
7279
- `:sentimental` (local fallback, limited feature set)
7380

@@ -121,11 +128,11 @@ result = insight.analyze(
121128
```
122129

123130
#### 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 |
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 |
129136

130137
#### 📾 Sample Output
131138

@@ -211,11 +218,11 @@ result = insight.extract(
211218
```
212219

213220
#### 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 |
219226

220227
#### 📾 Sample Output
221228

@@ -267,10 +274,10 @@ result = insight.extract(
267274
```
268275

269276
#### 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 |
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 |
274281

275282
#### 📾 Sample Output
276283

@@ -310,7 +317,7 @@ result = insight.extract(
310317

311318
## Provider Options & Custom Prompts
312319

313-
> ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai` provider.
320+
> ⚠️ All advanced options (`question`, `prompt`, `key_phrase_prompt`, `sentiment_prompt`, `batch_size`) apply only to the `:openai` and `:claude` providers.
314321
> They are safely ignored for `:aws` and `:sentimental`.
315322
316323
---
@@ -323,6 +330,12 @@ result = insight.extract(
323330
OPENAI_API_KEY=your_openai_key_here
324331
```
325332

333+
### Claude AI
334+
335+
```bash
336+
CLAUDE_API_KEY=your_claude_key_here
337+
```
338+
326339
### AWS Comprehend
327340

328341
```bash
@@ -373,6 +386,7 @@ Pull requests welcome! Please open an issue to discuss major changes first.
373386
## 💬 Acknowledgements
374387

375388
- [OpenAI GPT](https://platform.openai.com/docs)
389+
- [Claude AI](https://docs.anthropic.com/claude/reference/getting-started-with-the-api)
376390
- [AWS Comprehend](https://docs.aws.amazon.com/comprehend/latest/dg/what-is.html)
377391
- [Sentimental Gem](https://github.com/7compass/sentimental)
378392

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)