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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class TraceTextFormatter
5
+ SEPARATOR = "=" * 80
6
+ SUBSEPARATOR = "-" * 80
7
+ INDENT = " "
8
+ MAX_CONTENT_LENGTH = 10_000
9
+
10
+ attr_reader :trace
11
+
12
+ def initialize(trace)
13
+ @trace = trace
14
+ end
15
+
16
+ # Main public method to format the trace as plain text
17
+ def format
18
+ parts = []
19
+ parts << format_trace_header
20
+ parts << format_trace_details
21
+ parts << format_trace_annotations if trace.annotations.any?
22
+ parts << ""
23
+ parts << format_observations_section
24
+ parts << SEPARATOR
25
+ parts.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def format_trace_header
31
+ [
32
+ SEPARATOR,
33
+ "TRACE: #{trace.name || 'Unnamed Trace'}",
34
+ SEPARATOR
35
+ ].join("\n")
36
+ end
37
+
38
+ def format_trace_details
39
+ details = []
40
+ details << "Trace ID: #{trace.trace_id}"
41
+ details << "Start Time: #{format_time(trace.start_time)}"
42
+ details << "End Time: #{format_time(trace.end_time)}" if trace.end_time
43
+ details << "Duration: #{format_duration(trace.duration_ms)}" if trace.duration_ms
44
+ details << "Total Cost: #{format_cost(trace.total_cost)}" if trace.total_cost && trace.total_cost > 0
45
+ details << "Total Tokens: #{trace.total_tokens}" if trace.total_tokens && trace.total_tokens > 0
46
+
47
+ if trace.user_id.present?
48
+ details << "User ID: #{trace.user_id}"
49
+ end
50
+
51
+ if trace.release.present?
52
+ details << "Release: #{trace.release}"
53
+ end
54
+
55
+ if trace.version.present?
56
+ details << "Version: #{trace.version}"
57
+ end
58
+
59
+ if trace.input.present?
60
+ details << ""
61
+ details << "Input:"
62
+ details << format_content(trace.input)
63
+ end
64
+
65
+ if trace.output.present?
66
+ details << ""
67
+ details << "Output:"
68
+ details << format_content(trace.output)
69
+ end
70
+
71
+ if trace.metadata.present? && trace.metadata.any?
72
+ details << ""
73
+ details << "Metadata:"
74
+ details << format_json(trace.metadata)
75
+ end
76
+
77
+ if trace.tags.present? && trace.tags.any?
78
+ details << ""
79
+ details << "Tags: #{trace.tags.to_json}"
80
+ end
81
+
82
+ details.join("\n")
83
+ end
84
+
85
+ def format_trace_annotations
86
+ annotations = trace.annotations.order(created_at: :asc)
87
+ return "" if annotations.empty?
88
+
89
+ parts = []
90
+ parts << ""
91
+ parts << "--- ANNOTATIONS (#{annotations.count}) ---"
92
+
93
+ annotations.each do |annotation|
94
+ parts << format_annotation(annotation)
95
+ end
96
+
97
+ parts.join("\n")
98
+ end
99
+
100
+ def format_observations_section
101
+ observations = trace.observations.order(start_time: :asc)
102
+ return "No observations recorded." if observations.empty?
103
+
104
+ parts = []
105
+ parts << SEPARATOR
106
+ parts << "OBSERVATIONS (#{observations.count})"
107
+ parts << SEPARATOR
108
+ parts << ""
109
+
110
+ # Build tree structure
111
+ observations_by_parent = observations.group_by(&:parent_observation_id)
112
+ root_observations = observations_by_parent[nil] || []
113
+
114
+ root_observations.each do |observation|
115
+ parts << format_observation(observation, observations_by_parent, 0)
116
+ parts << ""
117
+ end
118
+
119
+ parts.join("\n")
120
+ end
121
+
122
+ def format_observation(observation, observations_by_parent, depth)
123
+ indent = INDENT * depth
124
+ parts = []
125
+
126
+ # Observation header
127
+ parts << "#{indent}┌─ [#{observation.type.demodulize.upcase}] #{observation.name}"
128
+ parts << "#{indent}│ Observation ID: #{observation.observation_id}"
129
+ parts << "#{indent}│ Start: #{format_time(observation.start_time)}"
130
+ parts << "#{indent}│ End: #{format_time(observation.end_time)}" if observation.end_time
131
+ parts << "#{indent}│ Duration: #{format_duration(observation.duration_ms)}" if observation.duration_ms
132
+
133
+ # Type-specific formatting
134
+ if observation.is_a?(Observ::Generation)
135
+ parts << format_generation_details(observation, indent)
136
+ elsif observation.is_a?(Observ::Span)
137
+ parts << format_span_details(observation, indent)
138
+ end
139
+
140
+ parts << "#{indent}└─ End of #{observation.type.demodulize.upcase}"
141
+
142
+ # Recursively format child observations
143
+ children = observations_by_parent[observation.observation_id] || []
144
+ if children.any?
145
+ parts << ""
146
+ parts << "#{indent} ┌─ CHILD OBSERVATIONS (#{children.count})"
147
+ children.each do |child|
148
+ parts << format_observation(child, observations_by_parent, depth + 1)
149
+ end
150
+ parts << "#{indent} └─ END CHILD OBSERVATIONS"
151
+ end
152
+
153
+ parts.join("\n")
154
+ end
155
+
156
+ def format_generation_details(generation, indent)
157
+ details = []
158
+
159
+ if generation.model.present?
160
+ details << "#{indent}│ Model: #{generation.model}"
161
+ end
162
+
163
+ if generation.cost_usd && generation.cost_usd > 0
164
+ details << "#{indent}│ Cost: #{format_cost(generation.cost_usd)}"
165
+ end
166
+
167
+ if generation.usage.present? && generation.usage.any?
168
+ details << "#{indent}│ "
169
+ details << "#{indent}│ Usage:"
170
+ generation.usage.each do |key, value|
171
+ details << "#{indent}│ - #{key.to_s.humanize}: #{value}"
172
+ end
173
+ end
174
+
175
+ if generation.model_parameters.present? && generation.model_parameters.any?
176
+ details << "#{indent}│ "
177
+ details << "#{indent}│ Model Parameters:"
178
+ generation.model_parameters.each do |key, value|
179
+ details << "#{indent}│ - #{key}: #{value}"
180
+ end
181
+ end
182
+
183
+ if generation.prompt_name.present?
184
+ details << "#{indent}│ Prompt Name: #{generation.prompt_name}"
185
+ details << "#{indent}│ Prompt Version: #{generation.prompt_version}" if generation.prompt_version.present?
186
+ end
187
+
188
+ if generation.finish_reason.present?
189
+ details << "#{indent}│ Finish Reason: #{generation.finish_reason}"
190
+ end
191
+
192
+ if generation.status_message.present?
193
+ details << "#{indent}│ Status: #{generation.status_message}"
194
+ end
195
+
196
+ if generation.messages.present? && generation.messages.any?
197
+ details << "#{indent}│ "
198
+ details << "#{indent}│ Messages:"
199
+ generation.messages.each_with_index do |msg, idx|
200
+ details << "#{indent}│ [#{idx + 1}] #{msg['role']}: #{truncate_content(msg['content'], 200)}"
201
+ end
202
+ end
203
+
204
+ if generation.tools.present? && generation.tools.any?
205
+ details << "#{indent}│ "
206
+ details << "#{indent}│ Tools Available: #{generation.tools.count}"
207
+ generation.tools.first(3).each do |tool|
208
+ tool_name = tool.is_a?(Hash) ? tool["name"] || tool[:name] : tool.to_s
209
+ details << "#{indent}│ - #{tool_name}"
210
+ end
211
+ details << "#{indent}│ ... and #{generation.tools.count - 3} more" if generation.tools.count > 3
212
+ end
213
+
214
+ if generation.input.present?
215
+ details << "#{indent}│ "
216
+ details << "#{indent}│ Input:"
217
+ format_content(generation.input).split("\n").each do |line|
218
+ details << "#{indent}│ #{line}"
219
+ end
220
+ end
221
+
222
+ if generation.output.present?
223
+ details << "#{indent}│ "
224
+ details << "#{indent}│ Output:"
225
+ format_content(generation.output).split("\n").each do |line|
226
+ details << "#{indent}│ #{line}"
227
+ end
228
+ end
229
+
230
+ if generation.metadata.present? && generation.metadata.any?
231
+ details << "#{indent}│ "
232
+ details << "#{indent}│ Metadata:"
233
+ format_json(generation.metadata).split("\n").each do |line|
234
+ details << "#{indent}│ #{line}"
235
+ end
236
+ end
237
+
238
+ if generation.provider_metadata.present? && generation.provider_metadata.any?
239
+ details << "#{indent}│ "
240
+ details << "#{indent}│ Provider Metadata:"
241
+ format_json(generation.provider_metadata).split("\n").each do |line|
242
+ details << "#{indent}│ #{line}"
243
+ end
244
+ end
245
+
246
+ details.join("\n")
247
+ end
248
+
249
+ def format_span_details(span, indent)
250
+ details = []
251
+
252
+ if span.status_message.present?
253
+ details << "#{indent}│ Status: #{span.status_message}"
254
+ end
255
+
256
+ if span.input.present?
257
+ details << "#{indent}│ "
258
+ details << "#{indent}│ Input:"
259
+ format_content(span.input).split("\n").each do |line|
260
+ details << "#{indent}│ #{line}"
261
+ end
262
+ end
263
+
264
+ if span.output.present?
265
+ details << "#{indent}│ "
266
+ details << "#{indent}│ Output:"
267
+ format_content(span.output).split("\n").each do |line|
268
+ details << "#{indent}│ #{line}"
269
+ end
270
+ end
271
+
272
+ if span.metadata.present? && span.metadata.any?
273
+ details << "#{indent}│ "
274
+ details << "#{indent}│ Metadata:"
275
+ format_json(span.metadata).split("\n").each do |line|
276
+ details << "#{indent}│ #{line}"
277
+ end
278
+ end
279
+
280
+ details.join("\n")
281
+ end
282
+
283
+ def format_annotation(annotation, prefix = "")
284
+ parts = []
285
+ timestamp = format_time(annotation.created_at)
286
+
287
+ if annotation.annotator.present?
288
+ parts << "#{prefix}[#{timestamp}] #{annotation.annotator}"
289
+ else
290
+ parts << "#{prefix}[#{timestamp}]"
291
+ end
292
+
293
+ parts << "#{prefix}#{annotation.content}"
294
+
295
+ if annotation.tags.present? && annotation.tags.any?
296
+ parts << "#{prefix}Tags: #{annotation.tags.to_json}"
297
+ end
298
+
299
+ parts << ""
300
+ parts.join("\n")
301
+ end
302
+
303
+ def format_time(time)
304
+ return "N/A" unless time
305
+ time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
306
+ end
307
+
308
+ def format_duration(duration_ms)
309
+ return "N/A" unless duration_ms
310
+ "#{duration_ms}ms"
311
+ end
312
+
313
+ def format_cost(cost)
314
+ return "$0.000000" unless cost
315
+ "$#{sprintf('%.6f', cost)}"
316
+ end
317
+
318
+ def format_content(content)
319
+ return "" if content.blank?
320
+
321
+ # Try to parse as JSON for pretty printing
322
+ if content.is_a?(String)
323
+ begin
324
+ parsed = JSON.parse(content)
325
+ return format_json(parsed)
326
+ rescue JSON::ParserError
327
+ # Not JSON, return as-is
328
+ end
329
+ end
330
+
331
+ truncate_content(content.to_s)
332
+ end
333
+
334
+ def format_json(obj)
335
+ JSON.pretty_generate(obj)
336
+ rescue StandardError
337
+ obj.to_s
338
+ end
339
+
340
+ def truncate_content(content, max_length = MAX_CONTENT_LENGTH)
341
+ return "" if content.blank?
342
+
343
+ content_str = content.to_s
344
+ return content_str if content_str.length <= max_length
345
+
346
+ "#{content_str[0...max_length]}...\n[Content truncated, original length: #{content_str.length} characters]"
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class PromptConfigValidator
5
+ attr_reader :config, :errors
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ @errors = []
10
+ end
11
+
12
+ def valid?
13
+ @errors = []
14
+
15
+ return true if config.blank?
16
+
17
+ unless config.is_a?(Hash)
18
+ @errors << "Config must be a Hash"
19
+ return false
20
+ end
21
+
22
+ validate_against_schema
23
+ @errors.empty?
24
+ end
25
+
26
+ private
27
+
28
+ def validate_against_schema
29
+ schema.each do |key, rules|
30
+ value, raw_key = value_with_key(key)
31
+
32
+ # Check required fields
33
+ if rules[:required] && value.nil?
34
+ @errors << "#{key} is required"
35
+ next
36
+ end
37
+
38
+ # Skip validation if value is nil and not required
39
+ next if value.nil?
40
+
41
+ # Coerce values like numeric strings before validation
42
+ coerced_value = coerce_value(value, rules[:type])
43
+ assign_value(raw_key, coerced_value) if raw_key && coerced_value != value
44
+ value = coerced_value
45
+
46
+ # Validate type
47
+ validate_type(key, value, rules)
48
+
49
+ # Validate range if specified
50
+ validate_range(key, value, rules) if rules[:range]
51
+
52
+ # Validate allowed values if specified
53
+ validate_allowed_values(key, value, rules) if rules[:allowed]
54
+
55
+ # Validate array items if specified
56
+ validate_array_items(key, value, rules) if rules[:item_type]
57
+ end
58
+
59
+ # Check for unknown keys
60
+ validate_unknown_keys if schema_strict?
61
+ end
62
+
63
+ def validate_type(key, value, rules)
64
+ expected_type = rules[:type]
65
+
66
+ case expected_type
67
+ when :integer
68
+ unless value.is_a?(Integer)
69
+ @errors << "#{key} must be an integer"
70
+ end
71
+ when :float
72
+ unless value.is_a?(Numeric)
73
+ @errors << "#{key} must be a number"
74
+ end
75
+ when :string
76
+ unless value.is_a?(String)
77
+ @errors << "#{key} must be a string"
78
+ end
79
+ when :boolean
80
+ unless [ true, false ].include?(value)
81
+ @errors << "#{key} must be a boolean"
82
+ end
83
+ when :array
84
+ unless value.is_a?(Array)
85
+ @errors << "#{key} must be an array"
86
+ end
87
+ when :hash
88
+ unless value.is_a?(Hash)
89
+ @errors << "#{key} must be a hash"
90
+ end
91
+ end
92
+ end
93
+
94
+ def validate_range(key, value, rules)
95
+ range = rules[:range]
96
+
97
+ return unless value.is_a?(Numeric)
98
+
99
+ unless range.cover?(value)
100
+ @errors << "#{key} must be between #{range.min} and #{range.max}"
101
+ end
102
+ end
103
+
104
+ def validate_allowed_values(key, value, rules)
105
+ allowed = rules[:allowed]
106
+
107
+ unless allowed.include?(value)
108
+ @errors << "#{key} must be one of: #{allowed.join(', ')}"
109
+ end
110
+ end
111
+
112
+ def validate_array_items(key, value, rules)
113
+ return unless value.is_a?(Array)
114
+
115
+ item_type = rules[:item_type]
116
+
117
+ value.each_with_index do |item, index|
118
+ case item_type
119
+ when :string
120
+ unless item.is_a?(String)
121
+ @errors << "#{key}[#{index}] must be a string"
122
+ end
123
+ when :integer
124
+ unless item.is_a?(Integer)
125
+ @errors << "#{key}[#{index}] must be an integer"
126
+ end
127
+ when :float
128
+ unless item.is_a?(Numeric)
129
+ @errors << "#{key}[#{index}] must be a number"
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ def validate_unknown_keys
136
+ schema_keys = schema.keys.map { |k| [ k.to_s, k.to_sym ] }.flatten
137
+ config_keys = config.keys
138
+
139
+ unknown_keys = config_keys - schema_keys
140
+
141
+ if unknown_keys.any?
142
+ @errors << "Unknown configuration keys: #{unknown_keys.join(', ')}"
143
+ end
144
+ end
145
+
146
+ def schema
147
+ @schema ||= Observ.config.prompt_config_schema
148
+ end
149
+
150
+ def schema_strict?
151
+ Observ.config.prompt_config_schema_strict
152
+ end
153
+
154
+ def value_with_key(key)
155
+ if config.key?(key.to_s)
156
+ [ config[key.to_s], key.to_s ]
157
+ elsif config.key?(key.to_sym)
158
+ [ config[key.to_sym], key.to_sym ]
159
+ else
160
+ [ nil, nil ]
161
+ end
162
+ end
163
+
164
+ def assign_value(raw_key, value)
165
+ return unless raw_key
166
+ config[raw_key] = value
167
+ end
168
+
169
+ def coerce_value(value, expected_type)
170
+ case expected_type
171
+ when :integer
172
+ return value.to_i if integer_string?(value)
173
+ when :float
174
+ return value.to_f if numeric_string?(value)
175
+ end
176
+ value
177
+ end
178
+
179
+ def integer_string?(value)
180
+ value.is_a?(String) && value.match?(/\A-?\d+\z/)
181
+ end
182
+
183
+ def numeric_string?(value)
184
+ value.is_a?(String) && value.match?(/\A-?\d+(?:\.\d+)?\z/)
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,11 @@
1
+ <%# Link to the "First" page
2
+ - available local variables
3
+ url: url to the first page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="first">
10
+ <%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
11
+ </span>
@@ -0,0 +1,8 @@
1
+ <%# Non-link tag that stands for skipped pages...
2
+ - available local variables
3
+ current_page: a page object for the currently displayed page
4
+ total_pages: total number of pages
5
+ per_page: number of items to fetch per page
6
+ remote: data-remote
7
+ -%>
8
+ <span class="page gap"><%= t('views.pagination.truncate').html_safe %></span>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "Last" page
2
+ - available local variables
3
+ url: url to the last page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="last">
10
+ <%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
11
+ </span>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "Next" page
2
+ - available local variables
3
+ url: url to the next page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="next">
10
+ <%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
11
+ </span>
@@ -0,0 +1,12 @@
1
+ <%# Link showing page number
2
+ - available local variables
3
+ page: a page object for "this" page
4
+ url: url to this page
5
+ current_page: a page object for the currently displayed page
6
+ total_pages: total number of pages
7
+ per_page: number of items to fetch per page
8
+ remote: data-remote
9
+ -%>
10
+ <span class="page<%= ' current' if page.current? %>">
11
+ <%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
12
+ </span>
@@ -0,0 +1,25 @@
1
+ <%# The container tag
2
+ - available local variables
3
+ current_page: a page object for the currently displayed page
4
+ total_pages: total number of pages
5
+ per_page: number of items to fetch per page
6
+ remote: data-remote
7
+ paginator: the paginator that renders the pagination tags inside
8
+ -%>
9
+ <%= paginator.render do -%>
10
+ <nav class="pagination" role="navigation" aria-label="pager">
11
+ <%= first_page_tag unless current_page.first? %>
12
+ <%= prev_page_tag unless current_page.first? %>
13
+ <% each_page do |page| -%>
14
+ <% if page.display_tag? -%>
15
+ <%= page_tag page %>
16
+ <% elsif !page.was_truncated? -%>
17
+ <%= gap_tag %>
18
+ <% end -%>
19
+ <% end -%>
20
+ <% unless current_page.out_of_range? %>
21
+ <%= next_page_tag unless current_page.last? %>
22
+ <%= last_page_tag unless current_page.last? %>
23
+ <% end %>
24
+ </nav>
25
+ <% end -%>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "Previous" page
2
+ - available local variables
3
+ url: url to the previous page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="prev">
10
+ <%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
11
+ </span>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "First" page
2
+ - available local variables
3
+ url: url to the first page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="first">
10
+ <%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
11
+ </span>
@@ -0,0 +1,8 @@
1
+ <%# Non-link tag that stands for skipped pages...
2
+ - available local variables
3
+ current_page: a page object for the currently displayed page
4
+ total_pages: total number of pages
5
+ per_page: number of items to fetch per page
6
+ remote: data-remote
7
+ -%>
8
+ <span class="page gap"><%= t('views.pagination.truncate').html_safe %></span>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "Last" page
2
+ - available local variables
3
+ url: url to the last page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="last">
10
+ <%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
11
+ </span>
@@ -0,0 +1,11 @@
1
+ <%# Link to the "Next" page
2
+ - available local variables
3
+ url: url to the next page
4
+ current_page: a page object for the currently displayed page
5
+ total_pages: total number of pages
6
+ per_page: number of items to fetch per page
7
+ remote: data-remote
8
+ -%>
9
+ <span class="next">
10
+ <%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
11
+ </span>
@@ -0,0 +1,12 @@
1
+ <%# Link showing page number
2
+ - available local variables
3
+ page: a page object for "this" page
4
+ url: url to this page
5
+ current_page: a page object for the currently displayed page
6
+ total_pages: total number of pages
7
+ per_page: number of items to fetch per page
8
+ remote: data-remote
9
+ -%>
10
+ <span class="page<%= ' current' if page.current? %>">
11
+ <%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
12
+ </span>