tracebook 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +43 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +881 -0
  6. data/Rakefile +21 -0
  7. data/app/assets/images/tracebook/.keep +0 -0
  8. data/app/assets/javascripts/tracebook/application.js +88 -0
  9. data/app/assets/stylesheets/tracebook/application.css +173 -0
  10. data/app/controllers/concerns/.keep +0 -0
  11. data/app/controllers/tracebook/application_controller.rb +4 -0
  12. data/app/controllers/tracebook/exports_controller.rb +25 -0
  13. data/app/controllers/tracebook/interactions_controller.rb +71 -0
  14. data/app/helpers/tracebook/application_helper.rb +4 -0
  15. data/app/helpers/tracebook/interactions_helper.rb +35 -0
  16. data/app/jobs/tracebook/application_job.rb +5 -0
  17. data/app/jobs/tracebook/daily_rollups_job.rb +100 -0
  18. data/app/jobs/tracebook/export_job.rb +162 -0
  19. data/app/jobs/tracebook/persist_interaction_job.rb +160 -0
  20. data/app/mailers/tracebook/application_mailer.rb +6 -0
  21. data/app/models/concerns/.keep +0 -0
  22. data/app/models/tracebook/application_record.rb +5 -0
  23. data/app/models/tracebook/interaction.rb +100 -0
  24. data/app/models/tracebook/pricing_rule.rb +84 -0
  25. data/app/models/tracebook/redaction_rule.rb +81 -0
  26. data/app/models/tracebook/rollup_daily.rb +73 -0
  27. data/app/views/layouts/tracebook/application.html.erb +18 -0
  28. data/app/views/tracebook/interactions/index.html.erb +105 -0
  29. data/app/views/tracebook/interactions/show.html.erb +44 -0
  30. data/config/routes.rb +8 -0
  31. data/db/migrate/20241112000100_create_tracebook_interactions.rb +55 -0
  32. data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +24 -0
  33. data/db/migrate/20241112000300_create_tracebook_pricing_rules.rb +21 -0
  34. data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +19 -0
  35. data/lib/tasks/tracebook_tasks.rake +4 -0
  36. data/lib/tasks/yard.rake +29 -0
  37. data/lib/tracebook/adapters/active_agent.rb +82 -0
  38. data/lib/tracebook/adapters/ruby_llm.rb +97 -0
  39. data/lib/tracebook/adapters.rb +6 -0
  40. data/lib/tracebook/config.rb +130 -0
  41. data/lib/tracebook/engine.rb +5 -0
  42. data/lib/tracebook/errors.rb +9 -0
  43. data/lib/tracebook/mappers/anthropic.rb +59 -0
  44. data/lib/tracebook/mappers/base.rb +38 -0
  45. data/lib/tracebook/mappers/ollama.rb +49 -0
  46. data/lib/tracebook/mappers/openai.rb +75 -0
  47. data/lib/tracebook/mappers.rb +283 -0
  48. data/lib/tracebook/normalized_interaction.rb +86 -0
  49. data/lib/tracebook/pricing/calculator.rb +39 -0
  50. data/lib/tracebook/pricing.rb +5 -0
  51. data/lib/tracebook/redaction_pipeline.rb +88 -0
  52. data/lib/tracebook/redactors/base.rb +29 -0
  53. data/lib/tracebook/redactors/card_pan.rb +15 -0
  54. data/lib/tracebook/redactors/email.rb +15 -0
  55. data/lib/tracebook/redactors/phone.rb +15 -0
  56. data/lib/tracebook/redactors.rb +8 -0
  57. data/lib/tracebook/result.rb +53 -0
  58. data/lib/tracebook/version.rb +3 -0
  59. data/lib/tracebook.rb +201 -0
  60. metadata +164 -0
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
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
6
+
7
+ resources :exports, only: [ :create, :show ]
8
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookInteractions < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_interactions do |t|
6
+ t.string :project
7
+ t.string :provider, null: false
8
+ t.string :model, null: false
9
+ t.string :session_id
10
+
11
+ t.text :request_payload
12
+ t.text :response_payload
13
+ t.text :request_text
14
+ t.text :response_text
15
+
16
+ t.integer :input_tokens
17
+ t.integer :output_tokens
18
+ t.integer :total_tokens
19
+ t.integer :latency_ms
20
+
21
+ t.integer :status, null: false, default: 0
22
+ t.integer :review_state, null: false, default: 0
23
+ t.string :error_class
24
+ t.text :error_message
25
+
26
+ t.string :user_type
27
+ t.bigint :user_id
28
+ t.bigint :parent_id
29
+
30
+ t.text :tags
31
+ t.text :metadata
32
+
33
+ t.string :request_payload_store, null: false, default: "inline"
34
+ t.string :response_payload_store, null: false, default: "inline"
35
+ t.bigint :request_payload_blob_id
36
+ t.bigint :response_payload_blob_id
37
+
38
+ t.integer :cost_input_cents, default: 0, null: false
39
+ t.integer :cost_output_cents, default: 0, null: false
40
+ t.integer :cost_total_cents, default: 0, null: false
41
+ t.string :currency, null: false, default: "USD"
42
+
43
+ t.timestamps
44
+ end
45
+
46
+ add_index :tracebook_interactions, :created_at
47
+ add_index :tracebook_interactions, [ :provider, :model, :created_at ]
48
+ add_index :tracebook_interactions, [ :project, :created_at ]
49
+ add_index :tracebook_interactions, :session_id
50
+ add_index :tracebook_interactions, :status
51
+ add_index :tracebook_interactions, :review_state
52
+ add_index :tracebook_interactions, :parent_id
53
+ add_index :tracebook_interactions, [ :user_type, :user_id ]
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookRollupsDailies < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_rollups_dailies do |t|
6
+ t.date :date, null: false
7
+ t.string :project
8
+ t.string :provider
9
+ t.string :model
10
+
11
+ t.integer :interactions_count, null: false, default: 0
12
+ t.integer :success_count, null: false, default: 0
13
+ t.integer :error_count, null: false, default: 0
14
+ t.integer :input_tokens_sum, null: false, default: 0
15
+ t.integer :output_tokens_sum, null: false, default: 0
16
+ t.integer :cost_cents_sum, null: false, default: 0
17
+ t.string :currency, null: false, default: "USD"
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :tracebook_rollups_dailies, [ :date, :project, :provider, :model ], unique: true, name: "index_tracebook_rollups_on_dimensions"
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookPricingRules < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_pricing_rules do |t|
6
+ t.string :provider, null: false
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
11
+ t.date :effective_from, null: false
12
+ t.date :effective_to
13
+ t.string :currency, null: false, default: "USD"
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :tracebook_pricing_rules, :provider
19
+ add_index :tracebook_pricing_rules, [ :provider, :effective_from ], name: "index_tracebook_pricing_on_provider_effective_from"
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTracebookRedactionRules < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :tracebook_redaction_rules do |t|
6
+ t.string :name, null: false
7
+ t.text :pattern, null: false
8
+ t.string :replacement, null: false, default: "[REDACTED]"
9
+ t.integer :applies_to, null: false, default: 2
10
+ t.boolean :enabled, null: false, default: true
11
+ t.integer :priority, null: false, default: 100
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :tracebook_redaction_rules, :enabled
17
+ add_index :tracebook_redaction_rules, :priority
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :tracebook do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "yard"
5
+
6
+ YARD::Rake::YardocTask.new(:yard) do |t|
7
+ t.files = [ "lib/**/*.rb", "app/**/*.rb" ]
8
+ t.options = [
9
+ "--markup", "markdown",
10
+ "--markup-provider", "kramdown",
11
+ "--readme", "README.md",
12
+ "--output-dir", "doc",
13
+ "--protected",
14
+ "--private"
15
+ ]
16
+ end
17
+
18
+ desc "Generate YARD documentation and open in browser"
19
+ task "yard:open" => :yard do
20
+ system "open doc/index.html"
21
+ end
22
+
23
+ desc "Generate YARD stats"
24
+ task "yard:stats" do
25
+ sh "yard stats --list-undoc"
26
+ end
27
+ rescue LoadError
28
+ # YARD not available
29
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Tracebook
6
+ module Adapters
7
+ module ActiveAgent
8
+ extend self
9
+
10
+ def enable!(bus: nil)
11
+ bus ||= discover_bus
12
+ return unless bus.respond_to?(:subscribe)
13
+
14
+ subscribers << bus.subscribe { |event| handle_event(event) }
15
+ end
16
+
17
+ def disable!
18
+ subscribers.clear
19
+ end
20
+
21
+ private
22
+
23
+ def handle_event(event)
24
+ payload = event.with_indifferent_access
25
+ provider = payload[:provider]&.to_s.presence || "active_agent"
26
+ mapper = mapper_for(provider)
27
+ meta_hash = payload[:meta] || {}
28
+ meta = meta_hash.merge(
29
+ session_id: payload[:session_id],
30
+ parent_id: payload[:parent_id]
31
+ )
32
+
33
+ normalized = if mapper
34
+ mapper.normalize(raw_request: payload[:request], raw_response: payload[:response], meta: meta)
35
+ else
36
+ fallback(provider, payload, meta)
37
+ end
38
+
39
+ TraceBook.record!(**normalized.to_h)
40
+ end
41
+
42
+ def mapper_for(provider)
43
+ case provider
44
+ when "openai"
45
+ Tracebook::Mappers::OpenAI.new
46
+ when "anthropic"
47
+ Tracebook::Mappers::Anthropic.new
48
+ when "ollama"
49
+ Tracebook::Mappers::Ollama.new
50
+ else
51
+ nil
52
+ end
53
+ end
54
+
55
+ def fallback(provider, payload, meta)
56
+ NormalizedInteraction.new(
57
+ provider: provider,
58
+ model: payload.dig(:request, :model),
59
+ project: meta[:project],
60
+ request_payload: payload[:request],
61
+ response_payload: payload[:response],
62
+ status: meta[:status] || :success,
63
+ tags: Array(meta[:tags]),
64
+ session_id: meta[:session_id],
65
+ parent_id: meta[:parent_id]
66
+ )
67
+ end
68
+
69
+ def subscribers
70
+ @subscribers ||= []
71
+ end
72
+
73
+ def discover_bus
74
+ ActiveAgent::Bus if defined?(ActiveAgent::Bus)
75
+ rescue NameError
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+
6
+ module Tracebook
7
+ module Adapters
8
+ # Adapter for capturing LLM interactions via ActiveSupport::Notifications.
9
+ #
10
+ # This adapter subscribes to instrumentation events (default: "ruby_llm.request")
11
+ # and automatically records interactions in TraceBook.
12
+ #
13
+ # @example Basic setup
14
+ # # config/initializers/tracebook_adapters.rb
15
+ # TraceBook::Adapters::RubyLLM.enable!
16
+ #
17
+ # @example Custom event name
18
+ # TraceBook::Adapters::RubyLLM.enable!(instrumentation: "my_llm.complete")
19
+ #
20
+ # @example Emitting events from your LLM client
21
+ # ActiveSupport::Notifications.instrument("ruby_llm.request", {
22
+ # provider: "openai",
23
+ # request: { model: "gpt-4o", messages: messages },
24
+ # response: response,
25
+ # meta: {
26
+ # project: "support",
27
+ # user: current_user,
28
+ # session_id: session.id,
29
+ # latency_ms: 150,
30
+ # status: :success,
31
+ # tags: ["production", "triage"]
32
+ # }
33
+ # })
34
+ #
35
+ # @see Mappers
36
+ module RubyLLM
37
+ extend self
38
+
39
+ # Default ActiveSupport::Notifications event name
40
+ DEFAULT_EVENT = "ruby_llm.request".freeze
41
+
42
+ # Enables the adapter to start capturing events.
43
+ #
44
+ # Subscribes to the specified instrumentation event and routes payloads
45
+ # through {Mappers} to {TraceBook.record!}.
46
+ #
47
+ # @param instrumentation [String] Event name to subscribe to
48
+ # @return [void]
49
+ #
50
+ # @example
51
+ # TraceBook::Adapters::RubyLLM.enable!
52
+ # TraceBook::Adapters::RubyLLM.enable!(instrumentation: "custom.llm")
53
+ def enable!(instrumentation: DEFAULT_EVENT)
54
+ return if subscribers.key?(instrumentation)
55
+
56
+ subscribers[instrumentation] = ActiveSupport::Notifications.subscribe(instrumentation) do |*args|
57
+ event = ActiveSupport::Notifications::Event.new(*args)
58
+ handle_payload(event.payload.with_indifferent_access)
59
+ end
60
+ end
61
+
62
+ # Disables the adapter and unsubscribes from events.
63
+ #
64
+ # @param instrumentation [String] Event name to unsubscribe from
65
+ # @return [void]
66
+ #
67
+ # @example
68
+ # TraceBook::Adapters::RubyLLM.disable!
69
+ def disable!(instrumentation: DEFAULT_EVENT)
70
+ token = subscribers.delete(instrumentation)
71
+ ActiveSupport::Notifications.unsubscribe(token) if token
72
+ end
73
+
74
+ private
75
+
76
+ def handle_payload(payload)
77
+ provider = payload[:provider].to_s.presence || "ruby_llm"
78
+ normalized = Mappers.normalize(
79
+ provider,
80
+ raw_request: payload[:request],
81
+ raw_response: payload[:response],
82
+ meta: payload[:meta] || {}
83
+ )
84
+
85
+ TraceBook.record!(**normalized.to_h)
86
+ rescue KeyError => error
87
+ Rails.logger.error("TraceBook RubyLLM adapter error: #{error.message}") if defined?(Rails)
88
+ end
89
+
90
+ def subscribers
91
+ @subscribers ||= {}
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/ruby_llm"
4
+ require_relative "adapters/active_agent"
5
+
6
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ # Configuration object for TraceBook.
5
+ #
6
+ # Contains all configurable options for the TraceBook engine. Configuration
7
+ # is frozen after the {Tracebook.configure} block executes to prevent
8
+ # runtime modifications.
9
+ #
10
+ # @example Basic configuration
11
+ # TraceBook.configure do |config|
12
+ # config.project_name = "Support Console"
13
+ # config.persist_async = Rails.env.production?
14
+ # config.default_currency = "USD"
15
+ # end
16
+ #
17
+ # @example With custom redactors
18
+ # TraceBook.configure do |config|
19
+ # config.custom_redactors += [
20
+ # ->(payload) { payload.gsub(/api_key=\w+/, "api_key=[REDACTED]") }
21
+ # ]
22
+ # end
23
+ #
24
+ # @see Tracebook.configure
25
+ class Config
26
+ # @!attribute [rw] project_name
27
+ # @return [String, nil] Project identifier for this application (optional)
28
+ # Used to filter interactions by project in the dashboard
29
+ attr_accessor :project_name
30
+
31
+ # @!attribute [rw] persist_async
32
+ # @return [Boolean] Whether to persist interactions asynchronously (default: true)
33
+ # When true, {Tracebook.record!} enqueues {PersistInteractionJob}.
34
+ # When false, interactions are persisted inline.
35
+ attr_accessor :persist_async
36
+
37
+ # @!attribute [rw] inline_payload_bytes
38
+ # @return [Integer] Maximum payload size before spilling to ActiveStorage (default: 64KB)
39
+ # Payloads larger than this threshold are stored as ActiveStorage blobs
40
+ # instead of inline JSONB columns.
41
+ attr_accessor :inline_payload_bytes
42
+
43
+ # @!attribute [rw] default_currency
44
+ # @return [String] Currency code for cost calculations (default: "USD")
45
+ # Used in {PricingRule} cost tracking
46
+ attr_accessor :default_currency
47
+
48
+ # @!attribute [rw] export_formats
49
+ # @return [Array<Symbol>] Available export formats (default: [:csv, :ndjson])
50
+ # Supported formats for {ExportJob}
51
+ attr_accessor :export_formats
52
+
53
+ # @!attribute [rw] redactors
54
+ # @return [Array<Redactor>] Built-in PII redactors
55
+ # Default redactors for email, phone, credit card numbers.
56
+ # See {Redactors::Email}, {Redactors::Phone}, {Redactors::CardPAN}
57
+ attr_accessor :redactors
58
+
59
+ # @!attribute [rw] custom_redactors
60
+ # @return [Array<Proc>] Custom redaction lambdas
61
+ # Additional user-defined redactors that receive the payload string
62
+ # and return a redacted version.
63
+ # @example
64
+ # config.custom_redactors += [
65
+ # ->(payload) { payload.gsub(/secret=\w+/, "secret=[REDACTED]") }
66
+ # ]
67
+ attr_accessor :custom_redactors
68
+
69
+ # @!attribute [rw] auto_subscribe_ruby_llm
70
+ # @return [Boolean] Auto-enable RubyLLM adapter on boot (default: false)
71
+ attr_accessor :auto_subscribe_ruby_llm
72
+
73
+ # @!attribute [rw] auto_subscribe_active_agent
74
+ # @return [Boolean] Auto-enable ActiveAgent adapter on boot (default: false)
75
+ attr_accessor :auto_subscribe_active_agent
76
+
77
+ # Creates a new configuration with default values.
78
+ #
79
+ # @return [Config]
80
+ def initialize
81
+ @project_name = nil
82
+ @persist_async = true
83
+ @inline_payload_bytes = 64 * 1024
84
+ @default_currency = "USD"
85
+ @export_formats = [ :csv, :ndjson ]
86
+ @redactors = default_redactors
87
+ @custom_redactors = []
88
+ @auto_subscribe_ruby_llm = false
89
+ @auto_subscribe_active_agent = false
90
+ end
91
+
92
+ # Returns true if configuration has been finalized.
93
+ #
94
+ # @return [Boolean]
95
+ def finalized?
96
+ @finalized == true
97
+ end
98
+
99
+ # Freezes the configuration to prevent further changes.
100
+ #
101
+ # Called automatically by {Tracebook.configure} after the block executes.
102
+ #
103
+ # @return [void]
104
+ def finalize!
105
+ return if finalized?
106
+
107
+ @finalized = true
108
+ freeze_collections!
109
+ freeze
110
+ end
111
+
112
+ private
113
+
114
+ def default_redactors
115
+ [
116
+ Tracebook::Redactors::Email.new,
117
+ Tracebook::Redactors::Phone.new,
118
+ Tracebook::Redactors::CardPAN.new
119
+ ]
120
+ end
121
+
122
+ def freeze_collections!
123
+ @redactors = @redactors.map { |redactor| redactor }.freeze
124
+ @custom_redactors = @custom_redactors.map { |callable| callable }.freeze
125
+ @export_formats = @export_formats.map(&:to_sym).freeze
126
+ end
127
+ end
128
+ end
129
+
130
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,5 @@
1
+ module Tracebook
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Tracebook
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracebook
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+ end
8
+
9
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Tracebook
6
+ module Mappers
7
+ class Anthropic < Base
8
+ def normalize(raw_request:, raw_response:, meta: {})
9
+ request = symbolize(raw_request || {})
10
+ response = symbolize(raw_response || {})
11
+ meta_info = indifferent_meta(meta)
12
+
13
+ build_interaction(
14
+ provider: "anthropic",
15
+ model: request[:model] || response[:model],
16
+ project: meta_info[:project],
17
+ request_payload: raw_request,
18
+ response_payload: raw_response,
19
+ request_text: extract_blocks(request[:messages]),
20
+ response_text: extract_blocks(response[:content]),
21
+ input_tokens: anthropic_usage(response, :input_tokens),
22
+ output_tokens: anthropic_usage(response, :output_tokens),
23
+ latency_ms: meta_info[:latency_ms],
24
+ status: meta_info[:status]&.to_sym || :success,
25
+ error_class: nil,
26
+ error_message: nil,
27
+ tags: Array(meta_info[:tags]).compact,
28
+ metadata: {},
29
+ user: meta_info[:user],
30
+ parent_id: meta_info[:parent_id],
31
+ session_id: meta_info[:session_id]
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def extract_blocks(blocks)
38
+ Array(blocks).flat_map do |block|
39
+ block = block.with_indifferent_access
40
+ case block[:type]
41
+ when "text"
42
+ block[:text]
43
+ when "input_text"
44
+ block[:text]
45
+ else
46
+ nil
47
+ end
48
+ end.compact.join("\n\n")
49
+ end
50
+
51
+ def anthropic_usage(response, key)
52
+ usage = response[:usage] || {}
53
+ usage.with_indifferent_access[key]&.to_i
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+ require "active_support/core_ext/object/deep_dup"
5
+
6
+ module Tracebook
7
+ module Mappers
8
+ class Base
9
+ def normalize(raw_request:, raw_response:, meta: {})
10
+ raise NotImplementedError
11
+ end
12
+
13
+ private
14
+
15
+ def build_interaction(**attributes)
16
+ NormalizedInteraction.new(**attributes)
17
+ end
18
+
19
+ def indifferent_meta(meta)
20
+ (meta || {}).with_indifferent_access
21
+ end
22
+
23
+ def symbolize(hash)
24
+ hash.deep_dup.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
25
+ end
26
+
27
+ def compact_hash(hash)
28
+ hash.each_with_object({}) do |(key, value), memo|
29
+ next if value.nil?
30
+
31
+ memo[key] = value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ TraceBook = Tracebook unless defined?(TraceBook)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Tracebook
6
+ module Mappers
7
+ class Ollama < Base
8
+ def normalize(raw_request:, raw_response:, meta: {})
9
+ request = symbolize(raw_request || {})
10
+ response = symbolize(raw_response || {})
11
+ meta_info = indifferent_meta(meta)
12
+
13
+ metadata = {}
14
+ metadata["eval_count"] = response[:eval_count] if response.key?(:eval_count)
15
+
16
+ build_interaction(
17
+ provider: "ollama",
18
+ model: request[:model] || response[:model],
19
+ project: meta_info[:project],
20
+ request_payload: raw_request,
21
+ response_payload: raw_response,
22
+ request_text: request[:prompt] || request[:input],
23
+ response_text: response[:response],
24
+ input_tokens: response[:prompt_eval_count],
25
+ output_tokens: response[:eval_count],
26
+ latency_ms: meta_info[:latency_ms] || to_milliseconds(response[:total_duration]),
27
+ status: meta_info[:status]&.to_sym || :success,
28
+ error_class: nil,
29
+ error_message: nil,
30
+ tags: Array(meta_info[:tags]).compact,
31
+ metadata: metadata,
32
+ user: meta_info[:user],
33
+ parent_id: meta_info[:parent_id],
34
+ session_id: meta_info[:session_id]
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def to_milliseconds(value)
41
+ return unless value
42
+
43
+ (value.to_f * 1000).to_i
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ TraceBook = Tracebook unless defined?(TraceBook)