rubyllm-observ 0.6.6 → 0.6.7
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 +319 -1
- data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
- data/app/assets/javascripts/observ/controllers/index.js +29 -0
- data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
- data/app/assets/stylesheets/observ/_chat.scss +199 -0
- data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
- data/app/assets/stylesheets/observ/application.scss +1 -0
- data/app/controllers/observ/dataset_items_controller.rb +2 -2
- data/app/controllers/observ/dataset_runs_controller.rb +1 -1
- data/app/controllers/observ/datasets_controller.rb +2 -2
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +11 -3
- data/app/controllers/observ/scores_controller.rb +1 -1
- data/app/controllers/observ/traces_controller.rb +1 -1
- data/app/helpers/observ/application_helper.rb +1 -0
- data/app/helpers/observ/markdown_helper.rb +29 -0
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
- data/app/models/observ/embedding.rb +45 -0
- data/app/models/observ/image_generation.rb +38 -0
- data/app/models/observ/moderation.rb +40 -0
- data/app/models/observ/null_prompt.rb +49 -2
- data/app/models/observ/observation.rb +3 -1
- data/app/models/observ/session.rb +33 -0
- data/app/models/observ/trace.rb +90 -4
- data/app/models/observ/transcription.rb +38 -0
- data/app/services/observ/chat_instrumenter.rb +96 -6
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/guardrail_service.rb +9 -0
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +235 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- data/app/views/observ/chats/show.html.erb +9 -0
- data/app/views/observ/messages/_message.html.erb +1 -1
- data/app/views/observ/messages/create.turbo_stream.erb +1 -3
- data/app/views/observ/prompts/_config_editor.html.erb +115 -0
- data/app/views/observ/prompts/_form.html.erb +2 -13
- data/app/views/observ/prompts/_new_form.html.erb +2 -12
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
- data/lib/observ/engine.rb +7 -0
- data/lib/observ/version.rb +1 -1
- metadata +31 -1
|
@@ -129,6 +129,149 @@
|
|
|
129
129
|
color: inherit;
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
// Markdown lists
|
|
134
|
+
ul,
|
|
135
|
+
ol {
|
|
136
|
+
margin: 0.5rem 0;
|
|
137
|
+
padding-left: 1.5rem;
|
|
138
|
+
|
|
139
|
+
li {
|
|
140
|
+
margin-bottom: 0.25rem;
|
|
141
|
+
|
|
142
|
+
&:last-child {
|
|
143
|
+
margin-bottom: 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ul {
|
|
149
|
+
list-style-type: disc;
|
|
150
|
+
|
|
151
|
+
ul {
|
|
152
|
+
list-style-type: circle;
|
|
153
|
+
|
|
154
|
+
ul {
|
|
155
|
+
list-style-type: square;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ol {
|
|
161
|
+
list-style-type: decimal;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Markdown blockquotes
|
|
165
|
+
blockquote {
|
|
166
|
+
margin: 0.5rem 0;
|
|
167
|
+
padding: 0.5rem 1rem;
|
|
168
|
+
border-left: 3px solid $observ-border-strong;
|
|
169
|
+
background-color: $observ-bg-elevated;
|
|
170
|
+
color: $observ-text-secondary;
|
|
171
|
+
font-style: italic;
|
|
172
|
+
|
|
173
|
+
p {
|
|
174
|
+
margin: 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Markdown headers
|
|
179
|
+
h1,
|
|
180
|
+
h2,
|
|
181
|
+
h3,
|
|
182
|
+
h4,
|
|
183
|
+
h5,
|
|
184
|
+
h6 {
|
|
185
|
+
margin: 1rem 0 0.5rem 0;
|
|
186
|
+
font-weight: 600;
|
|
187
|
+
line-height: 1.3;
|
|
188
|
+
color: $observ-text-primary;
|
|
189
|
+
|
|
190
|
+
&:first-child {
|
|
191
|
+
margin-top: 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
h1 {
|
|
196
|
+
font-size: 1.5em;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
h2 {
|
|
200
|
+
font-size: 1.3em;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
h3 {
|
|
204
|
+
font-size: 1.15em;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
h4,
|
|
208
|
+
h5,
|
|
209
|
+
h6 {
|
|
210
|
+
font-size: 1em;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Markdown tables
|
|
214
|
+
table {
|
|
215
|
+
width: 100%;
|
|
216
|
+
border-collapse: collapse;
|
|
217
|
+
margin: 0.5rem 0;
|
|
218
|
+
font-size: 0.9em;
|
|
219
|
+
|
|
220
|
+
th,
|
|
221
|
+
td {
|
|
222
|
+
padding: 0.5rem;
|
|
223
|
+
border: 1px solid $observ-border-color;
|
|
224
|
+
text-align: left;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
th {
|
|
228
|
+
background-color: $observ-bg-elevated;
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
tr:nth-child(even) {
|
|
233
|
+
background-color: rgba($observ-bg-elevated, 0.5);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Markdown horizontal rule
|
|
238
|
+
hr {
|
|
239
|
+
margin: 1rem 0;
|
|
240
|
+
border: none;
|
|
241
|
+
border-top: 1px solid $observ-border-color;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Markdown strikethrough
|
|
245
|
+
del {
|
|
246
|
+
text-decoration: line-through;
|
|
247
|
+
color: $observ-text-muted;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Markdown links
|
|
251
|
+
a {
|
|
252
|
+
color: $observ-primary;
|
|
253
|
+
text-decoration: none;
|
|
254
|
+
|
|
255
|
+
&:hover {
|
|
256
|
+
text-decoration: underline;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Markdown highlight (==text==)
|
|
261
|
+
mark {
|
|
262
|
+
background-color: rgba($observ-warning, 0.3);
|
|
263
|
+
padding: 0.1rem 0.2rem;
|
|
264
|
+
border-radius: $observ-border-radius-sm;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Strong and emphasis
|
|
268
|
+
strong {
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
em {
|
|
273
|
+
font-style: italic;
|
|
274
|
+
}
|
|
132
275
|
}
|
|
133
276
|
|
|
134
277
|
&__tool-calls {
|
|
@@ -160,6 +303,62 @@
|
|
|
160
303
|
}
|
|
161
304
|
}
|
|
162
305
|
|
|
306
|
+
// ==========================================================================
|
|
307
|
+
// TYPING INDICATOR (AI thinking)
|
|
308
|
+
// ==========================================================================
|
|
309
|
+
.observ-typing-indicator {
|
|
310
|
+
display: flex;
|
|
311
|
+
align-items: center;
|
|
312
|
+
gap: $observ-spacing-sm;
|
|
313
|
+
padding: $observ-spacing-md $observ-spacing-lg;
|
|
314
|
+
background-color: $observ-bg-surface;
|
|
315
|
+
border-left: 3px solid $observ-success;
|
|
316
|
+
border-radius: $observ-border-radius-sm;
|
|
317
|
+
|
|
318
|
+
&__dots {
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 4px;
|
|
322
|
+
|
|
323
|
+
span {
|
|
324
|
+
width: 8px;
|
|
325
|
+
height: 8px;
|
|
326
|
+
background-color: $observ-success;
|
|
327
|
+
border-radius: 50%;
|
|
328
|
+
animation: observ-typing-bounce 1.4s ease-in-out infinite;
|
|
329
|
+
|
|
330
|
+
&:nth-child(1) {
|
|
331
|
+
animation-delay: 0s;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
&:nth-child(2) {
|
|
335
|
+
animation-delay: 0.2s;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
&:nth-child(3) {
|
|
339
|
+
animation-delay: 0.4s;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
&__text {
|
|
345
|
+
font-size: $observ-font-size-sm;
|
|
346
|
+
color: $observ-text-secondary;
|
|
347
|
+
font-style: italic;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
@keyframes observ-typing-bounce {
|
|
352
|
+
0%, 60%, 100% {
|
|
353
|
+
transform: translateY(0);
|
|
354
|
+
opacity: 0.4;
|
|
355
|
+
}
|
|
356
|
+
30% {
|
|
357
|
+
transform: translateY(-6px);
|
|
358
|
+
opacity: 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
163
362
|
// ==========================================================================
|
|
164
363
|
// MESSAGE FORM (chat input)
|
|
165
364
|
// ==========================================================================
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
@import 'variables';
|
|
2
|
+
@import 'namespace';
|
|
3
|
+
|
|
4
|
+
@include observ-scoped {
|
|
5
|
+
.observ-config-editor {
|
|
6
|
+
// Main container
|
|
7
|
+
&__fieldset {
|
|
8
|
+
border: 1px solid $observ-border-color;
|
|
9
|
+
border-radius: $observ-border-radius;
|
|
10
|
+
padding: $observ-spacing-lg;
|
|
11
|
+
margin: 0 0 $observ-spacing-lg 0;
|
|
12
|
+
background-color: $observ-bg-surface;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
&__legend {
|
|
16
|
+
font-size: $observ-font-size-sm;
|
|
17
|
+
font-weight: 600;
|
|
18
|
+
color: $observ-text-secondary;
|
|
19
|
+
text-transform: uppercase;
|
|
20
|
+
letter-spacing: 0.05em;
|
|
21
|
+
padding: 0 $observ-spacing-sm;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&__row {
|
|
25
|
+
margin-bottom: $observ-spacing-lg;
|
|
26
|
+
|
|
27
|
+
&:last-child {
|
|
28
|
+
margin-bottom: 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
&__input-group {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: $observ-spacing-sm;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&__number-input {
|
|
39
|
+
max-width: 150px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&__range-hint {
|
|
43
|
+
font-size: $observ-font-size-xs;
|
|
44
|
+
color: $observ-text-muted;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Advanced section (collapsible)
|
|
48
|
+
&__advanced {
|
|
49
|
+
border: 1px solid $observ-border-color;
|
|
50
|
+
border-radius: $observ-border-radius;
|
|
51
|
+
background-color: $observ-bg-surface;
|
|
52
|
+
|
|
53
|
+
&[open] {
|
|
54
|
+
.observ-config-editor__advanced-summary {
|
|
55
|
+
border-bottom: 1px solid $observ-border-subtle;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
&__advanced-summary {
|
|
61
|
+
padding: $observ-spacing-md $observ-spacing-lg;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-weight: 500;
|
|
64
|
+
color: $observ-text-secondary;
|
|
65
|
+
transition: $observ-transition;
|
|
66
|
+
list-style: none;
|
|
67
|
+
|
|
68
|
+
&::-webkit-details-marker {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&::before {
|
|
73
|
+
content: '▸ ';
|
|
74
|
+
display: inline-block;
|
|
75
|
+
transition: transform 0.2s ease;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&:hover {
|
|
79
|
+
color: $observ-text-primary;
|
|
80
|
+
background-color: $observ-bg-hover;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
&__advanced[open] &__advanced-summary::before {
|
|
85
|
+
transform: rotate(90deg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
&__advanced-content {
|
|
89
|
+
padding: $observ-spacing-lg;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validation status
|
|
93
|
+
&__status {
|
|
94
|
+
margin-top: $observ-spacing-sm;
|
|
95
|
+
padding: $observ-spacing-sm $observ-spacing-md;
|
|
96
|
+
border-radius: $observ-border-radius-sm;
|
|
97
|
+
font-size: $observ-font-size-sm;
|
|
98
|
+
font-family: $observ-font-family-mono;
|
|
99
|
+
|
|
100
|
+
&--success {
|
|
101
|
+
background-color: rgba($observ-success, 0.1);
|
|
102
|
+
color: lighten($observ-success, 15%);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
&--error {
|
|
106
|
+
background-color: rgba($observ-danger, 0.1);
|
|
107
|
+
color: lighten($observ-danger, 15%);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&--valid {
|
|
111
|
+
color: lighten($observ-success, 15%);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
&--invalid {
|
|
115
|
+
color: lighten($observ-danger, 15%);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -26,7 +26,7 @@ module Observ
|
|
|
26
26
|
redirect_to dataset_path(@dataset, tab: "items"),
|
|
27
27
|
notice: "Item added to dataset successfully."
|
|
28
28
|
else
|
|
29
|
-
render :new, status: :
|
|
29
|
+
render :new, status: :unprocessable_content
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -38,7 +38,7 @@ module Observ
|
|
|
38
38
|
redirect_to dataset_path(@dataset, tab: "items"),
|
|
39
39
|
notice: "Item updated successfully."
|
|
40
40
|
else
|
|
41
|
-
render :edit, status: :
|
|
41
|
+
render :edit, status: :unprocessable_content
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -40,7 +40,7 @@ module Observ
|
|
|
40
40
|
redirect_to dataset_run_path(@dataset, @run),
|
|
41
41
|
notice: "Run '#{@run.name}' created with #{@run.total_items} items. Execution will begin shortly."
|
|
42
42
|
else
|
|
43
|
-
render :new, status: :
|
|
43
|
+
render :new, status: :unprocessable_content
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -32,7 +32,7 @@ module Observ
|
|
|
32
32
|
redirect_to dataset_path(@dataset), notice: "Dataset '#{@dataset.name}' created successfully."
|
|
33
33
|
else
|
|
34
34
|
@agents = available_agents
|
|
35
|
-
render :new, status: :
|
|
35
|
+
render :new, status: :unprocessable_content
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
@@ -45,7 +45,7 @@ module Observ
|
|
|
45
45
|
redirect_to dataset_path(@dataset), notice: "Dataset '#{@dataset.name}' updated successfully."
|
|
46
46
|
else
|
|
47
47
|
@agents = available_agents
|
|
48
|
-
render :edit, status: :
|
|
48
|
+
render :edit, status: :unprocessable_content
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -5,7 +5,11 @@ module Observ
|
|
|
5
5
|
def create
|
|
6
6
|
return unless content.present?
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Create user message synchronously so it appears immediately
|
|
9
|
+
@message = @chat.messages.create!(role: :user, content: content)
|
|
10
|
+
|
|
11
|
+
# Enqueue job to get assistant response (will broadcast when complete)
|
|
12
|
+
ChatResponseJob.perform_later(@chat.id, @message.id)
|
|
9
13
|
|
|
10
14
|
respond_to do |format|
|
|
11
15
|
format.turbo_stream
|
|
@@ -103,7 +103,13 @@ module Observ
|
|
|
103
103
|
# Parse config JSON string before updating
|
|
104
104
|
update_params = prompt_params.except(:name, :version, :promote_to_production)
|
|
105
105
|
if update_params[:config].present?
|
|
106
|
-
|
|
106
|
+
parsed = parse_config(update_params[:config])
|
|
107
|
+
if parsed.nil?
|
|
108
|
+
# JSON parsing failed, re-render form with error
|
|
109
|
+
render :edit, status: :unprocessable_content
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
update_params[:config] = parsed
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
if @prompt.update(update_params)
|
|
@@ -180,8 +186,10 @@ module Observ
|
|
|
180
186
|
def parse_config(config_string)
|
|
181
187
|
return {} if config_string.blank?
|
|
182
188
|
JSON.parse(config_string)
|
|
183
|
-
rescue JSON::ParserError
|
|
184
|
-
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
# Add error to flash for display
|
|
191
|
+
flash.now[:alert] = "Invalid JSON configuration: #{e.message}"
|
|
192
|
+
nil
|
|
185
193
|
end
|
|
186
194
|
|
|
187
195
|
def current_user_identifier
|
|
@@ -26,7 +26,7 @@ module Observ
|
|
|
26
26
|
end
|
|
27
27
|
else
|
|
28
28
|
respond_to do |format|
|
|
29
|
-
format.turbo_stream { render :create_error, status: :
|
|
29
|
+
format.turbo_stream { render :create_error, status: :unprocessable_content }
|
|
30
30
|
format.html { redirect_back(fallback_location: root_path, alert: "Failed to save score.") }
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -60,7 +60,7 @@ module Observ
|
|
|
60
60
|
else
|
|
61
61
|
@datasets = Observ::Dataset.order(:name)
|
|
62
62
|
flash.now[:alert] = "Failed to add trace to dataset: #{@item.errors.full_messages.join(', ')}"
|
|
63
|
-
render :add_to_dataset_drawer, status: :
|
|
63
|
+
render :add_to_dataset_drawer, status: :unprocessable_content
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "redcarpet"
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
module MarkdownHelper
|
|
5
|
+
def render_markdown(content)
|
|
6
|
+
return "" if content.blank?
|
|
7
|
+
|
|
8
|
+
markdown_renderer.render(content).html_safe
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def markdown_renderer
|
|
14
|
+
@markdown_renderer ||= Redcarpet::Markdown.new(
|
|
15
|
+
Redcarpet::Render::HTML.new(
|
|
16
|
+
hard_wrap: true,
|
|
17
|
+
link_attributes: { target: "_blank", rel: "noopener noreferrer" }
|
|
18
|
+
),
|
|
19
|
+
autolink: true,
|
|
20
|
+
fenced_code_blocks: true,
|
|
21
|
+
tables: true,
|
|
22
|
+
strikethrough: true,
|
|
23
|
+
no_intra_emphasis: true,
|
|
24
|
+
highlight: true,
|
|
25
|
+
quote: true
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
module PromptsHelper
|
|
5
|
+
# Returns model options grouped by provider for use with grouped_collection_select
|
|
6
|
+
# or manually building optgroups
|
|
7
|
+
def chat_model_options_grouped
|
|
8
|
+
return [] unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
|
|
9
|
+
|
|
10
|
+
RubyLLM.models.chat_models
|
|
11
|
+
.group_by(&:provider)
|
|
12
|
+
.sort_by { |provider, _| provider }
|
|
13
|
+
.map do |provider, models|
|
|
14
|
+
[
|
|
15
|
+
provider.titleize,
|
|
16
|
+
models.sort_by(&:display_name).map { |m| [ m.display_name, m.id ] }
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Rails.logger.warn "[Observ] Failed to load RubyLLM models: #{e.message}"
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Extract config value with fallback
|
|
25
|
+
def config_value(prompt, key, default = nil)
|
|
26
|
+
config = prompt_config_hash(prompt)
|
|
27
|
+
return default unless config.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
config[key.to_s] || config[key.to_sym] || default
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Extract config hash from prompt or form object
|
|
35
|
+
def prompt_config_hash(prompt)
|
|
36
|
+
return {} unless prompt
|
|
37
|
+
|
|
38
|
+
# Handle PromptForm which has config as string
|
|
39
|
+
if prompt.respond_to?(:parsed_config)
|
|
40
|
+
prompt.parsed_config
|
|
41
|
+
elsif prompt.respond_to?(:config)
|
|
42
|
+
prompt.config
|
|
43
|
+
else
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class ModerationGuardrailJob < ApplicationJob
|
|
5
|
+
queue_as :moderation
|
|
6
|
+
|
|
7
|
+
# Retry configuration
|
|
8
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
9
|
+
discard_on ActiveRecord::RecordNotFound
|
|
10
|
+
|
|
11
|
+
# Process a single trace or session
|
|
12
|
+
#
|
|
13
|
+
# @param trace_id [Integer] ID of the trace to moderate
|
|
14
|
+
# @param session_id [Integer] ID of the session to moderate
|
|
15
|
+
# @param options [Hash] Options for moderation
|
|
16
|
+
# @option options [Boolean] :moderate_input Whether to moderate input (default: true)
|
|
17
|
+
# @option options [Boolean] :moderate_output Whether to moderate output (default: true)
|
|
18
|
+
# @option options [Boolean] :aggregate Whether to moderate aggregated session content
|
|
19
|
+
def perform(trace_id: nil, session_id: nil, **options)
|
|
20
|
+
if trace_id
|
|
21
|
+
moderate_trace(trace_id, options)
|
|
22
|
+
elsif session_id
|
|
23
|
+
moderate_session(session_id, options)
|
|
24
|
+
else
|
|
25
|
+
Rails.logger.warn "[ModerationGuardrailJob] No trace_id or session_id provided"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Class method to enqueue moderation for traces matching criteria
|
|
30
|
+
#
|
|
31
|
+
# @param scope [ActiveRecord::Relation] Scope of traces to moderate
|
|
32
|
+
# @param sample_percentage [Integer] Percentage of traces to sample (1-100)
|
|
33
|
+
def self.enqueue_for_scope(scope, sample_percentage: 100)
|
|
34
|
+
traces = scope.left_joins(:review_item)
|
|
35
|
+
.where(observ_review_items: { id: nil })
|
|
36
|
+
|
|
37
|
+
if sample_percentage < 100
|
|
38
|
+
sample_size = (traces.count * sample_percentage / 100.0).ceil
|
|
39
|
+
traces = traces.order("RANDOM()").limit(sample_size)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
traces.find_each do |trace|
|
|
43
|
+
perform_later(trace_id: trace.id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Enqueue moderation for user-facing sessions only
|
|
48
|
+
#
|
|
49
|
+
# @param since [Time] Only process sessions created after this time
|
|
50
|
+
def self.enqueue_user_facing(since: 1.hour.ago)
|
|
51
|
+
Observ::Session
|
|
52
|
+
.where(created_at: since..)
|
|
53
|
+
.where("metadata->>'user_facing' = ?", "true")
|
|
54
|
+
.find_each do |session|
|
|
55
|
+
perform_later(session_id: session.id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Enqueue moderation for specific agent types
|
|
60
|
+
#
|
|
61
|
+
# @param agent_types [Array<String>] Agent types to moderate
|
|
62
|
+
# @param since [Time] Only process sessions created after this time
|
|
63
|
+
def self.enqueue_for_agent_types(agent_types, since: 1.hour.ago)
|
|
64
|
+
Observ::Session
|
|
65
|
+
.where(created_at: since..)
|
|
66
|
+
.where("metadata->>'agent_type' IN (?)", agent_types)
|
|
67
|
+
.find_each do |session|
|
|
68
|
+
perform_later(session_id: session.id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def moderate_trace(trace_id, options)
|
|
75
|
+
trace = Observ::Trace.find(trace_id)
|
|
76
|
+
|
|
77
|
+
service = ModerationGuardrailService.new
|
|
78
|
+
result = service.evaluate_trace(
|
|
79
|
+
trace,
|
|
80
|
+
moderate_input: options.fetch(:moderate_input, true),
|
|
81
|
+
moderate_output: options.fetch(:moderate_output, true)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
log_result("Trace #{trace_id}", result)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def moderate_session(session_id, options)
|
|
88
|
+
session = Observ::Session.find(session_id)
|
|
89
|
+
|
|
90
|
+
service = ModerationGuardrailService.new
|
|
91
|
+
|
|
92
|
+
if options[:aggregate]
|
|
93
|
+
# Moderate aggregated session content
|
|
94
|
+
result = service.evaluate_session_content(session)
|
|
95
|
+
log_result("Session #{session_id} (aggregated)", result)
|
|
96
|
+
else
|
|
97
|
+
# Moderate each trace individually
|
|
98
|
+
results = service.evaluate_session(session)
|
|
99
|
+
flagged_count = results.count(&:flagged?)
|
|
100
|
+
Rails.logger.info "[ModerationGuardrailJob] Session #{session_id}: #{flagged_count}/#{results.size} traces flagged"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def log_result(identifier, result)
|
|
105
|
+
case result.action
|
|
106
|
+
when :flagged
|
|
107
|
+
Rails.logger.info "[ModerationGuardrailJob] #{identifier} flagged (#{result.priority}): #{result.details[:flagged_categories]}"
|
|
108
|
+
when :skipped
|
|
109
|
+
Rails.logger.debug "[ModerationGuardrailJob] #{identifier} skipped: #{result.reason}"
|
|
110
|
+
when :passed
|
|
111
|
+
Rails.logger.debug "[ModerationGuardrailJob] #{identifier} passed moderation"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class Embedding < Observation
|
|
5
|
+
# Set input texts for the embedding call
|
|
6
|
+
def set_input(texts)
|
|
7
|
+
update!(
|
|
8
|
+
input: texts.is_a?(Array) ? texts.to_json : texts
|
|
9
|
+
)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
|
|
13
|
+
merged_usage = (self.usage || {}).merge(usage.stringify_keys)
|
|
14
|
+
|
|
15
|
+
update!(
|
|
16
|
+
output: output.is_a?(String) ? output : output.to_json,
|
|
17
|
+
usage: merged_usage,
|
|
18
|
+
cost_usd: cost_usd,
|
|
19
|
+
end_time: Time.current,
|
|
20
|
+
status_message: status_message
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Embedding-specific helpers
|
|
25
|
+
def input_tokens
|
|
26
|
+
usage&.dig("input_tokens") || 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def total_tokens
|
|
30
|
+
input_tokens # Embeddings only have input tokens
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def batch_size
|
|
34
|
+
metadata&.dig("batch_size") || 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dimensions
|
|
38
|
+
metadata&.dig("dimensions")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vectors_count
|
|
42
|
+
metadata&.dig("vectors_count") || 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|