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.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. 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
- { name: b["name"].to_s, arguments: (b["input"] || {}).transform_keys(&:to_sym) }
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.map do |m|
85
- { "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
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
- name: fn["name"].to_s,
71
- arguments: parse_arguments(fn["arguments"])
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.map do |m|
88
- { "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
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