nitro_intelligence 2.0.0 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c03a8a4c436205c01d926c024c1bcd12542553f36e3998b10462653cec4cdc1
4
- data.tar.gz: 58f340818e47d7b6e36dd84f44b4072bf8636a83d4fd6931e65e62ec1ed29ab5
3
+ metadata.gz: 1181b17cc855ffa2f039b1401fd5dd2040c262d2878e6d265e65f9a581f6106e
4
+ data.tar.gz: 28b3a022b8d6d7e8e8472930fcf089fa5a3ead8a6c0d917e7587a73b69b8fba5
5
5
  SHA512:
6
- metadata.gz: 70eab830245ab3c7bd4a6835c080770ee324dca9c440ba200f0ac29fc528e9b4be5579091259a9abed7c35ce51372da82d4452d1df71ee26c51a61bdb342acaa
7
- data.tar.gz: 549cd0d2477d1382cff28d866266797983b0ddd860396c10ff5edd8087f5538a4834b1194d1ae6d5d16d497825bf660308bcacfcb3da12846e2f69ef611264a4
6
+ metadata.gz: e5fd5911aa6740d71f9dc5564766fe80137d7c2da6e7195ee406649387a22a8387bc02db78671e643d90445c6b8578e2b7b47d7573d4abdf677b35edcc41c6a3
7
+ data.tar.gz: d11585c4f9731911910660e67eb8702634475fd7bbf523b7313c81910d2775852f918a789a669719840196d64c474c1452842f6948e7618563ee1d5b2a1cd5e1
data/docs/README.md CHANGED
@@ -254,6 +254,49 @@ client.chat(
254
254
 
255
255
  If no `prompt_label` or `prompt_version` is provided, the 'production' prompt is used by default.
256
256
 
257
+ ### Prompt Fallbacks
258
+
259
+ Pass `prompt_fallback_name` to name a second prompt to use when the one you asked for isn't there. The prompt in `prompt_name` is tried first; if it doesn't exist, or its lookup fails, the fallback is used instead:
260
+
261
+ ```ruby
262
+ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
263
+ client.chat(
264
+ message: "Why is the sky blue?",
265
+ parameters: {
266
+ prompt_name: "My Prompt - Scheduling", # used when it exists
267
+ prompt_fallback_name: "My Prompt", # used when it doesn't
268
+ # prompt_fallback_label: "debug",
269
+ # prompt_fallback_version: "v2",
270
+ prompt_variables: {
271
+ appointment_id: "1234",
272
+ },
273
+ }
274
+ )
275
+ ```
276
+
277
+ This is for shipping a variant of a prompt — per domain, per locale, per experiment — without having to create one for every caller. Build the specific name however your feature likes; the fallback is just the prompt you'd have asked for anyway.
278
+
279
+ In practice the caller composes the specific name from something it already knows. Summarising a call, for example: each queue holds a different kind of conversation, so a queue that needs its own wording can have its own prompt while the rest use the general one.
280
+
281
+ ```ruby
282
+ base = "call_summary"
283
+ queue = call.queue.slug # "sales", "support", "collections", ...
284
+
285
+ client.chat(
286
+ parameters: {
287
+ prompt_name: [base, queue].compact.join("."), # call_summary.collections
288
+ prompt_fallback_name: base,
289
+ prompt_variables: { transcript: call.transcript },
290
+ }
291
+ )
292
+ ```
293
+
294
+ The fallback is looked up at its own `prompt_fallback_label` and `prompt_fallback_version`, and inherits neither from the prompt you asked for first. They describe a different prompt: a version number is minted per prompt, so version 3 of one name is an unrelated revision of another, and a label the first prompt carries need not exist on the fallback at all.
295
+
296
+ That means an omitted `prompt_fallback_label` resolves the fallback the same way any unlabelled lookup does — at the 'production' label. **If you want both prompts to come from the same label, name it for both.**
297
+
298
+ Whichever prompt is used is the one linked to the trace and merged into your request. If the fallback is missing too, the error is raised rather than swallowed — a request that asked for a prompt shouldn't quietly run without one.
299
+
257
300
  ### Custom Trace Names
258
301
 
259
302
  To provide custom trace names to the observability platform, you can pass 'trace_name' in parameters. Example:
@@ -10,6 +10,9 @@ module NitroIntelligence
10
10
  class RunError < StandardError; end
11
11
  class ThreadResumptionError < StandardError; end
12
12
 
13
+ # Aegra answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
14
+ THREAD_CONFLICT_CODE = 409
15
+
13
16
  attr_reader :base_url, :user_id
14
17
 
15
18
  def initialize(base_url:, api_key:, user_id: "default-user")
@@ -21,6 +24,7 @@ module NitroIntelligence
21
24
  @api_key = api_key
22
25
  @user_id = user_id
23
26
  @tool_call_review_validator = ToolCallReviewValidator.new
27
+ @graph_ids = {}
24
28
  end
25
29
 
26
30
  def await_run(thread_id:, assistant_id:, messages:, context: {})
@@ -29,7 +33,7 @@ module NitroIntelligence
29
33
  initial_state = messages[0..-2]
30
34
  last_message = messages.last
31
35
 
32
- initialize_thread_if_needed(thread_id:, initial_state:)
36
+ initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
33
37
  trigger_run(thread_id:, assistant_id:, context:, last_message:)
34
38
  end
35
39
 
@@ -77,20 +81,78 @@ module NitroIntelligence
77
81
 
78
82
  private
79
83
 
80
- def initialize_thread_if_needed(thread_id:, initial_state:)
81
- thread_response = post(
84
+ # Aegra accepts `initial_state` on thread creation but never applies it, so a brand new thread is
85
+ # seeded through the thread state endpoint instead. A thread that already exists is left untouched:
86
+ # its state was seeded when it was created and has been built up by every run since.
87
+ def initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
88
+ thread_response = create_thread(thread_id:, assistant_id:)
89
+
90
+ return if thread_already_exists?(thread_response)
91
+ raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
92
+ return if initial_state.blank?
93
+
94
+ seed_new_thread_state(thread_id:, initial_state:)
95
+ end
96
+
97
+ # Creating the thread and seeding its state are separate requests, so a failure between them leaves
98
+ # an empty thread behind. A retry would find that thread, take it for one already under way, skip
99
+ # seeding and run without the history -- losing the very thing seeding exists for, without an error.
100
+ # Discard the thread instead, so a retry starts over from a clean slate.
101
+ def seed_new_thread_state(thread_id:, initial_state:)
102
+ seed_thread_state(thread_id:, initial_state:)
103
+ rescue
104
+ discard_thread(thread_id:)
105
+ raise
106
+ end
107
+
108
+ def discard_thread(thread_id:)
109
+ delete(path: "/threads/#{thread_id}")
110
+ rescue
111
+ # Best effort. The seeding failure is the one worth surfacing, and it is raised either way.
112
+ end
113
+
114
+ def create_thread(thread_id:, assistant_id:)
115
+ post(
82
116
  path: "/threads",
83
117
  body: {
84
118
  threadId: thread_id.to_s,
85
- ifExists: "do_nothing",
86
- initial_state: { messages: initial_state },
119
+ ifExists: "raise",
120
+ # Without a graph_id, the thread state cannot be updated before the thread's first run.
121
+ metadata: { graph_id: graph_id_for(assistant_id) },
87
122
  user_id:,
88
123
  }
89
124
  )
125
+ end
90
126
 
91
- raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
127
+ def graph_id_for(assistant_id)
128
+ @graph_ids[assistant_id] ||= fetch_graph_id(assistant_id)
129
+ end
92
130
 
93
- JSON.parse(thread_response.body)
131
+ def fetch_graph_id(assistant_id)
132
+ assistant_response = get(path: "/assistants/#{assistant_id}")
133
+
134
+ raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
135
+
136
+ graph_id = JSON.parse(assistant_response.body)["graph_id"]
137
+
138
+ raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
139
+
140
+ graph_id
141
+ end
142
+
143
+ def seed_thread_state(thread_id:, initial_state:)
144
+ state_response = post(
145
+ path: "/threads/#{thread_id}/state",
146
+ body: { values: { messages: initial_state } }
147
+ )
148
+
149
+ raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
150
+
151
+ JSON.parse(state_response.body)
152
+ end
153
+
154
+ def thread_already_exists?(response)
155
+ response.code.to_i == THREAD_CONFLICT_CODE
94
156
  end
95
157
 
96
158
  def get_thread_state(thread_id:)
@@ -177,6 +239,17 @@ module NitroIntelligence
177
239
  http.request(request)
178
240
  end
179
241
 
242
+ def delete(path:)
243
+ uri = URI("#{base_url}#{path}")
244
+ http = Net::HTTP.new(uri.host, uri.port)
245
+ http.use_ssl = uri.scheme == "https"
246
+
247
+ request = Net::HTTP::Delete.new(uri)
248
+ request_headers.each { |k, v| request[k] = v }
249
+
250
+ http.request(request)
251
+ end
252
+
180
253
  def post(path:, body:)
181
254
  uri = URI("#{base_url}#{path}")
182
255
  http = Net::HTTP.new(uri.host, uri.port)
@@ -1,21 +1,19 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
6
- class AudioTranscriptionHandler
7
+ class AudioTranscriptionHandler < BaseHandler
7
8
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::TranscriptionCreateParams.fields.keys.uniq.freeze
8
9
 
9
- def initialize(client:)
10
- @client = client
11
- end
12
-
13
10
  def create(audio_file:, message: "", parameters: {})
14
11
  validate_and_resolve!(parameters)
15
12
  perform_request(audio_file:, message:, parameters:)
16
13
  end
17
14
 
18
15
  def perform_request(audio_file:, message: "", parameters: {})
16
+ add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
19
17
  @client.audio.transcriptions.create(
20
18
  prompt: message,
21
19
  file: audio_file,
@@ -0,0 +1,22 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Handlers
4
+ class BaseHandler
5
+ MODALITY_HEADER = "nip-modality".freeze
6
+ REQUESTED_MODEL_HEADER = "nip-requested-model".freeze
7
+
8
+ def initialize(client:)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ def add_request_headers(parameters, headers)
15
+ request_options = (parameters[:request_options] ||= {})
16
+ (request_options[:extra_headers] ||= {}).merge!(headers.compact)
17
+ parameters
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,21 +1,19 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
6
- class ChatHandler
7
+ class ChatHandler < BaseHandler
7
8
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
8
9
 
9
- def initialize(client:)
10
- @client = client
11
- end
12
-
13
10
  def create(message: "", parameters: {})
14
11
  validate_and_resolve!(parameters, message)
15
12
  perform_request(parameters:)
16
13
  end
17
14
 
18
15
  def perform_request(parameters: {})
16
+ add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
19
17
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
20
18
  end
21
19
 
@@ -29,7 +27,6 @@ module NitroIntelligence
29
27
  metadata: {},
30
28
  messages: [],
31
29
  model: NitroIntelligence.model_catalog.default_text_model&.name,
32
- extra_headers: { "Prefer" => "wait" },
33
30
  }
34
31
 
35
32
  parameters.replace(default_parameters.merge(parameters))
@@ -1,16 +1,13 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
  require "nitro_intelligence/media/image_generation"
3
4
 
4
5
  module NitroIntelligence
5
6
  module Client
6
7
  module Handlers
7
- class ImageHandler
8
+ class ImageHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
9
10
 
10
- def initialize(client:)
11
- @client = client
12
- end
13
-
14
11
  def create(message: "", target_image: nil, reference_images: [], parameters: {})
15
12
  image_generation = build_image_generation(message:, target_image:, reference_images:, parameters:)
16
13
 
@@ -23,6 +20,7 @@ module NitroIntelligence
23
20
  end
24
21
 
25
22
  def perform_request(parameters: {})
23
+ add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
26
24
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
27
25
  end
28
26
 
@@ -32,7 +30,6 @@ module NitroIntelligence
32
30
  metadata: {},
33
31
  messages: image_generation.messages,
34
32
  model: image_generation.config.model,
35
- extra_headers: { "Prefer" => "wait" },
36
33
  request_options: {
37
34
  extra_body: {
38
35
  image_config: {
@@ -1,5 +1,6 @@
1
1
  require "base64"
2
2
  require "nitro_intelligence/media/audio"
3
+ require "nitro_intelligence/observability/prompt_resolver"
3
4
 
4
5
  module NitroIntelligence
5
6
  module Client
@@ -34,26 +35,21 @@ module NitroIntelligence
34
35
  private
35
36
 
36
37
  def handle_prompt(message:, parameters:)
37
- return nil if parameters[:prompt_name].blank?
38
-
39
- prompt = @observer.project_client.project.prompt_store.get_prompt(
40
- prompt_name: parameters[:prompt_name],
41
- prompt_label: parameters[:prompt_label],
42
- prompt_version: parameters[:prompt_version]
38
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
39
+ store: @observer.project_client.project.prompt_store,
40
+ parameters:
43
41
  )
44
- prompt_variables = parameters[:prompt_variables] || {}
45
-
46
- if prompt.present?
47
- # Prompts for audio transcriptions should only be text
48
- if prompt.type != "text"
49
- raise ObservedAudioTranscriptionPromptError,
50
- "Prompt type for audio transcription must be text: #{prompt.name}"
51
- end
52
- interpolated_prompt = prompt.compile(**prompt_variables)
42
+ return nil if prompt.blank?
53
43
 
54
- message.prepend("#{interpolated_prompt} ").strip!
55
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
44
+ # Prompts for audio transcriptions should only be text
45
+ if prompt.type != "text"
46
+ raise ObservedAudioTranscriptionPromptError,
47
+ "Prompt type for audio transcription must be text: #{prompt.name}"
56
48
  end
49
+ interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
50
+
51
+ message.prepend("#{interpolated_prompt} ").strip!
52
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
57
53
 
58
54
  prompt
59
55
  end
@@ -1,3 +1,5 @@
1
+ require "nitro_intelligence/observability/prompt_resolver"
2
+
1
3
  module NitroIntelligence
2
4
  module Client
3
5
  module Handlers
@@ -28,23 +30,18 @@ module NitroIntelligence
28
30
  private
29
31
 
30
32
  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]
33
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
34
+ store: @observer.project_client.project.prompt_store,
35
+ parameters:
37
36
  )
38
- prompt_variables = parameters[:prompt_variables] || {}
37
+ return nil if prompt.blank?
39
38
 
40
- if prompt.present?
41
- parameters[:messages] = prompt.interpolate(
42
- messages: parameters[:messages],
43
- variables: prompt_variables
44
- )
39
+ parameters[:messages] = prompt.interpolate(
40
+ messages: parameters[:messages],
41
+ variables: parameters[:prompt_variables] || {}
42
+ )
45
43
 
46
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
47
- end
44
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
48
45
 
49
46
  prompt
50
47
  end
@@ -1,4 +1,5 @@
1
1
  require "nitro_intelligence/media/image_generation"
2
+ require "nitro_intelligence/observability/prompt_resolver"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
@@ -58,23 +59,18 @@ module NitroIntelligence
58
59
  end
59
60
 
60
61
  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]
62
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
63
+ store: @observer.project_client.project.prompt_store,
64
+ parameters:
67
65
  )
68
- prompt_variables = parameters[:prompt_variables] || {}
66
+ return nil if prompt.blank?
69
67
 
70
- if prompt.present?
71
- parameters[:messages] = prompt.interpolate(
72
- messages: parameters[:messages],
73
- variables: prompt_variables
74
- )
68
+ parameters[:messages] = prompt.interpolate(
69
+ messages: parameters[:messages],
70
+ variables: parameters[:prompt_variables] || {}
71
+ )
75
72
 
76
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
77
- end
73
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
78
74
 
79
75
  prompt
80
76
  end
@@ -1,4 +1,5 @@
1
1
  require "nitro_intelligence/media/audio"
2
+ require "nitro_intelligence/observability/prompt_resolver"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
@@ -33,26 +34,21 @@ module NitroIntelligence
33
34
  private
34
35
 
35
36
  def handle_prompt(parameters:)
36
- return nil if parameters[:prompt_name].blank?
37
-
38
- prompt = @observer.project_client.project.prompt_store.get_prompt(
39
- prompt_name: parameters[:prompt_name],
40
- prompt_label: parameters[:prompt_label],
41
- prompt_version: parameters[:prompt_version]
37
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
38
+ store: @observer.project_client.project.prompt_store,
39
+ parameters:
42
40
  )
43
- prompt_variables = parameters[:prompt_variables] || {}
44
-
45
- if prompt.present?
46
- # Prompts for tts should only be text
47
- if prompt.type != "text"
48
- raise ObservedTextToSpeechPromptError,
49
- "Prompt type for text-to-speech must be text: #{prompt.name}"
50
- end
51
- interpolated_prompt = prompt.compile(**prompt_variables)
52
-
53
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
54
- parameters[:instructions] = interpolated_prompt
41
+ return nil if prompt.blank?
42
+
43
+ # Prompts for tts should only be text
44
+ if prompt.type != "text"
45
+ raise ObservedTextToSpeechPromptError,
46
+ "Prompt type for text-to-speech must be text: #{prompt.name}"
55
47
  end
48
+ interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
49
+
50
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
51
+ parameters[:instructions] = interpolated_prompt
56
52
 
57
53
  prompt
58
54
  end
@@ -1,21 +1,19 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
6
- class TextToSpeechHandler
7
+ class TextToSpeechHandler < BaseHandler
7
8
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::SpeechCreateParams.fields.keys.uniq.freeze
8
9
 
9
- def initialize(client:)
10
- @client = client
11
- end
12
-
13
10
  def create(message: "", parameters: {})
14
11
  validate_and_resolve!(parameters)
15
12
  perform_request(message:, parameters:)
16
13
  end
17
14
 
18
15
  def perform_request(message: "", parameters: {})
16
+ add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
19
17
  @client.audio.speech.create(
20
18
  input: message,
21
19
  **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
@@ -25,8 +23,6 @@ module NitroIntelligence
25
23
  def validate_and_resolve!(parameters)
26
24
  model_name = parameters[:model] || NitroIntelligence.model_catalog.default_text_to_speech_model&.name
27
25
  model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
28
-
29
- # Check model supported
30
26
  raise ArgumentError, "Unsupported model: '#{model_name}'" unless model
31
27
 
32
28
  default_parameters = {
@@ -35,17 +31,19 @@ module NitroIntelligence
35
31
  voice: model.default_voice,
36
32
  response_format: model.default_response_format,
37
33
  }
38
-
39
34
  parameters.replace(default_parameters.merge(parameters))
35
+ validate_voice_and_format!(model, parameters)
36
+ end
37
+
38
+ private
40
39
 
41
- # Check voice supported
40
+ def validate_voice_and_format!(model, parameters)
42
41
  unless model.voices.include?(parameters[:voice])
43
42
  raise ArgumentError,
44
43
  "Unsupported voice: '#{parameters[:voice]}'. " \
45
44
  "Supported voices for #{model.name} are: #{model.voices}"
46
45
  end
47
46
 
48
- # Check format supported
49
47
  return if model.response_formats.include?(parameters[:response_format])
50
48
 
51
49
  raise ArgumentError,
@@ -0,0 +1,69 @@
1
+ require "nitro_intelligence/observability/prompt_store"
2
+
3
+ module NitroIntelligence
4
+ module Observability
5
+ # Resolves a managed prompt from the lookups a request asks for, most specific first. A lookup that is
6
+ # missing -- or that fails -- yields to the next; the last one's failure propagates.
7
+ class PromptResolver
8
+ # One prompt lookup: the name asked for, at a label or a version.
9
+ Lookup = Data.define(:name, :label, :version)
10
+
11
+ def self.for(store:, parameters:)
12
+ new(store: store, lookups: lookups_for(parameters)).prompt
13
+ end
14
+
15
+ # The fallback names its own label and version and inherits neither: a version is minted per prompt,
16
+ # and a label the requested prompt carries need not exist on the fallback at all.
17
+ def self.lookups_for(parameters)
18
+ [
19
+ Lookup.new(
20
+ name: parameters[:prompt_name],
21
+ label: parameters[:prompt_label],
22
+ version: parameters[:prompt_version]
23
+ ),
24
+ Lookup.new(
25
+ name: parameters[:prompt_fallback_name],
26
+ label: parameters[:prompt_fallback_label],
27
+ version: parameters[:prompt_fallback_version]
28
+ ),
29
+ ]
30
+ end
31
+
32
+ def initialize(store:, lookups:)
33
+ @store = store
34
+ @lookups = lookups.select { |lookup| lookup.name.present? }.uniq
35
+ end
36
+
37
+ def prompt
38
+ *optional, final = @lookups
39
+ return nil if final.nil?
40
+
41
+ optional.each do |lookup|
42
+ resolved = fetch_optional(lookup)
43
+ return resolved if resolved
44
+ end
45
+
46
+ fetch(final)
47
+ end
48
+
49
+ private
50
+
51
+ def fetch(lookup)
52
+ @store.get_prompt(
53
+ prompt_name: lookup.name,
54
+ prompt_label: lookup.label,
55
+ prompt_version: lookup.version
56
+ )
57
+ end
58
+
59
+ def fetch_optional(lookup)
60
+ fetch(lookup)
61
+ rescue => e
62
+ NitroIntelligence.logger.info(
63
+ "#{self.class} #{e} - Falling back to the next prompt name after a failed lookup: #{lookup.name}"
64
+ )
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,3 +1,3 @@
1
1
  module NitroIntelligence
2
- VERSION = "2.0.0".freeze
2
+ VERSION = "2.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nitro_intelligence
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko
@@ -80,6 +80,7 @@ files:
80
80
  - lib/nitro_intelligence/client/client.rb
81
81
  - lib/nitro_intelligence/client/factory.rb
82
82
  - lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb
83
+ - lib/nitro_intelligence/client/handlers/base_handler.rb
83
84
  - lib/nitro_intelligence/client/handlers/chat_handler.rb
84
85
  - lib/nitro_intelligence/client/handlers/image_handler.rb
85
86
  - lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb
@@ -104,6 +105,7 @@ files:
104
105
  - lib/nitro_intelligence/observability/project_client.rb
105
106
  - lib/nitro_intelligence/observability/project_client_registry.rb
106
107
  - lib/nitro_intelligence/observability/prompt.rb
108
+ - lib/nitro_intelligence/observability/prompt_resolver.rb
107
109
  - lib/nitro_intelligence/observability/prompt_store.rb
108
110
  - lib/nitro_intelligence/observability/upload_handler.rb
109
111
  - lib/nitro_intelligence/reporter.rb