nitro_intelligence 0.0.1 → 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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/docs/README.md +83 -11
  3. data/lib/nitro_intelligence/agent_server.rb +119 -8
  4. data/lib/nitro_intelligence/client/base.rb +52 -0
  5. data/lib/nitro_intelligence/client/client.rb +13 -0
  6. data/lib/nitro_intelligence/client/factory.rb +53 -0
  7. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +38 -0
  8. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +41 -0
  9. data/lib/nitro_intelligence/client/handlers/image_handler.rb +61 -0
  10. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +90 -0
  11. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +74 -0
  12. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +109 -0
  13. data/lib/nitro_intelligence/client/observed.rb +38 -0
  14. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +75 -0
  15. data/lib/nitro_intelligence/configuration.rb +2 -0
  16. data/lib/nitro_intelligence/langfuse_extension.rb +10 -53
  17. data/lib/nitro_intelligence/media/audio.rb +50 -0
  18. data/lib/nitro_intelligence/media/image_generation.rb +4 -2
  19. data/lib/nitro_intelligence/models/model_catalog.rb +7 -2
  20. data/lib/nitro_intelligence/observability/project.rb +33 -0
  21. data/lib/nitro_intelligence/observability/project_client.rb +18 -0
  22. data/lib/nitro_intelligence/observability/project_client_registry.rb +44 -0
  23. data/lib/nitro_intelligence/observability/prompt.rb +58 -0
  24. data/lib/nitro_intelligence/observability/prompt_store.rb +112 -0
  25. data/lib/nitro_intelligence/observability/upload_handler.rb +138 -0
  26. data/lib/nitro_intelligence/reporter.rb +43 -0
  27. data/lib/nitro_intelligence/tool_call_review_validator.rb +69 -0
  28. data/lib/nitro_intelligence/trace.rb +2 -2
  29. data/lib/nitro_intelligence/version.rb +1 -1
  30. data/lib/nitro_intelligence.rb +10 -44
  31. metadata +26 -10
  32. data/lib/nitro_intelligence/client.rb +0 -337
  33. data/lib/nitro_intelligence/media/upload_handler.rb +0 -135
  34. data/lib/nitro_intelligence/prompt/prompt.rb +0 -56
  35. data/lib/nitro_intelligence/prompt/prompt_store.rb +0 -110
@@ -0,0 +1,74 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Handlers
4
+ module Observed
5
+ class ChatHandler
6
+ def initialize(base_handler:, observer:)
7
+ @base_handler = base_handler
8
+ @observer = observer
9
+ end
10
+
11
+ def create(message: "", parameters: {})
12
+ @base_handler.validate_and_resolve!(parameters, message)
13
+
14
+ prompt = handle_prompt(parameters:)
15
+ trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
16
+
17
+ @observer.observe(
18
+ "chat-completion",
19
+ type: :generation,
20
+ parameters:,
21
+ trace_name:,
22
+ prompt:
23
+ ) do |_generation|
24
+ workflow(parameters:)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def handle_prompt(parameters:)
31
+ return nil if parameters[:prompt_name].blank?
32
+
33
+ prompt = @observer.project_client.project.prompt_store.get_prompt(
34
+ prompt_name: parameters[:prompt_name],
35
+ prompt_label: parameters[:prompt_label],
36
+ prompt_version: parameters[:prompt_version]
37
+ )
38
+ prompt_variables = parameters[:prompt_variables] || {}
39
+
40
+ if prompt.present?
41
+ parameters[:messages] = prompt.interpolate(
42
+ messages: parameters[:messages],
43
+ variables: prompt_variables
44
+ )
45
+
46
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
47
+ end
48
+
49
+ prompt
50
+ end
51
+
52
+ def workflow(parameters:)
53
+ chat_completion = @base_handler.perform_request(parameters:)
54
+ input = parameters[:messages]
55
+ output = chat_completion.choices.first.message.to_h
56
+
57
+ trace_attributes = {
58
+ model: chat_completion.model,
59
+ input:,
60
+ output:,
61
+ usage_details: {
62
+ prompt_tokens: chat_completion.usage.prompt_tokens,
63
+ completion_tokens: chat_completion.usage.completion_tokens,
64
+ total_tokens: chat_completion.usage.total_tokens,
65
+ },
66
+ }
67
+
68
+ [chat_completion, trace_attributes]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,109 @@
1
+ require "nitro_intelligence/media/image_generation"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ module Handlers
6
+ module Observed
7
+ class ImageHandler
8
+ def initialize(base_handler:, observer:)
9
+ @base_handler = base_handler
10
+ @observer = observer
11
+ end
12
+
13
+ def create(message: "", target_image: nil, reference_images: [], parameters: {})
14
+ image_generation = build_image_generation(message:, target_image:, reference_images:, parameters:)
15
+
16
+ @base_handler.validate_and_resolve!(parameters, image_generation)
17
+
18
+ # Modifies parameters in place
19
+ prompt = handle_prompt(parameters:)
20
+ trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
21
+
22
+ @observer.observe(
23
+ "image-generation",
24
+ type: :generation,
25
+ parameters:,
26
+ trace_name:,
27
+ prompt:
28
+ ) do |generation|
29
+ workflow(generation:, image_generation:, parameters:)
30
+ end
31
+
32
+ image_generation
33
+ end
34
+
35
+ private
36
+
37
+ def build_image_generation(message:, target_image:, reference_images:, parameters:)
38
+ NitroIntelligence::ImageGeneration.new(message:, target_image:, reference_images:) do |config|
39
+ config.aspect_ratio = parameters[:aspect_ratio] if parameters.key?(:aspect_ratio)
40
+ config.model = parameters[:model] if parameters.key?(:model)
41
+ config.resolution = parameters[:resolution] if parameters.key?(:resolution)
42
+ end
43
+ end
44
+
45
+ def handle_image_generation_uploads(input, output, image_generation)
46
+ # If we are doing image generation we should upload the media to observability manually
47
+ upload_handler = NitroIntelligence::Observability::UploadHandler.new(
48
+ auth_token: @observer.project_client.project.auth_token
49
+ )
50
+ upload_handler.upload(
51
+ image_generation.trace_id,
52
+ upload_queue: Queue.new(image_generation.files)
53
+ )
54
+
55
+ # Replace base64 strings with media references
56
+ upload_handler.replace_base64_with_media_references(input)
57
+ upload_handler.replace_base64_with_media_references(output)
58
+ end
59
+
60
+ def handle_prompt(parameters:)
61
+ return nil if parameters[:prompt_name].blank?
62
+
63
+ prompt = @observer.project_client.project.prompt_store.get_prompt(
64
+ prompt_name: parameters[:prompt_name],
65
+ prompt_label: parameters[:prompt_label],
66
+ prompt_version: parameters[:prompt_version]
67
+ )
68
+ prompt_variables = parameters[:prompt_variables] || {}
69
+
70
+ if prompt.present?
71
+ parameters[:messages] = prompt.interpolate(
72
+ messages: parameters[:messages],
73
+ variables: prompt_variables
74
+ )
75
+
76
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
77
+ end
78
+
79
+ prompt
80
+ end
81
+
82
+ def workflow(generation:, image_generation:, parameters:)
83
+ chat_completion = @base_handler.perform_request(parameters:)
84
+
85
+ image_generation.trace_id = generation.trace_id
86
+ image_generation.parse_file(chat_completion)
87
+
88
+ input = parameters[:messages]
89
+ output = chat_completion.choices.first.message.to_h
90
+ handle_image_generation_uploads(input, output, image_generation)
91
+
92
+ trace_attributes = {
93
+ model: chat_completion.model,
94
+ input:,
95
+ output:,
96
+ usage_details: {
97
+ prompt_tokens: chat_completion.usage.prompt_tokens,
98
+ completion_tokens: chat_completion.usage.completion_tokens,
99
+ total_tokens: chat_completion.usage.total_tokens,
100
+ },
101
+ }
102
+
103
+ [chat_completion, trace_attributes]
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,38 @@
1
+ require "nitro_intelligence/client/handlers/observed/audio_transcription_handler"
2
+ require "nitro_intelligence/client/handlers/observed/chat_handler"
3
+ require "nitro_intelligence/client/handlers/observed/image_handler"
4
+ require "nitro_intelligence/observability/prompt_store"
5
+ require "nitro_intelligence/observability/upload_handler"
6
+ require "nitro_intelligence/trace"
7
+
8
+ module NitroIntelligence
9
+ module Client
10
+ class Observed < Base
11
+ def initialize(client:, observer:)
12
+ super(client:)
13
+ @observer = observer
14
+ end
15
+
16
+ private
17
+
18
+ def chat_handler
19
+ @chat_handler ||= Handlers::Observed::ChatHandler.new(
20
+ base_handler: Handlers::ChatHandler.new(client: @client), observer: @observer
21
+ )
22
+ end
23
+
24
+ def audio_transcription_handler
25
+ @audio_transcription_handler ||=
26
+ Handlers::Observed::AudioTranscriptionHandler.new(
27
+ base_handler: Handlers::AudioTranscriptionHandler.new(client: @client), observer: @observer
28
+ )
29
+ end
30
+
31
+ def image_handler
32
+ @image_handler ||= Handlers::Observed::ImageHandler.new(
33
+ base_handler: Handlers::ImageHandler.new(client: @client), observer: @observer
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,75 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Observers
4
+ class LangfuseObserver
5
+ attr_reader :project_client
6
+
7
+ def initialize(project_client:)
8
+ @project_client = project_client
9
+ end
10
+
11
+ def observe(operation_name, type:, parameters:, trace_name:, prompt: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
12
+ metadata = parameters[:metadata]
13
+ seed = parameters[:trace_seed]
14
+ user_id = parameters[:user_id] || NitroIntelligence.configuration.observability_user_id
15
+ trace_id = NitroIntelligence::Trace.create_id(seed:) if seed.present?
16
+
17
+ if prompt
18
+ metadata[:prompt_name] = prompt.name
19
+ metadata[:prompt_version] = prompt.version
20
+ end
21
+
22
+ metadata = metadata.transform_values(&:to_s)
23
+
24
+ Langfuse.propagate_attributes(
25
+ user_id:,
26
+ metadata:
27
+ ) do
28
+ @project_client.observability_client.observe(
29
+ operation_name,
30
+ as_type: type,
31
+ trace_id:,
32
+ environment: NitroIntelligence.environment.to_s,
33
+ model: parameters[:model],
34
+ metadata:
35
+ ) do |generation|
36
+ generation.update_trace(name: trace_name, release: NitroIntelligence.configuration.current_revision)
37
+ generation.update({ prompt: { name: prompt.name, version: prompt.version } }) if prompt
38
+
39
+ result, trace_attributes = yield(generation)
40
+
41
+ if trace_attributes
42
+ handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
43
+
44
+ generation.model = trace_attributes[:model] if trace_attributes[:model]
45
+ generation.usage_details = trace_attributes[:usage_details] if trace_attributes[:usage_details]
46
+ generation.input = trace_attributes[:input] if trace_attributes[:input]
47
+ generation.output = trace_attributes[:output] if trace_attributes[:output]
48
+
49
+ generation.update_trace(input: trace_attributes[:input], output: trace_attributes[:output])
50
+ end
51
+
52
+ result
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def handle_truncation(_input, output, model_name)
60
+ model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
61
+
62
+ return unless model&.omit_output_fields
63
+
64
+ model.omit_output_fields.each do |omit_output_field|
65
+ last_key = omit_output_field.last
66
+ parent_keys = omit_output_field[0...-1]
67
+ parent = parent_keys.empty? ? output : output.dig(*parent_keys)
68
+
69
+ parent[last_key] = "[Truncated...]" if parent.is_a?(Hash) && parent.key?(last_key)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -8,6 +8,7 @@ module NitroIntelligence
8
8
 
9
9
  config_accessor :logger, default: Logger.new($stdout)
10
10
  config_accessor :cache_provider, default: NitroIntelligence::NullCache.new
11
+ config_accessor :current_revision, default: ""
11
12
  config_accessor :environment, default: "test"
12
13
  config_accessor :agent_server_config, default: {}
13
14
  config_accessor :inference_api_key, default: ""
@@ -15,6 +16,7 @@ module NitroIntelligence
15
16
  config_accessor :model_config, default: {}
16
17
  config_accessor :observability_base_url, default: ""
17
18
  config_accessor :observability_projects, default: []
19
+ config_accessor :observability_user_id, default: ""
18
20
 
19
21
  class << self
20
22
  def configure
@@ -4,6 +4,9 @@
4
4
  # The content of this file should eventually make its way upstream. Setting a custom trace ID is
5
5
  # already awaiting approval in https://github.com/simplepractice/langfuse-rb/pull/69.
6
6
  #
7
+
8
+ require "nitro_intelligence/langfuse_tracer_provider"
9
+
7
10
  module NitroIntelligence
8
11
  class LangfuseExtension
9
12
  attr_reader :config
@@ -14,7 +17,7 @@ module NitroIntelligence
14
17
 
15
18
  @config = config
16
19
  @client = Langfuse::Client.new(config)
17
- @tracer_provider = LangfuseTracerProvider.new(config)
20
+ @tracer_provider = NitroIntelligence::LangfuseTracerProvider.new(config)
18
21
  end
19
22
 
20
23
  def shutdown(timeout: 30)
@@ -40,14 +43,11 @@ module NitroIntelligence
40
43
  )
41
44
  end
42
45
 
43
- def start_observation(name, attrs = {}, as_type: :span, parent_span_context: nil, start_time: nil,
46
+ def start_observation(name, attrs = {}, as_type: :span, trace_id: nil, parent_span_context: nil, start_time: nil, # rubocop:disable Metrics/ParameterLists
44
47
  skip_validation: false)
48
+ parent_span_context = Langfuse.send(:resolve_trace_context, trace_id, parent_span_context)
45
49
  type_str = as_type.to_s
46
-
47
- unless skip_validation || Langfuse.send(:valid_observation_type?, as_type)
48
- valid_types = Langfuse::OBSERVATION_TYPES.values.sort.join(", ")
49
- raise ArgumentError, "Invalid observation type: #{type_str}. Valid types: #{valid_types}"
50
- end
50
+ Langfuse.send(:validate_observation_type!, as_type, type_str) unless skip_validation
51
51
 
52
52
  otel_tracer = @tracer_provider.tracer
53
53
  otel_span = Langfuse.send(:create_otel_span,
@@ -73,54 +73,11 @@ module NitroIntelligence
73
73
  end
74
74
 
75
75
  def observe(name, attrs = {}, as_type: :span, trace_id: nil, **kwargs, &block)
76
- # Merge positional attrs and keyword kwargs
77
76
  merged_attrs = attrs.to_h.merge(kwargs)
78
- unless trace_id.nil?
79
- unless valid_trace_id?(trace_id)
80
- raise ArgumentError, "#{trace_id} is not a valid 32 lowercase hex char Langfuse trace ID"
81
- end
77
+ observation = start_observation(name, merged_attrs, as_type:, trace_id:)
78
+ return observation unless block
82
79
 
83
- parent_span_context = create_span_context_with_trace_id(trace_id)
84
- end
85
- observation = start_observation(name, merged_attrs, as_type:, parent_span_context:)
86
-
87
- if block
88
- # Block-based API: auto-ends when block completes
89
- # Set context and execute block
90
- current_context = OpenTelemetry::Context.current
91
- begin
92
- result = OpenTelemetry::Context.with_current(
93
- OpenTelemetry::Trace.context_with_span(observation.otel_span, parent_context: current_context)
94
- ) do
95
- yield(observation)
96
- end
97
- ensure
98
- # Only end if not already ended (events auto-end in start_observation)
99
- observation.end unless as_type.to_s == Langfuse::OBSERVATION_TYPES[:event]
100
- end
101
- result
102
- else
103
- # Stateful API - return observation
104
- # Events already auto-ended in start_observation
105
- observation
106
- end
107
- end
108
-
109
- private
110
-
111
- def valid_trace_id?(trace_id)
112
- !!(trace_id =~ /^[0-9a-f]{32}$/)
113
- end
114
-
115
- def create_span_context_with_trace_id(trace_id_as_hex_str)
116
- trace_id_as_byte_str = [trace_id_as_hex_str].pack("H*")
117
- # NOTE: trace_flags must be SAMPLED or the trace will not appear in Langfuse.
118
- # The Python SDK does the same: https://github.com/langfuse/langfuse-python/blob/v4.0.0/langfuse/_client/client.py#L1568
119
- trace_flags = OpenTelemetry::Trace::TraceFlags::SAMPLED
120
- OpenTelemetry::Trace::SpanContext.new(
121
- trace_id: trace_id_as_byte_str,
122
- trace_flags:
123
- )
80
+ observation.send(:run_in_context, &block)
124
81
  end
125
82
  end
126
83
  end
@@ -0,0 +1,50 @@
1
+ require "nitro_intelligence/media/media"
2
+
3
+ module NitroIntelligence
4
+ class Audio < Media
5
+ class AudioFileFormatError < StandardError; end
6
+
7
+ def initialize(file)
8
+ # TODO: Consider a library for dealing with audio files. Dirty implementation
9
+ raise AudioFileFormatError unless file.respond_to?(:to_path)
10
+
11
+ file_extension = File.basename(file).split(".").last
12
+ file = file.read
13
+ super
14
+
15
+ @file_extension = file_extension
16
+ @file_type = "audio"
17
+ @mime_type = determine_mime_type
18
+ end
19
+
20
+ private
21
+
22
+ # These are supported by Langfuse
23
+ def determine_mime_type
24
+ ext = @file_extension.downcase
25
+
26
+ case ext
27
+ when "m4a", "m4b", "m4r", "m4p", "mp4"
28
+ "audio/mp4"
29
+ when "mp3", "mpga", "mp2", "mp1"
30
+ "audio/mp3"
31
+ when "wav", "wave"
32
+ "audio/wav"
33
+ when "webm", "weba"
34
+ "audio/webm"
35
+ when "ogg", "spx"
36
+ "audio/ogg"
37
+ when "oga"
38
+ "audio/oga"
39
+ when "aac"
40
+ "audio/aac"
41
+ when "flac"
42
+ "audio/flac"
43
+ when "opus"
44
+ "audio/opus"
45
+ else
46
+ "audio/#{ext}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -9,7 +9,6 @@ require "nitro_intelligence/media/image"
9
9
  module NitroIntelligence
10
10
  class ImageGeneration
11
11
  class Config
12
- CUSTOM_PARAMS = %i[aspect_ratio image_generation resolution].freeze
13
12
  DEFAULT_ASPECT_RATIO = "1:1".freeze
14
13
  DEFAULT_RESOLUTION = "1K".freeze
15
14
 
@@ -17,7 +16,7 @@ module NitroIntelligence
17
16
 
18
17
  def initialize
19
18
  @aspect_ratio = DEFAULT_ASPECT_RATIO
20
- @model = NitroIntelligence.model_catalog.default_image_model.name
19
+ @model = NitroIntelligence.model_catalog.default_image_model&.name
21
20
  @resolution = DEFAULT_RESOLUTION
22
21
  end
23
22
  end
@@ -61,6 +60,9 @@ module NitroIntelligence
61
60
  @generated_image = Image.from_base64(base64_string)
62
61
  @generated_image.direction = "output"
63
62
  @generated_image
63
+ rescue ArgumentError
64
+ NitroIntelligence.logger.info("Skipping image parse due to invalid base64; likely already parsed.")
65
+ nil
64
66
  end
65
67
 
66
68
  private
@@ -2,16 +2,21 @@ require "nitro_intelligence/models/model_factory"
2
2
 
3
3
  module NitroIntelligence
4
4
  class ModelCatalog
5
- attr_reader :models, :default_text_model, :default_image_model
5
+ attr_reader :models, :default_audio_transcription_model, :default_image_model, :default_text_model
6
6
 
7
7
  def initialize(model_config)
8
8
  @models = (model_config[:models] || []).map { |model_metadata| ModelFactory.build(model_metadata) }
9
- @default_text_model = lookup_by_name(model_config[:default_text_model])
9
+ @default_audio_transcription_model = lookup_by_name(model_config[:default_audio_transcription_model])
10
10
  @default_image_model = lookup_by_name(model_config[:default_image_model])
11
+ @default_text_model = lookup_by_name(model_config[:default_text_model])
11
12
  end
12
13
 
13
14
  def lookup_by_name(name)
14
15
  @models.find { |model| model.name == name }
15
16
  end
17
+
18
+ def exists?(name)
19
+ lookup_by_name(name).present?
20
+ end
16
21
  end
17
22
  end
@@ -0,0 +1,33 @@
1
+ require "base64"
2
+ require "nitro_intelligence/observability/prompt_store"
3
+
4
+ module NitroIntelligence
5
+ module Observability
6
+ class Project
7
+ class NotFoundError < StandardError; end
8
+
9
+ attr_reader :slug, :id, :public_key, :secret_key, :auth_token, :prompt_store
10
+
11
+ def initialize(slug:, id:, public_key:, secret_key:, **_kwargs)
12
+ @slug = slug
13
+ @id = id
14
+ @public_key = public_key
15
+ @secret_key = secret_key
16
+ @auth_token = Base64.strict_encode64("#{public_key}:#{secret_key}")
17
+ @prompt_store = PromptStore.new(
18
+ observability_project_slug: slug,
19
+ observability_public_key: public_key,
20
+ observability_secret_key: secret_key
21
+ )
22
+ end
23
+
24
+ def self.find_by_slug(slug:)
25
+ project_config = NitroIntelligence.config.observability_projects.find { |project| project["slug"] == slug }
26
+
27
+ return new(**project_config.to_h.transform_keys(&:to_sym)) if project_config
28
+
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ module NitroIntelligence
2
+ module Observability
3
+ class ProjectClient
4
+ class NotFoundError < StandardError; end
5
+
6
+ attr_reader :project, :observability_client
7
+
8
+ def initialize(project:, observability_client:)
9
+ @project = project
10
+ @observability_client = observability_client
11
+ end
12
+
13
+ def shutdown
14
+ @observability_client.shutdown
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ require "nitro_intelligence/langfuse_extension"
2
+ require "nitro_intelligence/observability/project"
3
+ require "nitro_intelligence/observability/project_client"
4
+
5
+ module NitroIntelligence
6
+ module Observability
7
+ class ProjectClientRegistry
8
+ def initialize(base_url:)
9
+ @base_url = base_url
10
+ @project_clients = {}
11
+ end
12
+
13
+ def fetch(slug)
14
+ @project_clients[slug] ||= build_project_client(slug)
15
+ end
16
+
17
+ def shutdown_all
18
+ @project_clients.values.each(&:shutdown)
19
+ end
20
+
21
+ private
22
+
23
+ def build_project_client(slug)
24
+ project = Observability::Project.find_by_slug(slug:)
25
+
26
+ unless project
27
+ raise NitroIntelligence::Observability::Project::NotFoundError,
28
+ "No observability project config found for slug: #{slug}"
29
+ end
30
+
31
+ observability_client = NitroIntelligence::LangfuseExtension.new do |config|
32
+ config.public_key = project.public_key
33
+ config.secret_key = project.secret_key
34
+ config.base_url = @base_url
35
+ # Default flush of 60 seconds can be too quick when
36
+ # dealing with longer responses like image gen
37
+ config.flush_interval = 120
38
+ end
39
+
40
+ Observability::ProjectClient.new(project:, observability_client:)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,58 @@
1
+ module NitroIntelligence
2
+ module Observability
3
+ class Prompt
4
+ attr_reader :name, :type, :prompt, :version, :config, :labels, :tags
5
+
6
+ VARIABLE_REGEX = /\{\{([a-zA-Z0-9_]+)\}\}/
7
+
8
+ def initialize(name:, type:, prompt:, version:, **extra_args)
9
+ @name = name
10
+ @type = type
11
+ @prompt = prompt
12
+ @version = version
13
+ @config = extra_args[:config] || {}
14
+ @labels = extra_args[:labels] || []
15
+ @tags = extra_args[:tags] || []
16
+ end
17
+
18
+ # Returns prompt "content" from API with prompt variables replaced
19
+ # Prompt "content" will either be a string or an array of hashes
20
+ # based on prompt "type" ("text" or "chat")
21
+ def compile(**replacements)
22
+ return replace_variables(@prompt, **replacements) if @type == "text"
23
+
24
+ @prompt.map do |message|
25
+ message[:content] = replace_variables(message[:content], **replacements)
26
+ message
27
+ end
28
+ end
29
+
30
+ # Takes provided chat messages and inserts the compiled prompt
31
+ # into the correct position based on prompt "type" ("text" or "chat")
32
+ def interpolate(messages:, variables:)
33
+ if @type == "text"
34
+ messages.prepend({ role: "system", content: compile(**variables) })
35
+ elsif @type == "chat"
36
+ compile(**variables) + messages
37
+ end
38
+ end
39
+
40
+ def variables
41
+ messages = @type == "text" ? [@prompt] : @prompt.pluck(:content)
42
+
43
+ messages.map do |message|
44
+ message.scan(VARIABLE_REGEX).flatten.map(&:to_sym)
45
+ end.flatten
46
+ end
47
+
48
+ private
49
+
50
+ def replace_variables(input, **replacements)
51
+ input.gsub(VARIABLE_REGEX) do |match|
52
+ key = ::Regexp.last_match(1).to_sym
53
+ replacements.fetch(key, match)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end