4438762999b7290fa5ea0aaa87611c678eb6001f
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/PLAN.md b/PLAN.md
2index 119a21b91732db7ba2482ce7b809cf246ecc8316..d03c04534970f6b218536cc5fd82b0af1e8ce4b6 100644
3--- a/PLAN.md
4+++ b/PLAN.md
5@@ -161,7 +161,7 @@ Do not create `internal/servers` or a `models/` directory. Model files and servi
6
7 ### Phase 2 — model and audio adapters
8 - [ ] 4. Implement `internal/llm`: chat-completions HTTP/SSE client, Qwen3 no-thinking request options, bounded history, NPC and judge prompt builders, and parsers for both strict contracts. Test against `httptest` servers.
9diff --git a/cmd/jp/main.go b/cmd/jp/main.go
10index 3728ca97e20ab89fb59b281a96fb7b5a2e679e9c..db386e59361cb5f90e80100bf80dd69deb48f47e 100644
11--- a/cmd/jp/main.go
12+++ b/cmd/jp/main.go
13@@ -98,14 +98,9 @@ func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.
14 npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
15 judgeModel := &adapters.JudgeModel{Client: llmClient}
16
17- romanizer, err := stt.NewRomanizer()
18- if err != nil {
19- fatalf("load romanizer: %v", err)
20- }
21 speechIn := &adapters.SpeechInput{
22 Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
23 Whisper: stt.NewWhisperClient(cfg.STTURL, cfg.STTLanguage, hc),
24- Romanize: romanizer,
25 }
26 speechOut := &adapters.SpeechOutput{
27 Client: tts.NewClient(cfg.TTSBaseURL, hc),
28diff --git a/go.mod b/go.mod
29index 7e76705a4c6700f26b6001b8fa445bdacc6e77de..052652798fe4a04c7d7d05f343a84af496da3f96 100644
30--- a/go.mod
31+++ b/go.mod
32@@ -3,14 +3,12 @@ module japanese
33 go 1.27.0
34
35 require (
36+ github.com/charmbracelet/bubbletea v1.3.10
37 github.com/ebitengine/oto/v3 v3.5.0
38- github.com/ikawaha/kagome-dict/ipa v1.2.6
39- github.com/ikawaha/kagome/v2 v2.11.0
40 )
41
42 require (
43 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
44- github.com/charmbracelet/bubbletea v1.3.10 // indirect
45 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
46 github.com/charmbracelet/lipgloss v1.1.0 // indirect
47 github.com/charmbracelet/x/ansi v0.10.1 // indirect
48@@ -18,7 +16,6 @@ require (
49 github.com/charmbracelet/x/term v0.2.1 // indirect
50 github.com/ebitengine/purego v0.11.0 // indirect
51 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
52- github.com/ikawaha/kagome-dict v1.1.7 // indirect
53 github.com/jfreymuth/pulse v0.1.3 // indirect
54 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
55 github.com/mattn/go-isatty v0.0.20 // indirect
56diff --git a/go.sum b/go.sum
57index d82ca688ab092f03ea165aaf92cbbcdc84caffa0..60f01736a33de0ecd0c4d4a9a68077d053bb5ec2 100644
58--- a/go.sum
59+++ b/go.sum
60@@ -18,12 +18,6 @@ github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua
61 github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
62 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
63 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
64-github.com/ikawaha/kagome-dict v1.1.7 h1:O/uAL+WCGhp6kT0+szxBSPaSM4i+vdArSefFvJE4Nug=
65-github.com/ikawaha/kagome-dict v1.1.7/go.mod h1:9tvk7/jZkvYt40foxkB9CqSAAknoQrIPfzqQd05UkFw=
66-github.com/ikawaha/kagome-dict/ipa v1.2.6 h1:Bcvm4jgxAAnTIKb6ckqUKBiFDN0wuanFfycMuYt7xGQ=
67-github.com/ikawaha/kagome-dict/ipa v1.2.6/go.mod h1:ONdTMUAKMCq9yx4s69QRtPcJLEMVM0BNNYQrMCJLWb0=
68-github.com/ikawaha/kagome/v2 v2.11.0 h1:R914EkRzay9qtUbsFzEbcdZ3wHwwSPvbPkuBI1oIf78=
69-github.com/ikawaha/kagome/v2 v2.11.0/go.mod h1:6mYPezBou+iNVnX9uNa00Sfu6S6t2zcM8Nv1EW9Y9so=
70 github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
71 github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
72 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
73@@ -45,6 +39,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
74 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
75 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
76 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
77+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
78+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
79 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
82diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
83index b84e9e1d9f201b8b8de5169d466c6701a3db5528..08ec9708b1b35b79b58a073e3e8396134905b7b6 100644
84--- a/internal/adapters/speech.go
85+++ b/internal/adapters/speech.go
86@@ -4,18 +4,16 @@ import (
87 "context"
88 "fmt"
89
90- "japanese/internal/game"
91 "japanese/internal/stt"
92 "japanese/internal/tts"
93 )
94
95-// SpeechInput adapts the stt recorder, Whisper client, and romanizer to
96-// game.SpeechInput. Begin starts push-to-talk capture; End stops it, uploads
97-// the recording to Whisper, and romanizes the transcript.
98+// SpeechInput adapts the stt recorder and Whisper client to game.SpeechInput.
99+// Begin starts push-to-talk capture; End stops it, uploads the recording to
100+// Whisper, and returns the raw transcript.
101 type SpeechInput struct {
102 Recorder *stt.Recorder
103 Whisper *stt.WhisperClient
104- Romanize *stt.Romanizer
105
106 cur *stt.Recording
107 }
108@@ -29,20 +27,19 @@ func (s *SpeechInput) Begin(ctx context.Context) error {
109 return nil
110 }
111
112-func (s *SpeechInput) End(ctx context.Context) (game.Transcript, error) {
113+func (s *SpeechInput) End(ctx context.Context) (string, error) {
114 if s.cur == nil {
115- return game.Transcript{}, fmt.Errorf("mic: recording not started")
116+ return "", fmt.Errorf("mic: recording not started")
117 }
118 path, err := s.cur.Stop()
119 if err != nil {
120- return game.Transcript{}, fmt.Errorf("mic: %w", err)
121+ return "", fmt.Errorf("mic: %w", err)
122 }
123 raw, err := s.Whisper.Transcribe(ctx, path)
124 if err != nil {
125- return game.Transcript{}, err
126+ return "", err
127 }
128- tr := s.Romanize.Romanize(raw)
129- return game.Transcript{Raw: tr.Raw, Romaji: tr.Romaji}, nil
130+ return raw, nil
131 }
132
133 // SpeechOutput adapts the tts client and player to game.SpeechOutput. It
134diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
135index cc87cc739c4c402f292249cecc929169123bc1c3..57d66d0410202dc437291e8a34e86e5d53abd191 100644
136--- a/internal/adapters/speech_test.go
137+++ b/internal/adapters/speech_test.go
138@@ -47,20 +47,10 @@ func TestSpeechOutputSpeakErrorDoesNotFireOnDone(t *testing.T) {
139 }
140 }
141
142-func mustRomanizer(t *testing.T) *stt.Romanizer {
143- t.Helper()
144- r, err := stt.NewRomanizer()
145- if err != nil {
146- t.Fatalf("NewRomanizer: %v", err)
147- }
148- return r
149-}
150-
151 func TestSpeechInputBeginSurfacesRecorderFailure(t *testing.T) {
152 in := &SpeechInput{
153 Recorder: stt.NewRecorder("jp-definitely-not-a-real-command-xyz", 10*time.Second),
154 Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
155- Romanize: mustRomanizer(t),
156 }
157 err := in.Begin(context.Background())
158 if err == nil || !strings.Contains(err.Error(), "mic:") {
159@@ -72,7 +62,6 @@ func TestSpeechInputEndWithoutBegin(t *testing.T) {
160 in := &SpeechInput{
161 Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
162 Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
163- Romanize: mustRomanizer(t),
164 }
165 if _, err := in.End(context.Background()); err == nil || !strings.Contains(err.Error(), "recording not started") {
166 t.Fatalf("expected a not-started error, got %v", err)
167@@ -88,7 +77,6 @@ func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
168 in := &SpeechInput{
169 Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
170 Whisper: stt.NewWhisperClient(srv.URL, "", srv.Client()),
171- Romanize: mustRomanizer(t),
172 }
173 if err := in.Begin(context.Background()); err != nil {
174 t.Fatalf("Begin: %v", err)
175@@ -98,3 +86,25 @@ func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
176 t.Fatalf("expected STT error naming URL %q, got %v", srv.URL, err)
177 }
178 }
179+
180+func TestSpeechInputEndReturnsRawTranscript(t *testing.T) {
181+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182+ _, _ = w.Write([]byte(`{"text":"こんにちは"}`))
183+ }))
184+ defer srv.Close()
185+
186+ in := &SpeechInput{
187+ Recorder: stt.NewRecorder("true", 10*time.Second),
188+ Whisper: stt.NewWhisperClient(srv.URL, "", srv.Client()),
189+ }
190+ if err := in.Begin(context.Background()); err != nil {
191+ t.Fatalf("Begin: %v", err)
192+ }
193+ got, err := in.End(context.Background())
194+ if err != nil {
195+ t.Fatalf("End: %v", err)
196+ }
197+ if got != "こんにちは" {
198+ t.Errorf("End returned %q, want the raw transcript unchanged", got)
199+ }
200+}
201diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
202index 286bb99a15048d94a7280f67d8016cfc2c1264a3..ea075a4be0d7501f54430b17911513fc096b0406 100644
203--- a/internal/game/orchestrator.go
204+++ b/internal/game/orchestrator.go
205@@ -8,18 +8,11 @@ import (
206 "japanese/internal/llm"
207 )
208
209-// Transcript is a captured spoken line: the original text (for LLM prompts)
210-// and its deterministic romaji form (for display).
211-type Transcript struct {
212- Raw string
213- Romaji string
214-}
215-
216-// SpeechInput captures push-to-talk audio and returns a romanized transcript.
217-// Begin starts capture; End stops it, transcribes, and romanizes.
218+// SpeechInput captures push-to-talk audio and returns the raw transcript. Begin
219+// starts capture; End stops it and transcribes to the raw Japanese line.
220 type SpeechInput interface {
221 Begin(ctx context.Context) error
222- End(ctx context.Context) (Transcript, error)
223+ End(ctx context.Context) (string, error)
224 }
225
226 // NPCRequest carries everything the NPC model needs for one reply. Judge output
227@@ -62,11 +55,14 @@ type PersonaView struct {
228 // ErrNotInLocation is returned when a turn is finished outside any location.
229 var ErrNotInLocation = errors.New("game: not in a location")
230
231-// TurnResult reports what happened in one turn. A non-nil Err means no NPC
232-// reply was produced, so nothing was recorded to history. JudgeErr and SpeakErr
233-// are recoverable: they do not prevent recording and are surfaced separately.
234+// TurnResult reports what happened in one turn. Transcript is the raw Japanese
235+// line (for LLM prompts only, never shown); the display romaji comes from Judge
236+// when it succeeds, and there is no romaji when it fails. A non-nil Err means no
237+// NPC reply was produced, so nothing was recorded to history. JudgeErr and
238+// SpeakErr are recoverable: they do not prevent recording and are surfaced
239+// separately.
240 type TurnResult struct {
241- Transcript Transcript
242+ Transcript string
243 Judge llm.JudgeResult
244 JudgeErr error
245 NPC llm.NPCReply
246@@ -118,12 +114,12 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
247 return res
248 }
249
250- tr, err := o.speechIn.End(ctx)
251+ raw, err := o.speechIn.End(ctx)
252 if err != nil {
253 res.Err = err
254 return res
255 }
256- res.Transcript = tr
257+ res.Transcript = raw
258
259 view := o.personas[loc]
260 prior := toLLMTurns(o.state.History(loc))
261@@ -138,14 +134,14 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
262 wg.Add(2)
263 go func() {
264 defer wg.Done()
265- judgeRes, judgeErr = o.judge.Judge(ctx, JudgeRequest{Situation: view.Situation, Transcript: tr.Raw})
266+ judgeRes, judgeErr = o.judge.Judge(ctx, JudgeRequest{Situation: view.Situation, Transcript: raw})
267 }()
268 go func() {
269 defer wg.Done()
270 npcRes, npcErr = o.npc.Reply(ctx, NPCRequest{
271 Persona: view.Description,
272 Situation: view.Situation,
273- Transcript: tr.Raw,
274+ Transcript: raw,
275 History: prior,
276 })
277 }()
278@@ -158,11 +154,16 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
279 }
280 res.NPC = npcRes
281
282+ var playerRomaji string
283+ if judgeErr == nil {
284+ playerRomaji = judgeRes.Romaji
285+ }
286+
287 if err := o.speechOut.Speak(ctx, npcRes.Kana); err != nil {
288 res.SpeakErr = err
289 }
290
291- o.state.RecordTurn(loc, tr.Raw, tr.Romaji, Reply{Romaji: npcRes.Romaji, English: npcRes.English})
292+ o.state.RecordTurn(loc, raw, playerRomaji, Reply{Romaji: npcRes.Romaji, English: npcRes.English})
293 return res
294 }
295
296diff --git a/internal/game/state.go b/internal/game/state.go
297index 506ce70b70f6f2e36466398f666c8ec59c40b4ea..47c92e78919ee8bc7ddab3f356f35d03161e6f87 100644
298--- a/internal/game/state.go
299+++ b/internal/game/state.go
300@@ -53,7 +53,7 @@ type Reply struct {
301
302 // Turn is one exchange in a location's history: the player's spoken line and
303 // the NPC reply to it. PlayerRaw is the original transcript (for LLM prompts);
304-// PlayerRomaji is the deterministic romaji form shown to the player.
305+// PlayerRomaji is the romaji form shown to the player, sourced from the judge.
306 type Turn struct {
307 PlayerRaw string
308 PlayerRomaji string
309diff --git a/internal/game/turn_test.go b/internal/game/turn_test.go
310index e6c60f3d83df9d0620a64cc81c0022281b2a295b..e6597c02390aa412df25aaf367a807c11ef2dc64 100644
311--- a/internal/game/turn_test.go
312+++ b/internal/game/turn_test.go
313@@ -13,13 +13,13 @@ import (
314 )
315
316 type fakeInput struct {
317- transcript Transcript
318- endErr error
319+ raw string
320+ endErr error
321 }
322
323 func (f *fakeInput) Begin(ctx context.Context) error { return nil }
324-func (f *fakeInput) End(ctx context.Context) (Transcript, error) {
325- return f.transcript, f.endErr
326+func (f *fakeInput) End(ctx context.Context) (string, error) {
327+ return f.raw, f.endErr
328 }
329
330 type fakeNPC struct {
331@@ -76,9 +76,9 @@ func newOrchAtStation(t *testing.T, in SpeechInput, npc NPCModel, judge JudgeMod
332 }
333
334 func TestHappyTurnEndToEnd(t *testing.T) {
335- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
336+ in := &fakeInput{raw: "raw-line"}
337 npc := &fakeNPC{reply: llm.NPCReply{Romaji: "romaji-reply", Kana: "kana-reply", English: "english-reply"}}
338- judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Feedback: "good"}}
339+ judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Romaji: "ro-ma-ji", Feedback: "good"}}
340 out := &fakeOut{}
341
342 o, s := newOrchAtStation(t, in, npc, judge, out)
343@@ -87,8 +87,8 @@ func TestHappyTurnEndToEnd(t *testing.T) {
344 if res.Err != nil {
345 t.Fatalf("turn error: %v", res.Err)
346 }
347- if res.Transcript.Raw != "raw-line" || res.Transcript.Romaji != "ro-ma-ji" {
348- t.Errorf("transcript = %+v", res.Transcript)
349+ if res.Transcript != "raw-line" {
350+ t.Errorf("transcript = %q, want raw-line", res.Transcript)
351 }
352 if res.JudgeErr != nil || res.Judge.Score != 80 {
353 t.Errorf("judge = %+v err=%v", res.Judge, res.JudgeErr)
354@@ -104,7 +104,7 @@ func TestHappyTurnEndToEnd(t *testing.T) {
355 if len(hist) != 1 {
356 t.Fatalf("history len = %d, want 1", len(hist))
357 }
358- if hist[0].PlayerRaw != "raw-line" || hist[0].NPC.Romaji != "romaji-reply" || hist[0].NPC.Reveal != Hidden {
359+ if hist[0].PlayerRaw != "raw-line" || hist[0].PlayerRomaji != "ro-ma-ji" || hist[0].NPC.Romaji != "romaji-reply" || hist[0].NPC.Reveal != Hidden {
360 t.Errorf("recorded turn = %+v", hist[0])
361 }
362
363@@ -118,7 +118,7 @@ func TestHappyTurnEndToEnd(t *testing.T) {
364 }
365
366 func TestJudgeFailureDoesNotBlockNPC(t *testing.T) {
367- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
368+ in := &fakeInput{raw: "raw-line"}
369 npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
370 judge := &fakeJudge{err: errors.New("judge down")}
371 out := &fakeOut{}
372@@ -138,11 +138,15 @@ func TestJudgeFailureDoesNotBlockNPC(t *testing.T) {
373 if got := len(s.History("station")); got != 1 {
374 t.Errorf("history len = %d, want 1", got)
375 }
376+ hist := s.History("station")
377+ if hist[0].PlayerRaw != "raw-line" || hist[0].PlayerRomaji != "" {
378+ t.Errorf("judge failure should record raw with empty romaji, got %+v", hist[0])
379+ }
380 }
381
382 func TestJudgeOutputNeverEntersNPCInput(t *testing.T) {
383 const secret = "JUDGE-SECRET-XYZ"
384- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
385+ in := &fakeInput{raw: "raw-line"}
386 npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
387 judge := &fakeJudge{result: llm.JudgeResult{Score: 10, Feedback: secret}}
388 out := &fakeOut{}
389@@ -165,7 +169,7 @@ func TestJudgeOutputNeverEntersNPCInput(t *testing.T) {
390 }
391
392 func TestMalformedNPCOutputSurfacesRecoverableError(t *testing.T) {
393- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
394+ in := &fakeInput{raw: "raw-line"}
395 npc := &fakeNPC{err: &llm.ContractError{Kind: llm.MissingField, Field: "KANA"}}
396 judge := &fakeJudge{result: llm.JudgeResult{Score: 50, Feedback: "ok"}}
397 out := &fakeOut{}
398@@ -206,7 +210,7 @@ func TestSTTFailureSurfacesNamedErrorAndNoSideEffects(t *testing.T) {
399 }
400
401 func TestTTSFailureIsRecoverableAndStillRecords(t *testing.T) {
402- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
403+ in := &fakeInput{raw: "raw-line"}
404 npc := &fakeNPC{reply: llm.NPCReply{Romaji: "r", Kana: "k", English: "e"}}
405 judge := &fakeJudge{}
406 out := &fakeOut{err: fmt.Errorf("tts: request to http://127.0.0.1:8080/v1/audio/speech: boom")}
407@@ -263,7 +267,7 @@ func TestJudgeAndNPCRunInParallel(t *testing.T) {
408 mu.Unlock()
409 }
410
411- in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
412+ in := &fakeInput{raw: "raw-line"}
413diff --git a/internal/stt/romaji.go b/internal/stt/romaji.go
414deleted file mode 100644
415index 2dee611ad256a2e5e2594b05ec58c40099f833dd..0000000000000000000000000000000000000000
416--- a/internal/stt/romaji.go
417+++ /dev/null
418@@ -1,229 +0,0 @@
419-// Package stt wraps push-to-talk recording, the Whisper multipart client, and
420-// deterministic Japanese-to-romaji transcription. It is an HTTP/process client
421-// only and never starts or manages any model service.
422-package stt
423-
424-import (
425- "fmt"
426- "strings"
427-
428- "github.com/ikawaha/kagome-dict/ipa"
429- "github.com/ikawaha/kagome/v2/tokenizer"
430-)
431-
432-const unreadableMarker = "[unreadable]"
433-
434-// Transcript carries both the original Japanese line (for LLM prompts) and its
435-// deterministic romaji form (for the UI). The romaji field never contains kana
436-// or kanji.
437-type Transcript struct {
438- Raw string
439- Romaji string
440-}
441-
442-// Romanizer turns raw Japanese text into a Transcript. It owns a Kagome/IPADIC
443-// tokenizer, so one instance should be created once and reused.
444-type Romanizer struct {
445- tk *tokenizer.Tokenizer
446-}
447-
448-func NewRomanizer() (*Romanizer, error) {
449- tk, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos())
450- if err != nil {
451- return nil, fmt.Errorf("stt: init kagome: %w", err)
452- }
453- return &Romanizer{tk: tk}, nil
454-}
455-
456-// Romanize tokenizes raw and returns both the original line and its romaji.
457-func (r *Romanizer) Romanize(raw string) Transcript {
458- var sb strings.Builder
459- for _, tok := range r.tk.Tokenize(raw) {
460- frag := fragment(tok)
461- if frag == "" {
462- continue
463- }
464- if sb.Len() > 0 {
465- sb.WriteString(" ")
466- }
467- sb.WriteString(frag)
468- }
469- return Transcript{Raw: raw, Romaji: strings.TrimSpace(sb.String())}
470-}
471-
472-func fragment(tok tokenizer.Token) string {
473- if reading, ok := tok.Reading(); ok && isKanaOnly(reading) {
474- return KanaToHepburn(reading)
475- }
476- if isKanaOnly(tok.Surface) {
477- return KanaToHepburn(tok.Surface)
478- }
479- if hasKanjiOrKana(tok.Surface) {
480- return unreadableMarker
481- }
482- if hasASCIIAlnum(tok.Surface) {
483- return strings.TrimSpace(tok.Surface)
484- }
485- return ""
486-}
487-
488-// kanaToHepburn maps single syllables (both scripts) to Hepburn romaji. Small
489-// ya/yu/yo, the prolonged-sound mark, and small vowels are handled separately
490-// in KanaToHepburn.
491-var kanaToHepburn = map[rune]string{
492- // hiragana plain
493- 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
494- 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
495- 'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
496- 'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
497- 'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
498- 'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
499- 'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
500- 'や': "ya", 'ゆ': "yu", 'よ': "yo",
501- 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
502- 'わ': "wa", 'を': "wo", 'ん': "n",
503- // hiragana dakuten / handakuten
504- 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
505- 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
506- 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
507- 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
508- 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
509- // hiragana small vowels
510- 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e",
511-
512- // katakana plain
513- 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
514- 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
515- 'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
516- 'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
517- 'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
518diff --git a/internal/stt/romaji_test.go b/internal/stt/romaji_test.go
519deleted file mode 100644
520index 8dca0cdcf997fc238dff56bd5a32a1498872d866..0000000000000000000000000000000000000000
521--- a/internal/stt/romaji_test.go
522+++ /dev/null
523@@ -1,75 +0,0 @@
524-package stt
525-
526-import (
527- "strings"
528- "testing"
529-)
530-
531-func TestKanaToHepburn(t *testing.T) {
532- cases := []struct{ in, want string }{
533- {"こんにちは", "konnichiha"},
534- {"キャク", "kyaku"},
535- {"ジャ", "ja"},
536- {"ぎゃく", "gyaku"},
537- {"にゃん", "nyan"},
538- {"ひょう", "hyou"},
539- {"とうきょう", "toukyou"},
540- {"コーヒー", "koohii"},
541- {"ヴ", ""}, // unknown kana dropped
542- }
543- for _, c := range cases {
544- if got := KanaToHepburn(c.in); got != c.want {
545- t.Errorf("KanaToHepburn(%q) = %q, want %q", c.in, got, c.want)
546- }
547- }
548-}
549-
550-func TestRomanizeBasic(t *testing.T) {
551- r, err := NewRomanizer()
552- if err != nil {
553- t.Fatalf("NewRomanizer: %v", err)
554- }
555- cases := []struct{ raw, wantRomaji string }{
556- {"こんにちは。", "konnichiha"},
557- {"猫をみた", "neko wo mi ta"},
558- {"ラーメンがおいしいです", "raamen ga oishii desu"},
559- {"100円です", "100 en desu"},
560- {"客", "kyaku"},
561- {"じゃがいも", "jagaimo"},
562- }
563- for _, c := range cases {
564- tr := r.Romanize(c.raw)
565- if tr.Raw != c.raw {
566- t.Errorf("Romanize(%q).Raw = %q, want unchanged", c.raw, tr.Raw)
567- }
568- if tr.Romaji != c.wantRomaji {
569- t.Errorf("Romanize(%q).Romaji = %q, want %q", c.raw, tr.Romaji, c.wantRomaji)
570- }
571- }
572-}
573-
574-func TestRomanizeUnknownMarker(t *testing.T) {
575- r, err := NewRomanizer()
576- if err != nil {
577- t.Fatalf("NewRomanizer: %v", err)
578- }
579- tr := r.Romanize("㓆") // CJK Extension A, not in IPADIC
580- if !strings.Contains(tr.Romaji, unreadableMarker) {
581- t.Errorf("Romaji = %q, want it to contain %q", tr.Romaji, unreadableMarker)
582- }
583-}
584-
585-func TestRomanizeNoKanaLeak(t *testing.T) {
586- r, err := NewRomanizer()
587- if err != nil {
588- t.Fatalf("NewRomanizer: %v", err)
589- }
590- for _, raw := range []string{"こんにちは。", "猫をみた", "ラーメンがおいしいです", "100円です", "㓆をみた"} {
591- tr := r.Romanize(raw)
592- for _, ru := range tr.Romaji {
593- if isKanaRune(ru) || isKanjiRune(ru) {
594- t.Errorf("Romanize(%q).Romaji = %q leaks kana/kanji rune U+%04X", raw, tr.Romaji, ru)
595- }
596- }
597- }
598-}
599diff --git a/internal/stt/whisper.go b/internal/stt/whisper.go
600index 1a9c4792c1902a35b65704d239c9c98730813933..51707374635383736ef5d5bf7b0f92f29e9651e9 100644
601--- a/internal/stt/whisper.go
602+++ b/internal/stt/whisper.go
603@@ -1,3 +1,5 @@
604+// Package stt wraps push-to-talk recording and the Whisper multipart client. It
605+// is an HTTP/process client only and never starts or manages any model service.
606 package stt
607
608 import (
609diff --git a/internal/ui/app.go b/internal/ui/app.go
610index c3ac6380ecc55c74729d24564656324e8170ccea..ef228e5ebbe1b67e7280c3ec837769cb1d4797a1 100644
611--- a/internal/ui/app.go
612+++ b/internal/ui/app.go
613@@ -179,13 +179,14 @@ func (m *model) applyBegin(err error) {
614 }
615
616 func (m *model) applyFinish(res game.TurnResult) {
617- m.transcriptRomaji = res.Transcript.Romaji
618 if res.JudgeErr == nil {
619+ m.transcriptRomaji = res.Judge.Romaji
620 m.hasJudge = true
621 m.judgeScore = res.Judge.Score
622 m.judgeFeedback = res.Judge.Feedback
623 m.judgeErr = ""
624 } else {
625+ m.transcriptRomaji = transcriptUnavailable
626 m.hasJudge = false
627 m.judgeErr = fmt.Sprintf("LLM (%s): %v", m.svcURL("LLM"), res.JudgeErr)
628 }
629diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
630index 4f2c89bfedaadb793b72c19cea596252b174518e..306b4547c6c506ace93bae0d596728ef9b3ba87a 100644
631--- a/internal/ui/app_test.go
632+++ b/internal/ui/app_test.go
633@@ -27,8 +27,8 @@ func testState(t *testing.T) *game.State {
634 type fakeSpeech struct{}
635
636 func (fakeSpeech) Begin(ctx context.Context) error { return nil }
637-func (fakeSpeech) End(ctx context.Context) (game.Transcript, error) {
638- return game.Transcript{Raw: "ra", Romaji: "ra"}, nil
639+func (fakeSpeech) End(ctx context.Context) (string, error) {
640+ return "ra", nil
641 }
642
643 type fakeNPC struct{}
644@@ -40,7 +40,7 @@ func (fakeNPC) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, er
645 type fakeJudge struct{}
646
647 func (fakeJudge) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
648- return llm.JudgeResult{Score: 82, Feedback: "fine romaji feedback"}, nil
649+ return llm.JudgeResult{Score: 82, Romaji: "ra", Feedback: "fine romaji feedback"}, nil
650 }
651
652 type fakeOut struct{}
653@@ -84,7 +84,7 @@ func TestToggleStateMachine(t *testing.T) {
654 }
655
656 // finish result -> idle
657- res := game.TurnResult{Transcript: game.Transcript{Romaji: "ra"}, NPC: llm.NPCReply{English: "english-reply"}}
658+ res := game.TurnResult{Transcript: "raw", Judge: llm.JudgeResult{Score: 80, Romaji: "ra", Feedback: "ok"}, NPC: llm.NPCReply{English: "english-reply"}}
659 if _, _ = m.Update(finishResultMsg{res: res}); m.phase != phaseIdle {
660 t.Fatalf("after finish: phase=%v; want idle", m.phase)
661 }
662@@ -124,7 +124,7 @@ func TestFinishWorkerRunsTurn(t *testing.T) {
663 if fr.res.Err != nil {
664 t.Fatalf("turn err: %v", fr.res.Err)
665 }
666- if fr.res.Transcript.Romaji != "ra" || fr.res.Judge.Score != 82 || fr.res.NPC.English != "english-reply" {
667+ if fr.res.Transcript != "ra" || fr.res.Judge.Score != 82 || fr.res.NPC.English != "english-reply" {
668 t.Fatalf("unexpected turn result: %+v", fr.res)
669 }
670 }
671@@ -174,8 +174,8 @@ func TestProbeCheckingThenTransitionsInPlace(t *testing.T) {
672 func TestNpcSpeakingTransitionsToDone(t *testing.T) {
673 m, _ := buildModel(t)
674 res := game.TurnResult{
675- Transcript: game.Transcript{Romaji: "ra"},
676- Judge: llm.JudgeResult{Score: 80, Feedback: "ok"},
677+ Transcript: "raw",
678+ Judge: llm.JudgeResult{Score: 80, Romaji: "ra", Feedback: "ok"},
679 NPC: llm.NPCReply{Romaji: "r", English: "e"},
680 }
681 if _, _ = m.Update(finishResultMsg{res: res}); m.npcStatus != "speaking" {
682@@ -186,6 +186,23 @@ func TestNpcSpeakingTransitionsToDone(t *testing.T) {
683 }
684 }
685
686+func TestApplyFinishShowsJudgeRomajiOrPlaceholder(t *testing.T) {
687+ m, _ := buildModel(t)
688+
689+ okRes := game.TurnResult{Transcript: "raw", Judge: llm.JudgeResult{Score: 90, Romaji: "konnichiwa", Feedback: "ok"}, NPC: llm.NPCReply{Romaji: "r", English: "e"}}
690+ m.Update(finishResultMsg{res: okRes})
691+ if m.transcriptRomaji != "konnichiwa" {
692+ t.Fatalf("transcript = %q, want judge romaji", m.transcriptRomaji)
693+ }
694+
695+ badRes := game.TurnResult{Transcript: "こんにちは", JudgeErr: errors.New("judge down"), NPC: llm.NPCReply{Romaji: "r", English: "e"}}
696+ m.Update(finishResultMsg{res: badRes})
697+ if m.transcriptRomaji != transcriptUnavailable {
698+ t.Fatalf("transcript = %q, want placeholder on judge failure", m.transcriptRomaji)
699+ }
700+ assertNoJapanese(t, m.View())
701+}
702+
703 func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
704
705 func TestRevealOrdering(t *testing.T) {
706diff --git a/internal/ui/render.go b/internal/ui/render.go
707index 00b85408796e8ec0efff47f6896b9fc1392bd2f2..6f824ce6b01aa6563ed5a114e5e19039d2128214 100644
708--- a/internal/ui/render.go
709+++ b/internal/ui/render.go
710@@ -24,6 +24,10 @@ type serviceLine struct {
711 // controlsHint is the idle status line. It only lists keys wired in this build.
712 const controlsHint = "move: arrows / WASD Space: talk R: romaji T: english q: quit"
713
714+// transcriptUnavailable is shown for the player's line when the judge request
715+// failed, so no raw Japanese can ever reach the view.
716+const transcriptUnavailable = "(transcript unavailable)"
717+
718 // viewState is the plain, render-only snapshot the pure render functions take.
719 // It holds no pointers and no game types, so rendering is trivially pure.
720 type viewState struct {