nitro_intelligence 3.1.0 → 4.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/Rakefile +21 -1
  4. data/docs/ASSISTANTS.md +594 -0
  5. data/docs/CHANGELOG.md +243 -0
  6. data/docs/README.md +190 -12
  7. data/lib/nitro_intelligence/assistant.rb +82 -3
  8. data/lib/nitro_intelligence/assistant_registry.rb +18 -0
  9. data/lib/nitro_intelligence/assistants.rb +340 -102
  10. data/lib/nitro_intelligence/client/base.rb +88 -8
  11. data/lib/nitro_intelligence/client/client.rb +22 -2
  12. data/lib/nitro_intelligence/client/factory.rb +12 -3
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +6 -2
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
  19. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
  20. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
  21. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
  22. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
  23. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
  24. data/lib/nitro_intelligence/client/observed.rb +8 -0
  25. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  26. data/lib/nitro_intelligence/configuration.rb +48 -0
  27. data/lib/nitro_intelligence/decision.rb +105 -0
  28. data/lib/nitro_intelligence/deprecator.rb +10 -0
  29. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  30. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  31. data/lib/nitro_intelligence/media/audio.rb +6 -0
  32. data/lib/nitro_intelligence/media/image.rb +14 -1
  33. data/lib/nitro_intelligence/media/image_generation.rb +14 -2
  34. data/lib/nitro_intelligence/media/media.rb +4 -0
  35. data/lib/nitro_intelligence/models/model.rb +7 -0
  36. data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
  37. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  38. data/lib/nitro_intelligence/null_cache.rb +2 -0
  39. data/lib/nitro_intelligence/observability/project.rb +41 -2
  40. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  41. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  42. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  43. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  44. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  45. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  46. data/lib/nitro_intelligence/pending_review.rb +257 -0
  47. data/lib/nitro_intelligence/railtie.rb +10 -0
  48. data/lib/nitro_intelligence/reporter.rb +29 -0
  49. data/lib/nitro_intelligence/review_decision.rb +36 -0
  50. data/lib/nitro_intelligence/run_result.rb +160 -0
  51. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
  52. data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
  53. data/lib/nitro_intelligence/trace.rb +8 -0
  54. data/lib/nitro_intelligence/version.rb +2 -1
  55. data/lib/nitro_intelligence.rb +40 -0
  56. metadata +12 -1
@@ -1,9 +1,11 @@
1
1
  require "nitro_intelligence/models/model_factory"
2
2
 
3
3
  module NitroIntelligence
4
+ # @api private
4
5
  class ModelCatalog
5
6
  attr_reader :models,
6
7
  :default_audio_transcription_model,
8
+ :default_decision_model,
7
9
  :default_image_model,
8
10
  :default_text_model,
9
11
  :default_text_to_speech_model
@@ -12,6 +14,7 @@ module NitroIntelligence
12
14
  model_config = model_config.symbolize_keys
13
15
  @models = (model_config[:models] || []).map { |model_metadata| ModelFactory.build(model_metadata) }
14
16
  @default_audio_transcription_model = lookup_by_name(model_config[:default_audio_transcription_model])
17
+ @default_decision_model = lookup_by_name(model_config[:default_decision_model])
15
18
  @default_image_model = lookup_by_name(model_config[:default_image_model])
16
19
  @default_text_model = lookup_by_name(model_config[:default_text_model])
17
20
  @default_text_to_speech_model = lookup_by_name(model_config[:default_text_to_speech_model])
@@ -1,10 +1,12 @@
1
1
  require "nitro_intelligence/models/model"
2
2
 
3
3
  module NitroIntelligence
4
+ # @api private
4
5
  class ModelFactory
5
6
  TYPES = {
6
7
  "text" => TextModel,
7
8
  "audio_transcription" => TextModel,
9
+ "decision" => DecisionModel,
8
10
  "image" => ImageModel,
9
11
  "text_to_speech" => TextToSpeechModel,
10
12
  }.freeze
@@ -1,6 +1,8 @@
1
1
  module NitroIntelligence
2
2
  # Default no-operation cache implementation used
3
3
  # as a sensible default for initializing NitroIntelligence
4
+ #
5
+ # @api private
4
6
  class NullCache
5
7
  def read(_key) = nil
6
8
  def write(_key, _value, **_options) = true
@@ -2,18 +2,36 @@ require "base64"
2
2
  require "nitro_intelligence/observability/prompt_store"
3
3
 
4
4
  module NitroIntelligence
5
+ # Cerebro, the Langfuse deployment observed requests are recorded in.
5
6
  module Observability
7
+ # A Cerebro project `observability_projects` configures. Use it to reach Cerebro's API, or to
8
+ # link to the project's traces, where this gem does not.
6
9
  class Project
10
+ # Raised by {ProjectClientRegistry#fetch} when `observability_projects` does not configure the
11
+ # project.
7
12
  class NotFoundError < StandardError; end
8
13
 
9
- attr_reader :slug, :id, :public_key, :secret_key, :auth_token, :prompt_store
14
+ # @api private
15
+ # Recorded in place of anything a masked project would otherwise send to Cerebro.
16
+ MASKED_CONTENT = "[masked]".freeze
10
17
 
11
- def initialize(slug:, id:, public_key:, secret_key:, **_kwargs)
18
+ # @return [String] the project's id in Cerebro, as in its URLs:
19
+ # `"#{config.observability_base_url}/project/#{id}/traces/#{trace_id}"`
20
+ attr_reader :id
21
+ # @return [String] the Base64 `public_key:secret_key` credential for Cerebro's public API,
22
+ # without the scheme: send it as `Authorization: Basic #{auth_token}`
23
+ attr_reader :auth_token
24
+ # @api private
25
+ attr_reader :slug, :public_key, :secret_key, :prompt_store
26
+
27
+ # @api private
28
+ def initialize(slug:, id:, public_key:, secret_key:, mask_content: false, **_kwargs)
12
29
  @slug = slug
13
30
  @id = id
14
31
  @public_key = public_key
15
32
  @secret_key = secret_key
16
33
  @auth_token = Base64.strict_encode64("#{public_key}:#{secret_key}")
34
+ @mask_content = mask_content == true
17
35
  @prompt_store = PromptStore.new(
18
36
  observability_project_slug: slug,
19
37
  observability_public_key: public_key,
@@ -21,6 +39,27 @@ module NitroIntelligence
21
39
  )
22
40
  end
23
41
 
42
+ # @api private
43
+ def mask_content?
44
+ @mask_content
45
+ end
46
+
47
+ # @api private
48
+ #
49
+ # Replaces the whole value rather than fields within it: the shape of a response
50
+ # varies by provider, and redacting named fields is what let reasoning leak
51
+ # through the gateway's own redaction. Absence stays absence, so an observation
52
+ # that never had an output does not gain one.
53
+ def mask(value)
54
+ return value if !mask_content? || value.nil?
55
+
56
+ MASKED_CONTENT
57
+ end
58
+
59
+ # The project `observability_projects` configures under `slug`.
60
+ #
61
+ # @param slug [String]
62
+ # @return [Project, nil] nil when no project is configured under `slug`
24
63
  def self.find_by_slug(slug:)
25
64
  project_config = NitroIntelligence.config.observability_projects.find { |project| project["slug"] == slug }
26
65
 
@@ -1,15 +1,24 @@
1
1
  module NitroIntelligence
2
2
  module Observability
3
+ # A project's connection to Cerebro, as {ProjectClientRegistry#fetch} gives it.
3
4
  class ProjectClient
5
+ # Not raised by the gem, which raises {Project::NotFoundError} for a project
6
+ # `observability_projects` does not configure. Public only because applications raise it
7
+ # themselves.
4
8
  class NotFoundError < StandardError; end
5
9
 
6
- attr_reader :project, :observability_client
10
+ # @return [Project]
11
+ attr_reader :project
12
+ # @api private
13
+ attr_reader :observability_client
7
14
 
15
+ # @api private
8
16
  def initialize(project:, observability_client:)
9
17
  @project = project
10
18
  @observability_client = observability_client
11
19
  end
12
20
 
21
+ # @api private
13
22
  def shutdown
14
23
  @observability_client.shutdown
15
24
  end
@@ -4,16 +4,34 @@ require "nitro_intelligence/observability/project_client"
4
4
 
5
5
  module NitroIntelligence
6
6
  module Observability
7
+ # Each Cerebro project's client, built once per process, as {NitroIntelligence.project_client_registry}
8
+ # holds them. Fetching each project at boot builds its client before the first request.
9
+ #
10
+ # @example In an initializer
11
+ # NitroIntelligence.config.observability_projects.each do |project|
12
+ # NitroIntelligence.project_client_registry.fetch(project["slug"])
13
+ # end
14
+ # at_exit { NitroIntelligence.project_client_registry.shutdown_all }
7
15
  class ProjectClientRegistry
16
+ # @api private
8
17
  def initialize(base_url:)
9
18
  @base_url = base_url
10
19
  @project_clients = {}
11
20
  end
12
21
 
22
+ # The client for the project configured under `slug`, built on first use.
23
+ #
24
+ # @param slug [String]
25
+ # @return [ProjectClient]
26
+ # @raise [Project::NotFoundError] when `observability_projects` does not configure the project
13
27
  def fetch(slug)
14
28
  @project_clients[slug] ||= build_project_client(slug)
15
29
  end
16
30
 
31
+ # Sends what each client still holds to Cerebro, and stops it. Call it as the process exits, or
32
+ # the last traces and scores are lost.
33
+ #
34
+ # @return [void]
17
35
  def shutdown_all
18
36
  @project_clients.values.each(&:shutdown)
19
37
  end
@@ -1,5 +1,6 @@
1
1
  module NitroIntelligence
2
2
  module Observability
3
+ # @api private
3
4
  class Prompt
4
5
  attr_reader :name, :type, :prompt, :version, :config, :labels, :tags
5
6
 
@@ -21,9 +22,9 @@ module NitroIntelligence
21
22
  def compile(**replacements)
22
23
  return replace_variables(@prompt, **replacements) if @type == "text"
23
24
 
25
+ # New messages, so the prompt keeps its variables for the next request that compiles it.
24
26
  @prompt.map do |message|
25
- message[:content] = replace_variables(message[:content], **replacements)
26
- message
27
+ message.merge(content: replace_variables(message[:content], **replacements))
27
28
  end
28
29
  end
29
30
 
@@ -31,7 +32,7 @@ module NitroIntelligence
31
32
  # into the correct position based on prompt "type" ("text" or "chat")
32
33
  def interpolate(messages:, variables:)
33
34
  if @type == "text"
34
- messages.prepend({ role: "system", content: compile(**variables) })
35
+ [{ role: "system", content: compile(**variables) }] + messages
35
36
  elsif @type == "chat"
36
37
  compile(**variables) + messages
37
38
  end
@@ -4,6 +4,8 @@ module NitroIntelligence
4
4
  module Observability
5
5
  # Resolves a managed prompt from the lookups a request asks for, most specific first. A lookup that is
6
6
  # missing -- or that fails -- yields to the next; the last one's failure propagates.
7
+ #
8
+ # @api private
7
9
  class PromptResolver
8
10
  # One prompt lookup: the name asked for, at a label or a version.
9
11
  Lookup = Data.define(:name, :label, :version)
@@ -7,10 +7,19 @@ require "nitro_intelligence/observability/prompt"
7
7
 
8
8
  module NitroIntelligence
9
9
  module Observability
10
+ # @api private
10
11
  class PromptStore
11
12
  OBSERVABILITY_PROMPTS_CACHE_KEY_PREFIX = "nitro_intelligence_observability_prompts_".freeze
12
13
 
14
+ # Raised by an observed request when Cerebro fails to return the prompt it names. The message
15
+ # is the response body.
16
+ #
17
+ # @api public
13
18
  class ObservabilityPromptError < StandardError; end
19
+ # Raised by an observed request when the prompt it names, and its fallback if it names one,
20
+ # does not exist.
21
+ #
22
+ # @api public
14
23
  class ObservabilityPromptNotFoundError < StandardError; end
15
24
 
16
25
  def initialize(observability_project_slug:, observability_public_key:, observability_secret_key:)
@@ -7,6 +7,7 @@ require "uri"
7
7
 
8
8
  module NitroIntelligence
9
9
  module Observability
10
+ # @api private
10
11
  class UploadHandler
11
12
  attr_reader :uploaded_media
12
13
 
@@ -58,7 +59,7 @@ module NitroIntelligence
58
59
  payload
59
60
  end
60
61
 
61
- def upload(trace_id, upload_queue: Queue.new)
62
+ def upload(trace_id, observation_id:, upload_queue: Queue.new)
62
63
  until upload_queue.empty?
63
64
  media = upload_queue.pop
64
65
 
@@ -68,6 +69,7 @@ module NitroIntelligence
68
69
  # returns {"mediaId" -> "", "uploadUrl" => ""}
69
70
  upload_url_response = get_upload_url({
70
71
  traceId: trace_id,
72
+ observationId: observation_id,
71
73
  contentType: media.mime_type,
72
74
  contentLength: content_length,
73
75
  sha256Hash: content_sha256,
@@ -0,0 +1,257 @@
1
+ require "active_support/core_ext/hash/indifferent_access"
2
+ require "nitro_intelligence/review_decision"
3
+ require "nitro_intelligence/tool_call_review_interrupt"
4
+ require "nitro_intelligence/tool_call_review_validator"
5
+
6
+ module NitroIntelligence
7
+ # What a paused run is waiting on: the interrupt the thread is holding, and the decisions that
8
+ # answer it.
9
+ #
10
+ # Assistants pauses for two kinds of review, never mixed in one interrupt:
11
+ #
12
+ # - tool calls, held by LangChain's `HumanInTheLoopMiddleware`, one action per call;
13
+ # - the run's final reply, a single action named `__reply__` whose `args` carry the draft's
14
+ # `content`.
15
+ #
16
+ # A turn with tool calls only ever pauses on its tool calls, and the reply is the run's final
17
+ # message, so `#tool_calls` and `#reply` are never both present.
18
+ #
19
+ # Built from a thread state (`GET /threads/{id}/state`, which reports `interrupts`) or from the
20
+ # response of a paused `/runs/wait` (which reports `__interrupt__`); both carry the messages the
21
+ # tool calls are matched back onto.
22
+ class PendingReview
23
+ # @api private
24
+ REPLY_ACTION = "__reply__".freeze
25
+
26
+ # @api private
27
+ # What a reviewer may do with a reply. LangChain's `respond` supplies a tool's result, which a
28
+ # reply does not have, so it is never offered for one, whatever the interrupt lists.
29
+ REPLY_DECISIONS = %w[approve edit reject].freeze
30
+
31
+ # @api private
32
+ KIND_NAMES = { tool_calls: "tool calls", reply: "a reply" }.freeze
33
+
34
+ # The draft reply a reviewer is asked to approve, edit or reject.
35
+ #
36
+ # @!attribute [r] content
37
+ # @return [String] the draft
38
+ # @!attribute [r] allowed_decisions
39
+ # @return [Array<String>] what the reviewer may do, from `approve`, `edit` and `reject`
40
+ # @!attribute [r] description
41
+ # @return [String, nil] the guidance the pause gives the reviewer
42
+ # @!attribute [r] interrupt_id
43
+ # @return [String] the pause, as {PendingReview#interrupt_id}
44
+ Reply = Struct.new(:content, :allowed_decisions, :description, :interrupt_id, keyword_init: true)
45
+
46
+ # The pending review in a thread state, or nil when the thread is not paused.
47
+ #
48
+ # @api private
49
+ def self.from_thread_state(thread_state)
50
+ interrupt = Array(thread_state["interrupts"]).first
51
+ interrupt && new(interrupt:, messages: Array(thread_state.dig("values", "messages")))
52
+ end
53
+
54
+ # The pending review in a `/runs/wait` response, or nil when the run did not pause.
55
+ #
56
+ # @api private
57
+ def self.from_run(run)
58
+ interrupt = Array(run["__interrupt__"]).first
59
+ interrupt && new(interrupt:, messages: Array(run["messages"]))
60
+ end
61
+
62
+ # @api private
63
+ def initialize(interrupt:, messages:)
64
+ @interrupt = interrupt.to_h
65
+ @messages = messages
66
+ end
67
+
68
+ # Identifies this pause. A thread that has moved on -- a new message superseded the review, or
69
+ # another reviewer decided it first -- reports a different interrupt, or none.
70
+ #
71
+ # @return [String, nil] nil for a tool-call pause the service reported without an id, which
72
+ # {Assistants#review} then resumes without checking that it is still the same pause
73
+ def interrupt_id
74
+ @interrupt["id"] || @interrupt["interrupt_id"]
75
+ end
76
+
77
+ # What the run is paused on.
78
+ #
79
+ # @return [Symbol] `:tool_calls` or `:reply`
80
+ def kind
81
+ action_name == REPLY_ACTION ? :reply : :tool_calls
82
+ end
83
+
84
+ # @return [Boolean] whether the run is paused on its tool calls
85
+ def tool_calls?
86
+ kind == :tool_calls
87
+ end
88
+
89
+ # @return [Boolean] whether the run is paused on its reply
90
+ def reply?
91
+ kind == :reply
92
+ end
93
+
94
+ # The tool calls the interrupt is holding, as `Assistants#tool_calls_under_review` reports them.
95
+ # Empty when the pause is on a reply.
96
+ #
97
+ # @return [Array<Hash>] each with `previous_message_id`, `id`, `name`, `args` and
98
+ # `allowed_decisions`
99
+ def tool_calls
100
+ return [] unless tool_calls?
101
+
102
+ tool_call_interrupt.tool_calls
103
+ end
104
+
105
+ # The held draft, or nil when the pause is not on a reply.
106
+ #
107
+ # @return [Reply, nil]
108
+ def reply
109
+ return nil unless reply?
110
+
111
+ @reply ||= Reply.new(
112
+ content: action_args["content"],
113
+ allowed_decisions:,
114
+ description: action_requests.first["description"],
115
+ interrupt_id:
116
+ )
117
+ end
118
+
119
+ # The `command.resume` that sends `decisions` to this pause, keyed by its interrupt. LangGraph
120
+ # applies a keyed resume to that interrupt only: keyed by one that is no longer pending -- the
121
+ # thread moved on between reading this pause and resuming it -- it decides nothing, and the
122
+ # thread stays paused on the newer interrupt. An interrupt reported without an id can only be
123
+ # resumed unkeyed.
124
+ #
125
+ # @api private
126
+ def resume(decisions)
127
+ interrupt_id.blank? ? { decisions: } : { interrupt_id => { decisions: } }
128
+ end
129
+
130
+ # Whether `run`, the response to a keyed resume of this pause, decided nothing because the
131
+ # interrupt was gone by the time the resume arrived. `run_id` is the run Assistants created for
132
+ # the resume, and the block reads the thread's state, only when it is needed.
133
+ #
134
+ # Such a resume comes back paused on the newer interrupt, exactly as a resume that was applied
135
+ # and paused again does -- a turn's tool calls decided, then the run's reply held. What tells
136
+ # them apart is who wrote the thread's latest checkpoint: Assistants records each run's id in
137
+ # the metadata of the checkpoints it writes, and a resume that decided nothing writes none, so
138
+ # the pause it reports still sits on a checkpoint an earlier run wrote.
139
+ #
140
+ # Best-effort, and only ever true on that evidence: without the run's id, the checkpoint's, or a
141
+ # state still on the reported pause (a new message may already have moved it on), or when the
142
+ # state cannot be read, it is false and the result stands as returned. That a stale decision is
143
+ # never applied is guaranteed by the keyed resume, not by this.
144
+ #
145
+ # @api private
146
+ def unapplied_resume?(run:, run_id:)
147
+ paused_on = self.class.from_run(run)&.interrupt_id
148
+ return false if interrupt_id.blank? || run_id.blank? || paused_on.blank? || paused_on == interrupt_id
149
+
150
+ state = yield
151
+ writer = state.dig("metadata", "run_id")
152
+ self.class.from_thread_state(state)&.interrupt_id == paused_on && writer.present? && writer != run_id
153
+ rescue
154
+ false
155
+ end
156
+
157
+ # The `command.resume` decisions for a review of this pause, validated against what the
158
+ # interrupt allows before anything is built, raising `Assistants::ThreadResumptionError`.
159
+ #
160
+ # For a reply, `reviews` is a single review hash. For tool calls, it is either a hash of reviews
161
+ # keyed by tool call id, as `Assistants#review_tool_calls` has always taken them, or a list of
162
+ # reviews that each carry their tool call's `id`.
163
+ #
164
+ # @api private
165
+ def decisions(reviews)
166
+ return [reply_decision(reviews)] if reply?
167
+
168
+ reviews = tool_call_reviews(reviews)
169
+ ToolCallReviewValidator.new.validate!(tool_calls: reviews, tool_calls_under_review: tool_calls)
170
+ tool_call_interrupt.decisions(reviews)
171
+ end
172
+
173
+ private
174
+
175
+ def tool_call_interrupt
176
+ @tool_call_interrupt ||= ToolCallReviewInterrupt.new(
177
+ "interrupts" => [@interrupt],
178
+ "values" => { "messages" => @messages }
179
+ )
180
+ end
181
+
182
+ def interrupt_value
183
+ @interrupt["value"] || {}
184
+ end
185
+
186
+ def action_requests
187
+ Array(interrupt_value["action_requests"])
188
+ end
189
+
190
+ def action_name
191
+ action_requests.first&.dig("name")
192
+ end
193
+
194
+ def action_args
195
+ action_requests.first["args"] || {}
196
+ end
197
+
198
+ def allowed_decisions
199
+ review_config = Array(interrupt_value["review_configs"]).find do |config|
200
+ config["action_name"] == action_name
201
+ end
202
+
203
+ Array(review_config&.dig("allowed_decisions")).map(&:to_s) & REPLY_DECISIONS
204
+ end
205
+
206
+ def tool_call_reviews(reviews)
207
+ return reviews unless reviews.is_a?(Array)
208
+
209
+ reviews.each_with_object({}) do |review, by_id|
210
+ unless review.is_a?(Hash) && review.with_indifferent_access[:id].present?
211
+ raise_error!("Each tool call review in a list must be a hash with the tool call's `id`")
212
+ end
213
+
214
+ review = review.with_indifferent_access
215
+ id = review[:id].to_s
216
+ raise_error!("Tool call `#{id}` is reviewed more than once") if by_id.key?(id)
217
+
218
+ by_id[id] = review.except(:id)
219
+ end
220
+ end
221
+
222
+ # A reply is a single action, so it is reviewed with a single hash.
223
+ def reply_decision(review)
224
+ raise_error!("The reply review must be a hash") unless review.is_a?(Hash)
225
+
226
+ review = review.with_indifferent_access
227
+ action = review[:action].to_s
228
+
229
+ unless allowed_decisions.include?(action)
230
+ raise_error!("Invalid review action `#{action}` for the reply; allowed: #{allowed_decisions.join(', ')}")
231
+ end
232
+
233
+ case action
234
+ when "approve" then ReviewDecision.approve
235
+ when "edit"
236
+ ReviewDecision.edit(REPLY_ACTION, { "content" => text!(review, :content, action:, required: true) })
237
+ when "reject" then ReviewDecision.reject(text!(review, :message, action:, required: false))
238
+ end
239
+ end
240
+
241
+ # A required field must be non-blank text; an optional one may be absent, but not something other
242
+ # than a String.
243
+ def text!(review, field, action:, required:)
244
+ value = review[field]
245
+ case ReviewDecision.message_problem(value, required:)
246
+ when :missing then raise_error!("A `#{action}` review of the reply must include a non-empty `#{field}`")
247
+ when :not_string then raise_error!("`#{field}` for the reply review must be a string")
248
+ end
249
+
250
+ value
251
+ end
252
+
253
+ def raise_error!(message)
254
+ raise Assistants::ThreadResumptionError, message
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,10 @@
1
+ require "nitro_intelligence/deprecator"
2
+
3
+ module NitroIntelligence
4
+ # @api private
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "nitro_intelligence.deprecator" do |app|
7
+ app.deprecators[:nitro_intelligence] = NitroIntelligence.deprecator
8
+ end
9
+ end
10
+ end
@@ -3,15 +3,33 @@ require "net/http"
3
3
  require "uri"
4
4
 
5
5
  module NitroIntelligence
6
+ # Records evaluations of traces in Cerebro.
7
+ #
8
+ # @example
9
+ # reporter = NitroIntelligence::Reporter.new(observability_project_slug: "my-feature-project")
10
+ # reporter.score(name: "precision", value: 0.5, trace_id:)
6
11
  class Reporter
12
+ # Raised by {#create_dataset_item} when Cerebro does not store the item. The message carries the
13
+ # response's status and body.
7
14
  class DatasetItemError < StandardError; end
8
15
 
16
+ # @param observability_project_slug [String] the Cerebro project the traces are recorded in
17
+ # @raise [Observability::Project::NotFoundError] when `observability_projects` does not configure
18
+ # the project
9
19
  def initialize(observability_project_slug:)
10
20
  @observability_project_slug = observability_project_slug
11
21
  @project_client = fetch_project_client
12
22
  @host = NitroIntelligence.config.observability_base_url
13
23
  end
14
24
 
25
+ # Adds an item to a dataset in the project, for evaluating prompts against later.
26
+ #
27
+ # @param attributes [Hash] the item, as Langfuse's `POST /api/public/dataset-items` takes it:
28
+ # `datasetName`, `input`, `expectedOutput`, `metadata`, `sourceTraceId` and the rest
29
+ # @return [Net::HTTPSuccess] the response
30
+ # @raise [DatasetItemError] when Cerebro does not store the item
31
+ # @example
32
+ # reporter.create_dataset_item({ datasetName: "sms-screening", input: { message: }, expectedOutput: "yes" })
15
33
  def create_dataset_item(attributes)
16
34
  uri = URI("#{@host}/api/public/dataset-items")
17
35
  http = Net::HTTP.new(uri.host, uri.port)
@@ -35,6 +53,17 @@ module NitroIntelligence
35
53
  response
36
54
  end
37
55
 
56
+ # Scores a trace. Scoring it again under the same `id` replaces the score.
57
+ #
58
+ # @param trace_id [String] the trace, such as {Trace.create_id} gives for a seed
59
+ # @param name [String] what the score measures
60
+ # @param value [Numeric, String, Boolean] the score: a number, `true` or `false` for `:boolean`,
61
+ # or the category's name for `:categorical`
62
+ # @param id [String] identifies the score, so that recording it again replaces it. Defaults to
63
+ # one per trace and name.
64
+ # @param data_type [Symbol] `:numeric`, `:boolean` or `:categorical`
65
+ # @return [void]
66
+ # @raise [ArgumentError] when `value` does not suit `data_type`
38
67
  def score(trace_id:, name:, value:, id: "#{trace_id}-#{name}", data_type: :numeric)
39
68
  @project_client.observability_client.create_score(
40
69
  id:,
@@ -0,0 +1,36 @@
1
+ module NitroIntelligence
2
+ # The `command.resume` decisions a review sends, and the check on the `message` a decision
3
+ # carries, shared by tool call reviews and reply reviews. Each caller keeps its own
4
+ # error wording, so this reports what is wrong rather than raising.
5
+ #
6
+ # @api private
7
+ module ReviewDecision
8
+ module_function
9
+
10
+ def approve
11
+ { "type" => "approve" }
12
+ end
13
+
14
+ def edit(name, args)
15
+ { "type" => "edit", "edited_action" => { "name" => name, "args" => args } }
16
+ end
17
+
18
+ # The middleware falls back to its own wording when a rejection carries no reason.
19
+ def reject(message)
20
+ message.nil? ? { "type" => "reject" } : { "type" => "reject", "message" => message }
21
+ end
22
+
23
+ def respond(message)
24
+ { "type" => "respond", "message" => message }
25
+ end
26
+
27
+ # What is wrong with a decision's `message`, or nil: `:missing` when a required one is absent
28
+ # or blank, `:not_string` when one is given as anything but a String.
29
+ def message_problem(message, required:)
30
+ return (:missing if required) if message.nil?
31
+ return :not_string unless message.is_a?(String)
32
+
33
+ :missing if required && message.blank?
34
+ end
35
+ end
36
+ end