coolhand 0.1.3 → 0.1.4
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/README.md +72 -6
- data/docs/elevenlabs.md +408 -0
- data/lib/coolhand/ruby/api_service.rb +72 -5
- data/lib/coolhand/ruby/configuration.rb +1 -1
- data/lib/coolhand/ruby/logger_service.rb +97 -0
- data/lib/coolhand/ruby/version.rb +1 -1
- data/lib/coolhand/ruby.rb +8 -0
- data/lib/coolhand.rb +4 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b8242433c93dec62fb833fc641c5917f6bb68c01e59e3061f2ac6300ec674c5
|
|
4
|
+
data.tar.gz: 7e6019bc86a3268df2ddebf4e0a9cfcabd32a72e3e8618a473c2035921aa0ff0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2255984a1e9238a8bfd1fedecf19aece7a552b25635873ec51803390ae679d206eb69dd0ff31d9df3d6169d78d9ba7503321eaf4bd048b2e59d3337b5c05758c
|
|
7
|
+
data.tar.gz: 44a5dc44c6a74385adf1e70057bf49f463c0401bea5f69b5850d697b414292fe0f9ebe208903f46325db80e0a638bf548c4a1dfcebe0d3b808f67a32b2167cdf
|
data/README.md
CHANGED
|
@@ -24,7 +24,7 @@ gem 'coolhand'
|
|
|
24
24
|
|
|
25
25
|
```ruby
|
|
26
26
|
# Add this configuration at the start of your application
|
|
27
|
-
require 'coolhand'
|
|
27
|
+
require 'coolhand/ruby'
|
|
28
28
|
|
|
29
29
|
Coolhand.configure do |config|
|
|
30
30
|
config.api_key = 'your_api_key_here'
|
|
@@ -52,7 +52,7 @@ end
|
|
|
52
52
|
Collect feedback on LLM responses to improve model performance:
|
|
53
53
|
|
|
54
54
|
```ruby
|
|
55
|
-
require 'coolhand'
|
|
55
|
+
require 'coolhand/ruby'
|
|
56
56
|
|
|
57
57
|
# Create feedback for an LLM response
|
|
58
58
|
feedback_service = Coolhand::Ruby::FeedbackService.new(Coolhand.configuration)
|
|
@@ -164,7 +164,7 @@ end
|
|
|
164
164
|
|
|
165
165
|
```ruby
|
|
166
166
|
require 'openai'
|
|
167
|
-
require 'coolhand'
|
|
167
|
+
require 'coolhand/ruby'
|
|
168
168
|
|
|
169
169
|
# Configure Coolhand
|
|
170
170
|
Coolhand.configure do |config|
|
|
@@ -190,7 +190,7 @@ puts response.dig("choices", 0, "message", "content")
|
|
|
190
190
|
|
|
191
191
|
```ruby
|
|
192
192
|
require 'anthropic'
|
|
193
|
-
require 'coolhand'
|
|
193
|
+
require 'coolhand/ruby'
|
|
194
194
|
|
|
195
195
|
# Configure Coolhand
|
|
196
196
|
Coolhand.configure do |config|
|
|
@@ -210,6 +210,50 @@ puts response["content"]
|
|
|
210
210
|
# Automatically logged to Coolhand!
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
+
## Logging Inbound Webhooks
|
|
214
|
+
|
|
215
|
+
For inbound webhooks (like audio transcripts or tool calls), the automatic interceptor won't capture them since they're incoming requests TO your application. In these cases, use the simple `forward_webhook` helper method:
|
|
216
|
+
|
|
217
|
+
### Webhook Forwarding Example (Recommended)
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
class WebhooksController < ApplicationController
|
|
221
|
+
def elevenlabs
|
|
222
|
+
raw_body = request.body.read
|
|
223
|
+
webhook_data = JSON.parse(raw_body)
|
|
224
|
+
|
|
225
|
+
# Forward to Coolhand with automatic field generation and binary filtering
|
|
226
|
+
Thread.new do
|
|
227
|
+
Coolhand.logger_service.forward_webhook(
|
|
228
|
+
webhook_body: webhook_data, # Required: webhook payload
|
|
229
|
+
source: "elevenlabs", # Required: service name
|
|
230
|
+
event_type: webhook_data["type"], # Optional: e.g., post_call_transcription
|
|
231
|
+
headers: request.headers, # Optional & recommended
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
render json: { status: "success" }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Required Parameters:**
|
|
241
|
+
- `webhook_body` - The webhook payload (Hash or parsed JSON)
|
|
242
|
+
- `source` - Service name (String, e.g., "elevenlabs", "stripe", "twilio")
|
|
243
|
+
|
|
244
|
+
**Optional Parameters:**
|
|
245
|
+
- `event_type` - Event type to append to URL (e.g., "post_call_transcription" → `webhook://elevenlabs/post_call_transcription`)
|
|
246
|
+
- `headers` - Request headers (automatically sanitized)
|
|
247
|
+
- `conversation_id`, `agent_id`, `metadata` - Custom fields for your tracking needs
|
|
248
|
+
|
|
249
|
+
**Error Handling:**
|
|
250
|
+
- **Silent mode = false**: Raises `ArgumentError` if required parameters are missing
|
|
251
|
+
- **Silent mode = true**: Logs warning and returns `false` if required parameters are missing
|
|
252
|
+
|
|
253
|
+
That's it! The `forward_webhook` method automatically:
|
|
254
|
+
- ✅ Generates unique ID and timestamp
|
|
255
|
+
- ✅ Filters out binary data (audio, images, etc.)
|
|
256
|
+
|
|
213
257
|
## What Gets Logged
|
|
214
258
|
|
|
215
259
|
The monitor captures:
|
|
@@ -221,6 +265,24 @@ The monitor captures:
|
|
|
221
265
|
|
|
222
266
|
Headers containing API keys are automatically sanitized for security.
|
|
223
267
|
|
|
268
|
+
## Binary Data Filtering
|
|
269
|
+
|
|
270
|
+
**Coolhand does not track or store binary data.** The gem automatically filters out:
|
|
271
|
+
|
|
272
|
+
- Audio files and data (`audio`, `audio_data`, `full_audio`, `raw_audio`)
|
|
273
|
+
- Image data (`image_data`, `image_content`)
|
|
274
|
+
- File content (`file_content`, `binary_content`)
|
|
275
|
+
- Base64 encoded data (`audio_base64`, `base64_data`)
|
|
276
|
+
- Voice samples and audio URLs
|
|
277
|
+
|
|
278
|
+
This ensures:
|
|
279
|
+
- ✅ **Smaller payloads** - Only text and metadata are sent
|
|
280
|
+
- ✅ **Faster processing** - No bandwidth wasted on binary data
|
|
281
|
+
- ✅ **Privacy focused** - Audio/video content never leaves your infrastructure
|
|
282
|
+
- ✅ **Clean logs** - Focus on conversational data, not media files
|
|
283
|
+
|
|
284
|
+
The filtering is automatic and applies to all monitored API calls and webhook logging.
|
|
285
|
+
|
|
224
286
|
## Supported Libraries
|
|
225
287
|
|
|
226
288
|
The monitor works with any Ruby library that uses Faraday for HTTP(S) requests to LLM APIs, including:
|
|
@@ -276,7 +338,7 @@ For standard Ruby scripts or non-Rails applications:
|
|
|
276
338
|
|
|
277
339
|
```ruby
|
|
278
340
|
#!/usr/bin/env ruby
|
|
279
|
-
require 'coolhand'
|
|
341
|
+
require 'coolhand/ruby'
|
|
280
342
|
|
|
281
343
|
Coolhand.configure do |config|
|
|
282
344
|
config.api_key = 'your_api_key_here' # Store securely, don't commit to git
|
|
@@ -305,6 +367,10 @@ The monitor handles errors gracefully:
|
|
|
305
367
|
- Invalid API keys will be reported but won't crash your app
|
|
306
368
|
- Network issues are handled with appropriate error messages
|
|
307
369
|
|
|
370
|
+
## Integration Guides
|
|
371
|
+
|
|
372
|
+
- **[ElevenLabs Integration](docs/elevenlabs.md)** - Complete guide for integrating ElevenLabs Conversational AI with webhook capture and feedback submission
|
|
373
|
+
|
|
308
374
|
## Security
|
|
309
375
|
|
|
310
376
|
- API keys in request headers are automatically redacted
|
|
@@ -324,4 +390,4 @@ The monitor handles errors gracefully:
|
|
|
324
390
|
|
|
325
391
|
## License
|
|
326
392
|
|
|
327
|
-
Apache-2.0
|
|
393
|
+
Apache-2.0
|
data/docs/elevenlabs.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# ElevenLabs Integration Guide
|
|
2
|
+
|
|
3
|
+
This guide explains how to integrate Coolhand with ElevenLabs Conversational AI, including webhook capture and feedback submission from the widget.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [Overview](#overview)
|
|
7
|
+
- [Webhook Integration](#webhook-integration)
|
|
8
|
+
- [Feedback Collection](#feedback-collection)
|
|
9
|
+
- [Complete Example](#complete-example)
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
ElevenLabs Conversational AI provides voice-based AI assistants. This integration allows you to:
|
|
14
|
+
1. Capture conversation data via webhooks
|
|
15
|
+
2. Fetch and submit user feedback to Coolhand
|
|
16
|
+
3. Track conversation quality and user satisfaction
|
|
17
|
+
|
|
18
|
+
## Webhook Integration
|
|
19
|
+
|
|
20
|
+
### Setting Up Webhook Capture
|
|
21
|
+
|
|
22
|
+
ElevenLabs sends webhooks when conversations occur. Here's how to capture and forward them to Coolhand:
|
|
23
|
+
|
|
24
|
+
#### 1. Create a Webhook Controller
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# app/controllers/webhooks_controller.rb
|
|
28
|
+
class WebhooksController < ApplicationController
|
|
29
|
+
skip_before_action :verify_authenticity_token
|
|
30
|
+
|
|
31
|
+
def elevenlabs
|
|
32
|
+
begin
|
|
33
|
+
# Forward the webhook to Coolhand
|
|
34
|
+
result = Coolhand.logger_service.forward_webhook(
|
|
35
|
+
webhook_body: request.raw_post || request.body.read,
|
|
36
|
+
source: "elevenlabs",
|
|
37
|
+
event_type: request.headers["X-ElevenLabs-Event"] || "conversation",
|
|
38
|
+
headers: request.headers
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if result
|
|
42
|
+
# Optionally save conversation data locally
|
|
43
|
+
save_conversation_transcript(params) if params[:type] == "conversation.finished"
|
|
44
|
+
|
|
45
|
+
render json: { status: "success" }, status: :ok
|
|
46
|
+
else
|
|
47
|
+
render json: { status: "error", message: "Failed to forward webhook" }, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
rescue => e
|
|
50
|
+
Rails.logger.error "Webhook processing error: #{e.message}"
|
|
51
|
+
render json: { status: "error", message: e.message }, status: :internal_server_error
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def save_conversation_transcript(webhook_data)
|
|
58
|
+
ConversationTranscript.create!(
|
|
59
|
+
conversation_id: webhook_data.dig(:conversation, :conversation_id),
|
|
60
|
+
transcript: extract_transcript(webhook_data),
|
|
61
|
+
user_message: extract_user_message(webhook_data),
|
|
62
|
+
assistant_message: extract_assistant_message(webhook_data),
|
|
63
|
+
timestamp: Time.current
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_transcript(data)
|
|
68
|
+
transcript = data.dig(:conversation, :transcript) || []
|
|
69
|
+
transcript.map { |entry| "#{entry[:role]}: #{entry[:message]}" }.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_user_message(data)
|
|
73
|
+
transcript = data.dig(:conversation, :transcript) || []
|
|
74
|
+
user_entries = transcript.select { |entry| entry[:role] == "user" }
|
|
75
|
+
user_entries.map { |entry| entry[:message] }.join(" ")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_assistant_message(data)
|
|
79
|
+
transcript = data.dig(:conversation, :transcript) || []
|
|
80
|
+
agent_entries = transcript.select { |entry| entry[:role] == "agent" }
|
|
81
|
+
agent_entries.map { |entry| entry[:message] }.join(" ")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
#### 2. Configure Routes
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# config/routes.rb
|
|
90
|
+
Rails.application.routes.draw do
|
|
91
|
+
post "webhooks/elevenlabs", to: "webhooks#elevenlabs"
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### 3. Configure Coolhand for ElevenLabs
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# config/initializers/coolhand.rb
|
|
99
|
+
Coolhand.configure do |config|
|
|
100
|
+
config.api_key = ENV["COOLHAND_API_KEY"]
|
|
101
|
+
config.environment = Rails.env
|
|
102
|
+
config.silent = false
|
|
103
|
+
|
|
104
|
+
# Include ElevenLabs API in intercept addresses
|
|
105
|
+
config.intercept_addresses = [
|
|
106
|
+
"api.openai.com",
|
|
107
|
+
"api.anthropic.com",
|
|
108
|
+
"api.elevenlabs.io"
|
|
109
|
+
]
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Feedback Collection
|
|
114
|
+
|
|
115
|
+
### Fetching Feedback from ElevenLabs API
|
|
116
|
+
|
|
117
|
+
ElevenLabs stores conversation feedback in the metadata field. Here's how to retrieve and submit it to Coolhand:
|
|
118
|
+
|
|
119
|
+
#### 1. Create ElevenLabs API Service
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# app/services/elevenlabs_api_service.rb
|
|
123
|
+
require 'net/http'
|
|
124
|
+
require 'uri'
|
|
125
|
+
require 'json'
|
|
126
|
+
|
|
127
|
+
class ElevenlabsApiService
|
|
128
|
+
BASE_URL = 'https://api.elevenlabs.io'
|
|
129
|
+
API_KEY = ENV['ELEVENLABS_API_KEY']
|
|
130
|
+
|
|
131
|
+
def initialize
|
|
132
|
+
unless API_KEY
|
|
133
|
+
raise "ElevenLabs API key is required. Please set ELEVENLABS_API_KEY environment variable."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Fetch conversation data from ElevenLabs
|
|
138
|
+
def fetch_conversation(conversation_id)
|
|
139
|
+
url = "#{BASE_URL}/v1/convai/conversations/#{conversation_id}"
|
|
140
|
+
uri = URI(url)
|
|
141
|
+
|
|
142
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
143
|
+
http.use_ssl = true
|
|
144
|
+
|
|
145
|
+
request = Net::HTTP::Get.new(uri)
|
|
146
|
+
request['xi-api-key'] = API_KEY
|
|
147
|
+
request['Content-Type'] = 'application/json'
|
|
148
|
+
|
|
149
|
+
response = http.request(request)
|
|
150
|
+
|
|
151
|
+
if response.code == '200'
|
|
152
|
+
JSON.parse(response.body)
|
|
153
|
+
else
|
|
154
|
+
Rails.logger.error "ElevenLabs API error: #{response.code} - #{response.body}"
|
|
155
|
+
raise "Failed to fetch conversation: #{response.code}"
|
|
156
|
+
end
|
|
157
|
+
rescue => e
|
|
158
|
+
Rails.logger.error "Error fetching conversation from ElevenLabs: #{e.message}"
|
|
159
|
+
raise e
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Extract feedback data from conversation response
|
|
163
|
+
def extract_feedback(conversation_data)
|
|
164
|
+
feedback_data = {}
|
|
165
|
+
|
|
166
|
+
# Feedback is located in metadata.feedback
|
|
167
|
+
if conversation_data.dig('metadata', 'feedback')
|
|
168
|
+
feedback = conversation_data['metadata']['feedback']
|
|
169
|
+
|
|
170
|
+
# Extract rating (numerical score)
|
|
171
|
+
if feedback['rating']
|
|
172
|
+
feedback_data[:feedback_rating] = feedback['rating']
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Extract comment (text feedback)
|
|
176
|
+
if feedback['comment'] && !feedback['comment'].empty?
|
|
177
|
+
feedback_data[:feedback_text] = feedback['comment']
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Return nil if no feedback found
|
|
182
|
+
return nil if feedback_data.empty?
|
|
183
|
+
|
|
184
|
+
feedback_data
|
|
185
|
+
rescue => e
|
|
186
|
+
Rails.logger.error "Error extracting feedback: #{e.message}"
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### 2. Create Feedback Controller
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# app/controllers/transcripts_controller.rb
|
|
196
|
+
class TranscriptsController < ApplicationController
|
|
197
|
+
def index
|
|
198
|
+
@transcripts = ConversationTranscript.order(timestamp: :desc)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fetch_feedback
|
|
202
|
+
conversation_id = params[:conversation_id]
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
# Initialize ElevenLabs API service
|
|
206
|
+
elevenlabs_service = ElevenlabsApiService.new
|
|
207
|
+
|
|
208
|
+
# Fetch conversation data from ElevenLabs
|
|
209
|
+
conversation_data = elevenlabs_service.fetch_conversation(conversation_id)
|
|
210
|
+
|
|
211
|
+
# Extract feedback from the response
|
|
212
|
+
feedback_data = elevenlabs_service.extract_feedback(conversation_data)
|
|
213
|
+
|
|
214
|
+
if feedback_data
|
|
215
|
+
# Submit feedback to Coolhand using llm_provider_unique_id for matching
|
|
216
|
+
coolhand_feedback = {
|
|
217
|
+
like: feedback_data[:feedback_rating],
|
|
218
|
+
explanation: feedback_data[:feedback_text],
|
|
219
|
+
llm_provider_unique_id: conversation_id # Important: use this field for matching
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result = Coolhand.feedback_service.create_feedback(coolhand_feedback)
|
|
223
|
+
|
|
224
|
+
if result
|
|
225
|
+
flash[:success] = "Feedback successfully fetched from ElevenLabs and submitted to Coolhand!"
|
|
226
|
+
else
|
|
227
|
+
flash[:error] = "Failed to submit feedback to Coolhand"
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
flash[:warning] = "No feedback found for this conversation in ElevenLabs"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
rescue => e
|
|
234
|
+
flash[:error] = "Error fetching feedback: #{e.message}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
redirect_to transcripts_path
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### 3. Create UI for Feedback Submission
|
|
243
|
+
|
|
244
|
+
```erb
|
|
245
|
+
<!-- app/views/transcripts/index.html.erb -->
|
|
246
|
+
<div class="container">
|
|
247
|
+
<h1>Voice Chat Transcripts</h1>
|
|
248
|
+
|
|
249
|
+
<% if @transcripts.any? %>
|
|
250
|
+
<div class="transcripts-list">
|
|
251
|
+
<% @transcripts.each do |transcript| %>
|
|
252
|
+
<div class="transcript-item">
|
|
253
|
+
<div class="transcript-header">
|
|
254
|
+
<div class="header-info">
|
|
255
|
+
<strong>Conversation ID:</strong> <%= transcript.conversation_id %>
|
|
256
|
+
<span class="timestamp"><%= transcript.timestamp&.strftime("%m/%d/%Y %I:%M %p") %></span>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="header-actions">
|
|
259
|
+
<%= form_with url: "/transcripts/fetch_feedback", method: :post, local: true do |form| %>
|
|
260
|
+
<%= form.hidden_field :conversation_id, value: transcript.conversation_id %>
|
|
261
|
+
<%= form.submit "Fetch Feedback", class: "btn btn-primary btn-sm" %>
|
|
262
|
+
<% end %>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<% if transcript.transcript.present? %>
|
|
267
|
+
<div class="message full-transcript">
|
|
268
|
+
<span class="label">Transcript:</span> <%= transcript.transcript %>
|
|
269
|
+
</div>
|
|
270
|
+
<% end %>
|
|
271
|
+
</div>
|
|
272
|
+
<% end %>
|
|
273
|
+
</div>
|
|
274
|
+
<% end %>
|
|
275
|
+
</div>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Complete Example
|
|
279
|
+
|
|
280
|
+
### Full Rails Integration
|
|
281
|
+
|
|
282
|
+
Here's a complete example showing how all the pieces work together:
|
|
283
|
+
|
|
284
|
+
#### 1. Database Migration
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# db/migrate/xxx_create_conversation_transcripts.rb
|
|
288
|
+
class CreateConversationTranscripts < ActiveRecord::Migration[7.0]
|
|
289
|
+
def change
|
|
290
|
+
create_table :conversation_transcripts do |t|
|
|
291
|
+
t.string :conversation_id, null: false
|
|
292
|
+
t.text :transcript
|
|
293
|
+
t.text :user_message
|
|
294
|
+
t.text :assistant_message
|
|
295
|
+
t.datetime :timestamp
|
|
296
|
+
t.timestamps
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
add_index :conversation_transcripts, :conversation_id, unique: true
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
#### 2. Model
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
# app/models/conversation_transcript.rb
|
|
308
|
+
class ConversationTranscript < ApplicationRecord
|
|
309
|
+
validates :conversation_id, presence: true, uniqueness: true
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### 3. ElevenLabs Widget Integration
|
|
314
|
+
|
|
315
|
+
```html
|
|
316
|
+
<!-- app/views/home/index.html.erb -->
|
|
317
|
+
<div id="voice-chat-container">
|
|
318
|
+
<!-- ElevenLabs Conversational AI Widget -->
|
|
319
|
+
<elevenlabs-convai agent-id="YOUR_AGENT_ID"></elevenlabs-convai>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<script src="https://unpkg.com/@elevenlabs/convai-widget-embed" async type="text/javascript"></script>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Key Points
|
|
326
|
+
|
|
327
|
+
### Webhook Best Practices
|
|
328
|
+
|
|
329
|
+
1. **Always forward raw webhook data**: Use `request.raw_post` to preserve the original payload
|
|
330
|
+
2. **Include headers**: ElevenLabs headers contain important metadata
|
|
331
|
+
3. **Set source as "elevenlabs"**: This helps Coolhand properly categorize the data
|
|
332
|
+
4. **Handle errors gracefully**: Log errors but always return 200 OK to prevent webhook retries
|
|
333
|
+
|
|
334
|
+
### Feedback Matching
|
|
335
|
+
|
|
336
|
+
1. **Use `llm_provider_unique_id`**: This field allows matching by the ElevenLabs conversation ID
|
|
337
|
+
2. **Don't use `llm_request_log_id`** unless you have the actual Coolhand log ID
|
|
338
|
+
3. **Feedback location**: Look for feedback in `metadata.feedback` in the API response
|
|
339
|
+
|
|
340
|
+
### Data Structure
|
|
341
|
+
|
|
342
|
+
ElevenLabs feedback structure in API response:
|
|
343
|
+
```json
|
|
344
|
+
{
|
|
345
|
+
"metadata": {
|
|
346
|
+
"feedback": {
|
|
347
|
+
"type": "rating",
|
|
348
|
+
"rating": 2,
|
|
349
|
+
"comment": "didn't work",
|
|
350
|
+
"likes": 0,
|
|
351
|
+
"dislikes": 0
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Environment Variables
|
|
358
|
+
|
|
359
|
+
Required environment variables:
|
|
360
|
+
```bash
|
|
361
|
+
COOLHAND_API_KEY=your_coolhand_api_key
|
|
362
|
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Troubleshooting
|
|
366
|
+
|
|
367
|
+
### Common Issues
|
|
368
|
+
|
|
369
|
+
1. **Webhook not being received**
|
|
370
|
+
- Verify your webhook URL is publicly accessible
|
|
371
|
+
- Check ElevenLabs dashboard for webhook configuration
|
|
372
|
+
- Look for errors in Rails logs
|
|
373
|
+
|
|
374
|
+
2. **Feedback not found**
|
|
375
|
+
- Ensure the user actually provided feedback in the widget
|
|
376
|
+
- Check that conversation ID is correct
|
|
377
|
+
- Verify API key has proper permissions
|
|
378
|
+
|
|
379
|
+
3. **Coolhand submission fails**
|
|
380
|
+
- Use `llm_provider_unique_id` instead of `llm_request_log_id`
|
|
381
|
+
- Ensure Coolhand API key is valid
|
|
382
|
+
- Check that feedback data is properly formatted
|
|
383
|
+
|
|
384
|
+
### Testing
|
|
385
|
+
|
|
386
|
+
Test the integration in Rails console:
|
|
387
|
+
```ruby
|
|
388
|
+
# Test ElevenLabs API connection
|
|
389
|
+
service = ElevenlabsApiService.new
|
|
390
|
+
conversation_id = "conv_xxx"
|
|
391
|
+
response = service.fetch_conversation(conversation_id)
|
|
392
|
+
feedback = service.extract_feedback(response)
|
|
393
|
+
|
|
394
|
+
# Test Coolhand submission
|
|
395
|
+
coolhand_feedback = {
|
|
396
|
+
like: feedback[:feedback_rating],
|
|
397
|
+
explanation: feedback[:feedback_text],
|
|
398
|
+
llm_provider_unique_id: conversation_id
|
|
399
|
+
}
|
|
400
|
+
result = Coolhand.feedback_service.create_feedback(coolhand_feedback)
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Support
|
|
404
|
+
|
|
405
|
+
For issues specific to:
|
|
406
|
+
- **Coolhand Ruby Gem**: [GitHub Issues](https://github.com/your-org/coolhand-ruby)
|
|
407
|
+
- **ElevenLabs API**: [ElevenLabs Documentation](https://docs.elevenlabs.io)
|
|
408
|
+
- **Integration Questions**: Contact your Coolhand support team
|
|
@@ -49,8 +49,18 @@ module Coolhand
|
|
|
49
49
|
|
|
50
50
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
51
51
|
headers = create_request_options(payload)
|
|
52
|
-
headers.each
|
|
53
|
-
|
|
52
|
+
headers.each do |key, value|
|
|
53
|
+
# Ensure header values are UTF-8 encoded
|
|
54
|
+
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
55
|
+
request[key] = encoded_value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
59
|
+
cleaned_payload = sanitize_payload_for_json(payload)
|
|
60
|
+
json_body = JSON.generate(cleaned_payload)
|
|
61
|
+
|
|
62
|
+
# Ensure the request body is properly encoded as UTF-8
|
|
63
|
+
request.body = json_body.force_encoding("UTF-8")
|
|
54
64
|
|
|
55
65
|
begin
|
|
56
66
|
response = http.request(request)
|
|
@@ -60,11 +70,12 @@ module Coolhand
|
|
|
60
70
|
log success_message
|
|
61
71
|
result
|
|
62
72
|
else
|
|
63
|
-
|
|
73
|
+
body = response.body.force_encoding("UTF-8") if response.body
|
|
74
|
+
puts "❌ Request failed: #{response.code} - #{body}"
|
|
64
75
|
nil
|
|
65
76
|
end
|
|
66
77
|
rescue StandardError => e
|
|
67
|
-
|
|
78
|
+
log "❌ Request error: #{e.message}"
|
|
68
79
|
nil
|
|
69
80
|
end
|
|
70
81
|
end
|
|
@@ -115,12 +126,68 @@ module Coolhand
|
|
|
115
126
|
result
|
|
116
127
|
end
|
|
117
128
|
|
|
129
|
+
# Filter list of known binary/problematic field names by service
|
|
130
|
+
BINARY_DATA_FILTERS = {
|
|
131
|
+
# ElevenLabs fields that contain binary audio data
|
|
132
|
+
elevenlabs: %w[
|
|
133
|
+
full_audio
|
|
134
|
+
audio
|
|
135
|
+
audio_data
|
|
136
|
+
raw_audio
|
|
137
|
+
audio_base64
|
|
138
|
+
voice_sample
|
|
139
|
+
audio_url
|
|
140
|
+
],
|
|
141
|
+
# OpenAI fields that might contain binary data
|
|
142
|
+
openai: %w[
|
|
143
|
+
file_content
|
|
144
|
+
audio_data
|
|
145
|
+
image_data
|
|
146
|
+
binary_content
|
|
147
|
+
]
|
|
148
|
+
}.freeze
|
|
149
|
+
|
|
118
150
|
private
|
|
119
151
|
|
|
152
|
+
# Get all filtered field names as a flat array
|
|
153
|
+
def filtered_field_names
|
|
154
|
+
@filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Recursively sanitize payload to remove known problematic fields
|
|
158
|
+
def sanitize_payload_for_json(obj)
|
|
159
|
+
case obj
|
|
160
|
+
when Hash
|
|
161
|
+
obj.each_with_object({}) do |(key, value), sanitized|
|
|
162
|
+
key_str = key.to_s.downcase
|
|
163
|
+
|
|
164
|
+
# Skip if key matches any filtered field name
|
|
165
|
+
next if filtered_field_names.any? { |filter| key_str.include?(filter) }
|
|
166
|
+
|
|
167
|
+
sanitized[key] = sanitize_payload_for_json(value)
|
|
168
|
+
end
|
|
169
|
+
when Array
|
|
170
|
+
obj.map { |item| sanitize_payload_for_json(item) }
|
|
171
|
+
else
|
|
172
|
+
obj
|
|
173
|
+
end
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
log "⚠️ Warning: Error sanitizing payload: #{e.message}"
|
|
176
|
+
obj
|
|
177
|
+
end
|
|
178
|
+
|
|
120
179
|
def log_feedback_info(feedback)
|
|
121
180
|
return if silent
|
|
122
181
|
|
|
123
|
-
|
|
182
|
+
# Log the appropriate identifier based on what was provided
|
|
183
|
+
if feedback[:llm_request_log_id]
|
|
184
|
+
puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
|
|
185
|
+
elsif feedback[:llm_provider_unique_id]
|
|
186
|
+
puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
|
|
187
|
+
else
|
|
188
|
+
puts "\n📝 CREATING FEEDBACK"
|
|
189
|
+
end
|
|
190
|
+
|
|
124
191
|
puts "👍/👎 Like: #{feedback[:like]}"
|
|
125
192
|
|
|
126
193
|
if feedback[:explanation]
|
|
@@ -11,7 +11,7 @@ module Coolhand
|
|
|
11
11
|
@environment = "production"
|
|
12
12
|
@api_key = nil
|
|
13
13
|
@silent = false
|
|
14
|
-
@intercept_addresses = ["api.openai.com", "api.anthropic.com"]
|
|
14
|
+
@intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.elevenlabs.io", ":generateContent"]
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# Custom setter that preserves defaults when nil/empty array is provided
|
|
@@ -12,6 +12,103 @@ module Coolhand
|
|
|
12
12
|
def log_to_api(captured_data)
|
|
13
13
|
create_log(captured_data, "auto-monitor")
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# Helper method for forwarding webhook data to Coolhand
|
|
17
|
+
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
18
|
+
# Validate required parameters
|
|
19
|
+
unless Coolhand.required_field?(webhook_body)
|
|
20
|
+
error_msg = "webhook_body is required and cannot be nil or empty"
|
|
21
|
+
if Coolhand.configuration.silent
|
|
22
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
23
|
+
return false
|
|
24
|
+
else
|
|
25
|
+
raise ArgumentError, error_msg
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless Coolhand.required_field?(source)
|
|
30
|
+
error_msg = "source is required and cannot be nil or empty"
|
|
31
|
+
if Coolhand.configuration.silent
|
|
32
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
33
|
+
return false
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, error_msg
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Auto-generate required fields unless provided
|
|
40
|
+
webhook_data = {
|
|
41
|
+
id: options[:id] || SecureRandom.uuid,
|
|
42
|
+
timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
|
|
43
|
+
method: options[:method] || "POST",
|
|
44
|
+
url: options[:url] || build_webhook_url(source, event_type),
|
|
45
|
+
headers: sanitize_headers(headers),
|
|
46
|
+
request_body: clean_webhook_body(webhook_body, source),
|
|
47
|
+
response_body: options[:response_body],
|
|
48
|
+
response_headers: options[:response_headers],
|
|
49
|
+
status_code: options[:status_code] || 200,
|
|
50
|
+
source: "#{source}_webhook"
|
|
51
|
+
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|
|
52
|
+
|
|
53
|
+
# Send to API asynchronously
|
|
54
|
+
log_to_api(webhook_data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_webhook_url(source, event_type)
|
|
60
|
+
base = "webhook://#{source}"
|
|
61
|
+
event_type ? "#{base}/#{event_type}" : base
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sanitize_headers(headers)
|
|
65
|
+
return {} if headers.nil?
|
|
66
|
+
return {} if headers.respond_to?(:empty?) && headers.empty?
|
|
67
|
+
return {} unless headers.respond_to?(:each)
|
|
68
|
+
|
|
69
|
+
# Handle Rails request headers or plain hash
|
|
70
|
+
clean_headers = {}
|
|
71
|
+
headers.each do |key, value|
|
|
72
|
+
next unless key && value
|
|
73
|
+
|
|
74
|
+
# Convert Rails HTTP_ prefix headers
|
|
75
|
+
clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
|
|
76
|
+
|
|
77
|
+
# Redact sensitive headers
|
|
78
|
+
clean_value = clean_key.match?(/key|token|secret|authorization|signature/i) ? "[REDACTED]" : value.to_s
|
|
79
|
+
clean_headers[clean_key] = clean_value
|
|
80
|
+
end
|
|
81
|
+
clean_headers
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clean_webhook_body(body, source)
|
|
85
|
+
# Service-specific binary field filters
|
|
86
|
+
binary_fields = case source.to_s.downcase
|
|
87
|
+
when "elevenlabs"
|
|
88
|
+
%w[full_audio audio audio_data raw_audio audio_base64 voice_sample audio_url]
|
|
89
|
+
when "twilio"
|
|
90
|
+
%w[recording_url media_url]
|
|
91
|
+
else
|
|
92
|
+
%w[audio_data image_data file_content binary_content]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
remove_binary_fields(body, binary_fields)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def remove_binary_fields(obj, fields_to_remove)
|
|
99
|
+
case obj
|
|
100
|
+
when Hash
|
|
101
|
+
obj.each_with_object({}) do |(key, value), cleaned|
|
|
102
|
+
next if fields_to_remove.any? { |field| key.to_s.downcase.include?(field) }
|
|
103
|
+
|
|
104
|
+
cleaned[key] = remove_binary_fields(value, fields_to_remove)
|
|
105
|
+
end
|
|
106
|
+
when Array
|
|
107
|
+
obj.map { |item| remove_binary_fields(item, fields_to_remove) }
|
|
108
|
+
else
|
|
109
|
+
obj
|
|
110
|
+
end
|
|
111
|
+
end
|
|
15
112
|
end
|
|
16
113
|
end
|
|
17
114
|
end
|
data/lib/coolhand/ruby.rb
CHANGED
|
@@ -78,5 +78,13 @@ module Coolhand
|
|
|
78
78
|
def logger_service
|
|
79
79
|
Ruby::LoggerService.new
|
|
80
80
|
end
|
|
81
|
+
|
|
82
|
+
def required_field?(value)
|
|
83
|
+
return false if value.nil?
|
|
84
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
85
|
+
return false if value.to_s.strip.empty?
|
|
86
|
+
|
|
87
|
+
true
|
|
88
|
+
end
|
|
81
89
|
end
|
|
82
90
|
end
|
data/lib/coolhand.rb
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2025-
|
|
12
|
+
date: 2025-12-06 00:00:00.000000000 Z
|
|
13
13
|
dependencies: []
|
|
14
14
|
description: A Ruby gem to automatically monitor and log external LLM requests. It
|
|
15
15
|
patches Net::HTTP to capture request and response data.
|
|
@@ -28,6 +28,8 @@ files:
|
|
|
28
28
|
- README.md
|
|
29
29
|
- Rakefile
|
|
30
30
|
- coolhand-ruby.gemspec
|
|
31
|
+
- docs/elevenlabs.md
|
|
32
|
+
- lib/coolhand.rb
|
|
31
33
|
- lib/coolhand/ruby.rb
|
|
32
34
|
- lib/coolhand/ruby/api_service.rb
|
|
33
35
|
- lib/coolhand/ruby/collector.rb
|