rubyllm-observ 0.5.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 +7 -0
- data/README.md +778 -0
- data/Rakefile +49 -0
- data/app/assets/javascripts/observ/application.js +12 -0
- data/app/assets/javascripts/observ/controllers/autoscroll_controller.js +33 -0
- data/app/assets/javascripts/observ/controllers/chat_form_controller.js +93 -0
- data/app/assets/javascripts/observ/controllers/copy_controller.js +43 -0
- data/app/assets/javascripts/observ/controllers/dashboard_controller.js +58 -0
- data/app/assets/javascripts/observ/controllers/drawer_controller.js +58 -0
- data/app/assets/javascripts/observ/controllers/expandable_controller.js +33 -0
- data/app/assets/javascripts/observ/controllers/filter_controller.js +36 -0
- data/app/assets/javascripts/observ/controllers/index.js +52 -0
- data/app/assets/javascripts/observ/controllers/json_viewer_controller.js +260 -0
- data/app/assets/javascripts/observ/controllers/message_form_controller.js +58 -0
- data/app/assets/javascripts/observ/controllers/prompt_variables_controller.js +64 -0
- data/app/assets/javascripts/observ/controllers/text_select_controller.js +14 -0
- data/app/assets/stylesheets/observ/_annotations.scss +127 -0
- data/app/assets/stylesheets/observ/_card.scss +52 -0
- data/app/assets/stylesheets/observ/_chat.scss +156 -0
- data/app/assets/stylesheets/observ/_components.scss +460 -0
- data/app/assets/stylesheets/observ/_dashboard.scss +40 -0
- data/app/assets/stylesheets/observ/_datasets.scss +697 -0
- data/app/assets/stylesheets/observ/_drawer.scss +273 -0
- data/app/assets/stylesheets/observ/_json_viewer.scss +120 -0
- data/app/assets/stylesheets/observ/_layout.scss +256 -0
- data/app/assets/stylesheets/observ/_metrics.scss +99 -0
- data/app/assets/stylesheets/observ/_observations.scss +160 -0
- data/app/assets/stylesheets/observ/_pagination.scss +143 -0
- data/app/assets/stylesheets/observ/_prompts.scss +365 -0
- data/app/assets/stylesheets/observ/_table.scss +53 -0
- data/app/assets/stylesheets/observ/_variables.scss +53 -0
- data/app/assets/stylesheets/observ/application.scss +15 -0
- data/app/controllers/observ/annotations_controller.rb +144 -0
- data/app/controllers/observ/application_controller.rb +8 -0
- data/app/controllers/observ/chats_controller.rb +58 -0
- data/app/controllers/observ/dashboard_controller.rb +159 -0
- data/app/controllers/observ/dataset_items_controller.rb +85 -0
- data/app/controllers/observ/dataset_run_items_controller.rb +84 -0
- data/app/controllers/observ/dataset_runs_controller.rb +110 -0
- data/app/controllers/observ/datasets_controller.rb +74 -0
- data/app/controllers/observ/messages_controller.rb +26 -0
- data/app/controllers/observ/observations_controller.rb +59 -0
- data/app/controllers/observ/prompt_versions_controller.rb +148 -0
- data/app/controllers/observ/prompts_controller.rb +205 -0
- data/app/controllers/observ/sessions_controller.rb +45 -0
- data/app/controllers/observ/traces_controller.rb +86 -0
- data/app/forms/observ/prompt_form.rb +96 -0
- data/app/helpers/observ/application_helper.rb +9 -0
- data/app/helpers/observ/chats_helper.rb +47 -0
- data/app/helpers/observ/dashboard_helper.rb +154 -0
- data/app/helpers/observ/datasets_helper.rb +62 -0
- data/app/helpers/observ/pagination_helper.rb +38 -0
- data/app/jobs/observ/application_job.rb +4 -0
- data/app/jobs/observ/dataset_runner_job.rb +49 -0
- data/app/mailers/observ/application_mailer.rb +6 -0
- data/app/models/concerns/observ/agent_phaseable.rb +124 -0
- data/app/models/concerns/observ/agent_selectable.rb +50 -0
- data/app/models/concerns/observ/chat_enhancements.rb +109 -0
- data/app/models/concerns/observ/message_enhancements.rb +31 -0
- data/app/models/concerns/observ/observability_instrumentation.rb +124 -0
- data/app/models/concerns/observ/prompt_management.rb +320 -0
- data/app/models/concerns/observ/trace_association.rb +9 -0
- data/app/models/observ/annotation.rb +23 -0
- data/app/models/observ/application_record.rb +5 -0
- data/app/models/observ/dataset.rb +51 -0
- data/app/models/observ/dataset_item.rb +41 -0
- data/app/models/observ/dataset_run.rb +104 -0
- data/app/models/observ/dataset_run_item.rb +111 -0
- data/app/models/observ/generation.rb +56 -0
- data/app/models/observ/null_prompt.rb +59 -0
- data/app/models/observ/observation.rb +38 -0
- data/app/models/observ/prompt.rb +315 -0
- data/app/models/observ/score.rb +51 -0
- data/app/models/observ/session.rb +131 -0
- data/app/models/observ/span.rb +13 -0
- data/app/models/observ/trace.rb +135 -0
- data/app/presenters/observ/agent_select_presenter.rb +59 -0
- data/app/services/observ/agent_executor_service.rb +174 -0
- data/app/services/observ/agent_provider.rb +60 -0
- data/app/services/observ/agent_selection_service.rb +53 -0
- data/app/services/observ/chat_instrumenter.rb +523 -0
- data/app/services/observ/dataset_runner_service.rb +153 -0
- data/app/services/observ/evaluator_runner_service.rb +58 -0
- data/app/services/observ/evaluators/base_evaluator.rb +51 -0
- data/app/services/observ/evaluators/contains_evaluator.rb +53 -0
- data/app/services/observ/evaluators/exact_match_evaluator.rb +23 -0
- data/app/services/observ/evaluators/json_structure_evaluator.rb +44 -0
- data/app/services/observ/prompt_manager/cache_statistics.rb +82 -0
- data/app/services/observ/prompt_manager/caching.rb +167 -0
- data/app/services/observ/prompt_manager/comparison.rb +49 -0
- data/app/services/observ/prompt_manager/version_management.rb +96 -0
- data/app/services/observ/prompt_manager.rb +40 -0
- data/app/services/observ/trace_text_formatter.rb +349 -0
- data/app/validators/observ/prompt_config_validator.rb +187 -0
- data/app/views/kaminari/_first_page.html.erb +11 -0
- data/app/views/kaminari/_gap.html.erb +8 -0
- data/app/views/kaminari/_last_page.html.erb +11 -0
- data/app/views/kaminari/_next_page.html.erb +11 -0
- data/app/views/kaminari/_page.html.erb +12 -0
- data/app/views/kaminari/_paginator.html.erb +25 -0
- data/app/views/kaminari/_prev_page.html.erb +11 -0
- data/app/views/kaminari/observ/_first_page.html.erb +11 -0
- data/app/views/kaminari/observ/_gap.html.erb +8 -0
- data/app/views/kaminari/observ/_last_page.html.erb +11 -0
- data/app/views/kaminari/observ/_next_page.html.erb +11 -0
- data/app/views/kaminari/observ/_page.html.erb +12 -0
- data/app/views/kaminari/observ/_paginator.html.erb +25 -0
- data/app/views/kaminari/observ/_prev_page.html.erb +11 -0
- data/app/views/layouts/observ/application.html.erb +88 -0
- data/app/views/observ/annotations/_annotation.html.erb +13 -0
- data/app/views/observ/annotations/_form.html.erb +28 -0
- data/app/views/observ/annotations/index.html.erb +28 -0
- data/app/views/observ/annotations/sessions_index.html.erb +48 -0
- data/app/views/observ/annotations/traces_index.html.erb +48 -0
- data/app/views/observ/chats/_form.html.erb +45 -0
- data/app/views/observ/chats/index.html.erb +67 -0
- data/app/views/observ/chats/new.html.erb +17 -0
- data/app/views/observ/chats/show.html.erb +34 -0
- data/app/views/observ/dashboard/index.html.erb +236 -0
- data/app/views/observ/dataset_items/_form.html.erb +49 -0
- data/app/views/observ/dataset_items/edit.html.erb +18 -0
- data/app/views/observ/dataset_items/index.html.erb +95 -0
- data/app/views/observ/dataset_items/new.html.erb +18 -0
- data/app/views/observ/dataset_run_items/_score_close_drawer.html.erb +4 -0
- data/app/views/observ/dataset_run_items/_score_drawer.html.erb +75 -0
- data/app/views/observ/dataset_run_items/_score_success.html.erb +29 -0
- data/app/views/observ/dataset_run_items/_scores_cell.html.erb +19 -0
- data/app/views/observ/dataset_run_items/details_drawer.turbo_stream.erb +80 -0
- data/app/views/observ/dataset_run_items/score_drawer.turbo_stream.erb +7 -0
- data/app/views/observ/dataset_runs/index.html.erb +108 -0
- data/app/views/observ/dataset_runs/new.html.erb +57 -0
- data/app/views/observ/dataset_runs/review.html.erb +155 -0
- data/app/views/observ/dataset_runs/show.html.erb +166 -0
- data/app/views/observ/datasets/_form.html.erb +62 -0
- data/app/views/observ/datasets/_items_tab.html.erb +66 -0
- data/app/views/observ/datasets/_runs_tab.html.erb +82 -0
- data/app/views/observ/datasets/edit.html.erb +32 -0
- data/app/views/observ/datasets/index.html.erb +105 -0
- data/app/views/observ/datasets/new.html.erb +18 -0
- data/app/views/observ/datasets/show.html.erb +67 -0
- data/app/views/observ/messages/_content.html.erb +1 -0
- data/app/views/observ/messages/_form.html.erb +33 -0
- data/app/views/observ/messages/_message.html.erb +14 -0
- data/app/views/observ/messages/_tool_calls.html.erb +10 -0
- data/app/views/observ/messages/create.turbo_stream.erb +9 -0
- data/app/views/observ/observations/index.html.erb +97 -0
- data/app/views/observ/observations/show_generation.html.erb +195 -0
- data/app/views/observ/observations/show_span.html.erb +93 -0
- data/app/views/observ/prompts/_diff_content.html.erb +16 -0
- data/app/views/observ/prompts/_form.html.erb +111 -0
- data/app/views/observ/prompts/_new_form.html.erb +102 -0
- data/app/views/observ/prompts/_prompt_actions.html.erb +4 -0
- data/app/views/observ/prompts/_prompt_content_highlighted.html.erb +4 -0
- data/app/views/observ/prompts/_version_actions.html.erb +40 -0
- data/app/views/observ/prompts/compare.html.erb +155 -0
- data/app/views/observ/prompts/edit.html.erb +17 -0
- data/app/views/observ/prompts/index.html.erb +108 -0
- data/app/views/observ/prompts/new.html.erb +17 -0
- data/app/views/observ/prompts/show.html.erb +138 -0
- data/app/views/observ/prompts/versions.html.erb +87 -0
- data/app/views/observ/sessions/annotations_drawer.turbo_stream.erb +25 -0
- data/app/views/observ/sessions/drawer_test.turbo_stream.erb +49 -0
- data/app/views/observ/sessions/index.html.erb +91 -0
- data/app/views/observ/sessions/show.html.erb +251 -0
- data/app/views/observ/traces/add_to_dataset_drawer.turbo_stream.erb +48 -0
- data/app/views/observ/traces/annotations_drawer.turbo_stream.erb +25 -0
- data/app/views/observ/traces/index.html.erb +87 -0
- data/app/views/observ/traces/show.html.erb +285 -0
- data/app/views/observ/traces/text_output_drawer.turbo_stream.erb +48 -0
- data/app/views/shared/_drawer.html.erb +26 -0
- data/config/routes.rb +80 -0
- data/db/migrate/001_create_observ_sessions.rb +21 -0
- data/db/migrate/002_create_observ_traces.rb +25 -0
- data/db/migrate/003_create_observ_observations.rb +42 -0
- data/db/migrate/004_add_message_id_to_observ_traces.rb +7 -0
- data/db/migrate/005_create_observ_prompts.rb +21 -0
- data/db/migrate/006_fix_prompt_config_strings.rb +23 -0
- data/db/migrate/007_create_observ_annotations.rb +12 -0
- data/db/migrate/009_add_prompt_fields_to_observ_chats.rb +11 -0
- data/db/migrate/010_create_observ_datasets.rb +15 -0
- data/db/migrate/011_create_observ_dataset_items.rb +17 -0
- data/db/migrate/012_create_observ_dataset_runs.rb +22 -0
- data/db/migrate/013_create_observ_dataset_run_items.rb +16 -0
- data/db/migrate/014_create_observ_scores.rb +26 -0
- data/lib/generators/observ/add_phase_tracking/add_phase_tracking_generator.rb +150 -0
- data/lib/generators/observ/add_phase_tracking/templates/migration.rb.tt +6 -0
- data/lib/generators/observ/install/USAGE +27 -0
- data/lib/generators/observ/install/install_generator.rb +270 -0
- data/lib/generators/observ/install_chat/install_chat_generator.rb +313 -0
- data/lib/generators/observ/install_chat/templates/agents/base_agent.rb.tt +147 -0
- data/lib/generators/observ/install_chat/templates/agents/simple_agent.rb.tt +55 -0
- data/lib/generators/observ/install_chat/templates/concerns/observ_chat_enhancements.rb.tt +34 -0
- data/lib/generators/observ/install_chat/templates/concerns/observ_message_enhancements.rb.tt +18 -0
- data/lib/generators/observ/install_chat/templates/initializers/observability.rb.tt +20 -0
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +56 -0
- data/lib/generators/observ/install_chat/templates/migrations/add_agent_class_name.rb.tt +6 -0
- data/lib/generators/observ/install_chat/templates/migrations/add_observability_session_id.rb.tt +6 -0
- data/lib/generators/observ/install_chat/templates/tools/think_tool.rb.tt +29 -0
- data/lib/generators/observ/install_chat/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/observ/asset_installer.rb +130 -0
- data/lib/observ/asset_syncer.rb +104 -0
- data/lib/observ/configuration.rb +108 -0
- data/lib/observ/engine.rb +50 -0
- data/lib/observ/index_file_generator.rb +142 -0
- data/lib/observ/instrumenter/ruby_llm.rb +6 -0
- data/lib/observ/version.rb +3 -0
- data/lib/observ.rb +29 -0
- data/lib/tasks/observ_tasks.rake +75 -0
- metadata +453 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class Session < ApplicationRecord
|
|
5
|
+
self.table_name = "observ_sessions"
|
|
6
|
+
|
|
7
|
+
has_many :traces, class_name: "Observ::Trace",
|
|
8
|
+
foreign_key: :observ_session_id, dependent: :destroy, inverse_of: :observ_session
|
|
9
|
+
has_many :annotations, as: :annotatable, dependent: :destroy
|
|
10
|
+
|
|
11
|
+
validates :session_id, presence: true, uniqueness: true
|
|
12
|
+
validates :start_time, presence: true
|
|
13
|
+
|
|
14
|
+
before_validation :set_session_id, on: :create
|
|
15
|
+
before_validation :set_start_time, on: :create
|
|
16
|
+
|
|
17
|
+
def create_trace(name: nil, input: nil, metadata: {}, tags: [])
|
|
18
|
+
traces.create!(
|
|
19
|
+
trace_id: SecureRandom.uuid,
|
|
20
|
+
name: name || "chat_exchange",
|
|
21
|
+
input: input.is_a?(String) ? input : input.to_json,
|
|
22
|
+
metadata: metadata,
|
|
23
|
+
tags: tags,
|
|
24
|
+
user_id: user_id,
|
|
25
|
+
start_time: Time.current
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finalize
|
|
30
|
+
update!(end_time: Time.current)
|
|
31
|
+
update_aggregated_metrics
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def duration_s
|
|
35
|
+
return nil unless end_time
|
|
36
|
+
(end_time - start_time).round(1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def average_llm_latency_ms
|
|
40
|
+
generations = Observ::Generation.joins(trace: :observ_session)
|
|
41
|
+
.where(observ_sessions: { id: id })
|
|
42
|
+
.where.not(end_time: nil)
|
|
43
|
+
return 0 if generations.empty?
|
|
44
|
+
|
|
45
|
+
total_duration = generations.sum do |g|
|
|
46
|
+
((g.end_time - g.start_time) * 1000).round(2)
|
|
47
|
+
end
|
|
48
|
+
(total_duration / generations.count).round(0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def session_metrics
|
|
52
|
+
if end_time.nil?
|
|
53
|
+
{
|
|
54
|
+
session_id: session_id,
|
|
55
|
+
total_traces: traces.count,
|
|
56
|
+
total_llm_calls: generation_count,
|
|
57
|
+
total_tokens: traces.sum(:total_tokens),
|
|
58
|
+
total_cost: traces.sum(:total_cost).to_f,
|
|
59
|
+
total_llm_duration_ms: calculate_total_llm_duration,
|
|
60
|
+
average_llm_latency_ms: average_llm_latency_ms,
|
|
61
|
+
duration_s: duration_s
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
{
|
|
65
|
+
session_id: session_id,
|
|
66
|
+
total_traces: total_traces_count,
|
|
67
|
+
total_llm_calls: total_llm_calls_count,
|
|
68
|
+
total_tokens: total_tokens,
|
|
69
|
+
total_cost: total_cost.to_f,
|
|
70
|
+
total_llm_duration_ms: total_llm_duration_ms,
|
|
71
|
+
average_llm_latency_ms: average_llm_latency_ms,
|
|
72
|
+
duration_s: duration_s
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update_aggregated_metrics
|
|
78
|
+
update_columns(
|
|
79
|
+
total_traces_count: traces.count,
|
|
80
|
+
total_llm_calls_count: generation_count,
|
|
81
|
+
total_tokens: traces.sum(:total_tokens),
|
|
82
|
+
total_cost: traces.sum(:total_cost),
|
|
83
|
+
total_llm_duration_ms: calculate_total_llm_duration
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def update_metadata(new_metadata)
|
|
88
|
+
self.metadata = (self.metadata || {}).merge(new_metadata)
|
|
89
|
+
save
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def instrument_chat(chat_instance, context: {})
|
|
93
|
+
instrumenter = Observ::ChatInstrumenter.new(
|
|
94
|
+
self,
|
|
95
|
+
chat_instance,
|
|
96
|
+
context: context
|
|
97
|
+
)
|
|
98
|
+
instrumenter.instrument!
|
|
99
|
+
instrumenter
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def chat
|
|
103
|
+
@chat ||= ::Chat.find_by(observability_session_id: session_id) if defined?(::Chat)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def set_session_id
|
|
109
|
+
self.session_id ||= SecureRandom.uuid
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def set_start_time
|
|
113
|
+
self.start_time ||= Time.current
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def generation_count
|
|
117
|
+
Observ::Generation.joins(:trace)
|
|
118
|
+
.where(observ_traces: { observ_session_id: id })
|
|
119
|
+
.count
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def calculate_total_llm_duration
|
|
123
|
+
Observ::Generation.joins(:trace)
|
|
124
|
+
.where(observ_traces: { observ_session_id: id })
|
|
125
|
+
.where.not(end_time: nil)
|
|
126
|
+
.sum do |g|
|
|
127
|
+
((g.end_time - g.start_time) * 1000).round(2)
|
|
128
|
+
end || 0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class Span < Observation
|
|
5
|
+
def finalize(output: nil, status_message: nil)
|
|
6
|
+
update!(
|
|
7
|
+
output: output.is_a?(String) ? output : output.to_json,
|
|
8
|
+
end_time: Time.current,
|
|
9
|
+
status_message: status_message
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class Trace < ApplicationRecord
|
|
5
|
+
self.table_name = "observ_traces"
|
|
6
|
+
|
|
7
|
+
belongs_to :observ_session, class_name: "Observ::Session", inverse_of: :traces
|
|
8
|
+
has_many :observations, class_name: "Observ::Observation",
|
|
9
|
+
foreign_key: :observ_trace_id, dependent: :destroy, inverse_of: :trace
|
|
10
|
+
belongs_to :message, optional: true
|
|
11
|
+
has_many :annotations, as: :annotatable, dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :trace_id, presence: true, uniqueness: true
|
|
14
|
+
validates :start_time, presence: true
|
|
15
|
+
|
|
16
|
+
after_save :update_session_metrics, if: :saved_change_to_total_cost_or_total_tokens?
|
|
17
|
+
|
|
18
|
+
def create_generation(name: "llm_generation", model: nil, metadata: {}, **options)
|
|
19
|
+
observations.create!(
|
|
20
|
+
observation_id: SecureRandom.uuid,
|
|
21
|
+
type: "Observ::Generation",
|
|
22
|
+
name: name,
|
|
23
|
+
model: model,
|
|
24
|
+
metadata: metadata,
|
|
25
|
+
start_time: Time.current,
|
|
26
|
+
**options.slice(:model_parameters, :prompt_name, :prompt_version, :parent_observation_id)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_span(name:, input: nil, metadata: {}, parent_observation_id: nil)
|
|
31
|
+
observations.create!(
|
|
32
|
+
observation_id: SecureRandom.uuid,
|
|
33
|
+
type: "Observ::Span",
|
|
34
|
+
name: name,
|
|
35
|
+
input: input.is_a?(String) ? input : input.to_json,
|
|
36
|
+
metadata: metadata,
|
|
37
|
+
parent_observation_id: parent_observation_id,
|
|
38
|
+
start_time: Time.current
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finalize(output: nil, metadata: {})
|
|
43
|
+
merged_metadata = (self.metadata || {}).merge(metadata)
|
|
44
|
+
update!(
|
|
45
|
+
output: output.is_a?(String) ? output : output.to_json,
|
|
46
|
+
metadata: merged_metadata,
|
|
47
|
+
end_time: Time.current
|
|
48
|
+
)
|
|
49
|
+
update_aggregated_metrics
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finalize_with_response(response)
|
|
53
|
+
if response.is_a?(String)
|
|
54
|
+
finalize(output: response)
|
|
55
|
+
return response
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
response_metadata = extract_response_metadata(response)
|
|
59
|
+
|
|
60
|
+
finalize(
|
|
61
|
+
output: response.respond_to?(:content) ? response.content : response.to_s,
|
|
62
|
+
metadata: response_metadata
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
response.respond_to?(:content) ? response.content : response.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def duration_ms
|
|
69
|
+
return nil unless end_time
|
|
70
|
+
((end_time - start_time) * 1000).round(2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def update_aggregated_metrics
|
|
74
|
+
new_total_cost = generations.sum(:cost_usd) || 0.0
|
|
75
|
+
|
|
76
|
+
# Database-agnostic token calculation
|
|
77
|
+
new_total_tokens = generations.sum do |gen|
|
|
78
|
+
gen.usage&.dig("total_tokens") || 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
update_columns(
|
|
82
|
+
total_cost: new_total_cost,
|
|
83
|
+
total_tokens: new_total_tokens
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def generations
|
|
88
|
+
observations.where(type: "Observ::Generation")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def spans
|
|
92
|
+
observations.where(type: "Observ::Span")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def models_used
|
|
96
|
+
generations.where.not(model: nil).distinct.pluck(:model)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def extract_response_metadata(response)
|
|
102
|
+
metadata = {}
|
|
103
|
+
|
|
104
|
+
metadata[:model_id] = response.model_id if response.respond_to?(:model_id)
|
|
105
|
+
metadata[:input_tokens] = response.input_tokens if response.respond_to?(:input_tokens)
|
|
106
|
+
|
|
107
|
+
if response.respond_to?(:output_tokens)
|
|
108
|
+
metadata[:output_tokens] = response.output_tokens
|
|
109
|
+
metadata[:total_tokens] = (response.input_tokens || 0) + response.output_tokens
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
metadata[:role] = response.role if response.respond_to?(:role)
|
|
113
|
+
|
|
114
|
+
if response.respond_to?(:tool_calls) && response.tool_calls&.any?
|
|
115
|
+
metadata[:tool_calls_count] = response.tool_calls.count
|
|
116
|
+
metadata[:tool_calls] = response.tool_calls.map do |tc|
|
|
117
|
+
{
|
|
118
|
+
name: tc.respond_to?(:name) ? tc.name : nil,
|
|
119
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : nil
|
|
120
|
+
}.compact
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
metadata
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def saved_change_to_total_cost_or_total_tokens?
|
|
128
|
+
saved_change_to_total_cost? || saved_change_to_total_tokens?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def update_session_metrics
|
|
132
|
+
observ_session&.update_aggregated_metrics
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Presenter for agent selection in the Observ domain
|
|
2
|
+
#
|
|
3
|
+
# This presenter receives agents via dependency injection and formats them
|
|
4
|
+
# for display in select dropdowns. It has NO knowledge of:
|
|
5
|
+
# - How agents are discovered
|
|
6
|
+
# - Where agent files are located
|
|
7
|
+
# - The BaseAgent class hierarchy
|
|
8
|
+
#
|
|
9
|
+
# It only knows about the AgentSelectable interface that agents must implement.
|
|
10
|
+
#
|
|
11
|
+
# Usage with dependency injection (recommended):
|
|
12
|
+
# agents = Observ::AgentProvider.all_agents
|
|
13
|
+
# presenter = Observ::AgentSelectPresenter.new(agents: agents)
|
|
14
|
+
# presenter.options
|
|
15
|
+
# # => [["Default Agent", ""], ["Deep Research", "DeepResearchAgent"], ...]
|
|
16
|
+
#
|
|
17
|
+
# Usage with convenience class method:
|
|
18
|
+
# Observ::AgentSelectPresenter.options
|
|
19
|
+
# # => [["Default Agent", ""], ["Deep Research", "DeepResearchAgent"], ...]
|
|
20
|
+
module Observ
|
|
21
|
+
class AgentSelectPresenter
|
|
22
|
+
attr_reader :agents
|
|
23
|
+
|
|
24
|
+
# Initialize with dependency injection
|
|
25
|
+
# @param agents [Array<Class>] array of agent classes implementing AgentSelectable
|
|
26
|
+
def initialize(agents:)
|
|
27
|
+
@agents = agents
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns formatted options for Rails select helper
|
|
31
|
+
# Format: [[display_name, identifier], ...]
|
|
32
|
+
# @return [Array<Array<String>>] options array for select dropdown
|
|
33
|
+
def options
|
|
34
|
+
[ default_option ] + agent_options
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience class method that injects agents from Observ::AgentProvider
|
|
38
|
+
# Useful when you don't need to filter or transform agents
|
|
39
|
+
# @param agents [Array<Class>] optional array of agents (defaults to Observ::AgentProvider.all_agents)
|
|
40
|
+
# @return [Array<Array<String>>] options array for select dropdown
|
|
41
|
+
def self.options(agents: Observ::AgentProvider.all_agents)
|
|
42
|
+
new(agents: agents).options
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Default option for "no agent selected" state
|
|
48
|
+
# @return [Array<String>] the default option
|
|
49
|
+
def default_option
|
|
50
|
+
[ "Default Agent", "" ]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Maps agents to [display_name, identifier] pairs
|
|
54
|
+
# @return [Array<Array<String>>] agent options
|
|
55
|
+
def agent_options
|
|
56
|
+
agents.map { |agent| [ agent.display_name, agent.agent_identifier ] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
# Generic service for executing any agent against an input
|
|
5
|
+
#
|
|
6
|
+
# This service encapsulates the RubyLLM chat configuration and execution pattern,
|
|
7
|
+
# providing a unified way to run agents for dataset evaluations or other purposes.
|
|
8
|
+
#
|
|
9
|
+
# The service:
|
|
10
|
+
# - Creates a RubyLLM chat with the agent's model
|
|
11
|
+
# - Applies system prompt, schema, and model parameters
|
|
12
|
+
# - Optionally instruments the chat for observability
|
|
13
|
+
# - Handles both simple text input and structured context hashes
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# # Basic usage
|
|
17
|
+
# executor = Observ::AgentExecutorService.new(LanguageDetectionAgent)
|
|
18
|
+
# result = executor.call("Hello, how are you?")
|
|
19
|
+
#
|
|
20
|
+
# # With observability
|
|
21
|
+
# session = Observ::Session.create!(user_id: "user_123")
|
|
22
|
+
# executor = Observ::AgentExecutorService.new(
|
|
23
|
+
# LanguageDetectionAgent,
|
|
24
|
+
# observability_session: session
|
|
25
|
+
# )
|
|
26
|
+
# result = executor.call("Bonjour!")
|
|
27
|
+
#
|
|
28
|
+
# # With context hash (for agents that implement build_user_prompt)
|
|
29
|
+
# executor = Observ::AgentExecutorService.new(CharacterGeneratorAgent)
|
|
30
|
+
# result = executor.call(genre: "fantasy", title: "Dragon Quest")
|
|
31
|
+
#
|
|
32
|
+
class AgentExecutorService
|
|
33
|
+
class ExecutionError < StandardError; end
|
|
34
|
+
class RubyLLMNotAvailableError < ExecutionError; end
|
|
35
|
+
|
|
36
|
+
attr_reader :agent_class, :observability_session
|
|
37
|
+
|
|
38
|
+
# Initialize the executor
|
|
39
|
+
#
|
|
40
|
+
# @param agent_class [Class] The agent class to execute (must respond to model, system_prompt)
|
|
41
|
+
# @param observability_session [Observ::Session, nil] Optional session for tracing
|
|
42
|
+
# @param context [Hash] Additional context metadata for tracing
|
|
43
|
+
def initialize(agent_class, observability_session: nil, context: {})
|
|
44
|
+
@agent_class = agent_class
|
|
45
|
+
@observability_session = observability_session
|
|
46
|
+
@context = context
|
|
47
|
+
|
|
48
|
+
validate_ruby_llm_available!
|
|
49
|
+
validate_agent_class!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Execute the agent with the given input
|
|
53
|
+
#
|
|
54
|
+
# @param input [String, Hash] The input text or context hash
|
|
55
|
+
# @return [Hash, String] The agent's response (structured if schema defined, string otherwise)
|
|
56
|
+
# @raise [ExecutionError] If the agent execution fails
|
|
57
|
+
def call(input)
|
|
58
|
+
chat = build_chat
|
|
59
|
+
configure_chat(chat)
|
|
60
|
+
instrument_chat(chat) if observability_session
|
|
61
|
+
|
|
62
|
+
user_prompt = build_user_prompt(input)
|
|
63
|
+
response = chat.ask(user_prompt)
|
|
64
|
+
|
|
65
|
+
normalize_response(response.content)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise ExecutionError, "Agent execution failed: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def validate_ruby_llm_available!
|
|
73
|
+
return if defined?(RubyLLM)
|
|
74
|
+
|
|
75
|
+
raise RubyLLMNotAvailableError,
|
|
76
|
+
"RubyLLM is not available. Please ensure the ruby_llm gem is installed and configured."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_agent_class!
|
|
80
|
+
unless agent_class.respond_to?(:model) && agent_class.respond_to?(:system_prompt)
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"Agent class must respond to :model and :system_prompt. Got: #{agent_class.name}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_chat
|
|
87
|
+
RubyLLM.chat(model: agent_class.model)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def configure_chat(chat)
|
|
91
|
+
# Apply system prompt
|
|
92
|
+
chat.with_instructions(agent_class.system_prompt)
|
|
93
|
+
|
|
94
|
+
# Apply schema for structured output if agent defines one
|
|
95
|
+
if agent_class.respond_to?(:schema) && agent_class.schema
|
|
96
|
+
chat.with_schema(agent_class.schema)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Apply model parameters (temperature, max_tokens, etc.)
|
|
100
|
+
if agent_class.respond_to?(:model_parameters)
|
|
101
|
+
params = agent_class.model_parameters
|
|
102
|
+
chat.with_params(**params) if params.any?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
chat
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def instrument_chat(chat)
|
|
109
|
+
return unless observability_session
|
|
110
|
+
|
|
111
|
+
instrumenter = Observ::ChatInstrumenter.new(
|
|
112
|
+
observability_session,
|
|
113
|
+
chat,
|
|
114
|
+
context: default_context.merge(@context)
|
|
115
|
+
)
|
|
116
|
+
instrumenter.instrument!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def default_context
|
|
120
|
+
{
|
|
121
|
+
service: "agent_executor",
|
|
122
|
+
agent_class: agent_class
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build the user prompt from input
|
|
127
|
+
#
|
|
128
|
+
# If the agent implements build_user_prompt, use it with the input as context.
|
|
129
|
+
# Otherwise, extract text from the input directly.
|
|
130
|
+
def build_user_prompt(input)
|
|
131
|
+
if agent_class.respond_to?(:build_user_prompt)
|
|
132
|
+
context = input.is_a?(Hash) ? input : { text: input }
|
|
133
|
+
agent_class.build_user_prompt(context)
|
|
134
|
+
else
|
|
135
|
+
extract_text_input(input)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_text_input(input)
|
|
140
|
+
case input
|
|
141
|
+
when String
|
|
142
|
+
input
|
|
143
|
+
when Hash
|
|
144
|
+
# Try common keys for text content
|
|
145
|
+
input[:text] || input["text"] ||
|
|
146
|
+
input[:content] || input["content"] ||
|
|
147
|
+
input[:input] || input["input"] ||
|
|
148
|
+
input.to_json
|
|
149
|
+
else
|
|
150
|
+
input.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_response(content)
|
|
155
|
+
case content
|
|
156
|
+
when Hash
|
|
157
|
+
# Symbolize keys for consistent access
|
|
158
|
+
deep_symbolize_keys(content)
|
|
159
|
+
when String
|
|
160
|
+
content
|
|
161
|
+
else
|
|
162
|
+
content.respond_to?(:to_h) ? deep_symbolize_keys(content.to_h) : content
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def deep_symbolize_keys(hash)
|
|
167
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
168
|
+
new_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
169
|
+
new_value = value.is_a?(Hash) ? deep_symbolize_keys(value) : value
|
|
170
|
+
result[new_key] = new_value
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
# Service for discovering and providing available agents
|
|
5
|
+
# This is the ONLY class that knows how to discover agents in the application
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Loading agent files in development mode (via Zeitwerk)
|
|
9
|
+
# - Discovering all agents that implement Observ::AgentSelectable
|
|
10
|
+
# - Sorting and filtering agents
|
|
11
|
+
#
|
|
12
|
+
# The Observ domain queries this service to get available agents,
|
|
13
|
+
# maintaining clean separation between domains.
|
|
14
|
+
#
|
|
15
|
+
# Configuration:
|
|
16
|
+
# You can customize the agent discovery path via configuration:
|
|
17
|
+
#
|
|
18
|
+
# Observ.configure do |config|
|
|
19
|
+
# config.agent_path = Rails.root.join("lib", "my_agents")
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Usage:
|
|
23
|
+
# agents = Observ::AgentProvider.all_agents
|
|
24
|
+
# # => [LanguageDetectionAgent, MoodDetectionAgent, ...]
|
|
25
|
+
class AgentProvider
|
|
26
|
+
class << self
|
|
27
|
+
# Returns all available agents that implement the Observ::AgentSelectable interface
|
|
28
|
+
# Agents are sorted alphabetically by display_name
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<Class>] array of agent classes
|
|
31
|
+
def all_agents
|
|
32
|
+
ensure_agents_loaded
|
|
33
|
+
|
|
34
|
+
return [] unless defined?(::BaseAgent)
|
|
35
|
+
|
|
36
|
+
::BaseAgent.descendants
|
|
37
|
+
.select { |agent_class| agent_class.included_modules.include?(Observ::AgentSelectable) }
|
|
38
|
+
.sort_by(&:display_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Ensures all agent files are loaded in development mode
|
|
44
|
+
# Uses Zeitwerk's eager_load_dir for thread-safe, Rails-idiomatic loading
|
|
45
|
+
# In production, eager loading handles this automatically (this becomes a no-op)
|
|
46
|
+
def ensure_agents_loaded
|
|
47
|
+
return if Rails.application.config.eager_load
|
|
48
|
+
|
|
49
|
+
agent_path = Observ.config.agent_path || default_agent_path
|
|
50
|
+
Rails.autoloaders.main.eager_load_dir(agent_path) if agent_path.exist?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Default path where agents are located
|
|
54
|
+
# @return [Pathname] the default agent path
|
|
55
|
+
def default_agent_path
|
|
56
|
+
Rails.root.join("app", "agents")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
# Service for providing agent selection options in the Observ domain
|
|
5
|
+
#
|
|
6
|
+
# This service encapsulates the workflow of:
|
|
7
|
+
# 1. Discovering available agents (via Observ::AgentProvider)
|
|
8
|
+
# 2. Formatting them for UI presentation (via AgentSelectPresenter)
|
|
9
|
+
#
|
|
10
|
+
# This service acts as the single entry point for agent selection,
|
|
11
|
+
# hiding the complexity of agent discovery and presentation from
|
|
12
|
+
# controllers and views.
|
|
13
|
+
#
|
|
14
|
+
# Usage in controllers:
|
|
15
|
+
# @agent_select_options = Observ::AgentSelectionService.options
|
|
16
|
+
#
|
|
17
|
+
# Usage in helpers:
|
|
18
|
+
# def agent_select_options
|
|
19
|
+
# Observ::AgentSelectionService.options
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Example output:
|
|
23
|
+
# [
|
|
24
|
+
# ["Default Agent", ""],
|
|
25
|
+
# ["Deep Research", "DeepResearchAgent"],
|
|
26
|
+
# ["Simple Research", "ResearchAgent"]
|
|
27
|
+
# ]
|
|
28
|
+
class AgentSelectionService
|
|
29
|
+
class << self
|
|
30
|
+
# Returns formatted select options for agent selection dropdown
|
|
31
|
+
#
|
|
32
|
+
# This method orchestrates the entire agent selection workflow:
|
|
33
|
+
# - Discovers all available agents
|
|
34
|
+
# - Formats them for use in Rails select helpers
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Array<String>>] options array for Rails select helper
|
|
37
|
+
# Format: [["Display Name", "ClassName"], ...]
|
|
38
|
+
def options
|
|
39
|
+
AgentSelectPresenter.options(agents: Observ::AgentProvider.all_agents)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns all available agents (pass-through to Observ::AgentProvider)
|
|
43
|
+
#
|
|
44
|
+
# Useful when you need the raw agent classes instead of formatted options.
|
|
45
|
+
# For most UI purposes, prefer using .options instead.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Class>] array of agent classes implementing Observ::AgentSelectable
|
|
48
|
+
def all_agents
|
|
49
|
+
Observ::AgentProvider.all_agents
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|