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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/Rakefile +21 -1
- data/docs/ASSISTANTS.md +594 -0
- data/docs/CHANGELOG.md +243 -0
- data/docs/README.md +190 -12
- data/lib/nitro_intelligence/assistant.rb +82 -3
- data/lib/nitro_intelligence/assistant_registry.rb +18 -0
- data/lib/nitro_intelligence/assistants.rb +340 -102
- data/lib/nitro_intelligence/client/base.rb +88 -8
- data/lib/nitro_intelligence/client/client.rb +22 -2
- data/lib/nitro_intelligence/client/factory.rb +12 -3
- data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
- data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +6 -2
- data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
- data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
- data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
- data/lib/nitro_intelligence/client/observed.rb +8 -0
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
- data/lib/nitro_intelligence/configuration.rb +48 -0
- data/lib/nitro_intelligence/decision.rb +105 -0
- data/lib/nitro_intelligence/deprecator.rb +10 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
- data/lib/nitro_intelligence/media/audio.rb +6 -0
- data/lib/nitro_intelligence/media/image.rb +14 -1
- data/lib/nitro_intelligence/media/image_generation.rb +14 -2
- data/lib/nitro_intelligence/media/media.rb +4 -0
- data/lib/nitro_intelligence/models/model.rb +7 -0
- data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
- data/lib/nitro_intelligence/models/model_factory.rb +2 -0
- data/lib/nitro_intelligence/null_cache.rb +2 -0
- data/lib/nitro_intelligence/observability/project.rb +41 -2
- data/lib/nitro_intelligence/observability/project_client.rb +10 -1
- data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
- data/lib/nitro_intelligence/observability/prompt.rb +4 -3
- data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
- data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
- data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
- data/lib/nitro_intelligence/pending_review.rb +257 -0
- data/lib/nitro_intelligence/railtie.rb +10 -0
- data/lib/nitro_intelligence/reporter.rb +29 -0
- data/lib/nitro_intelligence/review_decision.rb +36 -0
- data/lib/nitro_intelligence/run_result.rb +160 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
- data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
- data/lib/nitro_intelligence/trace.rb +8 -0
- data/lib/nitro_intelligence/version.rb +2 -1
- data/lib/nitro_intelligence.rb +40 -0
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|