A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
178 lines
9.7 KiB
Ruby
178 lines
9.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Pipeline — composes the chain: audio → Transcriber → transcript → Summariser → result. A plain class
|
|
# (no job runner) so the whole thing runs and is tested offline. server.rb calls it off the request
|
|
# thread on a recording upload; test/test_pipeline.rb drives it with stubs.
|
|
#
|
|
# Design rule: the TRANSCRIPT is the valuable, durable artifact. If summarisation fails, the pipeline
|
|
# still succeeds (stage="transcribed") — the summary is advisory and can be retried/backfilled later.
|
|
|
|
require_relative "summariser"
|
|
require_relative "lib/helpdesk/glossary"
|
|
require_relative "lib/helpdesk/wav_split"
|
|
require_relative "lib/helpdesk/dual_transcript"
|
|
require "tmpdir"
|
|
require "fileutils"
|
|
require_relative File.join("..", "transcription-worker", "transcriber")
|
|
|
|
module Helpdesk
|
|
class Pipeline
|
|
# Speaker hints for a SINGLE-speaker channel. Not a claim about how many people are on it - see
|
|
# transcribe_best. Higher values segment more finely; these were measured, not guessed.
|
|
SEGMENT_MIN_SPEAKERS = 4
|
|
SEGMENT_MAX_SPEAKERS = 8
|
|
|
|
def initialize(transcriber: Transcriber.new, summariser: Summariser.new, wiki_retriever: nil)
|
|
@transcriber = transcriber
|
|
@summariser = summariser
|
|
@wiki = wiki_retriever # optional RAG: retrieves relevant wiki chunks per transcript
|
|
end
|
|
|
|
# Config-driven build: use the HOSTED Whisper (RemoteTranscriber) when WHISPER_URL is set,
|
|
# else the local WhisperX subprocess; load the wiki RAG index if present. Both transcribers
|
|
# satisfy the same transcribe(...) contract.
|
|
def self.build(summariser: Summariser.new)
|
|
url = ENV["WHISPER_URL"].to_s
|
|
transcriber =
|
|
if url.empty?
|
|
Transcriber.new
|
|
else
|
|
require_relative File.join("..", "transcription-worker", "remote_transcriber")
|
|
RemoteTranscriber.new(base_url: url)
|
|
end
|
|
new(transcriber: transcriber, summariser: summariser, wiki_retriever: load_wiki)
|
|
end
|
|
|
|
# Load the wiki RAG index if it exists + we have a key to embed the query. Nil = no grounding.
|
|
def self.load_wiki
|
|
path = ENV["WIKI_INDEX_PATH"] || File.join(__dir__, "wiki", "wiki_index.json")
|
|
key = ENV["OPENROUTER_API_KEY"].to_s
|
|
return nil if key.empty? || !File.exist?(path)
|
|
require_relative "wiki/embedder"
|
|
require_relative "wiki/wiki_index"
|
|
WikiIndex.new(embedder: Embedder.new(api_key: key)).load(path)
|
|
rescue => e
|
|
warn "[helpdesk] wiki index load failed: #{e.class}: #{e.message}"
|
|
nil
|
|
end
|
|
|
|
# → { ok:, stage:, transcript?:, summary?:, action_items?:, cost?:, error?:, transcription:, summary_meta?,
|
|
# context_digest?:, caller_facts?:, ledger_deltas?: }
|
|
# stage ∈ transcription(fail) | no_speech | transcribed(summary failed) | summarised
|
|
def process(audio, operator: nil, campaign: nil, ledger: [], model: "large-v2", timeout: nil)
|
|
t = transcribe_best(audio, model: model, timeout: timeout)
|
|
out = { transcription: { state: t[:state], retryable: t[:retryable], exit_code: t[:exit_code] } }
|
|
out[:channels] = t[:channels] if t[:channels]
|
|
|
|
unless %w[transcribed no_speech].include?(t[:state])
|
|
return out.merge(ok: false, stage: "transcription", error: t[:reason] || "transcription #{t[:state]}")
|
|
end
|
|
|
|
transcript = t[:transcript].to_s
|
|
# Repair domain words the transcriber cannot know. Whisper has never seen "yamt" and the hosted
|
|
# service takes no vocabulary hint, so it writes the sound it heard ("jamt"); better audio cannot
|
|
# fix that because nothing in the signal spells the word. Only distinctive terms are listed, and
|
|
# every substitution is logged - a transcript is a record of what a caller was told, so it must
|
|
# never be edited silently. See lib/helpdesk/glossary.rb (grep anchor: glossary-correct).
|
|
transcript, glossary_fixes = Glossary.correct(transcript)
|
|
unless glossary_fixes.empty?
|
|
out[:glossary_fixes] = glossary_fixes
|
|
warn "[helpdesk] glossary: #{glossary_fixes.map { |c| "#{c[:from]}->#{c[:to]}" }.join(', ')}"
|
|
end
|
|
out[:transcript] = transcript
|
|
return out.merge(ok: true, stage: "no_speech", summary: nil, action_items: []) if transcript.strip.empty?
|
|
|
|
s = @summariser.summarise(transcript, operator: operator, campaign: campaign,
|
|
wiki_context: wiki_context_for(transcript), ledger: ledger)
|
|
out[:summary_meta] = { ok: s[:ok], model: s[:model], cost: s[:cost], needs_review: s[:needs_review], fell_back: s[:fell_back] }
|
|
if s[:ok]
|
|
out.merge(ok: true, stage: "summarised", summary: s[:summary], action_items: s[:action_items],
|
|
suggested_position: s[:suggested_position], cost: s[:cost],
|
|
context_digest: s[:context_digest], caller_facts: s[:caller_facts],
|
|
ledger_deltas: s[:ledger_deltas] || [])
|
|
else
|
|
# keep the transcript; leave the summary for a retry/backfill (advisory, non-blocking)
|
|
out.merge(ok: true, stage: "transcribed", summary: nil, action_items: [], summary_error: s[:error] || "needs_review")
|
|
end
|
|
end
|
|
|
|
# When the phone recorded the two call legs as separate channels, transcribe each one on its own and
|
|
# merge them; otherwise transcribe the file as it stands. Splitting is not an optimisation, it is the
|
|
# only way the separation survives: Whisper resamples to mono before decoding, so handing it a stereo
|
|
# file puts both speakers back into one channel and discards what the phone captured.
|
|
#
|
|
# Every failure path falls back to transcribing the original file, because a correctly attributed
|
|
# transcript is a bonus and a transcript is not. grep anchor: dual-channel-transcribe.
|
|
def transcribe_best(audio, model:, timeout:)
|
|
return transcribe_single(audio, model: model, timeout: timeout) unless WavSplit.splittable?(audio)
|
|
|
|
dir = Dir.mktmpdir("helpdesk-legs")
|
|
left = File.join(dir, "operator.wav")
|
|
right = File.join(dir, "caller.wav")
|
|
begin
|
|
WavSplit.split(audio, left_path: left, right_path: right)
|
|
# Diarisation is used here as a SEGMENTER, not a classifier. Its speaker labels are discarded
|
|
# (we already know who owns each channel), but its turn boundaries are the only timestamps the
|
|
# service emits - so pinning a channel to one speaker, which is the truth, collapses it into a
|
|
# single block with one timestamp and leaves nothing to interleave on. Measured on a real call:
|
|
# 1 speaker gave 1 segment, 2 gave 4, and 4-8 gave 6, with the transcribed WORDS byte-identical
|
|
# at every setting. So asking for more speakers than exist costs nothing and buys the resolution
|
|
# the merge needs. A leg that fails falls back to the mixed file below.
|
|
l = @transcriber.transcribe(left, model: model, timeout: timeout,
|
|
min_speakers: SEGMENT_MIN_SPEAKERS, max_speakers: SEGMENT_MAX_SPEAKERS)
|
|
r = @transcriber.transcribe(right, model: model, timeout: timeout,
|
|
min_speakers: SEGMENT_MIN_SPEAKERS, max_speakers: SEGMENT_MAX_SPEAKERS)
|
|
unless l[:state] == "transcribed" && r[:state] == "transcribed"
|
|
warn "[helpdesk] dual-channel: a leg failed (left=#{l[:state]} right=#{r[:state]}), " \
|
|
"falling back to the mixed file"
|
|
return transcribe_single(audio, model: model, timeout: timeout)
|
|
end
|
|
unless DualTranscript.plausible?(l[:transcript], r[:transcript])
|
|
warn "[helpdesk] dual-channel: the channels do not look like two different speakers, " \
|
|
"falling back to the mixed file"
|
|
return transcribe_single(audio, model: model, timeout: timeout)
|
|
end
|
|
{ state: "transcribed", retryable: false, channels: 2,
|
|
transcript: DualTranscript.merge(left: l[:transcript], right: r[:transcript]) }
|
|
rescue => e
|
|
warn "[helpdesk] dual-channel split failed (#{e.class}: #{e.message}), using the mixed file"
|
|
transcribe_single(audio, model: model, timeout: timeout)
|
|
ensure
|
|
FileUtils.remove_entry(dir, true)
|
|
end
|
|
end
|
|
|
|
def transcribe_single(audio, model:, timeout:)
|
|
@transcriber.transcribe(audio, model: model, timeout: timeout)
|
|
end
|
|
|
|
# Retrieve the most relevant wiki chunks for this transcript as a context string (nil if no index).
|
|
# k=15 is the A/B sweet spot: enough recall to surface concrete specs, without the dilution that
|
|
# made k=40 answer more vaguely. The grounding is traceable — each action item cites its source page.
|
|
def wiki_context_for(transcript, k: 15)
|
|
return nil unless @wiki
|
|
chunks = @wiki.retrieve(transcript, k: k)
|
|
return nil if chunks.empty?
|
|
chunks.map { |c| "- [#{c[:title]}] #{c[:text][0, 600]}" }.join("\n")
|
|
rescue => e
|
|
warn "[helpdesk] wiki retrieve failed: #{e.class}: #{e.message}"
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
# CLI: [OPENROUTER_API_KEY=…] ruby pipeline.rb <audio> [model]
|
|
# Runs the REAL transcriber; summarises for real if a key is set, else reports the summary step as
|
|
# key-gated (the transcript still prints).
|
|
if $PROGRAM_NAME == __FILE__
|
|
audio = ARGV[0] or abort "usage: ruby pipeline.rb <audio> [model]"
|
|
r = Helpdesk::Pipeline.new.process(audio, model: ARGV[1] || "large-v2")
|
|
puts "stage: #{r[:stage]} ok: #{r[:ok]}"
|
|
puts "\n--- transcript ---\n#{r[:transcript]}" if r[:transcript]
|
|
puts "\n--- summary ---\n#{r[:summary]}" if r[:summary]
|
|
if r[:action_items]&.any?
|
|
puts "\n--- action items ---"; r[:action_items].each { |a| puts " • #{a['text']} [#{a['kind']}#{a['target'] ? " → #{a['target']}" : ''}]" }
|
|
end
|
|
puts "\n(summary step: #{r.dig(:summary_meta) || r[:summary_error] || 'n/a'})" unless r[:summary]
|
|
exit(r[:ok] ? 0 : 1)
|
|
end
|