8976c674b65f3d10bf403584a979d44e39bc7aa4
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..a2d85f4f3ce096aabcae00fd6fee3c583ef9aa2b
4--- /dev/null
5+++ b/internal/adapters/llm.go
6@@ -0,0 +1,48 @@
7+// Package adapters provides the concrete game adapters that wrap the existing
8+// HTTP clients (llm, stt, tts). They implement the interfaces defined in
9+// internal/game so the orchestrator can be wired to real services here and to
10+// fakes in tests. This package is an HTTP client only; it never starts or
11+// manages any model service.
12+package adapters
13+
14+import (
15+ "context"
16+
17+ "japanese/internal/game"
18+ "japanese/internal/llm"
19+)
20+
21+// NPCModel adapts an llm.Client to game.NPCModel. It builds the persona and
22+// situation prompt with bounded history and parses the strict three-field reply.
23+type NPCModel struct {
24+ Client *llm.Client
25+ Policy llm.HistoryPolicy
26+}
27+
28+func (m *NPCModel) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, error) {
29+ policy := m.Policy
30+ if policy.MaxTurns == 0 && policy.PromptCharBudget == 0 {
31+ policy = llm.DefaultHistoryPolicy()
32+ }
33+ msgs := llm.BuildNPCMessages(req.Persona, req.Situation, req.Transcript, req.History, policy)
34+ raw, err := m.Client.Generate(ctx, msgs, llm.Qwen3Options())
35+ if err != nil {
36+ return llm.NPCReply{}, err
37+ }
38+ return llm.ParseNPCReply(raw)
39+}
40+
41+// JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
42+// persona-free judge request and parses the two-field result.
43+type JudgeModel struct {
44+ Client *llm.Client
45+}
46+
47+func (m *JudgeModel) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
48+ msgs := llm.BuildJudgeMessages(req.Situation, req.Transcript)
49+ raw, err := m.Client.Generate(ctx, msgs, llm.Qwen3Options())
50+ if err != nil {
51+ return llm.JudgeResult{}, err
52+ }
53+ return llm.ParseJudge(raw)
54+}
55diff --git a/internal/adapters/llm_test.go b/internal/adapters/llm_test.go
56new file mode 100644
57index 0000000000000000000000000000000000000000..e6331d5ec4702336f09e3b4fa6875e3bd996f1b6
58--- /dev/null
59+++ b/internal/adapters/llm_test.go
60@@ -0,0 +1,155 @@
61+package adapters
62+
63+import (
64+ "context"
65+ "encoding/json"
66+ "errors"
67+ "fmt"
68+ "net/http"
69+ "net/http/httptest"
70+ "strings"
71+ "testing"
72+
73+ "japanese/internal/game"
74+ "japanese/internal/llm"
75+)
76+
77+type llmCapture struct{ body []byte }
78+
79+func newFakeLLM(t *testing.T, respond func(w http.ResponseWriter)) (*llm.Client, *llmCapture) {
80+ t.Helper()
81+ cap := &llmCapture{}
82+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ buf := make([]byte, 8192)
84+ n, _ := r.Body.Read(buf)
85+ cap.body = buf[:n]
86+ respond(w)
87+ }))
88+ t.Cleanup(srv.Close)
89+ return llm.NewClient(srv.URL, srv.Client()), cap
90+}
91+
92+func sseReply(w http.ResponseWriter, full string) {
93+ w.Header().Set("Content-Type", "text/event-stream")
94+ b, _ := json.Marshal(map[string]any{
95+ "choices": []map[string]any{{"delta": map[string]any{"content": full}}},
96+ })
97+ fmt.Fprintf(w, "data: %s\n\n", b)
98+ fmt.Fprint(w, "data: [DONE]\n\n")
99+}
100+
101+type capturedChat struct {
102+ Messages []struct {
103+ Role string `json:"role"`
104+ Content string `json:"content"`
105+ } `json:"messages"`
106+}
107+
108+func TestNPCModelReplyAndRequestShape(t *testing.T) {
109+ client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
110+ sseReply(w, "ROMAJI|romaji-reply\nKANA|kana-reply\nENGLISH|english-reply")
111+ })
112+ m := &NPCModel{Client: client}
113+
114+ got, err := m.Reply(context.Background(), game.NPCRequest{
115+ Persona: "a careful clerk",
116+ Situation: "at a ticket window",
117+ Transcript: "raw-line",
118+ })
119+ if err != nil {
120+ t.Fatalf("Reply: %v", err)
121+ }
122+ if got.Romaji != "romaji-reply" || got.Kana != "kana-reply" || got.English != "english-reply" {
123+ t.Errorf("unexpected reply: %+v", got)
124+ }
125+
126+ var req capturedChat
127+ if err := json.Unmarshal(cap.body, &req); err != nil {
128+ t.Fatalf("request not JSON: %v", err)
129+ }
130+ system := req.Messages[0].Content
131+ if !strings.Contains(system, "a careful clerk") || !strings.Contains(system, "at a ticket window") {
132+ t.Errorf("system message missing persona/situation: %q", system)
133+ }
134+ if last := req.Messages[len(req.Messages)-1]; last.Content != "raw-line" {
135+ t.Errorf("last message = %q, want raw transcript", last.Content)
136+ }
137+}
138+
139+func TestNPCModelMalformedSurfacesContractError(t *testing.T) {
140+ client, _ := newFakeLLM(t, func(w http.ResponseWriter) {
141+ sseReply(w, "ROMAJI|r\nENGLISH|e") // missing KANA
142+ })
143+ m := &NPCModel{Client: client}
144+
145+ if _, err := m.Reply(context.Background(), game.NPCRequest{Persona: "p", Situation: "s", Transcript: "t"}); err == nil {
146+ t.Fatal("expected error for malformed NPC output")
147+ } else {
148+ var ce *llm.ContractError
149+ if !errors.As(err, &ce) {
150+ t.Errorf("expected *llm.ContractError, got %T (%v)", err, err)
151+ }
152+ }
153+}
154+
155+func TestNPCModelBoundsHistory(t *testing.T) {
156+ client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
157+ sseReply(w, "ROMAJI|r\nKANA|k\nENGLISH|e")
158+ })
159+ m := &NPCModel{Client: client, Policy: llm.HistoryPolicy{MaxTurns: 2, PromptCharBudget: 1_000_000}}
160diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
161new file mode 100644
162index 0000000000000000000000000000000000000000..2fabeaf93032bab86a2922e085e5923ef39d114b
163--- /dev/null
164+++ b/internal/adapters/speech.go
165@@ -0,0 +1,61 @@
166+package adapters
167+
168+import (
169+ "context"
170+ "fmt"
171+
172+ "japanese/internal/game"
173+ "japanese/internal/stt"
174+ "japanese/internal/tts"
175+)
176+
177+// SpeechInput adapts the stt recorder, Whisper client, and romanizer to
178+// game.SpeechInput. Begin starts push-to-talk capture; End stops it, uploads
179+// the recording to Whisper, and romanizes the transcript.
180+type SpeechInput struct {
181+ Recorder *stt.Recorder
182+ Whisper *stt.WhisperClient
183+ Romanize *stt.Romanizer
184+
185+ cur *stt.Recording
186+}
187+
188+func (s *SpeechInput) Begin(ctx context.Context) error {
189+ rec, err := s.Recorder.Start(ctx)
190+ if err != nil {
191+ return fmt.Errorf("mic: %w", err)
192+ }
193+ s.cur = rec
194+ return nil
195+}
196+
197+func (s *SpeechInput) End(ctx context.Context) (game.Transcript, error) {
198+ if s.cur == nil {
199+ return game.Transcript{}, fmt.Errorf("mic: recording not started")
200+ }
201+ path, err := s.cur.Stop()
202+ if err != nil {
203+ return game.Transcript{}, fmt.Errorf("mic: %w", err)
204+ }
205+ raw, err := s.Whisper.Transcribe(ctx, path)
206+ if err != nil {
207+ return game.Transcript{}, err
208+ }
209+ tr := s.Romanize.Romanize(raw)
210+ return game.Transcript{Raw: tr.Raw, Romaji: tr.Romaji}, nil
211+}
212+
213+// SpeechOutput adapts the tts client and player to game.SpeechOutput. It
214+// synthesizes the NPC reply's kana and plays it without blocking.
215+type SpeechOutput struct {
216+ Client *tts.Client
217+ Player *tts.Player
218+}
219+
220+func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
221+ wav, err := s.Client.Speech(ctx, kana)
222+ if err != nil {
223+ return err
224+ }
225+ return s.Player.Play(wav)
226+}
227diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
228new file mode 100644
229index 0000000000000000000000000000000000000000..9a42959e6e1f5befe199b3644de108f8d1500797
230--- /dev/null
231+++ b/internal/adapters/speech_test.go
232@@ -0,0 +1,24 @@
233+package adapters
234+
235+import (
236+ "context"
237+ "net/http"
238+ "net/http/httptest"
239+ "strings"
240+ "testing"
241+
242+ "japanese/internal/tts"
243+)
244+
245+func TestSpeechOutputSpeakErrorNamesURL(t *testing.T) {
246+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247+ http.Error(w, "boom", http.StatusInternalServerError)
248+ }))
249+ defer srv.Close()
250+
251+ out := &SpeechOutput{Client: tts.NewClient(srv.URL, srv.Client()), Player: tts.NewPlayer()}
252+ err := out.Speak(context.Background(), "こんにちは")
253+ if err == nil || !strings.Contains(err.Error(), srv.URL) {
254+ t.Errorf("expected TTS error naming URL %q, got %v", srv.URL, err)
255+ }
256+}
257diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
258new file mode 100644
259index 0000000000000000000000000000000000000000..286bb99a15048d94a7280f67d8016cfc2c1264a3
260--- /dev/null
261+++ b/internal/game/orchestrator.go
262@@ -0,0 +1,175 @@
263+package game
264+
265+import (
266+ "context"
267+ "errors"
268+ "sync"
269+
270+ "japanese/internal/llm"
271+)
272+
273+// Transcript is a captured spoken line: the original text (for LLM prompts)
274+// and its deterministic romaji form (for display).
275+type Transcript struct {
276+ Raw string
277+ Romaji string
278+}
279+
280+// SpeechInput captures push-to-talk audio and returns a romanized transcript.
281+// Begin starts capture; End stops it, transcribes, and romanizes.
282+type SpeechInput interface {
283+ Begin(ctx context.Context) error
284+ End(ctx context.Context) (Transcript, error)
285+}
286+
287+// NPCRequest carries everything the NPC model needs for one reply. Judge output
288+// never appears in it.
289+type NPCRequest struct {
290+ Persona string // stable persona description
291+ Situation string // local situation text
292+ Transcript string // raw transcript (Japanese)
293+ History []llm.Turn // prior turns; the adapter bounds this
294+}
295+
296+// NPCModel produces one NPC reply for a request.
297+type NPCModel interface {
298+ Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error)
299+}
300+
301+// JudgeRequest carries what the judge needs to score one spoken line.
302+type JudgeRequest struct {
303+ Situation string
304+ Transcript string // raw transcript
305+}
306+
307+// JudgeModel scores a spoken line. It is independent of the NPC model.
308+type JudgeModel interface {
309+ Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error)
310+}
311+
312+// SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
313+type SpeechOutput interface {
314+ Speak(ctx context.Context, kana string) error
315+}
316+
317+// PersonaView is the stable persona data the orchestrator sends to the models
318+// for one location: a composed description and the local situation text.
319+type PersonaView struct {
320+ Description string
321+ Situation string
322+}
323+
324+// ErrNotInLocation is returned when a turn is finished outside any location.
325+var ErrNotInLocation = errors.New("game: not in a location")
326+
327+// TurnResult reports what happened in one turn. A non-nil Err means no NPC
328+// reply was produced, so nothing was recorded to history. JudgeErr and SpeakErr
329+// are recoverable: they do not prevent recording and are surfaced separately.
330+type TurnResult struct {
331+ Transcript Transcript
332+ Judge llm.JudgeResult
333+ JudgeErr error
334+ NPC llm.NPCReply
335+ SpeakErr error
336+ Err error
337+}
338+
339+// Orchestrator runs the turn flow over the adapter interfaces, keeping all game
340+// state in the embedded State. It does no I/O itself; every adapter call is an
341+// explicit await on the caller's context, so it is safe to run off a UI event
342+// loop (for example from a Bubble Tea command goroutine).
343+type Orchestrator struct {
344+ state *State
345+ personas map[string]PersonaView
346+ speechIn SpeechInput
347+ npc NPCModel
348+ judge JudgeModel
349+ speechOut SpeechOutput
350+}
351+
352+// NewOrchestrator wires an orchestrator over a state and the four adapters.
353+func NewOrchestrator(state *State, personas map[string]PersonaView, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) *Orchestrator {
354+ return &Orchestrator{
355+ state: state,
356+ personas: personas,
357+ speechIn: in,
358+ npc: npc,
359+ judge: judge,
360+ speechOut: out,
361+ }
362diff --git a/internal/game/turn_test.go b/internal/game/turn_test.go
363new file mode 100644
364index 0000000000000000000000000000000000000000..e6c60f3d83df9d0620a64cc81c0022281b2a295b
365--- /dev/null
366+++ b/internal/game/turn_test.go
367@@ -0,0 +1,301 @@
368+package game
369+
370+import (
371+ "context"
372+ "errors"
373+ "fmt"
374+ "strings"
375+ "sync"
376+ "testing"
377+ "time"
378+
379+ "japanese/internal/llm"
380+)
381+
382+type fakeInput struct {
383+ transcript Transcript
384+ endErr error
385+}
386+
387+func (f *fakeInput) Begin(ctx context.Context) error { return nil }
388+func (f *fakeInput) End(ctx context.Context) (Transcript, error) {
389+ return f.transcript, f.endErr
390+}
391+
392+type fakeNPC struct {
393+ reply llm.NPCReply
394+ err error
395+ lastReq NPCRequest
396+ got bool
397+}
398+
399+func (f *fakeNPC) Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error) {
400+ f.lastReq = req
401+ f.got = true
402+ return f.reply, f.err
403+}
404+
405+type fakeJudge struct {
406+ result llm.JudgeResult
407+ err error
408+}
409+
410+func (f *fakeJudge) Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error) {
411+ return f.result, f.err
412+}
413+
414+type fakeOut struct {
415+ spoken []string
416+ err error
417+}
418+
419+func (f *fakeOut) Speak(ctx context.Context, kana string) error {
420+ if f.err != nil {
421+ return f.err
422+ }
423+ f.spoken = append(f.spoken, kana)
424+ return nil
425+}
426+
427+// newOrchAtStation builds an orchestrator whose state is standing at the
428+// station location.
429+func newOrchAtStation(t *testing.T, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) (*Orchestrator, *State) {
430+ t.Helper()
431+ m, err := ParseMap([]byte(validMapJSON))
432+ if err != nil {
433+ t.Fatalf("ParseMap: %v", err)
434+ }
435+ s := NewState(m)
436+ if !s.Move(Down) || !s.Move(Right) {
437+ t.Fatalf("could not reach station")
438+ }
439+ personas := map[string]PersonaView{
440+ "station": {Description: "a careful clerk", Situation: "at a ticket window"},
441+ }
442+ return NewOrchestrator(s, personas, in, npc, judge, out), s
443+}
444+
445+func TestHappyTurnEndToEnd(t *testing.T) {
446+ in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
447+ npc := &fakeNPC{reply: llm.NPCReply{Romaji: "romaji-reply", Kana: "kana-reply", English: "english-reply"}}
448+ judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Feedback: "good"}}
449+ out := &fakeOut{}
450+
451+ o, s := newOrchAtStation(t, in, npc, judge, out)
452+ res := o.Finish(context.Background())
453+
454+ if res.Err != nil {
455+ t.Fatalf("turn error: %v", res.Err)
456+ }
457+ if res.Transcript.Raw != "raw-line" || res.Transcript.Romaji != "ro-ma-ji" {
458+ t.Errorf("transcript = %+v", res.Transcript)
459+ }
460+ if res.JudgeErr != nil || res.Judge.Score != 80 {
461+ t.Errorf("judge = %+v err=%v", res.Judge, res.JudgeErr)
462+ }
463+ if res.NPC.Romaji != "romaji-reply" || res.SpeakErr != nil {
464+ t.Errorf("npc/speak = %+v err=%v", res.NPC, res.SpeakErr)
465+ }
466+ if len(out.spoken) != 1 || out.spoken[0] != "kana-reply" {