3c233854f946816f9a1b0664c05eaf38c9fcf82b
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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 }