Prd/components/transcription-worker/transcriber.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
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.
2026-07-27 18:50:32 +02:00

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