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.
Files changed (209) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +778 -0
  3. data/Rakefile +49 -0
  4. data/app/assets/javascripts/observ/application.js +12 -0
  5. data/app/assets/javascripts/observ/controllers/autoscroll_controller.js +33 -0
  6. data/app/assets/javascripts/observ/controllers/chat_form_controller.js +93 -0
  7. data/app/assets/javascripts/observ/controllers/copy_controller.js +43 -0
  8. data/app/assets/javascripts/observ/controllers/dashboard_controller.js +58 -0
  9. data/app/assets/javascripts/observ/controllers/drawer_controller.js +58 -0
  10. data/app/assets/javascripts/observ/controllers/expandable_controller.js +33 -0
  11. data/app/assets/javascripts/observ/controllers/filter_controller.js +36 -0
  12. data/app/assets/javascripts/observ/controllers/index.js +52 -0
  13. data/app/assets/javascripts/observ/controllers/json_viewer_controller.js +260 -0
  14. data/app/assets/javascripts/observ/controllers/message_form_controller.js +58 -0
  15. data/app/assets/javascripts/observ/controllers/prompt_variables_controller.js +64 -0
  16. data/app/assets/javascripts/observ/controllers/text_select_controller.js +14 -0
  17. data/app/assets/stylesheets/observ/_annotations.scss +127 -0
  18. data/app/assets/stylesheets/observ/_card.scss +52 -0
  19. data/app/assets/stylesheets/observ/_chat.scss +156 -0
  20. data/app/assets/stylesheets/observ/_components.scss +460 -0
  21. data/app/assets/stylesheets/observ/_dashboard.scss +40 -0
  22. data/app/assets/stylesheets/observ/_datasets.scss +697 -0
  23. data/app/assets/stylesheets/observ/_drawer.scss +273 -0
  24. data/app/assets/stylesheets/observ/_json_viewer.scss +120 -0
  25. data/app/assets/stylesheets/observ/_layout.scss +256 -0
  26. data/app/assets/stylesheets/observ/_metrics.scss +99 -0
  27. data/app/assets/stylesheets/observ/_observations.scss +160 -0
  28. data/app/assets/stylesheets/observ/_pagination.scss +143 -0
  29. data/app/assets/stylesheets/observ/_prompts.scss +365 -0
  30. data/app/assets/stylesheets/observ/_table.scss +53 -0
  31. data/app/assets/stylesheets/observ/_variables.scss +53 -0
  32. data/app/assets/stylesheets/observ/application.scss +15 -0
  33. data/app/controllers/observ/annotations_controller.rb +144 -0
  34. data/app/controllers/observ/application_controller.rb +8 -0
  35. data/app/controllers/observ/chats_controller.rb +58 -0
  36. data/app/controllers/observ/dashboard_controller.rb +159 -0
  37. data/app/controllers/observ/dataset_items_controller.rb +85 -0
  38. data/app/controllers/observ/dataset_run_items_controller.rb +84 -0
  39. data/app/controllers/observ/dataset_runs_controller.rb +110 -0
  40. data/app/controllers/observ/datasets_controller.rb +74 -0
  41. data/app/controllers/observ/messages_controller.rb +26 -0
  42. data/app/controllers/observ/observations_controller.rb +59 -0
  43. data/app/controllers/observ/prompt_versions_controller.rb +148 -0
  44. data/app/controllers/observ/prompts_controller.rb +205 -0
  45. data/app/controllers/observ/sessions_controller.rb +45 -0
  46. data/app/controllers/observ/traces_controller.rb +86 -0
  47. data/app/forms/observ/prompt_form.rb +96 -0
  48. data/app/helpers/observ/application_helper.rb +9 -0
  49. data/app/helpers/observ/chats_helper.rb +47 -0
  50. data/app/helpers/observ/dashboard_helper.rb +154 -0
  51. data/app/helpers/observ/datasets_helper.rb +62 -0
  52. data/app/helpers/observ/pagination_helper.rb +38 -0
  53. data/app/jobs/observ/application_job.rb +4 -0
  54. data/app/jobs/observ/dataset_runner_job.rb +49 -0
  55. data/app/mailers/observ/application_mailer.rb +6 -0
  56. data/app/models/concerns/observ/agent_phaseable.rb +124 -0
  57. data/app/models/concerns/observ/agent_selectable.rb +50 -0
  58. data/app/models/concerns/observ/chat_enhancements.rb +109 -0
  59. data/app/models/concerns/observ/message_enhancements.rb +31 -0
  60. data/app/models/concerns/observ/observability_instrumentation.rb +124 -0
  61. data/app/models/concerns/observ/prompt_management.rb +320 -0
  62. data/app/models/concerns/observ/trace_association.rb +9 -0
  63. data/app/models/observ/annotation.rb +23 -0
  64. data/app/models/observ/application_record.rb +5 -0
  65. data/app/models/observ/dataset.rb +51 -0
  66. data/app/models/observ/dataset_item.rb +41 -0
  67. data/app/models/observ/dataset_run.rb +104 -0
  68. data/app/models/observ/dataset_run_item.rb +111 -0
  69. data/app/models/observ/generation.rb +56 -0
  70. data/app/models/observ/null_prompt.rb +59 -0
  71. data/app/models/observ/observation.rb +38 -0
  72. data/app/models/observ/prompt.rb +315 -0
  73. data/app/models/observ/score.rb +51 -0
  74. data/app/models/observ/session.rb +131 -0
  75. data/app/models/observ/span.rb +13 -0
  76. data/app/models/observ/trace.rb +135 -0
  77. data/app/presenters/observ/agent_select_presenter.rb +59 -0
  78. data/app/services/observ/agent_executor_service.rb +174 -0
  79. data/app/services/observ/agent_provider.rb +60 -0
  80. data/app/services/observ/agent_selection_service.rb +53 -0
  81. data/app/services/observ/chat_instrumenter.rb +523 -0
  82. data/app/services/observ/dataset_runner_service.rb +153 -0
  83. data/app/services/observ/evaluator_runner_service.rb +58 -0
  84. data/app/services/observ/evaluators/base_evaluator.rb +51 -0
  85. data/app/services/observ/evaluators/contains_evaluator.rb +53 -0
  86. data/app/services/observ/evaluators/exact_match_evaluator.rb +23 -0
  87. data/app/services/observ/evaluators/json_structure_evaluator.rb +44 -0
  88. data/app/services/observ/prompt_manager/cache_statistics.rb +82 -0
  89. data/app/services/observ/prompt_manager/caching.rb +167 -0
  90. data/app/services/observ/prompt_manager/comparison.rb +49 -0
  91. data/app/services/observ/prompt_manager/version_management.rb +96 -0
  92. data/app/services/observ/prompt_manager.rb +40 -0
  93. data/app/services/observ/trace_text_formatter.rb +349 -0
  94. data/app/validators/observ/prompt_config_validator.rb +187 -0
  95. data/app/views/kaminari/_first_page.html.erb +11 -0
  96. data/app/views/kaminari/_gap.html.erb +8 -0
  97. data/app/views/kaminari/_last_page.html.erb +11 -0
  98. data/app/views/kaminari/_next_page.html.erb +11 -0
  99. data/app/views/kaminari/_page.html.erb +12 -0
  100. data/app/views/kaminari/_paginator.html.erb +25 -0
  101. data/app/views/kaminari/_prev_page.html.erb +11 -0
  102. data/app/views/kaminari/observ/_first_page.html.erb +11 -0
  103. data/app/views/kaminari/observ/_gap.html.erb +8 -0
  104. data/app/views/kaminari/observ/_last_page.html.erb +11 -0
  105. data/app/views/kaminari/observ/_next_page.html.erb +11 -0
  106. data/app/views/kaminari/observ/_page.html.erb +12 -0
  107. data/app/views/kaminari/observ/_paginator.html.erb +25 -0
  108. data/app/views/kaminari/observ/_prev_page.html.erb +11 -0
  109. data/app/views/layouts/observ/application.html.erb +88 -0
  110. data/app/views/observ/annotations/_annotation.html.erb +13 -0
  111. data/app/views/observ/annotations/_form.html.erb +28 -0
  112. data/app/views/observ/annotations/index.html.erb +28 -0
  113. data/app/views/observ/annotations/sessions_index.html.erb +48 -0
  114. data/app/views/observ/annotations/traces_index.html.erb +48 -0
  115. data/app/views/observ/chats/_form.html.erb +45 -0
  116. data/app/views/observ/chats/index.html.erb +67 -0
  117. data/app/views/observ/chats/new.html.erb +17 -0
  118. data/app/views/observ/chats/show.html.erb +34 -0
  119. data/app/views/observ/dashboard/index.html.erb +236 -0
  120. data/app/views/observ/dataset_items/_form.html.erb +49 -0
  121. data/app/views/observ/dataset_items/edit.html.erb +18 -0
  122. data/app/views/observ/dataset_items/index.html.erb +95 -0
  123. data/app/views/observ/dataset_items/new.html.erb +18 -0
  124. data/app/views/observ/dataset_run_items/_score_close_drawer.html.erb +4 -0
  125. data/app/views/observ/dataset_run_items/_score_drawer.html.erb +75 -0
  126. data/app/views/observ/dataset_run_items/_score_success.html.erb +29 -0
  127. data/app/views/observ/dataset_run_items/_scores_cell.html.erb +19 -0
  128. data/app/views/observ/dataset_run_items/details_drawer.turbo_stream.erb +80 -0
  129. data/app/views/observ/dataset_run_items/score_drawer.turbo_stream.erb +7 -0
  130. data/app/views/observ/dataset_runs/index.html.erb +108 -0
  131. data/app/views/observ/dataset_runs/new.html.erb +57 -0
  132. data/app/views/observ/dataset_runs/review.html.erb +155 -0
  133. data/app/views/observ/dataset_runs/show.html.erb +166 -0
  134. data/app/views/observ/datasets/_form.html.erb +62 -0
  135. data/app/views/observ/datasets/_items_tab.html.erb +66 -0
  136. data/app/views/observ/datasets/_runs_tab.html.erb +82 -0
  137. data/app/views/observ/datasets/edit.html.erb +32 -0
  138. data/app/views/observ/datasets/index.html.erb +105 -0
  139. data/app/views/observ/datasets/new.html.erb +18 -0
  140. data/app/views/observ/datasets/show.html.erb +67 -0
  141. data/app/views/observ/messages/_content.html.erb +1 -0
  142. data/app/views/observ/messages/_form.html.erb +33 -0
  143. data/app/views/observ/messages/_message.html.erb +14 -0
  144. data/app/views/observ/messages/_tool_calls.html.erb +10 -0
  145. data/app/views/observ/messages/create.turbo_stream.erb +9 -0
  146. data/app/views/observ/observations/index.html.erb +97 -0
  147. data/app/views/observ/observations/show_generation.html.erb +195 -0
  148. data/app/views/observ/observations/show_span.html.erb +93 -0
  149. data/app/views/observ/prompts/_diff_content.html.erb +16 -0
  150. data/app/views/observ/prompts/_form.html.erb +111 -0
  151. data/app/views/observ/prompts/_new_form.html.erb +102 -0
  152. data/app/views/observ/prompts/_prompt_actions.html.erb +4 -0
  153. data/app/views/observ/prompts/_prompt_content_highlighted.html.erb +4 -0
  154. data/app/views/observ/prompts/_version_actions.html.erb +40 -0
  155. data/app/views/observ/prompts/compare.html.erb +155 -0
  156. data/app/views/observ/prompts/edit.html.erb +17 -0
  157. data/app/views/observ/prompts/index.html.erb +108 -0
  158. data/app/views/observ/prompts/new.html.erb +17 -0
  159. data/app/views/observ/prompts/show.html.erb +138 -0
  160. data/app/views/observ/prompts/versions.html.erb +87 -0
  161. data/app/views/observ/sessions/annotations_drawer.turbo_stream.erb +25 -0
  162. data/app/views/observ/sessions/drawer_test.turbo_stream.erb +49 -0
  163. data/app/views/observ/sessions/index.html.erb +91 -0
  164. data/app/views/observ/sessions/show.html.erb +251 -0
  165. data/app/views/observ/traces/add_to_dataset_drawer.turbo_stream.erb +48 -0
  166. data/app/views/observ/traces/annotations_drawer.turbo_stream.erb +25 -0
  167. data/app/views/observ/traces/index.html.erb +87 -0
  168. data/app/views/observ/traces/show.html.erb +285 -0
  169. data/app/views/observ/traces/text_output_drawer.turbo_stream.erb +48 -0
  170. data/app/views/shared/_drawer.html.erb +26 -0
  171. data/config/routes.rb +80 -0
  172. data/db/migrate/001_create_observ_sessions.rb +21 -0
  173. data/db/migrate/002_create_observ_traces.rb +25 -0
  174. data/db/migrate/003_create_observ_observations.rb +42 -0
  175. data/db/migrate/004_add_message_id_to_observ_traces.rb +7 -0
  176. data/db/migrate/005_create_observ_prompts.rb +21 -0
  177. data/db/migrate/006_fix_prompt_config_strings.rb +23 -0
  178. data/db/migrate/007_create_observ_annotations.rb +12 -0
  179. data/db/migrate/009_add_prompt_fields_to_observ_chats.rb +11 -0
  180. data/db/migrate/010_create_observ_datasets.rb +15 -0
  181. data/db/migrate/011_create_observ_dataset_items.rb +17 -0
  182. data/db/migrate/012_create_observ_dataset_runs.rb +22 -0
  183. data/db/migrate/013_create_observ_dataset_run_items.rb +16 -0
  184. data/db/migrate/014_create_observ_scores.rb +26 -0
  185. data/lib/generators/observ/add_phase_tracking/add_phase_tracking_generator.rb +150 -0
  186. data/lib/generators/observ/add_phase_tracking/templates/migration.rb.tt +6 -0
  187. data/lib/generators/observ/install/USAGE +27 -0
  188. data/lib/generators/observ/install/install_generator.rb +270 -0
  189. data/lib/generators/observ/install_chat/install_chat_generator.rb +313 -0
  190. data/lib/generators/observ/install_chat/templates/agents/base_agent.rb.tt +147 -0
  191. data/lib/generators/observ/install_chat/templates/agents/simple_agent.rb.tt +55 -0
  192. data/lib/generators/observ/install_chat/templates/concerns/observ_chat_enhancements.rb.tt +34 -0
  193. data/lib/generators/observ/install_chat/templates/concerns/observ_message_enhancements.rb.tt +18 -0
  194. data/lib/generators/observ/install_chat/templates/initializers/observability.rb.tt +20 -0
  195. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +56 -0
  196. data/lib/generators/observ/install_chat/templates/migrations/add_agent_class_name.rb.tt +6 -0
  197. data/lib/generators/observ/install_chat/templates/migrations/add_observability_session_id.rb.tt +6 -0
  198. data/lib/generators/observ/install_chat/templates/tools/think_tool.rb.tt +29 -0
  199. data/lib/generators/observ/install_chat/templates/views/messages/_content.html.erb.tt +1 -0
  200. data/lib/observ/asset_installer.rb +130 -0
  201. data/lib/observ/asset_syncer.rb +104 -0
  202. data/lib/observ/configuration.rb +108 -0
  203. data/lib/observ/engine.rb +50 -0
  204. data/lib/observ/index_file_generator.rb +142 -0
  205. data/lib/observ/instrumenter/ruby_llm.rb +6 -0
  206. data/lib/observ/version.rb +3 -0
  207. data/lib/observ.rb +29 -0
  208. data/lib/tasks/observ_tasks.rake +75 -0
  209. metadata +453 -0
@@ -0,0 +1,148 @@
1
+ module Observ
2
+ class PromptVersionsController < ApplicationController
3
+ before_action :set_prompt_name
4
+ before_action :set_prompt
5
+
6
+ # GET /observ/prompts/:prompt_id/versions/:id
7
+ def show
8
+ redirect_to prompt_path(@prompt_name, version: @prompt.version)
9
+ end
10
+
11
+ # POST /observ/prompts/:prompt_id/versions/:id/promote
12
+ def promote
13
+ unless @prompt.draft?
14
+ respond_to do |format|
15
+ format.html do
16
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
17
+ alert: "Only draft prompts can be promoted"
18
+ end
19
+ format.json { render json: { error: "Only draft prompts can be promoted" }, status: :unprocessable_content }
20
+ end
21
+ return
22
+ end
23
+
24
+ begin
25
+ Observ::PromptManager.promote(name: @prompt_name, version: @prompt.version)
26
+
27
+ respond_to do |format|
28
+ format.html do
29
+ redirect_to prompt_path(@prompt_name),
30
+ notice: "Version #{@prompt.version} promoted to production"
31
+ end
32
+ format.json { render json: { success: true, message: "Promoted to production" } }
33
+ end
34
+ rescue StandardError => e
35
+ respond_to do |format|
36
+ format.html do
37
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
38
+ alert: e.message
39
+ end
40
+ format.json { render json: { error: e.message }, status: :unprocessable_content }
41
+ end
42
+ end
43
+ end
44
+
45
+ # POST /observ/prompts/:prompt_id/versions/:id/demote
46
+ def demote
47
+ unless @prompt.production?
48
+ respond_to do |format|
49
+ format.html do
50
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
51
+ alert: "Only production prompts can be demoted"
52
+ end
53
+ format.json { render json: { error: "Only production prompts can be demoted" }, status: :unprocessable_content }
54
+ end
55
+ return
56
+ end
57
+
58
+ begin
59
+ Observ::PromptManager.demote(name: @prompt_name, version: @prompt.version)
60
+
61
+ respond_to do |format|
62
+ format.html do
63
+ redirect_to prompt_path(@prompt_name),
64
+ notice: "Version #{@prompt.version} demoted to archived"
65
+ end
66
+ format.json { render json: { success: true, message: "Demoted to archived" } }
67
+ end
68
+ rescue StandardError => e
69
+ respond_to do |format|
70
+ format.html do
71
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
72
+ alert: e.message
73
+ end
74
+ format.json { render json: { error: e.message }, status: :unprocessable_content }
75
+ end
76
+ end
77
+ end
78
+
79
+ # POST /observ/prompts/:prompt_id/versions/:id/restore
80
+ def restore
81
+ unless @prompt.archived?
82
+ respond_to do |format|
83
+ format.html do
84
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
85
+ alert: "Only archived prompts can be restored"
86
+ end
87
+ format.json { render json: { error: "Only archived prompts can be restored" }, status: :unprocessable_content }
88
+ end
89
+ return
90
+ end
91
+
92
+ begin
93
+ Observ::PromptManager.restore(name: @prompt_name, version: @prompt.version)
94
+
95
+ respond_to do |format|
96
+ format.html do
97
+ redirect_to prompt_path(@prompt_name),
98
+ notice: "Version #{@prompt.version} restored to production"
99
+ end
100
+ format.json { render json: { success: true, message: "Restored to production" } }
101
+ end
102
+ rescue StandardError => e
103
+ respond_to do |format|
104
+ format.html do
105
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
106
+ alert: e.message
107
+ end
108
+ format.json { render json: { error: e.message }, status: :unprocessable_content }
109
+ end
110
+ end
111
+ end
112
+
113
+ # POST /observ/prompts/:prompt_id/versions/:id/clone
114
+ def clone
115
+ # Production and archived prompts are immutable - clone to draft for editing
116
+ begin
117
+ new_prompt = Observ::PromptManager.create(
118
+ name: @prompt_name,
119
+ prompt: @prompt.prompt,
120
+ config: @prompt.config,
121
+ commit_message: "Cloned from version #{@prompt.version}",
122
+ created_by: current_user_identifier,
123
+ promote_to_production: false
124
+ )
125
+
126
+ redirect_to edit_prompt_path(@prompt_name, version: new_prompt.version),
127
+ notice: "Created editable draft (v#{new_prompt.version}) from version #{@prompt.version}"
128
+ rescue ActiveRecord::RecordInvalid, StandardError => e
129
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
130
+ alert: e.message
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def set_prompt_name
137
+ @prompt_name = params[:prompt_id]
138
+ end
139
+
140
+ def set_prompt
141
+ @prompt = Observ::Prompt.find(params[:id])
142
+ end
143
+
144
+ def current_user_identifier
145
+ "system" # Default fallback
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,205 @@
1
+ module Observ
2
+ class PromptsController < ApplicationController
3
+ before_action :set_prompt_name, only: [ :show, :edit, :update, :destroy, :versions, :compare ]
4
+ before_action :set_prompt, only: [ :edit, :update, :destroy ]
5
+
6
+ # GET /observ/prompts
7
+ def index
8
+ @prompts = Observ::Prompt.select(:name)
9
+ .distinct
10
+ .order(:name)
11
+
12
+ # Apply search filter
13
+ if params[:search].present?
14
+ @prompts = @prompts.where("name LIKE ? COLLATE NOCASE", "%#{params[:search]}%")
15
+ end
16
+
17
+ # Apply state filter
18
+ if params[:state].present?
19
+ @prompts = @prompts.where(state: params[:state])
20
+ end
21
+
22
+ @prompts = @prompts.page(params[:page]).per(Observ.config.pagination_per_page)
23
+
24
+ # Enrich with metadata for display
25
+ @prompt_data = @prompts.map do |prompt|
26
+ latest = Observ::Prompt.where(name: prompt.name).order(version: :desc).first
27
+ production = Observ::Prompt.where(name: prompt.name, state: :production).first
28
+
29
+ {
30
+ name: prompt.name,
31
+ total_versions: Observ::Prompt.where(name: prompt.name).count,
32
+ production_version: production&.version,
33
+ latest_version: latest.version,
34
+ latest_state: latest.state,
35
+ last_updated: latest.updated_at,
36
+ has_draft: Observ::Prompt.where(name: prompt.name, state: :draft).exists?
37
+ }
38
+ end
39
+ end
40
+
41
+ # GET /observ/prompts/new
42
+ def new
43
+ @form = Observ::PromptForm.new(
44
+ name: params[:name],
45
+ from_version: params[:from_version]
46
+ )
47
+ end
48
+
49
+ # POST /observ/prompts
50
+ def create
51
+ @form = Observ::PromptForm.new(form_params)
52
+ @form.created_by = current_user_identifier
53
+
54
+ if @form.save
55
+ redirect_to prompt_path(@form.persisted_prompt.name),
56
+ notice: "Prompt created successfully (v#{@form.persisted_prompt.version})"
57
+ else
58
+ render :new, status: :unprocessable_content
59
+ end
60
+ end
61
+
62
+ # GET /observ/prompts/:id
63
+ def show
64
+ # Get production version by default, or latest version if no production
65
+ @prompt = Observ::Prompt.where(name: @prompt_name, state: :production).first ||
66
+ Observ::Prompt.where(name: @prompt_name).order(version: :desc).first
67
+
68
+ unless @prompt
69
+ redirect_to prompts_path, alert: "Prompt not found"
70
+ return
71
+ end
72
+
73
+ # Load specific version if requested
74
+ if params[:version].present?
75
+ @prompt = Observ::Prompt.find_by(name: @prompt_name, version: params[:version])
76
+ unless @prompt
77
+ redirect_to prompt_path(@prompt_name), alert: "Version not found"
78
+ return
79
+ end
80
+ end
81
+
82
+ @all_versions = Observ::Prompt.where(name: @prompt_name).order(version: :desc)
83
+ @production_version = @all_versions.find(&:production?)
84
+ end
85
+
86
+ # GET /observ/prompts/:id/edit
87
+ def edit
88
+ # Only draft prompts can be edited
89
+ unless @prompt.draft?
90
+ redirect_to prompt_path(@prompt_name),
91
+ alert: "Only draft prompts can be edited. Clone this version to create an editable draft."
92
+ end
93
+ end
94
+
95
+ # PATCH /observ/prompts/:id
96
+ def update
97
+ unless @prompt.draft?
98
+ redirect_to prompt_path(@prompt_name),
99
+ alert: "Only draft prompts can be edited"
100
+ return
101
+ end
102
+
103
+ # Parse config JSON string before updating
104
+ update_params = prompt_params.except(:name, :version, :promote_to_production)
105
+ if update_params[:config].present?
106
+ update_params[:config] = parse_config(update_params[:config])
107
+ end
108
+
109
+ if @prompt.update(update_params)
110
+ redirect_to prompt_path(@prompt_name, version: @prompt.version),
111
+ notice: "Prompt updated successfully"
112
+ else
113
+ render :edit, status: :unprocessable_content
114
+ end
115
+ end
116
+
117
+ # DELETE /observ/prompts/:id
118
+ def destroy
119
+ # Can only delete draft and archived prompts
120
+ if @prompt.production?
121
+ redirect_to prompt_path(@prompt_name),
122
+ alert: "Cannot delete production prompts"
123
+ return
124
+ end
125
+
126
+ @prompt.destroy
127
+ redirect_to prompts_path, notice: "Prompt version #{@prompt.version} deleted"
128
+ end
129
+
130
+ # GET /observ/prompts/:id/versions
131
+ def versions
132
+ @versions = Observ::Prompt.where(name: @prompt_name).order(version: :desc)
133
+ @production_version = @versions.find(&:production?)
134
+
135
+ respond_to do |format|
136
+ format.html # Render the HTML view
137
+ format.json do
138
+ render json: @versions.as_json(only: [ :version, :state, :commit_message, :created_at ])
139
+ end
140
+ end
141
+ end
142
+
143
+ # GET /observ/prompts/:id/compare?from=1&to=2
144
+ def compare
145
+ @from_version = Observ::Prompt.find_by(name: @prompt_name, version: params[:from])
146
+ @to_version = Observ::Prompt.find_by(name: @prompt_name, version: params[:to])
147
+
148
+ unless @from_version && @to_version
149
+ redirect_to versions_prompt_path(@prompt_name),
150
+ alert: "Both versions must be specified"
151
+ return
152
+ end
153
+
154
+ @diff = calculate_diff(@from_version.prompt, @to_version.prompt)
155
+ end
156
+
157
+ private
158
+
159
+ def set_prompt_name
160
+ @prompt_name = params[:id] || params[:name]
161
+ end
162
+
163
+ def set_prompt
164
+ version = params[:version] || Observ::Prompt.where(name: @prompt_name, state: :draft).maximum(:version)
165
+ @prompt = Observ::Prompt.find_by!(name: @prompt_name, version: version)
166
+ end
167
+
168
+ def prompt_params
169
+ params.require(:observ_prompt).permit(
170
+ :name, :prompt, :config, :commit_message, :promote_to_production
171
+ )
172
+ end
173
+
174
+ def form_params
175
+ params.require(:observ_prompt_form).permit(
176
+ :name, :prompt, :config, :commit_message, :promote_to_production, :from_version
177
+ )
178
+ end
179
+
180
+ def parse_config(config_string)
181
+ return {} if config_string.blank?
182
+ JSON.parse(config_string)
183
+ rescue JSON::ParserError
184
+ {}
185
+ end
186
+
187
+ def current_user_identifier
188
+ # Implement based on your authentication system
189
+ "system" # Default fallback
190
+ end
191
+
192
+ def calculate_diff(text1, text2)
193
+ # Simple line-by-line diff
194
+ # In production, consider using gems like 'diffy' or 'diff-lcs'
195
+ lines1 = text1.split("\n")
196
+ lines2 = text2.split("\n")
197
+
198
+ {
199
+ removed: lines1 - lines2,
200
+ added: lines2 - lines1,
201
+ common: lines1 & lines2
202
+ }
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,45 @@
1
+ module Observ
2
+ class SessionsController < ApplicationController
3
+ def index
4
+ @sessions = Observ::Session.order(start_time: :desc)
5
+ apply_filters if params[:filter].present?
6
+ @sessions = @sessions.page(params[:page]).per(Observ.config.pagination_per_page)
7
+ end
8
+
9
+ def show
10
+ @session = Observ::Session.find(params[:id])
11
+ @traces = @session.traces.order(start_time: :asc)
12
+ @session_metrics = @session.session_metrics
13
+ @chat = @session.chat
14
+ end
15
+
16
+ def metrics
17
+ @session = Observ::Session.find(params[:id])
18
+ render json: @session.session_metrics
19
+ end
20
+
21
+ def drawer_test
22
+ @session = Observ::Session.find(params[:id])
23
+ end
24
+
25
+ def annotations_drawer
26
+ @session = Observ::Session.find(params[:id])
27
+ @annotations = @session.annotations.recent
28
+ @annotation = @session.annotations.build
29
+ end
30
+
31
+ private
32
+
33
+ def apply_filters
34
+ @sessions = @sessions.where(user_id: params[:filter][:user_id]) if params[:filter][:user_id].present?
35
+
36
+ if params[:filter][:start_date].present?
37
+ @sessions = @sessions.where("start_time >= ?", params[:filter][:start_date])
38
+ end
39
+
40
+ if params[:filter][:end_date].present?
41
+ @sessions = @sessions.where("start_time <= ?", params[:filter][:end_date])
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
1
+ module Observ
2
+ class TracesController < ApplicationController
3
+ def index
4
+ @traces = Observ::Trace
5
+ .includes(:observ_session)
6
+ .order(start_time: :desc)
7
+ .page(params[:page])
8
+ .per(Observ.config.pagination_per_page)
9
+
10
+ apply_filters if params[:filter].present?
11
+ end
12
+
13
+ def show
14
+ @trace = Observ::Trace.includes(:observations).find(params[:id])
15
+ @observations = @trace.observations.order(start_time: :asc)
16
+ @generations = @trace.generations
17
+ @spans = @trace.spans
18
+ end
19
+
20
+ def search
21
+ @traces = Observ::Trace
22
+ .includes(:observ_session)
23
+ .where("trace_id LIKE ? OR name LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%")
24
+ .order(start_time: :desc)
25
+ .limit(50)
26
+
27
+ render :index
28
+ end
29
+
30
+ def annotations_drawer
31
+ @trace = Observ::Trace.find(params[:id])
32
+ @annotations = @trace.annotations.recent
33
+ @annotation = @trace.annotations.build
34
+ end
35
+
36
+ def text_output_drawer
37
+ @trace = Observ::Trace.includes(:observations, :annotations, observ_session: :annotations).find(params[:id])
38
+ @formatted_text = Observ::TraceTextFormatter.new(@trace).format
39
+ end
40
+
41
+ def add_to_dataset_drawer
42
+ @trace = Observ::Trace.find(params[:id])
43
+ @datasets = Observ::Dataset.order(:name)
44
+ end
45
+
46
+ def add_to_dataset
47
+ @trace = Observ::Trace.find(params[:id])
48
+ @dataset = Observ::Dataset.find(params[:dataset_id])
49
+
50
+ @item = @dataset.items.build(
51
+ input: @trace.input,
52
+ expected_output: params[:expected_output].presence || @trace.output,
53
+ source_trace: @trace,
54
+ status: :active
55
+ )
56
+
57
+ if @item.save
58
+ redirect_to dataset_path(@dataset, tab: "items"),
59
+ notice: "Trace added to dataset '#{@dataset.name}' successfully."
60
+ else
61
+ @datasets = Observ::Dataset.order(:name)
62
+ flash.now[:alert] = "Failed to add trace to dataset: #{@item.errors.full_messages.join(', ')}"
63
+ render :add_to_dataset_drawer, status: :unprocessable_entity
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def apply_filters
70
+ if params[:filter][:session_id].present?
71
+ session = Observ::Session.find_by(session_id: params[:filter][:session_id])
72
+ @traces = @traces.where(observ_session_id: session&.id) if session
73
+ end
74
+
75
+ @traces = @traces.where(name: params[:filter][:name]) if params[:filter][:name].present?
76
+
77
+ if params[:filter][:start_date].present?
78
+ @traces = @traces.where("start_time >= ?", params[:filter][:start_date])
79
+ end
80
+
81
+ if params[:filter][:end_date].present?
82
+ @traces = @traces.where("start_time <= ?", params[:filter][:end_date])
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class PromptForm
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+
8
+ attribute :name, :string
9
+ attribute :prompt, :string
10
+ attribute :config, :string # JSON string from form
11
+ attribute :commit_message, :string
12
+ attribute :promote_to_production, :boolean, default: false
13
+ attribute :from_version, :integer
14
+
15
+ # For dependency injection
16
+ attr_accessor :created_by
17
+ attr_reader :persisted_prompt
18
+
19
+ validates :name, presence: true
20
+ validates :prompt, presence: true
21
+ validate :config_must_be_valid_json
22
+
23
+ def initialize(attributes = {})
24
+ super
25
+ load_from_version if from_version.present? && name.present?
26
+ end
27
+
28
+ def save
29
+ return false unless valid?
30
+
31
+ @persisted_prompt = PromptManager.create(
32
+ name: name,
33
+ prompt: prompt,
34
+ config: parsed_config,
35
+ commit_message: commit_message,
36
+ created_by: created_by,
37
+ promote_to_production: promote_to_production
38
+ )
39
+
40
+ true
41
+ rescue ActiveRecord::RecordInvalid => e
42
+ # Copy model errors to form
43
+ e.record.errors.each do |error|
44
+ errors.add(error.attribute, error.message)
45
+ end
46
+ false
47
+ end
48
+
49
+ def parsed_config
50
+ return {} if config.blank?
51
+ JSON.parse(config)
52
+ rescue JSON::ParserError
53
+ {}
54
+ end
55
+
56
+ # For form display - returns formatted JSON string
57
+ def config_json
58
+ return "" if config.blank?
59
+ config.is_a?(String) ? config : JSON.pretty_generate(config)
60
+ end
61
+
62
+ # ActiveModel compatibility for form_with
63
+ def model_name
64
+ ActiveModel::Name.new(self.class, nil, "observ_prompt_form")
65
+ end
66
+
67
+ def persisted?
68
+ false
69
+ end
70
+
71
+ def to_key
72
+ nil
73
+ end
74
+
75
+ def to_model
76
+ self
77
+ end
78
+
79
+ private
80
+
81
+ def config_must_be_valid_json
82
+ return if config.blank?
83
+ JSON.parse(config)
84
+ rescue JSON::ParserError => e
85
+ errors.add(:config, "must be valid JSON: #{e.message}")
86
+ end
87
+
88
+ def load_from_version
89
+ source = Prompt.find_by(name: name, version: from_version)
90
+ return unless source
91
+
92
+ self.prompt = source.prompt
93
+ self.config = source.config.present? ? JSON.pretty_generate(source.config) : ""
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,9 @@
1
+ module Observ
2
+ module ApplicationHelper
3
+ # Include helper modules to make them available across all views
4
+ include Observ::DashboardHelper
5
+ include Observ::ChatsHelper
6
+ include Observ::PaginationHelper
7
+ include Observ::DatasetsHelper
8
+ end
9
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ module ChatsHelper
5
+ # Returns formatted agent selection options for the chat form
6
+ #
7
+ # This helper provides agent selection options for use in select dropdowns.
8
+ # The options are memoized per request to avoid redundant agent discovery.
9
+ #
10
+ # Usage in views:
11
+ # <%= form.select :agent_class_name, agent_select_options, {}, class: "observ-select" %>
12
+ #
13
+ # @return [Array<Array<String>>] options array for Rails select helper
14
+ # Format: [["Display Name", "ClassName"], ...]
15
+ def agent_select_options
16
+ @agent_select_options ||= AgentSelectionService.options
17
+ end
18
+
19
+ # Returns a map of agent class names to their prompt names
20
+ #
21
+ # This helper identifies which agents use prompt management and maps them
22
+ # to their configured prompt names. Used by the chat form's Stimulus controller
23
+ # to dynamically show/hide the prompt version selector.
24
+ #
25
+ # Usage in views:
26
+ # data: {
27
+ # observ__chat_form_agents_with_prompts_value: agents_with_prompts_map.to_json
28
+ # }
29
+ #
30
+ # @return [Hash<String, String>] Hash mapping agent class names to prompt names
31
+ # Format: { "AgentClassName" => "prompt-name", ... }
32
+ def agents_with_prompts_map
33
+ @agents_with_prompts_map ||= begin
34
+ Observ::AgentProvider.all_agents.each_with_object({}) do |agent_class, hash|
35
+ # Check if agent includes PromptManagement and has prompt management enabled
36
+ if agent_class.included_modules.include?(Observ::PromptManagement) &&
37
+ agent_class.respond_to?(:prompt_management_enabled?) &&
38
+ agent_class.prompt_management_enabled?
39
+ # Extract prompt name from agent's configuration
40
+ prompt_name = agent_class.prompt_config[:prompt_name]
41
+ hash[agent_class.name] = prompt_name if prompt_name.present?
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end