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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -27
- data/README.md +201 -754
- data/app/assets/javascripts/tracebook/application.js +92 -35
- data/app/assets/stylesheets/tracebook/application.css +1915 -50
- data/app/controllers/tracebook/application_controller.rb +25 -0
- data/app/controllers/tracebook/chats_controller.rb +229 -0
- data/app/controllers/tracebook/comments_controller.rb +25 -0
- data/app/helpers/tracebook/chats_helper.rb +29 -0
- data/app/models/tracebook/chat_review.rb +19 -0
- data/app/models/tracebook/comment.rb +14 -0
- data/app/models/tracebook/message_cost.rb +12 -0
- data/app/models/tracebook/pricing_rule.rb +6 -8
- data/app/views/tracebook/chats/index.html.erb +77 -0
- data/app/views/tracebook/chats/show.html.erb +94 -0
- data/config/routes.rb +7 -5
- data/db/migrate/20260325000100_create_tracebook_message_costs.rb +19 -0
- data/db/migrate/20260325000200_create_tracebook_chat_reviews.rb +19 -0
- data/db/migrate/{20241112000300_create_tracebook_pricing_rules.rb → 20260325000300_create_tracebook_pricing_rules.rb} +3 -3
- data/db/migrate/20260325000500_create_tracebook_comments.rb +15 -0
- data/lib/generators/tracebook/install/USAGE +10 -0
- data/lib/generators/tracebook/install/install_generator.rb +33 -0
- data/lib/generators/tracebook/install/templates/initializer.rb.tt +16 -0
- data/lib/tasks/tracebook_tasks.rake +14 -4
- data/lib/tracebook/adapters/ruby_llm.rb +19 -81
- data/lib/tracebook/adapters.rb +5 -4
- data/lib/tracebook/config.rb +85 -101
- data/lib/tracebook/engine.rb +6 -0
- data/lib/tracebook/errors.rb +0 -2
- data/lib/tracebook/pricing/calculator.rb +11 -6
- data/lib/tracebook/pricing.rb +0 -2
- data/lib/tracebook/redaction/pattern.rb +124 -0
- data/lib/tracebook/redaction/pipeline.rb +32 -0
- data/lib/tracebook/seeds/pricing_rules.rb +62 -0
- data/lib/tracebook/version.rb +1 -1
- data/lib/tracebook.rb +47 -152
- metadata +32 -43
- data/app/controllers/tracebook/exports_controller.rb +0 -25
- data/app/controllers/tracebook/interactions_controller.rb +0 -71
- data/app/helpers/tracebook/interactions_helper.rb +0 -35
- data/app/jobs/tracebook/daily_rollups_job.rb +0 -100
- data/app/jobs/tracebook/export_job.rb +0 -162
- data/app/jobs/tracebook/persist_interaction_job.rb +0 -160
- data/app/mailers/tracebook/application_mailer.rb +0 -6
- data/app/models/tracebook/interaction.rb +0 -100
- data/app/models/tracebook/redaction_rule.rb +0 -81
- data/app/models/tracebook/rollup_daily.rb +0 -73
- data/app/views/tracebook/interactions/index.html.erb +0 -105
- data/app/views/tracebook/interactions/show.html.erb +0 -44
- data/db/migrate/20241112000100_create_tracebook_interactions.rb +0 -55
- data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +0 -24
- data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +0 -19
- data/lib/tracebook/adapters/active_agent.rb +0 -82
- data/lib/tracebook/mappers/anthropic.rb +0 -59
- data/lib/tracebook/mappers/base.rb +0 -38
- data/lib/tracebook/mappers/ollama.rb +0 -49
- data/lib/tracebook/mappers/openai.rb +0 -75
- data/lib/tracebook/mappers.rb +0 -283
- data/lib/tracebook/normalized_interaction.rb +0 -86
- data/lib/tracebook/redaction_pipeline.rb +0 -88
- data/lib/tracebook/redactors/base.rb +0 -29
- data/lib/tracebook/redactors/card_pan.rb +0 -15
- data/lib/tracebook/redactors/email.rb +0 -15
- data/lib/tracebook/redactors/phone.rb +0 -15
- data/lib/tracebook/redactors.rb +0 -8
- 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
|
|
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
|
-
# - `
|
|
13
|
-
# - `
|
|
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
|
-
#
|
|
23
|
-
#
|
|
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 "← 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
|
+
· <%= 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
|
-
|
|
3
|
-
post :review, on: :member
|
|
4
|
-
post :bulk_review, on: :collection
|
|
5
|
-
end
|
|
2
|
+
root to: redirect("chats")
|
|
6
3
|
|
|
7
|
-
resources :
|
|
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: "
|
|
9
|
-
t.
|
|
10
|
-
t.
|
|
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
|