Compare commits

...

4 Commits

Author SHA1 Message Date
claude cc32c2c4ab Wire the transcription seam beside the model seam (V-486)
sttSeam is modelSeam for audio and sits at the same place in wireVoice, so
the voice path and the meeting recorder share one transcriber as they
always have.

A box with no workstation.stt block behaves byte-for-byte as it did before
this existed: the floor is handed back untouched and nothing probes. An
empty URL is normalised to no block at all, the way the model block already
works.

Health defaults to the URL's origin rather than the URL itself, because the
transcribe endpoint names a path and appending would ask for
/transcribe/health. A block with no token logs once that anything on the
LAN can post audio to that port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:42:15 +04:00
claude a1e97c94ac The workstation transcribes, homesrv is the floor (V-486)
Same arrangement as llm.Pair and for the same reason. The microphone is at
workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4%
WER in Russian against 27.5% for the ggml-small.bin homesrv loads. The
workstation is never assumed up: it sleeps, and the card is often held.

Admission is a cached atomic written only by the prober, so no voice turn
ever waits on a machine that may be asleep.

Speech-to-text has only the silent half of the degradation rule. A worse
transcript is still a turn, so there is nothing to name a gap about and
Transcribe always falls back. That is the whole difference from llm.Pair,
which also carries CompleteRemote for callers that must refuse instead. A
remote that dies mid-request corrects the cache and falls back in the same
turn, which is what TestPairFallsBackWhenRemoteFails pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:42:05 +04:00
claude c7f59e48f4 CrisperWhisper reads audio over HTTP, not a socket (V-486)
mavsttd is whisper.cpp linked into a Go daemon and reached over a unix
socket. CrisperWhisper 2.0 cannot be reached that way. whisper.cpp derives
its language count from the vocabulary size, and CW2's 51897 tokens shift
seven special token ids, so it never loads at all.

So it runs under transformers on workpc and this is the client. Same
stt.Transcriber interface and one method, a second transport rather than a
second seam. The body is the PCM itself, because a minute of 16kHz mono is
under 2MB raw and the format is fixed by audio.PCM16kMono.

Audio is the most sensitive thing that crosses this seam, so the client
carries a bearer token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:41:55 +04:00
kami 50c6637c1b Merge pull request 'The usage harness cannot read the query source badge' (#205) from task/662-usage-harness-source-badge into master 2026-08-08 19:59:14 +02:00
7 changed files with 646 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/stt"
)
// A box with no workstation.stt block transcribes exactly as it did before the
// seam existed: the floor is handed back untouched, and nothing probes.
func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) {
floor := stt.NewStub()
got, pair := sttSeam(&config.Config{}, floor)
if pair != nil {
t.Fatal("no block must build no pair")
}
if got != stt.Transcriber(floor) {
t.Fatal("no block must hand back the floor itself")
}
}
func TestSttSeamPrefersTheWorkstation(t *testing.T) {
cfg := &config.Config{Workstation: &config.WorkstationConfig{
URL: "http://127.0.0.1:1",
Stt: &config.WorkstationSttConfig{
URL: "http://127.0.0.1:2/transcribe",
Health: "http://127.0.0.1:2/health",
},
}}
got, pair := sttSeam(cfg, stt.NewStub())
if pair == nil {
t.Fatal("a configured block must build a pair")
}
defer pair.Stop()
if got != stt.Transcriber(pair) {
t.Fatal("the pair is what callers must transcribe through")
}
// Nothing answers on port 2, so the seam is the floor until it does.
if pair.Available() {
t.Fatal("an unreachable workstation must not be available")
}
}
+47 -1
View File
@@ -53,7 +53,11 @@ type voiceWiring struct {
// unless a `workstation` block names an address. Held here only so the
// prober is stopped on shutdown; callers were handed it at build time.
pair *llm.Pair
mcp *mcpWiring
// sttPair — CrisperWhisper 2.0 on the workstation with mavsttd as the
// floor, nil unless the `workstation.stt` block names an address. Held for
// the same reason as pair: to stop its prober on shutdown.
sttPair *stt.Pair
mcp *mcpWiring
// home — the Home Assistant client, nil unless the `smarthome` block is
// enabled (Vikunja #256). Its devices land in the same allowlist as every
// other act, so nothing else here has to know about it.
@@ -84,6 +88,9 @@ func (w *voiceWiring) close() {
if w.pair != nil {
w.pair.Stop()
}
if w.sttPair != nil {
w.sttPair.Stop()
}
w.mcp.close()
}
@@ -112,6 +119,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
} else {
transcriber = stt.NewStub()
}
transcriber, w.sttPair = sttSeam(cfg, transcriber)
w.transcriber = transcriber
// ----- tts (Stub in-process OR Remote) -----
@@ -366,6 +374,44 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
return pair, pair
}
// sttSeam builds the transcription seam the voice path and the meeting
// recorder share. It is modelSeam for audio and follows the same rule.
//
// With no `workstation.stt` block it hands back the floor untouched, which is
// today's deploy exactly. With one, it is an stt.Pair preferring CrisperWhisper
// 2.0 on workpc, which scores 10.4% WER in Russian against the floor's 27.5%
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
//
// Only the silent half of the degradation rule applies here. A worse transcript
// is still a turn, so there is nothing to name a gap about and the fallback is
// never spoken. That is why stt.Pair has no TranscribeRemote.
func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.Pair) {
if cfg.Workstation == nil || cfg.Workstation.Stt == nil {
return floor, nil
}
s := cfg.Workstation.Stt
lang := ""
if cfg.Voice != nil {
lang = cfg.Voice.Lang
if cfg.Voice.Stt != nil && cfg.Voice.Stt.Lang != "" {
lang = cfg.Voice.Stt.Lang
}
}
pair := stt.NewPair(
stt.NewHTTPTranscriber(s.URL, s.Token, lang, time.Duration(s.Timeout)),
floor,
s.Health,
time.Duration(s.Probe),
)
pair.Start(context.Background())
if s.Token == "" {
log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it")
}
log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor",
s.URL, time.Duration(s.Probe))
return pair, pair
}
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
if !enabled {
return nil
+75
View File
@@ -1,6 +1,7 @@
package config
import (
"net/url"
"strings"
"time"
)
@@ -35,12 +36,54 @@ type WorkstationConfig struct {
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
// the resident one, and a request that overruns falls back to the floor.
Timeout Duration `json:"timeout,omitempty"`
// Stt — CrisperWhisper 2.0 on the same machine, a separate service on its
// own port. Absent ⇒ every utterance goes to mavsttd, which is today.
Stt *WorkstationSttConfig `json:"stt,omitempty"`
}
// WorkstationSttConfig — speech-to-text on the workstation.
//
// It is a second service and not a second endpoint on mavgpud: whisper.cpp
// cannot load CrisperWhisper 2.0 at all, because it derives its language count
// from the vocabulary size and CW2's 51897 tokens shift seven special token
// ids. So CW2 runs under transformers, and this block addresses it.
//
// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for
// the ggml-small.bin homesrv loads
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
type WorkstationSttConfig struct {
// URL — the transcribe endpoint, e.g.
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
// to nil and mavsttd takes every turn.
URL string `json:"url,omitempty"`
// Health — the admission endpoint. Empty ⇒ the URL's origin + "/health".
// It answers 503 while the card is held, and that is the signal.
Health string `json:"health,omitempty"`
// Token — the bearer token the service checks. Audio is the most sensitive
// thing that crosses this seam, so a LAN deployment should set one. Write
// it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the
// way every other secret in this file is written.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
Probe Duration `json:"probe,omitempty"`
// Timeout — the per-request budget for one utterance. 0 ⇒
// DefaultWorkstationSttTimeout. A request that overruns falls back to
// mavsttd, which costs a worse transcript and not the turn.
Timeout Duration `json:"timeout,omitempty"`
}
// Workstation defaults, applied in normaliseWorkstation.
const (
DefaultWorkstationProbe = 15 * time.Second
DefaultWorkstationTimeout = 90 * time.Second
// One utterance, not one completion. A voice turn waits on this, so the
// budget is a few seconds and not a minute and a half.
DefaultWorkstationSttTimeout = 10 * time.Second
)
// normaliseWorkstation applies the block's defaults. No address, no preferred
@@ -63,4 +106,36 @@ func (c *Config) normaliseWorkstation() {
if w.Timeout <= 0 {
w.Timeout = Duration(DefaultWorkstationTimeout)
}
normaliseWorkstationStt(w)
}
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
// address, no remote: mavsttd then takes every utterance, which is today.
func normaliseWorkstationStt(w *WorkstationConfig) {
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
w.Stt = nil
}
if w.Stt == nil {
return
}
s := w.Stt
if strings.TrimSpace(s.Health) == "" {
s.Health = healthOrigin(s.URL)
}
if s.Probe <= 0 {
s.Probe = Duration(DefaultWorkstationProbe)
}
if s.Timeout <= 0 {
s.Timeout = Duration(DefaultWorkstationSttTimeout)
}
}
// healthOrigin derives the admission endpoint from the transcribe endpoint.
// The URL names a path, so appending to it would ask for /transcribe/health.
func healthOrigin(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return strings.TrimRight(raw, "/") + "/health"
}
return u.Scheme + "://" + u.Host + "/health"
}
+92
View File
@@ -0,0 +1,92 @@
package stt
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/kami/maven/internal/audio"
)
// HTTPTranscriber — speech-to-text on another host, over HTTP.
//
// mavsttd is whisper.cpp linked into a Go daemon and reached over a unix
// socket. CrisperWhisper 2.0 cannot be reached that way: whisper.cpp derives
// its language count from the vocabulary size, and CW2's 51897 tokens shift
// seven special token ids. It runs under transformers instead, as a service
// beside the model on workpc. See docs/evals/2026-08-09-crisperwhisper2-russian-wer.md.
//
// So this is the second transport for the same seam, not a second seam. The
// caller still sees stt.Transcriber and one method.
type HTTPTranscriber struct {
url string
token string
lang string
http *http.Client
}
// NewHTTPTranscriber builds the remote client. token may be empty for a
// service on a trusted socket, but audio is the most sensitive thing that
// crosses this seam, so a LAN deployment should always set one.
func NewHTTPTranscriber(url, token, lang string, timeout time.Duration) *HTTPTranscriber {
return &HTTPTranscriber{
url: url,
token: token,
lang: lang,
http: &http.Client{Timeout: timeout},
}
}
// ErrFormat — the audio is not the one canonical shape. Refused at the seam
// rather than sent to a model that expects something else.
var ErrFormat = errors.New("stt: audio is not 16kHz mono pcm_s16le")
type httpTranscript struct {
Text string `json:"text"`
Confidence float64 `json:"confidence"`
}
// Transcribe posts the raw PCM and reads back the text.
//
// The body is the PCM bytes themselves rather than JSON. A minute of 16kHz
// mono is under 2MB raw and about 2.6MB base64, and the format is fixed by
// audio.PCM16kMono, so a header carries it more cheaply than an envelope.
func (t *HTTPTranscriber) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) {
if !a.Format.IsValid() {
return "", 0, ErrFormat
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(a.Bytes))
if err != nil {
return "", 0, fmt.Errorf("stt: build request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Sample-Rate", strconv.Itoa(a.Format.SampleRate))
req.Header.Set("X-Channels", strconv.Itoa(a.Format.Channels))
req.Header.Set("X-Sample-Bits", strconv.Itoa(a.Format.SampleBits))
req.Header.Set("X-Language", t.lang)
if t.token != "" {
req.Header.Set("Authorization", "Bearer "+t.token)
}
resp, err := t.http.Do(req)
if err != nil {
return "", 0, fmt.Errorf("stt: post audio: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("stt: remote returned %d", resp.StatusCode)
}
var out httpTranscript
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", 0, fmt.Errorf("stt: decode transcript: %w", err)
}
return out.Text, out.Confidence, nil
}
var _ Transcriber = (*HTTPTranscriber)(nil)
+89
View File
@@ -0,0 +1,89 @@
package stt
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
func TestHTTPTranscriberSendsRawPCM(t *testing.T) {
t.Parallel()
var gotBody []byte
var gotHeader http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
gotHeader = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"text":"привет","confidence":0.82}`)
}))
defer srv.Close()
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("pcm-bytes")}
tr := NewHTTPTranscriber(srv.URL, "s3cret", "ru", 2*time.Second)
text, conf, err := tr.Transcribe(context.Background(), a)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text != "привет" || conf != 0.82 {
t.Fatalf("got %q %v", text, conf)
}
if string(gotBody) != "pcm-bytes" {
t.Fatalf("body should be the PCM itself, got %q", gotBody)
}
if got := gotHeader.Get("X-Sample-Rate"); got != strconv.Itoa(audio.PCM16kMono.SampleRate) {
t.Fatalf("X-Sample-Rate = %q", got)
}
if got := gotHeader.Get("X-Language"); got != "ru" {
t.Fatalf("X-Language = %q", got)
}
// Audio is the most sensitive thing crossing this seam.
if got := gotHeader.Get("Authorization"); got != "Bearer s3cret" {
t.Fatalf("Authorization = %q", got)
}
}
func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) {
t.Parallel()
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
_, _ = io.WriteString(w, `{"text":"x"}`)
}))
defer srv.Close()
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}
if _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a); err != nil {
t.Fatalf("Transcribe: %v", err)
}
if auth != "" {
t.Fatalf("Authorization should be absent, got %q", auth)
}
}
func TestHTTPTranscriberRefusesWrongFormat(t *testing.T) {
t.Parallel()
a := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}}
_, _, err := NewHTTPTranscriber("http://example.invalid", "", "ru", time.Second).Transcribe(context.Background(), a)
if !errors.Is(err, ErrFormat) {
t.Fatalf("want ErrFormat, got %v", err)
}
}
func TestHTTPTranscriberErrorsOnBadStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}
_, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a)
if err == nil {
t.Fatal("a 401 must be an error, so the Pair falls back")
}
}
+157
View File
@@ -0,0 +1,157 @@
package stt
import (
"context"
"errors"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/kami/maven/internal/audio"
)
// Pair — a preferred transcriber on the workstation, with mavsttd as the floor.
//
// Same arrangement as llm.Pair and for the same reason. The microphone is at
// workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4%
// WER in Russian against 27.5% for the ggml-small.bin homesrv loads
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). The workstation is
// never assumed up: it sleeps, and the card is often held by a training run.
//
// Speech-to-text has only the silent half of the degradation rule. A worse
// transcript is still a turn, and there is nothing to name a gap about, so
// Transcribe always falls back. That is the whole difference from llm.Pair,
// which also carries CompleteRemote for callers that must refuse instead.
type Pair struct {
remote Transcriber
floor Transcriber
// up — the cached admission answer, written only by the prober and read by
// every turn. A voice turn must never wait on a machine that may be asleep.
up atomic.Bool
health string
interval time.Duration
http *http.Client
stop chan struct{}
stopOnce sync.Once
}
const (
probeTimeout = 2 * time.Second
defaultProbeInterval = 15 * time.Second
)
// ErrNoFloor — a Pair was built with no local transcriber to fall back to. A
// configuration mistake: the floor is what makes the remote optional.
var ErrNoFloor = errors.New("stt: no floor transcriber")
// NewPair builds the two-transcriber arrangement. remote may be nil, which is
// the unconfigured deploy: every turn goes to the floor and nothing probes.
func NewPair(remote, floor Transcriber, health string, interval time.Duration) *Pair {
if interval <= 0 {
// The config normalises this, so a zero here is a caller that built the
// Pair directly. Panicking in a ticker is the wrong way to say so.
interval = defaultProbeInterval
}
return &Pair{
remote: remote,
floor: floor,
health: health,
interval: interval,
http: &http.Client{Timeout: probeTimeout},
stop: make(chan struct{}),
}
}
// Start begins probing. The first probe runs before the first tick, so a
// workstation that is already up serves the first utterance rather than the
// second. Safe with a nil remote.
func (p *Pair) Start(ctx context.Context) {
if p.remote == nil || p.health == "" {
return
}
go func() {
p.probe(ctx)
t := time.NewTicker(p.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-p.stop:
return
case <-t.C:
p.probe(ctx)
}
}
}()
}
// Stop ends the prober. Idempotent and safe from two goroutines.
func (p *Pair) Stop() {
p.stopOnce.Do(func() { close(p.stop) })
}
// Available reports whether the workstation will transcribe right now.
func (p *Pair) Available() bool {
return p.remote != nil && p.up.Load()
}
func (p *Pair) probe(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil)
if err != nil {
p.set(false)
return
}
resp, err := p.http.Do(req)
if err != nil {
p.set(false)
return
}
defer resp.Body.Close()
p.set(resp.StatusCode == http.StatusOK)
}
// set records the admission answer and logs only transitions. A machine that
// sleeps nightly would otherwise write one line per interval forever.
func (p *Pair) set(up bool) {
if p.up.Swap(up) == up {
return
}
if up {
log.Printf("stt: workstation transcriber available at %s", p.health)
} else {
log.Print("stt: workstation transcriber unavailable, falling back to mavsttd")
}
}
// Transcribe sends the audio to the workstation when it will take work, and to
// mavsttd otherwise. A remote that fails mid-request falls back too, because
// the admission answer is a cache and can be one interval out of date.
//
// Killing the remote mid-session must not drop the turn. That is the whole
// point of the floor, and it is what TestPairFallsBackWhenRemoteFails pins.
func (p *Pair) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) {
if p.floor == nil {
return "", 0, ErrNoFloor
}
if p.Available() {
text, conf, err := p.remote.Transcribe(ctx, a)
if err == nil {
log.Print("stt: transcribed on the workstation")
return text, conf, nil
}
// The cached answer was wrong. Correct it now rather than sending the
// next utterance into the same hole, then fall back.
p.set(false)
log.Printf("stt: workstation failed mid-request, falling back: %v", err)
}
return p.floor.Transcribe(ctx, a)
}
var _ Transcriber = (*Pair)(nil)
+143
View File
@@ -0,0 +1,143 @@
package stt
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
// scripted — a Transcriber that answers with a fixed text, or fails.
type scripted struct {
text string
err error
calls atomic.Int32
}
func (s *scripted) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
s.calls.Add(1)
if s.err != nil {
return "", 0, s.err
}
return s.text, 0.9, nil
}
func sample() audio.Audio {
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)}
}
// up builds a Pair whose admission answer is already true, without probing.
func up(remote, floor Transcriber) *Pair {
p := NewPair(remote, floor, "", time.Minute)
p.up.Store(true)
return p
}
func TestPairPrefersTheWorkstation(t *testing.T) {
t.Parallel()
remote := &scripted{text: "с рабочей станции"}
floor := &scripted{text: "с homesrv"}
text, _, err := up(remote, floor).Transcribe(context.Background(), sample())
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text != "с рабочей станции" {
t.Fatalf("want the remote transcript, got %q", text)
}
if floor.calls.Load() != 0 {
t.Fatalf("floor was called %d times, want 0", floor.calls.Load())
}
}
// The turn is what matters. A remote that dies mid-session must cost a worse
// transcript and nothing else. This is the V-486 bar.
func TestPairFallsBackWhenRemoteFails(t *testing.T) {
t.Parallel()
remote := &scripted{err: errors.New("connection refused")}
floor := &scripted{text: "с homesrv"}
p := up(remote, floor)
text, conf, err := p.Transcribe(context.Background(), sample())
if err != nil {
t.Fatalf("a failed remote must not fail the turn: %v", err)
}
if text != "с homesrv" {
t.Fatalf("want the floor transcript, got %q", text)
}
if conf != 0.9 {
t.Fatalf("want the floor confidence, got %v", conf)
}
if p.Available() {
t.Fatal("a failed request must correct the cached admission answer")
}
// The next utterance goes straight to the floor rather than into the
// same hole.
if _, _, err := p.Transcribe(context.Background(), sample()); err != nil {
t.Fatalf("second turn: %v", err)
}
if remote.calls.Load() != 1 {
t.Fatalf("remote called %d times, want 1", remote.calls.Load())
}
}
func TestPairWithNoRemoteIsTheFloor(t *testing.T) {
t.Parallel()
floor := &scripted{text: "с homesrv"}
p := NewPair(nil, floor, "", time.Minute)
p.Start(context.Background()) // no health url, so this is a no-op
if p.Available() {
t.Fatal("an unconfigured remote is never available")
}
text, _, err := p.Transcribe(context.Background(), sample())
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text != "с homesrv" {
t.Fatalf("want the floor transcript, got %q", text)
}
}
func TestPairWithNoFloorRefuses(t *testing.T) {
t.Parallel()
_, _, err := NewPair(nil, nil, "", time.Minute).Transcribe(context.Background(), sample())
if !errors.Is(err, ErrNoFloor) {
t.Fatalf("want ErrNoFloor, got %v", err)
}
}
func TestPairProbeReadsHealth(t *testing.T) {
t.Parallel()
var ok atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !ok.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
p := NewPair(&scripted{text: "remote"}, &scripted{text: "floor"}, srv.URL, time.Minute)
p.probe(context.Background())
if p.Available() {
t.Fatal("a 503 means the card is busy, so the workstation is not available")
}
ok.Store(true)
p.probe(context.Background())
if !p.Available() {
t.Fatal("a 200 means the workstation will take work")
}
}
func TestPairStopIsIdempotent(t *testing.T) {
t.Parallel()
p := NewPair(nil, &scripted{}, "", time.Minute)
p.Stop()
p.Stop()
}