tracebook 0.1.0 → 1.0.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -27
  3. data/README.md +201 -754
  4. data/app/assets/javascripts/tracebook/application.js +92 -35
  5. data/app/assets/stylesheets/tracebook/application.css +1915 -50
  6. data/app/controllers/tracebook/application_controller.rb +25 -0
  7. data/app/controllers/tracebook/chats_controller.rb +229 -0
  8. data/app/controllers/tracebook/comments_controller.rb +25 -0
  9. data/app/helpers/tracebook/chats_helper.rb +29 -0
  10. data/app/models/tracebook/chat_review.rb +19 -0
  11. data/app/models/tracebook/comment.rb +14 -0
  12. data/app/models/tracebook/message_cost.rb +12 -0
  13. data/app/models/tracebook/pricing_rule.rb +6 -8
  14. data/app/views/tracebook/chats/index.html.erb +77 -0
  15. data/app/views/tracebook/chats/show.html.erb +94 -0
  16. data/config/routes.rb +7 -5
  17. data/db/migrate/20260325000100_create_tracebook_message_costs.rb +19 -0
  18. data/db/migrate/20260325000200_create_tracebook_chat_reviews.rb +19 -0
  19. data/db/migrate/{20241112000300_create_tracebook_pricing_rules.rb → 20260325000300_create_tracebook_pricing_rules.rb} +3 -3
  20. data/db/migrate/20260325000500_create_tracebook_comments.rb +15 -0
  21. data/lib/generators/tracebook/install/USAGE +10 -0
  22. data/lib/generators/tracebook/install/install_generator.rb +33 -0
  23. data/lib/generators/tracebook/install/templates/initializer.rb.tt +16 -0
  24. data/lib/tasks/tracebook_tasks.rake +14 -4
  25. data/lib/tracebook/adapters/ruby_llm.rb +19 -81
  26. data/lib/tracebook/adapters.rb +5 -4
  27. data/lib/tracebook/config.rb +85 -101
  28. data/lib/tracebook/engine.rb +6 -0
  29. data/lib/tracebook/errors.rb +0 -2
  30. data/lib/tracebook/pricing/calculator.rb +11 -6
  31. data/lib/tracebook/pricing.rb +0 -2
  32. data/lib/tracebook/redaction/pattern.rb +124 -0
  33. data/lib/tracebook/redaction/pipeline.rb +32 -0
  34. data/lib/tracebook/seeds/pricing_rules.rb +62 -0
  35. data/lib/tracebook/version.rb +1 -1
  36. data/lib/tracebook.rb +47 -152
  37. metadata +32 -43
  38. data/app/controllers/tracebook/exports_controller.rb +0 -25
  39. data/app/controllers/tracebook/interactions_controller.rb +0 -71
  40. data/app/helpers/tracebook/interactions_helper.rb +0 -35
  41. data/app/jobs/tracebook/daily_rollups_job.rb +0 -100
  42. data/app/jobs/tracebook/export_job.rb +0 -162
  43. data/app/jobs/tracebook/persist_interaction_job.rb +0 -160
  44. data/app/mailers/tracebook/application_mailer.rb +0 -6
  45. data/app/models/tracebook/interaction.rb +0 -100
  46. data/app/models/tracebook/redaction_rule.rb +0 -81
  47. data/app/models/tracebook/rollup_daily.rb +0 -73
  48. data/app/views/tracebook/interactions/index.html.erb +0 -105
  49. data/app/views/tracebook/interactions/show.html.erb +0 -44
  50. data/db/migrate/20241112000100_create_tracebook_interactions.rb +0 -55
  51. data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +0 -24
  52. data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +0 -19
  53. data/lib/tracebook/adapters/active_agent.rb +0 -82
  54. data/lib/tracebook/mappers/anthropic.rb +0 -59
  55. data/lib/tracebook/mappers/base.rb +0 -38
  56. data/lib/tracebook/mappers/ollama.rb +0 -49
  57. data/lib/tracebook/mappers/openai.rb +0 -75
  58. data/lib/tracebook/mappers.rb +0 -283
  59. data/lib/tracebook/normalized_interaction.rb +0 -86
  60. data/lib/tracebook/redaction_pipeline.rb +0 -88
  61. data/lib/tracebook/redactors/base.rb +0 -29
  62. data/lib/tracebook/redactors/card_pan.rb +0 -15
  63. data/lib/tracebook/redactors/email.rb +0 -15
  64. data/lib/tracebook/redactors/phone.rb +0 -15
  65. data/lib/tracebook/redactors.rb +0 -8
  66. data/lib/tracebook/result.rb +0 -53
@@ -1,4 +1,29 @@
1
1
  module Tracebook
2
2
  class ApplicationController < ActionController::Base
3
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
4
+
5
+ protected
6
+
7
+ def render_not_found
8
+ render plain: "Not Found", status: :not_found
9
+ end
10
+
11
+ # Returns a display name for the current user from the host app.
12
+ # Tries common patterns: email, name, id. Falls back to "Anonymous".
13
+ #
14
+ # @return [String]
15
+ def current_tracebook_user_label
16
+ return "Anonymous" unless respond_to?(:current_user, true) && current_user
17
+
18
+ if current_user.respond_to?(:email) && current_user.email.present?
19
+ current_user.email
20
+ elsif current_user.respond_to?(:name) && current_user.name.present?
21
+ current_user.name
22
+ elsif current_user.respond_to?(:id)
23
+ "User ##{current_user.id}"
24
+ else
25
+ "Anonymous"
26
+ end
27
+ end
3
28
  end
4
29
  end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class ChatsController < ApplicationController
5
+ include Pagy::Method
6
+
7
+ def index
8
+ chat_model = Tracebook.config.chat_model
9
+ scope = chat_model.includes(:messages).order(updated_at: :desc)
10
+
11
+ scope = apply_filters(scope)
12
+
13
+ @pagy, @chats = pagy(:offset, scope, limit: Tracebook.config.per_page)
14
+ @chat_stats = preload_chat_stats(@chats)
15
+ @filters = filter_params
16
+ @actors = load_actors(chat_model)
17
+ @models = load_models(chat_model)
18
+ @kpis = calculate_kpis(chat_model)
19
+ end
20
+
21
+ def show
22
+ chat_model = Tracebook.config.chat_model
23
+ @chat = chat_model.includes(messages: :model).find(params[:id])
24
+ @messages = @chat.messages.order(created_at: :asc)
25
+ @costs_by_message = load_message_costs(@messages)
26
+ @review = ChatReview.find_or_initialize_by(chat: @chat)
27
+ @comments = @review.persisted? ? @review.comments.chronological : []
28
+ @kpis = calculate_chat_kpis(@chat)
29
+
30
+ respond_to do |format|
31
+ format.html
32
+ format.json do
33
+ send_data chat_as_json(@chat),
34
+ type: "application/json",
35
+ filename: "chat-#{@chat.id}.json",
36
+ disposition: "attachment"
37
+ end
38
+ end
39
+ end
40
+
41
+ def review
42
+ chat_model = Tracebook.config.chat_model
43
+ chat = chat_model.find(params[:id])
44
+ review = ChatReview.for_chat(chat)
45
+
46
+ review.update!(
47
+ **review_params,
48
+ reviewed_at: Time.current
49
+ )
50
+
51
+ redirect_to chat_path(chat), notice: "Review updated to #{review.review_state}."
52
+ end
53
+
54
+ private
55
+
56
+ def filter_params
57
+ params.fetch(:filters, {}).permit(:actor, :model, :review_state)
58
+ end
59
+
60
+ def apply_filters(scope)
61
+ filters = filter_params
62
+
63
+ if filters[:actor].present?
64
+ scope = scope.where(user_id: filters[:actor])
65
+ end
66
+
67
+ if filters[:model].present?
68
+ scope = scope.joins(:model).where(models: { model_id: filters[:model] })
69
+ end
70
+
71
+ if filters[:review_state].present?
72
+ if filters[:review_state] == "pending"
73
+ non_pending_ids = ChatReview.where(chat_type: Tracebook.config.chat_class).where.not(review_state: :pending).pluck(:chat_id)
74
+ scope = scope.where.not(id: non_pending_ids)
75
+ else
76
+ reviewed_chat_ids = ChatReview.where(
77
+ review_state: filters[:review_state],
78
+ chat_type: Tracebook.config.chat_class
79
+ ).pluck(:chat_id)
80
+ scope = scope.where(id: reviewed_chat_ids)
81
+ end
82
+ end
83
+
84
+ scope
85
+ end
86
+
87
+ def preload_chat_stats(chats)
88
+ chat_ids = chats.map(&:id)
89
+ chat_class = Tracebook.config.chat_class
90
+ message_class = Tracebook.config.message_class
91
+
92
+ # Preload reviews
93
+ reviews = ChatReview.where(chat_type: chat_class, chat_id: chat_ids).index_by(&:chat_id)
94
+
95
+ # Preload costs per chat: sum via message join
96
+ message_model = Tracebook.config.message_model
97
+ assistant_by_chat = message_model
98
+ .where(chat_id: chat_ids, role: "assistant")
99
+ .group(:chat_id)
100
+ .select("chat_id, COUNT(*) as msg_count, COALESCE(SUM(input_tokens), 0) as total_input, COALESCE(SUM(output_tokens), 0) as total_output")
101
+ .index_by { |r| r[:chat_id] }
102
+
103
+ message_table = message_model.table_name
104
+ costs_by_chat = MessageCost
105
+ .joins(
106
+ ActiveRecord::Base.sanitize_sql_array([
107
+ "INNER JOIN #{message_table} ON #{message_table}.id = tracebook_message_costs.message_id AND tracebook_message_costs.message_type = ?",
108
+ message_class
109
+ ])
110
+ )
111
+ .where(message_table => { chat_id: chat_ids })
112
+ .group("#{message_table}.chat_id")
113
+ .sum(:cost_total_cents)
114
+
115
+ chat_ids.each_with_object({}) do |cid, hash|
116
+ stats = assistant_by_chat[cid]
117
+ hash[cid] = {
118
+ review_state: reviews[cid]&.review_state || "pending",
119
+ message_count: stats&.msg_count || 0,
120
+ total_input: stats&.total_input || 0,
121
+ total_output: stats&.total_output || 0,
122
+ total_cost_cents: costs_by_chat[cid] || 0
123
+ }
124
+ end
125
+ end
126
+
127
+ def load_actors(chat_model)
128
+ return [] unless chat_model.column_names.include?("user_id")
129
+
130
+ user_ids = chat_model.where.not(user_id: nil).distinct.pluck(:user_id)
131
+ return [] if user_ids.empty?
132
+
133
+ user_class = chat_model.reflect_on_association(:user)&.klass
134
+ return user_ids.map { |id| [ id, id ] } unless user_class
135
+
136
+ user_class.where(id: user_ids).map { |u| [ actor_label(u), u.id ] }
137
+ end
138
+
139
+ def load_models(chat_model)
140
+ return [] unless chat_model.reflect_on_association(:model)
141
+
142
+ chat_model.joins(:model).distinct.pluck("models.model_id").sort
143
+ end
144
+
145
+ def actor_label(actor)
146
+ if Tracebook.config.actor_display
147
+ Tracebook.config.actor_display.call(actor)
148
+ else
149
+ actor.try(:name) || actor.try(:email) || "#{actor.class}##{actor.id}"
150
+ end
151
+ end
152
+
153
+ def calculate_kpis(chat_model)
154
+ message_model = Tracebook.config.message_model
155
+ {
156
+ total_chats: chat_model.count,
157
+ total_messages: message_model.where(role: "assistant").count,
158
+ total_cost_cents: MessageCost.sum(:cost_total_cents)
159
+ }
160
+ end
161
+
162
+ def review_params
163
+ params.permit(:review_state, :review_comment, :reviewed_by)
164
+ end
165
+
166
+ def load_message_costs(messages)
167
+ assistant_ids = messages.select { |m| m.role == "assistant" }.map(&:id)
168
+ return {} if assistant_ids.empty?
169
+
170
+ MessageCost.where(
171
+ message_type: Tracebook.config.message_class,
172
+ message_id: assistant_ids
173
+ ).index_by { |c| c.message_id.to_i }
174
+ end
175
+
176
+ def chat_as_json(chat)
177
+ messages_json = @messages.map do |message|
178
+ msg = {
179
+ role: message.role,
180
+ content: message.content,
181
+ created_at: message.created_at.iso8601
182
+ }
183
+
184
+ if message.role == "assistant"
185
+ msg[:input_tokens] = message.input_tokens
186
+ msg[:output_tokens] = message.output_tokens
187
+ msg[:model] = message.model&.model_id
188
+
189
+ cost = @costs_by_message[message.id]
190
+ if cost
191
+ msg[:cost] = {
192
+ input_cents: cost.cost_input_cents,
193
+ output_cents: cost.cost_output_cents,
194
+ total_cents: cost.cost_total_cents
195
+ }
196
+ end
197
+ end
198
+
199
+ msg
200
+ end
201
+
202
+ review_state = @review.persisted? ? @review.review_state : "pending"
203
+
204
+ {
205
+ id: chat.id,
206
+ actor: helpers.actor_name(chat),
207
+ created_at: chat.created_at.iso8601,
208
+ updated_at: chat.updated_at.iso8601,
209
+ kpis: @kpis,
210
+ review_state: review_state,
211
+ messages: messages_json
212
+ }.to_json
213
+ end
214
+
215
+ def calculate_chat_kpis(chat)
216
+ messages = chat.messages.where(role: "assistant")
217
+ message_ids = messages.pluck(:id)
218
+ message_type = Tracebook.config.message_class
219
+ costs = MessageCost.where(message_type: message_type, message_id: message_ids)
220
+
221
+ {
222
+ message_count: messages.count,
223
+ total_input_tokens: messages.sum(:input_tokens),
224
+ total_output_tokens: messages.sum(:output_tokens),
225
+ total_cost_cents: costs.sum(:cost_total_cents)
226
+ }
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class CommentsController < ApplicationController
5
+ def create
6
+ chat = Tracebook.config.chat_model.find(params[:chat_id])
7
+ review = ChatReview.for_chat(chat)
8
+
9
+ comment = review.comments.build(comment_params)
10
+ comment.author = current_tracebook_user_label
11
+
12
+ if comment.save
13
+ redirect_to chat_path(chat), notice: "Comment added"
14
+ else
15
+ redirect_to chat_path(chat), alert: "Failed to add comment"
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def comment_params
22
+ params.require(:comment).permit(:body)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ module ChatsHelper
5
+ def cents_to_human(cents)
6
+ number_to_currency(cents.to_f / 100.0)
7
+ end
8
+
9
+ def review_badge(review_state)
10
+ css_class = case review_state.to_s
11
+ when "approved" then "tb-status tb-status-success"
12
+ when "flagged" then "tb-status tb-status-error"
13
+ else "tb-status tb-status-pending"
14
+ end
15
+ content_tag(:span, review_state, class: css_class)
16
+ end
17
+
18
+ def actor_name(chat)
19
+ actor = chat.try(:user)
20
+ return "—" unless actor
21
+
22
+ if Tracebook.config.actor_display
23
+ Tracebook.config.actor_display.call(actor)
24
+ else
25
+ actor.try(:name) || actor.try(:email) || "#{actor.class.name}##{actor.id}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class ChatReview < ApplicationRecord
5
+ self.table_name = "tracebook_chat_reviews"
6
+
7
+ belongs_to :chat, polymorphic: true, optional: true
8
+ has_many :comments, class_name: "Tracebook::Comment", dependent: :destroy
9
+
10
+ enum :review_state, { pending: 0, approved: 1, flagged: 2 }, prefix: true
11
+
12
+ validates :chat_type, presence: true
13
+ validates :chat_id, presence: true
14
+
15
+ def self.for_chat(chat)
16
+ find_or_create_by!(chat: chat)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class Comment < ApplicationRecord
5
+ self.table_name = "tracebook_comments"
6
+
7
+ belongs_to :chat_review, class_name: "Tracebook::ChatReview"
8
+
9
+ validates :author, presence: true
10
+ validates :body, presence: true
11
+
12
+ scope :chronological, -> { order(created_at: :asc) }
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class MessageCost < ApplicationRecord
5
+ self.table_name = "tracebook_message_costs"
6
+
7
+ belongs_to :message, polymorphic: true, optional: true
8
+
9
+ validates :message_type, presence: true
10
+ validates :message_id, presence: true
11
+ end
12
+ end
@@ -3,14 +3,14 @@
3
3
  module Tracebook
4
4
  # Pricing rule for calculating LLM interaction costs.
5
5
  #
6
- # Defines cost per 1000 tokens for a provider/model pattern. Supports
7
- # glob patterns for matching multiple models and date-based effective periods.
6
+ # Defines cost in cents per 1,000,000 tokens for a provider/model pattern.
7
+ # Supports glob patterns for matching multiple models and date-based effective periods.
8
8
  #
9
9
  # ## Fields
10
10
  # - `provider` - Provider name (e.g., "openai", "anthropic")
11
11
  # - `model_glob` - Glob pattern for matching models (e.g., "gpt-4o*", "claude-3-5-*")
12
- # - `input_per_1k` - Cost per 1000 input tokens
13
- # - `output_per_1k` - Cost per 1000 output tokens
12
+ # - `input_cents_per_unit` - Cost in cents per 1M input tokens (decimal)
13
+ # - `output_cents_per_unit` - Cost in cents per 1M output tokens (decimal)
14
14
  # - `currency` - Currency code (e.g., "USD")
15
15
  # - `effective_from` - Date this pricing takes effect
16
16
  # - `effective_to` - Optional end date for this pricing
@@ -19,8 +19,8 @@ module Tracebook
19
19
  # PricingRule.create!(
20
20
  # provider: "openai",
21
21
  # model_glob: "gpt-4o",
22
- # input_per_1k: 2.50,
23
- # output_per_1k: 10.00,
22
+ # input_cents_per_unit: 250, # $2.50/1M tokens
23
+ # output_cents_per_unit: 1000, # $10.00/1M tokens
24
24
  # currency: "USD",
25
25
  # effective_from: Date.new(2024, 8, 6)
26
26
  # )
@@ -80,5 +80,3 @@ module Tracebook
80
80
  end
81
81
  end
82
82
  end
83
-
84
- TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,77 @@
1
+ <div class="tb-container">
2
+ <header class="tb-header">
3
+ <h1>TraceBook</h1>
4
+ <div class="tb-kpis">
5
+ <div class="tb-kpi">
6
+ <span>Chats</span>
7
+ <strong><%= number_with_delimiter(@kpis[:total_chats]) %></strong>
8
+ </div>
9
+ <div class="tb-kpi">
10
+ <span>Messages</span>
11
+ <strong><%= number_with_delimiter(@kpis[:total_messages]) %></strong>
12
+ </div>
13
+ <div class="tb-kpi">
14
+ <span>Cost</span>
15
+ <strong><%= cents_to_human(@kpis[:total_cost_cents]) %></strong>
16
+ </div>
17
+ </div>
18
+ </header>
19
+
20
+ <section class="tb-filters">
21
+ <%= form_with url: chats_path, method: :get, scope: :filters, class: "tb-filter-form" do |form| %>
22
+ <div class="tb-filter-row" style="display: flex; gap: 1rem; align-items: flex-end;">
23
+ <div class="tb-filter-field" style="flex: 1;">
24
+ <%= form.label :actor %>
25
+ <%= form.select :actor, options_for_select(@actors, @filters[:actor]), { include_blank: "All actors" }, style: "width: 100%;" %>
26
+ </div>
27
+ <div class="tb-filter-field" style="flex: 1;">
28
+ <%= form.label :model %>
29
+ <%= form.select :model, options_for_select(@models, @filters[:model]), { include_blank: "All models" }, style: "width: 100%;" %>
30
+ </div>
31
+ <div class="tb-filter-field" style="flex: 1;">
32
+ <%= form.label :review_state, "Review" %>
33
+ <%= form.select :review_state, options_for_select([["Pending", "pending"], ["Approved", "approved"], ["Flagged", "flagged"]], @filters[:review_state]), { include_blank: "All states" }, style: "width: 100%;" %>
34
+ </div>
35
+ <div class="tb-filter-actions" style="display: flex; gap: 0.5rem; align-items: center; white-space: nowrap; margin-left: auto;">
36
+ <%= form.submit "Apply" %>
37
+ <%= link_to "Reset", chats_path, class: "tb-link" %>
38
+ </div>
39
+ </div>
40
+ <% end %>
41
+ </section>
42
+
43
+ <section class="tb-table-wrapper">
44
+ <table class="tb-table">
45
+ <thead>
46
+ <tr>
47
+ <th>Chat</th>
48
+ <th>Actor</th>
49
+ <th>Model</th>
50
+ <th>Messages</th>
51
+ <th>Tokens</th>
52
+ <th>Cost</th>
53
+ <th>Review</th>
54
+ <th>Last Activity</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ <% @chats.each do |chat| %>
59
+ <% stats = @chat_stats[chat.id] || {} %>
60
+ <tr>
61
+ <td data-label="Chat"><%= link_to "##{chat.id}", chat_path(chat) %></td>
62
+ <td data-label="Actor"><%= actor_name(chat) %></td>
63
+ <td data-label="Model"><%= chat.try(:model)&.try(:model_id) || "—" %></td>
64
+ <td data-label="Messages"><%= stats[:message_count] || 0 %></td>
65
+ <td data-label="Tokens"><%= number_with_delimiter(stats[:total_input] || 0) %> / <%= number_with_delimiter(stats[:total_output] || 0) %></td>
66
+ <td data-label="Cost"><%= cents_to_human(stats[:total_cost_cents] || 0) %></td>
67
+ <td data-label="Review"><%= review_badge(stats[:review_state] || "pending") %></td>
68
+ <td data-label="Last Activity"><%= chat.updated_at.strftime("%Y-%m-%d %H:%M") %></td>
69
+ </tr>
70
+ <% end %>
71
+ </tbody>
72
+ </table>
73
+ <nav class="tb-pagination" aria-label="Chats pagination">
74
+ <%== @pagy.series_nav %>
75
+ </nav>
76
+ </section>
77
+ </div>
@@ -0,0 +1,94 @@
1
+ <div class="tb-container">
2
+ <header class="tb-header">
3
+ <h1>TraceBook</h1>
4
+ <div class="tb-breadcrumb">
5
+ <%= link_to "&larr; All Chats".html_safe, chats_path, class: "tb-back-link" %>
6
+ <span class="tb-muted">/ Chat #<%= @chat.id %> — <%= actor_name(@chat) %></span>
7
+ <%= link_to "Export JSON", chat_path(@chat, format: :json), class: "tb-export-btn" %>
8
+ </div>
9
+ <div class="tb-kpis">
10
+ <div class="tb-kpi">
11
+ <span>Messages</span>
12
+ <strong><%= @kpis[:message_count] %></strong>
13
+ </div>
14
+ <div class="tb-kpi">
15
+ <span>Input Tokens</span>
16
+ <strong><%= number_with_delimiter(@kpis[:total_input_tokens]) %></strong>
17
+ </div>
18
+ <div class="tb-kpi">
19
+ <span>Output Tokens</span>
20
+ <strong><%= number_with_delimiter(@kpis[:total_output_tokens]) %></strong>
21
+ </div>
22
+ <div class="tb-kpi">
23
+ <span>Cost</span>
24
+ <strong><%= cents_to_human(@kpis[:total_cost_cents]) %></strong>
25
+ </div>
26
+ </div>
27
+ </header>
28
+
29
+ <section class="tb-table-wrapper">
30
+ <h2 class="tb-section-title">Conversation</h2>
31
+ <div class="tb-messages">
32
+ <% @messages.each do |message| %>
33
+ <div class="tb-message tb-message-<%= message.role %>">
34
+ <div class="tb-message-header">
35
+ <strong><%= message.role.capitalize %></strong>
36
+ <span class="tb-muted"><%= message.created_at.strftime("%H:%M:%S") %></span>
37
+ <% if message.role == "assistant" %>
38
+ <span class="tb-muted">
39
+ <%= message.input_tokens %> / <%= message.output_tokens %> tokens
40
+ <% cost = @costs_by_message[message.id] %>
41
+ <% if cost %>
42
+ &middot; <%= cents_to_human(cost.cost_total_cents) %>
43
+ <% end %>
44
+ </span>
45
+ <% end %>
46
+ </div>
47
+ <div class="tb-message-content">
48
+ <%= simple_format(message.content.to_s.truncate(2000)) %>
49
+ </div>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+ </section>
54
+
55
+ <section class="tb-table-wrapper">
56
+ <h2 class="tb-section-title">Review</h2>
57
+
58
+ <% current_state = @review.persisted? ? @review.review_state : "pending" %>
59
+ <div class="tb-review-bar">
60
+ <div class="tb-review-status">
61
+ <span class="tb-review-status-label">Status</span>
62
+ <%= review_badge(current_state) %>
63
+ </div>
64
+ <%= form_with url: review_chat_path(@chat), method: :post do |f| %>
65
+ <div class="tb-review-actions">
66
+ <button type="submit" name="review_state" value="approved" class="tb-review-btn tb-review-btn-approved">Approve</button>
67
+ <button type="submit" name="review_state" value="flagged" class="tb-review-btn tb-review-btn-flagged">Flag</button>
68
+ <button type="submit" name="review_state" value="pending" class="tb-review-btn tb-review-btn-pending">Reset</button>
69
+ </div>
70
+ <% end %>
71
+ </div>
72
+
73
+ <% if @comments.any? %>
74
+ <div class="tb-review-comments">
75
+ <% @comments.each do |comment| %>
76
+ <div class="tb-review-comment">
77
+ <div class="tb-review-comment-meta">
78
+ <strong class="tb-review-comment-author"><%= comment.author %></strong>
79
+ <span class="tb-muted"><%= comment.created_at.strftime("%Y-%m-%d %H:%M") %></span>
80
+ </div>
81
+ <p class="tb-review-comment-body"><%= comment.body %></p>
82
+ </div>
83
+ <% end %>
84
+ </div>
85
+ <% end %>
86
+
87
+ <%= form_with url: chat_comments_path(@chat), method: :post, scope: :comment do |f| %>
88
+ <div class="tb-review-compose">
89
+ <%= f.text_area :body, placeholder: "Add a comment...", rows: 2, required: true, class: "tb-review-textarea" %>
90
+ <%= f.submit "Comment", class: "tb-review-submit" %>
91
+ </div>
92
+ <% end %>
93
+ </section>
94
+ </div>
data/config/routes.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  Tracebook::Engine.routes.draw do
2
- resources :interactions, only: [ :index, :show ] do
3
- post :review, on: :member
4
- post :bulk_review, on: :collection
5
- end
2
+ root to: redirect("chats")
6
3
 
7
- resources :exports, only: [ :create, :show ]
4
+ resources :chats, only: [ :index, :show ] do
5
+ member do
6
+ post :review
7
+ end
8
+ resources :comments, only: [ :create ]
9
+ end
8
10
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookMessageCosts < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_message_costs do |t|
6
+ t.string :message_type, null: false
7
+ t.bigint :message_id, null: false
8
+ t.decimal :cost_input_cents, precision: 12, scale: 4, default: 0, null: false
9
+ t.decimal :cost_output_cents, precision: 12, scale: 4, default: 0, null: false
10
+ t.decimal :cost_total_cents, precision: 12, scale: 4, default: 0, null: false
11
+ t.string :currency, null: false, default: "USD"
12
+ t.integer :latency_ms
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :tracebook_message_costs, [ :message_type, :message_id ], unique: true, name: "index_tracebook_message_costs_on_message"
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookChatReviews < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_chat_reviews do |t|
6
+ t.string :chat_type, null: false
7
+ t.bigint :chat_id, null: false
8
+ t.integer :review_state, null: false, default: 0
9
+ t.text :review_comment
10
+ t.datetime :reviewed_at
11
+ t.string :reviewed_by
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :tracebook_chat_reviews, [ :chat_type, :chat_id ], unique: true, name: "index_tracebook_chat_reviews_on_chat"
17
+ add_index :tracebook_chat_reviews, :review_state
18
+ end
19
+ end
@@ -5,9 +5,9 @@ class CreateTracebookPricingRules < ActiveRecord::Migration[8.0]
5
5
  create_table :tracebook_pricing_rules do |t|
6
6
  t.string :provider, null: false
7
7
  t.string :model_glob, null: false
8
- t.string :unit, null: false, default: "per_1k_tokens"
9
- t.integer :input_cents_per_unit, null: false, default: 0
10
- t.integer :output_cents_per_unit, null: false, default: 0
8
+ t.string :unit, null: false, default: "per_1m_tokens"
9
+ t.decimal :input_cents_per_unit, precision: 10, scale: 4, null: false, default: 0
10
+ t.decimal :output_cents_per_unit, precision: 10, scale: 4, null: false, default: 0
11
11
  t.date :effective_from, null: false
12
12
  t.date :effective_to
13
13
  t.string :currency, null: false, default: "USD"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookComments < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_comments do |t|
6
+ t.references :chat_review, null: false, foreign_key: { to_table: :tracebook_chat_reviews }
7
+ t.string :author, null: false
8
+ t.text :body, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :tracebook_comments, [ :chat_review_id, :created_at ]
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Install TraceBook by copying migrations and creating an initializer.
3
+
4
+ Example:
5
+ bin/rails generate tracebook:install
6
+
7
+ After running:
8
+ 1. Run: bin/rails db:migrate
9
+ 2. Mount the engine in config/routes.rb
10
+ 3. Configure authorization in the initializer