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
|
@@ -68,7 +68,11 @@ module Igniter
|
|
|
68
68
|
tool_calls = content_blocks
|
|
69
69
|
.select { |b| b["type"] == "tool_use" }
|
|
70
70
|
.map do |b|
|
|
71
|
-
{
|
|
71
|
+
{
|
|
72
|
+
id: b["id"].to_s,
|
|
73
|
+
name: b["name"].to_s,
|
|
74
|
+
arguments: (b["input"] || {}).transform_keys(&:to_sym),
|
|
75
|
+
}
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
usage = response.fetch("usage", {})
|
|
@@ -80,9 +84,38 @@ module Igniter
|
|
|
80
84
|
{ role: :assistant, content: text_content, tool_calls: tool_calls }
|
|
81
85
|
end
|
|
82
86
|
|
|
83
|
-
def normalize_messages(messages)
|
|
84
|
-
messages.
|
|
85
|
-
|
|
87
|
+
def normalize_messages(messages) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
88
|
+
messages.flat_map do |m|
|
|
89
|
+
role = (m[:role] || m["role"]).to_sym
|
|
90
|
+
|
|
91
|
+
case role
|
|
92
|
+
when :assistant
|
|
93
|
+
calls = Array(m[:tool_calls])
|
|
94
|
+
if calls.any?
|
|
95
|
+
# Anthropic requires tool_use blocks inside the content array
|
|
96
|
+
blocks = []
|
|
97
|
+
blocks << { "type" => "text", "text" => m[:content].to_s } unless m[:content].to_s.empty?
|
|
98
|
+
calls.each do |tc|
|
|
99
|
+
blocks << {
|
|
100
|
+
"type" => "tool_use",
|
|
101
|
+
"id" => tc[:id].to_s,
|
|
102
|
+
"name" => tc[:name].to_s,
|
|
103
|
+
"input" => (tc[:arguments] || {}).transform_keys(&:to_s),
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
[{ "role" => "assistant", "content" => blocks }]
|
|
107
|
+
else
|
|
108
|
+
[{ "role" => "assistant", "content" => m[:content].to_s }]
|
|
109
|
+
end
|
|
110
|
+
when :tool_results
|
|
111
|
+
# All results for one LLM turn → single user message with tool_result blocks
|
|
112
|
+
blocks = Array(m[:results]).map do |r|
|
|
113
|
+
{ "type" => "tool_result", "tool_use_id" => r[:id].to_s, "content" => r[:content].to_s }
|
|
114
|
+
end
|
|
115
|
+
[{ "role" => "user", "content" => blocks }]
|
|
116
|
+
else
|
|
117
|
+
[{ "role" => role.to_s, "content" => (m[:content] || m["content"]).to_s }]
|
|
118
|
+
end
|
|
86
119
|
end
|
|
87
120
|
end
|
|
88
121
|
|
|
@@ -67,8 +67,9 @@ module Igniter
|
|
|
67
67
|
raw.map do |tc|
|
|
68
68
|
fn = tc["function"] || {}
|
|
69
69
|
{
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
id: tc["id"].to_s,
|
|
71
|
+
name: fn["name"].to_s,
|
|
72
|
+
arguments: parse_arguments(fn["arguments"]),
|
|
72
73
|
}
|
|
73
74
|
end
|
|
74
75
|
end
|
|
@@ -83,9 +84,37 @@ module Igniter
|
|
|
83
84
|
{}
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
def normalize_messages(messages)
|
|
87
|
-
messages.
|
|
88
|
-
|
|
87
|
+
def normalize_messages(messages) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
88
|
+
messages.flat_map do |m|
|
|
89
|
+
role = (m[:role] || m["role"]).to_sym
|
|
90
|
+
|
|
91
|
+
case role
|
|
92
|
+
when :assistant
|
|
93
|
+
calls = Array(m[:tool_calls])
|
|
94
|
+
if calls.any?
|
|
95
|
+
# OpenAI assistant message with tool_calls field
|
|
96
|
+
formatted = calls.map do |tc|
|
|
97
|
+
{
|
|
98
|
+
"id" => tc[:id].to_s,
|
|
99
|
+
"type" => "function",
|
|
100
|
+
"function" => {
|
|
101
|
+
"name" => tc[:name].to_s,
|
|
102
|
+
"arguments" => JSON.generate(tc[:arguments] || {}),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
[{ "role" => "assistant", "content" => m[:content].to_s, "tool_calls" => formatted }]
|
|
107
|
+
else
|
|
108
|
+
[{ "role" => "assistant", "content" => m[:content].to_s }]
|
|
109
|
+
end
|
|
110
|
+
when :tool_results
|
|
111
|
+
# OpenAI expects one :tool message per result
|
|
112
|
+
Array(m[:results]).map do |r|
|
|
113
|
+
{ "role" => "tool", "tool_call_id" => r[:id].to_s, "name" => r[:name].to_s, "content" => r[:content].to_s }
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
[{ "role" => role.to_s, "content" => (m[:content] || m["content"]).to_s }]
|
|
117
|
+
end
|
|
89
118
|
end
|
|
90
119
|
end
|
|
91
120
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
module Transcription
|
|
6
|
+
module Providers
|
|
7
|
+
# AssemblyAI transcription provider.
|
|
8
|
+
#
|
|
9
|
+
# Uses a 3-step async workflow:
|
|
10
|
+
# 1. Upload audio file to AssemblyAI CDN → upload_url
|
|
11
|
+
# 2. Submit transcription job → job_id
|
|
12
|
+
# 3. Poll until completed / error → result
|
|
13
|
+
#
|
|
14
|
+
# Docs: https://www.assemblyai.com/docs/api-reference
|
|
15
|
+
#
|
|
16
|
+
# Strengths:
|
|
17
|
+
# - Speaker diarization (diarize: true → speaker_labels: true)
|
|
18
|
+
# - Rich intelligence features via options
|
|
19
|
+
# - Free tier (333 hr/month)
|
|
20
|
+
#
|
|
21
|
+
# Extra options:
|
|
22
|
+
# sentiment_analysis: Boolean — per-sentence sentiment
|
|
23
|
+
# auto_chapters: Boolean — topic-based chapters
|
|
24
|
+
# entity_detection: Boolean — named entity recognition
|
|
25
|
+
# pii_redact: Array — entity types to redact (e.g. [:name, :phone_number])
|
|
26
|
+
# auto_highlights: Boolean — key phrases extraction
|
|
27
|
+
# summarization: Boolean — extractive summary
|
|
28
|
+
# custom_vocabulary: Array — boost accuracy for specific words
|
|
29
|
+
class AssemblyAI < Base # rubocop:disable Metrics/ClassLength
|
|
30
|
+
API_BASE = "https://api.assemblyai.com"
|
|
31
|
+
MIN_INTERVAL = 1 # seconds
|
|
32
|
+
MAX_INTERVAL = 30 # seconds — exponential backoff cap
|
|
33
|
+
|
|
34
|
+
def initialize(api_key: ENV["ASSEMBLYAI_API_KEY"], base_url: API_BASE,
|
|
35
|
+
timeout: 60, poll_interval: 2, poll_timeout: 300)
|
|
36
|
+
super()
|
|
37
|
+
@api_key = api_key
|
|
38
|
+
@base_url = base_url.to_s.chomp("/")
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@poll_interval = poll_interval
|
|
41
|
+
@poll_timeout = poll_timeout
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transcribe(audio_source, model:, language: nil, diarize: false, # rubocop:disable Metrics/ParameterLists
|
|
45
|
+
_word_timestamps: true, poll_interval: nil, poll_timeout: nil, **options)
|
|
46
|
+
validate_api_key!
|
|
47
|
+
|
|
48
|
+
interval = poll_interval || @poll_interval
|
|
49
|
+
deadline = poll_timeout || @poll_timeout
|
|
50
|
+
|
|
51
|
+
audio = read_audio(audio_source)
|
|
52
|
+
fname = filename_from(audio_source)
|
|
53
|
+
upload_url = upload_file(audio, fname)
|
|
54
|
+
job_id = submit_job(upload_url, model: model, language: language,
|
|
55
|
+
diarize: diarize, options: options)
|
|
56
|
+
raw = poll_until_complete(job_id, interval: interval, timeout: deadline)
|
|
57
|
+
build_result(raw, model: model, diarize: diarize)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# ── Step 1: Upload ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def upload_file(audio_data, filename)
|
|
65
|
+
ctype = audio_content_type(filename)
|
|
66
|
+
uri = URI.parse("#{@base_url}/v2/upload")
|
|
67
|
+
http = http_for(uri)
|
|
68
|
+
request = Net::HTTP::Post.new(uri.path, auth_headers)
|
|
69
|
+
request["Content-Type"] = ctype
|
|
70
|
+
request.body = audio_data
|
|
71
|
+
|
|
72
|
+
response = handle_response(http.request(request))
|
|
73
|
+
response["upload_url"] || raise(Igniter::LLM::ProviderError, "AssemblyAI: no upload_url in response")
|
|
74
|
+
rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
|
|
75
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to AssemblyAI API: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ── Step 2: Submit job ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def submit_job(upload_url, model:, language:, diarize:, options:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
81
|
+
body = { audio_url: upload_url }
|
|
82
|
+
body[:speech_model] = model if model && model != "default"
|
|
83
|
+
body[:language_code] = language if language
|
|
84
|
+
body[:speaker_labels] = true if diarize
|
|
85
|
+
body[:word_boost] = options[:custom_vocabulary] if options[:custom_vocabulary]
|
|
86
|
+
body[:sentiment_analysis] = true if options[:sentiment_analysis]
|
|
87
|
+
body[:auto_chapters] = true if options[:auto_chapters]
|
|
88
|
+
body[:entity_detection] = true if options[:entity_detection]
|
|
89
|
+
body[:auto_highlights] = true if options[:auto_highlights]
|
|
90
|
+
body[:summarization] = true if options[:summarization]
|
|
91
|
+
body[:summary_model] = "informative" if options[:summarization]
|
|
92
|
+
body[:summary_type] = "bullets" if options[:summarization]
|
|
93
|
+
body[:redact_pii] = true if options[:pii_redact]
|
|
94
|
+
body[:redact_pii_policies] = options[:pii_redact].map(&:to_s) if options[:pii_redact]
|
|
95
|
+
|
|
96
|
+
uri = URI.parse("#{@base_url}/v2/transcripts")
|
|
97
|
+
http = http_for(uri)
|
|
98
|
+
request = Net::HTTP::Post.new(uri.path, json_headers)
|
|
99
|
+
request.body = JSON.generate(body)
|
|
100
|
+
|
|
101
|
+
response = handle_response(http.request(request))
|
|
102
|
+
response["id"] || raise(Igniter::LLM::ProviderError, "AssemblyAI: no transcript id in response")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ── Step 3: Poll ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def poll_until_complete(job_id, interval:, timeout:) # rubocop:disable Metrics/MethodLength
|
|
108
|
+
deadline = Time.now + timeout
|
|
109
|
+
wait = [interval.to_f, MIN_INTERVAL].max
|
|
110
|
+
|
|
111
|
+
loop do
|
|
112
|
+
result = fetch_transcript(job_id)
|
|
113
|
+
|
|
114
|
+
case result["status"]
|
|
115
|
+
when "completed" then return result
|
|
116
|
+
when "error"
|
|
117
|
+
raise Igniter::LLM::ProviderError,
|
|
118
|
+
"AssemblyAI transcription failed: #{result["error"]}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if Time.now > deadline
|
|
122
|
+
raise Igniter::LLM::ProviderError,
|
|
123
|
+
"AssemblyAI transcription timed out after #{timeout}s (job_id: #{job_id})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sleep(wait)
|
|
127
|
+
wait = [wait * 1.5, MAX_INTERVAL].min
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def fetch_transcript(job_id)
|
|
132
|
+
uri = URI.parse("#{@base_url}/v2/transcripts/#{job_id}")
|
|
133
|
+
http = http_for(uri)
|
|
134
|
+
request = Net::HTTP::Get.new(uri.path, auth_headers)
|
|
135
|
+
handle_response(http.request(request))
|
|
136
|
+
rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
|
|
137
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to AssemblyAI API: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ── Result building ────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def build_result(raw, model:, diarize:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
143
|
+
words = (raw["words"] || []).map do |w|
|
|
144
|
+
TranscriptWord.new(
|
|
145
|
+
word: w["text"].to_s,
|
|
146
|
+
start_time: w["start"].to_f / 1000.0, # AssemblyAI uses milliseconds
|
|
147
|
+
end_time: w["end"].to_f / 1000.0,
|
|
148
|
+
confidence: w["confidence"]&.to_f,
|
|
149
|
+
speaker: w["speaker"] # "A", "B", ... or nil
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
speakers = diarize ? build_speakers(raw["utterances"], words) : nil
|
|
154
|
+
|
|
155
|
+
TranscriptResult.new(
|
|
156
|
+
text: raw["text"].to_s,
|
|
157
|
+
words: words,
|
|
158
|
+
speakers: speakers,
|
|
159
|
+
language: raw["language_code"],
|
|
160
|
+
duration: raw["audio_duration"]&.to_f,
|
|
161
|
+
provider: :assemblyai,
|
|
162
|
+
model: model,
|
|
163
|
+
raw: raw
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_speakers(utterances, _words)
|
|
168
|
+
return [] unless utterances&.any?
|
|
169
|
+
|
|
170
|
+
utterances.map do |u|
|
|
171
|
+
SpeakerSegment.new(
|
|
172
|
+
speaker: u["speaker"], # "A", "B", ...
|
|
173
|
+
start_time: u["start"].to_f / 1000.0,
|
|
174
|
+
end_time: u["end"].to_f / 1000.0,
|
|
175
|
+
text: u["text"].to_s
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ── Helpers ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def auth_headers
|
|
183
|
+
{ "Authorization" => @api_key }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def json_headers
|
|
187
|
+
auth_headers.merge("Content-Type" => "application/json")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_api_key!
|
|
191
|
+
return if @api_key && !@api_key.empty?
|
|
192
|
+
|
|
193
|
+
raise Igniter::LLM::ConfigurationError,
|
|
194
|
+
"AssemblyAI API key not configured. Set ASSEMBLYAI_API_KEY or pass api_key: to the provider."
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module LLM
|
|
9
|
+
module Transcription
|
|
10
|
+
module Providers
|
|
11
|
+
# Abstract base for all transcription providers.
|
|
12
|
+
#
|
|
13
|
+
# Subclasses must implement #transcribe and may override the protected helpers.
|
|
14
|
+
# All helpers use only stdlib (Net::HTTP, JSON) — zero production dependencies.
|
|
15
|
+
class Base
|
|
16
|
+
# @param audio_source [String, Pathname, IO] File path or IO object.
|
|
17
|
+
# @param model [String]
|
|
18
|
+
# @param language [String, nil] BCP-47 code, or nil for auto-detection.
|
|
19
|
+
# @param diarize [Boolean] Request speaker labels.
|
|
20
|
+
# @param word_timestamps [Boolean] Request per-word timing.
|
|
21
|
+
# @param options [Hash] Provider-specific extras.
|
|
22
|
+
# @return [TranscriptResult]
|
|
23
|
+
def transcribe(audio_source, model:, language: nil, diarize: false, # rubocop:disable Metrics/ParameterLists
|
|
24
|
+
word_timestamps: true, **options)
|
|
25
|
+
raise NotImplementedError, "#{self.class}#transcribe must be implemented"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# ── Audio helpers ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def read_audio(source)
|
|
33
|
+
case source
|
|
34
|
+
when String then File.binread(source)
|
|
35
|
+
when Pathname then File.binread(source.to_s)
|
|
36
|
+
when StringIO then source.string.b
|
|
37
|
+
when IO then source.read.b
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "audio_source must be a file path or IO object, got #{source.class}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filename_from(source)
|
|
44
|
+
case source
|
|
45
|
+
when String then File.basename(source)
|
|
46
|
+
when Pathname then source.basename.to_s
|
|
47
|
+
else "audio.wav"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Best-effort MIME type from file extension.
|
|
52
|
+
def audio_content_type(filename)
|
|
53
|
+
ext = File.extname(filename.to_s).downcase
|
|
54
|
+
{
|
|
55
|
+
".mp3" => "audio/mpeg", ".mp4" => "video/mp4", ".m4a" => "audio/mp4",
|
|
56
|
+
".wav" => "audio/wav", ".webm" => "audio/webm", ".ogg" => "audio/ogg",
|
|
57
|
+
".flac" => "audio/flac", ".mpeg" => "audio/mpeg", ".mpga" => "audio/mpeg"
|
|
58
|
+
}.fetch(ext, "application/octet-stream")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── Multipart form-data builder ────────────────────────────────────
|
|
62
|
+
#
|
|
63
|
+
# Returns [body (binary String), boundary (String)].
|
|
64
|
+
# Hash values that contain :data are treated as file parts.
|
|
65
|
+
def build_multipart(fields) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
66
|
+
boundary = "IgniterBdy#{Time.now.to_i}"
|
|
67
|
+
crlf = "\r\n"
|
|
68
|
+
body = String.new("", encoding: "BINARY")
|
|
69
|
+
|
|
70
|
+
fields.each do |name, value|
|
|
71
|
+
body << "--#{boundary}#{crlf}"
|
|
72
|
+
if value.is_a?(Hash) && value.key?(:data)
|
|
73
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{value[:filename]}\"#{crlf}"
|
|
74
|
+
body << "Content-Type: #{value[:content_type]}#{crlf}#{crlf}"
|
|
75
|
+
data = value[:data]
|
|
76
|
+
data = data.b if data.respond_to?(:b)
|
|
77
|
+
body << data
|
|
78
|
+
else
|
|
79
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"#{crlf}#{crlf}"
|
|
80
|
+
body << value.to_s
|
|
81
|
+
end
|
|
82
|
+
body << crlf
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
body << "--#{boundary}--#{crlf}"
|
|
86
|
+
[body, boundary]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── HTTP helpers ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def http_for(uri)
|
|
92
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
93
|
+
http.use_ssl = uri.scheme == "https"
|
|
94
|
+
http.read_timeout = @timeout || 300
|
|
95
|
+
http.open_timeout = 15
|
|
96
|
+
http
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_response(response) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
100
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
101
|
+
body = begin
|
|
102
|
+
JSON.parse(response.body)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
msg = body["error"].is_a?(Hash) ? body.dig("error", "message") : body["error"]
|
|
107
|
+
msg ||= response.body.to_s.slice(0, 200)
|
|
108
|
+
raise Igniter::LLM::ProviderError, "#{provider_name} error #{response.code}: #{msg}"
|
|
109
|
+
end
|
|
110
|
+
JSON.parse(response.body)
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
raise Igniter::LLM::ProviderError, "#{provider_name} returned invalid JSON: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def provider_name
|
|
116
|
+
self.class.name.to_s.split("::").last
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
module Transcription
|
|
6
|
+
module Providers
|
|
7
|
+
# Deepgram transcription provider (Nova-3 and others).
|
|
8
|
+
#
|
|
9
|
+
# API: POST /v1/listen (raw binary body, model/options as query params)
|
|
10
|
+
# Docs: https://developers.deepgram.com/docs/getting-started-with-pre-recorded-audio
|
|
11
|
+
#
|
|
12
|
+
# Strengths:
|
|
13
|
+
# - Speaker diarization (diarize: true)
|
|
14
|
+
# - Per-second billing (cheapest for variable-length calls)
|
|
15
|
+
# - Synchronous response (no polling)
|
|
16
|
+
# - Intelligence features via options: :sentiment, :topics, :intents, :summarize
|
|
17
|
+
#
|
|
18
|
+
# Extra options:
|
|
19
|
+
# sentiment: Boolean — per-sentence sentiment analysis
|
|
20
|
+
# topics: Boolean — topic detection
|
|
21
|
+
# intents: Boolean — intent recognition
|
|
22
|
+
# summarize: Boolean — extractive summary
|
|
23
|
+
# smart_format: Boolean — punctuation + capitalization (default: true)
|
|
24
|
+
# punctuate: Boolean — add punctuation (default: true)
|
|
25
|
+
class Deepgram < Base # rubocop:disable Metrics/ClassLength
|
|
26
|
+
API_BASE = "https://api.deepgram.com"
|
|
27
|
+
|
|
28
|
+
def initialize(api_key: ENV["DEEPGRAM_API_KEY"], base_url: API_BASE, timeout: 300)
|
|
29
|
+
super()
|
|
30
|
+
@api_key = api_key
|
|
31
|
+
@base_url = base_url.to_s.chomp("/")
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transcribe(audio_source, model:, language: nil, diarize: false, # rubocop:disable Metrics/ParameterLists
|
|
36
|
+
_word_timestamps: true, **options)
|
|
37
|
+
validate_api_key!
|
|
38
|
+
|
|
39
|
+
audio = read_audio(audio_source)
|
|
40
|
+
fname = filename_from(audio_source)
|
|
41
|
+
ctype = audio_content_type(fname)
|
|
42
|
+
|
|
43
|
+
params = build_params(model: model, language: language, diarize: diarize, options: options)
|
|
44
|
+
raw = post_binary("/v1/listen", audio, ctype, params)
|
|
45
|
+
build_result(raw, model: model, diarize: diarize)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_params(model:, language:, diarize:, options:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
51
|
+
p = { model: model }
|
|
52
|
+
p[:language] = language if language
|
|
53
|
+
p[:diarize] = "true" if diarize
|
|
54
|
+
p[:utterances] = "true" if diarize # structured speaker segments
|
|
55
|
+
p[:punctuate] = options.fetch(:punctuate, true) ? "true" : "false"
|
|
56
|
+
p[:smart_format] = options.fetch(:smart_format, true) ? "true" : "false"
|
|
57
|
+
p[:sentiment] = "true" if options[:sentiment]
|
|
58
|
+
p[:topics] = "true" if options[:topics]
|
|
59
|
+
p[:intents] = "true" if options[:intents]
|
|
60
|
+
p[:summarize] = "v2" if options[:summarize]
|
|
61
|
+
p
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_result(raw, model:, diarize:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
65
|
+
alternative = raw.dig("results", "channels", 0, "alternatives", 0) || {}
|
|
66
|
+
|
|
67
|
+
words = (alternative["words"] || []).map do |w|
|
|
68
|
+
TranscriptWord.new(
|
|
69
|
+
word: (w["punctuated_word"] || w["word"]).to_s,
|
|
70
|
+
start_time: w["start"].to_f,
|
|
71
|
+
end_time: w["end"].to_f,
|
|
72
|
+
confidence: w["confidence"]&.to_f,
|
|
73
|
+
speaker: w["speaker"] # Integer (0-based) when diarize: true, else nil
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
speakers = diarize ? build_speakers(raw.dig("results", "utterances"), words) : nil
|
|
78
|
+
lang = raw.dig("results", "channels", 0, "detected_language") ||
|
|
79
|
+
raw.dig("metadata", "detected_language")
|
|
80
|
+
|
|
81
|
+
TranscriptResult.new(
|
|
82
|
+
text: alternative["transcript"].to_s,
|
|
83
|
+
words: words,
|
|
84
|
+
speakers: speakers,
|
|
85
|
+
language: lang,
|
|
86
|
+
duration: raw.dig("metadata", "duration")&.to_f,
|
|
87
|
+
provider: :deepgram,
|
|
88
|
+
model: model,
|
|
89
|
+
raw: raw
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build SpeakerSegment list from Deepgram utterances (preferred) or word grouping.
|
|
94
|
+
def build_speakers(utterances, words) # rubocop:disable Metrics/MethodLength
|
|
95
|
+
if utterances&.any?
|
|
96
|
+
utterances.map do |u|
|
|
97
|
+
SpeakerSegment.new(
|
|
98
|
+
speaker: u["speaker"],
|
|
99
|
+
start_time: u["start"].to_f,
|
|
100
|
+
end_time: u["end"].to_f,
|
|
101
|
+
text: u["transcript"].to_s
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
# Fall back: group consecutive words by speaker
|
|
106
|
+
group_words_by_speaker(words)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def group_words_by_speaker(words) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
111
|
+
return [] if words.empty?
|
|
112
|
+
|
|
113
|
+
segments = []
|
|
114
|
+
current = nil
|
|
115
|
+
|
|
116
|
+
words.each do |w|
|
|
117
|
+
if current.nil? || w.speaker != current[:speaker]
|
|
118
|
+
segments << current if current
|
|
119
|
+
current = { speaker: w.speaker, start_time: w.start_time, end_time: w.end_time, words: [w.word] }
|
|
120
|
+
else
|
|
121
|
+
current[:end_time] = w.end_time
|
|
122
|
+
current[:words] << w.word
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
segments << current if current
|
|
126
|
+
|
|
127
|
+
segments.map do |seg|
|
|
128
|
+
SpeakerSegment.new(
|
|
129
|
+
speaker: seg[:speaker],
|
|
130
|
+
start_time: seg[:start_time],
|
|
131
|
+
end_time: seg[:end_time],
|
|
132
|
+
text: seg[:words].join(" ")
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def post_binary(path, audio_data, content_type, params)
|
|
138
|
+
query = URI.encode_www_form(params)
|
|
139
|
+
uri = URI.parse("#{@base_url}#{path}?#{query}")
|
|
140
|
+
http = http_for(uri)
|
|
141
|
+
|
|
142
|
+
request = Net::HTTP::Post.new("#{uri.path}?#{uri.query}")
|
|
143
|
+
request["Authorization"] = "Token #{@api_key}"
|
|
144
|
+
request["Content-Type"] = content_type
|
|
145
|
+
request.body = audio_data
|
|
146
|
+
|
|
147
|
+
handle_response(http.request(request))
|
|
148
|
+
rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
|
|
149
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to Deepgram API: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_api_key!
|
|
153
|
+
return if @api_key && !@api_key.empty?
|
|
154
|
+
|
|
155
|
+
raise Igniter::LLM::ConfigurationError,
|
|
156
|
+
"Deepgram API key not configured. Set DEEPGRAM_API_KEY or pass api_key: to the provider."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|