3c233854f946816f9a1b0664c05eaf38c9fcf82b

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Split ASR client to a byte-based API and extract shared startup bootstrap

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index 7af270da27f5c5dd67a0a2334b0fc5d8f07e8440..02057132812065acb68a653de824856b4c73934a 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -1,20 +1,16 @@
  6 package main
  7 
  8 import (
  9-	"context"
 10 	"fmt"
 11-	"net/http"
 12 	"os"
 13 	"time"
 14 
 15 	"github.com/charmbracelet/bubbletea"
 16 
 17 	"japanese/internal/adapters"
 18-	"japanese/internal/availability"
 19+	"japanese/internal/bootstrap"
 20 	"japanese/internal/config"
 21 	"japanese/internal/game"
 22-	"japanese/internal/llm"
 23-	"japanese/internal/scenario"
 24 	"japanese/internal/stt"
 25 	"japanese/internal/tts"
 26 	"japanese/internal/ui"
 27@@ -23,70 +19,20 @@ import (
 28 // recordCap bounds a single push-to-talk capture. It is passed to the recorder
 29 // (which enforces it) and to the UI (which shows it as the cap indicator).
 30 const recordCap = 15 * time.Second
 31-const requestTimeout = 10 * time.Second
 32-const llmReadyTimeout = 30 * time.Second
 33-const warmupTimeout = 30 * time.Second
 34 
 35-// llama-server slot assignments and the capture command are fixed for this
 36-// deployment; they are not user-configurable. One slot per prompt family keeps
 37-// each system prompt cached in the server's unified KV pool; scratch questions
 38-// and flashcards share a slot since both are rare one-shot side tasks.
 39-const (
 40-	gameSlot       = 0
 41-	judgeSlot      = 1
 42-	compactionSlot = 2
 43-	sheetSlot      = 3
 44-	scratchSlot    = 4
 45-)
 46+// The capture command is fixed for this deployment; it is not user-configurable.
 47 const recordCommand = "arecord"
 48 
 49 func main() {
 50 	cfg := config.ParseConfig()
 51 
 52-	sc, err := scenario.Load(cfg.Scenario)
 53-	if err != nil {
 54-		fatalf("load scenario: %v", err)
 55-	}
 56-
 57-	hc := &http.Client{Timeout: requestTimeout}
 58-	gameClient := newLLMClient(cfg, hc, gameSlot)
 59-	judgeClient := newLLMClient(cfg, hc, judgeSlot)
 60-	compactionClient := newLLMClient(cfg, hc, compactionSlot)
 61-	sheetClient := newLLMClient(cfg, hc, sheetSlot)
 62-	scratchClient := newLLMClient(cfg, hc, scratchSlot)
 63-
 64-	if err := availability.CheckAudio(hc, cfg.AudioConfig.AudioBaseURL, []string{tts.ModelName, stt.ASRModelName}); err != nil {
 65-		fmt.Fprintf(os.Stderr, "jp: audio service unavailable: %v\n", err)
 66-	}
 67-
 68-	readyCtx, cancelReady := context.WithTimeout(context.Background(), llmReadyTimeout)
 69-	if err := gameClient.WaitReady(readyCtx); err != nil {
 70-		fmt.Fprintf(os.Stderr, "jp: LLM service not ready: %v\n", err)
 71-	}
 72-	cancelReady()
 73-
 74-	warmups := []struct {
 75-		client *llm.Client
 76-		prompt string
 77-	}{
 78-		{gameClient, llm.GameSystemPrompt(sc.Brief)},
 79-		{judgeClient, llm.JudgeSystemPrompt()},
 80-		{compactionClient, llm.CompactionPrompt()},
 81-		{sheetClient, llm.SheetSystemPrompt()},
 82-	}
 83-	ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
 84-	for _, w := range warmups {
 85-		if err = w.client.Warmup(ctx, []string{w.prompt}); err != nil {
 86-			break
 87-		}
 88-	}
 89-	cancel()
 90+	svc, err := bootstrap.New(cfg)
 91 	if err != nil {
 92-		fmt.Fprintf(os.Stderr, "jp: LLM warmup failed: %v\n", err)
 93+		fatalf("%v", err)
 94 	}
 95 
 96-	state := game.NewState(sc.Brief)
 97-	orch := buildOrchestrator(cfg, state, hc, gameClient, judgeClient, compactionClient, sheetClient, scratchClient)
 98+	state := game.NewState(svc.Brief)
 99+	orch := buildOrchestrator(svc, state)
100 
101 	m := ui.NewModel(state, orch, int(recordCap.Seconds()))
102 	if _, err := tea.NewProgram(m).Run(); err != nil {
103@@ -94,32 +40,26 @@ func main() {
104 	}
105diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
106index cf3d96d3b84cd361455a852529d5c823ba87b2b8..03508667b35a6778cfe5c0af3f5665db12f4f4c4 100644
107--- a/internal/adapters/speech.go
108+++ b/internal/adapters/speech.go
109@@ -4,6 +4,7 @@ import (
110 	"context"
111 	"errors"
112 	"fmt"
113+	"os"
114 	"sync"
115 
116 	"japanese/internal/stt"
117@@ -37,7 +38,14 @@ func (s *SpeechInput) End(ctx context.Context) (string, error) {
118 	if err != nil {
119 		return "", fmt.Errorf("mic: %w", err)
120 	}
121-	raw, err := s.ASR.Transcribe(ctx, path)
122+
123+	data, err := os.ReadFile(path)
124+	if err != nil {
125+		return "", fmt.Errorf("mic: read recording %s: %w", path, err)
126+	}
127+	defer func() { _ = os.Remove(path) }()
128+
129+	raw, err := s.ASR.TranscribeBytes(ctx, data)
130 	if err != nil {
131 		return "", err
132 	}
133diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
134new file mode 100644
135index 0000000000000000000000000000000000000000..608e3dc9bb7662b581fd4e32bc0aa1f0e2e42aae
136--- /dev/null
137+++ b/internal/bootstrap/bootstrap.go
138@@ -0,0 +1,113 @@
139+// Package bootstrap builds the service clients shared by the jp entry points:
140+// scenario load, one LLM client per prompt slot, the audio availability check,
141+// the LLM readiness wait, and the prompt warmup. Warnings (unavailable audio,
142+// not-ready LLM, failed warmup) print to stderr; only a bad scenario file is
143+// returned as an error.
144+package bootstrap
145+
146+import (
147+	"context"
148+	"fmt"
149+	"net/http"
150+	"os"
151+	"time"
152+
153+	"japanese/internal/availability"
154+	"japanese/internal/config"
155+	"japanese/internal/llm"
156+	"japanese/internal/scenario"
157+	"japanese/internal/stt"
158+	"japanese/internal/tts"
159+)
160+
161+const (
162+	requestTimeout  = 10 * time.Second
163+	llmReadyTimeout = 30 * time.Second
164+	warmupTimeout   = 30 * time.Second
165+
166+	// llama-server slot assignments are fixed for this deployment; they are not
167+	// user-configurable. One slot per prompt family keeps each system prompt
168+	// cached in the server's unified KV pool; scratch questions and flashcards
169+	// share a slot since both are rare one-shot side tasks.
170+	gameSlot       = 0
171+	judgeSlot      = 1
172+	compactionSlot = 2
173+	sheetSlot      = 3
174+	scratchSlot    = 4
175+)
176+
177+// Services is the set of shared clients one jp entry point builds at startup.
178+type Services struct {
179+	Cfg        *config.Config
180+	Brief      string
181+	HTTP       *http.Client
182+	Game       *llm.Client
183+	Judge      *llm.Client
184+	Compaction *llm.Client
185+	Sheet      *llm.Client
186+	Scratch    *llm.Client
187+}
188+
189+// New loads the scenario, builds one LLM client per slot, checks audio
190+// availability, waits for the game client, and warms up the prompt slots.
191+func New(cfg *config.Config) (*Services, error) {
192+	sc, err := scenario.Load(cfg.Scenario)
193+	if err != nil {
194+		return nil, fmt.Errorf("load scenario: %w", err)
195+	}
196+
197+	hc := &http.Client{Timeout: requestTimeout}
198+	gameClient := newLLMClient(cfg, hc, gameSlot)
199+	judgeClient := newLLMClient(cfg, hc, judgeSlot)
200+	compactionClient := newLLMClient(cfg, hc, compactionSlot)
201+	sheetClient := newLLMClient(cfg, hc, sheetSlot)
202+	scratchClient := newLLMClient(cfg, hc, scratchSlot)
203+
204+	if err := availability.CheckAudio(hc, cfg.AudioConfig.AudioBaseURL, []string{tts.ModelName, stt.ASRModelName}); err != nil {
205+		fmt.Fprintf(os.Stderr, "jp: audio service unavailable: %v\n", err)
206+	}
207+
208+	readyCtx, cancelReady := context.WithTimeout(context.Background(), llmReadyTimeout)
209+	if err := gameClient.WaitReady(readyCtx); err != nil {
210+		fmt.Fprintf(os.Stderr, "jp: LLM service not ready: %v\n", err)
211+	}
212+	cancelReady()
213+
214+	warmups := []struct {
215+		client *llm.Client
216+		prompt string
217+	}{
218+		{gameClient, llm.GameSystemPrompt(sc.Brief)},
219+		{judgeClient, llm.JudgeSystemPrompt()},
220+		{compactionClient, llm.CompactionPrompt()},
221+		{sheetClient, llm.SheetSystemPrompt()},
222+	}
223+	ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
224+	defer cancel()
225+	var warmErr error
226+	for _, w := range warmups {
227+		if warmErr = w.client.Warmup(ctx, []string{w.prompt}); warmErr != nil {
228+			break
229+		}
230+	}
231+	if warmErr != nil {
232+		fmt.Fprintf(os.Stderr, "jp: LLM warmup failed: %v\n", warmErr)
233+	}
234+
235+	return &Services{
236+		Cfg:        cfg,
237+		Brief:      sc.Brief,
238diff --git a/internal/stt/asr.go b/internal/stt/asr.go
239index a5f316e433309e72537dba6f430edb714cfb9fee..07f31562744b7df7f5caeab08d05f722f9a8ccbe 100644
240--- a/internal/stt/asr.go
241+++ b/internal/stt/asr.go
242@@ -10,7 +10,6 @@ import (
243 	"io"
244 	"mime/multipart"
245 	"net/http"
246-	"os"
247 	"strings"
248 )
249 
250@@ -37,17 +36,10 @@ func NewASRClient(baseURL string, hc *http.Client) *ASRClient {
251 	return &ASRClient{URL: strings.TrimRight(baseURL, "/") + "/v1/audio/transcriptions", HTTP: hc}
252 }
253 
254-// Transcribe uploads the WAV at wavPath and returns the recognized text. The
255-// file is removed after the request completes (success or failure). Only the
256-// given game-created path is ever deleted.
257-func (c *ASRClient) Transcribe(ctx context.Context, wavPath string) (string, error) {
258-	data, err := os.ReadFile(wavPath)
259-	if err != nil {
260-		return "", fmt.Errorf("stt: read recording %s: %w", wavPath, err)
261-	}
262-	defer func() { _ = os.Remove(wavPath) }()
263-
264-	body, contentType, err := encodeMultipart(data)
265+// TranscribeBytes sends the WAV bytes to the transcriptions endpoint and
266+// returns the recognized text.
267+func (c *ASRClient) TranscribeBytes(ctx context.Context, wav []byte) (string, error) {
268+	body, contentType, err := encodeMultipart(wav)
269 	if err != nil {
270 		return "", fmt.Errorf("stt: encode multipart: %w", err)
271 	}