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,403 @@
|
|
|
1
|
+
# Igniter — LLM::Transcriber
|
|
2
|
+
|
|
3
|
+
`Igniter::LLM::Transcriber` is a first-class Executor for audio-to-text conversion.
|
|
4
|
+
It plugs into the Contract graph exactly like any other compute node, giving you
|
|
5
|
+
caching, dependency resolution, and parallel execution for free.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
audio file / URL
|
|
9
|
+
│
|
|
10
|
+
▼
|
|
11
|
+
CallTranscriber ← LLM::Transcriber subclass
|
|
12
|
+
│ TranscriptResult
|
|
13
|
+
│ .text, .words, .speakers, .duration
|
|
14
|
+
▼
|
|
15
|
+
CallExtractor ← LLM::Executor subclass
|
|
16
|
+
│ Hash (structured JSON)
|
|
17
|
+
▼
|
|
18
|
+
CRM / DB
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "igniter/integrations/llm"
|
|
27
|
+
|
|
28
|
+
Igniter::LLM.configure do |c|
|
|
29
|
+
c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class MyTranscriber < Igniter::LLM::Transcriber
|
|
33
|
+
transcription_provider :deepgram
|
|
34
|
+
model "nova-3"
|
|
35
|
+
language "en"
|
|
36
|
+
diarize true
|
|
37
|
+
|
|
38
|
+
def call(audio_path:) = transcribe(audio_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
result = MyTranscriber.call(audio_path: "meeting.mp3")
|
|
42
|
+
puts result.text
|
|
43
|
+
puts result.speakers.map { |s| "#{s.speaker}: #{s.text}" }.join("\n")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Providers
|
|
49
|
+
|
|
50
|
+
### OpenAI Whisper / gpt-4o-mini-transcribe
|
|
51
|
+
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| **Sync** | Yes — single POST, immediate response |
|
|
55
|
+
| **Diarization** | **No** |
|
|
56
|
+
| **Word timestamps** | Yes |
|
|
57
|
+
| **Languages** | 99+ (auto-detect or `language:`) |
|
|
58
|
+
| **Max file size** | 20 MB |
|
|
59
|
+
| **Pricing** | whisper-1: `$0.006/min` · gpt-4o-mini-transcribe: `$0.003/min` |
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class CallTranscriber < Igniter::LLM::Transcriber
|
|
63
|
+
transcription_provider :openai
|
|
64
|
+
model "gpt-4o-mini-transcribe" # or "whisper-1"
|
|
65
|
+
language "en"
|
|
66
|
+
|
|
67
|
+
def call(audio_url:) = transcribe(audio_url)
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Extra options:** `prompt:` — context hint to improve spelling/vocabulary accuracy.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
### Deepgram Nova-3
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
|---|---|
|
|
79
|
+
| **Sync** | Yes — binary POST, immediate response |
|
|
80
|
+
| **Diarization** | Yes (`diarize true`) |
|
|
81
|
+
| **Word timestamps** | Yes (always included) |
|
|
82
|
+
| **Languages** | 99+ Nova-3 Multilingual · single-language Nova-3 |
|
|
83
|
+
| **Pricing** | `$0.0077/min` (includes diarization, per-second billing) |
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
Igniter::LLM.configure do |c|
|
|
87
|
+
c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class CallTranscriber < Igniter::LLM::Transcriber
|
|
91
|
+
transcription_provider :deepgram
|
|
92
|
+
model "nova-3"
|
|
93
|
+
diarize true
|
|
94
|
+
|
|
95
|
+
def call(audio_url:) = transcribe(audio_url)
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Extra options:**
|
|
100
|
+
```ruby
|
|
101
|
+
transcribe(audio_url,
|
|
102
|
+
sentiment: true, # per-sentence sentiment scores
|
|
103
|
+
topics: true, # topic detection
|
|
104
|
+
intents: true, # intent recognition
|
|
105
|
+
summarize: true # extractive summary
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Results accessible via `result.raw["results"]["channels"][0]["alternatives"][0]`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### AssemblyAI Universal-2
|
|
114
|
+
|
|
115
|
+
| | |
|
|
116
|
+
|---|---|
|
|
117
|
+
| **Sync** | **No** — async: upload → submit → poll |
|
|
118
|
+
| **Diarization** | Yes (`diarize true`) |
|
|
119
|
+
| **Word timestamps** | Yes (millisecond precision) |
|
|
120
|
+
| **Languages** | 99+ |
|
|
121
|
+
| **Pricing** | `$0.0025/min` base + `$0.0003/min` speaker labels ≈ `$0.0028/min` |
|
|
122
|
+
| **Free tier** | 333 hr/month |
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Igniter::LLM.configure do |c|
|
|
126
|
+
c.assemblyai.api_key = ENV["ASSEMBLYAI_API_KEY"]
|
|
127
|
+
c.assemblyai.poll_interval = 3 # seconds between status checks
|
|
128
|
+
c.assemblyai.poll_timeout = 600 # fail-safe timeout in seconds
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class CallTranscriber < Igniter::LLM::Transcriber
|
|
132
|
+
transcription_provider :assemblyai
|
|
133
|
+
diarize true
|
|
134
|
+
poll_interval 3
|
|
135
|
+
poll_timeout 600
|
|
136
|
+
|
|
137
|
+
def call(audio_url:) = transcribe(audio_url)
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Extra options:**
|
|
142
|
+
```ruby
|
|
143
|
+
transcribe(audio_url,
|
|
144
|
+
sentiment_analysis: true,
|
|
145
|
+
auto_chapters: true,
|
|
146
|
+
entity_detection: true,
|
|
147
|
+
pii_redact: [:phone_number, :name, :email],
|
|
148
|
+
custom_vocabulary: ["Acme", "thermidor"]
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Provider comparison
|
|
155
|
+
|
|
156
|
+
| Need | Recommended |
|
|
157
|
+
|------|-------------|
|
|
158
|
+
| Lowest cost, no speakers | `openai` + `gpt-4o-mini-transcribe` — $0.003/min |
|
|
159
|
+
| Lowest cost WITH speakers | `assemblyai` — $0.0028/min |
|
|
160
|
+
| Fastest response (sync) | `deepgram` — typical < 2 s |
|
|
161
|
+
| Richest features | `assemblyai` — chapters, PII, entity detection |
|
|
162
|
+
| Real-time / WebSocket | `deepgram` — same price as REST |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## DSL reference
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class MyTranscriber < Igniter::LLM::Transcriber
|
|
170
|
+
# ── Required ──────────────────────────────────────────────────────────
|
|
171
|
+
transcription_provider :openai # :openai | :deepgram | :assemblyai
|
|
172
|
+
|
|
173
|
+
# ── Optional ──────────────────────────────────────────────────────────
|
|
174
|
+
model "nova-3" # defaults: openai→whisper-1, deepgram→nova-3,
|
|
175
|
+
# assemblyai→universal-2
|
|
176
|
+
language "en" # BCP-47; nil = auto-detect
|
|
177
|
+
diarize true # request speaker labels (not supported by OpenAI)
|
|
178
|
+
word_timestamps true # per-word start/end times (default: true)
|
|
179
|
+
|
|
180
|
+
# AssemblyAI async polling
|
|
181
|
+
poll_interval 3 # seconds between poll attempts (default: 2)
|
|
182
|
+
poll_timeout 600 # max seconds to wait (default: 300)
|
|
183
|
+
|
|
184
|
+
def call(**inputs)
|
|
185
|
+
transcribe(inputs[:audio_url])
|
|
186
|
+
# or with provider-specific extras:
|
|
187
|
+
transcribe(inputs[:audio_url], sentiment: true, topics: true)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Result structure
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
result = MyTranscriber.call(audio_url: "call.mp3")
|
|
198
|
+
|
|
199
|
+
result.text # => "Hello, thank you for calling..."
|
|
200
|
+
result.language # => "en"
|
|
201
|
+
result.duration # => 243.5 (seconds)
|
|
202
|
+
result.provider # => :assemblyai
|
|
203
|
+
result.model # => "universal-2"
|
|
204
|
+
result.raw # => Hash — original provider response
|
|
205
|
+
|
|
206
|
+
# Word-level timestamps
|
|
207
|
+
result.words.first
|
|
208
|
+
# => #<TranscriptWord word="Hello" start_time=0.0 end_time=0.4 confidence=0.99 speaker="A">
|
|
209
|
+
|
|
210
|
+
# Speaker segments (when diarize: true)
|
|
211
|
+
result.speakers
|
|
212
|
+
# => [
|
|
213
|
+
# #<SpeakerSegment speaker="A" start_time=0.0 end_time=5.1 text="Hello, thank you...">,
|
|
214
|
+
# #<SpeakerSegment speaker="B" start_time=5.3 end_time=12.4 text="Hi, I need help...">,
|
|
215
|
+
# ...
|
|
216
|
+
# ]
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
> **Note:** AssemblyAI uses letter labels (`"A"`, `"B"`, ...).
|
|
220
|
+
> Deepgram uses integers (`0`, `1`, ...).
|
|
221
|
+
> OpenAI has no speaker labels — `speakers` is `nil`.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## In a Contract graph
|
|
226
|
+
|
|
227
|
+
Transcriber is a full `Igniter::Executor`. Use it as a `compute:` node
|
|
228
|
+
with `cache_ttl:` to avoid re-transcribing the same audio on retries:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class CallAnalysisPipeline < Igniter::Contract
|
|
232
|
+
define do
|
|
233
|
+
input :audio_url
|
|
234
|
+
input :recorded_at, required: false
|
|
235
|
+
|
|
236
|
+
compute :transcript, call: CallTranscriber,
|
|
237
|
+
with: :audio_url,
|
|
238
|
+
cache_ttl: 86_400 # 24 h — same URL = free
|
|
239
|
+
|
|
240
|
+
compute :extraction, call: CallExtractor,
|
|
241
|
+
with: %i[transcript recorded_at]
|
|
242
|
+
|
|
243
|
+
output :transcript, from: :transcript
|
|
244
|
+
output :extraction, from: :extraction
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
result = CallAnalysisPipeline.call(
|
|
249
|
+
audio_url: "https://cdn.callrail.com/recordings/abc123.mp3",
|
|
250
|
+
recorded_at: call.created_at.iso8601
|
|
251
|
+
)
|
|
252
|
+
result[:transcript] # => TranscriptResult
|
|
253
|
+
result[:extraction] # => Hash from LLM analysis
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Production: call-center CRM (Rails)
|
|
259
|
+
|
|
260
|
+
See `examples/llm/call_center_analysis.rb` for a complete runnable example.
|
|
261
|
+
|
|
262
|
+
### Volume / cost estimate (27 000 min/month)
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
AssemblyAI (transcript + diarization) ≈ $77/month
|
|
266
|
+
gpt-4o-mini (extraction, 14 000 calls) ≈ $4/month
|
|
267
|
+
─────────────
|
|
268
|
+
Total ≈ $81/month
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Rails integration sketch
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
# app/models/call_recording.rb
|
|
275
|
+
class CallRecording < ApplicationRecord
|
|
276
|
+
after_create_commit :schedule_analysis, if: :audio_url?
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def schedule_analysis = CallAnalysisJob.perform_later(id)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# app/jobs/call_analysis_job.rb
|
|
284
|
+
class CallAnalysisJob < ApplicationJob
|
|
285
|
+
queue_as :transcription
|
|
286
|
+
sidekiq_options retry: 3
|
|
287
|
+
|
|
288
|
+
def perform(id)
|
|
289
|
+
rec = CallRecording.find(id)
|
|
290
|
+
result = CallAnalysisPipeline.call(
|
|
291
|
+
audio_url: rec.audio_url,
|
|
292
|
+
recorded_at: rec.created_at.iso8601
|
|
293
|
+
)
|
|
294
|
+
t = result[:transcript]
|
|
295
|
+
e = result[:extraction]
|
|
296
|
+
|
|
297
|
+
rec.update!(
|
|
298
|
+
transcript_text: t.text,
|
|
299
|
+
duration_seconds: t.duration,
|
|
300
|
+
speakers_json: t.speakers&.map(&:to_h),
|
|
301
|
+
conversion: e["conversion"],
|
|
302
|
+
call_type: e["call_type"],
|
|
303
|
+
zip_codes: e["zip_codes"],
|
|
304
|
+
addresses: e["addresses"],
|
|
305
|
+
phones: e["phones"],
|
|
306
|
+
service_names: e["service_names"],
|
|
307
|
+
scheduled_at: e["scheduled_datetime"]&.then { Time.parse(_1) },
|
|
308
|
+
ai_confidence: e["confidence"],
|
|
309
|
+
ai_notes: e["notes"]
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Recommended PostgreSQL columns
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
add_column :call_recordings, :transcript_text, :text
|
|
319
|
+
add_column :call_recordings, :duration_seconds, :float
|
|
320
|
+
add_column :call_recordings, :speakers_json, :jsonb, default: []
|
|
321
|
+
add_column :call_recordings, :conversion, :boolean
|
|
322
|
+
add_column :call_recordings, :call_type, :string
|
|
323
|
+
add_column :call_recordings, :zip_codes, :string, array: true, default: []
|
|
324
|
+
add_column :call_recordings, :addresses, :string, array: true, default: []
|
|
325
|
+
add_column :call_recordings, :phones, :string, array: true, default: []
|
|
326
|
+
add_column :call_recordings, :service_names, :string, array: true, default: []
|
|
327
|
+
add_column :call_recordings, :scheduled_at, :datetime
|
|
328
|
+
add_column :call_recordings, :ai_confidence, :float
|
|
329
|
+
add_column :call_recordings, :ai_notes, :text
|
|
330
|
+
|
|
331
|
+
# Useful indexes
|
|
332
|
+
add_index :call_recordings, :conversion
|
|
333
|
+
add_index :call_recordings, :call_type
|
|
334
|
+
add_index :call_recordings, :zip_codes, using: :gin
|
|
335
|
+
add_index :call_recordings, :service_names, using: :gin
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Roadmap: Real-time operator assistance (Asterisk)
|
|
341
|
+
|
|
342
|
+
Once you own the Asterisk PBX, the path to live analysis:
|
|
343
|
+
|
|
344
|
+
```
|
|
345
|
+
Asterisk RTP stream
|
|
346
|
+
│ (audio chunks via AGI/AMI)
|
|
347
|
+
▼
|
|
348
|
+
Deepgram WebSocket
|
|
349
|
+
│ partial + final transcript events
|
|
350
|
+
▼
|
|
351
|
+
Igniter Actor (CallMonitorAgent)
|
|
352
|
+
│ detects key events in real-time
|
|
353
|
+
▼
|
|
354
|
+
ActionCable → Operator UI
|
|
355
|
+
│ "ZIP 77001 detected — pull nearby techs"
|
|
356
|
+
│ "Customer says 'Samsung washer' — show checklist"
|
|
357
|
+
│ "Sentiment dropping — offer discount code"
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Cost for real-time Deepgram:** same `$0.0077/min` (no surcharge vs REST).
|
|
361
|
+
Total with real-time: ~$212/month vs $81/month batch.
|
|
362
|
+
The premium pays for live operator assistance and reduced handle time.
|
|
363
|
+
|
|
364
|
+
### Agent sketch
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
class CallMonitorAgent < Igniter::Agent
|
|
368
|
+
on :transcript_chunk do |payload|
|
|
369
|
+
text = payload[:text]
|
|
370
|
+
next if text.length < 40 # wait for meaningful chunks
|
|
371
|
+
|
|
372
|
+
hints = ContextExtractor.call(partial_text: text)
|
|
373
|
+
next if hints.empty?
|
|
374
|
+
|
|
375
|
+
broadcast(:operator_hint,
|
|
376
|
+
call_id: payload[:call_id],
|
|
377
|
+
operator_id: payload[:operator_id],
|
|
378
|
+
hints: hints)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Configuration reference
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
Igniter::LLM.configure do |c|
|
|
389
|
+
# OpenAI (used by both chat executors and openai transcription provider)
|
|
390
|
+
c.openai.api_key = ENV["OPENAI_API_KEY"]
|
|
391
|
+
c.openai.base_url = "https://api.openai.com" # override for Azure/proxy
|
|
392
|
+
c.openai.timeout = 120
|
|
393
|
+
|
|
394
|
+
# Deepgram
|
|
395
|
+
c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
|
|
396
|
+
c.deepgram.timeout = 300 # pre-recorded audio; allow for large files
|
|
397
|
+
|
|
398
|
+
# AssemblyAI
|
|
399
|
+
c.assemblyai.api_key = ENV["ASSEMBLYAI_API_KEY"]
|
|
400
|
+
c.assemblyai.poll_interval = 3 # start polling after N seconds
|
|
401
|
+
c.assemblyai.poll_timeout = 600 # raise ProviderError if not done in time
|
|
402
|
+
end
|
|
403
|
+
```
|
data/examples/README.md
CHANGED
|
@@ -328,6 +328,43 @@ log_entries=1
|
|
|
328
328
|
done=true
|
|
329
329
|
```
|
|
330
330
|
|
|
331
|
+
### `companion/demo.rb`
|
|
332
|
+
|
|
333
|
+
Run:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
ruby examples/companion/demo.rb
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Shows:
|
|
340
|
+
|
|
341
|
+
- `Igniter::Application` — unified entry point with `config_file`, `configure`, `register`, `schedule`
|
|
342
|
+
- `compose` + `export` — four-stage pipeline (ASR → Intent → Chat → TTS) wired as one graph
|
|
343
|
+
- Mock executors — runs end-to-end without hardware or API keys
|
|
344
|
+
- Turn-by-turn interactive loop with session history
|
|
345
|
+
|
|
346
|
+
For real Ollama inference:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
COMPANION_REAL_LLM=1 ruby examples/companion/demo.rb
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Expected output per turn:
|
|
353
|
+
|
|
354
|
+
```text
|
|
355
|
+
── Turn 1 ────────────────────────────────────────────
|
|
356
|
+
[ASR mock] → "Hello, are you there?"
|
|
357
|
+
[Intent mock] → question
|
|
358
|
+
[Chat mock] → "I'd need a moment to look that up..."
|
|
359
|
+
[TTS mock] → synthesising 76 chars
|
|
360
|
+
Heard: "Hello, are you there?"
|
|
361
|
+
Intent: question (92%)
|
|
362
|
+
Response: "I'd need a moment to look that up..."
|
|
363
|
+
Audio: 4328 chars (Base64 WAV)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
See [`companion/README.md`](companion/README.md) for distributed deployment (k3s), ESP32 setup, and real hardware instructions.
|
|
367
|
+
|
|
331
368
|
## Validation
|
|
332
369
|
|
|
333
370
|
These scripts are exercised by [example_scripts_spec.rb](/Users/alex/dev/hotfix/igniter/spec/igniter/example_scripts_spec.rb), so the documented commands and outputs stay aligned with the code.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# examples/consensus.rb
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates Igniter::Consensus — a Raft-inspired consensus cluster.
|
|
6
|
+
#
|
|
7
|
+
# Two APIs are shown:
|
|
8
|
+
#
|
|
9
|
+
# High-level Cluster.start / write / read / read_contract
|
|
10
|
+
# Low-level Raw Igniter::Contract with Consensus executors (BidAuction)
|
|
11
|
+
#
|
|
12
|
+
# The Raft protocol (leader election, log replication, quorum commits) is
|
|
13
|
+
# fully encapsulated inside Igniter::Consensus::Node. Users only interact
|
|
14
|
+
# with Cluster and, optionally, a custom StateMachine subclass.
|
|
15
|
+
#
|
|
16
|
+
# Run: bundle exec ruby examples/consensus.rb
|
|
17
|
+
|
|
18
|
+
require "igniter/consensus"
|
|
19
|
+
|
|
20
|
+
puts "=" * 62
|
|
21
|
+
puts " Igniter::Consensus Demo (5-node Raft cluster)"
|
|
22
|
+
puts "=" * 62
|
|
23
|
+
|
|
24
|
+
NODES = %i[n1 n2 n3 n4 n5].freeze
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
# [1] Start cluster with the default KV state machine
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
puts "\n[1] Starting #{NODES.size}-node cluster"
|
|
30
|
+
|
|
31
|
+
cluster = Igniter::Consensus::Cluster.start(nodes: NODES)
|
|
32
|
+
puts " Nodes: #{NODES.join(", ")}"
|
|
33
|
+
puts " Quorum needed: #{cluster.quorum_size}/#{NODES.size}"
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
# [2] Wait for a leader — Cluster#wait_for_leader polls until election completes
|
|
37
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
puts "\n[2] Waiting for leader election..."
|
|
39
|
+
|
|
40
|
+
leader_ref = cluster.wait_for_leader
|
|
41
|
+
puts " Leader: #{leader_ref.state[:node_id]} term=#{leader_ref.state[:term]}"
|
|
42
|
+
|
|
43
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
# [3] Writes — Cluster#write dispatches to the leader
|
|
45
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
puts "\n[3] Writing to consensus log"
|
|
47
|
+
|
|
48
|
+
cluster.write(key: :price, value: 99)
|
|
49
|
+
cluster.write(key: :available, value: true)
|
|
50
|
+
sleep 0.5 # allow replication
|
|
51
|
+
|
|
52
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# [4] Cluster status via Cluster#status
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
puts "\n[4] Cluster state after replication"
|
|
56
|
+
puts " #{"node".ljust(5)} #{"role".ljust(10)} #{"term".ljust(5)} " \
|
|
57
|
+
"#{"log".ljust(4)} #{"ci".ljust(4)} state_machine"
|
|
58
|
+
puts " " + "-" * 58
|
|
59
|
+
|
|
60
|
+
cluster.status.each do |s|
|
|
61
|
+
puts " #{s[:node_id].to_s.ljust(5)} #{s[:role].to_s.ljust(10)} " \
|
|
62
|
+
"#{s[:term].to_s.ljust(5)} #{s[:log_size].to_s.ljust(4)} " \
|
|
63
|
+
"#{s[:commit_index].to_s.ljust(4)} #{s[:state_machine].inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
# [5] ReadQuery contract — declarative graph: find_leader → read_value
|
|
68
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
puts "\n[5] ReadQuery contract: reading :price"
|
|
70
|
+
|
|
71
|
+
q = cluster.read_contract(key: :price)
|
|
72
|
+
q.resolve_all
|
|
73
|
+
puts " :price=#{q.result.value}"
|
|
74
|
+
|
|
75
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
# [6] Leader crash → automatic failover
|
|
77
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
old_leader_id = leader_ref.state[:node_id]
|
|
79
|
+
puts "\n[6] Crashing leader #{old_leader_id}..."
|
|
80
|
+
|
|
81
|
+
Igniter::Registry.find(old_leader_id)&.kill
|
|
82
|
+
Igniter::Registry.unregister(old_leader_id)
|
|
83
|
+
|
|
84
|
+
surviving_ids = NODES.reject { |n| n == old_leader_id }
|
|
85
|
+
surviving = Igniter::Consensus::Cluster.new(nodes: surviving_ids)
|
|
86
|
+
|
|
87
|
+
puts " Waiting for new election..."
|
|
88
|
+
new_leader_ref = surviving.wait_for_leader
|
|
89
|
+
puts " New leader: #{new_leader_ref.state[:node_id]} term=#{new_leader_ref.state[:term]}"
|
|
90
|
+
|
|
91
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
# [7] Post-failover write + read via ReadQuery
|
|
93
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
puts "\n[7] Write after failover"
|
|
95
|
+
|
|
96
|
+
surviving.write(key: :price, value: 150)
|
|
97
|
+
sleep 0.4
|
|
98
|
+
|
|
99
|
+
q2 = surviving.read_contract(key: :price)
|
|
100
|
+
q2.resolve_all
|
|
101
|
+
puts " :price after failover = #{q2.result.value}"
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
# [8] Custom state machine — counter with typed commands
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
puts "\n[8] Custom state machine (counter)"
|
|
107
|
+
|
|
108
|
+
class CounterMachine < Igniter::Consensus::StateMachine
|
|
109
|
+
apply :increment do |state, cmd|
|
|
110
|
+
state.merge(cmd[:key] => (state[cmd[:key]] || 0) + cmd[:by])
|
|
111
|
+
end
|
|
112
|
+
apply :reset do |state, cmd|
|
|
113
|
+
state.merge(cmd[:key] => 0)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
counter_cluster = Igniter::Consensus::Cluster.start(
|
|
118
|
+
nodes: %i[cx1 cx2 cx3],
|
|
119
|
+
state_machine: CounterMachine,
|
|
120
|
+
)
|
|
121
|
+
counter_cluster.wait_for_leader
|
|
122
|
+
counter_cluster.write(type: :increment, key: :page_views, by: 100)
|
|
123
|
+
counter_cluster.write(type: :increment, key: :page_views, by: 250)
|
|
124
|
+
sleep 0.4
|
|
125
|
+
puts " page_views = #{counter_cluster.read(:page_views)}"
|
|
126
|
+
counter_cluster.stop!
|
|
127
|
+
|
|
128
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
# [9] BidAuction — parallel bid submission with durable consensus log
|
|
130
|
+
#
|
|
131
|
+
# Key Igniter properties:
|
|
132
|
+
# 1. bid1/bid2/bid3 have no mutual deps → thread_pool submits them concurrently
|
|
133
|
+
# 2. winner depends on all three → runs only after every bid is logged
|
|
134
|
+
# 3. Same SubmitBid executor reused for all three bids (captures dep name via **)
|
|
135
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
puts "\n[9] BidAuction — three vendors submit bids in parallel"
|
|
137
|
+
|
|
138
|
+
class SubmitBid < Igniter::Executor
|
|
139
|
+
# Called with: cluster: + one named bid dep (vendor1_bid / vendor2_bid / …).
|
|
140
|
+
# The dep name differs per compute node; ** captures whichever is passed.
|
|
141
|
+
def call(cluster:, **bid_kwarg)
|
|
142
|
+
bid = bid_kwarg.values.first # { vendor_id:, price: }
|
|
143
|
+
ref = cluster.leader
|
|
144
|
+
raise Igniter::ResolutionError, "No leader — cannot submit bid" unless ref
|
|
145
|
+
ref.send(:client_write, command: { key: :"bid_#{bid[:vendor_id]}", value: bid[:price] })
|
|
146
|
+
bid
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class SelectWinner < Igniter::Executor
|
|
151
|
+
def call(bid1:, bid2:, bid3:)
|
|
152
|
+
[bid1, bid2, bid3].min_by { |b| b[:price] }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
class BidAuction < Igniter::Contract
|
|
157
|
+
runner :thread_pool, pool_size: 3 # bid1, bid2, bid3 run concurrently
|
|
158
|
+
|
|
159
|
+
define do
|
|
160
|
+
input :cluster
|
|
161
|
+
input :vendor1_bid # { vendor_id: String, price: Float }
|
|
162
|
+
input :vendor2_bid
|
|
163
|
+
input :vendor3_bid
|
|
164
|
+
|
|
165
|
+
compute :bid1, with: [:cluster, :vendor1_bid], call: SubmitBid
|
|
166
|
+
compute :bid2, with: [:cluster, :vendor2_bid], call: SubmitBid
|
|
167
|
+
compute :bid3, with: [:cluster, :vendor3_bid], call: SubmitBid
|
|
168
|
+
|
|
169
|
+
compute :winner, with: [:bid1, :bid2, :bid3], call: SelectWinner
|
|
170
|
+
|
|
171
|
+
output :winner
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
auction = BidAuction.new(
|
|
176
|
+
cluster: surviving,
|
|
177
|
+
vendor1_bid: { vendor_id: "alpha", price: 45.00 },
|
|
178
|
+
vendor2_bid: { vendor_id: "betacor", price: 38.50 },
|
|
179
|
+
vendor3_bid: { vendor_id: "gamma", price: 52.00 },
|
|
180
|
+
)
|
|
181
|
+
auction.resolve_all
|
|
182
|
+
winner = auction.result.winner
|
|
183
|
+
puts " Winner: vendor=#{winner[:vendor_id]} price=$#{"%.2f" % winner[:price]}"
|
|
184
|
+
|
|
185
|
+
sleep 0.4
|
|
186
|
+
puts " Bids in consensus log:"
|
|
187
|
+
surviving.status.each do |s|
|
|
188
|
+
bids = s[:state_machine].select { |k, _| k.to_s.start_with?("bid_") }
|
|
189
|
+
.map { |k, v| "#{k}=$#{"%.2f" % v}" }
|
|
190
|
+
.join(" ")
|
|
191
|
+
puts " #{s[:node_id].to_s.ljust(5)} #{s[:role].to_s.ljust(10)} #{bids}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
# [10] Quorum failure — Raft's CP safety guarantee
|
|
196
|
+
#
|
|
197
|
+
# With only 2/5 nodes alive (< quorum 3), no leader can be elected.
|
|
198
|
+
# The cluster becomes unavailable rather than returning stale/inconsistent data.
|
|
199
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
puts "\n[10] Quorum failure: Raft's CP guarantee"
|
|
201
|
+
|
|
202
|
+
# Identify the current leader among surviving nodes, then kill it plus one follower
|
|
203
|
+
minority_ids = surviving_ids.reject { |n|
|
|
204
|
+
ref = Igniter::Registry.find(n)
|
|
205
|
+
ref&.alive? && ref.state[:role] == :leader
|
|
206
|
+
}.first(2)
|
|
207
|
+
|
|
208
|
+
(surviving_ids - minority_ids).each do |nid|
|
|
209
|
+
Igniter::Registry.find(nid)&.kill rescue nil
|
|
210
|
+
Igniter::Registry.unregister(nid) rescue nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
puts " Surviving: #{minority_ids.join(", ")} (quorum needs #{cluster.quorum_size}/#{NODES.size})"
|
|
214
|
+
puts " Waiting — no leader should be elected..."
|
|
215
|
+
sleep Igniter::Consensus::ELECTION_TIMEOUT_BASE +
|
|
216
|
+
Igniter::Consensus::ELECTION_TIMEOUT_JITTER + 0.3
|
|
217
|
+
|
|
218
|
+
minority_roles = minority_ids.map { |n|
|
|
219
|
+
ref = Igniter::Registry.find(n)
|
|
220
|
+
ref&.alive? ? "#{n}:#{ref.state[:role]}" : "#{n}:dead"
|
|
221
|
+
}
|
|
222
|
+
puts " States: #{minority_roles.join(" ")}"
|
|
223
|
+
|
|
224
|
+
minority_cluster = Igniter::Consensus::Cluster.new(nodes: minority_ids)
|
|
225
|
+
puts " ConsensusQuery with #{minority_ids.size}/#{NODES.size} nodes:"
|
|
226
|
+
begin
|
|
227
|
+
minority_cluster.read_contract(key: :price).resolve_all
|
|
228
|
+
puts " UNEXPECTED: query succeeded"
|
|
229
|
+
rescue Igniter::Error => e
|
|
230
|
+
puts " → #{e.class.name.split("::").last}: #{e.message}"
|
|
231
|
+
puts " (correct — cluster is unavailable, not returning stale data)"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
# Cleanup
|
|
236
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
minority_ids.each { |n| Igniter::Registry.find(n)&.stop(timeout: 2) rescue nil }
|
|
238
|
+
Igniter::Registry.clear
|
|
239
|
+
puts "\nDone."
|