Prd/components/transcription-worker/test_remote_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

134 lines
6.4 KiB
Ruby

# frozen_string_literal: true
# Zero-dependency test for RemoteTranscriber — a fake HTTP client simulates the hosted Whisper job
# lifecycle (submit -> poll -> download), so the whole flow is verified with no network/mTLS/models.
# Run: ruby components/transcription-worker/test_remote_transcriber.rb
require "json"
require_relative "remote_transcriber"
$pass = 0; $fail = 0
def ok(d); $pass += 1; puts " ok #{d}"; end
def bad(d, g = nil); $fail += 1; puts " FAIL #{d}#{g.nil? ? '' : " (#{g.inspect})"}"; end
def eq(a, b, d); a == b ? ok(d) : bad(d, a); end
def truthy(x, d); x ? ok(d) : bad(d, x); end
# Scriptable fake of RemoteTranscriber's client contract: post_multipart + get -> [code, body].
class FakeClient
attr_reader :fields, :file, :gets
def initialize(statuses: ["done"], transcript: "x", submit_code: 201, job_id: "job-1",
download_code: 200, status_code: 200)
@statuses = statuses.dup; @transcript = transcript
@submit_code = submit_code; @job_id = job_id
@download_code = download_code; @status_code = status_code; @gets = 0
end
def post_multipart(_path, file, fields)
@file = file; @fields = fields
[@submit_code, @job_id ? { job_id: @job_id }.to_json : "{}"]
end
def get(path)
@gets += 1
return [@download_code, @transcript] if path.end_with?("/download")
[@status_code, { status: (@statuses.shift || @statuses.last || "done") }.to_json]
end
end
def rt(client, **opts)
Helpdesk::RemoteTranscriber.new(base_url: "https://x/app", client: client, poll_interval: 0, **opts)
end
puts "happy path: submit -> running -> done -> download"
c = FakeClient.new(statuses: %w[running running done], transcript: "[Speaker 1]: Dobrý den.\n")
r = rt(c).transcribe("/tmp/call.wav")
eq(r[:state], "transcribed", "state=transcribed")
eq(r[:transcript], "[Speaker 1]: Dobrý den.\n", "transcript returned from download")
eq(r[:job_id], "job-1", "job_id carried through")
truthy(c.gets >= 4, "polled until done then downloaded")
puts "submit sends the right options"
eq(c.fields["language"], "cs", "language=cs")
eq(c.fields["model"], "large-v2", "model=large-v2 default")
eq(c.fields["min_speakers"], "2", "min_speakers=2")
eq(c.fields["max_speakers"], "3", "max_speakers=3")
eq(c.file, "/tmp/call.wav", "file path passed to submit")
puts "empty transcript -> no_speech"
r = rt(FakeClient.new(statuses: ["done"], transcript: " \n")).transcribe("/tmp/a.wav")
eq(r[:state], "no_speech", "blank download -> no_speech")
puts "remote error stage -> failed + retryable"
r = rt(FakeClient.new(statuses: %w[running error])).transcribe("/tmp/a.wav")
eq(r[:state], "failed", "error stage -> failed")
truthy(r[:retryable], "failed is retryable (transient)")
eq(r[:reason], "remote error", "reason names the remote stage")
puts "cancelled/interrupted -> failed retryable"
eq(rt(FakeClient.new(statuses: ["cancelled"])).transcribe("/tmp/a.wav")[:state], "failed", "cancelled -> failed")
puts "submit non-201 -> failed (no polling)"
c = FakeClient.new(submit_code: 400)
r = rt(c).transcribe("/tmp/a.wav")
eq(r[:state], "failed", "submit 400 -> failed")
eq(c.gets, 0, "did not poll after a failed submit")
puts "missing job_id -> failed"
eq(rt(FakeClient.new(job_id: nil)).transcribe("/tmp/a.wav")[:state], "failed", "no job_id -> failed")
puts "poll HTTP error -> failed"
eq(rt(FakeClient.new(status_code: 503, statuses: ["running"])).transcribe("/tmp/a.wav")[:state], "failed", "status 503 -> failed")
puts "download HTTP error -> failed"
eq(rt(FakeClient.new(statuses: ["done"], download_code: 500)).transcribe("/tmp/a.wav")[:state], "failed", "download 500 -> failed")
puts "poll timeout -> failed"
r = rt(FakeClient.new(statuses: %w[running running running running running])).transcribe("/tmp/a.wav", timeout: 0)
eq(r[:state], "failed", "never-terminal + timeout=0 -> failed")
eq(r[:reason], "poll timeout", "reason=poll timeout")
puts "drops into Pipeline as the transcriber (composition)"
require_relative "../backend/pipeline"
stub_summariser = Object.new
def stub_summariser.summarise(_t, **) = { ok: true, summary: "Shrnutí hovoru.", action_items: [], model: "stub", cost: 0.0 }
rtc = Helpdesk::RemoteTranscriber.new(base_url: "https://x/app", poll_interval: 0,
client: FakeClient.new(statuses: %w[running done], transcript: "[Speaker 1]: Ahoj.\n"))
res = Helpdesk::Pipeline.new(transcriber: rtc, summariser: stub_summariser).process("/tmp/a.wav")
eq(res[:stage], "summarised", "Pipeline(remote) -> summarised")
eq(res[:transcript], "[Speaker 1]: Ahoj.\n", "Pipeline carries the remote transcript")
eq(res[:summary], "Shrnutí hovoru.", "Pipeline attaches the summary")
# Scriptable poll fake: status_seq is a list of [http_code, status_string_or_nil] returned per status GET.
class SeqClient
attr_reader :gets
def initialize(status_seq:, transcript: "[Speaker 1]: Ahoj.\n", download_code: 200)
@seq = status_seq.dup; @transcript = transcript; @download_code = download_code; @gets = 0
end
def post_multipart(_p, _f, _fields) = [201, { job_id: "job-1" }.to_json]
def get(path)
@gets += 1
return [@download_code, @transcript] if path.end_with?("/download")
code, status = (@seq.shift || [200, "done"])
[code, status ? { status: status }.to_json : "upstream error"]
end
end
puts "poll resilience: a transient blip mid-poll is retried, not aborted"
c = SeqClient.new(status_seq: [[502, nil], [0, nil], [200, "running"], [200, "done"]])
r = rt(c).transcribe("/tmp/call.wav")
eq(r[:state], "transcribed", "transient 502 then client-error(0) during poll -> retried -> done")
eq(r[:transcript], "[Speaker 1]: Ahoj.\n", "recovered job still downloads the transcript")
puts "poll resilience: persistent upstream failure gives up within a bounded budget (not infinite)"
c = SeqClient.new(status_seq: Array.new(30) { [503, nil] })
r = rt(c).transcribe("/tmp/call.wav")
eq(r[:state], "failed", "persistent 503 -> failed (retryable) rather than looping forever")
truthy(c.gets <= Helpdesk::RemoteTranscriber::MAX_POLL_TRANSIENT + 2,
"gave up within MAX_POLL_TRANSIENT budget (#{c.gets} polls)")
puts "poll resilience: a definite 404 (job gone) stops immediately, no long retry"
c = SeqClient.new(status_seq: [[200, "running"], [404, nil]])
r = rt(c).transcribe("/tmp/call.wav")
eq(r[:state], "failed", "404 during poll -> failed immediately")
eq(c.gets, 2, "no retry storm after a definite 404")
puts "\n#{$pass} passed, #{$fail} failed"
exit($fail.zero? ? 0 : 1)