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.
103 lines
4.1 KiB
Ruby
103 lines
4.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Transcriber — the LOCAL transcription fallback: drives the WhisperX CLI (transcribe.py, shipped in
|
|
# resources/whisper/; the running copy's directory is WHISPER_DIR) as a subprocess and maps its exit
|
|
# codes to an Event state. Standard library only. Production uses the hosted Whisper instead
|
|
# (remote_transcriber.rb, chosen when WHISPER_URL is set); this one runs when it isn't.
|
|
#
|
|
# Exit-code contract (from transcribe.py):
|
|
# 0 ok · 2 bad args/token · 3 no ffmpeg · 5 HF terms not accepted · 6 alignment failed
|
|
# 7 diarization failed · 130 Ctrl-C · (1/137/other = crash/OOM)
|
|
# We treat {2,3,5,6} as terminal config errors (don't retry — alert the operator), 0 as success
|
|
# (empty output = "no speech"), and everything else as retryable (transient I/O, OOM, timeout).
|
|
|
|
require "open3"
|
|
|
|
module Helpdesk
|
|
class Transcriber
|
|
CONFIG_ERRORS = [2, 3, 5, 6].freeze # terminal — re-running won't help
|
|
|
|
def initialize(whisper_dir: ENV["WHISPER_DIR"] || "/home/lucy/whisper",
|
|
python: nil, script: nil, env_file: nil, offline: true)
|
|
@dir = whisper_dir
|
|
@python = python || File.join(@dir, ".venv/bin/python")
|
|
@script = script || File.join(@dir, "transcribe.py")
|
|
@env_file = env_file || File.join(@dir, ".env")
|
|
@offline = offline # use the 7 GB cached models, no network (won't fight the OS sync)
|
|
end
|
|
|
|
# → { state:, retryable:, exit_code:, transcript:, stderr:, reason? }
|
|
# state ∈ transcribed | no_speech | config_error | failed
|
|
def transcribe(audio, model: "large-v2", min_speakers: 2, max_speakers: 3, timeout: nil, output: nil)
|
|
out = (output || "#{audio}.transcript.txt").to_s
|
|
errlog = "#{out}.stderr"
|
|
cmd = [@python, @script, audio.to_s, "-o", out,
|
|
"--model", model, "--min-speakers", min_speakers.to_s, "--max-speakers", max_speakers.to_s]
|
|
pid = Process.spawn(build_env, *cmd, pgroup: true, out: File::NULL, err: errlog)
|
|
code = wait_with_timeout(pid, timeout)
|
|
classify(code, out, errlog)
|
|
end
|
|
|
|
private
|
|
|
|
def build_env
|
|
env = {}
|
|
if File.exist?(@env_file)
|
|
File.foreach(@env_file) do |line|
|
|
next unless (m = line.strip.match(/\A([A-Z_][A-Z0-9_]*)=(.*)\z/))
|
|
env[m[1]] = m[2].gsub(/\A["']|["']\z/, "")
|
|
end
|
|
end
|
|
env["HF_HUB_OFFLINE"] = "1" if @offline
|
|
env
|
|
end
|
|
|
|
def wait_with_timeout(pid, timeout)
|
|
return Process.wait2(pid).last.exitstatus unless timeout
|
|
deadline = mono + timeout
|
|
loop do
|
|
_, st = Process.wait2(pid, Process::WNOHANG)
|
|
return st.exitstatus if st
|
|
if mono > deadline
|
|
(Process.kill("TERM", -Process.getpgid(pid)) rescue nil)
|
|
(Process.wait(pid) rescue nil)
|
|
return :timeout
|
|
end
|
|
sleep 0.25
|
|
end
|
|
end
|
|
|
|
def classify(code, out, errlog)
|
|
stderr = File.exist?(errlog) ? File.read(errlog) : ""
|
|
return fail_result(nil, stderr, "timeout") if code == :timeout
|
|
case code
|
|
when 0
|
|
text = File.exist?(out) ? File.read(out) : ""
|
|
if text.strip.empty?
|
|
{ state: "no_speech", retryable: false, exit_code: 0, transcript: "", stderr: stderr }
|
|
else
|
|
{ state: "transcribed", retryable: false, exit_code: 0, transcript: text, stderr: stderr }
|
|
end
|
|
when *CONFIG_ERRORS
|
|
{ state: "config_error", retryable: false, exit_code: code, transcript: nil, stderr: stderr }
|
|
else
|
|
fail_result(code, stderr, "exit #{code}")
|
|
end
|
|
end
|
|
|
|
def fail_result(code, stderr, reason)
|
|
{ state: "failed", retryable: true, exit_code: code, transcript: nil, stderr: stderr, reason: reason }
|
|
end
|
|
|
|
def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
end
|
|
end
|
|
|
|
# CLI: ruby transcriber.rb <audio> [model]
|
|
if $PROGRAM_NAME == __FILE__
|
|
audio = ARGV[0] or abort "usage: ruby transcriber.rb <audio> [model]"
|
|
r = Helpdesk::Transcriber.new.transcribe(audio, model: ARGV[1] || "large-v2")
|
|
warn "state=#{r[:state]} exit=#{r[:exit_code]} retryable=#{r[:retryable]}"
|
|
puts r[:transcript] if r[:transcript]
|
|
exit(r[:state] == "transcribed" || r[:state] == "no_speech" ? 0 : 1)
|
|
end
|