7d076ecae8887de8e98433327f94d8375c208672
- 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-server/main.go b/cmd/jp-server/main.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..50b6bb4242eb521c8e874afd94ab3774cadcc957
4--- /dev/null
5+++ b/cmd/jp-server/main.go
6@@ -0,0 +1,34 @@
7+package main
8+
9+import (
10+ "fmt"
11+ "net/http"
12+ "os"
13+
14+ "japanese/internal/bootstrap"
15+ "japanese/internal/config"
16+ "japanese/internal/server"
17+)
18+
19+func main() {
20+ cfg := config.ParseServerConfig()
21+ if cfg.Token == "" {
22+ fatalf("token is required: set --token or JP_SERVER_TOKEN")
23+ }
24+
25+ svc, err := bootstrap.New(&cfg.Config)
26+ if err != nil {
27+ fatalf("%v", err)
28+ }
29+
30+ srv := server.New(svc, cfg.Token)
31+ fmt.Printf("jp-server: listening on %s\n", cfg.Listen)
32+ if err := http.ListenAndServe(cfg.Listen, srv.Handler()); err != nil {
33+ fatalf("%v", err)
34+ }
35+}
36+
37+func fatalf(format string, args ...any) {
38+ fmt.Fprintf(os.Stderr, "jp-server: "+format+"\n", args...)
39+ os.Exit(1)
40+}
41diff --git a/internal/adapters/flashcards.go b/internal/adapters/flashcards.go
42index e82a85d22a66835e2350881f3acbfac0e66ee46a..5a44d55ec92f01290fa54c50e827f8a121408b8c 100644
43--- a/internal/adapters/flashcards.go
44+++ b/internal/adapters/flashcards.go
45@@ -5,6 +5,7 @@ import (
46 "encoding/csv"
47 "fmt"
48 "os"
49+ "sync"
50
51 "japanese/internal/llm"
52 )
53@@ -41,9 +42,16 @@ func (m *FlashcardModel) Generate(ctx context.Context, instructions string) (int
54 return len(rows), nil
55 }
56
57+// flashcardsMu serializes deck appends so concurrent sessions cannot
58+// double-write the header or interleave rows.
59+var flashcardsMu sync.Mutex
60+
61 // appendFlashcards appends valid rows to the deck file, creating it with the
62 // header when it does not exist yet. Existing rows are never rewritten.
63 func appendFlashcards(path string, rows []llm.FlashcardRow) error {
64+ flashcardsMu.Lock()
65+ defer flashcardsMu.Unlock()
66+
67 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
68 if err != nil {
69 return fmt.Errorf("open flashcards: %w", err)
70diff --git a/internal/config/config.go b/internal/config/config.go
71index fffa9fc641428616f776a4047684c17461091c59..df40b5ec2548177bb1614d5bbf9f03d3c8dccb0f 100644
72--- a/internal/config/config.go
73+++ b/internal/config/config.go
74@@ -30,18 +30,37 @@ type Config struct {
75 Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
76 }
77
78+// ServerConfig is the jp-server option group: the shared Config plus the HTTP
79+// listen address and bearer token.
80+type ServerConfig struct {
81+ Config
82+
83+ Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"127.0.0.1:8081" description:"address to listen on"`
84+ Token string `long:"token" env:"JP_SERVER_TOKEN" description:"bearer token required on all requests"`
85+}
86+
87 func ParseConfig() *Config {
88 var cfg Config
89- parser := flags.NewParser(&cfg, flags.HelpFlag)
90+ parse("jp", &cfg)
91+ return &cfg
92+}
93+
94+// ParseServerConfig parses the jp-server command line into a ServerConfig.
95+func ParseServerConfig() *ServerConfig {
96+ var cfg ServerConfig
97+ parse("jp-server", &cfg)
98+ return &cfg
99+}
100+
101+func parse(prog string, target any) {
102+ parser := flags.NewParser(target, flags.HelpFlag)
103 if _, err := parser.ParseArgs(os.Args[1:]); err != nil {
104 if e, ok := err.(*flags.Error); ok && e.Type == flags.ErrHelp {
105 parser.WriteHelp(os.Stdout)
106 os.Exit(0)
107 }
108- fmt.Fprintf(os.Stderr, "jp: %v\n", err)
109+ fmt.Fprintf(os.Stderr, prog+": %v\n", err)
110 parser.WriteHelp(os.Stderr)
111 os.Exit(1)
112 }
113-
114- return &cfg
115 }
116diff --git a/internal/server/handlers.go b/internal/server/handlers.go
117new file mode 100644
118index 0000000000000000000000000000000000000000..08888a61e01927f91e45292904f6d560d66a3c6b
119--- /dev/null
120+++ b/internal/server/handlers.go
121@@ -0,0 +1,214 @@
122+package server
123+
124+import (
125+ "context"
126+ "encoding/base64"
127+ "encoding/json"
128+ "errors"
129+ "io"
130+ "net/http"
131+ "strings"
132+
133+ "japanese/internal/game"
134+)
135+
136+type judgeWire struct {
137+ Score int `json:"score"`
138+ Romaji string `json:"romaji,omitempty"`
139+ Feedback string `json:"feedback,omitempty"`
140+}
141+
142+type askWire struct {
143+ Romaji string `json:"romaji,omitempty"`
144+ Translation string `json:"translation,omitempty"`
145+ Breakdown string `json:"breakdown,omitempty"`
146+}
147+
148+// logEntry is one display entry on the wire. Kana never crosses the protocol.
149+type logEntry struct {
150+ Action string `json:"action,omitempty"`
151+ Desc string `json:"desc,omitempty"`
152+ Romaji string `json:"romaji,omitempty"`
153+ English string `json:"english,omitempty"`
154+ HasSpeech bool `json:"hasSpeech"`
155+ Judge *judgeWire `json:"judge,omitempty"`
156+ Ask *askWire `json:"ask,omitempty"`
157+}
158+
159+func toLogEntry(e game.DisplayEntry) logEntry {
160+ out := logEntry{
161+ Action: e.Action,
162+ Desc: e.Desc,
163+ Romaji: e.Romaji,
164+ English: e.English,
165+ HasSpeech: e.HasSpeech,
166+ }
167+ if e.HasJudge {
168+ out.Judge = &judgeWire{Score: e.Score, Romaji: e.PlayerRomaji, Feedback: e.Feedback}
169+ }
170+ if e.HasAsk {
171+ out.Ask = &askWire{Romaji: e.AskRomaji, Translation: e.AskTranslation, Breakdown: e.AskBreakdown}
172+ }
173+ return out
174+}
175+
176+type turnResponse struct {
177+ logEntry
178+ Location string `json:"location,omitempty"`
179+ Talk string `json:"talk,omitempty"`
180+ Audio string `json:"audio,omitempty"`
181+ SpeakError string `json:"speakError,omitempty"`
182+ JudgeError string `json:"judgeError,omitempty"`
183+}
184+
185+type flashcardResponse struct {
186+ Added int `json:"added"`
187+}
188+
189+type errorWire struct {
190+ Error string `json:"error"`
191+}
192+
193+func writeJSON(w http.ResponseWriter, status int, v any) {
194+ w.Header().Set("Content-Type", "application/json")
195+ w.WriteHeader(status)
196+ _ = json.NewEncoder(w).Encode(v)
197+}
198+
199+func writeError(w http.ResponseWriter, status int, msg string) {
200+ writeJSON(w, status, errorWire{Error: msg})
201+}
202+
203+func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
204+ sess := s.newSession()
205+ s.mu.Lock()
206+ s.sessions[sess.id] = sess
207+ s.mu.Unlock()
208+ writeJSON(w, http.StatusOK, map[string]string{"id": sess.id})
209+}
210+
211+func (s *Server) getLog(w http.ResponseWriter, r *http.Request) {
212+ sess := s.session(r.PathValue("id"))
213+ if sess == nil {
214+ writeError(w, http.StatusNotFound, "unknown session")
215+ return
216+ }
217+
218+ sess.mu.Lock()
219+ entries := sess.state.Display()
220+ sess.mu.Unlock()
221diff --git a/internal/server/server.go b/internal/server/server.go
222new file mode 100644
223index 0000000000000000000000000000000000000000..f63e3b05d1d9fa552054cc84394999eb1c16b921
224--- /dev/null
225+++ b/internal/server/server.go
226@@ -0,0 +1,103 @@
227+// Package server exposes jp game sessions over HTTP for a thin client.
228+// Sessions are in-memory and die with the process; turns serialize per
229+// session. Kana never crosses the wire.
230+package server
231+
232+import (
233+ "crypto/hmac"
234+ "crypto/rand"
235+ "encoding/hex"
236+ "net/http"
237+ "strings"
238+ "sync"
239+ "time"
240+
241+ "japanese/internal/adapters"
242+ "japanese/internal/bootstrap"
243+ "japanese/internal/game"
244+ "japanese/internal/stt"
245+ "japanese/internal/tts"
246+)
247+
248+const (
249+ turnTimeout = 3 * time.Minute
250+ maxUpload = int64(2 << 20)
251+)
252+
253+// Server serves jp game sessions over HTTP.
254+type Server struct {
255+ svc *bootstrap.Services
256+ token []byte
257+
258+ mu sync.Mutex
259+ sessions map[string]*session
260+}
261+
262+// New builds a server over the shared service clients.
263+func New(svc *bootstrap.Services, token string) *Server {
264+ return &Server{svc: svc, token: []byte(token), sessions: make(map[string]*session)}
265+}
266+
267+// Handler returns the HTTP handler with bearer-token auth applied.
268+func (s *Server) Handler() http.Handler {
269+ mux := http.NewServeMux()
270+ mux.HandleFunc("POST /v1/sessions", s.createSession)
271+ mux.HandleFunc("GET /v1/sessions/{id}/log", s.getLog)
272+ mux.HandleFunc("POST /v1/sessions/{id}/actions", s.postAction)
273+ mux.HandleFunc("POST /v1/sessions/{id}/speech", s.postSpeech)
274+ return auth(s.token, mux)
275+}
276+
277+func auth(token []byte, next http.Handler) http.Handler {
278+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
279+ got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
280+ if !hmac.Equal([]byte(got), token) {
281+ writeError(w, http.StatusUnauthorized, "unauthorized")
282+ return
283+ }
284+ next.ServeHTTP(w, r)
285+ })
286+}
287+
288+// session is one in-memory game: a fresh state and orchestrator over the
289+// shared service clients. The mutex serializes that session's turns.
290+type session struct {
291+ id string
292+
293+ mu sync.Mutex
294+ state *game.State
295+ orch *game.Orchestrator
296+ input *remoteInput
297+ output *bufferedOutput
298+}
299+
300+func (s *Server) newSession() *session {
301+ var idb [16]byte
302+ if _, err := rand.Read(idb[:]); err != nil {
303+ panic(err)
304+ }
305+ id := hex.EncodeToString(idb[:])
306+
307+ state := game.NewState(s.svc.Brief)
308+ speechIn := &remoteInput{asr: stt.NewASRClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
309+ speechOut := &bufferedOutput{client: tts.NewClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
310+ orch := game.NewOrchestrator(
311+ state,
312+ &adapters.GameModel{Client: s.svc.Game},
313+ &adapters.JudgeModel{Client: s.svc.Judge},
314+ &adapters.Compactor{Client: s.svc.Compaction},
315+ &adapters.SheetModel{Client: s.svc.Sheet},
316+ &adapters.ScratchModel{Client: s.svc.Scratch},
317+ &adapters.FlashcardModel{Client: s.svc.Scratch},
318+ speechIn,
319+ speechOut,
320+ )
321+
322+ return &session{id: id, state: state, orch: orch, input: speechIn, output: speechOut}
323+}
324+
325+func (s *Server) session(id string) *session {
326diff --git a/internal/server/speech.go b/internal/server/speech.go
327new file mode 100644
328index 0000000000000000000000000000000000000000..da6d2b66889dd43d342e7618d64425f069d84c98
329--- /dev/null
330+++ b/internal/server/speech.go
331@@ -0,0 +1,77 @@
332+package server
333+
334+import (
335+ "context"
336+ "errors"
337+ "sync"
338+
339+ "japanese/internal/stt"
340+ "japanese/internal/tts"
341+)
342+
343+// remoteInput adapts the uploaded WAV of one spoken turn to game.SpeechInput.
344+// Begin is a no-op; End transcribes the bytes stashed with SetWAV.
345+type remoteInput struct {
346+ asr *stt.ASRClient
347+
348+ mu sync.Mutex
349+ wav []byte
350+}
351+
352+func (r *remoteInput) Begin(ctx context.Context) error { return nil }
353+
354+// SetWAV stashes the WAV uploaded for this turn.
355+func (r *remoteInput) SetWAV(wav []byte) {
356+ r.mu.Lock()
357+ r.wav = wav
358+ r.mu.Unlock()
359+}
360+
361+func (r *remoteInput) End(ctx context.Context) (string, error) {
362+ r.mu.Lock()
363+ wav := r.wav
364+ r.wav = nil
365+ r.mu.Unlock()
366+ if len(wav) == 0 {
367+ return "", errors.New("speech: no audio uploaded")
368+ }
369+ raw, err := r.asr.TranscribeBytes(ctx, wav)
370+ if err != nil {
371+ return "", err
372+ }
373+ return raw, nil
374+}
375+
376+// bufferedOutput adapts the TTS client to game.SpeechOutput without playback:
377+// Speak synthesizes into memory and TakeLast drains the last clip.
378+type bufferedOutput struct {
379+ client *tts.Client
380+
381+ mu sync.Mutex
382+ last []byte
383+}
384+
385+func (b *bufferedOutput) Speak(ctx context.Context, kana string) error {
386+ wav, err := b.client.Speech(ctx, kana)
387+ if err != nil {
388+ return err
389+ }
390+ b.mu.Lock()
391+ cp := make([]byte, len(wav))
392+ copy(cp, wav)
393+ b.last = cp
394+ b.mu.Unlock()
395+ return nil
396+}
397+
398+// TakeLast returns the last synthesized clip and clears it.
399+func (b *bufferedOutput) TakeLast() []byte {
400+ b.mu.Lock()
401+ wav := b.last
402+ b.last = nil
403+ b.mu.Unlock()
404+ return wav
405+}
406+
407+// Replay is a no-op: replay is client-side on iOS.
408+func (b *bufferedOutput) Replay() error { return nil }