igniter 0.4.3 → 0.5.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/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +128 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
module Transcription
|
|
6
|
+
module Providers
|
|
7
|
+
# OpenAI Whisper transcription provider.
|
|
8
|
+
#
|
|
9
|
+
# API: POST /v1/audio/transcriptions (multipart/form-data)
|
|
10
|
+
# Docs: https://platform.openai.com/docs/api-reference/audio
|
|
11
|
+
#
|
|
12
|
+
# Limitations vs other providers:
|
|
13
|
+
# - No speaker diarization (speakers will be nil in result)
|
|
14
|
+
# - 20 MB file size limit
|
|
15
|
+
# - Only word-level granularity (no sentence-level)
|
|
16
|
+
#
|
|
17
|
+
# Extra options passed through:
|
|
18
|
+
# prompt: String — context hint to guide spelling/vocabulary
|
|
19
|
+
# response_format: Symbol — :srt or :vtt stores raw subtitles in result.raw[:subtitle_text]
|
|
20
|
+
class OpenAI < Base
|
|
21
|
+
API_BASE = "https://api.openai.com"
|
|
22
|
+
|
|
23
|
+
def initialize(api_key: ENV["OPENAI_API_KEY"], base_url: API_BASE, timeout: 120)
|
|
24
|
+
super()
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = base_url.to_s.chomp("/")
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def transcribe(audio_source, model:, language: nil, _diarize: false, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
|
|
31
|
+
word_timestamps: true, **options)
|
|
32
|
+
validate_api_key!
|
|
33
|
+
|
|
34
|
+
audio = read_audio(audio_source)
|
|
35
|
+
fname = filename_from(audio_source)
|
|
36
|
+
ctype = audio_content_type(fname)
|
|
37
|
+
|
|
38
|
+
fields = {
|
|
39
|
+
"file" => { data: audio, filename: fname, content_type: ctype },
|
|
40
|
+
"model" => model,
|
|
41
|
+
"response_format" => "verbose_json"
|
|
42
|
+
}
|
|
43
|
+
fields["language"] = language if language
|
|
44
|
+
fields["prompt"] = options[:prompt] if options[:prompt]
|
|
45
|
+
# Request word-level timestamps
|
|
46
|
+
fields["timestamp_granularities[]"] = "word" if word_timestamps
|
|
47
|
+
|
|
48
|
+
body, boundary = build_multipart(fields)
|
|
49
|
+
raw = post_multipart("/v1/audio/transcriptions", body, boundary)
|
|
50
|
+
build_result(raw, model: model)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_result(raw, model:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
56
|
+
words = (raw["words"] || []).map do |w|
|
|
57
|
+
TranscriptWord.new(
|
|
58
|
+
word: w["word"].to_s.strip,
|
|
59
|
+
start_time: w["start"].to_f,
|
|
60
|
+
end_time: w["end"].to_f,
|
|
61
|
+
confidence: w["confidence"]&.to_f,
|
|
62
|
+
speaker: nil # Whisper has no diarization
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
TranscriptResult.new(
|
|
67
|
+
text: raw["text"].to_s,
|
|
68
|
+
words: words,
|
|
69
|
+
speakers: nil,
|
|
70
|
+
language: raw["language"],
|
|
71
|
+
duration: raw["duration"]&.to_f,
|
|
72
|
+
provider: :openai,
|
|
73
|
+
model: model,
|
|
74
|
+
raw: raw
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def post_multipart(path, body, boundary)
|
|
79
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
80
|
+
http = http_for(uri)
|
|
81
|
+
|
|
82
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
83
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
84
|
+
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
85
|
+
request.body = body
|
|
86
|
+
|
|
87
|
+
handle_response(http.request(request))
|
|
88
|
+
rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
|
|
89
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to OpenAI API: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_api_key!
|
|
93
|
+
return if @api_key && !@api_key.empty?
|
|
94
|
+
|
|
95
|
+
raise Igniter::LLM::ConfigurationError,
|
|
96
|
+
"OpenAI API key not configured. Set OPENAI_API_KEY or pass api_key: to the provider."
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
# Base class for audio transcription executors.
|
|
6
|
+
#
|
|
7
|
+
# A Transcriber is a fully-fledged Igniter::Executor — it can be used as a
|
|
8
|
+
# compute node in a Contract graph, paired with cache_ttl:, and composed
|
|
9
|
+
# with downstream LLM::Executor skills:
|
|
10
|
+
#
|
|
11
|
+
# class CallTranscriber < Igniter::LLM::Transcriber
|
|
12
|
+
# transcription_provider :deepgram
|
|
13
|
+
# model "nova-3"
|
|
14
|
+
# language "ru"
|
|
15
|
+
# diarize true
|
|
16
|
+
#
|
|
17
|
+
# def call(audio_path:)
|
|
18
|
+
# transcribe(audio_path)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # Standalone
|
|
23
|
+
# result = CallTranscriber.call(audio_path: "call_123.mp3")
|
|
24
|
+
# result.text # => "Добрый день, чем могу помочь?"
|
|
25
|
+
# result.speakers # => [#<SpeakerSegment speaker=0 ...>, ...]
|
|
26
|
+
#
|
|
27
|
+
# # Inside a Contract graph
|
|
28
|
+
# compute :transcript, call: CallTranscriber, with: :audio_path, cache_ttl: 3600
|
|
29
|
+
class Transcriber < Igniter::Executor
|
|
30
|
+
PROVIDERS = %i[openai deepgram assemblyai].freeze
|
|
31
|
+
DEFAULT_MODELS = { openai: "whisper-1", deepgram: "nova-3", assemblyai: "universal-2" }.freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def inherited(subclass)
|
|
35
|
+
super
|
|
36
|
+
TRANSCRIBER_IVARS.each do |ivar|
|
|
37
|
+
subclass.instance_variable_set(ivar, instance_variable_get(ivar))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ── Provider DSL ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def transcription_provider(name = nil)
|
|
44
|
+
return @transcription_provider if name.nil?
|
|
45
|
+
|
|
46
|
+
name = name.to_sym
|
|
47
|
+
unless PROVIDERS.include?(name)
|
|
48
|
+
raise ArgumentError, "Unknown transcription provider #{name.inspect}. Available: #{PROVIDERS.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@transcription_provider = name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def model(name = nil)
|
|
55
|
+
return @transcription_model if name.nil?
|
|
56
|
+
|
|
57
|
+
@transcription_model = name.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def language(lang = nil)
|
|
61
|
+
return @language if lang.nil?
|
|
62
|
+
|
|
63
|
+
@language = lang.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def diarize(bool = nil)
|
|
67
|
+
return @diarize.nil? ? false : @diarize if bool.nil?
|
|
68
|
+
|
|
69
|
+
@diarize = bool
|
|
70
|
+
end
|
|
71
|
+
alias diarize? diarize
|
|
72
|
+
|
|
73
|
+
def word_timestamps(bool = nil)
|
|
74
|
+
return @word_timestamps.nil? || @word_timestamps if bool.nil?
|
|
75
|
+
|
|
76
|
+
@word_timestamps = bool
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ── Polling DSL (AssemblyAI) ───────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
# Seconds between poll attempts (uses exponential backoff up to 30s).
|
|
82
|
+
def poll_interval(secs = nil)
|
|
83
|
+
return @poll_interval || 2 if secs.nil?
|
|
84
|
+
|
|
85
|
+
@poll_interval = secs.to_f
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Maximum total seconds to wait for async transcription.
|
|
89
|
+
def poll_timeout(secs = nil)
|
|
90
|
+
return @poll_timeout || 300 if secs.nil?
|
|
91
|
+
|
|
92
|
+
@poll_timeout = secs.to_f
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# All instance variables the DSL manages — copied in inherited hook.
|
|
97
|
+
TRANSCRIBER_IVARS = %i[
|
|
98
|
+
@transcription_provider @transcription_model @language
|
|
99
|
+
@diarize @word_timestamps @poll_interval @poll_timeout
|
|
100
|
+
].freeze
|
|
101
|
+
private_constant :TRANSCRIBER_IVARS
|
|
102
|
+
|
|
103
|
+
protected
|
|
104
|
+
|
|
105
|
+
# Transcribe audio and return a TranscriptResult.
|
|
106
|
+
# Call this from your #call implementation.
|
|
107
|
+
#
|
|
108
|
+
# @param audio_source [String, Pathname, IO] File path or IO object.
|
|
109
|
+
# @param options [Hash] Provider-specific extras forwarded verbatim.
|
|
110
|
+
# @return [Transcription::TranscriptResult]
|
|
111
|
+
def transcribe(audio_source, **options)
|
|
112
|
+
provider_instance.transcribe(
|
|
113
|
+
audio_source,
|
|
114
|
+
model: current_model,
|
|
115
|
+
language: self.class.language,
|
|
116
|
+
diarize: self.class.diarize?,
|
|
117
|
+
word_timestamps: self.class.word_timestamps,
|
|
118
|
+
poll_interval: self.class.poll_interval,
|
|
119
|
+
poll_timeout: self.class.poll_timeout,
|
|
120
|
+
**options
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def provider_instance
|
|
127
|
+
pname = self.class.transcription_provider
|
|
128
|
+
unless pname
|
|
129
|
+
raise Igniter::LLM::ConfigurationError,
|
|
130
|
+
"#{self.class.name}: transcription_provider not configured. " \
|
|
131
|
+
"Call transcription_provider :openai, :deepgram, or :assemblyai in your class."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@provider_instance ||= Igniter::LLM.transcription_provider_instance(pname)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def current_model
|
|
138
|
+
self.class.model ||
|
|
139
|
+
Transcriber::DEFAULT_MODELS[self.class.transcription_provider] ||
|
|
140
|
+
raise(Igniter::LLM::ConfigurationError,
|
|
141
|
+
"No model configured for provider #{self.class.transcription_provider}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
module Transcription
|
|
6
|
+
# Single word with timing and optional speaker label.
|
|
7
|
+
# end_time is used instead of `end` to avoid the Ruby keyword conflict.
|
|
8
|
+
TranscriptWord = Struct.new(:word, :start_time, :end_time, :confidence, :speaker, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# A contiguous block of speech from one speaker.
|
|
11
|
+
SpeakerSegment = Struct.new(:speaker, :start_time, :end_time, :text, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Normalised result returned by every Transcription provider.
|
|
14
|
+
#
|
|
15
|
+
# @attr text [String] Full transcript text.
|
|
16
|
+
# @attr words [Array<TranscriptWord>] Word-level timestamps (empty Array if not requested).
|
|
17
|
+
# @attr speakers [Array<SpeakerSegment>, nil] Speaker segments or nil when diarization was not requested.
|
|
18
|
+
# @attr language [String, nil] BCP-47 language code detected/specified (e.g. "en", "ru").
|
|
19
|
+
# @attr duration [Float, nil] Audio duration in seconds.
|
|
20
|
+
# @attr provider [Symbol] Provider used (:openai, :deepgram, :assemblyai).
|
|
21
|
+
# @attr model [String] Model string (e.g. "whisper-1", "nova-3").
|
|
22
|
+
# @attr raw [Hash] Original provider response — provider-specific extras live here.
|
|
23
|
+
TranscriptResult = Struct.new(
|
|
24
|
+
:text, :words, :speakers, :language, :duration, :provider, :model, :raw,
|
|
25
|
+
keyword_init: true
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -8,6 +8,12 @@ require_relative "llm/providers/ollama"
|
|
|
8
8
|
require_relative "llm/providers/anthropic"
|
|
9
9
|
require_relative "llm/providers/openai"
|
|
10
10
|
require_relative "llm/executor"
|
|
11
|
+
require_relative "llm/transcription/transcript_result"
|
|
12
|
+
require_relative "llm/transcription/providers/base"
|
|
13
|
+
require_relative "llm/transcription/providers/openai"
|
|
14
|
+
require_relative "llm/transcription/providers/deepgram"
|
|
15
|
+
require_relative "llm/transcription/providers/assemblyai"
|
|
16
|
+
require_relative "llm/transcription/transcriber"
|
|
11
17
|
|
|
12
18
|
module Igniter
|
|
13
19
|
module LLM
|
|
@@ -32,14 +38,21 @@ module Igniter
|
|
|
32
38
|
@provider_instances[name.to_sym] ||= build_provider(name.to_sym)
|
|
33
39
|
end
|
|
34
40
|
|
|
41
|
+
# Returns a memoized transcription provider instance.
|
|
42
|
+
def transcription_provider_instance(name)
|
|
43
|
+
@transcription_provider_instances ||= {}
|
|
44
|
+
@transcription_provider_instances[name.to_sym] ||= build_transcription_provider(name.to_sym)
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
# Reset cached provider instances (useful after reconfiguration).
|
|
36
48
|
def reset_providers!
|
|
37
49
|
@provider_instances = nil
|
|
50
|
+
@transcription_provider_instances = nil
|
|
38
51
|
end
|
|
39
52
|
|
|
40
53
|
private
|
|
41
54
|
|
|
42
|
-
def build_provider(name)
|
|
55
|
+
def build_provider(name) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
43
56
|
case name
|
|
44
57
|
when :ollama
|
|
45
58
|
cfg = config.ollama
|
|
@@ -54,6 +67,29 @@ module Igniter
|
|
|
54
67
|
raise ConfigurationError, "Unknown LLM provider: #{name}. Available: #{AVAILABLE_PROVIDERS.inspect}"
|
|
55
68
|
end
|
|
56
69
|
end
|
|
70
|
+
|
|
71
|
+
def build_transcription_provider(name) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
72
|
+
case name
|
|
73
|
+
when :openai
|
|
74
|
+
cfg = config.openai
|
|
75
|
+
Transcription::Providers::OpenAI.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
|
|
76
|
+
when :deepgram
|
|
77
|
+
cfg = config.deepgram
|
|
78
|
+
Transcription::Providers::Deepgram.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
|
|
79
|
+
when :assemblyai
|
|
80
|
+
cfg = config.assemblyai
|
|
81
|
+
Transcription::Providers::AssemblyAI.new(
|
|
82
|
+
api_key: cfg.api_key,
|
|
83
|
+
base_url: cfg.base_url,
|
|
84
|
+
timeout: cfg.timeout,
|
|
85
|
+
poll_interval: cfg.poll_interval,
|
|
86
|
+
poll_timeout: cfg.poll_timeout
|
|
87
|
+
)
|
|
88
|
+
else
|
|
89
|
+
raise ConfigurationError,
|
|
90
|
+
"Unknown transcription provider: #{name}. Available: #{Config::TRANSCRIPTION_PROVIDERS.inspect}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
57
93
|
end
|
|
58
94
|
end
|
|
59
95
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Memory
|
|
5
|
+
# Facade that pre-fills +agent_id+ on all Store operations.
|
|
6
|
+
#
|
|
7
|
+
# Callers interact with AgentMemory instead of the raw Store so they never
|
|
8
|
+
# have to pass +agent_id:+ explicitly. An optional +session_id+ groups
|
|
9
|
+
# episodes within a single interaction session.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# store = Igniter::Memory::Stores::InMemory.new
|
|
13
|
+
# memory = Igniter::Memory::AgentMemory.new(store: store, agent_id: "MyAgent:1")
|
|
14
|
+
#
|
|
15
|
+
# memory.record(type: :tool_call, content: "searched web for Ruby docs")
|
|
16
|
+
# memory.recall(query: "Ruby") # => [Episode, ...]
|
|
17
|
+
# memory.remember(:user_tz, "UTC")
|
|
18
|
+
# memory.facts # => { "user_tz" => Fact }
|
|
19
|
+
class AgentMemory
|
|
20
|
+
# @param store [Store] backing store implementation
|
|
21
|
+
# @param agent_id [String] identifier for this agent
|
|
22
|
+
# @param session_id [String, nil] optional session grouping key
|
|
23
|
+
def initialize(store:, agent_id:, session_id: nil)
|
|
24
|
+
@store = store
|
|
25
|
+
@agent_id = agent_id
|
|
26
|
+
@session_id = session_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Record a new episode.
|
|
30
|
+
#
|
|
31
|
+
# @param type [String, Symbol] category tag
|
|
32
|
+
# @param content [String] textual description of the event
|
|
33
|
+
# @param outcome [String, nil] result label, e.g. "success"/"failure"
|
|
34
|
+
# @param importance [Float] relevance weight 0.0–1.0
|
|
35
|
+
# @return [Episode]
|
|
36
|
+
def record(type:, content:, outcome: nil, importance: 0.5)
|
|
37
|
+
@store.record(
|
|
38
|
+
agent_id: @agent_id,
|
|
39
|
+
type: type,
|
|
40
|
+
content: content,
|
|
41
|
+
session_id: @session_id,
|
|
42
|
+
outcome: outcome,
|
|
43
|
+
importance: importance
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Keyword-search episodes for this agent.
|
|
48
|
+
#
|
|
49
|
+
# @param query [String, nil] search term; nil returns most recent
|
|
50
|
+
# @param limit [Integer] maximum results (default 10)
|
|
51
|
+
# @param type [String, Symbol, nil] optional type filter
|
|
52
|
+
# @return [Array<Episode>]
|
|
53
|
+
def recall(query: nil, limit: 10, type: nil)
|
|
54
|
+
@store.retrieve(agent_id: @agent_id, query: query, limit: limit, type: type)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Store or update a named fact.
|
|
58
|
+
#
|
|
59
|
+
# @param key [String, Symbol] fact name
|
|
60
|
+
# @param value [Object] fact value
|
|
61
|
+
# @param confidence [Float] confidence score 0.0–1.0
|
|
62
|
+
# @return [Fact]
|
|
63
|
+
def remember(key, value, confidence: 1.0)
|
|
64
|
+
@store.store_fact(agent_id: @agent_id, key: key, value: value, confidence: confidence)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns all stored facts for this agent.
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash{String => Fact}]
|
|
70
|
+
def facts
|
|
71
|
+
@store.facts(agent_id: @agent_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns recent episodes.
|
|
75
|
+
#
|
|
76
|
+
# @param last [Integer] maximum episodes to return
|
|
77
|
+
# @param type [String, Symbol, nil] optional type filter
|
|
78
|
+
# @return [Array<Episode>]
|
|
79
|
+
def recent(last: 20, type: nil)
|
|
80
|
+
@store.episodes(agent_id: @agent_id, last: last, type: type)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Run a reflection cycle and record the result.
|
|
84
|
+
#
|
|
85
|
+
# @param current_system_prompt [String, nil] current prompt to compare against
|
|
86
|
+
# @return [ReflectionRecord]
|
|
87
|
+
def reflect(current_system_prompt: nil)
|
|
88
|
+
ReflectionCycle.new(store: @store).reflect(
|
|
89
|
+
agent_id: @agent_id,
|
|
90
|
+
current_system_prompt: current_system_prompt
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check whether the agent should run a reflection cycle.
|
|
95
|
+
#
|
|
96
|
+
# Returns true when the recent failure rate exceeds the threshold.
|
|
97
|
+
#
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def should_reflect?
|
|
100
|
+
ReflectionCycle.new(store: @store).should_reflect?(agent_id: @agent_id)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Memory
|
|
5
|
+
# Immutable value object representing a single recorded event in agent history.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] id
|
|
8
|
+
# @return [Integer] unique identifier within the store
|
|
9
|
+
# @!attribute [r] agent_id
|
|
10
|
+
# @return [String] identifier of the agent that recorded this episode
|
|
11
|
+
# @!attribute [r] session_id
|
|
12
|
+
# @return [String, nil] optional session grouping identifier
|
|
13
|
+
# @!attribute [r] ts
|
|
14
|
+
# @return [Integer] Unix timestamp of when the episode was recorded
|
|
15
|
+
# @!attribute [r] type
|
|
16
|
+
# @return [String, Symbol] category of the episode (e.g. :tool_call, :response)
|
|
17
|
+
# @!attribute [r] content
|
|
18
|
+
# @return [String] textual description of what happened
|
|
19
|
+
# @!attribute [r] outcome
|
|
20
|
+
# @return [String, nil] result descriptor, e.g. "success" or "failure"
|
|
21
|
+
# @!attribute [r] importance
|
|
22
|
+
# @return [Float] relevance weight in [0.0, 1.0], default 0.5
|
|
23
|
+
Episode = Struct.new(
|
|
24
|
+
:id, :agent_id, :session_id, :ts,
|
|
25
|
+
:type, :content, :outcome, :importance,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Memory
|
|
5
|
+
# Immutable value object representing a persisted learned fact for an agent.
|
|
6
|
+
#
|
|
7
|
+
# Facts represent stable knowledge extracted from experience, keyed by a
|
|
8
|
+
# string name and associated with a confidence score.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] id
|
|
11
|
+
# @return [Integer] unique identifier within the store
|
|
12
|
+
# @!attribute [r] agent_id
|
|
13
|
+
# @return [String] identifier of the agent that owns this fact
|
|
14
|
+
# @!attribute [r] key
|
|
15
|
+
# @return [String] fact name (e.g. "user_timezone", "preferred_format")
|
|
16
|
+
# @!attribute [r] value
|
|
17
|
+
# @return [Object] the fact's value
|
|
18
|
+
# @!attribute [r] confidence
|
|
19
|
+
# @return [Float] confidence score in [0.0, 1.0], default 1.0
|
|
20
|
+
# @!attribute [r] updated_at
|
|
21
|
+
# @return [Integer] Unix timestamp of last upsert
|
|
22
|
+
Fact = Struct.new(
|
|
23
|
+
:id, :agent_id, :key, :value, :confidence, :updated_at,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Memory
|
|
5
|
+
# Mixin that adds episodic memory capabilities to Igniter::Agent and
|
|
6
|
+
# Igniter::LLM::Executor subclasses (or any class that includes it).
|
|
7
|
+
#
|
|
8
|
+
# Including +Memorable+ adds:
|
|
9
|
+
# * A class-level +enable_memory+ DSL method for opt-in activation
|
|
10
|
+
# * A +memory+ instance accessor returning a bound AgentMemory facade
|
|
11
|
+
# * Automatic agent_id derived from the class name and object_id
|
|
12
|
+
#
|
|
13
|
+
# Memory is NOT enabled by default — call +enable_memory+ in the class body.
|
|
14
|
+
#
|
|
15
|
+
# == Example
|
|
16
|
+
#
|
|
17
|
+
# class MyAgent < Igniter::Agent
|
|
18
|
+
# include Igniter::Memory::Memorable
|
|
19
|
+
#
|
|
20
|
+
# enable_memory store: Igniter::Memory::Stores::InMemory.new
|
|
21
|
+
#
|
|
22
|
+
# on :task do |state:, payload:, **|
|
|
23
|
+
# memory.record(type: :task, content: payload[:description])
|
|
24
|
+
# # ... handle task
|
|
25
|
+
# state
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# == Inheritance
|
|
30
|
+
#
|
|
31
|
+
# Subclasses inherit +memory_enabled?+ and +memory_store+ from their parent.
|
|
32
|
+
# Each subclass can override them independently.
|
|
33
|
+
module Memorable
|
|
34
|
+
def self.included(base)
|
|
35
|
+
base.extend(ClassMethods)
|
|
36
|
+
base.instance_variable_set(:@memory_store, nil)
|
|
37
|
+
base.instance_variable_set(:@memory_enabled, false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Enable episodic memory for this class.
|
|
42
|
+
#
|
|
43
|
+
# @param store [Store, nil] backing store; falls back to Igniter::Memory.default_store
|
|
44
|
+
# @return [void]
|
|
45
|
+
def enable_memory(store: nil)
|
|
46
|
+
@memory_enabled = true
|
|
47
|
+
@memory_store = store || Igniter::Memory.default_store
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the configured memory store, walking up the inheritance chain
|
|
51
|
+
# if none is set on this class.
|
|
52
|
+
#
|
|
53
|
+
# @return [Store]
|
|
54
|
+
def memory_store
|
|
55
|
+
@memory_store ||
|
|
56
|
+
(superclass.respond_to?(:memory_store) ? superclass.memory_store : Igniter::Memory.default_store)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns whether memory has been enabled for this class or any ancestor.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def memory_enabled?
|
|
63
|
+
@memory_enabled ||
|
|
64
|
+
(superclass.respond_to?(:memory_enabled?) && superclass.memory_enabled?)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the AgentMemory facade bound to this instance.
|
|
69
|
+
#
|
|
70
|
+
# Lazily initialised on first access. The agent_id incorporates the class
|
|
71
|
+
# name and object_id to keep instances isolated.
|
|
72
|
+
#
|
|
73
|
+
# @return [AgentMemory]
|
|
74
|
+
def memory
|
|
75
|
+
@memory ||= AgentMemory.new(
|
|
76
|
+
store: self.class.memory_store,
|
|
77
|
+
agent_id: memory_agent_id,
|
|
78
|
+
session_id: respond_to?(:session_id, true) ? session_id : nil
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def memory_agent_id
|
|
85
|
+
cls_name = self.class.name || "anonymous"
|
|
86
|
+
"#{cls_name}:#{object_id}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Memory
|
|
5
|
+
# Evaluates recent episodes and produces a ReflectionRecord.
|
|
6
|
+
#
|
|
7
|
+
# ReflectionCycle can operate in two modes:
|
|
8
|
+
#
|
|
9
|
+
# * **Rule-based** (default) — analyses failure rates using simple heuristics,
|
|
10
|
+
# requiring no LLM. Always available.
|
|
11
|
+
# * **LLM-assisted** — delegates to an LLM executor for a richer summary and
|
|
12
|
+
# an optional system-prompt patch. Enabled by passing an +llm:+ object.
|
|
13
|
+
#
|
|
14
|
+
# @example Rule-based reflection
|
|
15
|
+
# cycle = Igniter::Memory::ReflectionCycle.new(store: store)
|
|
16
|
+
# rec = cycle.reflect(agent_id: "MyAgent:1")
|
|
17
|
+
# # => ReflectionRecord (summary describes failure breakdown)
|
|
18
|
+
#
|
|
19
|
+
# @example With failure threshold check
|
|
20
|
+
# cycle.should_reflect?(agent_id: "MyAgent:1")
|
|
21
|
+
# # => true when recent failures >= DEFAULT_FAILURE_THRESHOLD
|
|
22
|
+
class ReflectionCycle
|
|
23
|
+
DEFAULT_FAILURE_THRESHOLD = 5
|
|
24
|
+
DEFAULT_WINDOW = 50
|
|
25
|
+
|
|
26
|
+
# @param store [Store] backing episode store
|
|
27
|
+
# @param failure_threshold [Integer] minimum failures to trigger reflection
|
|
28
|
+
# @param window [Integer] how many recent episodes to inspect
|
|
29
|
+
# @param llm [#call, nil] optional LLM executor for smart reflection
|
|
30
|
+
def initialize(store:, failure_threshold: DEFAULT_FAILURE_THRESHOLD,
|
|
31
|
+
window: DEFAULT_WINDOW, llm: nil)
|
|
32
|
+
@store = store
|
|
33
|
+
@failure_threshold = failure_threshold
|
|
34
|
+
@window = window
|
|
35
|
+
@llm = llm
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check whether a reflection cycle should be triggered.
|
|
39
|
+
#
|
|
40
|
+
# Returns true when the number of recent "failure" outcomes meets or
|
|
41
|
+
# exceeds the configured +failure_threshold+.
|
|
42
|
+
#
|
|
43
|
+
# @param agent_id [String]
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def should_reflect?(agent_id:)
|
|
46
|
+
recent = @store.episodes(agent_id: agent_id, last: @window)
|
|
47
|
+
failures = recent.count { |e| e.outcome == "failure" }
|
|
48
|
+
failures >= @failure_threshold
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Run a reflection cycle and persist the result.
|
|
52
|
+
#
|
|
53
|
+
# Analyses the most recent +window+ episodes and produces a
|
|
54
|
+
# ReflectionRecord with a summary (and optional system-prompt patch when
|
|
55
|
+
# an LLM is configured).
|
|
56
|
+
#
|
|
57
|
+
# @param agent_id [String]
|
|
58
|
+
# @param current_system_prompt [String, nil] passed to the LLM if available
|
|
59
|
+
# @return [ReflectionRecord]
|
|
60
|
+
def reflect(agent_id:, current_system_prompt: nil) # rubocop:disable Metrics/MethodLength
|
|
61
|
+
recent = @store.episodes(agent_id: agent_id, last: @window)
|
|
62
|
+
summary, patch = if @llm
|
|
63
|
+
smart_reflect(recent, current_system_prompt)
|
|
64
|
+
else
|
|
65
|
+
rule_based_reflect(recent)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@store.record_reflection(
|
|
69
|
+
agent_id: agent_id,
|
|
70
|
+
summary: summary,
|
|
71
|
+
system_patch: patch
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def rule_based_reflect(episodes) # rubocop:disable Metrics/CyclomaticComplexity
|
|
78
|
+
failures = episodes.select { |e| e.outcome == "failure" }
|
|
79
|
+
failure_types = failures.group_by(&:type).transform_values(&:count)
|
|
80
|
+
top = failure_types.max_by { |_, v| v }
|
|
81
|
+
|
|
82
|
+
summary = "#{failures.size}/#{episodes.size} failures. " \
|
|
83
|
+
"Top failure type: #{top&.first || "none"} (#{top&.last || 0}\xc3\x97)"
|
|
84
|
+
[summary, nil]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def smart_reflect(episodes, current_system_prompt)
|
|
88
|
+
result = @llm.call(
|
|
89
|
+
episodes: episodes.map { |e| { type: e.type, content: e.content, outcome: e.outcome } },
|
|
90
|
+
current_system_prompt: current_system_prompt
|
|
91
|
+
)
|
|
92
|
+
[result[:summary], result[:system_patch]]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|